17 Commits
23 changed files with 1928 additions and 730 deletions
+1
View File
@@ -1,3 +1,4 @@
bin/
.env
vendor/
.dirmd
+20 -230
View File
@@ -4,248 +4,38 @@ Generate markdown documentation from directory structure. Optimized for LLM inge
<video src="./docs/videos/dirmd.webm" autoplay loop muted playsinline></video>
## 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/<filename>)
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/
## 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 |
|------|-------------|---------|
| `-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/<filename> |
| `--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. 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 (non-sliced mode):
# /home/user/repos/myproject [ROOT]
## 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.
+22
View File
@@ -0,0 +1,22 @@
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:
- rm ./bin/dirmd
- 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 ./...
+45 -44
View File
@@ -16,8 +16,9 @@ import (
var (
rootCmd = &cobra.Command{
Use: "dirmd [flags] <directory>",
Short: "Generate markdown documentation from directory structure",
Use: "dirmd [flags] <directory>",
Short: "Generate markdown documentation from directory structure",
Version: config.Version,
Long: `dirmd walks a directory and generates markdown files with:
- Directory tree structure
@@ -27,35 +28,29 @@ Output is always written to a directory specified by -o.
Examples:
# Create new documentation (writes index.md inside ./docs/)
# Create new documentation
dirmd -o ./docs ~/repos/myproject
# Overwrite existing output directory
# Overwrite existing output directory (deletes all contents)
dirmd -o ./docs ~/repos/myproject -f
# Append a single file
dirmd -a -i README.md -o ./docs
# Vertical slices (root index.md + one markdown per top-level dir)
# Vertical slices
dirmd -o ./docs ~/repos/myproject --vertical-slices
# Skip frontend files
dirmd -o ./docs ~/repos/myproject --skip-frontend
# Frontend only
dirmd -o ./docs ~/repos/myproject --frontend-only
# Omit test files and test blocks
dirmd -o ./docs ~/repos/myproject --omit-test
# Include instructions from .dirmd
dirmd -o ./docs ~/repos/myproject --instructions
# Generate and upload to Proton Drive
dirmd -o ./docs ~/repos/myproject --proton-drive
# Generate and upload to a specific remote path
dirmd -o ./docs ~/repos/myproject --proton-drive --drive-path /my-files/docs/api
# 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,
}
@@ -75,6 +70,8 @@ Examples:
omitTest bool
omitMd bool
tuiMode bool
instructions bool
pickPaths []string
)
func Execute() {
@@ -86,7 +83,7 @@ func Execute() {
func init() {
rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output directory (required)")
rootCmd.Flags().BoolVarP(&appendMode, "append", "a", false, "append to existing index.md")
rootCmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite existing output directory")
rootCmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite existing output directory (deletes all contents)")
rootCmd.Flags().StringVar(&singleFile, "input-file", "", "single file input (append mode only)")
rootCmd.Flags().BoolVarP(&tuiMode, "interactive", "i", false, "launch interactive TUI configuration wizard")
rootCmd.Flags().Int64Var(&maxSize, "max-size", config.DefaultMaxSize, "max file size in bytes")
@@ -100,6 +97,8 @@ func init() {
rootCmd.Flags().BoolVar(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md")
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 {
@@ -108,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 {
@@ -127,26 +126,32 @@ 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...)
}
cfg := &config.Config{
OutputPath: outputPath,
AppendMode: appendMode,
Force: force,
MaxSize: maxSize,
MaxDepth: maxDepth,
Ignores: append(config.DefaultIgnores, ignorePatterns...),
Extensions: excludedExts,
ProtonDrive: protonDrive,
DrivePath: drivePath,
VerticalSlices: verticalSlices,
SkipFrontend: skipFrontend,
FrontendOnly: frontendOnly,
OmitTest: omitTest,
OmitMd: omitMd,
OutputPath: outputPath,
AppendMode: appendMode,
Force: force,
MaxSize: maxSize,
MaxDepth: maxDepth,
Ignores: append(config.DefaultIgnores, ignorePatterns...),
Extensions: excludedExts,
ProtonDrive: protonDrive,
DrivePath: drivePath,
VerticalSlices: verticalSlices,
SkipFrontend: skipFrontend,
FrontendOnly: frontendOnly,
OmitTest: omitTest,
OmitMd: omitMd,
IncludeInstructions: instructions,
PickPaths: pickPaths,
}
if singleFile != "" {
@@ -174,8 +179,13 @@ func run(cmd *cobra.Command, args []string) error {
}
if !appendMode {
if _, err := os.Stat(absOutput); err == nil && !force {
return fmt.Errorf("output directory %s already exists (use -f to overwrite)", absOutput)
if _, err := os.Stat(absOutput); err == nil {
if !force {
return fmt.Errorf("output directory %s already exists (use -f to overwrite)", absOutput)
}
if err := os.RemoveAll(absOutput); err != nil {
return fmt.Errorf("failed to remove existing output directory: %w", err)
}
}
}
@@ -202,7 +212,6 @@ func run(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "generated %d files in %s\n", len(generated), absOutput)
if protonDrive {
@@ -210,27 +219,22 @@ func run(cmd *cobra.Command, args []string) error {
if remoteDir == "" {
remoteDir = "/my-files/md/" + filepath.Base(absOutput)
}
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
}
if err := drive.UploadDir(absOutput, remoteDir); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Fprintln(os.Stderr, "local files were written successfully, but upload failed")
return err
}
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir)
}
return nil
}
indexPath := filepath.Join(absOutput, indexBasename)
if err := renderer.Write(result, indexPath, appendMode, cfg.SingleFile); err != nil {
return err
}
@@ -242,19 +246,16 @@ func run(cmd *cobra.Command, args []string) error {
if remoteDir == "" {
remoteDir = "/my-files/md/" + filepath.Base(absOutput)
}
if err := drive.CheckAuth(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Fprintln(os.Stderr, "local file was written successfully, but upload was skipped")
return err
}
if err := drive.UploadDir(absOutput, remoteDir); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Fprintln(os.Stderr, "local file was written successfully, but upload failed")
return err
}
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir)
}
+25 -181
View File
@@ -1,36 +1,16 @@
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",
"dependency-reduced-pom.xml",
"__pycache__",
"venv",
"env",
".git", "node_modules", "vendor", "bin", "go.mod", "go.sum",
"target", "build", "dist", "Cargo.lock", ".idea", ".vscode",
"dependency-reduced-pom.xml", "__pycache__", "venv", "env", "uv.lock",
}
var DefaultExts = []string{
@@ -42,10 +22,7 @@ var DefaultExts = []string{
".pdf", ".doc", ".docx", ".xls", ".xlsx",
".pem", ".key", ".cert",
".db", ".sqlite", ".sqlite3",
".class",
".jar",
".war",
".ear",
".class", ".jar", ".war", ".ear",
".pyc", ".pyo",
}
@@ -57,157 +34,24 @@ 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
AppendMode bool
Force bool
SingleFile bool
MaxSize int64
MaxDepth int
Ignores []string
Extensions []string
AbsRoot string
ProtonDrive bool
DrivePath string
VerticalSlices bool
SkipFrontend bool
FrontendOnly bool
OmitTest bool
OmitMd bool
}
func PrintUsage() {
fmt.Fprintf(os.Stderr, "dirmd v%s\n\n", Version)
fmt.Fprintf(os.Stderr, "Usage: dirmd [flags] <directory>\n")
fmt.Fprintf(os.Stderr, " dirmd -a -i <file> -o <output>\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
)
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 (.html, .css, .js, .ts, .vue, .svelte, etc.)")
flag.BoolVar(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md")
flag.BoolVar(&omitTest, "omit-test", false, "exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust")
flag.BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md")
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,
}
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
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
SkipFrontend bool
FrontendOnly bool
OmitTest bool
OmitMd bool
IncludeInstructions bool
PickPaths []string
}
+81
View File
@@ -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)
}
})
}
}
+9
View File
@@ -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)
}
}
+21 -10
View File
@@ -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
+269
View File
@@ -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 }
+37
View File
@@ -0,0 +1,37 @@
package git
import (
"fmt"
"os/exec"
"strings"
)
type CommitInfo struct {
ShortSHA string
Message string
}
func GetCommitInfo(rootDir string) *CommitInfo {
cmd := exec.Command("git", "-C", rootDir, "log", "-1", "--format=%h %s")
output, err := cmd.Output()
if err != nil {
return nil
}
parts := strings.SplitN(strings.TrimSpace(string(output)), " ", 2)
if len(parts) < 2 {
return nil
}
return &CommitInfo{
ShortSHA: parts[0],
Message: parts[1],
}
}
func FormatCommitLine(info *CommitInfo) string {
if info == nil {
return ""
}
return fmt.Sprintf("> Commit: %s (%s)\n", info.ShortSHA, info.Message)
}
+48
View File
@@ -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")
}
}
+41
View File
@@ -0,0 +1,41 @@
package instructions
import (
"os"
"path/filepath"
"strings"
)
const FileName = ".dirmd"
func Read(rootDir string) (string, error) {
path := filepath.Join(rootDir, FileName)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return string(data), nil
}
func Format(content string) string {
if content == "" {
return ""
}
content = strings.TrimSpace(content)
if content == "" {
return ""
}
lines := strings.Split(content, "\n")
var sb strings.Builder
sb.WriteString("> Instructions:\n")
for _, line := range lines {
sb.WriteString("> ")
sb.WriteString(line)
sb.WriteString("\n")
}
sb.WriteString("\n")
return sb.String()
}
@@ -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)
}
}
+75
View File
@@ -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
}
+292 -177
View File
@@ -6,8 +6,13 @@ import (
"path/filepath"
"sort"
"strings"
"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"
"git.flo.fo/FLO/dirmd/internal/walker"
)
@@ -35,14 +40,31 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
h1Path = filepath.Join(result.AbsRoot, result.Entries[0].RelPath)
}
fmt.Fprintf(&sb, "# %s [ROOT]\n\n", h1Path)
fmt.Fprintf(&sb, "# %s [ROOT]\n", h1Path)
if !appendMode {
fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339))
sb.WriteString(git.FormatCommitLine(result.Commit))
if result.Instructions != "" {
sb.WriteString(instructions.Format(result.Instructions))
} 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")
}
@@ -53,7 +75,6 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
for _, entry := range result.Entries {
fullPath := filepath.Join(result.AbsRoot, entry.RelPath)
content, err := readFileForOutput(fullPath, result.OmitTest)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
@@ -63,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)
@@ -104,14 +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); 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
@@ -121,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)
@@ -132,20 +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); 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)
}
}
@@ -153,21 +189,137 @@ 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) 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 [ROOT]\n\n", absRoot)
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()
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))
if instContent != "" {
sb.WriteString(instructions.Format(instContent))
} 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")
@@ -176,61 +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")
fmt.Fprintf(&sb, "%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) 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)
@@ -238,24 +417,17 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri
defer f.Close()
var sb strings.Builder
fmt.Fprintf(&sb, "# %s [SLICE]\n\n", absDir)
fmt.Fprintf(&sb, "# %s [SLICE]\n", absDir)
fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339))
sb.WriteString(git.FormatCommitLine(commit))
sb.WriteString(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")
sort.Slice(fileEntries, func(i, j int) bool {
return fileEntries[i].RelPath < fileEntries[j].RelPath
})
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 := readFileForOutput(fullPath, omitTest)
@@ -263,17 +435,14 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri
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, "--- 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")
fmt.Fprintf(&sb, "%s", content)
fmt.Fprintf(&sb, "\n%s", content)
if !strings.HasSuffix(content, "\n") {
fmt.Fprintf(&sb, "\n")
}
@@ -285,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()
}
@@ -393,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 {
@@ -407,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 {
@@ -456,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
}
}
@@ -513,6 +627,7 @@ func extToLang(ext string) string {
".cpp": "cpp",
".hpp": "cpp",
".txt": "text",
".toml": "toml",
}
if lang, ok := mapping[strings.ToLower(ext)]; ok {
+202
View File
@@ -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])
}
}
})
}
}
+23 -63
View File
@@ -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
}
+90
View File
@@ -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)
}
}
+130
View File
@@ -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()
}
+19 -16
View File
@@ -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"))
@@ -79,6 +79,7 @@ func Run() error {
huh.NewOption("Omit test files", "omit-test"),
huh.NewOption("Omit non-README markdown", "omit-md"),
huh.NewOption("Upload to Proton Drive", "proton-drive"),
huh.NewOption("Include .dirmd instructions", "instructions"),
).
Value(&options).
Height(10),
@@ -115,6 +116,7 @@ func Run() error {
omitTest := containsOption(options, "omit-test")
omitMd := containsOption(options, "omit-md")
protonDrive := containsOption(options, "proton-drive")
includeInstructions := containsOption(options, "instructions")
if skipFrontend && frontendOnly {
return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
@@ -155,20 +157,21 @@ func Run() error {
allIgnores = append(allIgnores, ignores...)
cfg := &config.Config{
InputPath: inputDir,
OutputPath: outputDir,
Force: force,
MaxSize: int64(maxSizeKB) * 1024,
MaxDepth: maxDepth,
Ignores: allIgnores,
Extensions: excludedExts,
ProtonDrive: protonDrive,
DrivePath: drivePath,
VerticalSlices: verticalSlices,
SkipFrontend: skipFrontend,
FrontendOnly: frontendOnly,
OmitTest: omitTest,
OmitMd: omitMd,
InputPath: inputDir,
OutputPath: outputDir,
Force: force,
MaxSize: int64(maxSizeKB) * 1024,
MaxDepth: maxDepth,
Ignores: allIgnores,
Extensions: excludedExts,
ProtonDrive: protonDrive,
DrivePath: drivePath,
VerticalSlices: verticalSlices,
SkipFrontend: skipFrontend,
FrontendOnly: frontendOnly,
OmitTest: omitTest,
OmitMd: omitMd,
IncludeInstructions: includeInstructions,
}
cmd := buildCommand(cfg)
+27
View File
@@ -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")
}
}
+33 -9
View File
@@ -9,6 +9,8 @@ import (
"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"
)
type Entry struct {
@@ -17,10 +19,13 @@ type Entry struct {
}
type Result struct {
Entries []Entry
AbsRoot string
SingleFile bool
OmitTest bool
Entries []Entry
AbsRoot string
SingleFile bool
OmitTest bool
Cfg *config.Config
Commit *git.CommitInfo
Instructions string
}
func Run(cfg *config.Config) (*Result, error) {
@@ -53,11 +58,19 @@ func processSingleFile(cfg *config.Config) (*Result, error) {
return nil, fmt.Errorf("file %s filtered out", cfg.AbsRoot)
}
commitInfo := git.GetCommitInfo(filepath.Dir(cfg.AbsRoot))
var instContent string
if cfg.IncludeInstructions {
instContent, _ = instructions.Read(filepath.Dir(cfg.AbsRoot))
}
return &Result{
Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}},
AbsRoot: filepath.Dir(cfg.AbsRoot),
SingleFile: true,
OmitTest: cfg.OmitTest,
Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}},
AbsRoot: filepath.Dir(cfg.AbsRoot),
SingleFile: true,
Cfg: cfg,
Commit: commitInfo,
Instructions: instContent,
}, nil
}
@@ -71,7 +84,18 @@ 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 {
fmt.Fprintf(os.Stderr, "warning: could not read .dirmd: %v\n", err)
} else {
result.Instructions = instContent
}
}
err := filepath.WalkDir(cfg.AbsRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
+358
View File
@@ -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")
}
}