package search import ( "strings" "verstak/internal/core/storage" ) // Result is a single search hit. type Result struct { NodeID string `json:"id"` Title string `json:"title"` Type string `json:"type"` Snippet string `json:"snippet,omitempty"` } // Service manages FTS5 search index. type Service struct { db *storage.DB } // NewService creates a search service. func NewService(db *storage.DB) *Service { return &Service{db: db} } // Index adds or updates a document in the FTS5 index. func (s *Service) Index(nodeID, title, content, path, tags, docType string) error { // Delete old entry first (FTS5 doesn't support UPDATE). s.db.Exec("DELETE FROM search_index WHERE node_id=?", nodeID) _, err := s.db.Exec( `INSERT INTO search_index (node_id,title,content,path,tags,type) VALUES (?,?,?,?,?,?)`, nodeID, title, content, path, tags, docType, ) return err } // Remove deletes a document from the index. func (s *Service) Remove(nodeID string) error { _, err := s.db.Exec("DELETE FROM search_index WHERE node_id=?", nodeID) return err } // Rebuild clears and rebuilds the entire index. // Creates the FTS5 table if it doesn't exist (requires FTS5 support). func (s *Service) Rebuild() error { _, _ = s.db.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5( node_id UNINDEXED, title, content, path, tags, type)`) _, err := s.db.Exec("DELETE FROM search_index") return err } // Search queries the FTS5 index. Returns up to 20 results. func (s *Service) Search(query string) ([]Result, error) { if strings.TrimSpace(query) == "" { return nil, nil } // Escape FTS5 special characters. fts := sanitizeFTS(query) rows, err := s.db.Query( `SELECT node_id, title, type, snippet(search_index, 0, '', '', '...', 32) as snip FROM search_index WHERE search_index MATCH ? ORDER BY rank LIMIT 20`, fts) if err != nil { // FTS5 table may not exist (no FTS5 support or not rebuilt yet). return nil, nil } defer rows.Close() var out []Result for rows.Next() { var r Result var snip sqlNullString if err := rows.Scan(&r.NodeID, &r.Title, &r.Type, &snip); err != nil { return nil, err } if snip.Valid { r.Snippet = snip.String } out = append(out, r) } return out, rows.Err() } func sanitizeFTS(q string) string { // Wrap in double quotes for phrase search, escape inner quotes. q = strings.TrimSpace(q) q = strings.ReplaceAll(q, `"`, `""`) return `"` + q + `"` } type sqlNullString = struct { String string Valid bool }