diff --git a/README.md b/README.md index 2d5c1aa..6c093bc 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # dirmd -Generate markdown documentation from directory structure. +Generate markdown documentation from directory structure. Optimized for LLM ingestion. ## Features - Recursive directory traversal -- Tree visualization (standard tree command format) -- Full file contents with syntax-highlighted code blocks +- ASCII tree visualization (LLM-friendly, minimal token overhead) +- 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 - Append mode for single files or directories - Force overwrite of existing output files - Configurable depth, file size limits, ignore patterns @@ -31,12 +32,42 @@ Generate markdown documentation from directory structure. # Append another directory dirmd -a ~/repos/anotherproject -o existing.md + # Generate with vertical slices + dirmd -o output.md ~/repos/myproject --vertical-slices + # 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 +## Vertical Slices Mode + +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. + +This mode cannot be combined with `--append` or `--input-file`. + +Example output with `dirmd -o docs.md ~/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/ + +## LLM-Optimized Output + +Output is designed for RAG ingestion and LLM context windows, not human presentation: + +- **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 +- **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 | @@ -45,6 +76,7 @@ Generate markdown documentation from directory structure. | `-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 | | `--max-size` | Max file size in bytes | 524288 | | `--max-depth` | Max directory recursion depth | 20 | | `--ignore` | Additional ignore patterns (glob) | | @@ -54,7 +86,9 @@ Generate markdown documentation from directory structure. ## Proton Drive Integration -dirmd can optionally upload the generated markdown file 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. +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. ### Prerequisites @@ -62,14 +96,7 @@ dirmd can optionally upload the generated markdown file to Proton Drive using th 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. The local markdown file is still written successfully before the upload is attempted. - -### Upload Behavior - -- Default remote path: `/my-files/md/` where `` is the basename of the local output file. -- Override with `--drive-path /my-files/custom/report.md` (full path including filename). -- Existing remote files are overwritten (`--conflict-strategy replace`). -- The local markdown file is always written before the upload is attempted. +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). @@ -84,6 +111,9 @@ For full documentation on the Proton Drive CLI, including installation, authenti # Skip additional file extensions dirmd -o output.md ~/repos/project --extensions .log --extensions .tmp + # Vertical slices with overwrite + dirmd -o docs.md ~/repos/project -f --vertical-slices + # Generate, then upload to default remote location dirmd -o docs.md ~/repos/project --proton-drive @@ -97,31 +127,35 @@ For full documentation on the Proton Drive CLI, including installation, authenti Produces markdown with: -1. Absolute path as H1 header -2. Directory tree (indented with 4 spaces per level) -3. All text files with content in code blocks -4. Language detection from file extensions +1. Absolute path as H1 header with `[ROOT]` or `[SLICE]` marker +2. ASCII directory tree (indented with `+-` and `|` characters) +3. Vertical slices index table (sliced mode only) +4. All text files with content in fenced code blocks +5. Explicit `--- FILE: ---` and `--- END FILE ---` delimiters with byte and line counts +6. Language detection from file extensions -Example output structure: +Example output structure (non-sliced mode): - # /home/user/repos/myproject + # /home/user/repos/myproject [ROOT] ## Directory Tree + ~~~ myproject/ - ├── cmd/ - │ └── main.go - └── internal/ - └── config.go + +- cmd/ + | +- main.go + +- internal/ + | +- config.go + ~~~ ## Contents - ### ./cmd/main.go - - ```go + --- 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. diff --git a/cmd/root.go b/cmd/root.go index d3d1c62..addb0b8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -36,7 +36,10 @@ Examples: # Overwrite existing local file dirmd -o output.md ~/repos/myproject -f - # Generate and upload to Proton Drive (default remote path /my-files/md/) + # Generate with vertical slices (root + one file per top-level dir) + dirmd -o output.md ~/repos/myproject --vertical-slices + + # Generate and upload to Proton Drive dirmd -o output.md ~/repos/myproject --proton-drive # Generate and upload to a specific remote path @@ -54,6 +57,7 @@ Examples: extensions []string protonDrive bool drivePath string + verticalSlices bool ) func Execute() { @@ -73,6 +77,7 @@ func init() { rootCmd.Flags().StringArrayVar(&extensions, "extensions", nil, "additional file extensions to skip (repeatable)") rootCmd.Flags().BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally") rootCmd.Flags().StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (e.g. /my-files/md/report.md). Requires --proton-drive") + rootCmd.Flags().BoolVar(&verticalSlices, "vertical-slices", false, "split output into root file + one markdown per top-level directory") rootCmd.MarkPersistentFlagRequired("output") } @@ -82,16 +87,25 @@ func run(cmd *cobra.Command, args []string) error { return fmt.Errorf("--drive-path requires --proton-drive") } + if verticalSlices && appendMode { + return fmt.Errorf("--vertical-slices cannot be used with --append") + } + + if verticalSlices && singleFile != "" { + return fmt.Errorf("--vertical-slices cannot be used with --input-file") + } + cfg := &config.Config{ - OutputPath: outputPath, - AppendMode: appendMode, - Force: force, - MaxSize: maxSize, - MaxDepth: maxDepth, - Ignores: append(config.DefaultIgnores, ignorePatterns...), - Extensions: append(config.DefaultExts, extensions...), - ProtonDrive: protonDrive, - DrivePath: drivePath, + OutputPath: outputPath, + AppendMode: appendMode, + Force: force, + MaxSize: maxSize, + MaxDepth: maxDepth, + Ignores: append(config.DefaultIgnores, ignorePatterns...), + Extensions: append(config.DefaultExts, extensions...), + ProtonDrive: protonDrive, + DrivePath: drivePath, + VerticalSlices: verticalSlices, } if singleFile != "" { @@ -113,7 +127,6 @@ func run(cmd *cobra.Command, args []string) error { } cfg.AbsRoot = abs - // Non-append mode: check for existing output file if !appendMode { if _, err := os.Stat(outputPath); err == nil && !force { return fmt.Errorf("output file %s already exists (use -f to overwrite or -a to append)", outputPath) @@ -129,6 +142,33 @@ func run(cmd *cobra.Command, args []string) error { return fmt.Errorf("no readable text files found in %s", cfg.AbsRoot) } + if verticalSlices { + generated, err := renderer.WriteSlices(result, outputPath) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "generated %d files\n", len(generated)) + + if protonDrive { + if err := drive.CheckAuth(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + fmt.Fprintln(os.Stderr, "local files were written successfully, but upload was skipped") + return err + } + + for _, path := range generated { + if err := drive.Upload(path, "/my-files/md/"+filepath.Base(path)); err != nil { + fmt.Fprintf(os.Stderr, "error uploading %s: %v\n", path, err) + } else { + fmt.Fprintf(os.Stderr, "uploaded %s to Proton Drive\n", path) + } + } + } + + return nil + } + if err := renderer.Write(result, outputPath, appendMode, cfg.SingleFile); err != nil { return err } diff --git a/internal/config/config.go b/internal/config/config.go index 419ec66..f9993ef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -53,18 +53,19 @@ func (s *stringSlice) Set(v string) error { } type Config struct { - InputPath string - OutputPath string - AppendMode bool - Force bool - SingleFile bool - MaxSize int64 - MaxDepth int - Ignores []string - Extensions []string - AbsRoot string - ProtonDrive bool - DrivePath string + InputPath string + OutputPath string + AppendMode bool + Force bool + SingleFile bool + MaxSize int64 + MaxDepth int + Ignores []string + Extensions []string + AbsRoot string + ProtonDrive bool + DrivePath string + VerticalSlices bool } func PrintUsage() { @@ -77,17 +78,18 @@ func PrintUsage() { 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 + input string + output string + appendMd bool + force bool + maxSize int64 + maxDepth int + ignores stringSlice + exts stringSlice + showVer bool + protonDrive bool + drivePath string + verticalSlices bool ) flag.StringVar(&input, "i", "", "single file input (append mode only)") @@ -101,6 +103,7 @@ func Parse() (*Config, error) { 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.Usage = PrintUsage flag.Parse() @@ -118,16 +121,25 @@ func Parse() (*Config, error) { 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") + } + cfg := &Config{ - OutputPath: output, - AppendMode: appendMd, - Force: force, - MaxSize: maxSize, - MaxDepth: maxDepth, - Ignores: append(DefaultIgnores, ignores...), - Extensions: append(DefaultExts, exts...), - ProtonDrive: protonDrive, - DrivePath: drivePath, + OutputPath: output, + AppendMode: appendMd, + Force: force, + MaxSize: maxSize, + MaxDepth: maxDepth, + Ignores: append(DefaultIgnores, ignores...), + Extensions: append(DefaultExts, exts...), + ProtonDrive: protonDrive, + DrivePath: drivePath, + VerticalSlices: verticalSlices, } if input != "" { diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index 398c298..acb2f4c 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "unicode/utf8" @@ -13,6 +14,7 @@ import ( const fenceChars = "~~~" +// Write writes a single markdown file with tree and file contents. func Write(result *walker.Result, outputPath string, appendMode bool, singleFile bool) error { flags := os.O_CREATE | os.O_WRONLY if appendMode { @@ -29,20 +31,19 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile var sb strings.Builder - // Determine the correct H1 path h1Path := result.AbsRoot if singleFile && len(result.Entries) > 0 { h1Path = filepath.Join(result.AbsRoot, result.Entries[0].RelPath) } - fmt.Fprintf(&sb, "# %s\n\n", h1Path) + fmt.Fprintf(&sb, "# %s [ROOT]\n\n", h1Path) if !singleFile { root := tree.Build(result.Entries) rootName := filepath.Base(result.AbsRoot) sb.WriteString("## Directory Tree\n\n") sb.WriteString(fenceChars + "\n") - sb.WriteString(tree.Render(root, rootName)) + sb.WriteString(renderTreeForLLM(root, rootName)) sb.WriteString(fenceChars + "\n\n") } @@ -61,8 +62,9 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile } language := extToLang(filepath.Ext(entry.RelPath)) + lines := countLines(content) - fmt.Fprintf(&sb, "### ./%s\n\n", entry.RelPath) + 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) @@ -72,7 +74,7 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile if !strings.HasSuffix(content, "\n") { fmt.Fprintf(&sb, "\n") } - fmt.Fprintf(&sb, "%s\n\n", fenceChars) + fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars) } } @@ -80,6 +82,337 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile return err } +// WriteSlices generates a root markdown file plus one markdown per top-level directory. +// Returns the list of generated file paths. +func WriteSlices(result *walker.Result, outputPath string) ([]string, error) { + entries := result.Entries + if len(entries) == 0 { + return nil, fmt.Errorf("no entries to write") + } + + absRoot := result.AbsRoot + rootName := filepath.Base(absRoot) + + topLevelDirs := make(map[string][]walker.Entry) + rootFiles := make([]walker.Entry, 0) + + for _, e := range entries { + rel := e.RelPath + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) == 1 { + rootFiles = append(rootFiles, e) + } else { + firstPart := parts[0] + topLevelDirs[firstPart] = append(topLevelDirs[firstPart], e) + } + } + + outDir := filepath.Dir(outputPath) + + var generated []string + + // Write root file + rootPath := outputPath + generated = append(generated, rootPath) + + if err := writeRootSlice(rootPath, absRoot, rootName, rootFiles, topLevelDirs, outDir); err != nil { + return nil, fmt.Errorf("failed to write root slice: %w", err) + } + + // Write each top-level directory slice into a mirroring subdirectory + var dirNames []string + for k := range topLevelDirs { + dirNames = append(dirNames, k) + } + sort.Strings(dirNames) + + for _, dirName := range dirNames { + sliceName := strings.TrimSuffix(dirName, "/") + ".md" + sliceDir := filepath.Join(outDir, dirName) + if err := os.MkdirAll(sliceDir, 0755); err != nil { + return nil, fmt.Errorf("cannot create slice directory %s: %w", sliceDir, err) + } + slicePath := filepath.Join(sliceDir, sliceName) + generated = append(generated, slicePath) + + absDir := filepath.Join(absRoot, dirName) + dirEntries := topLevelDirs[dirName] + + // Strip dirName prefix for tree building only + treeEntries := make([]walker.Entry, len(dirEntries)) + for i, e := range dirEntries { + treeEntries[i] = walker.Entry{ + RelPath: strings.TrimPrefix(e.RelPath, dirName+string(filepath.Separator)), + Size: e.Size, + } + } + + if err := writeDirSlice(slicePath, absDir, absRoot, dirName, treeEntries, dirEntries, filepath.Base(outputPath)); err != nil { + return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err) + } + } + + return generated, nil +} + +func writeRootSlice(path, absRoot, rootName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry, outDir string) error { + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("cannot create root file: %w", err) + } + defer f.Close() + + var sb strings.Builder + + fmt.Fprintf(&sb, "# %s [ROOT]\n\n", absRoot) + + // Build combined tree with references + treeLines := buildTreeWithRefs(rootName, topLevelDirs, rootFiles) + sb.WriteString("## Directory Tree\n\n") + sb.WriteString(fenceChars + "\n") + sb.WriteString(treeLines) + sb.WriteString(fenceChars + "\n\n") + + // Vertical slices index for LLM navigation + sb.WriteString("## Vertical Slices Index\n\n") + sb.WriteString("These files contain the full contents of each top-level directory.\n") + sb.WriteString("Read the relevant slice file for detailed source code.\n\n") + 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 { + entries := topLevelDirs[dirName] + fileCount := len(entries) + totalBytes := int64(0) + for _, e := range entries { + totalBytes += e.Size + } + sliceFile := filepath.Join(dirName, strings.TrimSuffix(dirName, "/")+".md") + fmt.Fprintf(&sb, "| %s | %s/ | %d | %d |\n", sliceFile, dirName, fileCount, totalBytes) + } + 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 := readFile(fullPath) + 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") + fmt.Fprintf(&sb, "%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 writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntries []walker.Entry, rootFileName string) error { + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("cannot create slice file: %w", err) + } + defer f.Close() + + var sb strings.Builder + + fmt.Fprintf(&sb, "# %s [SLICE]\n\n", absDir) + fmt.Fprintf(&sb, "Root document: %s\n\n", rootFileName) + + sb.WriteString("## Directory Tree\n\n") + sb.WriteString(fenceChars + "\n") + + sliceTree := buildTreeFromEntries(dirName, treeEntries) + sb.WriteString(sliceTree) + sb.WriteString(fenceChars + "\n\n") + + if len(fileEntries) > 0 { + sb.WriteString("## Contents\n\n") + + sort.Slice(fileEntries, func(i, j int) bool { + return fileEntries[i].RelPath < fileEntries[j].RelPath + }) + + for _, entry := range fileEntries { + fullPath := filepath.Join(absRoot, entry.RelPath) + content, err := readFile(fullPath) + 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") + fmt.Fprintf(&sb, "%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 buildTreeWithRefs(rootName string, topLevelDirs map[string][]walker.Entry, rootFiles []walker.Entry) string { + var sb strings.Builder + sb.WriteString(rootName) + sb.WriteString("/\n") + + // Collect and sort directory names + var dirNames []string + for k := range topLevelDirs { + dirNames = append(dirNames, k) + } + sort.Strings(dirNames) + + // Sort root files + sort.Slice(rootFiles, func(i, j int) bool { + return rootFiles[i].RelPath < rootFiles[j].RelPath + }) + + // Merge directories and files into a single sorted list for display + type treeItem struct { + name string + isDir bool + sliceRef string + } + + items := make([]treeItem, 0, len(dirNames)+len(rootFiles)) + for _, d := range dirNames { + items = append(items, treeItem{ + name: d, + isDir: true, + sliceRef: filepath.Join(d, strings.TrimSuffix(d, "/")+".md"), + }) + } + 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] + } + } + + 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, "") + return sb.String() +} + +func renderNodeLLM(sb *strings.Builder, node *tree.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 + + sb.WriteString(prefix) + sb.WriteString("+- ") + + sb.WriteString(child.Name) + if child.IsDir { + sb.WriteString("/") + } + sb.WriteString("\n") + + if child.IsDir && child.Children != nil { + newPrefix := prefix + if isLast { + newPrefix += " " + } else { + newPrefix += "| " + } + renderNodeLLM(sb, child, newPrefix) + } + } +} + func readFile(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { @@ -131,3 +464,14 @@ func extToLang(ext string) string { } return "" } + +func countLines(content string) int { + if content == "" { + return 0 + } + count := strings.Count(content, "\n") + if len(content) > 0 && content[len(content)-1] != '\n' { + count++ + } + return count +}