diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d147be..7f0ca4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,12 +7,35 @@ on: jobs: test: - runs-on: ubuntu-latest + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '1.25' - - run: go test ./... -v -count=1 - - run: go build -o stacklit ./cmd/stacklit - - run: ./stacklit init + cache: true + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... -race -count=1 + + - name: build + run: go build ./cmd/stacklit + + - name: smoke test (generate) + run: go run ./cmd/stacklit generate --quiet + shell: bash + + - name: smoke test (export markdown) + run: | + go run ./cmd/stacklit export -o stacklit-ci.md + test -s stacklit-ci.md + head -1 stacklit-ci.md | grep -q '^# ' + shell: bash diff --git a/README.md b/README.md index d0f759d..128a1b7 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,8 @@ stacklit diff # check if index is stale stacklit serve # start MCP server stacklit derive # print compact nav map (~250 tokens) stacklit derive --inject claude # inject map into CLAUDE.md +stacklit export # print readable markdown overview +stacklit export -o stacklit.md # write markdown overview to a file stacklit setup # auto-configure all detected AI tools stacklit setup claude # configure Claude Code + MCP stacklit setup cursor # configure Cursor + MCP diff --git a/USAGE.md b/USAGE.md index 762cde8..5f1a3e6 100644 --- a/USAGE.md +++ b/USAGE.md @@ -118,6 +118,8 @@ Regenerates `stacklit.html` and opens it in your browser. | `stacklit derive` | Print compact navigation map (~250 tokens) to stdout | | `stacklit derive --inject claude` | Inject map into CLAUDE.md | | `stacklit derive --inject cursor` | Inject map into .cursorrules | +| `stacklit export` | Print a readable markdown overview to stdout | +| `stacklit export -o stacklit.md` | Write the markdown overview to a file | | `stacklit setup` | Auto-detect and configure all AI tools | | `stacklit setup claude` | Configure Claude Code (CLAUDE.md + MCP) | | `stacklit setup cursor` | Configure Cursor (.cursorrules + MCP) | @@ -150,6 +152,30 @@ The server auto-reloads when `stacklit.json` changes on disk. --- +## Exporting to markdown + +`stacklit.json` is built for AI agents and tooling. When you want something a person can skim — a PR description, a GitHub issue, a Slack message — use the markdown export: + +```bash +stacklit export # print to stdout +stacklit export -o stacklit.md # write to a file +``` + +The markdown contains: + +- A title with the project name, language, file count, and total lines +- A language and framework summary table +- Entrypoints and key directories (when present) +- A modules table with purpose, size, and direct dependencies +- Per-module collapsible `
` blocks listing exports and types +- A dependency edge list, plus most-depended-on and isolated modules +- Hot files from the last 90 days (when git data is present) +- Hints: how to add a feature, the test command, env vars + +Output is deterministic: the same `stacklit.json` produces byte-identical markdown across runs and machines, so it's safe to commit. + +--- + ## Configuration Create `.stacklitrc.json` in your project root (optional): diff --git a/internal/cli/export.go b/internal/cli/export.go new file mode 100644 index 0000000..4661be7 --- /dev/null +++ b/internal/cli/export.go @@ -0,0 +1,60 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/glincker/stacklit/internal/renderer" + "github.com/glincker/stacklit/internal/schema" + "github.com/spf13/cobra" +) + +func newExportCmd() *cobra.Command { + var format, source, output string + + cmd := &cobra.Command{ + Use: "export", + Short: "Export the index in a human-readable format", + Long: `Renders stacklit.json in a different format for sharing. + +Currently supports markdown, which produces a readable project overview +suitable for pasting into PR descriptions, GitHub issues, or chat. + +Examples: + stacklit export Print markdown to stdout + stacklit export --format md Same as above + stacklit export -o stacklit.md Write markdown to a file + stacklit export --source ./other.json Read from a non-default index`, + RunE: func(cmd *cobra.Command, args []string) error { + data, err := os.ReadFile(source) + if err != nil { + return fmt.Errorf("could not read %s: %w (run 'stacklit generate' first, or 'stacklit init')", source, err) + } + var idx schema.Index + if err := json.Unmarshal(data, &idx); err != nil { + return fmt.Errorf("could not parse %s: %w", source, err) + } + + switch format { + case "md", "markdown": + if output == "" { + fmt.Print(renderer.RenderMarkdown(&idx)) + return nil + } + if err := renderer.WriteMarkdown(&idx, output); err != nil { + return fmt.Errorf("could not write %s: %w", output, err) + } + fmt.Fprintf(cmd.OutOrStderr(), "Wrote %s\n", output) + return nil + default: + return fmt.Errorf("unknown format %q (supported: md, markdown)", format) + } + }, + } + + cmd.Flags().StringVar(&format, "format", "md", "Output format (md)") + cmd.Flags().StringVar(&source, "source", "stacklit.json", "Path to the index JSON") + cmd.Flags().StringVarP(&output, "output", "o", "", "Write to file instead of stdout") + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index c291c5c..86bdd6c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -26,4 +26,5 @@ func init() { rootCmd.AddCommand(newServeCmd()) rootCmd.AddCommand(newDeriveCmd()) rootCmd.AddCommand(newSetupCmd()) + rootCmd.AddCommand(newExportCmd()) } diff --git a/internal/renderer/markdown.go b/internal/renderer/markdown.go new file mode 100644 index 0000000..173863d --- /dev/null +++ b/internal/renderer/markdown.go @@ -0,0 +1,337 @@ +package renderer + +import ( + "bytes" + "fmt" + "os" + "sort" + "strings" + + "github.com/glincker/stacklit/internal/schema" +) + +// RenderMarkdown returns a human-readable markdown overview of idx, designed +// for pasting into PR descriptions, GitHub issues, or chat. It's deterministic: +// modules and dependencies are sorted, so the output diffs cleanly between +// runs and between machines. +func RenderMarkdown(idx *schema.Index) string { + var sb strings.Builder + + writeHeader(&sb, idx) + writeOverview(&sb, idx) + writeEntrypoints(&sb, idx) + writeKeyDirectories(&sb, idx) + writeModulesTable(&sb, idx) + writeModuleDetails(&sb, idx) + writeDependencies(&sb, idx) + writeHotFiles(&sb, idx) + writeHints(&sb, idx) + writeFooter(&sb, idx) + + return sb.String() +} + +// WriteMarkdown writes the rendered markdown to path. If the file already +// matches the new content byte-for-byte, the file is left untouched so commit +// hooks don't see spurious changes. +func WriteMarkdown(idx *schema.Index, path string) error { + data := []byte(RenderMarkdown(idx)) + if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, data) { + return nil + } + return os.WriteFile(path, data, 0644) +} + +func writeHeader(sb *strings.Builder, idx *schema.Index) { + name := idx.Project.Name + if name == "" { + name = "Project" + } + fmt.Fprintf(sb, "# %s\n\n", name) + + parts := []string{} + if idx.Tech.PrimaryLanguage != "" { + parts = append(parts, idx.Tech.PrimaryLanguage) + } + if idx.Project.Type != "" { + parts = append(parts, idx.Project.Type) + } + if idx.Structure.TotalFiles > 0 { + parts = append(parts, fmt.Sprintf("%d files", idx.Structure.TotalFiles)) + } + if idx.Structure.TotalLines > 0 { + parts = append(parts, fmt.Sprintf("%s lines", formatThousands(idx.Structure.TotalLines))) + } + if len(parts) > 0 { + sb.WriteString(strings.Join(parts, " · ")) + sb.WriteString("\n\n") + } +} + +func writeOverview(sb *strings.Builder, idx *schema.Index) { + hasLangs := len(idx.Tech.Languages) > 0 + hasFrameworks := len(idx.Tech.Frameworks) > 0 + if !hasLangs && !hasFrameworks && idx.Architecture.Pattern == "" { + return + } + + sb.WriteString("## Overview\n\n") + + if idx.Architecture.Pattern != "" { + fmt.Fprintf(sb, "Pattern: %s\n\n", idx.Architecture.Pattern) + } + + if hasLangs { + sb.WriteString("| Language | Files | Lines |\n") + sb.WriteString("|----------|-------|-------|\n") + langs := sortedKeys(idx.Tech.Languages) + for _, lang := range langs { + s := idx.Tech.Languages[lang] + fmt.Fprintf(sb, "| %s | %d | %s |\n", escapePipe(lang), s.Files, formatThousands(s.Lines)) + } + sb.WriteString("\n") + } + + if hasFrameworks { + sb.WriteString("Frameworks: ") + sb.WriteString(strings.Join(idx.Tech.Frameworks, ", ")) + sb.WriteString("\n\n") + } +} + +func writeEntrypoints(sb *strings.Builder, idx *schema.Index) { + if len(idx.Structure.Entrypoints) == 0 { + return + } + sb.WriteString("## Entrypoints\n\n") + entries := append([]string(nil), idx.Structure.Entrypoints...) + sort.Strings(entries) + for _, e := range entries { + fmt.Fprintf(sb, "- `%s`\n", e) + } + sb.WriteString("\n") +} + +func writeKeyDirectories(sb *strings.Builder, idx *schema.Index) { + if len(idx.Structure.KeyDirectories) == 0 { + return + } + sb.WriteString("## Key directories\n\n") + keys := sortedKeys(idx.Structure.KeyDirectories) + for _, k := range keys { + fmt.Fprintf(sb, "- `%s` — %s\n", k, idx.Structure.KeyDirectories[k]) + } + sb.WriteString("\n") +} + +func writeModulesTable(sb *strings.Builder, idx *schema.Index) { + if len(idx.Modules) == 0 { + return + } + sb.WriteString("## Modules\n\n") + sb.WriteString("| Module | Purpose | Files | Lines | Depends on |\n") + sb.WriteString("|--------|---------|-------|-------|------------|\n") + + names := sortedKeys(idx.Modules) + for _, n := range names { + m := idx.Modules[n] + deps := "—" + if len(m.DependsOn) > 0 { + d := make([]string, 0, len(m.DependsOn)) + d = append(d, m.DependsOn...) + sort.Strings(d) + // Escape pipes per-element so `foo|bar` deps don't break the row. + for i, s := range d { + d[i] = escapePipe(s) + } + deps = "`" + strings.Join(d, "`, `") + "`" + } + fmt.Fprintf(sb, "| `%s` | %s | %d | %s | %s |\n", + escapePipe(n), + escapePipe(m.Purpose), + m.Files, + formatThousands(m.Lines), + deps, + ) + } + sb.WriteString("\n") +} + +// writeModuleDetails emits a collapsible
block per module that has +// exports or type definitions. Keeps the top-level overview short while +// still letting readers drill in. +func writeModuleDetails(sb *strings.Builder, idx *schema.Index) { + names := sortedKeys(idx.Modules) + hasAny := false + for _, n := range names { + m := idx.Modules[n] + if len(m.Exports) == 0 && len(m.TypeDefs) == 0 { + continue + } + hasAny = true + break + } + if !hasAny { + return + } + + sb.WriteString("## Module details\n\n") + + for _, n := range names { + m := idx.Modules[n] + if len(m.Exports) == 0 && len(m.TypeDefs) == 0 { + continue + } + fmt.Fprintf(sb, "
%s\n\n", n) + if m.Purpose != "" { + fmt.Fprintf(sb, "%s\n\n", m.Purpose) + } + if len(m.Exports) > 0 { + sb.WriteString("**Exports**\n\n") + exports := append([]string(nil), m.Exports...) + sort.Strings(exports) + for _, e := range exports { + fmt.Fprintf(sb, "- `%s`\n", e) + } + sb.WriteString("\n") + } + if len(m.TypeDefs) > 0 { + sb.WriteString("**Types**\n\n") + tnames := sortedKeys(m.TypeDefs) + for _, t := range tnames { + fmt.Fprintf(sb, "- `%s` — %s\n", t, m.TypeDefs[t]) + } + sb.WriteString("\n") + } + sb.WriteString("
\n\n") + } +} + +func writeDependencies(sb *strings.Builder, idx *schema.Index) { + if len(idx.Dependencies.Edges) == 0 && + len(idx.Dependencies.MostDepended) == 0 && + len(idx.Dependencies.Isolated) == 0 { + return + } + sb.WriteString("## Dependencies\n\n") + + if len(idx.Dependencies.MostDepended) > 0 { + sb.WriteString("Most depended on: ") + md := make([]string, 0, len(idx.Dependencies.MostDepended)) + md = append(md, idx.Dependencies.MostDepended...) + sort.Strings(md) + for i, s := range md { + md[i] = escapePipe(s) + } + sb.WriteString("`" + strings.Join(md, "`, `") + "`") + sb.WriteString("\n\n") + } + + if len(idx.Dependencies.Isolated) > 0 { + sb.WriteString("Isolated: ") + iso := make([]string, 0, len(idx.Dependencies.Isolated)) + iso = append(iso, idx.Dependencies.Isolated...) + sort.Strings(iso) + for i, s := range iso { + iso[i] = escapePipe(s) + } + sb.WriteString("`" + strings.Join(iso, "`, `") + "`") + sb.WriteString("\n\n") + } + + if len(idx.Dependencies.Edges) > 0 { + sb.WriteString("Edges:\n\n") + edges := append([][2]string(nil), idx.Dependencies.Edges...) + sort.Slice(edges, func(i, j int) bool { + if edges[i][0] != edges[j][0] { + return edges[i][0] < edges[j][0] + } + return edges[i][1] < edges[j][1] + }) + for _, e := range edges { + fmt.Fprintf(sb, "- `%s` → `%s`\n", escapePipe(e[0]), escapePipe(e[1])) + } + sb.WriteString("\n") + } +} + +func writeHotFiles(sb *strings.Builder, idx *schema.Index) { + if len(idx.Git.HotFiles) == 0 { + return + } + sb.WriteString("## Hot files (last 90 days)\n\n") + sb.WriteString("| File | Commits |\n") + sb.WriteString("|------|---------|\n") + for _, h := range idx.Git.HotFiles { + fmt.Fprintf(sb, "| `%s` | %d |\n", escapePipe(h.Path), h.Commits90d) + } + sb.WriteString("\n") +} + +func writeHints(sb *strings.Builder, idx *schema.Index) { + h := idx.Hints + if h.AddFeature == "" && h.TestCmd == "" && len(h.EnvVars) == 0 && len(h.DoNotTouch) == 0 { + return + } + sb.WriteString("## Hints\n\n") + if h.AddFeature != "" { + fmt.Fprintf(sb, "- Add a feature: %s\n", h.AddFeature) + } + if h.TestCmd != "" { + fmt.Fprintf(sb, "- Run tests: `%s`\n", h.TestCmd) + } + if len(h.EnvVars) > 0 { + env := append([]string(nil), h.EnvVars...) + sort.Strings(env) + fmt.Fprintf(sb, "- Env vars: `%s`\n", strings.Join(env, "`, `")) + } + if len(h.DoNotTouch) > 0 { + dnt := append([]string(nil), h.DoNotTouch...) + sort.Strings(dnt) + fmt.Fprintf(sb, "- Do not touch: `%s`\n", strings.Join(dnt, "`, `")) + } + sb.WriteString("\n") +} + +func writeFooter(sb *strings.Builder, idx *schema.Index) { + sb.WriteString("---\n") + sb.WriteString("Generated by [stacklit](https://github.com/glincker/stacklit).") + // Don't hard-code "stacklit.json" here: --source can point at any file. + // The timestamp is what's load-bearing. + if idx.GeneratedAt != "" { + fmt.Fprintf(sb, " Generated at %s.", idx.GeneratedAt) + } + sb.WriteString("\n") +} + +// sortedKeys returns the keys of m sorted lexicographically. Used to keep the +// rendered markdown deterministic regardless of Go's map iteration order. +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func formatThousands(n int) string { + s := fmt.Sprintf("%d", n) + if len(s) <= 3 { + return s + } + var out []byte + for i, c := range []byte(s) { + if i > 0 && (len(s)-i)%3 == 0 { + out = append(out, ',') + } + out = append(out, c) + } + return string(out) +} + +// escapePipe replaces "|" with "\|" so user-supplied strings can be embedded +// in markdown table cells without breaking the row. +func escapePipe(s string) string { + return strings.ReplaceAll(s, "|", `\|`) +} diff --git a/internal/renderer/markdown_test.go b/internal/renderer/markdown_test.go new file mode 100644 index 0000000..44f9529 --- /dev/null +++ b/internal/renderer/markdown_test.go @@ -0,0 +1,277 @@ +package renderer + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/glincker/stacklit/internal/schema" +) + +func makeRichTestIndex() *schema.Index { + return &schema.Index{ + GeneratedAt: "2026-05-05T00:00:00Z", + Project: schema.Project{ + Name: "myapp", + Type: "monorepo", + }, + Tech: schema.Tech{ + PrimaryLanguage: "go", + Languages: map[string]schema.LangStats{ + "go": {Files: 10, Lines: 5000}, + "typescript": {Files: 4, Lines: 1234}, + }, + Frameworks: []string{"Gin", "React"}, + }, + Structure: schema.Structure{ + Entrypoints: []string{"cmd/api/main.go", "cmd/worker/main.go"}, + TotalFiles: 14, + TotalLines: 6234, + KeyDirectories: map[string]string{ + "cmd/": "binaries", + "internal/": "private packages", + }, + }, + Modules: map[string]schema.ModuleInfo{ + "src/api": { + Purpose: "API handlers (with | pipe in purpose)", + Files: 5, + Lines: 1200, + Exports: []string{"NewServer()", "RegisterRoutes()"}, + DependsOn: []string{"src/auth", "src/db"}, + TypeDefs: map[string]string{ + "Server": "router *gin.Engine, port int", + }, + }, + "src/auth": { + Purpose: "Authentication", + Files: 3, + Lines: 500, + Exports: []string{"Authenticate(token string) (User, error)"}, + DependsOn: []string{"src/db"}, + }, + "src/db": { + Purpose: "Database access layer", + Files: 4, + Lines: 800, + }, + }, + Dependencies: schema.Dependencies{ + Edges: [][2]string{ + {"src/api", "src/auth"}, + {"src/api", "src/db"}, + {"src/auth", "src/db"}, + }, + MostDepended: []string{"src/db"}, + Isolated: []string{"scripts"}, + }, + Git: schema.GitInfo{ + HotFiles: []schema.HotFile{ + {Path: "src/api/server.go", Commits90d: 12}, + {Path: "src/auth/jwt.go", Commits90d: 7}, + }, + }, + Hints: schema.Hints{ + AddFeature: "Create handler in src/api/, add route in src/api/routes.go", + TestCmd: "go test ./...", + EnvVars: []string{"DATABASE_URL", "JWT_SECRET"}, + }, + } +} + +func TestRenderMarkdownContainsExpectedSections(t *testing.T) { + idx := makeRichTestIndex() + out := RenderMarkdown(idx) + + wantSubstrings := []string{ + "# myapp", + "go · monorepo · 14 files · 6,234 lines", + "## Overview", + "| Language | Files | Lines |", + "| go | 10 | 5,000 |", + "Frameworks: Gin, React", + "## Entrypoints", + "- `cmd/api/main.go`", + "- `cmd/worker/main.go`", + "## Key directories", + "- `cmd/` — binaries", + "## Modules", + "| `src/api` |", + "## Module details", + "
src/api", + "`NewServer()`", + "## Dependencies", + "Most depended on: `src/db`", + "Isolated: `scripts`", + "- `src/api` → `src/auth`", + "## Hot files (last 90 days)", + "`src/api/server.go`", + "## Hints", + "- Run tests: `go test ./...`", + "- Env vars: `DATABASE_URL`, `JWT_SECRET`", + "Generated by [stacklit]", + "Generated at 2026-05-05T00:00:00Z", + } + + for _, w := range wantSubstrings { + if !strings.Contains(out, w) { + t.Errorf("expected output to contain %q\n--- output ---\n%s", w, out) + } + } + + // Footer should no longer hard-code the source filename, since --source + // can point at any path. + if strings.Contains(out, "Source: `stacklit.json`") { + t.Errorf("footer should not hard-code 'stacklit.json' as the source") + } +} + +func TestRenderMarkdownEscapesPipeInTableCell(t *testing.T) { + idx := makeRichTestIndex() + out := RenderMarkdown(idx) + + // The purpose string for src/api contains a pipe; it must be escaped so + // it doesn't break the table row. + if !strings.Contains(out, `with \| pipe`) { + t.Errorf("expected pipe in module purpose to be escaped to '\\|', got:\n%s", out) + } +} + +func TestRenderMarkdownEscapesPipeEverywhere(t *testing.T) { + // Build an adversarial index with pipes in module name, dep name, language + // name, and hot-file path. Every cell that interpolates a user-supplied + // string must escape pipes, otherwise GitHub markdown breaks the row. + idx := &schema.Index{ + Project: schema.Project{Name: "edge"}, + Tech: schema.Tech{ + PrimaryLanguage: "go", + Languages: map[string]schema.LangStats{ + "weird|lang": {Files: 1, Lines: 1}, + }, + }, + Modules: map[string]schema.ModuleInfo{ + "weird|module": { + Purpose: "ok", + Files: 1, + Lines: 1, + DependsOn: []string{"other|dep"}, + }, + }, + Dependencies: schema.Dependencies{ + Edges: [][2]string{{"weird|module", "other|dep"}}, + }, + Git: schema.GitInfo{ + HotFiles: []schema.HotFile{ + {Path: "src/odd|name.go", Commits90d: 3}, + }, + }, + } + out := RenderMarkdown(idx) + + for _, want := range []string{ + `weird\|lang`, + `weird\|module`, + `other\|dep`, + `src/odd\|name.go`, + } { + if !strings.Contains(out, want) { + t.Errorf("expected escaped %q in output\n--- output ---\n%s", want, out) + } + } + // Raw, unescaped pipe inside a table cell is the failure mode we want to + // avoid. We don't grep for `|` literally (the table dividers use it), + // but we can confirm none of the original strings appear unescaped. + for _, bad := range []string{ + "| weird|lang ", + "| `weird|module` ", + "`other|dep`", + "| `src/odd|name.go`", + } { + if strings.Contains(out, bad) { + t.Errorf("found unescaped pipe in cell: %q", bad) + } + } +} + +func TestRenderMarkdownIsDeterministic(t *testing.T) { + idx := makeRichTestIndex() + first := RenderMarkdown(idx) + for i := 0; i < 5; i++ { + again := RenderMarkdown(idx) + if again != first { + t.Fatalf("RenderMarkdown is not deterministic on run %d", i+2) + } + } +} + +func TestRenderMarkdownEmptyIndex(t *testing.T) { + idx := &schema.Index{ + Project: schema.Project{Name: ""}, + Tech: schema.Tech{Languages: map[string]schema.LangStats{}}, + Modules: map[string]schema.ModuleInfo{}, + Dependencies: schema.Dependencies{}, + } + out := RenderMarkdown(idx) + + if !strings.Contains(out, "# Project") { + t.Errorf("expected fallback title '# Project' for empty index, got:\n%s", out) + } + // Sections that depend on data should be absent. + for _, section := range []string{"## Modules", "## Dependencies", "## Hot files", "## Hints"} { + if strings.Contains(out, section) { + t.Errorf("did not expect %q section in empty index, got:\n%s", section, out) + } + } +} + +func TestWriteMarkdownIsIdempotent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "stacklit.md") + idx := makeRichTestIndex() + + if err := WriteMarkdown(idx, path); err != nil { + t.Fatalf("first WriteMarkdown failed: %v", err) + } + first, err := os.Stat(path) + if err != nil { + t.Fatalf("stat after first write: %v", err) + } + + // Sleep-free: check that a second identical write doesn't change the file. + // We compare contents directly since modtime can be unreliable on fast filesystems. + firstBytes, _ := os.ReadFile(path) + + if err := WriteMarkdown(idx, path); err != nil { + t.Fatalf("second WriteMarkdown failed: %v", err) + } + secondBytes, _ := os.ReadFile(path) + + if string(firstBytes) != string(secondBytes) { + t.Errorf("WriteMarkdown produced different content on second run") + } + + // Sanity: the file is non-trivial in size. + if first.Size() < 100 { + t.Errorf("expected markdown output to be > 100 bytes, got %d", first.Size()) + } +} + +func TestFormatThousands(t *testing.T) { + cases := []struct { + in int + want string + }{ + {0, "0"}, + {42, "42"}, + {999, "999"}, + {1000, "1,000"}, + {6234, "6,234"}, + {1234567, "1,234,567"}, + } + for _, tc := range cases { + if got := formatThousands(tc.in); got != tc.want { + t.Errorf("formatThousands(%d) = %q, want %q", tc.in, got, tc.want) + } + } +}