67 Commits
Author SHA1 Message Date
bartal-lsn d7d94b26a0 Merge pull request 'v0.1.4' (#15) from v-1-4 into main
Reviewed-on: #15
2026-09-15 13:12:50 +01:00
bartal-lsn 9562be5e34 add test suite 2026-09-15 13:11:20 +01:00
bartal-lsn 9fa410e3a1 short readme 2026-09-15 12:10:23 +01:00
bartal-lsn c5dfbb3502 remove small-tree flag and only option to have bread crumbs in sub index files 2026-09-15 12:01:31 +01:00
bartal-lsn 84bea485ec add readme reconsiliation 2026-09-15 11:43:08 +01:00
bartal-lsn b63f0ba927 --pick flag 2026-09-15 11:42:41 +01:00
bartal-lsn c72c0d0b79 thin index + root files 2026-09-15 11:27:53 +01:00
bartal-lsn 35c61baa8d flags provenance block in index and slices 2026-09-15 11:22:44 +01:00
bartal-lsn 4396a6f619 unfiltered walker tree 2026-09-15 11:16:27 +01:00
bartal-lsn 6c365e9cde instructions only in index, not slices 2026-09-15 10:46:23 +01:00
bartal-lsn f908bffc41 ignore uv.lock 2026-09-03 17:27:00 +01:00
bartal-lsn a1638bd944 add .dirmd and other metadata 2026-09-02 12:01:42 +01:00
bartal-lsn 93d830d500 fix pre delete dirmd set dir bug and implement last git sha and commit# 2026-09-02 11:43:45 +01:00
bartal-lsn 89b7eb78a7 add timestamp to dirmd files 2026-09-02 10:39:55 +01:00
bartal-lsn b1dd808d1e add python support 2026-08-29 21:58:16 +01:00
bartal-lsn d247095e87 fix(drive cli)!: delete remote flag changed 2026-08-28 14:08:58 +01:00
bartal-lsn 66579d068d webm 2026-08-28 01:14:26 +01:00
bartal-lsn 4c14ef2cee webm 2026-08-28 01:10:37 +01:00
bartal-lsn 0f94e50746 webm 2026-08-28 01:07:41 +01:00
bartal-lsn c9aa028b3c webm 2026-08-28 01:01:49 +01:00
bartal-lsn bca125cb92 Update README.md 2026-08-28 00:50:13 +01:00
bartal-lsn 7d73901631 Update README.md 2026-08-28 00:49:26 +01:00
bartal-lsn 6663cb25f8 webm 2026-08-28 00:42:23 +01:00
bartal-lsn 8a28364839 Update README.md 2026-08-28 00:38:34 +01:00
bartal-lsn 894f97820e webm 2026-08-28 00:37:43 +01:00
bartal-lsn fb646a94be Update README.md 2026-08-28 00:32:49 +01:00
bartal-lsn 4bcae1358d Update README.md 2026-08-28 00:32:33 +01:00
bartal-lsn 2428a33046 Update README.md 2026-08-28 00:31:40 +01:00
bartal-lsn a48fe55d4b Update README.md 2026-08-28 00:29:47 +01:00
bartal-lsn 0df346dbf9 Update README.md 2026-08-28 00:29:18 +01:00
bartal-lsn d7f4c6826b Update README.md 2026-08-28 00:27:34 +01:00
bartal-lsn 79be211df3 test 2026-08-28 00:26:00 +01:00
bartal-lsn ba87d6184b test webm 2026-08-28 00:24:20 +01:00
bartal-lsn 180c3f0eba test webm 2026-08-28 00:19:24 +01:00
bartal-lsn be0c761cb3 fix bugs 2026-08-28 00:06:14 +01:00
bartal-lsn 4ebf3bd60f delete redundant docs 2026-08-27 23:04:07 +01:00
bartal-lsn 63547e74c2 add how to install 2026-08-27 22:44:53 +01:00
bartal-lsn 79b630b622 add how to install 2026-08-27 22:43:22 +01:00
bartal-lsn 4520ad3dfd build(new mod): change mod name
change the module name from dirmd to git.flo.fo/FLO/dirmd

Also change all the imports in the module
2026-08-27 22:33:02 +01:00
bartal-lsn 4072ca4a45 fix: correct module path 2026-08-27 22:24:34 +01:00
bartal-lsn ba81eecea8 post commit hook added to readme 2026-08-27 22:07:48 +01:00
bartal-lsn 287d60e978 implement huh 2026-08-27 22:03:04 +01:00
bartal-lsn e33682098a revert(remove file): removed text file
I added a file
2026-08-27 20:33:34 +01:00
bartal-lsn cbf00f3e33 feat(new file): this is a new txt file
here is some
2026-08-27 20:31:43 +01:00
bartal-lsn ab670b3ecd use huh 2026-08-27 20:09:59 +01:00
bartal-lsn 8bfd6b22c5 remove append# 2026-08-27 19:07:53 +01:00
bartal-lsn adc276c28a much better 2026-08-27 18:24:23 +01:00
bartal-lsn 73c2009139 tui better 2026-08-27 17:57:23 +01:00
bartal-lsn 71961f68da working on tui 2026-08-27 15:49:18 +01:00
bartal-lsn 3ea14b91c0 add tui 2026-08-27 15:24:18 +01:00
bartal-lsn a2afce3f8d add omit md 2026-08-27 14:42:00 +01:00
bartal-lsn 2bbbf4e81e add only frontend 2026-08-27 14:20:33 +01:00
bartal-lsn 1f5243d953 add omit test files and only frontend 2026-08-27 14:00:24 +01:00
bartal-lsn 95b5a7b70c remove go mod and go sum from diormd, into ingore 2026-08-24 10:36:04 +01:00
bartal-lsn 7f05519f6f ignore cargo.locak files 2026-08-17 11:14:07 +01:00
bartal-lsn 72bee49d3f add postfix repos name to all files 2026-08-13 20:53:21 +01:00
bartal-lsn 1e913c784e try gif stuff 2026-08-13 12:51:27 +01:00
bartal-lsn bc2cc0b531 docs for post commit hook with dirmd 2026-08-08 13:55:14 +01:00
bartal-lsn fa661300bf remove test files 2026-08-08 13:48:47 +01:00
bartal-lsn 6eaf4727f9 test2 2026-08-08 13:47:50 +01:00
bartal-lsn 55684367e6 hook test 2026-08-08 13:47:21 +01:00
bartal-lsn a483371dc5 fixed correct proton drive upload with vertical slices 2026-08-08 13:45:04 +01:00
bartal-lsn b60588c4a0 ignore java dependency files 2026-08-07 23:43:59 +01:00
bartal-lsn 9951a5947f ability to omit front end files like .css .js .html and so on 2026-08-07 23:26:43 +01:00
bartal-lsn 6fbb9bf983 finished vertical-.slices 2026-08-07 23:10:42 +01:00
bartal-lsn 8c39068453 fixed remote dir typo 2026-08-05 10:33:46 +01:00
bartal-lsn bccf9bf198 simplify readme and make a force flag for local overrides 2026-08-05 10:30:20 +01:00
30 changed files with 2867 additions and 347 deletions
+1
View File
@@ -1,3 +1,4 @@
bin/
.env
vendor/
.dirmd
+23 -65
View File
@@ -1,83 +1,41 @@
# dirmd
Generate markdown documentation from directory structure.
Generate markdown documentation from directory structure. Optimized for LLM ingestion.
## Features
<video src="./docs/videos/dirmd.webm" autoplay loop muted playsinline></video>
- Recursive directory traversal
- Tree visualization (standard tree command format)
- Full file contents with syntax-highlighted code blocks
- Filters dotfiles, binaries, and blacklisted extensions
- Append mode for single files or directories
- Configurable depth, file size limits, ignore patterns
## Install
## Build
go install git.flo.fo/FLO/dirmd@latest
go build -o dirmd .
Linux only. Run `dirmd -h` for all flags.
## Usage
## Why
# Create new documentation
dirmd -o output.md ~/repos/myproject
# Append a single file
dirmd -a -i README.md -o existing.md
# Append another directory
dirmd -a ~/repos/anotherproject -o existing.md
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.
## Flags
## Three ways to use it
| Flag | Description | Default |
|------|-------------|---------|
| `-o`, `--output` | Output markdown file path (required) | |
| `-a`, `--append` | Append to existing output file | false |
| `-i`, `--input-file` | Single file input (append mode only) | |
| `--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 | |
**One file — small repos.**
For small projects that fit in a context window comfortably:
## Examples
dirmd -o out.md ~/repos/myproject
# 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
Everything lands in a single markdown file. Simplest, works fine until it doesn't.
## Output Format
**Vertical slices — large repos.**
Once a single file gets too big to paste repeatedly, split it:
Produces markdown with:
dirmd -o docs ~/repos/myproject --vertical-slices
1. Absolute path as H1 header
2. Directory tree (indented with 4 spaces per level)
3. All text files with content in code blocks
4. Language detection from file extensions
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.
Example output structure:
**Slices + picks — you already know which files matter.**
Sometimes you need one specific file without its whole directory:
# /home/user/repos/myproject
## Directory Tree
myproject/
├── cmd/
│ └── main.go
└── internal/
└── config.go
## Contents
### ./cmd/main.go
```go
package main
func main() {}
```
dirmd -o docs ~/repos/myproject --vertical-slices \
--pick common/src/config.rs --pick init/src/main.rs
Note: Uses tilde fences (~~~) internally to avoid conflicts with nested backticks in source files.
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.
Rule of thumb: one file for tiny repos, slices when you need to browse, picks when you already know the answer.
+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 ./...
+154 -45
View File
@@ -7,42 +7,56 @@ import (
"github.com/spf13/cobra"
"dirmd/internal/config"
"dirmd/internal/drive"
"dirmd/internal/renderer"
"dirmd/internal/walker"
"git.flo.fo/FLO/dirmd/internal/config"
"git.flo.fo/FLO/dirmd/internal/drive"
"git.flo.fo/FLO/dirmd/internal/renderer"
"git.flo.fo/FLO/dirmd/internal/tui"
"git.flo.fo/FLO/dirmd/internal/walker"
)
var (
rootCmd = &cobra.Command{
Use: "dirmd [flags] <directory>",
Short: "Generate markdown documentation from directory structure",
Long: `dirmd walks a directory and generates a markdown file with:
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
- Directory tree structure
- All readable text files with their contents
Output is always written to a directory specified by -o.
Examples:
# Create new documentation
dirmd -o output.md ~/repos/myproject
dirmd -o ./docs ~/repos/myproject
# Append a single file
dirmd -a -i README.md -o existing.md
# Overwrite existing output directory (deletes all contents)
dirmd -o ./docs ~/repos/myproject -f
# Append another directory
dirmd -a -o existing.md ~/repos/anotherproject
# Append a single file
dirmd -a -i README.md -o ./docs
# Generate and upload to Proton Drive (default remote path /my-files/md/<filename>)
dirmd -o output.md ~/repos/myproject --proton-drive
# Vertical slices
dirmd -o ./docs ~/repos/myproject --vertical-slices
# Generate and upload to a specific remote path
dirmd -o output.md ~/repos/myproject --proton-drive --drive-path /my-files/docs/api.md`,
# Include instructions from .dirmd
dirmd -o ./docs ~/repos/myproject --instructions
# Generate and upload to Proton Drive
dirmd -o ./docs ~/repos/myproject --proton-drive
# Interactive TUI mode
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,
}
outputPath string
appendMode bool
force bool
singleFile string
maxSize int64
maxDepth int
@@ -50,6 +64,14 @@ Examples:
extensions []string
protonDrive bool
drivePath string
verticalSlices bool
skipFrontend bool
frontendOnly bool
omitTest bool
omitMd bool
tuiMode bool
instructions bool
pickPaths []string
)
func Execute() {
@@ -59,33 +81,77 @@ func Execute() {
}
func init() {
rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output markdown file path (required)")
rootCmd.Flags().BoolVarP(&appendMode, "append", "a", false, "append to existing output file")
rootCmd.Flags().StringVarP(&singleFile, "input-file", "i", "", "single file input (append mode only)")
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 (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")
rootCmd.Flags().IntVar(&maxDepth, "max-depth", config.DefaultMaxDepth, "max directory recursion depth")
rootCmd.Flags().StringArrayVar(&ignorePatterns, "ignore", nil, "additional ignore patterns (glob)")
rootCmd.Flags().StringArrayVar(&extensions, "extensions", nil, "additional file extensions to skip (repeatable)")
rootCmd.Flags().BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally")
rootCmd.Flags().StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (e.g. /my-files/md/report.md). Requires --proton-drive")
rootCmd.MarkPersistentFlagRequired("output")
rootCmd.Flags().BoolVar(&protonDrive, "proton-drive", false, "upload output directory to Proton Drive after writing locally")
rootCmd.Flags().StringVar(&drivePath, "drive-path", "", "full remote directory on Proton Drive (e.g. /my-files/docs/api). Requires --proton-drive")
rootCmd.Flags().BoolVar(&verticalSlices, "vertical-slices", false, "split output into root index.md + one markdown per top-level directory")
rootCmd.Flags().BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.)")
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 {
if tuiMode {
return tui.Run()
}
if outputPath == "" {
return fmt.Errorf("-o is required (or use --interactive for interactive mode)")
}
if drivePath != "" && !protonDrive {
return fmt.Errorf("--drive-path requires --proton-drive")
}
if verticalSlices && appendMode {
return fmt.Errorf("--vertical-slices cannot be used with --append")
}
if verticalSlices && singleFile != "" {
return fmt.Errorf("--vertical-slices cannot be used with --input-file")
}
if skipFrontend && frontendOnly {
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,
MaxSize: maxSize,
MaxDepth: maxDepth,
Ignores: append(config.DefaultIgnores, ignorePatterns...),
Extensions: append(config.DefaultExts, extensions...),
ProtonDrive: protonDrive,
DrivePath: drivePath,
OutputPath: outputPath,
AppendMode: appendMode,
Force: force,
MaxSize: maxSize,
MaxDepth: maxDepth,
Ignores: append(config.DefaultIgnores, ignorePatterns...),
Extensions: excludedExts,
ProtonDrive: protonDrive,
DrivePath: drivePath,
VerticalSlices: verticalSlices,
SkipFrontend: skipFrontend,
FrontendOnly: frontendOnly,
OmitTest: omitTest,
OmitMd: omitMd,
IncludeInstructions: instructions,
PickPaths: pickPaths,
}
if singleFile != "" {
@@ -107,13 +173,29 @@ func run(cmd *cobra.Command, args []string) error {
}
cfg.AbsRoot = abs
// Non-append mode: output must not exist
absOutput, err := filepath.Abs(outputPath)
if err != nil {
return fmt.Errorf("failed to resolve output path: %w", err)
}
if !appendMode {
if _, err := os.Stat(outputPath); err == nil {
return fmt.Errorf("output file %s already exists (use -a to append)", outputPath)
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)
}
}
}
if err := os.MkdirAll(absOutput, 0755); err != nil {
return fmt.Errorf("cannot create output directory: %w", err)
}
repoName := filepath.Base(cfg.AbsRoot)
fmt.Printf("%s\n", absOutput)
result, err := walker.Run(cfg)
if err != nil {
return err
@@ -123,31 +205,58 @@ func run(cmd *cobra.Command, args []string) error {
return fmt.Errorf("no readable text files found in %s", cfg.AbsRoot)
}
if err := renderer.Write(result, outputPath, appendMode, cfg.SingleFile); err != nil {
indexBasename := "index_" + repoName + ".md"
if verticalSlices {
generated, err := renderer.WriteSlices(result, absOutput, repoName)
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "generated %d files in %s\n", len(generated), absOutput)
if protonDrive {
remoteDir := drivePath
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
}
fmt.Fprintf(os.Stderr, "wrote %d entries to %s\n", len(result.Entries), outputPath)
fmt.Fprintf(os.Stderr, "wrote %d entries to %s\n", len(result.Entries), indexPath)
if protonDrive {
remotePath := drivePath
if remotePath == "" {
remotePath = "/my-files/md/" + filepath.Base(outputPath)
remoteDir := drivePath
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.Upload(outputPath, remotePath); err != nil {
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", outputPath, remotePath)
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir)
}
return nil
Binary file not shown.
Binary file not shown.
+29 -2
View File
@@ -1,10 +1,37 @@
module dirmd
module git.flo.fo/FLO/dirmd
go 1.26.4
require github.com/spf13/cobra v1.10.2
require (
charm.land/bubbles/v2 v2.2.1
charm.land/huh/v2 v2.0.3
github.com/spf13/cobra v1.10.2
)
require (
charm.land/bubbletea/v2 v2.0.9 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-runewidth v0.0.27 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
)
+66
View File
@@ -1,10 +1,76 @@
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+32 -131
View File
@@ -1,29 +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",
"target",
"build",
"dist",
".idea",
".vscode",
".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{
@@ -35,122 +22,36 @@ var DefaultExts = []string{
".pdf", ".doc", ".docx", ".xls", ".xlsx",
".pem", ".key", ".cert",
".db", ".sqlite", ".sqlite3",
".class", // Java compiled bytecode
".jar", // JAR archives
".war",
".ear",
".class", ".jar", ".war", ".ear",
".pyc", ".pyo",
}
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
var FrontendExts = []string{
".html", ".htm", ".gohtml", ".tmpl",
".css", ".scss", ".sass", ".less",
".js", ".jsx", ".mjs", ".cjs",
".ts", ".tsx",
".vue", ".svelte",
}
type Config struct {
InputPath string
OutputPath string
AppendMode bool
SingleFile bool
MaxSize int64
MaxDepth int
Ignores []string
Extensions []string
AbsRoot string
ProtonDrive bool
DrivePath 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
maxSize int64
maxDepth int
ignores stringSlice
exts stringSlice
showVer bool
protonDrive bool
drivePath string
)
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.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.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")
}
cfg := &Config{
OutputPath: output,
AppendMode: appendMd,
MaxSize: maxSize,
MaxDepth: maxDepth,
Ignores: append(DefaultIgnores, ignores...),
Extensions: append(DefaultExts, exts...),
ProtonDrive: protonDrive,
DrivePath: drivePath,
}
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 {
return nil, fmt.Errorf("output file %s already exists (use -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)
}
})
}
}
+58 -8
View File
@@ -10,12 +10,10 @@ import (
const binaryName = "proton-drive"
// Lookup returns the full path to the proton-drive binary in PATH.
func Lookup() (string, error) {
return exec.LookPath(binaryName)
}
// CheckAuth verifies that the user is authenticated by listing /my-files.
func CheckAuth() error {
binary, err := Lookup()
if err != nil {
@@ -32,11 +30,63 @@ func CheckAuth() error {
return nil
}
// Upload uploads localPath to remotePath on Proton Drive.
// remotePath is a full path including filename (e.g. /my-files/md/report.md).
// The CLI uploads to a parent directory and preserves the local filename,
// so if the remote filename differs from the local one, a temp copy is staged.
// Existing remote files are overwritten (--conflict-strategy replace).
func EnsureRemoteParent(remoteDir string) error {
binary, err := Lookup()
if err != nil {
return fmt.Errorf("proton-drive not found in PATH: %w", err)
}
parentDir := filepath.Dir(remoteDir)
grandparent := filepath.Dir(parentDir)
parentName := filepath.Base(parentDir)
cmd := exec.Command(binary, "filesystem", "create-folder", grandparent, parentName)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Run()
return nil
}
func CleanRemote(remoteDir string) {
binary, err := Lookup()
if err != nil {
return
}
cmd := exec.Command(binary, "filesystem", "delete", remoteDir)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Run()
}
func UploadDir(localDir, remoteDir string) error {
binary, err := Lookup()
if err != nil {
return fmt.Errorf("proton-drive not found in PATH: %w", err)
}
if err := EnsureRemoteParent(remoteDir); err != nil {
return fmt.Errorf("cannot prepare remote directory: %w", err)
}
CleanRemote(remoteDir)
remoteParent := filepath.Dir(remoteDir)
cmd := exec.Command(binary, "filesystem", "upload", localDir, remoteParent,
"--folder-conflict-strategy", "replace", "--file-conflict-strategy", "replace")
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("upload to proton-drive failed: %w: %s",
err, strings.TrimSpace(stderr.String()))
}
return nil
}
func Upload(localPath, remotePath string) error {
binary, err := Lookup()
if err != nil {
@@ -67,7 +117,7 @@ func Upload(localPath, remotePath string) error {
func runUpload(binary, localPath, remoteDir string) error {
cmd := exec.Command(binary, "filesystem", "upload", localPath, remoteDir,
"--conflict-strategy", "replace")
"--folder-conflict-strategy", "replace", "--file-conflict-strategy", "replace")
var stderr strings.Builder
cmd.Stderr = &stderr
+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)
}
}
+78 -4
View File
@@ -4,12 +4,17 @@ import (
"os"
"path/filepath"
"strings"
"git.flo.fo/FLO/dirmd/internal/config"
)
type Filter struct {
MaxSize int64
Extensions []string
Ignores []string
MaxSize int64
Extensions []string
Ignores []string
OmitTest bool
FrontendOnly bool
OmitMd bool
}
func (f *Filter) ShouldSkipDir(name string) bool {
@@ -37,6 +42,22 @@ func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool {
}
}
if info.Size() > f.MaxSize {
return true
}
if f.OmitMd && IsNonReadmeMarkdown(path, name) {
return true
}
if f.FrontendOnly && !IsFrontendOrReadme(path, name) {
return true
}
if f.OmitTest && IsTestFile(path, name) {
return true
}
ext := filepath.Ext(path)
for _, bad := range f.Extensions {
if strings.EqualFold(ext, bad) {
@@ -44,9 +65,62 @@ func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool {
}
}
if info.Size() > f.MaxSize {
return false
}
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") && !strings.EqualFold(name, "README.markdown")
}
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 false
}
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 {
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(path)
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(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
}
+525 -10
View File
@@ -4,11 +4,17 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"unicode/utf8"
"dirmd/internal/tree"
"dirmd/internal/walker"
"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"
)
const fenceChars = "~~~"
@@ -29,20 +35,36 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
var sb strings.Builder
// Determine the correct H1 path
h1Path := result.AbsRoot
if singleFile && len(result.Entries) > 0 {
h1Path = filepath.Join(result.AbsRoot, result.Entries[0].RelPath)
}
fmt.Fprintf(&sb, "# %s\n\n", h1Path)
fmt.Fprintf(&sb, "# %s [ROOT]\n", 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(tree.Render(root, rootName))
sb.WriteString(renderTreeWithLegend(treeRoot, rootName))
sb.WriteString(fenceChars + "\n\n")
}
@@ -53,16 +75,16 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
for _, entry := range result.Entries {
fullPath := filepath.Join(result.AbsRoot, entry.RelPath)
content, err := readFile(fullPath)
content, err := readFileForOutput(fullPath, result.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, "### ./%s\n\n", entry.RelPath)
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)
@@ -72,7 +94,7 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
if !strings.HasSuffix(content, "\n") {
fmt.Fprintf(&sb, "\n")
}
fmt.Fprintf(&sb, "%s\n\n", fenceChars)
fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars)
}
}
@@ -80,6 +102,432 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
return err
}
func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, error) {
entries := result.Entries
if len(entries) == 0 {
return nil, fmt.Errorf("no entries to write")
}
absRoot := result.AbsRoot
rootName := filepath.Base(absRoot)
topLevelDirs := make(map[string][]walker.Entry)
rootFiles := make([]walker.Entry, 0)
for _, e := range entries {
rel := e.RelPath
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) == 1 {
rootFiles = append(rootFiles, e)
} else {
firstPart := parts[0]
topLevelDirs[firstPart] = append(topLevelDirs[firstPart], e)
}
}
treeRoot, err := tree.BuildFullTree(absRoot, result.Cfg)
if err != nil {
return nil, fmt.Errorf("cannot build full tree: %w", err)
}
var dirNames []string
for k := range topLevelDirs {
dirNames = append(dirNames, k)
}
sort.Strings(dirNames)
for _, dirName := range dirNames {
if node, ok := treeRoot.Children[dirName]; ok {
node.Ref = filepath.Join(dirName, dirName+"_"+repoName+".md")
}
}
var generated []string
indexBasename := "index_" + repoName + ".md"
rootPath := filepath.Join(outputDir, indexBasename)
generated = append(generated, rootPath)
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)
}
slicePath := filepath.Join(sliceDir, sliceName)
generated = append(generated, slicePath)
absDir := filepath.Join(absRoot, dirName)
dirEntries := topLevelDirs[dirName]
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)
}
}
return generated, nil
}
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 pick file: %w", err)
}
defer f.Close()
var sb strings.Builder
fmt.Fprintf(&sb, "# %s [SLICE]\n", filepath.Join(absRoot, relPath))
fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339))
sb.WriteString(git.FormatCommitLine(commit))
sb.WriteString(FormatFlags(cfg))
fmt.Fprintf(&sb, "Root document: index_%s.md\n\n", filepath.Base(absRoot))
fullPath := filepath.Join(absRoot, relPath)
content, err := readFileForOutput(fullPath, omitTest)
if err != nil {
return fmt.Errorf("cannot read pick file %s: %w", relPath, err)
}
language := extToLang(filepath.Ext(relPath))
lines := countLines(content)
fmt.Fprintf(&sb, "Tree: %s\n\n", tree.RenderBreadcrumb(absRoot, relPath))
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", relPath, len(content), lines)
sb.WriteString(fenceChars)
if language != "" {
sb.WriteString(language)
}
sb.WriteString("\n")
sb.WriteString(content)
if !strings.HasSuffix(content, "\n") {
sb.WriteString("\n")
}
fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars)
_, err = f.WriteString(sb.String())
return err
}
func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[string][]walker.Entry, rootFiles []walker.Entry, commit *git.CommitInfo, instContent string, treeRoot *tree.Node, cfg *config.Config, picks []PickInfo) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("cannot create root index: %w", err)
}
defer f.Close()
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))
sb.WriteString("## Directory Tree\n\n")
sb.WriteString(fenceChars + "\n")
sb.WriteString(renderTreeWithLegend(treeRoot, rootName))
sb.WriteString(fenceChars + "\n\n")
sb.WriteString("## Vertical Slices Index\n\n")
sb.WriteString("These files contain the full contents of each top-level directory.\n")
sb.WriteString("Read the relevant slice file for detailed source code.\n\n")
sb.WriteString("| Slice File | Directory | Files | Total Bytes |\n")
sb.WriteString("|------------|-----------|-------|-------------|\n")
rootSliceFile := "root_" + repoName + ".md"
rootFileCount := len(rootFiles)
rootTotalBytes := int64(0)
for _, e := range rootFiles {
rootTotalBytes += e.Size
}
fmt.Fprintf(&sb, "| %s | (root) | %d | %d |\n", rootSliceFile, rootFileCount, rootTotalBytes)
for _, dirName := range sortedKeys(topLevelDirs) {
entries := topLevelDirs[dirName]
fileCount := len(entries)
totalBytes := int64(0)
for _, e := range entries {
totalBytes += e.Size
}
sliceFile := filepath.Join(dirName, dirName+"_"+repoName+".md")
fmt.Fprintf(&sb, "| %s | %s/ | %d | %d |\n", sliceFile, dirName, fileCount, totalBytes)
}
sb.WriteString("\n")
if len(picks) > 0 {
sb.WriteString("## Picks\n\n")
sb.WriteString("These files were explicitly selected via --pick flags.\n\n")
for _, p := range picks {
fmt.Fprintf(&sb, "- `%s` (%s)\n", p.RelPath, p.SliceDir)
}
sb.WriteString("\n")
}
_, err = f.WriteString(sb.String())
return err
}
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)
}
defer f.Close()
var sb strings.Builder
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)
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 })
for _, entry := range fileEntries {
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 sortedKeys(m map[string][]walker.Entry) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func renderTreeWithLegend(root *tree.Node, rootName string) string {
var sb strings.Builder
sb.WriteString(rootName)
sb.WriteString("/\n")
renderNodeLLM(&sb, root, "")
if hasAnyAnnotation(root) {
sb.WriteString("Legend: ~ ignored | ~ tests omitted | ~ omitted | ~ exceeded --max-depth\n")
}
return sb.String()
}
func renderNodeLLM(sb *strings.Builder, node *tree.Node, prefix string) {
keys := make([]string, 0, len(node.Children))
for k := range node.Children {
keys = append(keys, k)
}
sort.Strings(keys)
for i, key := range keys {
child := node.Children[key]
isLast := i == len(keys)-1
sb.WriteString(prefix)
sb.WriteString("+- ")
sb.WriteString(child.Name)
if child.IsDir {
sb.WriteString("/")
}
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 {
newPrefix := prefix
if isLast {
newPrefix += " "
} else {
newPrefix += "| "
}
renderNodeLLM(sb, child, newPrefix)
}
}
}
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 {
@@ -103,6 +551,61 @@ func readFile(path string) (string, error) {
return content, nil
}
func readFileForOutput(fullPath string, omitTest bool) (string, error) {
content, err := readFile(fullPath)
if err != nil {
return "", err
}
if omitTest && strings.EqualFold(filepath.Ext(fullPath), ".rs") {
content = stripTestBlocks(content)
}
return content, nil
}
func stripTestBlocks(content string) string {
lines := strings.Split(content, "\n")
var result []string
i := 0
for i < len(lines) {
line := lines[i]
trimmed := strings.TrimSpace(line)
if isCfgTestAttr(trimmed) {
braceDepth := 0
foundBrace := false
for i < len(lines) {
line := lines[i]
braceDepth += strings.Count(line, "{") - strings.Count(line, "}")
if strings.Contains(line, "{") {
foundBrace = true
}
i++
if foundBrace && braceDepth <= 0 {
break
}
}
continue
}
result = append(result, line)
i++
}
if len(result) == 0 {
return ""
}
return strings.Join(result, "\n")
}
func isCfgTestAttr(line string) bool {
return strings.Contains(line, "#[cfg(test)]") ||
strings.Contains(line, "#[cfg(all(test") ||
strings.Contains(line, "#[cfg(any(test")
}
func extToLang(ext string) string {
mapping := map[string]string{
".go": "go",
@@ -124,6 +627,7 @@ func extToLang(ext string) string {
".cpp": "cpp",
".hpp": "cpp",
".txt": "text",
".toml": "toml",
}
if lang, ok := mapping[strings.ToLower(ext)]; ok {
@@ -131,3 +635,14 @@ func extToLang(ext string) string {
}
return ""
}
func countLines(content string) int {
if content == "" {
return 0
}
count := strings.Count(content, "\n")
if len(content) > 0 && content[len(content)-1] != '\n' {
count++
}
return count
}
+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"
"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()
}
+100
View File
@@ -0,0 +1,100 @@
package tui
import (
"fmt"
"os"
"path/filepath"
"git.flo.fo/FLO/dirmd/internal/config"
"git.flo.fo/FLO/dirmd/internal/drive"
"git.flo.fo/FLO/dirmd/internal/renderer"
"git.flo.fo/FLO/dirmd/internal/walker"
)
type GenerateResult struct {
Files []string
Uploaded bool
Error error
}
func Generate(cfg *config.Config) GenerateResult {
result := GenerateResult{}
abs, err := filepath.Abs(cfg.InputPath)
if err != nil {
result.Error = fmt.Errorf("resolve input path: %w", err)
return result
}
cfg.AbsRoot = abs
absOutput, err := filepath.Abs(cfg.OutputPath)
if err != nil {
result.Error = fmt.Errorf("resolve output path: %w", err)
return result
}
if _, err := os.Stat(absOutput); err == nil && !cfg.Force {
if info, err2 := os.Stat(absOutput); err2 == nil && !info.IsDir() {
result.Error = fmt.Errorf("output %s exists and is not a directory (enable force)", absOutput)
return result
}
result.Error = fmt.Errorf("output %s exists (enable force)", absOutput)
return result
}
if err := os.MkdirAll(absOutput, 0755); err != nil {
result.Error = fmt.Errorf("create output dir: %w", err)
return result
}
cfg.OutputPath = absOutput
walkResult, err := walker.Run(cfg)
if err != nil {
result.Error = err
return result
}
if len(walkResult.Entries) == 0 {
result.Error = fmt.Errorf("no readable files in %s", cfg.AbsRoot)
return result
}
repoName := filepath.Base(cfg.AbsRoot)
if cfg.VerticalSlices {
generated, err := renderer.WriteSlices(walkResult, cfg.OutputPath, repoName)
if err != nil {
result.Error = err
return result
}
result.Files = generated
} else {
indexPath := filepath.Join(cfg.OutputPath, "index_"+repoName+".md")
if err := renderer.Write(walkResult, indexPath, false, false); err != nil {
result.Error = err
return result
}
result.Files = []string{indexPath}
}
if cfg.ProtonDrive {
remoteDir := cfg.DrivePath
if remoteDir == "" {
remoteDir = "/my-files/md/" + filepath.Base(cfg.OutputPath)
}
if err := drive.CheckAuth(); err != nil {
result.Error = fmt.Errorf("drive auth: %w", err)
return result
}
if err := drive.UploadDir(cfg.OutputPath, remoteDir); err != nil {
result.Error = fmt.Errorf("drive upload: %w", err)
return result
}
result.Uploaded = true
}
return result
}
+281
View File
@@ -0,0 +1,281 @@
package tui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"charm.land/bubbles/v2/key"
"charm.land/huh/v2"
"charm.land/huh/v2/spinner"
"git.flo.fo/FLO/dirmd/internal/config"
)
func Run() error {
clearScreen()
var (
inputDir string
outputDir string
options []string
drivePath string
maxDepthStr string
maxSizeStr string
ignorePatterns string
finalConfirm bool
)
inputDir = "."
maxDepthStr = "20"
maxSizeStr = "512"
defOut := defaultOutputDir()
outputDir = defOut
drivePath = "/my-files/md/"
km := huh.NewDefaultKeyMap()
km.Input.Next = key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab/↓", "next"))
km.Input.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev"))
km.Input.Submit = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit"))
km.Text.Next = key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab/↓", "next"))
km.Text.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev"))
km.Text.NewLine = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "new line"))
km.Text.Submit = key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "submit"))
km.Confirm.Next = key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab/↓", "next"))
km.Confirm.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev"))
km.Confirm.Toggle = key.NewBinding(key.WithKeys("left", "right", "h", "l"), key.WithHelp("←/→", "toggle"))
km.Confirm.Submit = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm"))
km.Confirm.Accept = key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "yes"))
km.Confirm.Reject = key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "no"))
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"), 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"))
km.MultiSelect.SelectAll = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "select all"))
km.MultiSelect.SelectNone = key.NewBinding(key.WithKeys("A"), key.WithHelp("A", "select none"))
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Input directory").Value(&inputDir).Placeholder("."),
huh.NewInput().Title("Output directory").Value(&outputDir).Placeholder(defOut),
huh.NewMultiSelect[string]().
Title("Options").
Description("↑↓ navigate • enter/space toggle • tab next field").
Options(
huh.NewOption("Force overwrite", "force"),
huh.NewOption("Vertical slices", "vertical-slices"),
huh.NewOption("Skip frontend files", "skip-frontend"),
huh.NewOption("Frontend only", "frontend-only"),
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),
huh.NewInput().
Title("Proton Drive path").
Description("Used if 'Upload to Proton Drive' is toggled").
Value(&drivePath).
Placeholder("/my-files/md/"),
huh.NewInput().Title("Max depth").Value(&maxDepthStr).Placeholder("20"),
huh.NewInput().Title("Max size (KB)").Value(&maxSizeStr).Placeholder("512"),
huh.NewText().
Title("Ignore patterns (one per line)").
Description("enter = new line • tab = next field").
Value(&ignorePatterns).
Placeholder("One pattern per line...").
Lines(4),
huh.NewConfirm().
Title("Confirm and proceed?").
Description("←/→ or y/n to choose • enter to confirm").
Value(&finalConfirm).
Affirmative("Yes").
Negative("No"),
),
).WithWidth(80).WithKeyMap(km)
if err := form.Run(); err != nil {
return fmt.Errorf("form: %w", err)
}
force := containsOption(options, "force")
verticalSlices := containsOption(options, "vertical-slices")
skipFrontend := containsOption(options, "skip-frontend")
frontendOnly := containsOption(options, "frontend-only")
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")
}
if !finalConfirm {
fmt.Fprintln(os.Stderr, "Cancelled.")
os.Exit(0)
}
maxDepth, err := strconv.Atoi(strings.TrimSpace(maxDepthStr))
if err != nil || maxDepth <= 0 {
maxDepth = 20
}
maxSizeKB, err := strconv.Atoi(strings.TrimSpace(maxSizeStr))
if err != nil || maxSizeKB <= 0 {
maxSizeKB = 512
}
var ignores []string
raw := strings.TrimSpace(ignorePatterns)
if raw != "" {
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line != "" {
ignores = append(ignores, line)
}
}
}
excludedExts := append([]string{}, config.DefaultExts...)
if skipFrontend {
excludedExts = append(excludedExts, config.FrontendExts...)
}
allIgnores := append([]string{}, config.DefaultIgnores...)
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,
IncludeInstructions: includeInstructions,
}
cmd := buildCommand(cfg)
fmt.Fprintf(os.Stderr, "%s\n", cmd)
var result GenerateResult
if err := spinner.New().
Title("Generating...").
Action(func() {
result = Generate(cfg)
}).
Run(); err != nil {
return fmt.Errorf("interrupted: %w", err)
}
if result.Error != nil {
return result.Error
}
for _, f := range result.Files {
fmt.Fprintf(os.Stdout, " %s\n", f)
}
if result.Uploaded {
fmt.Fprintln(os.Stdout, "Uploaded to Proton Drive")
}
return nil
}
func clearScreen() {
cmd := exec.Command("clear")
cmd.Stdout = os.Stdout
cmd.Run()
}
func containsOption(opts []string, target string) bool {
for _, o := range opts {
if o == target {
return true
}
}
return false
}
func defaultOutputDir() string {
cwd, err := os.Getwd()
if err != nil {
return "/tmp/output_dirmd"
}
base := filepath.Base(cwd)
return filepath.Join("/tmp", base+"_dirmd")
}
func buildCommand(cfg *config.Config) string {
var parts []string
parts = append(parts, "dirmd")
parts = append(parts, "-o", cfg.OutputPath)
if cfg.Force {
parts = append(parts, "-f")
}
if cfg.VerticalSlices {
parts = append(parts, "--vertical-slices")
}
if cfg.SkipFrontend {
parts = append(parts, "--skip-frontend")
}
if cfg.FrontendOnly {
parts = append(parts, "--frontend-only")
}
if cfg.OmitTest {
parts = append(parts, "--omit-test")
}
if cfg.OmitMd {
parts = append(parts, "--omit-md")
}
if cfg.ProtonDrive {
parts = append(parts, "--proton-drive")
if cfg.DrivePath != "" {
parts = append(parts, "--drive-path", cfg.DrivePath)
}
}
if cfg.MaxDepth != 20 {
parts = append(parts, "--max-depth", strconv.Itoa(cfg.MaxDepth))
}
if cfg.MaxSize != 512*1024 {
parts = append(parts, "--max-size", strconv.FormatInt(cfg.MaxSize, 10))
}
for _, ig := range extraIgnores(cfg) {
parts = append(parts, "--ignore", ig)
}
parts = append(parts, cfg.InputPath)
return strings.Join(parts, " ")
}
func extraIgnores(cfg *config.Config) []string {
defaultSet := make(map[string]bool)
for _, d := range config.DefaultIgnores {
defaultSet[d] = true
}
var extra []string
for _, ig := range cfg.Ignores {
if !defaultSet[ig] {
extra = append(extra, ig)
}
}
return extra
}
+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")
}
}
+48 -17
View File
@@ -7,8 +7,10 @@ import (
"path/filepath"
"strings"
"dirmd/internal/config"
"dirmd/internal/filter"
"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,9 +19,13 @@ type Entry struct {
}
type Result struct {
Entries []Entry
AbsRoot string
SingleFile bool
Entries []Entry
AbsRoot string
SingleFile bool
OmitTest bool
Cfg *config.Config
Commit *git.CommitInfo
Instructions string
}
func Run(cfg *config.Config) (*Result, error) {
@@ -40,31 +46,56 @@ func processSingleFile(cfg *config.Config) (*Result, error) {
}
flt := &filter.Filter{
MaxSize: cfg.MaxSize,
Extensions: cfg.Extensions,
Ignores: cfg.Ignores,
MaxSize: cfg.MaxSize,
Extensions: cfg.Extensions,
Ignores: cfg.Ignores,
OmitTest: cfg.OmitTest,
FrontendOnly: cfg.FrontendOnly,
OmitMd: cfg.OmitMd,
}
if flt.ShouldSkipFile(cfg.AbsRoot, filepath.Base(cfg.AbsRoot), info) {
return nil, fmt.Errorf("file %s filtered out", cfg.AbsRoot)
}
return &Result{
Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}},
AbsRoot: filepath.Dir(cfg.AbsRoot),
SingleFile: true,
}, nil
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,
Cfg: cfg,
Commit: commitInfo,
Instructions: instContent,
}, nil
}
func walkDirectory(cfg *config.Config) (*Result, error) {
flt := &filter.Filter{
MaxSize: cfg.MaxSize,
Extensions: cfg.Extensions,
Ignores: cfg.Ignores,
MaxSize: cfg.MaxSize,
Extensions: cfg.Extensions,
Ignores: cfg.Ignores,
OmitTest: cfg.OmitTest,
FrontendOnly: cfg.FrontendOnly,
OmitMd: cfg.OmitMd,
}
result := &Result{AbsRoot: cfg.AbsRoot}
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")
}
}
+1 -2
View File
@@ -1,8 +1,7 @@
package main
import "dirmd/cmd"
import "git.flo.fo/FLO/dirmd/cmd"
func main() {
cmd.Execute()
}