diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go index 73f0b67..52ac3dc 100644 --- a/internal/bundle/bundle.go +++ b/internal/bundle/bundle.go @@ -62,11 +62,15 @@ func Load(root string) (*Bundle, error) { relPath = filepath.ToSlash(relPath) // Reserved filenames are loaded separately; they may lack frontmatter. + // ParseReserved reads the file once and tolerates a missing frontmatter + // block (e.g. generated index.md), but still surfaces malformed + // frontmatter and other errors rather than silently dropping the file. if concept.ReservedNames[strings.ToLower(d.Name())] { - c, perr := concept.Parse(path, relPath) - if perr == nil { - b.Reserved = append(b.Reserved, c) + c, perr := concept.ParseReserved(path, relPath) + if perr != nil { + return fmt.Errorf("parse reserved %s: %w", relPath, perr) } + b.Reserved = append(b.Reserved, c) return nil } diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go index 2e53902..20382e3 100644 --- a/internal/bundle/bundle_test.go +++ b/internal/bundle/bundle_test.go @@ -1,7 +1,13 @@ package bundle import ( + "os" + "path/filepath" + "strings" "testing" + + "github.com/okfcli/okf/internal/concept" + "github.com/okfcli/okf/internal/index" ) func TestLoad_ValidBundle(t *testing.T) { @@ -64,3 +70,108 @@ func TestLoad_FrontmatterParsed(t *testing.T) { t.Errorf("tags len = %d, want 3", len(c.Frontmatter.Tags)) } } + +// TestLoad_ReservedIndexNoFrontmatter verifies that a reserved index.md file +// without YAML frontmatter (as generated by `okf index`) is still loaded into +// Reserved with a correct ID, Path, and raw Body content. +func TestLoad_ReservedIndexNoFrontmatter(t *testing.T) { + tmp := t.TempDir() + + // Write a concept file with frontmatter so the bundle is non-empty. + conceptDir := filepath.Join(tmp, "tables") + if err := os.MkdirAll(conceptDir, 0o755); err != nil { + t.Fatalf("mkdir tables: %v", err) + } + conceptContent := "---\ntype: BigQuery Table\ntitle: Events\n---\n\nEvent data.\n" + if err := os.WriteFile(filepath.Join(conceptDir, "events_.md"), []byte(conceptContent), 0o644); err != nil { + t.Fatalf("write concept: %v", err) + } + + // Write an index.md WITHOUT frontmatter (as `okf index` generates). + indexPath := filepath.Join(conceptDir, "index.md") + indexContent := "# Index\n\n- [events_](events_.md)\n" + if err := os.WriteFile(indexPath, []byte(indexContent), 0o644); err != nil { + t.Fatalf("write index: %v", err) + } + + b, err := Load(tmp) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + var found *concept.Concept + for _, r := range b.Reserved { + if r.ID == "tables/index" { + found = r + break + } + } + if found == nil { + t.Fatalf("expected Reserved to contain tables/index, got %d entries", len(b.Reserved)) + } + + // Path should be the absolute path to index.md. + absIndex, _ := filepath.Abs(indexPath) + if found.Path != absIndex { + t.Errorf("Path = %q, want %q", found.Path, absIndex) + } + + // Body should contain the raw file content. + if found.Body != indexContent { + t.Errorf("Body = %q, want %q", found.Body, indexContent) + } +} + +// TestLoad_GeneratedIndexIsDiscoverable is an end-to-end regression test for +// the user-visible symptom: after `okf index` generates index.md files (which +// have no frontmatter), reloading the bundle must expose them in b.Reserved so +// runIndex reports a non-empty indexes_written. Previously these files were +// silently dropped and the index command reported zero indexes. +func TestLoad_GeneratedIndexIsDiscoverable(t *testing.T) { + tmp := t.TempDir() + + conceptDir := filepath.Join(tmp, "tables") + if err := os.MkdirAll(conceptDir, 0o755); err != nil { + t.Fatalf("mkdir tables: %v", err) + } + conceptContent := "---\ntype: BigQuery Table\ntitle: Events\n---\n\nEvent data.\n" + if err := os.WriteFile(filepath.Join(conceptDir, "events_.md"), []byte(conceptContent), 0o644); err != nil { + t.Fatalf("write concept: %v", err) + } + + // Generate index.md files the same way `okf index` does. + if err := index.Generate(tmp); err != nil { + t.Fatalf("index.Generate: %v", err) + } + + b, err := Load(tmp) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + // Mirror runIndex's collection logic. + var indexFiles []string + for _, r := range b.Reserved { + if r.ID == "index" || strings.HasSuffix(r.ID, "/index") { + indexFiles = append(indexFiles, r.Path) + } + } + if len(indexFiles) == 0 { + t.Fatalf("expected at least one generated index in Reserved, got %d reserved entries", len(b.Reserved)) + } + + // The tables/ subdirectory index must be among them and carry its body. + var tablesIndex *concept.Concept + for _, r := range b.Reserved { + if r.ID == "tables/index" { + tablesIndex = r + break + } + } + if tablesIndex == nil { + t.Fatal("expected Reserved to contain tables/index") + } + if tablesIndex.Body == "" { + t.Error("generated index Body is empty, want raw index content") + } +} diff --git a/internal/concept/concept.go b/internal/concept/concept.go index 6e376da..085eca8 100644 --- a/internal/concept/concept.go +++ b/internal/concept/concept.go @@ -76,6 +76,31 @@ func ParseBytes(raw []byte, relPath, absPath string) (*Concept, error) { }, nil } +// ParseReserved reads and parses a reserved document (index.md, log.md) from +// path. Unlike Parse, a missing frontmatter block is not an error: such files +// (e.g. those generated by `okf index`) are returned as a Concept with empty +// Frontmatter and the raw file content as Body, so callers can still discover +// them. The file is read exactly once. Malformed-but-present frontmatter still +// returns an error rather than being silently dropped. +func ParseReserved(path, relPath string) (*Concept, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + c, err := ParseBytes(raw, relPath, path) + if err != nil { + if errors.Is(err, ErrNoFrontmatter) { + return &Concept{ + ID: conceptID(relPath), + Path: path, + Body: string(raw), + }, nil + } + return nil, err + } + return c, nil +} + // ConceptID converts a relative file path to a concept ID by stripping the .md // suffix and normalizing separators to forward slashes. // e.g. "tables/users.md" -> "tables/users"