From a0b81319dd34ced6a29444c1e490cde140394eaf Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 6 Mar 2026 16:40:23 +0000 Subject: [PATCH] feat(help): add LoadContentDir for directory-based catalog loading Co-Authored-By: Virgil --- pkg/help/catalog.go | 42 ++++++++++++++++++++++++++++++++++++++++ pkg/help/catalog_test.go | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/pkg/help/catalog.go b/pkg/help/catalog.go index c7d4d17..93b6591 100644 --- a/pkg/help/catalog.go +++ b/pkg/help/catalog.go @@ -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] diff --git a/pkg/help/catalog_test.go b/pkg/help/catalog_test.go index bd669cc..568038d 100644 --- a/pkg/help/catalog_test.go +++ b/pkg/help/catalog_test.go @@ -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{