// ingestion/internal/search/search.go package search import ( "bufio" "fmt" "log/slog" "os" "path/filepath" "sort" "strings" "github.com/mathiasbq/hyperguild/ingestion/internal/brain" ) // 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"` Wing string `json:"wing,omitempty"` Hall string `json:"hall,omitempty"` } // QueryOptions configures a search. // // When Wing is set, the walk is restricted to brain/wiki//. // When Hall is additionally set, the walk is restricted to // brain/wiki///. Without either, the legacy walk over // brain/knowledge/ and brain/wiki/ is used. type QueryOptions struct { Query string Limit int Wing string Hall string } // Query searches the brain. Returns up to opts.Limit results sorted by // score descending. Empty query returns nil. func Query(brainDir string, opts QueryOptions) ([]Result, error) { if opts.Limit <= 0 { opts.Limit = 5 } terms := strings.Fields(strings.ToLower(opts.Query)) if len(terms) == 0 { return nil, nil } roots, err := resolveRoots(brainDir, opts.Wing, opts.Hall) if err != nil { return nil, err } var results []Result for _, dir := range roots { if _, statErr := os.Stat(dir); os.IsNotExist(statErr) { continue } err := filepath.WalkDir(dir, 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) wing, hall := extractWingHall(string(content), rel) results = append(results, Result{ Path: rel, Title: extractTitle(string(content), d.Name()), Excerpt: excerpt(string(content), 300), Score: score, Wing: wing, Hall: hall, }) 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) > opts.Limit { results = results[:opts.Limit] } return results, nil } // resolveRoots returns the directories to walk for the given wing/hall // filters. Validates hall against the closed vocabulary when set. func resolveRoots(brainDir, wing, hall string) ([]string, error) { if hall != "" && !brain.IsValidHall(hall) { return nil, fmt.Errorf("invalid hall %q", hall) } if wing != "" { w := brain.Sanitise(wing) if w == "" { return nil, fmt.Errorf("invalid wing %q", wing) } if hall != "" { return []string{filepath.Join(brainDir, "wiki", w, hall)}, nil } return []string{filepath.Join(brainDir, "wiki", w)}, nil } if hall != "" { return nil, fmt.Errorf("hall filter requires wing") } return []string{ filepath.Join(brainDir, "knowledge"), filepath.Join(brainDir, "wiki"), }, nil } // extractWingHall reads wing/hall from frontmatter first, falling back to // path segments brain/wiki///. func extractWingHall(content, relPath string) (wing, hall 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 { continue } key, val, ok := strings.Cut(line, ":") if !ok { continue } v := strings.Trim(strings.TrimSpace(val), `"'`) switch strings.TrimSpace(key) { case "wing": wing = v case "hall": hall = v } } if wing != "" && hall != "" { return wing, hall } parts := strings.Split(relPath, "/") if len(parts) >= 4 && parts[0] == "wiki" { if wing == "" { wing = parts[1] } if hall == "" && brain.IsValidHall(parts[2]) { hall = parts[2] } } return wing, hall } 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 { 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 }