feat(help): add LoadContentDir for directory-based catalog loading

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-06 16:40:23 +00:00
parent 0c474a82d0
commit a0b81319dd
2 changed files with 80 additions and 0 deletions

View file

@ -2,9 +2,13 @@ package help
import (
"fmt"
"io/fs"
"iter"
"maps"
"os"
"path/filepath"
"slices"
"strings"
)
// Catalog manages help topics.
@ -90,6 +94,44 @@ func (c *Catalog) SearchResults(query string) iter.Seq[*SearchResult] {
return slices.Values(c.Search(query))
}
// LoadContentDir recursively loads all .md files from a directory into a Catalog.
func LoadContentDir(dir string) (*Catalog, error) {
c := &Catalog{
topics: make(map[string]*Topic),
index: newSearchIndex(),
}
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if !strings.HasSuffix(strings.ToLower(d.Name()), ".md") {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading %s: %w", path, err)
}
topic, err := ParseTopic(path, content)
if err != nil {
return fmt.Errorf("parsing %s: %w", path, err)
}
c.Add(topic)
return nil
})
if err != nil {
return nil, fmt.Errorf("walking directory %s: %w", dir, err)
}
return c, nil
}
// Get returns a topic by ID.
func (c *Catalog) Get(id string) (*Topic, error) {
t, ok := c.topics[id]

View file

@ -2,6 +2,8 @@ package help
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
@ -176,6 +178,42 @@ func TestCatalog_Search_Good_ScoreTiebreaking(t *testing.T) {
"scores should be equal for tie-breaking to apply")
}
func TestCatalog_LoadContentDir_Good(t *testing.T) {
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, "cli"), 0o755)
os.WriteFile(filepath.Join(dir, "cli", "dev-work.md"), []byte("---\ntitle: Dev Work\ntags: [cli, dev]\n---\n\n## Usage\n\ncore dev work syncs your workspace.\n"), 0o644)
os.WriteFile(filepath.Join(dir, "cli", "setup.md"), []byte("---\ntitle: Setup\ntags: [cli]\n---\n\n## Installation\n\nRun core setup to get started.\n"), 0o644)
catalog, err := LoadContentDir(dir)
require.NoError(t, err)
assert.Len(t, catalog.List(), 2)
devWork, err := catalog.Get("dev-work")
require.NoError(t, err)
assert.Equal(t, "Dev Work", devWork.Title)
assert.Contains(t, devWork.Tags, "cli")
results := catalog.Search("workspace")
assert.NotEmpty(t, results)
}
func TestCatalog_LoadContentDir_Good_Empty(t *testing.T) {
dir := t.TempDir()
catalog, err := LoadContentDir(dir)
require.NoError(t, err)
assert.Empty(t, catalog.List())
}
func TestCatalog_LoadContentDir_Good_SkipsNonMd(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("not markdown"), 0o644)
os.WriteFile(filepath.Join(dir, "topic.md"), []byte("---\ntitle: Topic\n---\n\nContent here.\n"), 0o644)
catalog, err := LoadContentDir(dir)
require.NoError(t, err)
assert.Len(t, catalog.List(), 1)
}
func BenchmarkSearch(b *testing.B) {
// Build a catalog with 100+ topics for benchmarking.
c := &Catalog{