add test suite

This commit is contained in:
2026-09-15 13:11:20 +01:00
parent 9fa410e3a1
commit 9562be5e34
16 changed files with 1233 additions and 267 deletions
+21
View File
@@ -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 ./...
+3 -2
View File
@@ -18,6 +18,7 @@ var (
rootCmd = &cobra.Command{ rootCmd = &cobra.Command{
Use: "dirmd [flags] <directory>", Use: "dirmd [flags] <directory>",
Short: "Generate markdown documentation from directory structure", Short: "Generate markdown documentation from directory structure",
Version: config.Version,
Long: `dirmd walks a directory and generates markdown files with: Long: `dirmd walks a directory and generates markdown files with:
- Directory tree structure - Directory tree structure
@@ -46,7 +47,7 @@ Examples:
dirmd -o ./docs ~/repos/myproject --proton-drive dirmd -o ./docs ~/repos/myproject --proton-drive
# Interactive TUI mode # Interactive TUI mode
dirmd --tui dirmd -i
# Pick specific files in vertical slices mode # 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`, dirmd -o ./docs ~/repos/myproject --vertical-slices --pick common/src/lib.rs --pick mssql-extractor/src/processor.rs`,
@@ -106,7 +107,7 @@ func run(cmd *cobra.Command, args []string) error {
} }
if outputPath == "" { 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 { if drivePath != "" && !protonDrive {
+2 -143
View File
@@ -1,19 +1,12 @@
package config package config
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)
const ( const (
DefaultMaxSize = 512 * 1024 DefaultMaxSize = 512 * 1024
DefaultMaxDepth = 20 DefaultMaxDepth = 20
Version = "0.1.4"
) )
var Version = "dev"
var DefaultIgnores = []string{ var DefaultIgnores = []string{
".git", "node_modules", "vendor", "bin", "go.mod", "go.sum", ".git", "node_modules", "vendor", "bin", "go.mod", "go.sum",
"target", "build", "dist", "Cargo.lock", ".idea", ".vscode", "target", "build", "dist", "Cargo.lock", ".idea", ".vscode",
@@ -41,11 +34,6 @@ var FrontendExts = []string{
".vue", ".svelte", ".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 { type Config struct {
InputPath string InputPath string
OutputPath string OutputPath string
@@ -67,132 +55,3 @@ type Config struct {
IncludeInstructions bool IncludeInstructions bool
PickPaths []string PickPaths []string
} }
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
includeInstructions bool
pickPaths stringSlice
)
flag.StringVar(&input, "i", "", "single file input (append mode only)")
flag.StringVar(&output, "o", "", "output markdown file path (required)")
flag.BoolVar(&appendMd, "a", false, "append to existing output file")
flag.BoolVar(&force, "f", false, "overwrite existing output file (non-append mode)")
flag.Int64Var(&maxSize, "max-size", DefaultMaxSize, "max file size in bytes")
flag.IntVar(&maxDepth, "max-depth", DefaultMaxDepth, "max directory recursion depth")
flag.Var(&ignores, "ignore", "additional ignore patterns (glob, repeatable)")
flag.Var(&exts, "extensions", "additional file extensions to skip (repeatable)")
flag.BoolVar(&showVer, "version", false, "print version and exit")
flag.BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally")
flag.StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (requires --proton-drive")
flag.BoolVar(&verticalSlices, "vertical-slices", false, "split output into root file + one markdown per top-level directory")
flag.BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types")
flag.BoolVar(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md")
flag.BoolVar(&omitTest, "omit-test", false, "exclude test files and strip #[cfg(test)] blocks")
flag.BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md")
flag.BoolVar(&includeInstructions, "instructions", false, "include .dirmd instructions file if present in root")
flag.Var(&pickPaths, "pick", "repeatable: relative path to file to include as pick (requires --vertical-slices)")
flag.Usage = PrintUsage
flag.Parse()
if showVer {
fmt.Printf("dirmd v%s\n", Version)
os.Exit(0)
}
if output == "" {
return nil, fmt.Errorf("-o is required")
}
if drivePath != "" && !protonDrive {
return nil, fmt.Errorf("--drive-path requires --proton-drive")
}
if verticalSlices && appendMd {
return nil, fmt.Errorf("--vertical-slices cannot be used with --append")
}
if verticalSlices && input != "" {
return nil, fmt.Errorf("--vertical-slices cannot be used with --input-file")
}
if skipFrontend && frontendOnly {
return nil, fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
}
if len(pickPaths) > 0 && !verticalSlices {
return nil, fmt.Errorf("--pick requires --vertical-slices")
}
excludedExts := append(DefaultExts, exts...)
if skipFrontend {
excludedExts = append(excludedExts, FrontendExts...)
}
cfg := &Config{
OutputPath: output,
AppendMode: appendMd,
Force: force,
MaxSize: maxSize,
MaxDepth: maxDepth,
Ignores: append(DefaultIgnores, ignores...),
Extensions: excludedExts,
ProtonDrive: protonDrive,
DrivePath: drivePath,
VerticalSlices: verticalSlices,
SkipFrontend: skipFrontend,
FrontendOnly: frontendOnly,
OmitTest: omitTest,
OmitMd: omitMd,
IncludeInstructions: includeInstructions,
PickPaths: pickPaths,
}
if input != "" {
cfg.SingleFile = true
cfg.InputPath = input
if !appendMd {
return nil, fmt.Errorf("single file input (-i) requires append mode (-a)")
}
} else {
args := flag.Args()
if len(args) < 1 {
return nil, fmt.Errorf("directory argument is required when -i is not used")
}
cfg.InputPath = args[0]
}
abs, err := filepath.Abs(cfg.InputPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve absolute path: %w", err)
}
cfg.AbsRoot = abs
if !appendMd {
if _, err := os.Stat(output); err == nil && !force {
return nil, fmt.Errorf("output file %s already exists (use -f to overwrite or -a to append)", output)
}
}
return cfg, nil
}
+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 return true
} }
if f.OmitMd && isNonReadmeMarkdown(path, name) { if f.OmitMd && IsNonReadmeMarkdown(path, name) {
return true return true
} }
if f.FrontendOnly && !isFrontendOrReadme(path, name) { if f.FrontendOnly && !IsFrontendOrReadme(path, name) {
return true return true
} }
if f.OmitTest && isTestFile(path, name) { if f.OmitTest && IsTestFile(path, name) {
return true return true
} }
@@ -68,25 +68,37 @@ func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool {
return false return false
} }
func isNonReadmeMarkdown(path, name string) bool { func IsNonReadmeMarkdown(path, name string) bool {
ext := strings.ToLower(filepath.Ext(path)) ext := strings.ToLower(filepath.Ext(path))
if ext != ".md" && ext != ".markdown" { if ext != ".md" && ext != ".markdown" {
return false 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)) ext := strings.ToLower(filepath.Ext(path))
for _, fe := range config.FrontendExts { for _, fe := range config.FrontendExts {
if strings.EqualFold(ext, fe) { if strings.EqualFold(ext, fe) {
return true 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)) ext := strings.ToLower(filepath.Ext(path))
switch ext { switch ext {
@@ -107,8 +119,7 @@ func isTestFile(path, name string) bool {
if strings.EqualFold(name, "conftest.py") { if strings.EqualFold(name, "conftest.py") {
return true return true
} }
slashPath := filepath.ToSlash(path) return IsTestDir(path)
return strings.Contains(slashPath, "/tests/")
} }
return false 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 }
+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")
}
}
@@ -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)
}
}
+15 -26
View File
@@ -84,7 +84,7 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
language := extToLang(filepath.Ext(entry.RelPath)) language := extToLang(filepath.Ext(entry.RelPath))
lines := countLines(content) 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) fmt.Fprintf(&sb, "%s", fenceChars)
if language != "" { if language != "" {
fmt.Fprintf(&sb, "%s", language) fmt.Fprintf(&sb, "%s", language)
@@ -161,7 +161,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e
generated = append(generated, pickInfoPaths(pickInfos)...) generated = append(generated, pickInfoPaths(pickInfos)...)
} }
if err := writeRootIndex(rootPath, absRoot, rootName, repoName, topLevelDirs, result.Commit, result.Instructions, treeRoot, result.Cfg, pickInfos); err != nil { if err := writeRootIndex(rootPath, absRoot, rootName, repoName, topLevelDirs, rootFiles, result.Commit, result.Instructions, treeRoot, result.Cfg, pickInfos); err != nil {
return nil, fmt.Errorf("failed to write root index: %w", err) return nil, fmt.Errorf("failed to write root index: %w", err)
} }
@@ -170,7 +170,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e
} }
for _, dirName := range dirNames { for _, dirName := range dirNames {
sliceName := strings.TrimSuffix(dirName, "/") + "_" + repoName + ".md" sliceName := dirName + "_" + repoName + ".md"
sliceDir := filepath.Join(outputDir, dirName) sliceDir := filepath.Join(outputDir, dirName)
if err := os.MkdirAll(sliceDir, 0755); err != nil { if err := os.MkdirAll(sliceDir, 0755); err != nil {
return nil, fmt.Errorf("cannot create slice directory %s: %w", sliceDir, err) return nil, fmt.Errorf("cannot create slice directory %s: %w", sliceDir, err)
@@ -283,7 +283,7 @@ func writePickFile(path, absRoot, relPath string, omitTest bool, commit *git.Com
fmt.Fprintf(&sb, "Tree: %s\n\n", tree.RenderBreadcrumb(absRoot, relPath)) fmt.Fprintf(&sb, "Tree: %s\n\n", tree.RenderBreadcrumb(absRoot, relPath))
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", relPath, infoSize(fullPath), lines) fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", relPath, len(content), lines)
sb.WriteString(fenceChars) sb.WriteString(fenceChars)
if language != "" { if language != "" {
sb.WriteString(language) sb.WriteString(language)
@@ -299,15 +299,7 @@ func writePickFile(path, absRoot, relPath string, omitTest bool, commit *git.Com
return err return err
} }
func infoSize(path string) int64 { 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 {
info, err := os.Stat(path)
if err != nil {
return 0
}
return info.Size()
}
func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[string][]walker.Entry, commit *git.CommitInfo, instContent string, treeRoot *tree.Node, cfg *config.Config, picks []PickInfo) error {
f, err := os.Create(path) f, err := os.Create(path)
if err != nil { if err != nil {
return fmt.Errorf("cannot create root index: %w", err) return fmt.Errorf("cannot create root index: %w", err)
@@ -337,8 +329,11 @@ func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[s
sb.WriteString("|------------|-----------|-------|-------------|\n") sb.WriteString("|------------|-----------|-------|-------------|\n")
rootSliceFile := "root_" + repoName + ".md" rootSliceFile := "root_" + repoName + ".md"
rootFileCount := len(topLevelDirs) rootFileCount := len(rootFiles)
rootTotalBytes := int64(0) rootTotalBytes := int64(0)
for _, e := range rootFiles {
rootTotalBytes += e.Size
}
fmt.Fprintf(&sb, "| %s | (root) | %d | %d |\n", rootSliceFile, rootFileCount, rootTotalBytes) fmt.Fprintf(&sb, "| %s | (root) | %d | %d |\n", rootSliceFile, rootFileCount, rootTotalBytes)
for _, dirName := range sortedKeys(topLevelDirs) { for _, dirName := range sortedKeys(topLevelDirs) {
@@ -348,7 +343,7 @@ func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[s
for _, e := range entries { for _, e := range entries {
totalBytes += e.Size 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) fmt.Fprintf(&sb, "| %s | %s/ | %d | %d |\n", sliceFile, dirName, fileCount, totalBytes)
} }
sb.WriteString("\n") sb.WriteString("\n")
@@ -398,7 +393,7 @@ func writeRootSliceFile(path, absRoot string, rootFiles []walker.Entry, omitTest
} }
language := extToLang(filepath.Ext(entry.RelPath)) language := extToLang(filepath.Ext(entry.RelPath))
lines := countLines(content) 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) fmt.Fprintf(&sb, "%s", fenceChars)
if language != "" { if language != "" {
fmt.Fprintf(&sb, "%s", language) fmt.Fprintf(&sb, "%s", language)
@@ -442,7 +437,7 @@ func writeDirSlice(path, absDir, absRoot, dirName string, fileEntries []walker.E
} }
language := extToLang(filepath.Ext(entry.RelPath)) language := extToLang(filepath.Ext(entry.RelPath))
lines := countLines(content) 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) fmt.Fprintf(&sb, "%s", fenceChars)
if language != "" { if language != "" {
fmt.Fprintf(&sb, "%s", language) fmt.Fprintf(&sb, "%s", language)
@@ -582,20 +577,13 @@ func stripTestBlocks(content string) string {
for i < len(lines) { for i < len(lines) {
line := lines[i] line := lines[i]
if !foundBrace {
if strings.Contains(line, "{") {
braceDepth += strings.Count(line, "{") - strings.Count(line, "}") braceDepth += strings.Count(line, "{") - strings.Count(line, "}")
if strings.Contains(line, "{") {
foundBrace = true foundBrace = true
} }
i++ i++
continue
}
braceDepth += strings.Count(line, "{") - strings.Count(line, "}") if foundBrace && braceDepth <= 0 {
i++
if braceDepth <= 0 {
break break
} }
} }
@@ -639,6 +627,7 @@ func extToLang(ext string) string {
".cpp": "cpp", ".cpp": "cpp",
".hpp": "cpp", ".hpp": "cpp",
".txt": "text", ".txt": "text",
".toml": "toml",
} }
if lang, ok := mapping[strings.ToLower(ext)]; ok { 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])
}
}
})
}
}
+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)
}
}
+21 -80
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"io/fs" "io/fs"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"git.flo.fo/FLO/dirmd/internal/config" "git.flo.fo/FLO/dirmd/internal/config"
@@ -40,12 +41,17 @@ func BuildFullTree(absRoot string, cfg *config.Config) (*Node, error) {
name := filepath.Base(path) name := filepath.Base(path)
if d.IsDir() { if d.IsDir() {
if isIgnoredName(name, cfg.Ignores) { if strings.HasPrefix(name, ".") {
node := addPath(root, rel, true) node := addPath(root, rel, true)
node.Annotation = "ignored (--ignore)" node.Annotation = "ignored (default)"
return filepath.SkipDir return filepath.SkipDir
} }
if cfg.OmitTest && isTestDir(rel) { if annotation, ok := ignoreReason(name, cfg.Ignores); ok {
node := addPath(root, rel, true)
node.Annotation = annotation
return filepath.SkipDir
}
if cfg.OmitTest && filter.IsTestDir(rel) {
node := addPath(root, rel, true) node := addPath(root, rel, true)
node.Annotation = "tests omitted (--omit-test)" node.Annotation = "tests omitted (--omit-test)"
return filepath.SkipDir return filepath.SkipDir
@@ -70,105 +76,40 @@ func BuildFullTree(absRoot string, cfg *config.Config) (*Node, error) {
return root, nil return root, nil
} }
func isIgnoredName(name string, ignores []string) bool { func ignoreReason(name string, ignores []string) (string, bool) {
if strings.HasPrefix(name, ".") {
return true
}
for _, pattern := range ignores { for _, pattern := range ignores {
if matched, _ := filepath.Match(pattern, name); matched { if matched, _ := filepath.Match(pattern, name); matched {
return true if slices.Contains(config.DefaultIgnores, pattern) {
return "ignored (default)", true
}
return "ignored (--ignore)", true
} }
} }
return false return "", false
}
func isTestDir(rel string) bool {
slashPath := filepath.ToSlash(rel)
return strings.Contains(slashPath, "/tests/") || strings.HasPrefix(slashPath, "tests/")
} }
func fileAnnotation(rel, name string, cfg *config.Config) (string, bool) { func fileAnnotation(rel, name string, cfg *config.Config) (string, bool) {
if strings.HasPrefix(name, ".") { if strings.HasPrefix(name, ".") {
return "", false return "", false
} }
for _, pattern := range cfg.Ignores { if annotation, ok := ignoreReason(name, cfg.Ignores); ok {
if matched, _ := filepath.Match(pattern, name); matched { return annotation, true
return "ignored (--ignore)", true
} }
} if cfg.OmitTest && filter.IsTestFile(rel, name) {
if cfg.OmitTest && isTestFileName(rel, name) {
return "tests omitted (--omit-test)", true return "tests omitted (--omit-test)", true
} }
if cfg.OmitMd && isNonReadmeMarkdown(name) { if cfg.OmitMd && filter.IsNonReadmeMarkdown(rel, name) {
return "omitted (--omit-md)", true return "omitted (--omit-md)", true
} }
if cfg.FrontendOnly && !isFrontendOrReadme(rel, name) { if cfg.FrontendOnly && !filter.IsFrontendOrReadme(rel, name) {
return "omitted (--frontend-only)", true return "omitted (--frontend-only)", true
} }
if cfg.SkipFrontend && isFrontendExt(rel) { if cfg.SkipFrontend && filter.IsFrontendExt(rel) {
return "omitted (--skip-frontend)", true return "omitted (--skip-frontend)", true
} }
return "", false return "", false
} }
func isTestFileName(rel, name string) bool {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".go":
return strings.HasSuffix(strings.ToLower(name), "_test.go")
case ".java":
base := strings.TrimSuffix(name, filepath.Ext(name))
if strings.HasSuffix(base, "Test") || strings.HasSuffix(base, "Tests") {
return true
}
slashPath := filepath.ToSlash(rel)
return strings.Contains(slashPath, "/src/test/")
case ".py":
lower := strings.ToLower(name)
if strings.HasPrefix(lower, "test_") || strings.HasSuffix(lower, "_test.py") {
return true
}
if strings.EqualFold(name, "conftest.py") {
return true
}
return isTestDir(rel)
}
return false
}
func isNonReadmeMarkdown(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
if ext != ".md" && ext != ".markdown" {
return false
}
return !strings.EqualFold(name, "README.md")
}
func isFrontendOrReadme(rel, name string) bool {
if isFrontendExt(rel) {
return true
}
return strings.EqualFold(name, "README.md")
}
func isFrontendExt(rel string) bool {
ext := strings.ToLower(filepath.Ext(rel))
for _, fe := range frontendExts {
if strings.EqualFold(ext, fe) {
return true
}
}
return false
}
var frontendExts = []string{
".html", ".htm", ".gohtml", ".tmpl",
".css", ".scss", ".sass", ".less",
".js", ".jsx", ".mjs", ".cjs",
".ts", ".tsx",
".vue", ".svelte",
}
func RenderBreadcrumb(absRoot, relPath string) string { func RenderBreadcrumb(absRoot, relPath string) string {
var sb strings.Builder var sb strings.Builder
rootName := filepath.Base(absRoot) rootName := filepath.Base(absRoot)
+2 -2
View File
@@ -56,8 +56,8 @@ func Run() error {
km.MultiSelect.Next = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next field")) 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.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.Up = key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up"))
km.MultiSelect.Down = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")) 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.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.Submit = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next"))
km.MultiSelect.Filter = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")) km.MultiSelect.Filter = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter"))
+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")
}
}
+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")
}
}