diff --git a/CLAUDE.md b/CLAUDE.md index 414fe05..8b66d40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -223,7 +223,9 @@ hm-options --version # Show version ### Development Workflow - **Always run `go fmt ./...` before committing Go code** - **Run Go commands using `nix develop -c`** (e.g., `nix develop -c go test ./...`) -- **Use `nix run` to run binaries** (e.g., `nix run .#nixpkgs-search -- options serve`) +- **Use `nix run` to run/test binaries** (e.g., `nix run .#nixpkgs-search -- options serve`) + - Do NOT use `go build -o /tmp/...` to test binaries - always use `nix run` + - Remember: modified files must be tracked by git for `nix run` to see them - File paths in responses should use format `path/to/file.go:123` ### Linting diff --git a/internal/database/postgres.go b/internal/database/postgres.go index a27e402..176ee1c 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -633,6 +633,12 @@ func (s *PostgresStore) GetPackage(ctx context.Context, revisionID int64, attrPa // SearchPackages searches for packages matching a query. func (s *PostgresStore) SearchPackages(ctx context.Context, revisionID int64, query string, filters PackageSearchFilters) ([]*Package, error) { + // Query includes exact match priority: + // - Priority 0: exact pname match + // - Priority 1: exact attr_path match + // - Priority 2: pname starts with query + // - Priority 3: attr_path starts with query + // - Priority 4: FTS match (ordered by ts_rank) baseQuery := ` SELECT id, revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure FROM packages @@ -656,10 +662,24 @@ func (s *PostgresStore) SearchPackages(ctx context.Context, revisionID int64, qu if filters.Insecure != nil { baseQuery += fmt.Sprintf(" AND insecure = $%d", argNum) args = append(args, *filters.Insecure) - _ = argNum // silence ineffassign - argNum tracks position but final value unused + argNum++ } - baseQuery += " ORDER BY attr_path" + // Order by exact match priority, then ts_rank, then attr_path + // CASE returns priority (lower = better), ts_rank returns positive scores (higher = better, so DESC) + baseQuery += fmt.Sprintf(` ORDER BY + CASE + WHEN pname = $%d THEN 0 + WHEN attr_path = $%d THEN 1 + WHEN pname LIKE $%d THEN 2 + WHEN attr_path LIKE $%d THEN 3 + ELSE 4 + END, + ts_rank(to_tsvector('english', attr_path || ' ' || pname || ' ' || COALESCE(description, '')), plainto_tsquery('english', $2)) DESC, + attr_path`, argNum, argNum+1, argNum+2, argNum+3) + // For LIKE comparisons, escape % and _ characters for PostgreSQL + likeQuery := strings.ReplaceAll(strings.ReplaceAll(query, "%", "\\%"), "_", "\\_") + "%" + args = append(args, query, query, likeQuery, likeQuery) if filters.Limit > 0 { baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit) diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go index 6f0e786..35e033a 100644 --- a/internal/database/sqlite.go +++ b/internal/database/sqlite.go @@ -696,6 +696,12 @@ func (s *SQLiteStore) GetPackage(ctx context.Context, revisionID int64, attrPath // SearchPackages searches for packages matching a query. func (s *SQLiteStore) SearchPackages(ctx context.Context, revisionID int64, query string, filters PackageSearchFilters) ([]*Package, error) { + // Query includes exact match priority: + // - Priority 0: exact pname match + // - Priority 1: exact attr_path match + // - Priority 2: pname starts with query + // - Priority 3: attr_path starts with query + // - Priority 4: FTS match (ordered by bm25 rank) baseQuery := ` SELECT p.id, p.revision_id, p.attr_path, p.pname, p.version, p.description, p.long_description, p.homepage, p.license, p.platforms, p.maintainers, p.broken, p.unfree, p.insecure FROM packages p @@ -705,6 +711,8 @@ func (s *SQLiteStore) SearchPackages(ctx context.Context, revisionID int64, quer // Escape the query for FTS5 by wrapping in double quotes for literal matching. escapedQuery := `"` + strings.ReplaceAll(query, `"`, `""`) + `"` + // For LIKE comparisons, escape % and _ characters + likeQuery := strings.ReplaceAll(strings.ReplaceAll(query, "%", "\\%"), "_", "\\_") args := []interface{}{revisionID, escapedQuery} if filters.Broken != nil { @@ -722,7 +730,19 @@ func (s *SQLiteStore) SearchPackages(ctx context.Context, revisionID int64, quer args = append(args, *filters.Insecure) } - baseQuery += " ORDER BY p.attr_path" + // Order by exact match priority, then FTS5 rank, then attr_path + // CASE returns priority (lower = better), bm25 returns negative scores (lower = better) + baseQuery += ` ORDER BY + CASE + WHEN p.pname = ? THEN 0 + WHEN p.attr_path = ? THEN 1 + WHEN p.pname LIKE ? ESCAPE '\' THEN 2 + WHEN p.attr_path LIKE ? ESCAPE '\' THEN 3 + ELSE 4 + END, + bm25(packages_fts), + p.attr_path` + args = append(args, query, query, likeQuery+"%", likeQuery+"%") if filters.Limit > 0 { baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit)