Files
hyperguild/ingestion/internal/search/search.go
Mathias Bergqvist caf18c9acb fix(ingestion): consistent error handling in search walk
Both walk-level errors and ReadFile failures now use best-effort
semantics (warn via slog, continue) instead of mixed abort/silent-skip.
filepath.Rel error is now propagated from the callback instead of
discarded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 20:23:03 +02:00

121 lines
2.6 KiB
Go

// ingestion/internal/search/search.go
package search
import (
"bufio"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
)
// Result is a single search hit from the brain wiki.
type Result struct {
Path string `json:"path"`
Title string `json:"title"`
Excerpt string `json:"excerpt"`
Score int `json:"score"`
}
// Query searches all .md files under brainDir/wiki/ for pages containing
// any of the whitespace-separated terms in query. Returns up to limit results
// sorted by score descending.
func Query(brainDir, query string, limit int) ([]Result, error) {
if limit <= 0 {
limit = 5
}
terms := strings.Fields(strings.ToLower(query))
if len(terms) == 0 {
return nil, nil
}
var results []Result
err := filepath.WalkDir(filepath.Join(brainDir, "wiki"), func(path string, d os.DirEntry, err error) error {
if err != nil {
slog.Warn("search: skipping path", "path", path, "err", err)
return nil
}
if d.IsDir() || !strings.HasSuffix(path, ".md") {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
slog.Warn("search: skipping unreadable file", "path", path, "err", err)
return nil
}
lower := strings.ToLower(string(content))
score := 0
for _, term := range terms {
score += strings.Count(lower, term)
}
if score == 0 {
return nil
}
rel, err := filepath.Rel(brainDir, path)
if err != nil {
return fmt.Errorf("rel path: %w", err)
}
rel = filepath.ToSlash(rel)
results = append(results, Result{
Path: rel,
Title: extractTitle(string(content), d.Name()),
Excerpt: excerpt(string(content), 300),
Score: score,
})
return nil
})
if err != nil {
return nil, err
}
sort.Slice(results, func(i, j int) bool {
return results[i].Score > results[j].Score
})
if len(results) > limit {
results = results[:limit]
}
return results, nil
}
func extractTitle(content, filename string) string {
scanner := bufio.NewScanner(strings.NewReader(content))
inFrontmatter := false
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "---" {
if !inFrontmatter {
inFrontmatter = true
continue
}
break
}
if inFrontmatter {
key, val, ok := strings.Cut(line, ":")
if ok && strings.TrimSpace(key) == "title" {
return strings.Trim(strings.TrimSpace(val), `"'`)
}
}
}
return strings.TrimSuffix(filename, ".md")
}
func excerpt(content string, maxLen int) string {
// Skip frontmatter, return first maxLen chars of body.
parts := strings.SplitN(content, "---", 3)
body := content
if len(parts) == 3 {
body = strings.TrimSpace(parts[2])
}
if len(body) > maxLen {
return body[:maxLen] + "…"
}
return body
}