package database import ( "context" "database/sql" "fmt" "strings" _ "modernc.org/sqlite" ) // SQLiteStore implements Store using SQLite. type SQLiteStore struct { db *sql.DB } // NewSQLiteStore creates a new SQLite store. func NewSQLiteStore(path string) (*SQLiteStore, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } // Enable foreign keys if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { db.Close() //nolint:errcheck // best-effort cleanup on connection failure return nil, fmt.Errorf("failed to enable foreign keys: %w", err) } return &SQLiteStore{db: db}, nil } // Initialize creates or migrates the database schema. func (s *SQLiteStore) Initialize(ctx context.Context) error { // Check current schema version var version int err := s.db.QueryRowContext(ctx, "SELECT version FROM schema_info LIMIT 1").Scan(&version) needsRecreate := err != nil || version != SchemaVersion if needsRecreate { // Drop all tables in correct order (respecting foreign keys) dropStmts := []string{ DropDeclarations, DropOptions, DropPackages, DropFiles, DropRevisions, DropSchemaInfo, "DROP TABLE IF EXISTS options_fts", "DROP TABLE IF EXISTS packages_fts", } for _, stmt := range dropStmts { if _, err := s.db.ExecContext(ctx, stmt); err != nil { return fmt.Errorf("failed to drop table: %w", err) } } } // Create tables (SQLite uses INTEGER PRIMARY KEY for auto-increment) createStmts := []string{ SchemaInfoTable, RevisionsTable, OptionsTable, DeclarationsTable, FilesTable, PackagesTable, IndexOptionsRevisionName, IndexOptionsRevisionParent, IndexFilesRevisionPath, IndexDeclarationsOption, IndexPackagesRevisionAttr, IndexPackagesRevisionPname, } for _, stmt := range createStmts { if _, err := s.db.ExecContext(ctx, stmt); err != nil { return fmt.Errorf("failed to create schema: %w", err) } } // Create FTS5 virtual table for SQLite full-text search _, err = s.db.ExecContext(ctx, ` CREATE VIRTUAL TABLE IF NOT EXISTS options_fts USING fts5( name, description, content='options', content_rowid='id' ) `) if err != nil { return fmt.Errorf("failed to create FTS table: %w", err) } // Create triggers to keep options FTS in sync optionsTriggers := []string{ `CREATE TRIGGER IF NOT EXISTS options_ai AFTER INSERT ON options BEGIN INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description); END`, `CREATE TRIGGER IF NOT EXISTS options_ad AFTER DELETE ON options BEGIN INSERT INTO options_fts(options_fts, rowid, name, description) VALUES('delete', old.id, old.name, old.description); END`, `CREATE TRIGGER IF NOT EXISTS options_au AFTER UPDATE ON options BEGIN INSERT INTO options_fts(options_fts, rowid, name, description) VALUES('delete', old.id, old.name, old.description); INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description); END`, } for _, trigger := range optionsTriggers { if _, err := s.db.ExecContext(ctx, trigger); err != nil { return fmt.Errorf("failed to create options trigger: %w", err) } } // Create FTS5 virtual table for packages full-text search _, err = s.db.ExecContext(ctx, ` CREATE VIRTUAL TABLE IF NOT EXISTS packages_fts USING fts5( attr_path, pname, description, content='packages', content_rowid='id' ) `) if err != nil { return fmt.Errorf("failed to create packages FTS table: %w", err) } // Create triggers to keep packages FTS in sync packagesTriggers := []string{ `CREATE TRIGGER IF NOT EXISTS packages_ai AFTER INSERT ON packages BEGIN INSERT INTO packages_fts(rowid, attr_path, pname, description) VALUES (new.id, new.attr_path, new.pname, new.description); END`, `CREATE TRIGGER IF NOT EXISTS packages_ad AFTER DELETE ON packages BEGIN INSERT INTO packages_fts(packages_fts, rowid, attr_path, pname, description) VALUES('delete', old.id, old.attr_path, old.pname, old.description); END`, `CREATE TRIGGER IF NOT EXISTS packages_au AFTER UPDATE ON packages BEGIN INSERT INTO packages_fts(packages_fts, rowid, attr_path, pname, description) VALUES('delete', old.id, old.attr_path, old.pname, old.description); INSERT INTO packages_fts(rowid, attr_path, pname, description) VALUES (new.id, new.attr_path, new.pname, new.description); END`, } for _, trigger := range packagesTriggers { if _, err := s.db.ExecContext(ctx, trigger); err != nil { return fmt.Errorf("failed to create packages trigger: %w", err) } } // Set schema version if needsRecreate { _, err = s.db.ExecContext(ctx, "INSERT INTO schema_info (version) VALUES (?)", SchemaVersion) if err != nil { return fmt.Errorf("failed to set schema version: %w", err) } } return nil } // Close closes the database connection. func (s *SQLiteStore) Close() error { return s.db.Close() } // CreateRevision creates a new revision record. func (s *SQLiteStore) CreateRevision(ctx context.Context, rev *Revision) error { result, err := s.db.ExecContext(ctx, ` INSERT INTO revisions (git_hash, channel_name, commit_date, option_count, package_count) VALUES (?, ?, ?, ?, ?)`, rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount, rev.PackageCount, ) if err != nil { return fmt.Errorf("failed to create revision: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get last insert id: %w", err) } rev.ID = id // Fetch the indexed_at timestamp err = s.db.QueryRowContext(ctx, "SELECT indexed_at FROM revisions WHERE id = ?", id).Scan(&rev.IndexedAt) if err != nil { return fmt.Errorf("failed to get indexed_at: %w", err) } return nil } // GetRevision retrieves a revision by git hash. func (s *SQLiteStore) GetRevision(ctx context.Context, gitHash string) (*Revision, error) { rev := &Revision{} err := s.db.QueryRowContext(ctx, ` SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count FROM revisions WHERE git_hash = ?`, gitHash, ).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("failed to get revision: %w", err) } return rev, nil } // GetRevisionByChannel retrieves a revision by channel name. func (s *SQLiteStore) GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error) { rev := &Revision{} err := s.db.QueryRowContext(ctx, ` SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count FROM revisions WHERE channel_name = ? ORDER BY indexed_at DESC LIMIT 1`, channel, ).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("failed to get revision by channel: %w", err) } return rev, nil } // ListRevisions returns all indexed revisions. func (s *SQLiteStore) ListRevisions(ctx context.Context) ([]*Revision, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count FROM revisions ORDER BY indexed_at DESC`) if err != nil { return nil, fmt.Errorf("failed to list revisions: %w", err) } defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration var revisions []*Revision for rows.Next() { rev := &Revision{} if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount); err != nil { return nil, fmt.Errorf("failed to scan revision: %w", err) } revisions = append(revisions, rev) } return revisions, rows.Err() } // DeleteRevision removes a revision and all associated data. func (s *SQLiteStore) DeleteRevision(ctx context.Context, id int64) error { _, err := s.db.ExecContext(ctx, "DELETE FROM revisions WHERE id = ?", id) if err != nil { return fmt.Errorf("failed to delete revision: %w", err) } return nil } // UpdateRevisionOptionCount updates the option count for a revision. func (s *SQLiteStore) UpdateRevisionOptionCount(ctx context.Context, id int64, count int) error { _, err := s.db.ExecContext(ctx, "UPDATE revisions SET option_count = ? WHERE id = ?", count, id) if err != nil { return fmt.Errorf("failed to update option count: %w", err) } return nil } // CreateOption creates a new option record. func (s *SQLiteStore) CreateOption(ctx context.Context, opt *Option) error { result, err := s.db.ExecContext(ctx, ` INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly, ) if err != nil { return fmt.Errorf("failed to create option: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get last insert id: %w", err) } opt.ID = id return nil } // CreateOptionsBatch creates multiple options in a batch. func (s *SQLiteStore) CreateOptionsBatch(ctx context.Context, opts []*Option) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() //nolint:errcheck // rollback after commit returns error, which is expected stmt, err := tx.PrepareContext(ctx, ` INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) } defer stmt.Close() //nolint:errcheck // statement closed with transaction for _, opt := range opts { result, err := stmt.ExecContext(ctx, opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly, ) if err != nil { return fmt.Errorf("failed to insert option %s: %w", opt.Name, err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get last insert id: %w", err) } opt.ID = id } return tx.Commit() } // GetOption retrieves an option by revision and name. func (s *SQLiteStore) GetOption(ctx context.Context, revisionID int64, name string) (*Option, error) { opt := &Option{} err := s.db.QueryRowContext(ctx, ` SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only FROM options WHERE revision_id = ? AND name = ?`, revisionID, name, ).Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("failed to get option: %w", err) } return opt, nil } // GetChildren retrieves direct children of an option. func (s *SQLiteStore) GetChildren(ctx context.Context, revisionID int64, parentPath string) ([]*Option, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only FROM options WHERE revision_id = ? AND parent_path = ? ORDER BY name`, revisionID, parentPath) if err != nil { return nil, fmt.Errorf("failed to get children: %w", err) } defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration var options []*Option for rows.Next() { opt := &Option{} if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil { return nil, fmt.Errorf("failed to scan option: %w", err) } options = append(options, opt) } return options, rows.Err() } // SearchOptions searches for options matching a query. func (s *SQLiteStore) SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) { var baseQuery string var args []interface{} // If the query looks like an option path (contains dots), prioritize name-based matching. // This ensures "services.nginx" finds "services.nginx.*" options, not random options // that happen to mention "nginx" in their description. if strings.Contains(query, ".") { // Use LIKE-based search for path queries, with ranking: // 1. Exact match // 2. Direct children (query.*) // 3. All descendants (query.*.*) baseQuery = ` SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only FROM options WHERE revision_id = ? AND (name = ? OR name LIKE ?)` args = []interface{}{revisionID, query, query + ".%"} } else { // For non-path queries, use FTS5 for full-text search on name and description baseQuery = ` SELECT o.id, o.revision_id, o.name, o.parent_path, o.type, o.default_value, o.example, o.description, o.read_only FROM options o INNER JOIN options_fts fts ON o.id = fts.rowid WHERE o.revision_id = ? AND options_fts MATCH ?` // Escape the query for FTS5 by wrapping in double quotes for literal matching. escapedQuery := `"` + strings.ReplaceAll(query, `"`, `""`) + `"` args = []interface{}{revisionID, escapedQuery} } // Use table alias for filters (works for both query types) tbl := "" if !strings.Contains(query, ".") { tbl = "o." } if filters.Type != "" { baseQuery += " AND " + tbl + "type = ?" args = append(args, filters.Type) } if filters.Namespace != "" { baseQuery += " AND " + tbl + "name LIKE ?" args = append(args, filters.Namespace+"%") } if filters.HasDefault != nil { if *filters.HasDefault { baseQuery += " AND " + tbl + "default_value IS NOT NULL" } else { baseQuery += " AND " + tbl + "default_value IS NULL" } } baseQuery += " ORDER BY " + tbl + "name" if filters.Limit > 0 { baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit) } if filters.Offset > 0 { baseQuery += fmt.Sprintf(" OFFSET %d", filters.Offset) } rows, err := s.db.QueryContext(ctx, baseQuery, args...) if err != nil { return nil, fmt.Errorf("failed to search options: %w", err) } defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration var options []*Option for rows.Next() { opt := &Option{} if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil { return nil, fmt.Errorf("failed to scan option: %w", err) } options = append(options, opt) } return options, rows.Err() } // CreateDeclaration creates a new declaration record. func (s *SQLiteStore) CreateDeclaration(ctx context.Context, decl *Declaration) error { result, err := s.db.ExecContext(ctx, ` INSERT INTO declarations (option_id, file_path, line) VALUES (?, ?, ?)`, decl.OptionID, decl.FilePath, decl.Line, ) if err != nil { return fmt.Errorf("failed to create declaration: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get last insert id: %w", err) } decl.ID = id return nil } // CreateDeclarationsBatch creates multiple declarations in a batch. func (s *SQLiteStore) CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() //nolint:errcheck // rollback after commit returns error, which is expected stmt, err := tx.PrepareContext(ctx, ` INSERT INTO declarations (option_id, file_path, line) VALUES (?, ?, ?)`) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) } defer stmt.Close() //nolint:errcheck // statement closed with transaction for _, decl := range decls { result, err := stmt.ExecContext(ctx, decl.OptionID, decl.FilePath, decl.Line) if err != nil { return fmt.Errorf("failed to insert declaration: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get last insert id: %w", err) } decl.ID = id } return tx.Commit() } // GetDeclarations retrieves declarations for an option. func (s *SQLiteStore) GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, option_id, file_path, line FROM declarations WHERE option_id = ?`, optionID) if err != nil { return nil, fmt.Errorf("failed to get declarations: %w", err) } defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration var decls []*Declaration for rows.Next() { decl := &Declaration{} if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line); err != nil { return nil, fmt.Errorf("failed to scan declaration: %w", err) } decls = append(decls, decl) } return decls, rows.Err() } // CreateFile creates a new file record. func (s *SQLiteStore) CreateFile(ctx context.Context, file *File) error { // Compute metadata if not already set if file.ByteSize == 0 { file.ByteSize = len(file.Content) } if file.LineCount == 0 { file.LineCount = countLines(file.Content) } result, err := s.db.ExecContext(ctx, ` INSERT INTO files (revision_id, file_path, extension, content, byte_size, line_count) VALUES (?, ?, ?, ?, ?, ?)`, file.RevisionID, file.FilePath, file.Extension, file.Content, file.ByteSize, file.LineCount, ) if err != nil { return fmt.Errorf("failed to create file: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get last insert id: %w", err) } file.ID = id return nil } // CreateFilesBatch creates multiple files in a batch. func (s *SQLiteStore) CreateFilesBatch(ctx context.Context, files []*File) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() //nolint:errcheck // rollback after commit returns error, which is expected stmt, err := tx.PrepareContext(ctx, ` INSERT INTO files (revision_id, file_path, extension, content, byte_size, line_count) VALUES (?, ?, ?, ?, ?, ?)`) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) } defer stmt.Close() //nolint:errcheck // statement closed with transaction for _, file := range files { // Compute metadata if not already set if file.ByteSize == 0 { file.ByteSize = len(file.Content) } if file.LineCount == 0 { file.LineCount = countLines(file.Content) } result, err := stmt.ExecContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content, file.ByteSize, file.LineCount) if err != nil { return fmt.Errorf("failed to insert file: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get last insert id: %w", err) } file.ID = id } return tx.Commit() } // GetFile retrieves a file by revision and path. func (s *SQLiteStore) GetFile(ctx context.Context, revisionID int64, path string) (*File, error) { file := &File{} err := s.db.QueryRowContext(ctx, ` SELECT id, revision_id, file_path, extension, content, byte_size, line_count FROM files WHERE revision_id = ? AND file_path = ?`, revisionID, path, ).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content, &file.ByteSize, &file.LineCount) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("failed to get file: %w", err) } return file, nil } // GetDeclarationsWithMetadata retrieves declarations with file metadata for an option. func (s *SQLiteStore) GetDeclarationsWithMetadata(ctx context.Context, revisionID, optionID int64) ([]*DeclarationWithMetadata, error) { rows, err := s.db.QueryContext(ctx, ` SELECT d.id, d.option_id, d.file_path, d.line, COALESCE(f.byte_size, 0), COALESCE(f.line_count, 0), (f.id IS NOT NULL) FROM declarations d LEFT JOIN files f ON f.revision_id = ? AND f.file_path = d.file_path WHERE d.option_id = ?`, revisionID, optionID) if err != nil { return nil, fmt.Errorf("failed to get declarations with metadata: %w", err) } defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration var decls []*DeclarationWithMetadata for rows.Next() { decl := &DeclarationWithMetadata{} if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line, &decl.ByteSize, &decl.LineCount, &decl.HasFile); err != nil { return nil, fmt.Errorf("failed to scan declaration: %w", err) } decls = append(decls, decl) } return decls, rows.Err() } // GetFileWithRange retrieves a file with a specified line range. func (s *SQLiteStore) GetFileWithRange(ctx context.Context, revisionID int64, path string, r FileRange) (*FileResult, error) { file, err := s.GetFile(ctx, revisionID, path) if err != nil { return nil, err } if file == nil { return nil, nil } return applyLineRange(file, r), nil } // CreatePackage creates a new package record. func (s *SQLiteStore) CreatePackage(ctx context.Context, pkg *Package) error { result, err := s.db.ExecContext(ctx, ` INSERT INTO packages (revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, pkg.RevisionID, pkg.AttrPath, pkg.Pname, pkg.Version, pkg.Description, pkg.LongDescription, pkg.Homepage, pkg.License, pkg.Platforms, pkg.Maintainers, pkg.Broken, pkg.Unfree, pkg.Insecure, ) if err != nil { return fmt.Errorf("failed to create package: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get last insert id: %w", err) } pkg.ID = id return nil } // CreatePackagesBatch creates multiple packages in a batch. func (s *SQLiteStore) CreatePackagesBatch(ctx context.Context, pkgs []*Package) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() //nolint:errcheck // rollback after commit returns error, which is expected stmt, err := tx.PrepareContext(ctx, ` INSERT INTO packages (revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) } defer stmt.Close() //nolint:errcheck // statement closed with transaction for _, pkg := range pkgs { result, err := stmt.ExecContext(ctx, pkg.RevisionID, pkg.AttrPath, pkg.Pname, pkg.Version, pkg.Description, pkg.LongDescription, pkg.Homepage, pkg.License, pkg.Platforms, pkg.Maintainers, pkg.Broken, pkg.Unfree, pkg.Insecure, ) if err != nil { return fmt.Errorf("failed to insert package %s: %w", pkg.AttrPath, err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get last insert id: %w", err) } pkg.ID = id } return tx.Commit() } // GetPackage retrieves a package by revision and attr_path. func (s *SQLiteStore) GetPackage(ctx context.Context, revisionID int64, attrPath string) (*Package, error) { pkg := &Package{} err := s.db.QueryRowContext(ctx, ` SELECT id, revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure FROM packages WHERE revision_id = ? AND attr_path = ?`, revisionID, attrPath, ).Scan(&pkg.ID, &pkg.RevisionID, &pkg.AttrPath, &pkg.Pname, &pkg.Version, &pkg.Description, &pkg.LongDescription, &pkg.Homepage, &pkg.License, &pkg.Platforms, &pkg.Maintainers, &pkg.Broken, &pkg.Unfree, &pkg.Insecure) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("failed to get package: %w", err) } return pkg, nil } // 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 INNER JOIN packages_fts fts ON p.id = fts.rowid WHERE p.revision_id = ? AND packages_fts MATCH ?` // 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 { baseQuery += " AND p.broken = ?" args = append(args, *filters.Broken) } if filters.Unfree != nil { baseQuery += " AND p.unfree = ?" args = append(args, *filters.Unfree) } if filters.Insecure != nil { baseQuery += " AND p.insecure = ?" args = append(args, *filters.Insecure) } // 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) } if filters.Offset > 0 { baseQuery += fmt.Sprintf(" OFFSET %d", filters.Offset) } rows, err := s.db.QueryContext(ctx, baseQuery, args...) if err != nil { return nil, fmt.Errorf("failed to search packages: %w", err) } defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration var packages []*Package for rows.Next() { pkg := &Package{} if err := rows.Scan(&pkg.ID, &pkg.RevisionID, &pkg.AttrPath, &pkg.Pname, &pkg.Version, &pkg.Description, &pkg.LongDescription, &pkg.Homepage, &pkg.License, &pkg.Platforms, &pkg.Maintainers, &pkg.Broken, &pkg.Unfree, &pkg.Insecure); err != nil { return nil, fmt.Errorf("failed to scan package: %w", err) } packages = append(packages, pkg) } return packages, rows.Err() } // UpdateRevisionPackageCount updates the package count for a revision. func (s *SQLiteStore) UpdateRevisionPackageCount(ctx context.Context, id int64, count int) error { _, err := s.db.ExecContext(ctx, "UPDATE revisions SET package_count = ? WHERE id = ?", count, id) if err != nil { return fmt.Errorf("failed to update package count: %w", err) } return nil } // countLines counts the number of lines in content. func countLines(content string) int { if content == "" { return 0 } count := 1 for _, c := range content { if c == '\n' { count++ } } // Don't count trailing newline as extra line if len(content) > 0 && content[len(content)-1] == '\n' { count-- } return count } // applyLineRange extracts a range of lines from a file. func applyLineRange(file *File, r FileRange) *FileResult { lines := strings.Split(file.Content, "\n") totalLines := len(lines) // Handle trailing newline if totalLines > 0 && lines[totalLines-1] == "" { totalLines-- lines = lines[:totalLines] } // Apply defaults offset := r.Offset if offset < 0 { offset = 0 } limit := r.Limit if limit <= 0 { limit = 250 // Default limit } // Calculate range startLine := offset + 1 // 1-based if offset >= totalLines { // Beyond end of file return &FileResult{ File: &File{ID: file.ID, RevisionID: file.RevisionID, FilePath: file.FilePath, Extension: file.Extension, Content: "", ByteSize: file.ByteSize, LineCount: file.LineCount}, TotalLines: totalLines, StartLine: 0, EndLine: 0, } } endIdx := offset + limit if endIdx > totalLines { endIdx = totalLines } endLine := endIdx // 1-based (last line included) // Extract lines selectedLines := lines[offset:endIdx] content := strings.Join(selectedLines, "\n") return &FileResult{ File: &File{ID: file.ID, RevisionID: file.RevisionID, FilePath: file.FilePath, Extension: file.Extension, Content: content, ByteSize: file.ByteSize, LineCount: file.LineCount}, TotalLines: totalLines, StartLine: startLine, EndLine: endLine, } }