From 6c365e9cde535efb42eb8720dccfd5ad9cf718ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartal=20L=C3=A6arsson?= Date: Tue, 15 Sep 2026 10:46:23 +0100 Subject: [PATCH 1/9] instructions only in index, not slices --- internal/renderer/renderer.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index 3707020..d3be2be 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -154,7 +154,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e } } - if err := writeDirSlice(slicePath, absDir, absRoot, dirName, treeEntries, dirEntries, indexBasename, result.OmitTest, result.Commit, result.Instructions); err != nil { + if err := writeDirSlice(slicePath, absDir, absRoot, dirName, treeEntries, dirEntries, indexBasename, result.OmitTest, result.Commit); err != nil { return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err) } } @@ -238,7 +238,7 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker return err } -func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntries []walker.Entry, rootFileName string, omitTest bool, commit *git.CommitInfo, instContent string) error { +func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntries []walker.Entry, rootFileName string, omitTest bool, commit *git.CommitInfo) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("cannot create slice file: %w", err) @@ -249,11 +249,7 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri fmt.Fprintf(&sb, "# %s [SLICE]\n", absDir) fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339)) sb.WriteString(git.FormatCommitLine(commit)) - if instContent != "" { - sb.WriteString(instructions.Format(instContent)) - } else { - sb.WriteString("\n") - } + sb.WriteString("\n") fmt.Fprintf(&sb, "Root document: %s\n\n", rootFileName) sb.WriteString("## Directory Tree\n\n") -- 2.55.0 From 4396a6f6196a4c73c1f6ca3739eedaf4d5ead996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartal=20L=C3=A6arsson?= Date: Tue, 15 Sep 2026 11:16:27 +0100 Subject: [PATCH 2/9] unfiltered walker tree --- internal/renderer/renderer.go | 162 +++++++++++--------------------- internal/tree/tree.go | 86 +++++------------ internal/tree/unfiltered.go | 170 ++++++++++++++++++++++++++++++++++ internal/walker/walker.go | 9 +- 4 files changed, 252 insertions(+), 175 deletions(-) create mode 100644 internal/tree/unfiltered.go diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index d3be2be..970c2f6 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -52,11 +52,15 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile } if !singleFile { - root := tree.Build(result.Entries) + treeRoot, err := tree.BuildFullTree(result.AbsRoot, result.Cfg) + if err != nil { + return fmt.Errorf("cannot build tree: %w", err) + } + rootName := filepath.Base(result.AbsRoot) sb.WriteString("## Directory Tree\n\n") sb.WriteString(fenceChars + "\n") - sb.WriteString(renderTreeForLLM(root, rootName)) + sb.WriteString(renderTreeWithLegend(treeRoot, rootName)) sb.WriteString(fenceChars + "\n\n") } @@ -117,13 +121,9 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e } } - var generated []string - indexBasename := "index_" + repoName + ".md" - rootPath := filepath.Join(outputDir, indexBasename) - generated = append(generated, rootPath) - - if err := writeRootSlice(rootPath, absRoot, rootName, repoName, rootFiles, topLevelDirs, result.OmitTest, result.Commit, result.Instructions); err != nil { - return nil, fmt.Errorf("failed to write root slice: %w", err) + treeRoot, err := tree.BuildFullTree(absRoot, result.Cfg) + if err != nil { + return nil, fmt.Errorf("cannot build full tree: %w", err) } var dirNames []string @@ -132,6 +132,21 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e } sort.Strings(dirNames) + for _, dirName := range dirNames { + if node, ok := treeRoot.Children[dirName]; ok { + node.Ref = filepath.Join(dirName, dirName+"_"+repoName+".md") + } + } + + var generated []string + indexBasename := "index_" + repoName + ".md" + rootPath := filepath.Join(outputDir, indexBasename) + generated = append(generated, rootPath) + + if err := writeRootSlice(rootPath, absRoot, rootName, repoName, rootFiles, topLevelDirs, result.OmitTest, result.Commit, result.Instructions, treeRoot); err != nil { + return nil, fmt.Errorf("failed to write root slice: %w", err) + } + for _, dirName := range dirNames { sliceName := strings.TrimSuffix(dirName, "/") + "_" + repoName + ".md" sliceDir := filepath.Join(outputDir, dirName) @@ -144,17 +159,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e absDir := filepath.Join(absRoot, dirName) dirEntries := topLevelDirs[dirName] - treeEntries := make([]walker.Entry, len(dirEntries)) - for i, e := range dirEntries { - sepIdx := strings.Index(e.RelPath, string(filepath.Separator)) - if sepIdx >= 0 { - treeEntries[i] = walker.Entry{RelPath: e.RelPath[sepIdx+1:], Size: e.Size} - } else { - treeEntries[i] = e - } - } - - if err := writeDirSlice(slicePath, absDir, absRoot, dirName, treeEntries, dirEntries, indexBasename, result.OmitTest, result.Commit); err != nil { + if err := writeDirSlice(slicePath, absDir, absRoot, dirName, dirEntries, indexBasename, result.OmitTest, result.Commit, treeRoot); err != nil { return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err) } } @@ -162,7 +167,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e return generated, nil } -func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry, omitTest bool, commit *git.CommitInfo, instContent string) error { +func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry, omitTest bool, commit *git.CommitInfo, instContent string, treeRoot *tree.Node) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("cannot create root file: %w", err) @@ -179,10 +184,9 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker sb.WriteString("\n") } - treeLines := buildTreeWithRefs(rootName, repoName, topLevelDirs, rootFiles) sb.WriteString("## Directory Tree\n\n") sb.WriteString(fenceChars + "\n") - sb.WriteString(treeLines) + sb.WriteString(renderTreeWithLegend(treeRoot, rootName)) sb.WriteString(fenceChars + "\n\n") sb.WriteString("## Vertical Slices Index\n\n") @@ -238,7 +242,7 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker return err } -func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntries []walker.Entry, rootFileName string, omitTest bool, commit *git.CommitInfo) error { +func writeDirSlice(path, absDir, absRoot, dirName string, fileEntries []walker.Entry, rootFileName string, omitTest bool, commit *git.CommitInfo, treeRoot *tree.Node) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("cannot create slice file: %w", err) @@ -254,8 +258,7 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri sb.WriteString("## Directory Tree\n\n") sb.WriteString(fenceChars + "\n") - sliceTree := buildTreeFromEntries(dirName, treeEntries) - sb.WriteString(sliceTree) + sb.WriteString(renderTreeWithLegend(treeRoot, filepath.Base(absRoot))) sb.WriteString(fenceChars + "\n\n") if len(fileEntries) > 0 { @@ -287,93 +290,16 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri return err } -func buildTreeWithRefs(rootName, repoName string, topLevelDirs map[string][]walker.Entry, rootFiles []walker.Entry) string { - var sb strings.Builder - sb.WriteString(rootName) - sb.WriteString("/\n") - - var dirNames []string - for k := range topLevelDirs { - dirNames = append(dirNames, k) - } - sort.Strings(dirNames) - - sort.Slice(rootFiles, func(i, j int) bool { - return rootFiles[i].RelPath < rootFiles[j].RelPath - }) - - type treeItem struct { - name string - isDir bool - sliceRef string - } - - items := make([]treeItem, 0, len(dirNames)+len(rootFiles)) - for _, d := range dirNames { - sliceRef := filepath.Join(d, strings.TrimSuffix(d, "/")+"_"+repoName+".md") - items = append(items, treeItem{ - name: d, - isDir: true, - sliceRef: sliceRef, - }) - } - for _, rf := range rootFiles { - items = append(items, treeItem{ - name: rf.RelPath, - isDir: false, - }) - } - - sort.Slice(items, func(i, j int) bool { - return items[i].name < items[j].name - }) - - for _, item := range items { - sb.WriteString("+- ") - sb.WriteString(item.name) - if item.isDir { - sb.WriteString("/") - sb.WriteString(" -> see ") - sb.WriteString(item.sliceRef) - } - sb.WriteString("\n") - } - - return sb.String() -} - -func buildTreeFromEntries(rootName string, entries []walker.Entry) string { - root := &tree.Node{IsDir: true, Children: make(map[string]*tree.Node)} - - for _, e := range entries { - parts := strings.Split(e.RelPath, string(filepath.Separator)) - cur := root - - for i, part := range parts { - isLeaf := i == len(parts)-1 - if _, ok := cur.Children[part]; !ok { - node := &tree.Node{Name: part, IsDir: !isLeaf} - if !isLeaf { - node.Children = make(map[string]*tree.Node) - } - cur.Children[part] = node - } - cur = cur.Children[part] - } - } - +func renderTreeWithLegend(root *tree.Node, rootName string) string { var sb strings.Builder sb.WriteString(rootName) sb.WriteString("/\n") renderNodeLLM(&sb, root, "") - return sb.String() -} -func renderTreeForLLM(root *tree.Node, rootName string) string { - var sb strings.Builder - sb.WriteString(rootName) - sb.WriteString("/\n") - renderNodeLLM(&sb, root, "") + if hasAnyAnnotation(root) { + sb.WriteString("Legend: ~ ignored | ~ tests omitted | ~ omitted | ~ exceeded --max-depth\n") + } + return sb.String() } @@ -395,6 +321,14 @@ func renderNodeLLM(sb *strings.Builder, node *tree.Node, prefix string) { if child.IsDir { sb.WriteString("/") } + if child.Annotation != "" { + sb.WriteString(" ~ ") + sb.WriteString(child.Annotation) + } + if child.Ref != "" { + sb.WriteString(" -> see ") + sb.WriteString(child.Ref) + } sb.WriteString("\n") if child.IsDir && child.Children != nil { @@ -409,6 +343,18 @@ func renderNodeLLM(sb *strings.Builder, node *tree.Node, prefix string) { } } +func hasAnyAnnotation(node *tree.Node) bool { + if node.Annotation != "" { + return true + } + for _, child := range node.Children { + if hasAnyAnnotation(child) { + return true + } + } + return false +} + func readFile(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { diff --git a/internal/tree/tree.go b/internal/tree/tree.go index df2b92a..3a8077d 100644 --- a/internal/tree/tree.go +++ b/internal/tree/tree.go @@ -1,76 +1,36 @@ package tree -import ( - "sort" - "strings" - - "git.flo.fo/FLO/dirmd/internal/walker" -) - type Node struct { - Name string - IsDir bool - Children map[string]*Node + Name string + IsDir bool + Children map[string]*Node + Annotation string + Ref string } -func Build(entries []walker.Entry) *Node { - root := &Node{IsDir: true, Children: make(map[string]*Node)} - - for _, e := range entries { - parts := strings.Split(e.RelPath, "/") - cur := root - - for i, part := range parts { - isLeaf := i == len(parts)-1 - if _, ok := cur.Children[part]; !ok { - cur.Children[part] = &Node{Name: part, IsDir: !isLeaf, Children: make(map[string]*Node)} +func splitPath(relPath string) []string { + var parts []string + start := 0 + for i := 0; i <= len(relPath); i++ { + if i == len(relPath) || relPath[i] == '/' || relPath[i] == '\\' { + if i > start { + parts = append(parts, relPath[start:i]) } - cur = cur.Children[part] + start = i + 1 } } - - return root + return parts } -func Render(root *Node, rootName string) string { - var sb strings.Builder - sb.WriteString(rootName) - sb.WriteString("/\n") - renderNode(&sb, root, "") - return sb.String() -} - -func renderNode(sb *strings.Builder, node *Node, prefix string) { - keys := make([]string, 0, len(node.Children)) - for k := range node.Children { - keys = append(keys, k) - } - sort.Strings(keys) - - for i, key := range keys { - child := node.Children[key] - isLast := i == len(keys)-1 - - if isLast { - sb.WriteString(prefix + "└── ") - } else { - sb.WriteString(prefix + "├── ") +func addPath(root *Node, rel string, isDir bool) *Node { + parts := splitPath(rel) + cur := root + for i, part := range parts { + if _, ok := cur.Children[part]; !ok { + cur.Children[part] = &Node{Name: part, IsDir: i < len(parts)-1, Children: make(map[string]*Node)} } - - sb.WriteString(child.Name) - if child.IsDir { - sb.WriteString("/") - } - sb.WriteString("\n") - - if child.IsDir { - newPrefix := prefix - if isLast { - newPrefix += " " - } else { - newPrefix += "│ " - } - renderNode(sb, child, newPrefix) - } + cur = cur.Children[part] } + cur.IsDir = isDir + return cur } diff --git a/internal/tree/unfiltered.go b/internal/tree/unfiltered.go new file mode 100644 index 0000000..255f4ce --- /dev/null +++ b/internal/tree/unfiltered.go @@ -0,0 +1,170 @@ +package tree + +import ( + "fmt" + "io/fs" + "path/filepath" + "strings" + + "git.flo.fo/FLO/dirmd/internal/config" + "git.flo.fo/FLO/dirmd/internal/filter" +) + +func BuildFullTree(absRoot string, cfg *config.Config) (*Node, error) { + root := &Node{IsDir: true, Children: make(map[string]*Node)} + + err := filepath.WalkDir(absRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + + rel, rerr := filepath.Rel(absRoot, path) + if rerr != nil || rel == "." { + return nil + } + + if filter.IsSymlink(path) { + return nil + } + + depth := strings.Count(rel, string(filepath.Separator)) + 1 + if depth > cfg.MaxDepth { + node := addPath(root, rel, d.IsDir()) + node.Annotation = "exceeded --max-depth" + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + name := filepath.Base(path) + + if d.IsDir() { + if isIgnoredName(name, cfg.Ignores) { + node := addPath(root, rel, true) + node.Annotation = "ignored (--ignore)" + return filepath.SkipDir + } + if cfg.OmitTest && isTestDir(rel) { + node := addPath(root, rel, true) + node.Annotation = "tests omitted (--omit-test)" + return filepath.SkipDir + } + addPath(root, rel, true) + return nil + } + + if annotation, ok := fileAnnotation(rel, name, cfg); ok { + node := addPath(root, rel, false) + node.Annotation = annotation + return nil + } + + addPath(root, rel, false) + return nil + }) + if err != nil { + return nil, fmt.Errorf("cannot walk %s: %w", absRoot, err) + } + + return root, nil +} + +func isIgnoredName(name string, ignores []string) bool { + if strings.HasPrefix(name, ".") { + return true + } + for _, pattern := range ignores { + if matched, _ := filepath.Match(pattern, name); matched { + return true + } + } + return false +} + +func isTestDir(rel string) bool { + slashPath := filepath.ToSlash(rel) + return strings.Contains(slashPath, "/tests/") || strings.HasPrefix(slashPath, "tests/") +} + +func fileAnnotation(rel, name string, cfg *config.Config) (string, bool) { + if strings.HasPrefix(name, ".") { + return "", false + } + for _, pattern := range cfg.Ignores { + if matched, _ := filepath.Match(pattern, name); matched { + return "ignored (--ignore)", true + } + } + if cfg.OmitTest && isTestFileName(rel, name) { + return "tests omitted (--omit-test)", true + } + if cfg.OmitMd && isNonReadmeMarkdown(name) { + return "omitted (--omit-md)", true + } + if cfg.FrontendOnly && !isFrontendOrReadme(rel, name) { + return "omitted (--frontend-only)", true + } + if cfg.SkipFrontend && isFrontendExt(rel) { + return "omitted (--skip-frontend)", true + } + return "", false +} + +func isTestFileName(rel, name string) bool { + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".go": + return strings.HasSuffix(strings.ToLower(name), "_test.go") + case ".java": + base := strings.TrimSuffix(name, filepath.Ext(name)) + if strings.HasSuffix(base, "Test") || strings.HasSuffix(base, "Tests") { + return true + } + slashPath := filepath.ToSlash(rel) + return strings.Contains(slashPath, "/src/test/") + case ".py": + lower := strings.ToLower(name) + if strings.HasPrefix(lower, "test_") || strings.HasSuffix(lower, "_test.py") { + return true + } + if strings.EqualFold(name, "conftest.py") { + return true + } + return isTestDir(rel) + } + return false +} + +func isNonReadmeMarkdown(name string) bool { + ext := strings.ToLower(filepath.Ext(name)) + if ext != ".md" && ext != ".markdown" { + return false + } + return !strings.EqualFold(name, "README.md") +} + +func isFrontendOrReadme(rel, name string) bool { + if isFrontendExt(rel) { + return true + } + return strings.EqualFold(name, "README.md") +} + +func isFrontendExt(rel string) bool { + ext := strings.ToLower(filepath.Ext(rel)) + for _, fe := range frontendExts { + if strings.EqualFold(ext, fe) { + return true + } + } + return false +} + +var frontendExts = []string{ + ".html", ".htm", ".gohtml", ".tmpl", + ".css", ".scss", ".sass", ".less", + ".js", ".jsx", ".mjs", ".cjs", + ".ts", ".tsx", + ".vue", ".svelte", +} diff --git a/internal/walker/walker.go b/internal/walker/walker.go index c44e8f9..af4c0a9 100644 --- a/internal/walker/walker.go +++ b/internal/walker/walker.go @@ -23,6 +23,7 @@ type Result struct { AbsRoot string SingleFile bool OmitTest bool + Cfg *config.Config Commit *git.CommitInfo Instructions string } @@ -67,7 +68,7 @@ func processSingleFile(cfg *config.Config) (*Result, error) { Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}}, AbsRoot: filepath.Dir(cfg.AbsRoot), SingleFile: true, - OmitTest: cfg.OmitTest, + Cfg: cfg, Commit: commitInfo, Instructions: instContent, }, nil @@ -83,10 +84,10 @@ func walkDirectory(cfg *config.Config) (*Result, error) { OmitMd: cfg.OmitMd, } - result := &Result{AbsRoot: cfg.AbsRoot, OmitTest: cfg.OmitTest} - + result := &Result{AbsRoot: cfg.AbsRoot, Cfg: cfg, OmitTest: cfg.OmitTest} + result.Commit = git.GetCommitInfo(cfg.AbsRoot) - + if cfg.IncludeInstructions { instContent, err := instructions.Read(cfg.AbsRoot) if err != nil { -- 2.55.0 From 35c61baa8d70bc8063945c79ebf66fa9acff23ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartal=20L=C3=A6arsson?= Date: Tue, 15 Sep 2026 11:22:44 +0100 Subject: [PATCH 3/9] flags provenance block in index and slices --- internal/renderer/flags.go | 75 +++++++++++++++++++++++++++++++++++ internal/renderer/renderer.go | 31 +++++++++------ 2 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 internal/renderer/flags.go diff --git a/internal/renderer/flags.go b/internal/renderer/flags.go new file mode 100644 index 0000000..1e4565f --- /dev/null +++ b/internal/renderer/flags.go @@ -0,0 +1,75 @@ +package renderer + +import ( + "fmt" + "slices" + "strings" + + "git.flo.fo/FLO/dirmd/internal/config" +) + +func FormatFlags(cfg *config.Config) string { + if cfg == nil { + return "" + } + + var flags []string + if cfg.OmitTest { + flags = append(flags, "--omit-test") + } + if cfg.OmitMd { + flags = append(flags, "--omit-md") + } + if cfg.SkipFrontend { + flags = append(flags, "--skip-frontend") + } + if cfg.FrontendOnly { + flags = append(flags, "--frontend-only") + } + if cfg.MaxDepth != config.DefaultMaxDepth { + flags = append(flags, fmt.Sprintf("--max-depth %d", cfg.MaxDepth)) + } + if cfg.MaxSize != config.DefaultMaxSize { + flags = append(flags, fmt.Sprintf("--max-size %d", cfg.MaxSize)) + } + + var sb strings.Builder + + if len(flags) > 0 { + fmt.Fprintf(&sb, "> Flags: %s\n", strings.Join(flags, " ")) + } + + extraIgnores := setMinus(cfg.Ignores, config.DefaultIgnores) + if len(extraIgnores) > 0 { + fmt.Fprintf(&sb, "> Ignored: %s\n", strings.Join(extraIgnores, ", ")) + } + + var extraExts []string + for _, e := range cfg.Extensions { + if slices.Contains(config.DefaultExts, e) || slices.Contains(config.FrontendExts, e) { + continue + } + extraExts = append(extraExts, e) + } + if len(extraExts) > 0 { + fmt.Fprintf(&sb, "> Skipped extensions: %s\n", strings.Join(extraExts, ", ")) + } + + if sb.Len() > 0 { + sb.WriteString("\n") + } + return sb.String() +} + +func setMinus(haystack, defaults []string) []string { + seen := make(map[string]bool) + var extra []string + for _, h := range haystack { + if slices.Contains(defaults, h) || seen[h] { + continue + } + seen[h] = true + extra = append(extra, h) + } + return extra +} diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index 970c2f6..babb9c4 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -9,6 +9,7 @@ import ( "time" "unicode/utf8" + "git.flo.fo/FLO/dirmd/internal/config" "git.flo.fo/FLO/dirmd/internal/git" "git.flo.fo/FLO/dirmd/internal/instructions" "git.flo.fo/FLO/dirmd/internal/tree" @@ -49,6 +50,8 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile } else { sb.WriteString("\n") } + + sb.WriteString(FormatFlags(result.Cfg)) } if !singleFile { @@ -143,7 +146,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e rootPath := filepath.Join(outputDir, indexBasename) generated = append(generated, rootPath) - if err := writeRootSlice(rootPath, absRoot, rootName, repoName, rootFiles, topLevelDirs, result.OmitTest, result.Commit, result.Instructions, treeRoot); err != nil { + if err := writeRootSlice(rootPath, absRoot, rootName, repoName, rootFiles, topLevelDirs, result.OmitTest, result.Commit, result.Instructions, treeRoot, result.Cfg); err != nil { return nil, fmt.Errorf("failed to write root slice: %w", err) } @@ -159,7 +162,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e absDir := filepath.Join(absRoot, dirName) dirEntries := topLevelDirs[dirName] - if err := writeDirSlice(slicePath, absDir, absRoot, dirName, dirEntries, indexBasename, result.OmitTest, result.Commit, treeRoot); err != nil { + if err := writeDirSlice(slicePath, absDir, absRoot, dirName, dirEntries, indexBasename, result.OmitTest, result.Commit, treeRoot, result.Cfg); err != nil { return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err) } } @@ -167,7 +170,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e return generated, nil } -func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry, omitTest bool, commit *git.CommitInfo, instContent string, treeRoot *tree.Node) error { +func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry, omitTest bool, commit *git.CommitInfo, instContent string, treeRoot *tree.Node, cfg *config.Config) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("cannot create root file: %w", err) @@ -183,6 +186,7 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker } else { sb.WriteString("\n") } + sb.WriteString(FormatFlags(cfg)) sb.WriteString("## Directory Tree\n\n") sb.WriteString(fenceChars + "\n") @@ -195,13 +199,7 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker sb.WriteString("| Slice File | Directory | Files | Total Bytes |\n") sb.WriteString("|------------|-----------|-------|-------------|\n") - var dirNames []string - for k := range topLevelDirs { - dirNames = append(dirNames, k) - } - sort.Strings(dirNames) - - for _, dirName := range dirNames { + for _, dirName := range sortedKeys(topLevelDirs) { entries := topLevelDirs[dirName] fileCount := len(entries) totalBytes := int64(0) @@ -242,7 +240,7 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker return err } -func writeDirSlice(path, absDir, absRoot, dirName string, fileEntries []walker.Entry, rootFileName string, omitTest bool, commit *git.CommitInfo, treeRoot *tree.Node) error { +func writeDirSlice(path, absDir, absRoot, dirName string, fileEntries []walker.Entry, rootFileName string, omitTest bool, commit *git.CommitInfo, treeRoot *tree.Node, cfg *config.Config) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("cannot create slice file: %w", err) @@ -253,7 +251,7 @@ func writeDirSlice(path, absDir, absRoot, dirName string, fileEntries []walker.E fmt.Fprintf(&sb, "# %s [SLICE]\n", absDir) fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339)) sb.WriteString(git.FormatCommitLine(commit)) - sb.WriteString("\n") + sb.WriteString(FormatFlags(cfg)) fmt.Fprintf(&sb, "Root document: %s\n\n", rootFileName) sb.WriteString("## Directory Tree\n\n") @@ -290,6 +288,15 @@ func writeDirSlice(path, absDir, absRoot, dirName string, fileEntries []walker.E return err } +func sortedKeys(m map[string][]walker.Entry) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + func renderTreeWithLegend(root *tree.Node, rootName string) string { var sb strings.Builder sb.WriteString(rootName) -- 2.55.0 From c72c0d0b79340148f27f803fc10f1d2d67a52a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartal=20L=C3=A6arsson?= Date: Tue, 15 Sep 2026 11:27:53 +0100 Subject: [PATCH 4/9] thin index + root files --- internal/renderer/renderer.go | 89 +++++++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 26 deletions(-) diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index babb9c4..c423b32 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -146,7 +146,15 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e rootPath := filepath.Join(outputDir, indexBasename) generated = append(generated, rootPath) - if err := writeRootSlice(rootPath, absRoot, rootName, repoName, rootFiles, topLevelDirs, result.OmitTest, result.Commit, result.Instructions, treeRoot, result.Cfg); err != nil { + rootSliceBasename := "root_" + repoName + ".md" + rootSlicePath := filepath.Join(outputDir, rootSliceBasename) + generated = append(generated, rootSlicePath) + + if err := writeRootIndex(rootPath, absRoot, rootName, repoName, topLevelDirs, result.Commit, result.Instructions, treeRoot, result.Cfg); err != nil { + return nil, fmt.Errorf("failed to write root index: %w", err) + } + + if err := writeRootSliceFile(rootSlicePath, absRoot, rootName, rootFiles, result.OmitTest, result.Commit, result.Cfg); err != nil { return nil, fmt.Errorf("failed to write root slice: %w", err) } @@ -170,10 +178,10 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e return generated, nil } -func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry, omitTest bool, commit *git.CommitInfo, instContent string, treeRoot *tree.Node, cfg *config.Config) error { +func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[string][]walker.Entry, commit *git.CommitInfo, instContent string, treeRoot *tree.Node, cfg *config.Config) error { f, err := os.Create(path) if err != nil { - return fmt.Errorf("cannot create root file: %w", err) + return fmt.Errorf("cannot create root index: %w", err) } defer f.Close() @@ -199,6 +207,13 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker sb.WriteString("| Slice File | Directory | Files | Total Bytes |\n") sb.WriteString("|------------|-----------|-------|-------------|\n") + // First row: root slice + rootSliceFile := "root_" + repoName + ".md" + rootFileCount := 0 + rootTotalBytes := int64(0) + // We'll calculate this in the caller, for now placeholder + fmt.Fprintf(&sb, "| %s | (root) | %d | %d |\n", rootSliceFile, rootFileCount, rootTotalBytes) + for _, dirName := range sortedKeys(topLevelDirs) { entries := topLevelDirs[dirName] fileCount := len(entries) @@ -211,29 +226,51 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker } sb.WriteString("\n") - if len(rootFiles) > 0 { - sb.WriteString("## Root Contents\n\n") - sort.Slice(rootFiles, func(i, j int) bool { return rootFiles[i].RelPath < rootFiles[j].RelPath }) - for _, entry := range rootFiles { - fullPath := filepath.Join(absRoot, entry.RelPath) - content, err := readFileForOutput(fullPath, omitTest) - if err != nil { - fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err) - continue - } - language := extToLang(filepath.Ext(entry.RelPath)) - lines := countLines(content) - fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines) - fmt.Fprintf(&sb, "%s", fenceChars) - if language != "" { - fmt.Fprintf(&sb, "%s", language) - } - fmt.Fprintf(&sb, "\n%s", content) - if !strings.HasSuffix(content, "\n") { - fmt.Fprintf(&sb, "\n") - } - fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars) + _, err = f.WriteString(sb.String()) + return err +} + +func writeRootSliceFile(path, absRoot, rootName string, rootFiles []walker.Entry, omitTest bool, commit *git.CommitInfo, cfg *config.Config) error { + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("cannot create root slice file: %w", err) + } + defer f.Close() + + var sb strings.Builder + fmt.Fprintf(&sb, "# %s [ROOT]\n", absRoot) + fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339)) + sb.WriteString(git.FormatCommitLine(commit)) + sb.WriteString(FormatFlags(cfg)) + + sb.WriteString("Root contents: all files at the repository root level.\n\n") + + if len(rootFiles) == 0 { + sb.WriteString("(none)\n") + _, err = f.WriteString(sb.String()) + return err + } + + sort.Slice(rootFiles, func(i, j int) bool { return rootFiles[i].RelPath < rootFiles[j].RelPath }) + for _, entry := range rootFiles { + fullPath := filepath.Join(absRoot, entry.RelPath) + content, err := readFileForOutput(fullPath, omitTest) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err) + continue } + language := extToLang(filepath.Ext(entry.RelPath)) + lines := countLines(content) + fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines) + fmt.Fprintf(&sb, "%s", fenceChars) + if language != "" { + fmt.Fprintf(&sb, "%s", language) + } + fmt.Fprintf(&sb, "\n%s", content) + if !strings.HasSuffix(content, "\n") { + fmt.Fprintf(&sb, "\n") + } + fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars) } _, err = f.WriteString(sb.String()) @@ -256,7 +293,7 @@ func writeDirSlice(path, absDir, absRoot, dirName string, fileEntries []walker.E sb.WriteString("## Directory Tree\n\n") sb.WriteString(fenceChars + "\n") - sb.WriteString(renderTreeWithLegend(treeRoot, filepath.Base(absRoot))) + sb.WriteString(renderTreeWithLegend(treeRoot, filepath.Base(absDir))) sb.WriteString(fenceChars + "\n\n") if len(fileEntries) > 0 { -- 2.55.0 From b63f0ba927c52e6b7266ed0b04439fc81dd5c704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartal=20L=C3=A6arsson?= Date: Tue, 15 Sep 2026 11:42:41 +0100 Subject: [PATCH 5/9] --pick flag --- cmd/root.go | 15 +++- internal/config/config.go | 13 ++- internal/renderer/renderer.go | 158 ++++++++++++++++++++++++++++++++-- internal/tree/unfiltered.go | 19 ++++ 4 files changed, 198 insertions(+), 7 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index a9cdc1d..154c179 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -46,7 +46,10 @@ Examples: dirmd -o ./docs ~/repos/myproject --proton-drive # Interactive TUI mode - dirmd --tui`, + dirmd --tui + + # Pick specific files in vertical slices mode + dirmd -o ./docs ~/repos/myproject --vertical-slices --pick common/src/lib.rs --pick mssql-extractor/src/processor.rs`, RunE: run, } @@ -67,6 +70,8 @@ Examples: omitMd bool tuiMode bool instructions bool + pickPaths []string + smallTree bool ) func Execute() { @@ -93,6 +98,8 @@ func init() { rootCmd.Flags().BoolVar(&omitTest, "omit-test", false, "exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust") rootCmd.Flags().BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md") rootCmd.Flags().BoolVar(&instructions, "instructions", false, "include .dirmd instructions file if present in root") + rootCmd.Flags().StringArrayVar(&pickPaths, "pick", nil, "repeatable: relative path to file to include as pick (requires --vertical-slices)") + rootCmd.Flags().BoolVar(&smallTree, "small-tree", false, "use breadcrumb-only tree for picked files (default: full tree)") } func run(cmd *cobra.Command, args []string) error { @@ -120,6 +127,10 @@ func run(cmd *cobra.Command, args []string) error { return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive") } + if len(pickPaths) > 0 && !verticalSlices { + return fmt.Errorf("--pick requires --vertical-slices") + } + excludedExts := append(config.DefaultExts, extensions...) if skipFrontend { excludedExts = append(excludedExts, config.FrontendExts...) @@ -141,6 +152,8 @@ func run(cmd *cobra.Command, args []string) error { OmitTest: omitTest, OmitMd: omitMd, IncludeInstructions: instructions, + PickPaths: pickPaths, + SmallTree: smallTree, } if singleFile != "" { diff --git a/internal/config/config.go b/internal/config/config.go index 5dae068..dda3337 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,7 +11,7 @@ import ( const ( DefaultMaxSize = 512 * 1024 DefaultMaxDepth = 20 - Version = "0.1.0" + Version = "0.1.4" ) var DefaultIgnores = []string{ @@ -65,6 +65,8 @@ type Config struct { OmitTest bool OmitMd bool IncludeInstructions bool + PickPaths []string + SmallTree bool } func PrintUsage() { @@ -94,6 +96,8 @@ func Parse() (*Config, error) { omitTest bool omitMd bool includeInstructions bool + pickPaths stringSlice + smallTree bool ) flag.StringVar(&input, "i", "", "single file input (append mode only)") @@ -113,6 +117,8 @@ func Parse() (*Config, error) { flag.BoolVar(&omitTest, "omit-test", false, "exclude test files and strip #[cfg(test)] blocks") flag.BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md") flag.BoolVar(&includeInstructions, "instructions", false, "include .dirmd instructions file if present in root") + flag.Var(&pickPaths, "pick", "repeatable: relative path to file to include as pick (requires --vertical-slices)") + flag.BoolVar(&smallTree, "small-tree", false, "use breadcrumb-only tree for picked files (default: full tree)") flag.Usage = PrintUsage flag.Parse() @@ -137,6 +143,9 @@ func Parse() (*Config, error) { if skipFrontend && frontendOnly { return nil, fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive") } + if len(pickPaths) > 0 && !verticalSlices { + return nil, fmt.Errorf("--pick requires --vertical-slices") + } excludedExts := append(DefaultExts, exts...) if skipFrontend { @@ -159,6 +168,8 @@ func Parse() (*Config, error) { OmitTest: omitTest, OmitMd: omitMd, IncludeInstructions: includeInstructions, + PickPaths: pickPaths, + SmallTree: smallTree, } if input != "" { diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index c423b32..27dfda2 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -10,6 +10,7 @@ import ( "unicode/utf8" "git.flo.fo/FLO/dirmd/internal/config" + "git.flo.fo/FLO/dirmd/internal/filter" "git.flo.fo/FLO/dirmd/internal/git" "git.flo.fo/FLO/dirmd/internal/instructions" "git.flo.fo/FLO/dirmd/internal/tree" @@ -150,7 +151,17 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e rootSlicePath := filepath.Join(outputDir, rootSliceBasename) generated = append(generated, rootSlicePath) - if err := writeRootIndex(rootPath, absRoot, rootName, repoName, topLevelDirs, result.Commit, result.Instructions, treeRoot, result.Cfg); err != nil { + pickInfos := make([]PickInfo, 0) + + if len(result.Cfg.PickPaths) > 0 { + pickInfos, err = writePickFiles(absRoot, absRoot, outputDir, result.Cfg, result.OmitTest, result.Commit, treeRoot, result.Cfg.SmallTree) + if err != nil { + return nil, fmt.Errorf("failed to write pick files: %w", err) + } + generated = append(generated, pickInfoPaths(pickInfos)...) + } + + if err := writeRootIndex(rootPath, absRoot, rootName, repoName, topLevelDirs, result.Commit, result.Instructions, treeRoot, result.Cfg, pickInfos); err != nil { return nil, fmt.Errorf("failed to write root index: %w", err) } @@ -178,7 +189,137 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e return generated, nil } -func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[string][]walker.Entry, commit *git.CommitInfo, instContent string, treeRoot *tree.Node, cfg *config.Config) error { +type PickInfo struct { + RelPath string + FileType string + SliceDir string +} + +func pickInfoPaths(picks []PickInfo) []string { + var paths []string + for _, p := range picks { + paths = append(paths, filepath.Join(p.SliceDir, p.FileType)) + } + return paths +} + +func writePickFiles(absRoot, baseRoot, outputDir string, cfg *config.Config, omitTest bool, commit *git.CommitInfo, treeRoot *tree.Node, smallTree bool) ([]PickInfo, error) { + var pickInfos []PickInfo + + for _, pickPath := range cfg.PickPaths { + fullPath := filepath.Join(absRoot, pickPath) + + info, err := os.Stat(fullPath) + if err != nil { + return nil, fmt.Errorf("cannot access pick path %s: %w", pickPath, err) + } + + if info.IsDir() { + return nil, fmt.Errorf("pick path must be a file: %s", pickPath) + } + + // Validate file passes filters + name := filepath.Base(pickPath) + filter := &filter.Filter{ + MaxSize: cfg.MaxSize, + Extensions: cfg.Extensions, + Ignores: cfg.Ignores, + OmitTest: cfg.OmitTest, + FrontendOnly: cfg.FrontendOnly, + OmitMd: cfg.OmitMd, + } + + if filter.ShouldSkipFile(fullPath, name, info) { + return nil, fmt.Errorf("pick path %s filtered by active rules", pickPath) + } + + // Determine slice dir from first path segment + parts := strings.Split(pickPath, string(filepath.Separator)) + sliceDir := outputDir + if len(parts) > 1 { + sliceDir = filepath.Join(outputDir, parts[0]) + if err := os.MkdirAll(sliceDir, 0755); err != nil { + return nil, fmt.Errorf("cannot create pick slice directory %s: %w", sliceDir, err) + } + } else { + // Root-level pick + sliceDir = outputDir + } + + sliceName := filepath.Base(pickPath) + ".md" + pickPathOut := filepath.Join(sliceDir, sliceName) + + if err := writePickFile(pickPathOut, absRoot, pickPath, omitTest, commit, cfg, treeRoot, smallTree); err != nil { + return nil, fmt.Errorf("failed to write pick %s: %w", pickPath, err) + } + + pickInfos = append(pickInfos, PickInfo{ + RelPath: pickPath, + FileType: sliceName, + SliceDir: filepath.Base(sliceDir), + }) + } + + return pickInfos, nil +} + +func writePickFile(path, absRoot, relPath string, omitTest bool, commit *git.CommitInfo, cfg *config.Config, treeRoot *tree.Node, smallTree bool) error { + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("cannot create pick file: %w", err) + } + defer f.Close() + + var sb strings.Builder + + fmt.Fprintf(&sb, "# %s [SLICE]\n", filepath.Join(absRoot, relPath)) + fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339)) + sb.WriteString(git.FormatCommitLine(commit)) + sb.WriteString(FormatFlags(cfg)) + + fullPath := filepath.Join(absRoot, relPath) + content, err := readFileForOutput(fullPath, omitTest) + if err != nil { + return fmt.Errorf("cannot read pick file %s: %w", relPath, err) + } + language := extToLang(filepath.Ext(relPath)) + lines := countLines(content) + + if smallTree { + breadcrumb := tree.RenderBreadcrumb(absRoot, relPath) + fmt.Fprintf(&sb, "Tree: %s\n\n", breadcrumb) + } else { + sb.WriteString("## Directory Tree\n\n") + sb.WriteString(fenceChars + "\n") + sb.WriteString(renderTreeWithLegend(treeRoot, filepath.Base(absRoot))) + sb.WriteString(fenceChars + "\n\n") + } + + fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", relPath, infoSize(fullPath), lines) + sb.WriteString(fenceChars) + if language != "" { + sb.WriteString(language) + } + sb.WriteString("\n") + sb.WriteString(content) + if !strings.HasSuffix(content, "\n") { + sb.WriteString("\n") + } + sb.WriteString(fmt.Sprintf("%s\n--- END FILE ---\n\n", fenceChars)) + + _, err = f.WriteString(sb.String()) + return err +} + +func infoSize(path string) int64 { + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.Size() +} + +func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[string][]walker.Entry, commit *git.CommitInfo, instContent string, treeRoot *tree.Node, cfg *config.Config, picks []PickInfo) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("cannot create root index: %w", err) @@ -207,11 +348,9 @@ func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[s sb.WriteString("| Slice File | Directory | Files | Total Bytes |\n") sb.WriteString("|------------|-----------|-------|-------------|\n") - // First row: root slice rootSliceFile := "root_" + repoName + ".md" - rootFileCount := 0 + rootFileCount := len(topLevelDirs) rootTotalBytes := int64(0) - // We'll calculate this in the caller, for now placeholder fmt.Fprintf(&sb, "| %s | (root) | %d | %d |\n", rootSliceFile, rootFileCount, rootTotalBytes) for _, dirName := range sortedKeys(topLevelDirs) { @@ -226,6 +365,15 @@ func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[s } sb.WriteString("\n") + if len(picks) > 0 { + sb.WriteString("## Picks\n\n") + sb.WriteString("These files were explicitly selected via --pick flags.\n\n") + for _, p := range picks { + fmt.Fprintf(&sb, "- `%s` (%s)\n", p.RelPath, p.SliceDir) + } + sb.WriteString("\n") + } + _, err = f.WriteString(sb.String()) return err } diff --git a/internal/tree/unfiltered.go b/internal/tree/unfiltered.go index 255f4ce..1aeb02f 100644 --- a/internal/tree/unfiltered.go +++ b/internal/tree/unfiltered.go @@ -168,3 +168,22 @@ var frontendExts = []string{ ".ts", ".tsx", ".vue", ".svelte", } + +func RenderBreadcrumb(absRoot, relPath string) string { + var sb strings.Builder + rootName := filepath.Base(absRoot) + dir := filepath.Dir(relPath) + if dir == "." { + return rootName + "/" + filepath.Base(relPath) + } + + parts := strings.Split(filepath.ToSlash(dir), "/") + sb.WriteString(rootName) + for _, part := range parts { + sb.WriteString(" / ") + sb.WriteString(part) + } + sb.WriteString(" / ") + sb.WriteString(filepath.Base(relPath)) + return sb.String() +} -- 2.55.0 From 84bea485ecca6d9ab157c097971075fc2e788f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartal=20L=C3=A6arsson?= Date: Tue, 15 Sep 2026 11:43:08 +0100 Subject: [PATCH 6/9] add readme reconsiliation --- README.md | 91 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 70 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 7d2a889..ac022db 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,13 @@ Generate markdown documentation from directory structure. Optimized for LLM inge - Recursive directory traversal - ASCII tree visualization (LLM-friendly, minimal token overhead) +- Full tree in every generated file — navigate from anywhere, no matter how deep you are +- Filtered files and directories stay visible in the tree with annotations (`~ ignored`, `~ tests omitted`) +- Flags provenance block: every generated file records which filters were active - Full file contents with explicit delimiters and metadata - Filters dotfiles, binaries, and blacklisted extensions -- Vertical slices mode: root file + one markdown per top-level directory +- Vertical slices mode: index file + root slice + one markdown per top-level directory +- Pick mode: include specific individual files alongside the slices - Append mode for single files or directories - Force overwrite of existing output files - Configurable depth, file size limits, ignore patterns @@ -83,6 +87,14 @@ dirmd -o "/tmp/${REPO_NAME}" "$REPO_ROOT" -f --vertical-slices --proton-drive # Combine filters dirmd -o output.md ~/repos/myproject --frontend-only --omit-test + # Pick specific files alongside vertical slices + dirmd -o output.md ~/repos/myproject --vertical-slices \ + --pick common/src/config.rs --pick init/src/main.rs + + # Same, with a compact breadcrumb instead of the full tree in pick files + dirmd -o output.md ~/repos/myproject --vertical-slices \ + --pick common/src/config.rs --small-tree + # Generate and upload to Proton Drive (default: /my-files/md/) dirmd -o output.md ~/repos/myproject --proton-drive @@ -101,6 +113,8 @@ The `--omit-test` flag removes test files and inline test blocks to reduce token The Rust stripping works by detecting the `#[cfg(test)]` attribute, then tracking brace depth until the module closes. Nested braces in string literals may occasionally throw off the count, but this is acceptable for RAG use cases. +Omitted test files and directories remain visible in the directory tree with a `~ tests omitted (--omit-test)` annotation, so consumers of the snapshot know they exist and can request them. + ## Frontend Filtering Two complementary flags control frontend file inclusion: @@ -126,17 +140,35 @@ Frontend extensions covered: When `--vertical-slices` is passed, dirmd splits output into multiple files instead of one monolithic document: -- **Root file** (`-o output.md`): Full directory tree with cross-references to slice files, a vertical slices index table (file counts, total bytes per directory), and full contents of all root-level files. -- **Slice files** (`cmd.md`, `internal.md`, etc.): One per top-level directory, placed alongside the root file. Each contains its own tree, a back-reference to the root document, and full recursive file contents for that directory. +- **Index file** (`index_.md`): timestamp, commit, `.dirmd` instructions (if `--instructions`), flags block, full directory tree with annotations and cross-references to slice files, and the vertical slices index table. Deliberately thin — no file contents. This is the always-load document. +- **Root slice** (`root_.md`): contents of all root-level files (README, Cargo.toml, justfile, etc.). Selectable like any other slice. +- **Slice files** (`/_.md`): one per top-level directory, placed in a subdirectory named after it. Each carries the full project tree (not just its subtree), a back-reference to the root document, the flags block, and full recursive file contents for that directory. + +Every generated file carries the **full project tree**, so navigation works no matter which file is loaded. Directories excluded via `--ignore` appear as a single collapsed node annotated `~ ignored (--ignore)` — never recursed into — and files filtered by `--omit-test`, `--omit-md`, or frontend flags appear annotated instead of vanishing. The tree tells the consumer what exists but was left out. This mode cannot be combined with `--append` or `--input-file`. -Example output with `dirmd -o docs.md ~/repos/myproject --vertical-slices`: +Example output with `dirmd -o docs ~/repos/myproject --vertical-slices`: docs/ - ├── docs.md ← root: tree + slices index + root file contents - ├── cmd.md ← slice: tree + contents of cmd/ - └── internal.md ← slice: tree + contents of internal/ + ├── index_myproject.md ← index: metadata + tree + slices table + ├── root_myproject.md ← root files (README, etc.) + ├── cmd/ + │ ├── cmd_myproject.md ← slice: full tree + contents of cmd/ + │ └── main.rs.md ← pick file (only if --pick was used) + └── internal/ + └── internal_myproject.md + +## Pick Mode + +`--pick ` selects individual files for inclusion in a sliced run. Repeatable. Requires `--vertical-slices`. + +- Picks are written to `//.md` — e.g. `--pick common/src/config.rs` produces `common/config.rs.md` beside the `common` slice. Root-level picks land directly in the output directory. +- Every pick run regenerates the full snapshot (slices + picks); a pick run costs one full generation. This keeps the output directory a consistent snapshot of one run. +- Pick files carry the same metadata block (timestamp, commit, flags) as slices, plus the full project tree by default. With `--small-tree`, the tree is replaced by a breadcrumb line (`Tree: myproject / common / src / config.rs`). +- Pick files never carry `.dirmd` instructions — those live in the index only. +- Pick paths must be files, must exist, and must not be excluded by active filters (`--ignore`, `--omit-test`, extension skips). Violations fail the run. +- Picks are listed in a dedicated `## Picks` section in the index file. ## Instructions File (`.dirmd`) @@ -149,6 +181,7 @@ This is useful for injecting specific context, constraints, or instructions for - **Format**: Plain text or Markdown. - **Output**: Rendered as a quoted block (`> Instructions: ...`) immediately after the timestamp and commit info. - **Opt-in**: The file is **ignored** unless `--instructions` is explicitly passed. +- **Scoping**: In vertical slices mode, instructions appear **only in the index file** — never in slice or pick files. One authoritative copy. Example `.dirmd`: @@ -164,10 +197,12 @@ Output is designed for RAG ingestion and LLM context windows, not human presenta - **ISO 8601 Timestamp**: Added as a metadata line (`> Generated: ...`) immediately after the H1 header for every new file created. - **Git Commit Info**: If the source directory is a Git repository, a second metadata line (`> Commit: ()`) is added below the timestamp. +- **Flags provenance block**: `> Flags: --omit-test --skip-frontend` plus lines for extra ignore patterns and skipped extensions. Appears in every generated file so any file in isolation reveals how the snapshot was produced and what to assume exists but was excluded. +- **Annotated full tree**: same complete tree in every file, with filtered entries annotated (`~ ignored`, `~ tests omitted`) instead of silently dropped - **ASCII tree format** (`+-` instead of Unicode box-drawing) to reduce token overhead - **Explicit file delimiters**: `--- FILE: ./path (bytes, lines) ---` and `--- END FILE ---` - **Document type markers**: `[ROOT]` and `[SLICE]` in H1 headers -- **Cross-references**: Directory entries in root tree show `-> see cmd.md`; slice files reference their root document +- **Cross-references**: Directory entries in the tree show `-> see cmd/cmd_myproject.md`; slice and pick files reference their root document - **Slices index table**: File counts and total bytes per slice for informed retrieval decisions - **Tilde fences** (`~~~`) to avoid conflicts with backticks in source files @@ -179,7 +214,10 @@ Output is designed for RAG ingestion and LLM context windows, not human presenta | `-a`, `--append` | Append to existing output file | false | | `-f`, `--force` | Overwrite existing output file (non-append mode) | false | | `-i`, `--input-file` | Single file input (append mode only) | | -| `--vertical-slices` | Split output into root file + one markdown per top-level directory | false | +| `--vertical-slices` | Split output into index + root slice + one markdown per top-level directory | false | +| `--pick` | Relative path to a file to include as pick (repeatable, requires `--vertical-slices`) | | +| `--small-tree` | Use breadcrumb instead of full tree in pick files | false | +| `--instructions` | Include `.dirmd` instructions file if present in root (index file only) | false | | `--max-size` | Max file size in bytes | 524288 | | `--max-depth` | Max directory recursion depth | 20 | | `--ignore` | Additional ignore patterns (glob) | | @@ -189,12 +227,13 @@ Output is designed for RAG ingestion and LLM context windows, not human presenta | `--skip-frontend` | Omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.) | false | | `--frontend-only` | Include only frontend file types and README.md | false | | `--omit-test` | Exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust | false | +| `--omit-md` | Omit all .md files except README.md | false | ## Proton Drive Integration dirmd can optionally upload the generated markdown file(s) to Proton Drive using the [Proton Drive CLI](https://proton.me/support/drive-cli). The upload is performed only when `--proton-drive` is passed. Without it, dirmd behaves exactly as before — no Proton Drive dependency is required. -In vertical slices mode, all generated files (root + slices) are uploaded to `/my-files/md/`. The `--drive-path` flag is ignored in slices mode ; each file is uploaded by its basename. +In vertical slices mode, the entire output directory (index + root slice + slices + picks) is uploaded to `/my-files/md/`. The `--drive-path` flag is ignored in slices mode ; each file is uploaded by its basename. ### Prerequisites @@ -218,22 +257,27 @@ For full documentation on the Proton Drive CLI, including installation, authenti dirmd -o output.md ~/repos/project --extensions .log --extensions .tmp # Vertical slices with overwrite - dirmd -o docs.md ~/repos/project -f --vertical-slices + dirmd -o docs ~/repos/project -f --vertical-slices # Skip frontend and omit tests - dirmd -o docs.md ~/repos/project -f --skip-frontend --omit-test + dirmd -o docs ~/repos/project -f --skip-frontend --omit-test # Frontend only with test omission - dirmd -o docs.md ~/repos/project -f --frontend-only --omit-test + dirmd -o docs ~/repos/project -f --frontend-only --omit-test + + # Full snapshot workflow with picks + dirmd -o docs ~/repos/project -f --vertical-slices --instructions \ + --omit-test --skip-frontend \ + --pick common/src/config.rs --pick mssql-extractor/src/processor.rs # Generate, then upload to default remote location - dirmd -o docs.md ~/repos/project --proton-drive + dirmd -o output.md ~/repos/project --proton-drive # Generate, then upload to a custom remote location - dirmd -o docs.md ~/repos/project --proton-drive --drive-path /my-files/projects/docs.md + dirmd -o output.md ~/repos/project --proton-drive --drive-path /my-files/projects/docs.md # Overwrite existing local file and upload to Drive - dirmd -o docs.md ~/repos/project -f --proton-drive + dirmd -o output.md ~/repos/project -f --proton-drive ## Output Format @@ -241,17 +285,19 @@ Produces markdown with: 1. Absolute path as H1 header with `[ROOT]` or `[SLICE]` marker 2. **Timestamp metadata line** (ISO 8601) immediately after the H1 header (only for new files) -3. ASCII directory tree (indented with `+-` and `|` characters) -4. Vertical slices index table (sliced mode only) -5. All text files with content in fenced code blocks -6. Explicit `--- FILE: ---` and `--- END FILE ---` delimiters with byte and line counts -7. Language detection from file extensions +3. **Flags provenance block** (`> Flags: ...`, `> Ignored: ...`, `> Skipped extensions: ...`) when non-default filters are active +4. ASCII directory tree (indented with `+-` and `|` characters), full project tree with annotations for filtered entries, identical in every file of a run +5. Vertical slices index table and picks section (sliced mode only) +6. All text files with content in fenced code blocks +7. Explicit `--- FILE: ---` and `--- END FILE ---` delimiters with byte and line counts +8. Language detection from file extensions Example output structure (non-sliced mode): # /home/user/repos/myproject [ROOT] > Generated: 2026-09-02T14:30:00Z > Commit: a1b2c3d (Fix login bug) + > Flags: --omit-test ## Directory Tree @@ -261,7 +307,10 @@ Example output structure (non-sliced mode): | +- main.go +- internal/ | +- config.go + | +- config_test.go ~ tests omitted (--omit-test) + +- target/ ~ ignored (--ignore) ~~~ + Legend: ~ ignored | ~ tests omitted | ~ omitted | ~ exceeded --max-depth ## Contents -- 2.55.0 From c5dfbb35024d1e7e8737d90f4cc54fee799d7f89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartal=20L=C3=A6arsson?= Date: Tue, 15 Sep 2026 12:01:31 +0100 Subject: [PATCH 7/9] remove small-tree flag and only option to have bread crumbs in sub index files --- cmd/root.go | 3 --- internal/config/config.go | 4 --- internal/renderer/renderer.go | 46 ++++++++++++----------------------- 3 files changed, 16 insertions(+), 37 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 154c179..cf9112f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -71,7 +71,6 @@ Examples: tuiMode bool instructions bool pickPaths []string - smallTree bool ) func Execute() { @@ -99,7 +98,6 @@ func init() { rootCmd.Flags().BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md") rootCmd.Flags().BoolVar(&instructions, "instructions", false, "include .dirmd instructions file if present in root") rootCmd.Flags().StringArrayVar(&pickPaths, "pick", nil, "repeatable: relative path to file to include as pick (requires --vertical-slices)") - rootCmd.Flags().BoolVar(&smallTree, "small-tree", false, "use breadcrumb-only tree for picked files (default: full tree)") } func run(cmd *cobra.Command, args []string) error { @@ -153,7 +151,6 @@ func run(cmd *cobra.Command, args []string) error { OmitMd: omitMd, IncludeInstructions: instructions, PickPaths: pickPaths, - SmallTree: smallTree, } if singleFile != "" { diff --git a/internal/config/config.go b/internal/config/config.go index dda3337..e33e091 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,7 +66,6 @@ type Config struct { OmitMd bool IncludeInstructions bool PickPaths []string - SmallTree bool } func PrintUsage() { @@ -97,7 +96,6 @@ func Parse() (*Config, error) { omitMd bool includeInstructions bool pickPaths stringSlice - smallTree bool ) flag.StringVar(&input, "i", "", "single file input (append mode only)") @@ -118,7 +116,6 @@ func Parse() (*Config, error) { flag.BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md") flag.BoolVar(&includeInstructions, "instructions", false, "include .dirmd instructions file if present in root") flag.Var(&pickPaths, "pick", "repeatable: relative path to file to include as pick (requires --vertical-slices)") - flag.BoolVar(&smallTree, "small-tree", false, "use breadcrumb-only tree for picked files (default: full tree)") flag.Usage = PrintUsage flag.Parse() @@ -169,7 +166,6 @@ func Parse() (*Config, error) { OmitMd: omitMd, IncludeInstructions: includeInstructions, PickPaths: pickPaths, - SmallTree: smallTree, } if input != "" { diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index 27dfda2..99976be 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -154,7 +154,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e pickInfos := make([]PickInfo, 0) if len(result.Cfg.PickPaths) > 0 { - pickInfos, err = writePickFiles(absRoot, absRoot, outputDir, result.Cfg, result.OmitTest, result.Commit, treeRoot, result.Cfg.SmallTree) + pickInfos, err = writePickFiles(absRoot, outputDir, result.Cfg, result.OmitTest, result.Commit) if err != nil { return nil, fmt.Errorf("failed to write pick files: %w", err) } @@ -165,7 +165,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e return nil, fmt.Errorf("failed to write root index: %w", err) } - if err := writeRootSliceFile(rootSlicePath, absRoot, rootName, rootFiles, result.OmitTest, result.Commit, result.Cfg); err != nil { + if err := writeRootSliceFile(rootSlicePath, absRoot, rootFiles, result.OmitTest, result.Commit, result.Cfg); err != nil { return nil, fmt.Errorf("failed to write root slice: %w", err) } @@ -181,7 +181,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e absDir := filepath.Join(absRoot, dirName) dirEntries := topLevelDirs[dirName] - if err := writeDirSlice(slicePath, absDir, absRoot, dirName, dirEntries, indexBasename, result.OmitTest, result.Commit, treeRoot, result.Cfg); err != nil { + if err := writeDirSlice(slicePath, absDir, absRoot, dirName, dirEntries, indexBasename, result.OmitTest, result.Commit, result.Cfg); err != nil { return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err) } } @@ -203,7 +203,7 @@ func pickInfoPaths(picks []PickInfo) []string { return paths } -func writePickFiles(absRoot, baseRoot, outputDir string, cfg *config.Config, omitTest bool, commit *git.CommitInfo, treeRoot *tree.Node, smallTree bool) ([]PickInfo, error) { +func writePickFiles(absRoot, outputDir string, cfg *config.Config, omitTest bool, commit *git.CommitInfo) ([]PickInfo, error) { var pickInfos []PickInfo for _, pickPath := range cfg.PickPaths { @@ -218,9 +218,8 @@ func writePickFiles(absRoot, baseRoot, outputDir string, cfg *config.Config, omi return nil, fmt.Errorf("pick path must be a file: %s", pickPath) } - // Validate file passes filters name := filepath.Base(pickPath) - filter := &filter.Filter{ + flt := &filter.Filter{ MaxSize: cfg.MaxSize, Extensions: cfg.Extensions, Ignores: cfg.Ignores, @@ -229,11 +228,10 @@ func writePickFiles(absRoot, baseRoot, outputDir string, cfg *config.Config, omi OmitMd: cfg.OmitMd, } - if filter.ShouldSkipFile(fullPath, name, info) { + if flt.ShouldSkipFile(fullPath, name, info) { return nil, fmt.Errorf("pick path %s filtered by active rules", pickPath) } - // Determine slice dir from first path segment parts := strings.Split(pickPath, string(filepath.Separator)) sliceDir := outputDir if len(parts) > 1 { @@ -241,15 +239,12 @@ func writePickFiles(absRoot, baseRoot, outputDir string, cfg *config.Config, omi if err := os.MkdirAll(sliceDir, 0755); err != nil { return nil, fmt.Errorf("cannot create pick slice directory %s: %w", sliceDir, err) } - } else { - // Root-level pick - sliceDir = outputDir } sliceName := filepath.Base(pickPath) + ".md" pickPathOut := filepath.Join(sliceDir, sliceName) - if err := writePickFile(pickPathOut, absRoot, pickPath, omitTest, commit, cfg, treeRoot, smallTree); err != nil { + if err := writePickFile(pickPathOut, absRoot, pickPath, omitTest, commit, cfg); err != nil { return nil, fmt.Errorf("failed to write pick %s: %w", pickPath, err) } @@ -263,7 +258,7 @@ func writePickFiles(absRoot, baseRoot, outputDir string, cfg *config.Config, omi return pickInfos, nil } -func writePickFile(path, absRoot, relPath string, omitTest bool, commit *git.CommitInfo, cfg *config.Config, treeRoot *tree.Node, smallTree bool) error { +func writePickFile(path, absRoot, relPath string, omitTest bool, commit *git.CommitInfo, cfg *config.Config) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("cannot create pick file: %w", err) @@ -276,6 +271,7 @@ func writePickFile(path, absRoot, relPath string, omitTest bool, commit *git.Com fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339)) sb.WriteString(git.FormatCommitLine(commit)) sb.WriteString(FormatFlags(cfg)) + fmt.Fprintf(&sb, "Root document: index_%s.md\n\n", filepath.Base(absRoot)) fullPath := filepath.Join(absRoot, relPath) content, err := readFileForOutput(fullPath, omitTest) @@ -285,15 +281,7 @@ func writePickFile(path, absRoot, relPath string, omitTest bool, commit *git.Com language := extToLang(filepath.Ext(relPath)) lines := countLines(content) - if smallTree { - breadcrumb := tree.RenderBreadcrumb(absRoot, relPath) - fmt.Fprintf(&sb, "Tree: %s\n\n", breadcrumb) - } else { - sb.WriteString("## Directory Tree\n\n") - sb.WriteString(fenceChars + "\n") - sb.WriteString(renderTreeWithLegend(treeRoot, filepath.Base(absRoot))) - sb.WriteString(fenceChars + "\n\n") - } + fmt.Fprintf(&sb, "Tree: %s\n\n", tree.RenderBreadcrumb(absRoot, relPath)) fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", relPath, infoSize(fullPath), lines) sb.WriteString(fenceChars) @@ -305,7 +293,7 @@ func writePickFile(path, absRoot, relPath string, omitTest bool, commit *git.Com if !strings.HasSuffix(content, "\n") { sb.WriteString("\n") } - sb.WriteString(fmt.Sprintf("%s\n--- END FILE ---\n\n", fenceChars)) + fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars) _, err = f.WriteString(sb.String()) return err @@ -378,7 +366,7 @@ func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[s return err } -func writeRootSliceFile(path, absRoot, rootName string, rootFiles []walker.Entry, omitTest bool, commit *git.CommitInfo, cfg *config.Config) error { +func writeRootSliceFile(path, absRoot string, rootFiles []walker.Entry, omitTest bool, commit *git.CommitInfo, cfg *config.Config) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("cannot create root slice file: %w", err) @@ -391,7 +379,8 @@ func writeRootSliceFile(path, absRoot, rootName string, rootFiles []walker.Entry sb.WriteString(git.FormatCommitLine(commit)) sb.WriteString(FormatFlags(cfg)) - sb.WriteString("Root contents: all files at the repository root level.\n\n") + rootName := filepath.Base(absRoot) + fmt.Fprintf(&sb, "Tree: %s /\n\n", rootName) if len(rootFiles) == 0 { sb.WriteString("(none)\n") @@ -425,7 +414,7 @@ func writeRootSliceFile(path, absRoot, rootName string, rootFiles []walker.Entry return err } -func writeDirSlice(path, absDir, absRoot, dirName string, fileEntries []walker.Entry, rootFileName string, omitTest bool, commit *git.CommitInfo, treeRoot *tree.Node, cfg *config.Config) error { +func writeDirSlice(path, absDir, absRoot, dirName string, fileEntries []walker.Entry, rootFileName string, omitTest bool, commit *git.CommitInfo, cfg *config.Config) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("cannot create slice file: %w", err) @@ -439,10 +428,7 @@ func writeDirSlice(path, absDir, absRoot, dirName string, fileEntries []walker.E sb.WriteString(FormatFlags(cfg)) fmt.Fprintf(&sb, "Root document: %s\n\n", rootFileName) - sb.WriteString("## Directory Tree\n\n") - sb.WriteString(fenceChars + "\n") - sb.WriteString(renderTreeWithLegend(treeRoot, filepath.Base(absDir))) - sb.WriteString(fenceChars + "\n\n") + fmt.Fprintf(&sb, "Tree: %s / %s\n\n", filepath.Base(absRoot), dirName) if len(fileEntries) > 0 { sb.WriteString("## Contents\n\n") -- 2.55.0 From 9fa410e3a16d122fad69b635396fd8e4f9598f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartal=20L=C3=A6arsson?= Date: Tue, 15 Sep 2026 12:10:23 +0100 Subject: [PATCH 8/9] short readme --- README.md | 322 ++++-------------------------------------------------- 1 file changed, 19 insertions(+), 303 deletions(-) diff --git a/README.md b/README.md index ac022db..b47c5fa 100644 --- a/README.md +++ b/README.md @@ -4,322 +4,38 @@ Generate markdown documentation from directory structure. Optimized for LLM inge -## Features +## Install -- Recursive directory traversal -- ASCII tree visualization (LLM-friendly, minimal token overhead) -- Full tree in every generated file — navigate from anywhere, no matter how deep you are -- Filtered files and directories stay visible in the tree with annotations (`~ ignored`, `~ tests omitted`) -- Flags provenance block: every generated file records which filters were active -- Full file contents with explicit delimiters and metadata -- Filters dotfiles, binaries, and blacklisted extensions -- Vertical slices mode: index file + root slice + one markdown per top-level directory -- Pick mode: include specific individual files alongside the slices -- Append mode for single files or directories -- Force overwrite of existing output files -- Configurable depth, file size limits, ignore patterns -- Optional upload to Proton Drive after generating output -- Skip frontend files (`.html`, `.css`, `.js`, etc.) -- Frontend only mode (include only frontend files and `README.md`) -- Omit test files and inline test blocks (Go, Java, Rust) + go install git.flo.fo/FLO/dirmd@latest -## Installation -Only for linux. -Download binary from release page or install with go -```bash -go install git.flo.fo/FLO/dirmd@latest -``` +Linux only. Run `dirmd -h` for all flags. -## Use with Git Post Hook -combine with git post hook to sync with proton drive on each commit +## Why -add something like this in ./.git/hooks/post-commit +LLMs don't need 40k tokens of preamble. dirmd dumps a repo as markdown: tree + file contents, minus the noise. Everything filtered out stays visible in the tree (`~ tests omitted`, `~ ignored`) so nothing vanishes silently — the model knows what exists and can ask for it. -```bash -#!/bin/bash +## Three ways to use it -set -euo pipefail +**One file — small repos.** +For small projects that fit in a context window comfortably: -if ! command -v dirmd &>/dev/null; then - exit 0 -fi + dirmd -o out.md ~/repos/myproject -REPO_ROOT=$(git rev-parse --show-toplevel) -REPO_NAME=$(basename "$REPO_ROOT") +Everything lands in a single markdown file. Simplest, works fine until it doesn't. -dirmd -o "/tmp/${REPO_NAME}" "$REPO_ROOT" -f --vertical-slices --proton-drive -``` +**Vertical slices — large repos.** +Once a single file gets too big to paste repeatedly, split it: + dirmd -o docs ~/repos/myproject --vertical-slices -## Build +This produces an index file (metadata + full tree + slice table) and one file per top-level directory. The full tree exists only in the index; every other file carries a one-line breadcrumb pointing back to it. Workflow: paste the index, then paste only the slice you need. - go build -o dirmd . +**Slices + picks — you already know which files matter.** +Sometimes you need one specific file without its whole directory: -## Usage - - # Create new documentation - dirmd -o output.md ~/repos/myproject - - # Overwrite existing output file - dirmd -o output.md ~/repos/myproject -f - - # Append a single file - dirmd -a -i README.md -o existing.md - - # Append another directory - dirmd -a ~/repos/anotherproject -o existing.md - - # Generate with vertical slices - dirmd -o output.md ~/repos/myproject --vertical-slices - - # Skip frontend files (.html, .css, .js, etc.) - dirmd -o output.md ~/repos/myproject --skip-frontend - - # Frontend only (include only frontend files and README.md) - dirmd -o output.md ~/repos/myproject --frontend-only - - # Omit test files and inline test blocks - # Go: excludes *_test.go - # Java: excludes *Test.java, *Tests.java, src/test/java/* - # Rust: strips #[cfg(test)] mod tests { ... } blocks - dirmd -o output.md ~/repos/myproject --omit-test - - # Combine filters - dirmd -o output.md ~/repos/myproject --frontend-only --omit-test - - # Pick specific files alongside vertical slices - dirmd -o output.md ~/repos/myproject --vertical-slices \ + dirmd -o docs ~/repos/myproject --vertical-slices \ --pick common/src/config.rs --pick init/src/main.rs - # Same, with a compact breadcrumb instead of the full tree in pick files - dirmd -o output.md ~/repos/myproject --vertical-slices \ - --pick common/src/config.rs --small-tree +Picks are individual files written alongside the slices, in the same directory layout (`common/config.rs.md` next to `common/common_myproject.md`). Use this when you're mid-conversation, already know the relevant files, and want a single consistent snapshot containing exactly those. - # Generate and upload to Proton Drive (default: /my-files/md/) - dirmd -o output.md ~/repos/myproject --proton-drive - - # Generate and upload to a specific remote path - dirmd -o output.md ~/repos/myproject --proton-drive --drive-path /my-files/docs/api.md - -## Test File Omission - -The `--omit-test` flag removes test files and inline test blocks to reduce token overhead in LLM context windows. Behavior varies by language: - -| Language | Exclusion Strategy | What Gets Removed | -|----------|-------------------|-------------------| -| **Go** | File exclusion | All `*_test.go` files | -| **Java** | File exclusion | `*Test.java`, `*Tests.java`, files under `src/test/java/` | -| **Rust** | Content stripping | Entire `#[cfg(test)] mod tests { ... }` blocks removed from `.rs` files | - -The Rust stripping works by detecting the `#[cfg(test)]` attribute, then tracking brace depth until the module closes. Nested braces in string literals may occasionally throw off the count, but this is acceptable for RAG use cases. - -Omitted test files and directories remain visible in the directory tree with a `~ tests omitted (--omit-test)` annotation, so consumers of the snapshot know they exist and can request them. - -## Frontend Filtering - -Two complementary flags control frontend file inclusion: - -| Flag | Effect | -|------|--------| -| `--skip-frontend` | Exclude frontend files, keep everything else | -| `--frontend-only` | Include only frontend files and `README.md`, exclude everything else | - -Frontend extensions covered: - -| Type | Extensions | -|------|------------| -| Templates | `.html`, `.htm`, `.gohtml`, `.tmpl` | -| Stylesheets | `.css`, `.scss`, `.sass`, `.less` | -| JavaScript | `.js`, `.jsx`, `.mjs`, `.cjs` | -| TypeScript | `.ts`, `.tsx` | -| Frameworks | `.vue`, `.svelte` | - -`--skip-frontend` and `--frontend-only` are mutually exclusive. Both can be combined with `--omit-test`. - -## Vertical Slices Mode - -When `--vertical-slices` is passed, dirmd splits output into multiple files instead of one monolithic document: - -- **Index file** (`index_.md`): timestamp, commit, `.dirmd` instructions (if `--instructions`), flags block, full directory tree with annotations and cross-references to slice files, and the vertical slices index table. Deliberately thin — no file contents. This is the always-load document. -- **Root slice** (`root_.md`): contents of all root-level files (README, Cargo.toml, justfile, etc.). Selectable like any other slice. -- **Slice files** (`/_.md`): one per top-level directory, placed in a subdirectory named after it. Each carries the full project tree (not just its subtree), a back-reference to the root document, the flags block, and full recursive file contents for that directory. - -Every generated file carries the **full project tree**, so navigation works no matter which file is loaded. Directories excluded via `--ignore` appear as a single collapsed node annotated `~ ignored (--ignore)` — never recursed into — and files filtered by `--omit-test`, `--omit-md`, or frontend flags appear annotated instead of vanishing. The tree tells the consumer what exists but was left out. - -This mode cannot be combined with `--append` or `--input-file`. - -Example output with `dirmd -o docs ~/repos/myproject --vertical-slices`: - - docs/ - ├── index_myproject.md ← index: metadata + tree + slices table - ├── root_myproject.md ← root files (README, etc.) - ├── cmd/ - │ ├── cmd_myproject.md ← slice: full tree + contents of cmd/ - │ └── main.rs.md ← pick file (only if --pick was used) - └── internal/ - └── internal_myproject.md - -## Pick Mode - -`--pick ` selects individual files for inclusion in a sliced run. Repeatable. Requires `--vertical-slices`. - -- Picks are written to `//.md` — e.g. `--pick common/src/config.rs` produces `common/config.rs.md` beside the `common` slice. Root-level picks land directly in the output directory. -- Every pick run regenerates the full snapshot (slices + picks); a pick run costs one full generation. This keeps the output directory a consistent snapshot of one run. -- Pick files carry the same metadata block (timestamp, commit, flags) as slices, plus the full project tree by default. With `--small-tree`, the tree is replaced by a breadcrumb line (`Tree: myproject / common / src / config.rs`). -- Pick files never carry `.dirmd` instructions — those live in the index only. -- Pick paths must be files, must exist, and must not be excluded by active filters (`--ignore`, `--omit-test`, extension skips). Violations fail the run. -- Picks are listed in a dedicated `## Picks` section in the index file. - -## Instructions File (`.dirmd`) - -If a file named `.dirmd` exists in the root of the input directory, you can include its contents as metadata in the generated output by passing the `--instructions` flag. - -This is useful for injecting specific context, constraints, or instructions for LLMs processing the snapshot. - -- **File Name**: `.dirmd` -- **Location**: Root of the input directory. -- **Format**: Plain text or Markdown. -- **Output**: Rendered as a quoted block (`> Instructions: ...`) immediately after the timestamp and commit info. -- **Opt-in**: The file is **ignored** unless `--instructions` is explicitly passed. -- **Scoping**: In vertical slices mode, instructions appear **only in the index file** — never in slice or pick files. One authoritative copy. - -Example `.dirmd`: - -```text -Focus on the `internal/` package for business logic. -Ignore `cmd/` unless explicitly asked. -Prioritize Go files over Python. -``` - -## LLM-Optimized Output - -Output is designed for RAG ingestion and LLM context windows, not human presentation: - -- **ISO 8601 Timestamp**: Added as a metadata line (`> Generated: ...`) immediately after the H1 header for every new file created. -- **Git Commit Info**: If the source directory is a Git repository, a second metadata line (`> Commit: ()`) is added below the timestamp. -- **Flags provenance block**: `> Flags: --omit-test --skip-frontend` plus lines for extra ignore patterns and skipped extensions. Appears in every generated file so any file in isolation reveals how the snapshot was produced and what to assume exists but was excluded. -- **Annotated full tree**: same complete tree in every file, with filtered entries annotated (`~ ignored`, `~ tests omitted`) instead of silently dropped -- **ASCII tree format** (`+-` instead of Unicode box-drawing) to reduce token overhead -- **Explicit file delimiters**: `--- FILE: ./path (bytes, lines) ---` and `--- END FILE ---` -- **Document type markers**: `[ROOT]` and `[SLICE]` in H1 headers -- **Cross-references**: Directory entries in the tree show `-> see cmd/cmd_myproject.md`; slice and pick files reference their root document -- **Slices index table**: File counts and total bytes per slice for informed retrieval decisions -- **Tilde fences** (`~~~`) to avoid conflicts with backticks in source files - -## Flags - -| Flag | Description | Default | -|------|-------------|---------| -| `-o`, `--output` | Output markdown file path (required) | | -| `-a`, `--append` | Append to existing output file | false | -| `-f`, `--force` | Overwrite existing output file (non-append mode) | false | -| `-i`, `--input-file` | Single file input (append mode only) | | -| `--vertical-slices` | Split output into index + root slice + one markdown per top-level directory | false | -| `--pick` | Relative path to a file to include as pick (repeatable, requires `--vertical-slices`) | | -| `--small-tree` | Use breadcrumb instead of full tree in pick files | false | -| `--instructions` | Include `.dirmd` instructions file if present in root (index file only) | false | -| `--max-size` | Max file size in bytes | 524288 | -| `--max-depth` | Max directory recursion depth | 20 | -| `--ignore` | Additional ignore patterns (glob) | | -| `--extensions` | Additional file extensions to skip | | -| `--proton-drive` | Upload output to Proton Drive after writing locally | false | -| `--drive-path` | Full remote path on Proton Drive (e.g. /my-files/md/report.md) | /my-files/md/ | -| `--skip-frontend` | Omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.) | false | -| `--frontend-only` | Include only frontend file types and README.md | false | -| `--omit-test` | Exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust | false | -| `--omit-md` | Omit all .md files except README.md | false | - -## Proton Drive Integration - -dirmd can optionally upload the generated markdown file(s) to Proton Drive using the [Proton Drive CLI](https://proton.me/support/drive-cli). The upload is performed only when `--proton-drive` is passed. Without it, dirmd behaves exactly as before — no Proton Drive dependency is required. - -In vertical slices mode, the entire output directory (index + root slice + slices + picks) is uploaded to `/my-files/md/`. The `--drive-path` flag is ignored in slices mode ; each file is uploaded by its basename. - -### Prerequisites - -1. Install the Proton Drive CLI — see the [official guide](https://proton.me/support/drive-cli) for download and installation instructions. -2. Authenticate by running `proton-drive auth login`. -3. Ensure `proton-drive` is in your PATH. - -If the CLI is not installed or you are not logged in, dirmd will report the error to stderr and exit with a non-zero code. Local files are always written successfully before the upload is attempted. - -For full documentation on the Proton Drive CLI, including installation, authentication, and troubleshooting, refer to the [official Proton Drive CLI support page](https://proton.me/support/drive-cli). - -## Examples - - # Custom depth and size limit - dirmd -o output.md ~/repos/project --max-depth 5 --max-size 262144 - - # Ignore specific directories - dirmd -o output.md ~/repos/project --ignore __pycache__ --ignore .venv - - # Skip additional file extensions - dirmd -o output.md ~/repos/project --extensions .log --extensions .tmp - - # Vertical slices with overwrite - dirmd -o docs ~/repos/project -f --vertical-slices - - # Skip frontend and omit tests - dirmd -o docs ~/repos/project -f --skip-frontend --omit-test - - # Frontend only with test omission - dirmd -o docs ~/repos/project -f --frontend-only --omit-test - - # Full snapshot workflow with picks - dirmd -o docs ~/repos/project -f --vertical-slices --instructions \ - --omit-test --skip-frontend \ - --pick common/src/config.rs --pick mssql-extractor/src/processor.rs - - # Generate, then upload to default remote location - dirmd -o output.md ~/repos/project --proton-drive - - # Generate, then upload to a custom remote location - dirmd -o output.md ~/repos/project --proton-drive --drive-path /my-files/projects/docs.md - - # Overwrite existing local file and upload to Drive - dirmd -o output.md ~/repos/project -f --proton-drive - -## Output Format - -Produces markdown with: - -1. Absolute path as H1 header with `[ROOT]` or `[SLICE]` marker -2. **Timestamp metadata line** (ISO 8601) immediately after the H1 header (only for new files) -3. **Flags provenance block** (`> Flags: ...`, `> Ignored: ...`, `> Skipped extensions: ...`) when non-default filters are active -4. ASCII directory tree (indented with `+-` and `|` characters), full project tree with annotations for filtered entries, identical in every file of a run -5. Vertical slices index table and picks section (sliced mode only) -6. All text files with content in fenced code blocks -7. Explicit `--- FILE: ---` and `--- END FILE ---` delimiters with byte and line counts -8. Language detection from file extensions - -Example output structure (non-sliced mode): - - # /home/user/repos/myproject [ROOT] - > Generated: 2026-09-02T14:30:00Z - > Commit: a1b2c3d (Fix login bug) - > Flags: --omit-test - - ## Directory Tree - - ~~~ - myproject/ - +- cmd/ - | +- main.go - +- internal/ - | +- config.go - | +- config_test.go ~ tests omitted (--omit-test) - +- target/ ~ ignored (--ignore) - ~~~ - Legend: ~ ignored | ~ tests omitted | ~ omitted | ~ exceeded --max-depth - - ## Contents - - --- FILE: ./cmd/main.go (45 bytes, 3 lines) --- - ~~~go - package main - - func main() {} - ~~~ - --- END FILE --- - -Note: Uses tilde fences (~~~) internally to avoid conflicts with nested backticks in source files. +Rule of thumb: one file for tiny repos, slices when you need to browse, picks when you already know the answer. -- 2.55.0 From 9562be5e34686511f4ed13868e7fc1f2676c12cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartal=20L=C3=A6arsson?= Date: Tue, 15 Sep 2026 13:11:20 +0100 Subject: [PATCH 9/9] add test suite --- Taskfile.yml | 21 ++ cmd/root.go | 9 +- internal/config/config.go | 145 +-------- internal/config/config_test.go | 81 +++++ internal/drive/drive_test.go | 9 + internal/filter/filter.go | 31 +- internal/filter/filter_test.go | 269 ++++++++++++++++ internal/git/git_test.go | 48 +++ internal/instructions/instructions_test.go | 60 ++++ internal/renderer/renderer.go | 45 +-- internal/renderer/renderer_test.go | 202 ++++++++++++ internal/tree/tree_test.go | 90 ++++++ internal/tree/unfiltered.go | 101 ++---- internal/tui/run.go | 4 +- internal/tui/run_test.go | 27 ++ internal/walker/walker_test.go | 358 +++++++++++++++++++++ 16 files changed, 1233 insertions(+), 267 deletions(-) create mode 100644 Taskfile.yml create mode 100644 internal/config/config_test.go create mode 100644 internal/drive/drive_test.go create mode 100644 internal/filter/filter_test.go create mode 100644 internal/git/git_test.go create mode 100644 internal/instructions/instructions_test.go create mode 100644 internal/renderer/renderer_test.go create mode 100644 internal/tree/tree_test.go create mode 100644 internal/tui/run_test.go create mode 100644 internal/walker/walker_test.go diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..ec9d1a1 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,21 @@ +version: "3" + +tasks: + build: + desc: Build dirmd with injected semantic version (task build VERSION=v0.1.4) + deps: [vet] + preconditions: + - sh: printf '%s' '{{.VERSION}}' | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' + msg: "VERSION is required and must be a semantic tag like v0.1.4 — task build VERSION=v0.1.4" + cmds: + - go build -trimpath -ldflags "-s -w -X git.flo.fo/FLO/dirmd/internal/config.Version={{.VERSION}}" -o bin/dirmd . + + vet: + desc: Run go vet + cmds: + - go vet ./... + + test: + desc: Run all tests + cmds: + - go test ./... diff --git a/cmd/root.go b/cmd/root.go index cf9112f..63caa4c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -16,8 +16,9 @@ import ( var ( rootCmd = &cobra.Command{ - Use: "dirmd [flags] ", - Short: "Generate markdown documentation from directory structure", + Use: "dirmd [flags] ", + Short: "Generate markdown documentation from directory structure", + Version: config.Version, Long: `dirmd walks a directory and generates markdown files with: - Directory tree structure @@ -46,7 +47,7 @@ Examples: dirmd -o ./docs ~/repos/myproject --proton-drive # Interactive TUI mode - dirmd --tui + dirmd -i # Pick specific files in vertical slices mode dirmd -o ./docs ~/repos/myproject --vertical-slices --pick common/src/lib.rs --pick mssql-extractor/src/processor.rs`, @@ -106,7 +107,7 @@ func run(cmd *cobra.Command, args []string) error { } if outputPath == "" { - return fmt.Errorf("-o is required (or use --tui for interactive mode)") + return fmt.Errorf("-o is required (or use --interactive for interactive mode)") } if drivePath != "" && !protonDrive { diff --git a/internal/config/config.go b/internal/config/config.go index e33e091..947dba5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,19 +1,12 @@ package config -import ( - "flag" - "fmt" - "os" - "path/filepath" - "strings" -) - const ( DefaultMaxSize = 512 * 1024 DefaultMaxDepth = 20 - Version = "0.1.4" ) +var Version = "dev" + var DefaultIgnores = []string{ ".git", "node_modules", "vendor", "bin", "go.mod", "go.sum", "target", "build", "dist", "Cargo.lock", ".idea", ".vscode", @@ -41,11 +34,6 @@ var FrontendExts = []string{ ".vue", ".svelte", } -type stringSlice []string - -func (s *stringSlice) String() string { return strings.Join(*s, ", ") } -func (s *stringSlice) Set(v string) error { *s = append(*s, v); return nil } - type Config struct { InputPath string OutputPath string @@ -67,132 +55,3 @@ type Config struct { IncludeInstructions bool PickPaths []string } - -func PrintUsage() { - fmt.Fprintf(os.Stderr, "dirmd v%s\n\n", Version) - fmt.Fprintf(os.Stderr, "Usage: dirmd [flags] \n") - fmt.Fprintf(os.Stderr, " dirmd -a -i -o \n\n") - fmt.Fprintf(os.Stderr, "Flags:\n") - flag.PrintDefaults() -} - -func Parse() (*Config, error) { - var ( - input string - output string - appendMd bool - force bool - maxSize int64 - maxDepth int - ignores stringSlice - exts stringSlice - showVer bool - protonDrive bool - drivePath string - verticalSlices bool - skipFrontend bool - frontendOnly bool - omitTest bool - omitMd bool - includeInstructions bool - pickPaths stringSlice - ) - - flag.StringVar(&input, "i", "", "single file input (append mode only)") - flag.StringVar(&output, "o", "", "output markdown file path (required)") - flag.BoolVar(&appendMd, "a", false, "append to existing output file") - flag.BoolVar(&force, "f", false, "overwrite existing output file (non-append mode)") - flag.Int64Var(&maxSize, "max-size", DefaultMaxSize, "max file size in bytes") - flag.IntVar(&maxDepth, "max-depth", DefaultMaxDepth, "max directory recursion depth") - flag.Var(&ignores, "ignore", "additional ignore patterns (glob, repeatable)") - flag.Var(&exts, "extensions", "additional file extensions to skip (repeatable)") - flag.BoolVar(&showVer, "version", false, "print version and exit") - flag.BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally") - flag.StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (requires --proton-drive") - flag.BoolVar(&verticalSlices, "vertical-slices", false, "split output into root file + one markdown per top-level directory") - flag.BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types") - flag.BoolVar(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md") - flag.BoolVar(&omitTest, "omit-test", false, "exclude test files and strip #[cfg(test)] blocks") - flag.BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md") - flag.BoolVar(&includeInstructions, "instructions", false, "include .dirmd instructions file if present in root") - flag.Var(&pickPaths, "pick", "repeatable: relative path to file to include as pick (requires --vertical-slices)") - - flag.Usage = PrintUsage - flag.Parse() - - if showVer { - fmt.Printf("dirmd v%s\n", Version) - os.Exit(0) - } - - if output == "" { - return nil, fmt.Errorf("-o is required") - } - if drivePath != "" && !protonDrive { - return nil, fmt.Errorf("--drive-path requires --proton-drive") - } - if verticalSlices && appendMd { - return nil, fmt.Errorf("--vertical-slices cannot be used with --append") - } - if verticalSlices && input != "" { - return nil, fmt.Errorf("--vertical-slices cannot be used with --input-file") - } - if skipFrontend && frontendOnly { - return nil, fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive") - } - if len(pickPaths) > 0 && !verticalSlices { - return nil, fmt.Errorf("--pick requires --vertical-slices") - } - - excludedExts := append(DefaultExts, exts...) - if skipFrontend { - excludedExts = append(excludedExts, FrontendExts...) - } - - cfg := &Config{ - OutputPath: output, - AppendMode: appendMd, - Force: force, - MaxSize: maxSize, - MaxDepth: maxDepth, - Ignores: append(DefaultIgnores, ignores...), - Extensions: excludedExts, - ProtonDrive: protonDrive, - DrivePath: drivePath, - VerticalSlices: verticalSlices, - SkipFrontend: skipFrontend, - FrontendOnly: frontendOnly, - OmitTest: omitTest, - OmitMd: omitMd, - IncludeInstructions: includeInstructions, - PickPaths: pickPaths, - } - - if input != "" { - cfg.SingleFile = true - cfg.InputPath = input - if !appendMd { - return nil, fmt.Errorf("single file input (-i) requires append mode (-a)") - } - } else { - args := flag.Args() - if len(args) < 1 { - return nil, fmt.Errorf("directory argument is required when -i is not used") - } - cfg.InputPath = args[0] - } - - abs, err := filepath.Abs(cfg.InputPath) - if err != nil { - return nil, fmt.Errorf("failed to resolve absolute path: %w", err) - } - cfg.AbsRoot = abs - - if !appendMd { - if _, err := os.Stat(output); err == nil && !force { - return nil, fmt.Errorf("output file %s already exists (use -f to overwrite or -a to append)", output) - } - } - - return cfg, nil -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..fb5907d --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,81 @@ +package config + +import ( + "slices" + "testing" +) + +func TestVersion(t *testing.T) { + if Version == "" { + t.Error("Version is empty") + } +} + +func TestDefaultIgnores(t *testing.T) { + tests := []struct { + name string + want string + }{ + {"git", ".git"}, + {"node_modules", "node_modules"}, + {"vendor", "vendor"}, + {"target", "target"}, + {"pycache", "__pycache__"}, + {"lock_file", "uv.lock"}, + {"cargo_lock", "Cargo.lock"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !slices.Contains(DefaultIgnores, tt.want) { + t.Errorf("DefaultIgnores does not contain %q", tt.want) + } + }) + } +} + +func TestDefaultExts(t *testing.T) { + tests := []struct { + name string + want string + }{ + {"jpg", ".jpg"}, + {"png", ".png"}, + {"mp4", ".mp4"}, + {"zip", ".zip"}, + {"exe", ".exe"}, + {"pdf", ".pdf"}, + {"pem", ".pem"}, + {"db", ".db"}, + {"jar", ".jar"}, + {"pyc", ".pyc"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !slices.Contains(DefaultExts, tt.want) { + t.Errorf("DefaultExts does not contain %q", tt.want) + } + }) + } +} + +func TestFrontendExts(t *testing.T) { + tests := []struct { + name string + want string + }{ + {"html", ".html"}, + {"css", ".css"}, + {"js", ".js"}, + {"ts", ".ts"}, + {"vue", ".vue"}, + {"svelte", ".svelte"}, + {"gohtml", ".gohtml"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !slices.Contains(FrontendExts, tt.want) { + t.Errorf("FrontendExts does not contain %q", tt.want) + } + }) + } +} diff --git a/internal/drive/drive_test.go b/internal/drive/drive_test.go new file mode 100644 index 0000000..45a73dc --- /dev/null +++ b/internal/drive/drive_test.go @@ -0,0 +1,9 @@ +package drive + +import "testing" + +func TestBinaryName_constant(t *testing.T) { + if binaryName != "proton-drive" { + t.Errorf("binaryName = %q; want \"proton-drive\"", binaryName) + } +} diff --git a/internal/filter/filter.go b/internal/filter/filter.go index 13b7895..2156c21 100644 --- a/internal/filter/filter.go +++ b/internal/filter/filter.go @@ -46,15 +46,15 @@ func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool { return true } - if f.OmitMd && isNonReadmeMarkdown(path, name) { + if f.OmitMd && IsNonReadmeMarkdown(path, name) { return true } - if f.FrontendOnly && !isFrontendOrReadme(path, name) { + if f.FrontendOnly && !IsFrontendOrReadme(path, name) { return true } - if f.OmitTest && isTestFile(path, name) { + if f.OmitTest && IsTestFile(path, name) { return true } @@ -68,25 +68,37 @@ func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool { return false } -func isNonReadmeMarkdown(path, name string) bool { +func IsNonReadmeMarkdown(path, name string) bool { ext := strings.ToLower(filepath.Ext(path)) if ext != ".md" && ext != ".markdown" { return false } - return !strings.EqualFold(name, "README.md") + return !strings.EqualFold(name, "README.md") && !strings.EqualFold(name, "README.markdown") } -func isFrontendOrReadme(path, name string) bool { +func IsFrontendOrReadme(path, name string) bool { + if IsFrontendExt(path) { + return true + } + return strings.EqualFold(name, "README.md") +} + +func IsFrontendExt(path string) bool { ext := strings.ToLower(filepath.Ext(path)) for _, fe := range config.FrontendExts { if strings.EqualFold(ext, fe) { return true } } - return strings.EqualFold(name, "README.md") + return false } -func isTestFile(path, name string) bool { +func IsTestDir(path string) bool { + slashPath := filepath.ToSlash(path) + return strings.Contains(slashPath, "/tests/") || strings.HasPrefix(slashPath, "tests/") +} + +func IsTestFile(path, name string) bool { ext := strings.ToLower(filepath.Ext(path)) switch ext { @@ -107,8 +119,7 @@ func isTestFile(path, name string) bool { if strings.EqualFold(name, "conftest.py") { return true } - slashPath := filepath.ToSlash(path) - return strings.Contains(slashPath, "/tests/") + return IsTestDir(path) } return false diff --git a/internal/filter/filter_test.go b/internal/filter/filter_test.go new file mode 100644 index 0000000..95f134d --- /dev/null +++ b/internal/filter/filter_test.go @@ -0,0 +1,269 @@ +package filter + +import ( + "os" + "path/filepath" + "testing" + "time" + + "git.flo.fo/FLO/dirmd/internal/config" +) + +func TestIsNonReadmeMarkdown(t *testing.T) { + tests := []struct { + name string + path string + want bool + }{ + {"readme_md_false", "README.md", false}, + {"readme_markdown_false", "README.markdown", false}, + {"readme_lower_false", "readme.md", false}, + {"other_md_true", "CHANGELOG.md", true}, + {"other_markdown_true", "LICENSE.markdown", true}, + {"txt_false", "README.txt", false}, + {"go_false", "main.go", false}, + {"empty_false", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsNonReadmeMarkdown(tt.path, filepath.Base(tt.path)); got != tt.want { + t.Errorf("IsNonReadmeMarkdown(%q) = %v; want %v", tt.path, got, tt.want) + } + }) + } +} + +func TestIsFrontendExt(t *testing.T) { + tests := []struct { + name string + path string + want bool + }{ + {"html_true", "page.html", true}, + {"htm_true", "page.htm", true}, + {"css_true", "style.css", true}, + {"js_true", "app.js", true}, + {"ts_true", "app.ts", true}, + {"vue_true", "component.vue", true}, + {"svelte_true", "app.svelte", true}, + {"gohtml_true", "template.gohtml", true}, + {"go_false", "main.go", false}, + {"md_false", "README.md", false}, + {"json_false", "config.json", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsFrontendExt(tt.path); got != tt.want { + t.Errorf("IsFrontendExt(%q) = %v; want %v", tt.path, got, tt.want) + } + }) + } +} + +func TestIsFrontendOrReadme(t *testing.T) { + tests := []struct { + name string + path string + want bool + }{ + {"html_true", "page.html", true}, + {"readme_true", "README.md", true}, + {"readme_lower_true", "readme.md", true}, + {"css_true", "style.css", true}, + {"go_false", "main.go", false}, + {"changelog_false", "CHANGELOG.md", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsFrontendOrReadme(tt.path, filepath.Base(tt.path)); got != tt.want { + t.Errorf("IsFrontendOrReadme(%q) = %v; want %v", tt.path, got, tt.want) + } + }) + } +} + +func TestIsTestDir(t *testing.T) { + tests := []struct { + name string + path string + want bool + }{ + {"tests_top_level_true", "tests/", true}, + {"tests_nested_true", "pkg/tests/unit/", true}, + {"test_single_letter_false", "test/", false}, + {"src_test_false_current_behavior", "src/test/java/com/example/", false}, + {"normal_false", "src/", false}, + {"tests_file_false", "testfile.go", false}, + {"empty_false", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsTestDir(tt.path); got != tt.want { + t.Errorf("IsTestDir(%q) = %v; want %v", tt.path, got, tt.want) + } + }) + } +} + +func TestIsTestFile(t *testing.T) { + tests := []struct { + name string + path string + want bool + }{ + {"go_test_true", "path/to/file_test.go", true}, + {"go_test_uppercase_true", "path/to/file_TEST.go", true}, + {"go_normal_false", "path/to/main.go", false}, + {"go_integration_test_true", "path/to/integration_test.go", true}, + {"java_test_suffix_true", "MyClassTest.java", true}, + {"java_tests_suffix_true", "MyClassTests.java", true}, + {"java_normal_false", "MyClass.java", false}, + {"java_src_test_true", "src/test/java/com/example/MyTest.java", true}, + {"py_test_prefix_true", "test_main.py", true}, + {"py_test_suffix_true", "main_test.py", true}, + {"py_conftest_true", "conftest.py", true}, + {"py_normal_false", "main.py", false}, + {"py_tests_dir_true", "tests/conftest.py", true}, + {"rust_cfg_false", "main.rs", false}, + {"c_false", "main.c", false}, + {"unknown_false", "README.md", false}, + {"empty_false", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsTestFile(tt.path, filepath.Base(tt.path)); got != tt.want { + t.Errorf("IsTestFile(%q, %q) = %v; want %v", tt.path, filepath.Base(tt.path), got, tt.want) + } + }) + } +} + +func TestShouldSkipFile(t *testing.T) { + tests := []struct { + name string + filter *Filter + path string + size int64 + want bool + }{ + { + name: "large_file_skipped", + filter: &Filter{MaxSize: 1000, Extensions: []string{}, Ignores: []string{}}, + path: "big.bin", + size: 2000, + want: true, + }, + { + name: "small_file_not_skipped", + filter: &Filter{MaxSize: 1000, Extensions: []string{}, Ignores: []string{}}, + path: "tiny.txt", + size: 500, + want: false, + }, + { + name: "excluded_extension_skipped", + filter: &Filter{MaxSize: 1000000, Extensions: []string{".bin"}, Ignores: []string{}}, + path: "file.bin", + size: 500, + want: true, + }, + { + name: "hidden_file_skipped", + filter: &Filter{MaxSize: 1000000, Extensions: []string{}, Ignores: []string{}}, + path: ".gitignore", + size: 500, + want: true, + }, + { + name: "omit_test_go_test", + filter: &Filter{MaxSize: 1000000, Extensions: []string{}, Ignores: []string{}, OmitTest: true}, + path: "main_test.go", + size: 500, + want: true, + }, + { + name: "omit_test_normal_go", + filter: &Filter{MaxSize: 1000000, Extensions: []string{}, Ignores: []string{}, OmitTest: true}, + path: "main.go", + size: 500, + want: false, + }, + { + name: "omit_md_changelog", + filter: &Filter{MaxSize: 1000000, Extensions: []string{}, Ignores: []string{}, OmitMd: true}, + path: "CHANGELOG.md", + size: 500, + want: true, + }, + { + name: "omit_md_readme_not", + filter: &Filter{MaxSize: 1000000, Extensions: []string{}, Ignores: []string{}, OmitMd: true}, + path: "README.md", + size: 500, + want: false, + }, + { + name: "omit_md_readme_markdown_not", + filter: &Filter{MaxSize: 1000000, Extensions: []string{}, Ignores: []string{}, OmitMd: true}, + path: "README.markdown", + size: 500, + want: false, + }, + { + name: "frontend_only_non_frontend", + filter: &Filter{MaxSize: 1000000, Extensions: []string{}, Ignores: []string{}, FrontendOnly: true}, + path: "main.go", + size: 500, + want: true, + }, + { + name: "frontend_only_readme", + filter: &Filter{MaxSize: 1000000, Extensions: []string{}, Ignores: []string{}, FrontendOnly: true}, + path: "README.md", + size: 500, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fi := fakeFileInfo{size: tt.size} + if got := tt.filter.ShouldSkipFile(tt.path, filepath.Base(tt.path), fi); got != tt.want { + t.Errorf("ShouldSkipFile() = %v; want %v", got, tt.want) + } + }) + } +} + +func TestShouldSkipDir(t *testing.T) { + tests := []struct { + name string + filter *Filter + dir string + want bool + }{ + {"hidden_dir", &Filter{Ignores: []string{}}, ".git", true}, + {"node_modules_with_defaults", &Filter{Ignores: config.DefaultIgnores}, "node_modules", true}, + {"node_modules_without_defaults", &Filter{Ignores: []string{}}, "node_modules", false}, + {"custom_ignore", &Filter{Ignores: []string{"vendor"}}, "vendor", true}, + {"normal_dir", &Filter{Ignores: config.DefaultIgnores}, "src", false}, + {"empty_ignores_no_skip", &Filter{Ignores: []string{}}, "src", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.filter.ShouldSkipDir(tt.dir); got != tt.want { + t.Errorf("ShouldSkipDir(%q) = %v; want %v", tt.dir, got, tt.want) + } + }) + } +} + +type fakeFileInfo struct { + size int64 +} + +func (f fakeFileInfo) Name() string { return "" } +func (f fakeFileInfo) Size() int64 { return f.size } +func (f fakeFileInfo) Mode() os.FileMode { return 0 } +func (f fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (f fakeFileInfo) IsDir() bool { return false } +func (f fakeFileInfo) Sys() any { return nil } diff --git a/internal/git/git_test.go b/internal/git/git_test.go new file mode 100644 index 0000000..2f97af8 --- /dev/null +++ b/internal/git/git_test.go @@ -0,0 +1,48 @@ +package git + +import ( + "os/exec" + "strings" + "testing" +) + +func TestFormatCommitLine_nil(t *testing.T) { + if got := FormatCommitLine(nil); got != "" { + t.Errorf("FormatCommitLine(nil) = %q; want empty", got) + } +} + +func TestFormatCommitLine_valid(t *testing.T) { + commit := &CommitInfo{ + ShortSHA: "abc1234", + Message: "Initial commit", + } + expected := "> Commit: abc1234 (Initial commit)\n" + if got := FormatCommitLine(commit); got != expected { + t.Errorf("FormatCommitLine() = %q; want %q", got, expected) + } +} + +func TestGetCommitInfo_no_git(t *testing.T) { + cmd := exec.Command("git", "-C", "/nonexistent", "log", "-1", "--format=%h %s") + output, err := cmd.Output() + if err == nil { + t.Fatalf("expected error from nonexistent path, got %s", string(output)) + } + + result := GetCommitInfo("/nonexistent") + if result != nil { + t.Errorf("GetCommitInfo(/nonexistent) = %v; want nil", result) + } +} + +func TestGetCommitInfo_no_commits(t *testing.T) { + out, err := exec.Command("sh", "-c", "echo 'abc123'").Output() + if err != nil { + t.Skip("shell command failed") + } + parts := strings.SplitN(strings.TrimSpace(string(out)), " ", 2) + if len(parts) < 2 { + t.Skip("cannot verify single-part output") + } +} diff --git a/internal/instructions/instructions_test.go b/internal/instructions/instructions_test.go new file mode 100644 index 0000000..a42f906 --- /dev/null +++ b/internal/instructions/instructions_test.go @@ -0,0 +1,60 @@ +package instructions + +import ( + "strings" + "testing" +) + +func TestFormat_empty(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"empty_string", "", ""}, + {"whitespace_only", " ", ""}, + {"newline_only", "\n\n", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Format(tt.input); got != tt.want { + t.Errorf("Format(%q) = %q; want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestFormat_valid(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"single_line", "hello world"}, + {"multi_line", "line 1\nline 2\nline 3"}, + {"trailing_newlines", "content\n\n\n"}, + {"leading_whitespace", "\n\ncontent"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Format(tt.input) + if !strings.HasPrefix(got, "> Instructions:\n") { + t.Errorf("missing instructions header") + } + if !strings.HasSuffix(got, "\n") { + t.Errorf("does not end with newline") + } + lines := strings.Split(got, "\n") + for _, line := range lines { + if line != "" && !strings.HasPrefix(line, ">") { + t.Errorf("line does not start with '>': %q", line) + } + } + }) + } +} + +func TestFileName_constant(t *testing.T) { + if FileName != ".dirmd" { + t.Errorf("FileName = %q; want \".dirmd\"", FileName) + } +} diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index 99976be..397115a 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -84,7 +84,7 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile language := extToLang(filepath.Ext(entry.RelPath)) lines := countLines(content) - fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines) + fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, len(content), lines) fmt.Fprintf(&sb, "%s", fenceChars) if language != "" { fmt.Fprintf(&sb, "%s", language) @@ -161,7 +161,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e generated = append(generated, pickInfoPaths(pickInfos)...) } - if err := writeRootIndex(rootPath, absRoot, rootName, repoName, topLevelDirs, result.Commit, result.Instructions, treeRoot, result.Cfg, pickInfos); err != nil { + if err := writeRootIndex(rootPath, absRoot, rootName, repoName, topLevelDirs, rootFiles, result.Commit, result.Instructions, treeRoot, result.Cfg, pickInfos); err != nil { return nil, fmt.Errorf("failed to write root index: %w", err) } @@ -170,7 +170,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e } for _, dirName := range dirNames { - sliceName := strings.TrimSuffix(dirName, "/") + "_" + repoName + ".md" + sliceName := dirName + "_" + repoName + ".md" sliceDir := filepath.Join(outputDir, dirName) if err := os.MkdirAll(sliceDir, 0755); err != nil { return nil, fmt.Errorf("cannot create slice directory %s: %w", sliceDir, err) @@ -283,7 +283,7 @@ func writePickFile(path, absRoot, relPath string, omitTest bool, commit *git.Com fmt.Fprintf(&sb, "Tree: %s\n\n", tree.RenderBreadcrumb(absRoot, relPath)) - fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", relPath, infoSize(fullPath), lines) + fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", relPath, len(content), lines) sb.WriteString(fenceChars) if language != "" { sb.WriteString(language) @@ -299,15 +299,7 @@ func writePickFile(path, absRoot, relPath string, omitTest bool, commit *git.Com return err } -func infoSize(path string) int64 { - info, err := os.Stat(path) - if err != nil { - return 0 - } - return info.Size() -} - -func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[string][]walker.Entry, commit *git.CommitInfo, instContent string, treeRoot *tree.Node, cfg *config.Config, picks []PickInfo) error { +func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[string][]walker.Entry, rootFiles []walker.Entry, commit *git.CommitInfo, instContent string, treeRoot *tree.Node, cfg *config.Config, picks []PickInfo) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("cannot create root index: %w", err) @@ -337,8 +329,11 @@ func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[s sb.WriteString("|------------|-----------|-------|-------------|\n") rootSliceFile := "root_" + repoName + ".md" - rootFileCount := len(topLevelDirs) + rootFileCount := len(rootFiles) rootTotalBytes := int64(0) + for _, e := range rootFiles { + rootTotalBytes += e.Size + } fmt.Fprintf(&sb, "| %s | (root) | %d | %d |\n", rootSliceFile, rootFileCount, rootTotalBytes) for _, dirName := range sortedKeys(topLevelDirs) { @@ -348,7 +343,7 @@ func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[s for _, e := range entries { totalBytes += e.Size } - sliceFile := filepath.Join(dirName, strings.TrimSuffix(dirName, "/")+"_"+repoName+".md") + sliceFile := filepath.Join(dirName, dirName+"_"+repoName+".md") fmt.Fprintf(&sb, "| %s | %s/ | %d | %d |\n", sliceFile, dirName, fileCount, totalBytes) } sb.WriteString("\n") @@ -398,7 +393,7 @@ func writeRootSliceFile(path, absRoot string, rootFiles []walker.Entry, omitTest } language := extToLang(filepath.Ext(entry.RelPath)) lines := countLines(content) - fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines) + fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, len(content), lines) fmt.Fprintf(&sb, "%s", fenceChars) if language != "" { fmt.Fprintf(&sb, "%s", language) @@ -442,7 +437,7 @@ func writeDirSlice(path, absDir, absRoot, dirName string, fileEntries []walker.E } language := extToLang(filepath.Ext(entry.RelPath)) lines := countLines(content) - fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines) + fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, len(content), lines) fmt.Fprintf(&sb, "%s", fenceChars) if language != "" { fmt.Fprintf(&sb, "%s", language) @@ -582,20 +577,13 @@ func stripTestBlocks(content string) string { for i < len(lines) { line := lines[i] - - if !foundBrace { - if strings.Contains(line, "{") { - braceDepth += strings.Count(line, "{") - strings.Count(line, "}") - foundBrace = true - } - i++ - continue - } - braceDepth += strings.Count(line, "{") - strings.Count(line, "}") + if strings.Contains(line, "{") { + foundBrace = true + } i++ - if braceDepth <= 0 { + if foundBrace && braceDepth <= 0 { break } } @@ -639,6 +627,7 @@ func extToLang(ext string) string { ".cpp": "cpp", ".hpp": "cpp", ".txt": "text", + ".toml": "toml", } if lang, ok := mapping[strings.ToLower(ext)]; ok { diff --git a/internal/renderer/renderer_test.go b/internal/renderer/renderer_test.go new file mode 100644 index 0000000..cd01f69 --- /dev/null +++ b/internal/renderer/renderer_test.go @@ -0,0 +1,202 @@ +package renderer + +import ( + "strings" + "testing" + + "git.flo.fo/FLO/dirmd/internal/config" + "git.flo.fo/FLO/dirmd/internal/tree" +) + +func TestCountLines_empty(t *testing.T) { + if got := countLines(""); got != 0 { + t.Errorf("countLines(\"\") = %d; want 0", got) + } +} + +func TestCountLines_basic(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + {"one_line_no_newline", "hello", 1}, + {"one_line_with_newline", "hello\n", 1}, + {"two_lines", "hello\nworld", 2}, + {"two_lines_trailing", "hello\nworld\n", 2}, + {"three_lines", "a\nb\nc", 3}, + {"empty_lines", "a\n\nb", 3}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := countLines(tt.input); got != tt.want { + t.Errorf("countLines(%q) = %d; want %d", tt.input, got, tt.want) + } + }) + } +} + +func TestStripTestBlocks_rust(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"no_test_block", "fn main() {}\nfn helper() {}", "fn main() {}\nfn helper() {}"}, + {"cfg_test_block", "#[cfg(test)]\nmod tests {\n #[test]\n fn test_one() {}\n}", ""}, + {"mixed_content", "pub fn main() {}\n#[cfg(test)]\nmod tests {\n #[test]\n fn t() {}\n}\npub fn end() {}", "pub fn main() {}\npub fn end() {}"}, + {"nested_braces", "#[cfg(test)]\nmod tests {\n mod inner {\n #[test]\n fn t() {}\n }\n}", ""}, + {"multiple_blocks", "#[cfg(test)]\nmod t1 {}\n#[cfg(test)]\nmod t2 {}", ""}, + {"cfg_all_test", "#[cfg(all(test, unix))]\nmod tests {}", ""}, + {"cfg_any_test", "#[cfg(any(test, feature = \"foo\"))]\nmod tests {}", ""}, + {"empty_after_strip", "#[cfg(test)]\nmod tests {}", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := stripTestBlocks(tt.input); got != tt.want { + t.Errorf("stripTestBlocks(%q)\ngot:\n%s\nwant:\n%s", tt.input, got, tt.want) + } + }) + } +} + +func TestExtToLang(t *testing.T) { + tests := []struct { + name string + ext string + want string + }{ + {"go", ".go", "go"}, + {"markdown", ".md", "markdown"}, + {"yaml_upper", ".YAML", "yaml"}, + {"yaml_yml", ".yml", "yaml"}, + {"javascript", ".js", "javascript"}, + {"typescript", ".ts", "typescript"}, + {"css", ".css", "css"}, + {"html", ".html", "html"}, + {"json", ".json", "json"}, + {"xml", ".xml", "xml"}, + {"sql", ".sql", "sql"}, + {"bash", ".sh", "bash"}, + {"python", ".py", "python"}, + {"rust", ".rs", "rust"}, + {"c", ".c", "c"}, + {"cpp", ".cpp", "cpp"}, + {"txt", ".txt", "text"}, + {"toml", ".toml", "toml"}, + {"unknown", ".xyz", ""}, + {"empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extToLang(tt.ext); got != tt.want { + t.Errorf("extToLang(%q) = %q; want %q", tt.ext, got, tt.want) + } + }) + } +} + +func TestHasAnyAnnotation_none(t *testing.T) { + root := &tree.Node{Name: "root", IsDir: true, Children: map[string]*tree.Node{ + "file.go": {Name: "file.go", IsDir: false}, + }} + if hasAnyAnnotation(root) { + t.Error("hasAnyAnnotation should be false for clean tree") + } +} + +func TestHasAnyAnnotation_present(t *testing.T) { + root := &tree.Node{Name: "root", IsDir: true, Children: map[string]*tree.Node{ + ".git": {Name: ".git", IsDir: true, Annotation: "ignored (default)"}, + }} + if !hasAnyAnnotation(root) { + t.Error("hasAnyAnnotation should be true with annotation") + } +} + +func TestHasAnyAnnotation_deep(t *testing.T) { + root := &tree.Node{Name: "root", IsDir: true, Children: map[string]*tree.Node{ + "internal": {Name: "internal", IsDir: true, Children: map[string]*tree.Node{ + "deep": {Name: "deep", IsDir: true, Annotation: "tests omitted (--omit-test)"}, + }}, + }} + if !hasAnyAnnotation(root) { + t.Error("hasAnyAnnotation should be true for deep annotation") + } +} + +func TestFormatFlags_nil(t *testing.T) { + if FormatFlags(nil) != "" { + t.Error("FormatFlags(nil) should be empty") + } +} + +func TestFormatFlags_empty(t *testing.T) { + cfg := &config.Config{ + MaxDepth: config.DefaultMaxDepth, + MaxSize: config.DefaultMaxSize, + } + if out := FormatFlags(cfg); out != "" { + t.Errorf("FormatFlags(default config) = %q; want empty", out) + } +} + +func TestFormatFlags_set(t *testing.T) { + cfg := &config.Config{ + OmitTest: true, + OmitMd: true, + MaxDepth: 10, + } + out := FormatFlags(cfg) + if !strings.Contains(out, "--omit-test") { + t.Error("missing --omit-test") + } + if !strings.Contains(out, "--omit-md") { + t.Error("missing --omit-md") + } + if !strings.Contains(out, "--max-depth 10") { + t.Error("missing --max-depth 10") + } +} + +func TestFormatFlags_extra_ignores(t *testing.T) { + cfg := &config.Config{ + Ignores: append(config.DefaultIgnores, "mycustomdir"), + } + out := FormatFlags(cfg) + if !strings.Contains(out, "mycustomdir") { + t.Error("missing custom ignore in output") + } + if strings.Contains(out, "node_modules") { + t.Error("default ignore leaked into Ignored line") + } +} + +func TestSetMinus(t *testing.T) { + tests := []struct { + name string + haystack []string + defaults []string + want []string + }{ + {"empty", nil, []string{"a"}, nil}, + {"all_default", []string{"a", "b"}, []string{"a", "b"}, nil}, + {"one_extra", []string{"a", "c"}, []string{"a"}, []string{"c"}}, + {"duplicates_deduped", []string{"c", "c", "a"}, []string{"a"}, []string{"c"}}, + {"no_defaults", []string{"x"}, nil, []string{"x"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := setMinus(tt.haystack, tt.defaults) + if len(got) != len(tt.want) { + t.Errorf("setMinus() length = %d; want %d", len(got), len(tt.want)) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("setMinus()[%d] = %q; want %q", i, got[i], tt.want[i]) + } + } + }) + } +} diff --git a/internal/tree/tree_test.go b/internal/tree/tree_test.go new file mode 100644 index 0000000..5ee77e9 --- /dev/null +++ b/internal/tree/tree_test.go @@ -0,0 +1,90 @@ +package tree + +import ( + "testing" +) + +func TestAddPath_single(t *testing.T) { + root := &Node{IsDir: true, Children: make(map[string]*Node)} + addPath(root, "README.md", false) + node, ok := root.Children["README.md"] + if !ok { + t.Fatal("missing README.md child") + } + if node.IsDir { + t.Error("README.md should not be a directory") + } +} + +func TestAddPath_nested(t *testing.T) { + root := &Node{IsDir: true, Children: make(map[string]*Node)} + addPath(root, "cmd/main.go", false) + cmdNode, ok := root.Children["cmd"] + if !ok { + t.Fatal("missing cmd child") + } + if !cmdNode.IsDir { + t.Error("cmd should be a directory") + } + if _, ok := cmdNode.Children["main.go"]; !ok { + t.Fatal("missing main.go child under cmd") + } +} + +func TestAddPath_overwrite_annotation(t *testing.T) { + root := &Node{IsDir: true, Children: make(map[string]*Node)} + node := addPath(root, "cmd", true) + node.Annotation = "ignored (default)" + + node2 := addPath(root, "cmd", true) + if node2 != node { + t.Error("addPath should return the same node for repeated path") + } + if node2.Annotation != "ignored (default)" { + t.Errorf("annotation lost: %q", node2.Annotation) + } +} + +func TestSplitPath(t *testing.T) { + tests := []struct { + name string + input string + expect []string + }{ + {"root_file", "README.md", []string{"README.md"}}, + {"two_parts", "cmd/main.go", []string{"cmd", "main.go"}}, + {"three_parts", "internal/tree/node.go", []string{"internal", "tree", "node.go"}}, + {"empty", "", nil}, + {"only_slashes", "///", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := splitPath(tt.input) + if len(got) != len(tt.expect) { + t.Errorf("splitPath(%q) = %v; want %v", tt.input, got, tt.expect) + return + } + for i := range got { + if got[i] != tt.expect[i] { + t.Errorf("splitPath(%q)[%d] = %q; want %q", tt.input, i, got[i], tt.expect[i]) + } + } + }) + } +} + +func TestRenderBreadcrumb_root(t *testing.T) { + breadcrumb := RenderBreadcrumb("/home/user/project", "README.md") + expected := "project/README.md" + if breadcrumb != expected { + t.Errorf("RenderBreadcrumb(...) = %q; want %q", breadcrumb, expected) + } +} + +func TestRenderBreadcrumb_nested(t *testing.T) { + breadcrumb := RenderBreadcrumb("/home/user/project", "internal/tree/tree.go") + expected := "project / internal / tree / tree.go" + if breadcrumb != expected { + t.Errorf("RenderBreadcrumb(...) = %q; want %q", breadcrumb, expected) + } +} diff --git a/internal/tree/unfiltered.go b/internal/tree/unfiltered.go index 1aeb02f..8c894c5 100644 --- a/internal/tree/unfiltered.go +++ b/internal/tree/unfiltered.go @@ -4,6 +4,7 @@ import ( "fmt" "io/fs" "path/filepath" + "slices" "strings" "git.flo.fo/FLO/dirmd/internal/config" @@ -40,12 +41,17 @@ func BuildFullTree(absRoot string, cfg *config.Config) (*Node, error) { name := filepath.Base(path) if d.IsDir() { - if isIgnoredName(name, cfg.Ignores) { + if strings.HasPrefix(name, ".") { node := addPath(root, rel, true) - node.Annotation = "ignored (--ignore)" + node.Annotation = "ignored (default)" return filepath.SkipDir } - if cfg.OmitTest && isTestDir(rel) { + if annotation, ok := ignoreReason(name, cfg.Ignores); ok { + node := addPath(root, rel, true) + node.Annotation = annotation + return filepath.SkipDir + } + if cfg.OmitTest && filter.IsTestDir(rel) { node := addPath(root, rel, true) node.Annotation = "tests omitted (--omit-test)" return filepath.SkipDir @@ -70,105 +76,40 @@ func BuildFullTree(absRoot string, cfg *config.Config) (*Node, error) { return root, nil } -func isIgnoredName(name string, ignores []string) bool { - if strings.HasPrefix(name, ".") { - return true - } +func ignoreReason(name string, ignores []string) (string, bool) { for _, pattern := range ignores { if matched, _ := filepath.Match(pattern, name); matched { - return true + if slices.Contains(config.DefaultIgnores, pattern) { + return "ignored (default)", true + } + return "ignored (--ignore)", true } } - return false -} - -func isTestDir(rel string) bool { - slashPath := filepath.ToSlash(rel) - return strings.Contains(slashPath, "/tests/") || strings.HasPrefix(slashPath, "tests/") + return "", false } func fileAnnotation(rel, name string, cfg *config.Config) (string, bool) { if strings.HasPrefix(name, ".") { return "", false } - for _, pattern := range cfg.Ignores { - if matched, _ := filepath.Match(pattern, name); matched { - return "ignored (--ignore)", true - } + if annotation, ok := ignoreReason(name, cfg.Ignores); ok { + return annotation, true } - if cfg.OmitTest && isTestFileName(rel, name) { + if cfg.OmitTest && filter.IsTestFile(rel, name) { return "tests omitted (--omit-test)", true } - if cfg.OmitMd && isNonReadmeMarkdown(name) { + if cfg.OmitMd && filter.IsNonReadmeMarkdown(rel, name) { return "omitted (--omit-md)", true } - if cfg.FrontendOnly && !isFrontendOrReadme(rel, name) { + if cfg.FrontendOnly && !filter.IsFrontendOrReadme(rel, name) { return "omitted (--frontend-only)", true } - if cfg.SkipFrontend && isFrontendExt(rel) { + if cfg.SkipFrontend && filter.IsFrontendExt(rel) { return "omitted (--skip-frontend)", true } return "", false } -func isTestFileName(rel, name string) bool { - ext := strings.ToLower(filepath.Ext(name)) - switch ext { - case ".go": - return strings.HasSuffix(strings.ToLower(name), "_test.go") - case ".java": - base := strings.TrimSuffix(name, filepath.Ext(name)) - if strings.HasSuffix(base, "Test") || strings.HasSuffix(base, "Tests") { - return true - } - slashPath := filepath.ToSlash(rel) - return strings.Contains(slashPath, "/src/test/") - case ".py": - lower := strings.ToLower(name) - if strings.HasPrefix(lower, "test_") || strings.HasSuffix(lower, "_test.py") { - return true - } - if strings.EqualFold(name, "conftest.py") { - return true - } - return isTestDir(rel) - } - return false -} - -func isNonReadmeMarkdown(name string) bool { - ext := strings.ToLower(filepath.Ext(name)) - if ext != ".md" && ext != ".markdown" { - return false - } - return !strings.EqualFold(name, "README.md") -} - -func isFrontendOrReadme(rel, name string) bool { - if isFrontendExt(rel) { - return true - } - return strings.EqualFold(name, "README.md") -} - -func isFrontendExt(rel string) bool { - ext := strings.ToLower(filepath.Ext(rel)) - for _, fe := range frontendExts { - if strings.EqualFold(ext, fe) { - return true - } - } - return false -} - -var frontendExts = []string{ - ".html", ".htm", ".gohtml", ".tmpl", - ".css", ".scss", ".sass", ".less", - ".js", ".jsx", ".mjs", ".cjs", - ".ts", ".tsx", - ".vue", ".svelte", -} - func RenderBreadcrumb(absRoot, relPath string) string { var sb strings.Builder rootName := filepath.Base(absRoot) diff --git a/internal/tui/run.go b/internal/tui/run.go index 95607ca..12f6814 100644 --- a/internal/tui/run.go +++ b/internal/tui/run.go @@ -56,8 +56,8 @@ func Run() error { km.MultiSelect.Next = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next field")) km.MultiSelect.Prev = key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev field")) - km.MultiSelect.Up = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")) - km.MultiSelect.Down = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")) + km.MultiSelect.Up = key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")) + km.MultiSelect.Down = key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")) km.MultiSelect.Toggle = key.NewBinding(key.WithKeys("enter", " "), key.WithHelp("enter/space", "toggle")) km.MultiSelect.Submit = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next")) km.MultiSelect.Filter = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")) diff --git a/internal/tui/run_test.go b/internal/tui/run_test.go new file mode 100644 index 0000000..db444a5 --- /dev/null +++ b/internal/tui/run_test.go @@ -0,0 +1,27 @@ +package tui + +import ( + "testing" +) + +func TestContainsOption_present(t *testing.T) { + opts := []string{"force", "vertical-slices", "omit-test"} + if !containsOption(opts, "vertical-slices") { + t.Error("containsOption failed to find present option") + } +} + +func TestContainsOption_absent(t *testing.T) { + opts := []string{"force", "vertical-slices"} + if containsOption(opts, "omit-test") { + t.Error("containsOption found absent option") + } +} + +func TestDefaultOutputDir_stable(t *testing.T) { + out1 := defaultOutputDir() + out2 := defaultOutputDir() + if out1 != out2 { + t.Error("defaultOutputDir should be deterministic for same cwd") + } +} diff --git a/internal/walker/walker_test.go b/internal/walker/walker_test.go new file mode 100644 index 0000000..ba87d3b --- /dev/null +++ b/internal/walker/walker_test.go @@ -0,0 +1,358 @@ +package walker + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "git.flo.fo/FLO/dirmd/internal/config" +) + +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("mkdir for %s: %v", name, err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("write %s: %v", name, err) + } +} + +func baseCfg(absRoot string) *config.Config { + return &config.Config{ + AbsRoot: absRoot, + InputPath: absRoot, + MaxSize: 1024 * 1024, + MaxDepth: 20, + Ignores: config.DefaultIgnores, + Extensions: config.DefaultExts, + } +} + +func relPaths(entries []Entry) []string { + paths := make([]string, 0, len(entries)) + for _, e := range entries { + paths = append(paths, e.RelPath) + } + return paths +} + +func containsPath(paths []string, target string) bool { + for _, p := range paths { + if p == target { + return true + } + } + return false +} + +func TestWalkDirectory_happy(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "main.go", "package main\n") + writeFile(t, root, "cmd/tool/main.go", "package main\n") + writeFile(t, root, "cmd/tool/util.go", "package tool\n") + writeFile(t, root, "internal/x/y/deep.go", "package y\n") + + result, err := Run(baseCfg(root)) + if err != nil { + t.Fatalf("Run: %v", err) + } + + got := relPaths(result.Entries) + want := []string{"main.go", "cmd/tool/main.go", "cmd/tool/util.go", "internal/x/y/deep.go"} + if len(got) != len(want) { + t.Fatalf("entries = %v; want %v", got, want) + } + for _, w := range want { + if !containsPath(got, w) { + t.Errorf("missing entry %q in %v", w, got) + } + } + if result.AbsRoot != root { + t.Errorf("AbsRoot = %q; want %q", result.AbsRoot, root) + } + if result.Cfg == nil { + t.Error("Cfg not attached to result") + } +} + +func TestWalkDirectory_respects_filters(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + must []string + musts []string + }{ + { + name: "default_ignores_skip_dirs_and_files", + setup: func(t *testing.T) string { + root := t.TempDir() + writeFile(t, root, "main.go", "x\n") + writeFile(t, root, ".git/config", "x\n") + writeFile(t, root, "node_modules/pkg/index.js", "x\n") + writeFile(t, root, "go.mod", "module x\n") + writeFile(t, root, "docs/readme.txt", "x\n") + return root + }, + must: []string{"main.go", "docs/readme.txt"}, + musts: []string{".git/config", "node_modules/pkg/index.js", "go.mod"}, + }, + { + name: "default_exts_skip_binary_types", + setup: func(t *testing.T) string { + root := t.TempDir() + writeFile(t, root, "keep.go", "x\n") + writeFile(t, root, "img.png", "x\n") + writeFile(t, root, "arch.zip", "x\n") + return root + }, + must: []string{"keep.go"}, + musts: []string{"img.png", "arch.zip"}, + }, + { + name: "hidden_files_and_dirs_skipped", + setup: func(t *testing.T) string { + root := t.TempDir() + writeFile(t, root, "visible.go", "x\n") + writeFile(t, root, ".hidden.go", "x\n") + writeFile(t, root, ".github/workflows/ci.yml", "x\n") + return root + }, + must: []string{"visible.go"}, + musts: []string{".hidden.go", ".github/workflows/ci.yml"}, + }, + { + name: "max_size_skips_large_files", + setup: func(t *testing.T) string { + root := t.TempDir() + writeFile(t, root, "small.go", "x\n") + writeFile(t, root, "large.go", "this content exceeds ten bytes\n") + return root + }, + must: []string{"small.go"}, + musts: []string{"large.go"}, + }, + { + name: "omit_test_skips_go_tests", + setup: func(t *testing.T) string { + root := t.TempDir() + writeFile(t, root, "main.go", "x\n") + writeFile(t, root, "main_test.go", "x\n") + writeFile(t, root, "tests/helper.py", "x\n") + return root + }, + must: []string{"main.go"}, + musts: []string{"main_test.go", "tests/helper.py"}, + }, + { + name: "omit_md_keeps_readme_only", + setup: func(t *testing.T) string { + root := t.TempDir() + writeFile(t, root, "README.md", "x\n") + writeFile(t, root, "NOTES.md", "x\n") + writeFile(t, root, "src/notes.md", "x\n") + return root + }, + must: []string{"README.md"}, + musts: []string{"NOTES.md", "src/notes.md"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := tt.setup(t) + cfg := baseCfg(root) + switch tt.name { + case "max_size_skips_large_files": + cfg.MaxSize = 10 + case "omit_test_skips_go_tests": + cfg.OmitTest = true + case "omit_md_keeps_readme_only": + cfg.OmitMd = true + } + + result, err := Run(cfg) + if err != nil { + t.Fatalf("Run: %v", err) + } + + got := relPaths(result.Entries) + for _, w := range tt.must { + if !containsPath(got, w) { + t.Errorf("missing %q in %v", w, got) + } + } + for _, w := range tt.musts { + if containsPath(got, w) { + t.Errorf("unexpected %q in %v", w, got) + } + } + }) + } +} + +func TestWalkDirectory_custom_ignore(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "main.go", "x\n") + writeFile(t, root, "scratch/notes.txt", "x\n") + + cfg := baseCfg(root) + cfg.Ignores = append([]string{}, config.DefaultIgnores...) + cfg.Ignores = append(cfg.Ignores, "scratch") + + result, err := Run(cfg) + if err != nil { + t.Fatalf("Run: %v", err) + } + got := relPaths(result.Entries) + if containsPath(got, "scratch/notes.txt") { + t.Errorf("custom-ignored dir walked: %v", got) + } + if !containsPath(got, "main.go") { + t.Errorf("missing main.go in %v", got) + } +} + +func TestWalkDirectory_max_depth(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "a/b/c/d/e/deep.go", "x\n") + writeFile(t, root, "a/shallow.go", "x\n") + + cfg := baseCfg(root) + cfg.MaxDepth = 3 + + result, err := Run(cfg) + if err != nil { + t.Fatalf("Run: %v", err) + } + got := relPaths(result.Entries) + if containsPath(got, "a/b/c/d/e/deep.go") { + t.Errorf("file beyond max depth included: %v", got) + } + if !containsPath(got, "a/shallow.go") { + t.Errorf("missing shallow file in %v", got) + } +} + +func TestWalkDirectory_symlink_skipped(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks unreliable on windows") + } + + root := t.TempDir() + target := filepath.Join(root, "real", "file.go") + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("x\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(root, "real"), filepath.Join(root, "linkdir")); err != nil { + t.Skipf("cannot create symlink: %v", err) + } + + result, err := Run(baseCfg(root)) + if err != nil { + t.Fatalf("Run: %v", err) + } + got := relPaths(result.Entries) + if containsPath(got, "linkdir/file.go") { + t.Errorf("symlinked dir walked: %v", got) + } + if !containsPath(got, "real/file.go") { + t.Errorf("missing real entry in %v", got) + } +} + +func TestWalkDirectory_instructions_read(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "main.go", "x\n") + writeFile(t, root, ".dirmd", "be helpful\n") + + cfg := baseCfg(root) + cfg.IncludeInstructions = true + + result, err := Run(cfg) + if err != nil { + t.Fatalf("Run: %v", err) + } + if result.Instructions != "be helpful\n" { + t.Errorf("Instructions = %q; want %q", result.Instructions, "be helpful\n") + } +} + +func TestWalkDirectory_instructions_not_read_by_default(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "main.go", "x\n") + writeFile(t, root, ".dirmd", "be helpful\n") + + result, err := Run(baseCfg(root)) + if err != nil { + t.Fatalf("Run: %v", err) + } + if result.Instructions != "" { + t.Errorf("Instructions = %q; want empty", result.Instructions) + } +} + +func TestWalkDirectory_git_commit_outside_repo(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "main.go", "x\n") + + result, err := Run(baseCfg(root)) + if err != nil { + t.Fatalf("Run: %v", err) + } + if result.Commit != nil { + t.Errorf("Commit = %v; want nil outside a git repo", result.Commit) + } +} + +func TestProcessSingleFile(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "main.go", "package main\n") + + cfg := baseCfg(filepath.Join(root, "main.go")) + cfg.SingleFile = true + + result, err := Run(cfg) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(result.Entries) != 1 { + t.Fatalf("entries = %v; want single entry", result.Entries) + } + if result.Entries[0].RelPath != "main.go" { + t.Errorf("RelPath = %q; want %q", result.Entries[0].RelPath, "main.go") + } + if result.AbsRoot != root { + t.Errorf("AbsRoot = %q; want %q", result.AbsRoot, root) + } + if !result.SingleFile { + t.Error("SingleFile not set") + } +} + +func TestProcessSingleFile_filtered_out(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "image.png", "x\n") + + cfg := baseCfg(filepath.Join(root, "image.png")) + cfg.SingleFile = true + + _, err := Run(cfg) + if err == nil { + t.Fatal("expected error for filtered single file") + } +} + +func TestProcessSingleFile_missing(t *testing.T) { + cfg := baseCfg("/nonexistent/path/file.go") + cfg.SingleFile = true + + _, err := Run(cfg) + if err == nil { + t.Fatal("expected error for missing file") + } +} -- 2.55.0