diff --git a/README.md b/README.md index 7d2a889..b47c5fa 100644 --- a/README.md +++ b/README.md @@ -4,273 +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 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 -- 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 + dirmd -o docs ~/repos/myproject --vertical-slices \ + --pick common/src/config.rs --pick init/src/main.rs - # Create new documentation - dirmd -o output.md ~/repos/myproject +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. - # 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 - - # 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. - -## 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: - -- **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/ - -## 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. - -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. -- **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 | -|------|-------------|---------| -| `-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 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) | | -| `--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 | - -## 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. - -### 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.md ~/repos/project -f --vertical-slices - - # Skip frontend and omit tests - dirmd -o docs.md ~/repos/project -f --skip-frontend --omit-test - - # Frontend only with test omission - dirmd -o docs.md ~/repos/project -f --frontend-only --omit-test - - # Generate, then upload to default remote location - dirmd -o docs.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 - - # Overwrite existing local file and upload to Drive - dirmd -o docs.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. 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 - -Example output structure (non-sliced mode): - - # /home/user/repos/myproject [ROOT] - > Generated: 2026-09-02T14:30:00Z - > Commit: a1b2c3d (Fix login bug) - - ## Directory Tree - - ~~~ - myproject/ - +- cmd/ - | +- main.go - +- internal/ - | +- config.go - ~~~ - - ## 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. 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 a9cdc1d..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,10 @@ 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`, RunE: run, } @@ -67,6 +71,7 @@ Examples: omitMd bool tuiMode bool instructions bool + pickPaths []string ) func Execute() { @@ -93,6 +98,7 @@ 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)") } func run(cmd *cobra.Command, args []string) error { @@ -101,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 { @@ -120,6 +126,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 +151,7 @@ func run(cmd *cobra.Command, args []string) error { OmitTest: omitTest, OmitMd: omitMd, IncludeInstructions: instructions, + PickPaths: pickPaths, } if singleFile != "" { diff --git a/internal/config/config.go b/internal/config/config.go index 5dae068..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.0" ) +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 @@ -65,127 +53,5 @@ type Config struct { OmitTest bool OmitMd bool IncludeInstructions bool -} - -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 - ) - - 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.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") - } - - 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, - } - - 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 + PickPaths []string } 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/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 3707020..397115a 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -9,6 +9,8 @@ import ( "time" "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" @@ -49,14 +51,20 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile } else { sb.WriteString("\n") } + + sb.WriteString(FormatFlags(result.Cfg)) } 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") } @@ -76,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) @@ -117,13 +125,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 @@ -133,7 +137,40 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e sort.Strings(dirNames) for _, dirName := range dirNames { - sliceName := strings.TrimSuffix(dirName, "/") + "_" + repoName + ".md" + 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) + + rootSliceBasename := "root_" + repoName + ".md" + rootSlicePath := filepath.Join(outputDir, rootSliceBasename) + generated = append(generated, rootSlicePath) + + pickInfos := make([]PickInfo, 0) + + if len(result.Cfg.PickPaths) > 0 { + 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) + } + generated = append(generated, pickInfoPaths(pickInfos)...) + } + + 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) + } + + 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) + } + + for _, dirName := range dirNames { + 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) @@ -144,17 +181,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, result.Instructions); 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) } } @@ -162,10 +189,120 @@ 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 { +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, outputDir string, cfg *config.Config, omitTest bool, commit *git.CommitInfo) ([]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) + } + + name := filepath.Base(pickPath) + flt := &filter.Filter{ + MaxSize: cfg.MaxSize, + Extensions: cfg.Extensions, + Ignores: cfg.Ignores, + OmitTest: cfg.OmitTest, + FrontendOnly: cfg.FrontendOnly, + OmitMd: cfg.OmitMd, + } + + if flt.ShouldSkipFile(fullPath, name, info) { + return nil, fmt.Errorf("pick path %s filtered by active rules", pickPath) + } + + 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) + } + } + + sliceName := filepath.Base(pickPath) + ".md" + pickPathOut := filepath.Join(sliceDir, sliceName) + + if err := writePickFile(pickPathOut, absRoot, pickPath, omitTest, commit, cfg); 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) error { f, err := os.Create(path) if err != nil { - return fmt.Errorf("cannot create root file: %w", err) + 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)) + fmt.Fprintf(&sb, "Root document: index_%s.md\n\n", filepath.Base(absRoot)) + + 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) + + fmt.Fprintf(&sb, "Tree: %s\n\n", tree.RenderBreadcrumb(absRoot, relPath)) + + fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", relPath, len(content), lines) + sb.WriteString(fenceChars) + if language != "" { + sb.WriteString(language) + } + sb.WriteString("\n") + sb.WriteString(content) + if !strings.HasSuffix(content, "\n") { + sb.WriteString("\n") + } + fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars) + + _, err = f.WriteString(sb.String()) + return err +} + +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) } defer f.Close() @@ -178,11 +315,11 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker } else { sb.WriteString("\n") } + sb.WriteString(FormatFlags(cfg)) - 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") @@ -191,54 +328,88 @@ 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) + rootSliceFile := "root_" + repoName + ".md" + rootFileCount := len(rootFiles) + rootTotalBytes := int64(0) + for _, e := range rootFiles { + rootTotalBytes += e.Size } - sort.Strings(dirNames) + fmt.Fprintf(&sb, "| %s | (root) | %d | %d |\n", rootSliceFile, rootFileCount, rootTotalBytes) - for _, dirName := range dirNames { + for _, dirName := range sortedKeys(topLevelDirs) { entries := topLevelDirs[dirName] fileCount := len(entries) totalBytes := int64(0) 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") - 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) + 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 } -func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntries []walker.Entry, rootFileName string, omitTest bool, commit *git.CommitInfo, instContent string) 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) + } + 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)) + + rootName := filepath.Base(absRoot) + fmt.Fprintf(&sb, "Tree: %s /\n\n", rootName) + + 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, len(content), 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 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) @@ -249,18 +420,10 @@ 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(FormatFlags(cfg)) 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") + fmt.Fprintf(&sb, "Tree: %s / %s\n\n", filepath.Base(absRoot), dirName) if len(fileEntries) > 0 { sb.WriteString("## Contents\n\n") @@ -274,7 +437,7 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri } 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) @@ -291,93 +454,25 @@ 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) +func sortedKeys(m map[string][]walker.Entry) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, 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() + sort.Strings(keys) + return keys } -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() } @@ -399,6 +494,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 { @@ -413,6 +516,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 { @@ -462,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 } } @@ -519,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.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/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 new file mode 100644 index 0000000..8c894c5 --- /dev/null +++ b/internal/tree/unfiltered.go @@ -0,0 +1,130 @@ +package tree + +import ( + "fmt" + "io/fs" + "path/filepath" + "slices" + "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 strings.HasPrefix(name, ".") { + node := addPath(root, rel, true) + node.Annotation = "ignored (default)" + return filepath.SkipDir + } + 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 + } + 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 ignoreReason(name string, ignores []string) (string, bool) { + for _, pattern := range ignores { + if matched, _ := filepath.Match(pattern, name); matched { + if slices.Contains(config.DefaultIgnores, pattern) { + return "ignored (default)", true + } + return "ignored (--ignore)", true + } + } + return "", false +} + +func fileAnnotation(rel, name string, cfg *config.Config) (string, bool) { + if strings.HasPrefix(name, ".") { + return "", false + } + if annotation, ok := ignoreReason(name, cfg.Ignores); ok { + return annotation, true + } + if cfg.OmitTest && filter.IsTestFile(rel, name) { + return "tests omitted (--omit-test)", true + } + if cfg.OmitMd && filter.IsNonReadmeMarkdown(rel, name) { + return "omitted (--omit-md)", true + } + if cfg.FrontendOnly && !filter.IsFrontendOrReadme(rel, name) { + return "omitted (--frontend-only)", true + } + if cfg.SkipFrontend && filter.IsFrontendExt(rel) { + return "omitted (--skip-frontend)", true + } + return "", false +} + +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() +} 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.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 { 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") + } +}