feat: scoring and frecency (#37)
Enhance lookup mechanics and priority propagation (`spec/lookup.go`) Remove the requirement of typing a leading `-` when searching for options, allowing flag suggestions to surface naturally during input Accurately forward the `Priority` field from registry specs into the `Suggestion` struct so the scoring engine evaluates relevance properly Integrate scoring into the main suggestion pipeline (`root/suggestions.go`) Collect context signals using `scoring.CollectSignals(cwd, query, rootCmd, store)` with the live working directory retrieved from `spec.GetCWD()` Pass the entire deduplicated list across spec, history and AI items through `scoring.Score(deduped, signals)` to compute scores and sort by descending order before rendering on screen Record execution history in Frecency Store (`root/wrapper.go`) Save the last executed command to `lastSubmittedCommand` whenever Enter is pressed or a suggestion is selected Trigger `store.Record(...)` asynchronously within the `IRIS_CMD_STOP` hook using a dedicated goroutine guarded by panic recovery and timeout limits, preventing blocks on the main shell thread Optimize workspace identification for AI (`internal/ai/context_provider.go`) Use `workspace.DetectCached(cwd)` to instantly fetch ecosystem context across Git, Node, Go, Rust, Python and Docker alongside active git branch state This caching mechanism avoids redundant subprocess execution when outside git repositories, significantly speeding up prompt context generation
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/versenilvis/iris/internal/workspace"
|
||||
)
|
||||
|
||||
type ContextRule interface {
|
||||
Match(ws workspace.WorkspaceInfo, cmd string) bool
|
||||
Bonus() int
|
||||
}
|
||||
|
||||
type SimpleContextRule struct {
|
||||
check func(ws workspace.WorkspaceInfo, cmd string) bool
|
||||
bonus int
|
||||
}
|
||||
|
||||
func (r *SimpleContextRule) Match(ws workspace.WorkspaceInfo, cmd string) bool {
|
||||
return r.check(ws, cmd)
|
||||
}
|
||||
|
||||
func (r *SimpleContextRule) Bonus() int {
|
||||
return r.bonus
|
||||
}
|
||||
|
||||
var DefaultContextRules = []ContextRule{
|
||||
// Git rules
|
||||
&SimpleContextRule{
|
||||
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
|
||||
return ws.HasGit && (strings.HasPrefix(cmd, "git status") || strings.HasPrefix(cmd, "git diff") ||
|
||||
strings.HasPrefix(cmd, "git add") || strings.HasPrefix(cmd, "git push") ||
|
||||
strings.HasPrefix(cmd, "git pull") || strings.HasPrefix(cmd, "git commit") ||
|
||||
strings.HasPrefix(cmd, "git switch") || strings.HasPrefix(cmd, "git checkout") ||
|
||||
strings.HasPrefix(cmd, "git branch"))
|
||||
},
|
||||
bonus: 40,
|
||||
},
|
||||
&SimpleContextRule{
|
||||
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
|
||||
return ws.HasGit && (strings.HasPrefix(cmd, "git init") || strings.HasPrefix(cmd, "git clone"))
|
||||
},
|
||||
bonus: -50,
|
||||
},
|
||||
|
||||
// Node.js & Bun rules
|
||||
&SimpleContextRule{
|
||||
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
|
||||
return ws.HasNodeProject && (strings.HasPrefix(cmd, "npm run ") || strings.HasPrefix(cmd, "pnpm run ") ||
|
||||
strings.HasPrefix(cmd, "yarn run ") || strings.HasPrefix(cmd, "bun run ") ||
|
||||
strings.HasPrefix(cmd, "npm test") || strings.HasPrefix(cmd, "npm start") ||
|
||||
strings.HasPrefix(cmd, "bun test") || strings.HasPrefix(cmd, "bun start"))
|
||||
},
|
||||
bonus: 50,
|
||||
},
|
||||
&SimpleContextRule{
|
||||
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
|
||||
return ws.HasNodeProject && (strings.HasPrefix(cmd, "npm install") || strings.HasPrefix(cmd, "npm i ") ||
|
||||
strings.HasPrefix(cmd, "pnpm add") || strings.HasPrefix(cmd, "yarn add") ||
|
||||
strings.HasPrefix(cmd, "bun install") || strings.HasPrefix(cmd, "bun add"))
|
||||
},
|
||||
bonus: 40,
|
||||
},
|
||||
|
||||
// Go rules
|
||||
&SimpleContextRule{
|
||||
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
|
||||
return ws.HasGoProject && (strings.HasPrefix(cmd, "go test") || strings.HasPrefix(cmd, "go run") ||
|
||||
strings.HasPrefix(cmd, "go build") || strings.HasPrefix(cmd, "go mod tidy") ||
|
||||
strings.HasPrefix(cmd, "go vet") || strings.HasPrefix(cmd, "go fmt"))
|
||||
},
|
||||
bonus: 50,
|
||||
},
|
||||
|
||||
// Rust rules
|
||||
&SimpleContextRule{
|
||||
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
|
||||
return ws.HasRustProject && (strings.HasPrefix(cmd, "cargo test") || strings.HasPrefix(cmd, "cargo run") ||
|
||||
strings.HasPrefix(cmd, "cargo build") || strings.HasPrefix(cmd, "cargo check") ||
|
||||
strings.HasPrefix(cmd, "cargo clippy"))
|
||||
},
|
||||
bonus: 50,
|
||||
},
|
||||
|
||||
// Python rules
|
||||
&SimpleContextRule{
|
||||
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
|
||||
return ws.HasPythonProject && (strings.HasPrefix(cmd, "pytest") || strings.HasPrefix(cmd, "python main.py") ||
|
||||
strings.HasPrefix(cmd, "pip install") || strings.HasPrefix(cmd, "poetry run ") ||
|
||||
strings.HasPrefix(cmd, "uv run "))
|
||||
},
|
||||
bonus: 50,
|
||||
},
|
||||
|
||||
// Justfile rules
|
||||
&SimpleContextRule{
|
||||
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
|
||||
return ws.HasJustfile && strings.HasPrefix(cmd, "just ")
|
||||
},
|
||||
bonus: 50,
|
||||
},
|
||||
|
||||
// Makefile & C/C++ rules
|
||||
&SimpleContextRule{
|
||||
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
|
||||
return ws.HasMakefile && strings.HasPrefix(cmd, "make")
|
||||
},
|
||||
bonus: 50,
|
||||
},
|
||||
&SimpleContextRule{
|
||||
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
|
||||
return ws.HasMakefile && (strings.HasPrefix(cmd, "gcc ") || strings.HasPrefix(cmd, "g++ ") ||
|
||||
strings.HasPrefix(cmd, "clang ") || strings.HasPrefix(cmd, "cmake "))
|
||||
},
|
||||
bonus: 40,
|
||||
},
|
||||
|
||||
// Docker & K8s rules
|
||||
&SimpleContextRule{
|
||||
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
|
||||
return ws.HasDockerfile && (strings.HasPrefix(cmd, "docker build") || strings.HasPrefix(cmd, "docker compose up") ||
|
||||
strings.HasPrefix(cmd, "docker compose down") || strings.HasPrefix(cmd, "docker compose logs"))
|
||||
},
|
||||
bonus: 40,
|
||||
},
|
||||
&SimpleContextRule{
|
||||
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
|
||||
return ws.HasK8s && (strings.HasPrefix(cmd, "kubectl get ") || strings.HasPrefix(cmd, "kubectl apply -f ") ||
|
||||
strings.HasPrefix(cmd, "kubectl logs ") || strings.HasPrefix(cmd, "kubectl describe ") ||
|
||||
strings.HasPrefix(cmd, "helm upgrade ") || strings.HasPrefix(cmd, "helm install "))
|
||||
},
|
||||
bonus: 40,
|
||||
},
|
||||
}
|
||||
|
||||
func ApplyContextRules(ws workspace.WorkspaceInfo, cmd string) int {
|
||||
return ApplyCustomContextRules(ws, cmd, DefaultContextRules)
|
||||
}
|
||||
|
||||
func ApplyCustomContextRules(ws workspace.WorkspaceInfo, cmd string, rules []ContextRule) int {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if cmd == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, rule := range rules {
|
||||
if rule.Match(ws, cmd) {
|
||||
total += rule.Bonus()
|
||||
}
|
||||
}
|
||||
|
||||
if total > 100 {
|
||||
return 100
|
||||
}
|
||||
if total < -100 {
|
||||
return -100
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/versenilvis/iris/internal/workspace"
|
||||
)
|
||||
|
||||
func TestApplyContextRules_MultiEcosystem(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ws workspace.WorkspaceInfo
|
||||
cmd string
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "git status inside git repo",
|
||||
ws: workspace.WorkspaceInfo{HasGit: true},
|
||||
cmd: "git status -s",
|
||||
expected: 40,
|
||||
},
|
||||
{
|
||||
name: "git init inside git repo penalized",
|
||||
ws: workspace.WorkspaceInfo{HasGit: true},
|
||||
cmd: "git init",
|
||||
expected: -50,
|
||||
},
|
||||
{
|
||||
name: "bun run dev inside node project",
|
||||
ws: workspace.WorkspaceInfo{HasNodeProject: true},
|
||||
cmd: "bun run dev",
|
||||
expected: 50,
|
||||
},
|
||||
{
|
||||
name: "go test inside go project",
|
||||
ws: workspace.WorkspaceInfo{HasGoProject: true},
|
||||
cmd: "go test ./...",
|
||||
expected: 50,
|
||||
},
|
||||
{
|
||||
name: "cargo check inside rust project",
|
||||
ws: workspace.WorkspaceInfo{HasRustProject: true},
|
||||
cmd: "cargo check",
|
||||
expected: 50,
|
||||
},
|
||||
{
|
||||
name: "pytest inside python project",
|
||||
ws: workspace.WorkspaceInfo{HasPythonProject: true},
|
||||
cmd: "pytest -v",
|
||||
expected: 50,
|
||||
},
|
||||
{
|
||||
name: "just build inside justfile project",
|
||||
ws: workspace.WorkspaceInfo{HasJustfile: true},
|
||||
cmd: "just build",
|
||||
expected: 50,
|
||||
},
|
||||
{
|
||||
name: "kubectl get pods inside k8s workspace",
|
||||
ws: workspace.WorkspaceInfo{HasK8s: true},
|
||||
cmd: "kubectl get pods",
|
||||
expected: 40,
|
||||
},
|
||||
{
|
||||
name: "unrelated command gets no bonus",
|
||||
ws: workspace.WorkspaceInfo{HasGit: true},
|
||||
cmd: "echo hello",
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ApplyContextRules(tc.ws, tc.cmd)
|
||||
if got != tc.expected {
|
||||
t.Errorf("expected bonus %d, got %d for command %q", tc.expected, got, tc.cmd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyContextRules_Clamping(t *testing.T) {
|
||||
ws := workspace.WorkspaceInfo{HasGit: true, HasNodeProject: true, HasMakefile: true}
|
||||
// Create rules that sum above 100 and below -100
|
||||
rules := []ContextRule{
|
||||
&SimpleContextRule{check: func(w workspace.WorkspaceInfo, c string) bool { return true }, bonus: 80},
|
||||
&SimpleContextRule{check: func(w workspace.WorkspaceInfo, c string) bool { return true }, bonus: 60},
|
||||
}
|
||||
got := ApplyCustomContextRules(ws, "any cmd", rules)
|
||||
if got != 100 {
|
||||
t.Errorf("expected clamp to 100, got %d", got)
|
||||
}
|
||||
|
||||
negRules := []ContextRule{
|
||||
&SimpleContextRule{check: func(w workspace.WorkspaceInfo, c string) bool { return true }, bonus: -80},
|
||||
&SimpleContextRule{check: func(w workspace.WorkspaceInfo, c string) bool { return true }, bonus: -60},
|
||||
}
|
||||
gotNeg := ApplyCustomContextRules(ws, "any cmd", negRules)
|
||||
if gotNeg != -100 {
|
||||
t.Errorf("expected clamp to -100, got %d", gotNeg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type FrecencyEntry struct {
|
||||
Cmd string
|
||||
Cwd string
|
||||
Count int
|
||||
LastUsed time.Time
|
||||
RawScore float64
|
||||
}
|
||||
|
||||
type FrecencyStore struct {
|
||||
db *sql.DB
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewFrecencyStore(dbPath string) (*FrecencyStore, error) {
|
||||
if dbPath == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbPath = filepath.Join(home, ".local", "share", "iris", "history.db")
|
||||
}
|
||||
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory for history.db: %w", err)
|
||||
}
|
||||
_ = os.Chmod(dir, 0700)
|
||||
|
||||
if f, err := os.OpenFile(dbPath, os.O_CREATE, 0600); err == nil {
|
||||
_ = f.Close()
|
||||
}
|
||||
_ = os.Chmod(dbPath, 0600)
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
|
||||
}
|
||||
|
||||
store := &FrecencyStore{db: db}
|
||||
if err := store.initSchema(context.Background()); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
_ = os.Chmod(dbPath, 0600)
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (f *FrecencyStore) configureSQLite(ctx context.Context) error {
|
||||
_, err := f.db.ExecContext(ctx, "PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;")
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *FrecencyStore) initSchema(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctxTimeout, cancel := context.WithTimeout(ctx, 2000*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if err := f.configureSQLite(ctxTimeout); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS history_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cmd TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1,
|
||||
last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(cmd, cwd)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_history_cwd_cmd ON history_entries(cwd, cmd);
|
||||
`
|
||||
_, err := f.db.ExecContext(ctxTimeout, schema)
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *FrecencyStore) Record(ctx context.Context, cmd, cwd string) error {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if cmd == "" || cwd == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctxTimeout, cancel := context.WithTimeout(ctx, 1000*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
query := `
|
||||
INSERT INTO history_entries (cmd, cwd, count, last_used)
|
||||
VALUES (?, ?, 1, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(cmd, cwd) DO UPDATE SET
|
||||
count = count + 1,
|
||||
last_used = CURRENT_TIMESTAMP;
|
||||
`
|
||||
_, err := f.db.ExecContext(ctxTimeout, query, cmd, cwd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *FrecencyStore) RawScore(count int, lastUsed time.Time) float64 {
|
||||
if count <= 0 {
|
||||
return 0
|
||||
}
|
||||
age := max(time.Since(lastUsed), 0)
|
||||
|
||||
var weight float64
|
||||
switch {
|
||||
case age <= time.Hour:
|
||||
weight = 100.0
|
||||
case age <= 24*time.Hour:
|
||||
weight = 50.0
|
||||
case age <= 7*24*time.Hour:
|
||||
weight = 20.0
|
||||
case age <= 30*24*time.Hour:
|
||||
weight = 5.0
|
||||
default:
|
||||
weight = 1.0
|
||||
}
|
||||
|
||||
return float64(count) * weight
|
||||
}
|
||||
|
||||
func (f *FrecencyStore) QueryLocal(ctx context.Context, cwd, prefix string, limit int) ([]FrecencyEntry, error) {
|
||||
if f == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctxTimeout, cancel := context.WithTimeout(ctx, 1000*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if prefix != "" {
|
||||
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cwd = ? AND cmd LIKE ?`, cwd, prefix+"%")
|
||||
} else {
|
||||
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cwd = ?`, cwd)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []FrecencyEntry
|
||||
for rows.Next() {
|
||||
var cmd, rCwd string
|
||||
var count int
|
||||
var lastUsedRaw string
|
||||
if err := rows.Scan(&cmd, &rCwd, &count, &lastUsedRaw); err != nil {
|
||||
continue
|
||||
}
|
||||
t, err := parseTimestamp(lastUsedRaw)
|
||||
if err != nil {
|
||||
t = time.Now()
|
||||
}
|
||||
entries = append(entries, FrecencyEntry{
|
||||
Cmd: cmd,
|
||||
Cwd: rCwd,
|
||||
Count: count,
|
||||
LastUsed: t,
|
||||
RawScore: f.RawScore(count, t),
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
return entries[i].RawScore > entries[j].RawScore
|
||||
})
|
||||
|
||||
if len(entries) > limit {
|
||||
entries = entries[:limit]
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (f *FrecencyStore) QueryGlobal(ctx context.Context, prefix string, limit int) ([]FrecencyEntry, error) {
|
||||
if f == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctxTimeout, cancel := context.WithTimeout(ctx, 1000*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if prefix != "" {
|
||||
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cmd LIKE ?`, prefix+"%")
|
||||
} else {
|
||||
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries`)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
dedupe := make(map[string]*FrecencyEntry)
|
||||
for rows.Next() {
|
||||
var cmd, rCwd string
|
||||
var count int
|
||||
var lastUsedRaw string
|
||||
if err := rows.Scan(&cmd, &rCwd, &count, &lastUsedRaw); err != nil {
|
||||
continue
|
||||
}
|
||||
t, err := parseTimestamp(lastUsedRaw)
|
||||
if err != nil {
|
||||
t = time.Now()
|
||||
}
|
||||
score := f.RawScore(count, t)
|
||||
if existing, found := dedupe[cmd]; found {
|
||||
existing.Count += count
|
||||
existing.RawScore += score
|
||||
if t.After(existing.LastUsed) {
|
||||
existing.LastUsed = t
|
||||
existing.Cwd = rCwd
|
||||
}
|
||||
} else {
|
||||
dedupe[cmd] = &FrecencyEntry{
|
||||
Cmd: cmd,
|
||||
Cwd: rCwd,
|
||||
Count: count,
|
||||
LastUsed: t,
|
||||
RawScore: score,
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var entries []FrecencyEntry
|
||||
for _, entry := range dedupe {
|
||||
entries = append(entries, *entry)
|
||||
}
|
||||
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
return entries[i].RawScore > entries[j].RawScore
|
||||
})
|
||||
|
||||
if len(entries) > limit {
|
||||
entries = entries[:limit]
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (f *FrecencyStore) Close() error {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.db != nil {
|
||||
return f.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseTimestamp(s string) (time.Time, error) {
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02 15:04:05.999999999-07:00", s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
return time.Parse("2006-01-02", s)
|
||||
}
|
||||
|
||||
var (
|
||||
globalFrecencyStore *FrecencyStore
|
||||
globalFrecencyMu sync.Mutex
|
||||
)
|
||||
|
||||
func GetFrecencyStore() (*FrecencyStore, error) {
|
||||
globalFrecencyMu.Lock()
|
||||
defer globalFrecencyMu.Unlock()
|
||||
|
||||
if globalFrecencyStore != nil {
|
||||
return globalFrecencyStore, nil
|
||||
}
|
||||
|
||||
store, err := NewFrecencyStore("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
globalFrecencyStore = store
|
||||
return globalFrecencyStore, nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFrecencyStore_RecordAndQueryLocal(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "history.db")
|
||||
store, err := NewFrecencyStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFrecencyStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
cwd := "/home/user/project"
|
||||
_ = store.Record(context.Background(), "git status", cwd)
|
||||
_ = store.Record(context.Background(), "git status", cwd)
|
||||
_ = store.Record(context.Background(), "git status", cwd)
|
||||
_ = store.Record(context.Background(), "git commit -m 'test'", cwd)
|
||||
|
||||
entries, err := store.QueryLocal(context.Background(), cwd, "git", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("QueryLocal failed: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
if entries[0].Cmd != "git status" || entries[0].Count != 3 {
|
||||
t.Errorf("expected top entry to be 'git status' with count 3, got %s (count %d)", entries[0].Cmd, entries[0].Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrecencyStore_RawScoreDistribution(t *testing.T) {
|
||||
store := &FrecencyStore{}
|
||||
now := time.Now()
|
||||
|
||||
oldHeavyScore := store.RawScore(5000, now.Add(-30*24*time.Hour))
|
||||
recentLightScore := store.RawScore(5, now.Add(-30*time.Minute))
|
||||
|
||||
if oldHeavyScore <= 0 || recentLightScore <= 0 {
|
||||
t.Errorf("expected positive raw scores, got %f and %f", oldHeavyScore, recentLightScore)
|
||||
}
|
||||
if recentLightScore >= oldHeavyScore {
|
||||
t.Logf("recent light score (%f) vs old heavy score (%f)", recentLightScore, oldHeavyScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrecencyStore_QueryGlobalDedupe(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "history.db")
|
||||
store, err := NewFrecencyStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFrecencyStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
_ = store.Record(context.Background(), "make build", "/repo/a")
|
||||
_ = store.Record(context.Background(), "make build", "/repo/a")
|
||||
_ = store.Record(context.Background(), "make build", "/repo/b")
|
||||
|
||||
entries, err := store.QueryGlobal(context.Background(), "make", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("QueryGlobal failed: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 deduplicated entry, got %d", len(entries))
|
||||
}
|
||||
if entries[0].Count != 3 {
|
||||
t.Errorf("expected combined count 3 across workspaces, got %d", entries[0].Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrecencyStore_Permissions(t *testing.T) {
|
||||
tmpRoot := t.TempDir()
|
||||
dbDir := filepath.Join(tmpRoot, "subdir", "iris")
|
||||
dbPath := filepath.Join(dbDir, "history.db")
|
||||
|
||||
if err := os.MkdirAll(dbDir, 0755); err != nil {
|
||||
t.Fatalf("failed to make pre-existing dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(dbPath, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("failed to write dummy existing db file: %v", err)
|
||||
}
|
||||
|
||||
store, err := NewFrecencyStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFrecencyStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
dirInfo, err := os.Stat(dbDir)
|
||||
if err != nil {
|
||||
t.Fatalf("stat dbDir failed: %v", err)
|
||||
}
|
||||
if perm := dirInfo.Mode().Perm(); perm != 0700 {
|
||||
t.Errorf("expected directory permissions 0700, got %04o", perm)
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat dbPath failed: %v", err)
|
||||
}
|
||||
if perm := fileInfo.Mode().Perm(); perm != 0600 {
|
||||
t.Errorf("expected database file permissions 0600, got %04o", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrecencyStore_SQLiteConfigurationAndContext(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "history.db")
|
||||
store, err := NewFrecencyStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFrecencyStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
var journalMode string
|
||||
if qErr := store.db.QueryRowContext(context.Background(), "PRAGMA journal_mode;").Scan(&journalMode); qErr != nil {
|
||||
t.Fatalf("failed to query journal_mode: %v", qErr)
|
||||
}
|
||||
if journalMode != "wal" {
|
||||
t.Errorf("expected journal_mode 'wal', got '%s'", journalMode)
|
||||
}
|
||||
|
||||
var busyTimeout int
|
||||
if qErr := store.db.QueryRowContext(context.Background(), "PRAGMA busy_timeout;").Scan(&busyTimeout); qErr != nil {
|
||||
t.Fatalf("failed to query busy_timeout: %v", qErr)
|
||||
}
|
||||
if busyTimeout != 5000 {
|
||||
t.Errorf("expected busy_timeout 5000, got %d", busyTimeout)
|
||||
}
|
||||
|
||||
ctxCanceled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err = store.Record(ctxCanceled, "git status", tmpDir)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("expected context.Canceled from Record with canceled context, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrecencyStore_NilReceiver(t *testing.T) {
|
||||
var nilStore *FrecencyStore
|
||||
if err := nilStore.Record(context.Background(), "cmd", "cwd"); err != nil {
|
||||
t.Errorf("expected nil error on nil store Record, got %v", err)
|
||||
}
|
||||
if entries, err := nilStore.QueryLocal(context.Background(), "cwd", "", 10); err != nil || entries != nil {
|
||||
t.Errorf("expected nil entries and nil error on nil store QueryLocal, got %v, %v", entries, err)
|
||||
}
|
||||
if entries, err := nilStore.QueryGlobal(context.Background(), "", 10); err != nil || entries != nil {
|
||||
t.Errorf("expected nil entries and nil error on nil store QueryGlobal, got %v, %v", entries, err)
|
||||
}
|
||||
if err := nilStore.Close(); err != nil {
|
||||
t.Errorf("expected nil error on nil store Close, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/versenilvis/iris/spec"
|
||||
)
|
||||
|
||||
type ScoreBreakdown struct {
|
||||
BasePriority int
|
||||
ContextBonus int
|
||||
Frecency int
|
||||
MatchQuality int
|
||||
}
|
||||
|
||||
type ScoredSuggestion struct {
|
||||
spec.Suggestion
|
||||
Score float64
|
||||
Breakdown ScoreBreakdown
|
||||
}
|
||||
|
||||
type ScoreConfig struct {
|
||||
WeightBasePriority float64
|
||||
WeightContextBonus float64
|
||||
WeightFrecency float64
|
||||
WeightMatchQuality float64
|
||||
}
|
||||
|
||||
var DefaultScoreConfig = ScoreConfig{
|
||||
WeightBasePriority: 0.30,
|
||||
WeightContextBonus: 0.25,
|
||||
WeightFrecency: 0.25,
|
||||
WeightMatchQuality: 0.20,
|
||||
}
|
||||
|
||||
func Score(suggestions []spec.Suggestion, signals SignalSet) []ScoredSuggestion {
|
||||
return ScoreWithConfig(suggestions, signals, DefaultScoreConfig)
|
||||
}
|
||||
|
||||
func ScoreWithConfig(suggestions []spec.Suggestion, signals SignalSet, config ScoreConfig) []ScoredSuggestion {
|
||||
if len(suggestions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
localMap := make(map[string]float64, len(signals.LocalFrecency))
|
||||
for _, e := range signals.LocalFrecency {
|
||||
localMap[e.Cmd] = e.RawScore
|
||||
}
|
||||
globalMap := make(map[string]float64, len(signals.GlobalFrecency))
|
||||
for _, e := range signals.GlobalFrecency {
|
||||
globalMap[e.Cmd] = e.RawScore
|
||||
}
|
||||
|
||||
rawFrec := make([]float64, len(suggestions))
|
||||
for i, s := range suggestions {
|
||||
if score, ok := localMap[s.Cmd]; ok {
|
||||
rawFrec[i] = score
|
||||
} else if score, ok := globalMap[s.Cmd]; ok {
|
||||
rawFrec[i] = score * 0.7
|
||||
} else {
|
||||
rawFrec[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
normFrec := normalizeFrecency(rawFrec)
|
||||
|
||||
scored := make([]ScoredSuggestion, len(suggestions))
|
||||
for i, s := range suggestions {
|
||||
bp := basePriorityFor(s)
|
||||
cb := ApplyContextRules(signals.Workspace, s.Cmd)
|
||||
frec := normFrec[i]
|
||||
mq := matchQualityScore(s.Cmd, signals.Query)
|
||||
|
||||
total := config.WeightBasePriority*float64(bp) +
|
||||
config.WeightContextBonus*float64(cb) +
|
||||
config.WeightFrecency*float64(frec) +
|
||||
config.WeightMatchQuality*float64(mq)
|
||||
|
||||
scored[i] = ScoredSuggestion{
|
||||
Suggestion: s,
|
||||
Score: total,
|
||||
Breakdown: ScoreBreakdown{
|
||||
BasePriority: bp,
|
||||
ContextBonus: cb,
|
||||
Frecency: frec,
|
||||
MatchQuality: mq,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(scored, func(i, j int) bool {
|
||||
if scored[i].Score != scored[j].Score {
|
||||
return scored[i].Score > scored[j].Score
|
||||
}
|
||||
if scored[i].Breakdown.Frecency != scored[j].Breakdown.Frecency {
|
||||
return scored[i].Breakdown.Frecency > scored[j].Breakdown.Frecency
|
||||
}
|
||||
if scored[i].Breakdown.ContextBonus != scored[j].Breakdown.ContextBonus {
|
||||
return scored[i].Breakdown.ContextBonus > scored[j].Breakdown.ContextBonus
|
||||
}
|
||||
return scored[i].Cmd < scored[j].Cmd
|
||||
})
|
||||
|
||||
return scored
|
||||
}
|
||||
|
||||
func basePriorityFor(s spec.Suggestion) int {
|
||||
if s.Priority > 0 {
|
||||
if s.Priority > 100 {
|
||||
return 100
|
||||
}
|
||||
return s.Priority
|
||||
}
|
||||
|
||||
switch s.Source {
|
||||
case "spec":
|
||||
return 60
|
||||
case "ai":
|
||||
if s.Confidence > 0 {
|
||||
if s.Confidence > 100 {
|
||||
return 100
|
||||
}
|
||||
return s.Confidence
|
||||
}
|
||||
return 50
|
||||
case "history":
|
||||
if s.Confidence > 0 {
|
||||
if s.Confidence > 100 {
|
||||
return 100
|
||||
}
|
||||
return s.Confidence
|
||||
}
|
||||
return 40
|
||||
default:
|
||||
return 50
|
||||
}
|
||||
}
|
||||
|
||||
func matchQualityScore(cmd, query string) int {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return 100
|
||||
}
|
||||
if cmd == query {
|
||||
return 100
|
||||
}
|
||||
if strings.HasPrefix(cmd, query) {
|
||||
return 100
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(cmd), strings.ToLower(query)) {
|
||||
return 80
|
||||
}
|
||||
if strings.Contains(strings.ToLower(cmd), strings.ToLower(query)) {
|
||||
return 50
|
||||
}
|
||||
if isSubsequence(strings.ToLower(query), strings.ToLower(cmd)) {
|
||||
return 30
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isSubsequence(sub, full string) bool {
|
||||
subRunes := []rune(sub)
|
||||
fullRunes := []rune(full)
|
||||
if len(subRunes) == 0 {
|
||||
return true
|
||||
}
|
||||
i := 0
|
||||
for j := 0; j < len(fullRunes) && i < len(subRunes); j++ {
|
||||
if subRunes[i] == fullRunes[j] {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return i == len(subRunes)
|
||||
}
|
||||
|
||||
func normalizeFrecency(raw []float64) []int {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
maxRaw := 0.0
|
||||
for _, r := range raw {
|
||||
if r > maxRaw {
|
||||
maxRaw = r
|
||||
}
|
||||
}
|
||||
if maxRaw <= 0 {
|
||||
res := make([]int, len(raw))
|
||||
return res
|
||||
}
|
||||
|
||||
res := make([]int, len(raw))
|
||||
for i, r := range raw {
|
||||
val := int(math.Round((r / maxRaw) * 100.0))
|
||||
if val > 100 {
|
||||
val = 100
|
||||
} else if val < 0 {
|
||||
val = 0
|
||||
}
|
||||
res[i] = val
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/versenilvis/iris/internal/workspace"
|
||||
"github.com/versenilvis/iris/spec"
|
||||
)
|
||||
|
||||
func TestScore_GitInitAndStatusInGitRepo(t *testing.T) {
|
||||
suggestions := []spec.Suggestion{
|
||||
{Cmd: "git init", Source: "spec"},
|
||||
{Cmd: "git status -s", Source: "spec"},
|
||||
}
|
||||
signals := SignalSet{
|
||||
Workspace: workspace.WorkspaceInfo{HasGit: true},
|
||||
Query: "git",
|
||||
}
|
||||
|
||||
scored := Score(suggestions, signals)
|
||||
if len(scored) != 2 {
|
||||
t.Fatalf("expected 2 scored suggestions, got %d", len(scored))
|
||||
}
|
||||
if scored[0].Cmd != "git status -s" {
|
||||
t.Errorf("expected 'git status -s' at top when inside git repo, got %s", scored[0].Cmd)
|
||||
}
|
||||
if scored[1].Cmd != "git init" {
|
||||
t.Errorf("expected 'git init' at bottom, got %s", scored[1].Cmd)
|
||||
}
|
||||
if scored[1].Breakdown.ContextBonus != -50 {
|
||||
t.Errorf("expected -50 penalty for git init, got %d", scored[1].Breakdown.ContextBonus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_NormalizedFrecency(t *testing.T) {
|
||||
suggestions := []spec.Suggestion{
|
||||
{Cmd: "ls -la", Source: "history"},
|
||||
{Cmd: "git push", Source: "history"},
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
signals := SignalSet{
|
||||
LocalFrecency: []FrecencyEntry{
|
||||
{Cmd: "ls -la", RawScore: 25000.0, LastUsed: now.Add(-30 * 24 * time.Hour)},
|
||||
{Cmd: "git push", RawScore: 500.0, LastUsed: now},
|
||||
},
|
||||
}
|
||||
|
||||
scored := Score(suggestions, signals)
|
||||
var lsBreakdown, pushBreakdown ScoreBreakdown
|
||||
for _, s := range scored {
|
||||
switch s.Cmd {
|
||||
case "ls -la":
|
||||
lsBreakdown = s.Breakdown
|
||||
case "git push":
|
||||
pushBreakdown = s.Breakdown
|
||||
}
|
||||
}
|
||||
|
||||
if lsBreakdown.Frecency != 100 {
|
||||
t.Errorf("expected max raw score to normalize to 100, got %d", lsBreakdown.Frecency)
|
||||
}
|
||||
if pushBreakdown.Frecency <= 0 || pushBreakdown.Frecency > 100 {
|
||||
t.Errorf("expected normalized frecency in (0, 100], got %d", pushBreakdown.Frecency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_PrefixOverFuzzyMatch(t *testing.T) {
|
||||
suggestions := []spec.Suggestion{
|
||||
{Cmd: "make build", Source: "spec"}, // fuzzy/contains for 'bl'
|
||||
{Cmd: "block", Source: "spec"}, // prefix exact for 'bl'
|
||||
}
|
||||
signals := SignalSet{Query: "bl"}
|
||||
|
||||
scored := Score(suggestions, signals)
|
||||
if len(scored) != 2 {
|
||||
t.Fatalf("expected 2 scored suggestions, got %d", len(scored))
|
||||
}
|
||||
if scored[0].Cmd != "block" {
|
||||
t.Errorf("expected prefix match 'block' to outscore fuzzy 'make build', got %s", scored[0].Cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_AISuggestionConfidence(t *testing.T) {
|
||||
suggestions := []spec.Suggestion{
|
||||
{Cmd: "npm run custom-script", Source: "ai", Confidence: 85},
|
||||
{Cmd: "npm help", Source: "history"},
|
||||
}
|
||||
signals := SignalSet{
|
||||
Workspace: workspace.WorkspaceInfo{HasNodeProject: true},
|
||||
Query: "npm",
|
||||
}
|
||||
|
||||
scored := Score(suggestions, signals)
|
||||
if len(scored) < 1 {
|
||||
t.Fatalf("expected scored suggestions")
|
||||
}
|
||||
if scored[0].Cmd != "npm run custom-script" {
|
||||
t.Errorf("expected high-confidence AI suggestion with context bonus at top, got %s", scored[0].Cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_UnsortedHistorySorting(t *testing.T) {
|
||||
suggestions := []spec.Suggestion{
|
||||
{Cmd: "cmdA", Source: "history"},
|
||||
{Cmd: "cmdB", Source: "history"},
|
||||
{Cmd: "cmdC", Source: "history"},
|
||||
}
|
||||
signals := SignalSet{
|
||||
LocalFrecency: []FrecencyEntry{
|
||||
{Cmd: "cmdC", RawScore: 100.0},
|
||||
{Cmd: "cmdA", RawScore: 50.0},
|
||||
{Cmd: "cmdB", RawScore: 10.0},
|
||||
},
|
||||
}
|
||||
|
||||
scored := Score(suggestions, signals)
|
||||
if len(scored) != 3 {
|
||||
t.Fatalf("expected 3 items, got %d", len(scored))
|
||||
}
|
||||
if scored[0].Cmd != "cmdC" || scored[1].Cmd != "cmdA" || scored[2].Cmd != "cmdB" {
|
||||
t.Errorf("expected cmdC > cmdA > cmdB based on frecency, got %s, %s, %s", scored[0].Cmd, scored[1].Cmd, scored[2].Cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasePriorityFor_HistoryWithConfidence(t *testing.T) {
|
||||
s1 := spec.Suggestion{Source: "history"}
|
||||
if p := basePriorityFor(s1); p != 40 {
|
||||
t.Errorf("expected default history priority 40 when confidence unset, got %d", p)
|
||||
}
|
||||
|
||||
s2 := spec.Suggestion{Source: "history", Confidence: 85}
|
||||
if p := basePriorityFor(s2); p != 85 {
|
||||
t.Errorf("expected history priority 85 when confidence is 85, got %d", p)
|
||||
}
|
||||
|
||||
s3 := spec.Suggestion{Source: "history", Confidence: 150}
|
||||
if p := basePriorityFor(s3); p != 100 {
|
||||
t.Errorf("expected capped history priority 100 when confidence is > 100, got %d", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSubsequence_UTF8(t *testing.T) {
|
||||
_ = isSubsequence("gố", "gõ tiếng việt")
|
||||
if !isSubsequence("việt", "tiếng việt") {
|
||||
t.Error("expected 'việt' to be subsequence of 'tiếng việt'")
|
||||
}
|
||||
if !isSubsequence("tệt", "tiếng việt") {
|
||||
t.Error("expected 'tệt' to be subsequence of 'tiếng việt'")
|
||||
}
|
||||
if isSubsequence("xyz", "tiếng việt") {
|
||||
t.Error("expected 'xyz' NOT to be subsequence of 'tiếng việt'")
|
||||
}
|
||||
if !isSubsequence("αγ", "αβγδε") {
|
||||
t.Error("expected multi-byte rune 'αγ' to be subsequence of 'αβγδε'")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/versenilvis/iris/internal/workspace"
|
||||
)
|
||||
|
||||
type SignalSet struct {
|
||||
Workspace workspace.WorkspaceInfo
|
||||
LocalFrecency []FrecencyEntry
|
||||
GlobalFrecency []FrecencyEntry
|
||||
Query string
|
||||
RootCommand string
|
||||
Cwd string
|
||||
}
|
||||
|
||||
// CollectSignals gathers environment, workspace, and historical frecency signals for the given query and directory
|
||||
func CollectSignals(ctx context.Context, cwd, query, rootCmd string, frecency *FrecencyStore) SignalSet {
|
||||
ws := workspace.DetectCached(cwd)
|
||||
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
var local, global []FrecencyEntry
|
||||
if frecency != nil {
|
||||
local, _ = frecency.QueryLocal(ctx, cwd, query, 50)
|
||||
global, _ = frecency.QueryGlobal(ctx, query, 50)
|
||||
}
|
||||
|
||||
return SignalSet{
|
||||
Workspace: ws,
|
||||
LocalFrecency: local,
|
||||
GlobalFrecency: global,
|
||||
Query: strings.TrimSpace(query),
|
||||
RootCommand: strings.TrimSpace(rootCmd),
|
||||
Cwd: cwd,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCollectSignals(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte("{}"), 0644)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "history.db")
|
||||
store, err := NewFrecencyStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFrecencyStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
_ = store.Record(context.Background(), "npm run dev", tmpDir)
|
||||
_ = store.Record(context.Background(), "npm test", "/other/dir")
|
||||
|
||||
signals := CollectSignals(context.Background(), tmpDir, "npm", "npm", store)
|
||||
|
||||
if !signals.Workspace.HasNodeProject {
|
||||
t.Error("expected HasNodeProject to be true in collected signals")
|
||||
}
|
||||
if len(signals.LocalFrecency) != 1 || signals.LocalFrecency[0].Cmd != "npm run dev" {
|
||||
t.Errorf("expected local frecency to contain 'npm run dev', got %v", signals.LocalFrecency)
|
||||
}
|
||||
if len(signals.GlobalFrecency) != 2 {
|
||||
t.Errorf("expected global frecency to contain 2 entries, got %d", len(signals.GlobalFrecency))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user