Compare commits
57
Commits
bc2cc0b531
...
v0.1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7d94b26a0 | ||
|
|
9562be5e34 | ||
|
|
9fa410e3a1 | ||
|
|
c5dfbb3502 | ||
|
|
84bea485ec | ||
|
|
b63f0ba927 | ||
|
|
c72c0d0b79 | ||
|
|
35c61baa8d | ||
|
|
4396a6f619 | ||
|
|
6c365e9cde | ||
|
|
f908bffc41 | ||
|
|
a1638bd944 | ||
|
|
93d830d500 | ||
|
|
89b7eb78a7 | ||
|
|
b1dd808d1e | ||
|
|
d247095e87 | ||
|
|
66579d068d | ||
|
|
4c14ef2cee | ||
|
|
0f94e50746 | ||
|
|
c9aa028b3c | ||
|
|
bca125cb92 | ||
|
|
7d73901631 | ||
|
|
6663cb25f8 | ||
|
|
8a28364839 | ||
|
|
894f97820e | ||
|
|
fb646a94be | ||
|
|
4bcae1358d | ||
|
|
2428a33046 | ||
|
|
a48fe55d4b | ||
|
|
0df346dbf9 | ||
|
|
d7f4c6826b | ||
|
|
79be211df3 | ||
|
|
ba87d6184b | ||
|
|
180c3f0eba | ||
|
|
be0c761cb3 | ||
|
|
4ebf3bd60f | ||
|
|
63547e74c2 | ||
|
|
79b630b622 | ||
|
|
4520ad3dfd | ||
|
|
4072ca4a45 | ||
|
|
ba81eecea8 | ||
|
|
287d60e978 | ||
|
|
e33682098a | ||
|
|
cbf00f3e33 | ||
|
|
ab670b3ecd | ||
|
|
8bfd6b22c5 | ||
|
|
adc276c28a | ||
|
|
73c2009139 | ||
|
|
71961f68da | ||
|
|
3ea14b91c0 | ||
|
|
a2afce3f8d | ||
|
|
2bbbf4e81e | ||
|
|
1f5243d953 | ||
|
|
95b5a7b70c | ||
|
|
7f05519f6f | ||
|
|
72bee49d3f | ||
|
|
1e913c784e |
@@ -1,3 +1,4 @@
|
|||||||
bin/
|
bin/
|
||||||
.env
|
.env
|
||||||
vendor/
|
vendor/
|
||||||
|
.dirmd
|
||||||
|
|||||||
@@ -2,160 +2,40 @@
|
|||||||
|
|
||||||
Generate markdown documentation from directory structure. Optimized for LLM ingestion.
|
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
|
## Install
|
||||||
- ASCII tree visualization (LLM-friendly, minimal token overhead)
|
|
||||||
- Full file contents with explicit delimiters and metadata
|
|
||||||
- Filters dotfiles, binaries, and blacklisted extensions
|
|
||||||
- Vertical slices mode: root file + one markdown per top-level directory
|
|
||||||
- Append mode for single files or directories
|
|
||||||
- Force overwrite of existing output files
|
|
||||||
- Configurable depth, file size limits, ignore patterns
|
|
||||||
- Optional upload to Proton Drive after generating output
|
|
||||||
|
|
||||||
## 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
|
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.
|
||||||
dirmd -o output.md ~/repos/myproject
|
|
||||||
|
|
||||||
# Overwrite existing output file
|
## Three ways to use it
|
||||||
dirmd -o output.md ~/repos/myproject -f
|
|
||||||
|
|
||||||
# Append a single file
|
**One file — small repos.**
|
||||||
dirmd -a -i README.md -o existing.md
|
For small projects that fit in a context window comfortably:
|
||||||
|
|
||||||
# Append another directory
|
dirmd -o out.md ~/repos/myproject
|
||||||
dirmd -a ~/repos/anotherproject -o existing.md
|
|
||||||
|
|
||||||
# Generate with vertical slices
|
Everything lands in a single markdown file. Simplest, works fine until it doesn't.
|
||||||
dirmd -o output.md ~/repos/myproject --vertical-slices
|
|
||||||
|
|
||||||
# Generate and upload to Proton Drive (default: /my-files/md/<filename>)
|
**Vertical slices — large repos.**
|
||||||
dirmd -o output.md ~/repos/myproject --proton-drive
|
Once a single file gets too big to paste repeatedly, split it:
|
||||||
|
|
||||||
# Generate and upload to a specific remote path
|
dirmd -o docs ~/repos/myproject --vertical-slices
|
||||||
dirmd -o output.md ~/repos/myproject --proton-drive --drive-path /my-files/docs/api.md
|
|
||||||
|
|
||||||
## Vertical Slices Mode
|
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.
|
||||||
|
|
||||||
When `--vertical-slices` is passed, dirmd splits output into multiple files instead of one monolithic document:
|
**Slices + picks — you already know which files matter.**
|
||||||
|
Sometimes you need one specific file without its whole directory:
|
||||||
|
|
||||||
- **Root file** (`-o output.md`): Full directory tree with cross-references to slice files, a vertical slices index table (file counts, total bytes per directory), and full contents of all root-level files.
|
dirmd -o docs ~/repos/myproject --vertical-slices \
|
||||||
- **Slice files** (`cmd.md`, `internal.md`, etc.): One per top-level directory, placed alongside the root file. Each contains its own tree, a back-reference to the root document, and full recursive file contents for that directory.
|
--pick common/src/config.rs --pick init/src/main.rs
|
||||||
|
|
||||||
This mode cannot be combined with `--append` or `--input-file`.
|
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.
|
||||||
|
|
||||||
Example output with `dirmd -o docs.md ~/repos/myproject --vertical-slices`:
|
Rule of thumb: one file for tiny repos, slices when you need to browse, picks when you already know the answer.
|
||||||
|
|
||||||
docs/
|
|
||||||
├── docs.md ← root: tree + slices index + root file contents
|
|
||||||
├── cmd.md ← slice: tree + contents of cmd/
|
|
||||||
└── internal.md ← slice: tree + contents of internal/
|
|
||||||
|
|
||||||
## LLM-Optimized Output
|
|
||||||
|
|
||||||
Output is designed for RAG ingestion and LLM context windows, not human presentation:
|
|
||||||
|
|
||||||
- **ASCII tree format** (`+-` instead of Unicode box-drawing) to reduce token overhead
|
|
||||||
- **Explicit file delimiters**: `--- FILE: ./path (bytes, lines) ---` and `--- END FILE ---`
|
|
||||||
- **Document type markers**: `[ROOT]` and `[SLICE]` in H1 headers
|
|
||||||
- **Cross-references**: Directory entries in root tree show `-> see cmd.md`; slice files reference their root document
|
|
||||||
- **Slices index table**: File counts and total bytes per slice for informed retrieval decisions
|
|
||||||
- **Tilde fences** (`~~~`) to avoid conflicts with backticks in source files
|
|
||||||
|
|
||||||
## Flags
|
|
||||||
|
|
||||||
| Flag | Description | Default |
|
|
||||||
|------|-------------|---------|
|
|
||||||
| `-o`, `--output` | Output markdown file path (required) | |
|
|
||||||
| `-a`, `--append` | Append to existing output file | false |
|
|
||||||
| `-f`, `--force` | Overwrite existing output file (non-append mode) | false |
|
|
||||||
| `-i`, `--input-file` | Single file input (append mode only) | |
|
|
||||||
| `--vertical-slices` | Split output into root file + one markdown per top-level directory | false |
|
|
||||||
| `--max-size` | Max file size in bytes | 524288 |
|
|
||||||
| `--max-depth` | Max directory recursion depth | 20 |
|
|
||||||
| `--ignore` | Additional ignore patterns (glob) | |
|
|
||||||
| `--extensions` | Additional file extensions to skip | |
|
|
||||||
| `--proton-drive` | Upload output to Proton Drive after writing locally | false |
|
|
||||||
| `--drive-path` | Full remote path on Proton Drive (e.g. /my-files/md/report.md) | /my-files/md/<filename> |
|
|
||||||
|
|
||||||
## Proton Drive Integration
|
|
||||||
|
|
||||||
dirmd can optionally upload the generated markdown file(s) to Proton Drive using the [Proton Drive CLI](https://proton.me/support/drive-cli). The upload is performed only when `--proton-drive` is passed. Without it, dirmd behaves exactly as before — no Proton Drive dependency is required.
|
|
||||||
|
|
||||||
In vertical slices mode, all generated files (root + slices) are uploaded to `/my-files/md/`. The `--drive-path` flag is ignored in slices mode; each file is uploaded by its basename.
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
1. Install the Proton Drive CLI — see the [official guide](https://proton.me/support/drive-cli) for download and installation instructions.
|
|
||||||
2. Authenticate by running `proton-drive auth login`.
|
|
||||||
3. Ensure `proton-drive` is in your `PATH`.
|
|
||||||
|
|
||||||
If the CLI is not installed or you are not logged in, dirmd will report the error to stderr and exit with a non-zero code. Local files are always written successfully before the upload is attempted.
|
|
||||||
|
|
||||||
For full documentation on the Proton Drive CLI, including installation, authentication, and troubleshooting, refer to the [official Proton Drive CLI support page](https://proton.me/support/drive-cli).
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
# Custom depth and size limit
|
|
||||||
dirmd -o output.md ~/repos/project --max-depth 5 --max-size 262144
|
|
||||||
|
|
||||||
# Ignore specific directories
|
|
||||||
dirmd -o output.md ~/repos/project --ignore __pycache__ --ignore .venv
|
|
||||||
|
|
||||||
# Skip additional file extensions
|
|
||||||
dirmd -o output.md ~/repos/project --extensions .log --extensions .tmp
|
|
||||||
|
|
||||||
# Vertical slices with overwrite
|
|
||||||
dirmd -o docs.md ~/repos/project -f --vertical-slices
|
|
||||||
|
|
||||||
# Generate, then upload to default remote location
|
|
||||||
dirmd -o docs.md ~/repos/project --proton-drive
|
|
||||||
|
|
||||||
# Generate, then upload to a custom remote location
|
|
||||||
dirmd -o docs.md ~/repos/project --proton-drive --drive-path /my-files/projects/docs.md
|
|
||||||
|
|
||||||
# Overwrite existing local file and upload to Drive
|
|
||||||
dirmd -o docs.md ~/repos/project -f --proton-drive
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
|
|
||||||
Produces markdown with:
|
|
||||||
|
|
||||||
1. Absolute path as H1 header with `[ROOT]` or `[SLICE]` marker
|
|
||||||
2. ASCII directory tree (indented with `+-` and `|` characters)
|
|
||||||
3. Vertical slices index table (sliced mode only)
|
|
||||||
4. All text files with content in fenced code blocks
|
|
||||||
5. Explicit `--- FILE: ---` and `--- END FILE ---` delimiters with byte and line counts
|
|
||||||
6. Language detection from file extensions
|
|
||||||
|
|
||||||
Example output structure (non-sliced mode):
|
|
||||||
|
|
||||||
# /home/user/repos/myproject [ROOT]
|
|
||||||
|
|
||||||
## Directory Tree
|
|
||||||
|
|
||||||
~~~
|
|
||||||
myproject/
|
|
||||||
+- cmd/
|
|
||||||
| +- main.go
|
|
||||||
+- internal/
|
|
||||||
| +- config.go
|
|
||||||
~~~
|
|
||||||
|
|
||||||
## Contents
|
|
||||||
|
|
||||||
--- FILE: ./cmd/main.go (45 bytes, 3 lines) ---
|
|
||||||
~~~go
|
|
||||||
package main
|
|
||||||
|
|
||||||
func main() {}
|
|
||||||
~~~
|
|
||||||
--- END FILE ---
|
|
||||||
|
|
||||||
Note: Uses tilde fences (~~~) internally to avoid conflicts with nested backticks in source files.
|
|
||||||
|
|||||||
@@ -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 ./...
|
||||||
+62
-29
@@ -7,16 +7,18 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"dirmd/internal/config"
|
"git.flo.fo/FLO/dirmd/internal/config"
|
||||||
"dirmd/internal/drive"
|
"git.flo.fo/FLO/dirmd/internal/drive"
|
||||||
"dirmd/internal/renderer"
|
"git.flo.fo/FLO/dirmd/internal/renderer"
|
||||||
"dirmd/internal/walker"
|
"git.flo.fo/FLO/dirmd/internal/tui"
|
||||||
|
"git.flo.fo/FLO/dirmd/internal/walker"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
rootCmd = &cobra.Command{
|
rootCmd = &cobra.Command{
|
||||||
Use: "dirmd [flags] <directory>",
|
Use: "dirmd [flags] <directory>",
|
||||||
Short: "Generate markdown documentation from directory structure",
|
Short: "Generate markdown documentation from directory structure",
|
||||||
|
Version: config.Version,
|
||||||
Long: `dirmd walks a directory and generates markdown files with:
|
Long: `dirmd walks a directory and generates markdown files with:
|
||||||
|
|
||||||
- Directory tree structure
|
- Directory tree structure
|
||||||
@@ -26,26 +28,29 @@ Output is always written to a directory specified by -o.
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
# Create new documentation (writes index.md inside ./docs/)
|
# Create new documentation
|
||||||
dirmd -o ./docs ~/repos/myproject
|
dirmd -o ./docs ~/repos/myproject
|
||||||
|
|
||||||
# Overwrite existing output directory
|
# Overwrite existing output directory (deletes all contents)
|
||||||
dirmd -o ./docs ~/repos/myproject -f
|
dirmd -o ./docs ~/repos/myproject -f
|
||||||
|
|
||||||
# Append a single file
|
# Append a single file
|
||||||
dirmd -a -i README.md -o ./docs
|
dirmd -a -i README.md -o ./docs
|
||||||
|
|
||||||
# Vertical slices (root index.md + one markdown per top-level dir)
|
# Vertical slices
|
||||||
dirmd -o ./docs ~/repos/myproject --vertical-slices
|
dirmd -o ./docs ~/repos/myproject --vertical-slices
|
||||||
|
|
||||||
# Skip frontend files
|
# Include instructions from .dirmd
|
||||||
dirmd -o ./docs ~/repos/myproject --skip-frontend
|
dirmd -o ./docs ~/repos/myproject --instructions
|
||||||
|
|
||||||
# Generate and upload to Proton Drive
|
# Generate and upload to Proton Drive
|
||||||
dirmd -o ./docs ~/repos/myproject --proton-drive
|
dirmd -o ./docs ~/repos/myproject --proton-drive
|
||||||
|
|
||||||
# Generate and upload to a specific remote path
|
# Interactive TUI mode
|
||||||
dirmd -o ./docs ~/repos/myproject --proton-drive --drive-path /my-files/docs/api`,
|
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,
|
RunE: run,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +66,12 @@ Examples:
|
|||||||
drivePath string
|
drivePath string
|
||||||
verticalSlices bool
|
verticalSlices bool
|
||||||
skipFrontend bool
|
skipFrontend bool
|
||||||
|
frontendOnly bool
|
||||||
|
omitTest bool
|
||||||
|
omitMd bool
|
||||||
|
tuiMode bool
|
||||||
|
instructions bool
|
||||||
|
pickPaths []string
|
||||||
)
|
)
|
||||||
|
|
||||||
func Execute() {
|
func Execute() {
|
||||||
@@ -72,8 +83,9 @@ func Execute() {
|
|||||||
func init() {
|
func init() {
|
||||||
rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output directory (required)")
|
rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output directory (required)")
|
||||||
rootCmd.Flags().BoolVarP(&appendMode, "append", "a", false, "append to existing index.md")
|
rootCmd.Flags().BoolVarP(&appendMode, "append", "a", false, "append to existing index.md")
|
||||||
rootCmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite existing output directory")
|
rootCmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite existing output directory (deletes all contents)")
|
||||||
rootCmd.Flags().StringVarP(&singleFile, "input-file", "i", "", "single file input (append mode only)")
|
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().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().IntVar(&maxDepth, "max-depth", config.DefaultMaxDepth, "max directory recursion depth")
|
||||||
rootCmd.Flags().StringArrayVar(&ignorePatterns, "ignore", nil, "additional ignore patterns (glob)")
|
rootCmd.Flags().StringArrayVar(&ignorePatterns, "ignore", nil, "additional ignore patterns (glob)")
|
||||||
@@ -82,11 +94,22 @@ func init() {
|
|||||||
rootCmd.Flags().StringVar(&drivePath, "drive-path", "", "full remote directory on Proton Drive (e.g. /my-files/docs/api). Requires --proton-drive")
|
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(&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(&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.MarkPersistentFlagRequired("output")
|
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 {
|
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 {
|
if drivePath != "" && !protonDrive {
|
||||||
return fmt.Errorf("--drive-path requires --proton-drive")
|
return fmt.Errorf("--drive-path requires --proton-drive")
|
||||||
}
|
}
|
||||||
@@ -99,6 +122,14 @@ func run(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("--vertical-slices cannot be used with --input-file")
|
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...)
|
excludedExts := append(config.DefaultExts, extensions...)
|
||||||
if skipFrontend {
|
if skipFrontend {
|
||||||
excludedExts = append(excludedExts, config.FrontendExts...)
|
excludedExts = append(excludedExts, config.FrontendExts...)
|
||||||
@@ -116,6 +147,11 @@ func run(cmd *cobra.Command, args []string) error {
|
|||||||
DrivePath: drivePath,
|
DrivePath: drivePath,
|
||||||
VerticalSlices: verticalSlices,
|
VerticalSlices: verticalSlices,
|
||||||
SkipFrontend: skipFrontend,
|
SkipFrontend: skipFrontend,
|
||||||
|
FrontendOnly: frontendOnly,
|
||||||
|
OmitTest: omitTest,
|
||||||
|
OmitMd: omitMd,
|
||||||
|
IncludeInstructions: instructions,
|
||||||
|
PickPaths: pickPaths,
|
||||||
}
|
}
|
||||||
|
|
||||||
if singleFile != "" {
|
if singleFile != "" {
|
||||||
@@ -142,18 +178,22 @@ func run(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("failed to resolve output path: %w", err)
|
return fmt.Errorf("failed to resolve output path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
absOutput = filepath.Join(filepath.Dir(absOutput), filepath.Base(absOutput)+"_dirmd")
|
|
||||||
|
|
||||||
if !appendMode {
|
if !appendMode {
|
||||||
if _, err := os.Stat(absOutput); err == nil && !force {
|
if _, err := os.Stat(absOutput); err == nil {
|
||||||
|
if !force {
|
||||||
return fmt.Errorf("output directory %s already exists (use -f to overwrite)", absOutput)
|
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 {
|
if err := os.MkdirAll(absOutput, 0755); err != nil {
|
||||||
return fmt.Errorf("cannot create output directory: %w", err)
|
return fmt.Errorf("cannot create output directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
repoName := filepath.Base(cfg.AbsRoot)
|
||||||
fmt.Printf("%s\n", absOutput)
|
fmt.Printf("%s\n", absOutput)
|
||||||
|
|
||||||
result, err := walker.Run(cfg)
|
result, err := walker.Run(cfg)
|
||||||
@@ -165,12 +205,13 @@ func run(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("no readable text files found in %s", cfg.AbsRoot)
|
return fmt.Errorf("no readable text files found in %s", cfg.AbsRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
indexBasename := "index_" + repoName + ".md"
|
||||||
|
|
||||||
if verticalSlices {
|
if verticalSlices {
|
||||||
generated, err := renderer.WriteSlices(result, absOutput)
|
generated, err := renderer.WriteSlices(result, absOutput, repoName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "generated %d files in %s\n", len(generated), absOutput)
|
fmt.Fprintf(os.Stderr, "generated %d files in %s\n", len(generated), absOutput)
|
||||||
|
|
||||||
if protonDrive {
|
if protonDrive {
|
||||||
@@ -178,27 +219,22 @@ func run(cmd *cobra.Command, args []string) error {
|
|||||||
if remoteDir == "" {
|
if remoteDir == "" {
|
||||||
remoteDir = "/my-files/md/" + filepath.Base(absOutput)
|
remoteDir = "/my-files/md/" + filepath.Base(absOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := drive.CheckAuth(); err != nil {
|
if err := drive.CheckAuth(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
fmt.Fprintln(os.Stderr, "local files were written successfully, but upload was skipped")
|
fmt.Fprintln(os.Stderr, "local files were written successfully, but upload was skipped")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := drive.UploadDir(absOutput, remoteDir); err != nil {
|
if err := drive.UploadDir(absOutput, remoteDir); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
fmt.Fprintln(os.Stderr, "local files were written successfully, but upload failed")
|
fmt.Fprintln(os.Stderr, "local files were written successfully, but upload failed")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir)
|
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
indexPath := filepath.Join(absOutput, "index.md")
|
indexPath := filepath.Join(absOutput, indexBasename)
|
||||||
|
|
||||||
if err := renderer.Write(result, indexPath, appendMode, cfg.SingleFile); err != nil {
|
if err := renderer.Write(result, indexPath, appendMode, cfg.SingleFile); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -210,19 +246,16 @@ func run(cmd *cobra.Command, args []string) error {
|
|||||||
if remoteDir == "" {
|
if remoteDir == "" {
|
||||||
remoteDir = "/my-files/md/" + filepath.Base(absOutput)
|
remoteDir = "/my-files/md/" + filepath.Base(absOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := drive.CheckAuth(); err != nil {
|
if err := drive.CheckAuth(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
fmt.Fprintln(os.Stderr, "local file was written successfully, but upload was skipped")
|
fmt.Fprintln(os.Stderr, "local file was written successfully, but upload was skipped")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := drive.UploadDir(absOutput, remoteDir); err != nil {
|
if err := drive.UploadDir(absOutput, remoteDir); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
fmt.Fprintln(os.Stderr, "local file was written successfully, but upload failed")
|
fmt.Fprintln(os.Stderr, "local file was written successfully, but upload failed")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir)
|
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# Automating sending files to proton drive on commits
|
|
||||||
|
|
||||||
If you add this to any repos, and have dirmd in path and logged in on proton drive cli, then after each commit the newest code is pushed to proton drive, as vertical slices. If you do not want vertical slices of the code omit --vertical-slices. Also check out the other flags of dirmd if required.
|
|
||||||
|
|
||||||
## repos-root/.git/hooks/post-commit
|
|
||||||
|
|
||||||
create this file
|
|
||||||
|
|
||||||
```sh
|
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if ! command -v dirmd &>/dev/null; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
|
||||||
REPO_NAME=$(basename "$REPO_ROOT")
|
|
||||||
|
|
||||||
dirmd -o "/tmp/${REPO_NAME}_new" "$REPO_ROOT" -f --vertical-slices --proton-drive
|
|
||||||
```
|
|
||||||
|
|
||||||
and then make it executable with something like `chmod +x .git/hooks/post-commit`
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,10 +1,37 @@
|
|||||||
module dirmd
|
module git.flo.fo/FLO/dirmd
|
||||||
|
|
||||||
go 1.26.4
|
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 (
|
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/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/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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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/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 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
+12
-145
@@ -1,30 +1,16 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultMaxSize = 512 * 1024
|
DefaultMaxSize = 512 * 1024
|
||||||
DefaultMaxDepth = 20
|
DefaultMaxDepth = 20
|
||||||
Version = "0.1.0"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
var DefaultIgnores = []string{
|
var DefaultIgnores = []string{
|
||||||
".git",
|
".git", "node_modules", "vendor", "bin", "go.mod", "go.sum",
|
||||||
"node_modules",
|
"target", "build", "dist", "Cargo.lock", ".idea", ".vscode",
|
||||||
"vendor",
|
"dependency-reduced-pom.xml", "__pycache__", "venv", "env", "uv.lock",
|
||||||
"bin",
|
|
||||||
"target",
|
|
||||||
"build",
|
|
||||||
"dist",
|
|
||||||
".idea",
|
|
||||||
".vscode",
|
|
||||||
"dependency-reduced-pom.xml",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var DefaultExts = []string{
|
var DefaultExts = []string{
|
||||||
@@ -36,10 +22,8 @@ var DefaultExts = []string{
|
|||||||
".pdf", ".doc", ".docx", ".xls", ".xlsx",
|
".pdf", ".doc", ".docx", ".xls", ".xlsx",
|
||||||
".pem", ".key", ".cert",
|
".pem", ".key", ".cert",
|
||||||
".db", ".sqlite", ".sqlite3",
|
".db", ".sqlite", ".sqlite3",
|
||||||
".class",
|
".class", ".jar", ".war", ".ear",
|
||||||
".jar",
|
".pyc", ".pyo",
|
||||||
".war",
|
|
||||||
".ear",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var FrontendExts = []string{
|
var FrontendExts = []string{
|
||||||
@@ -50,17 +34,6 @@ var FrontendExts = []string{
|
|||||||
".vue", ".svelte",
|
".vue", ".svelte",
|
||||||
}
|
}
|
||||||
|
|
||||||
type stringSlice []string
|
|
||||||
|
|
||||||
func (s *stringSlice) String() string {
|
|
||||||
return strings.Join(*s, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stringSlice) Set(v string) error {
|
|
||||||
*s = append(*s, v)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
InputPath string
|
InputPath string
|
||||||
OutputPath string
|
OutputPath string
|
||||||
@@ -76,115 +49,9 @@ type Config struct {
|
|||||||
DrivePath string
|
DrivePath string
|
||||||
VerticalSlices bool
|
VerticalSlices bool
|
||||||
SkipFrontend bool
|
SkipFrontend bool
|
||||||
}
|
FrontendOnly bool
|
||||||
|
OmitTest bool
|
||||||
func PrintUsage() {
|
OmitMd bool
|
||||||
fmt.Fprintf(os.Stderr, "dirmd v%s\n\n", Version)
|
IncludeInstructions bool
|
||||||
fmt.Fprintf(os.Stderr, "Usage: dirmd [flags] <directory>\n")
|
PickPaths []string
|
||||||
fmt.Fprintf(os.Stderr, " dirmd -a -i <file> -o <output>\n\n")
|
|
||||||
fmt.Fprintf(os.Stderr, "Flags:\n")
|
|
||||||
flag.PrintDefaults()
|
|
||||||
}
|
|
||||||
|
|
||||||
func Parse() (*Config, error) {
|
|
||||||
var (
|
|
||||||
input string
|
|
||||||
output string
|
|
||||||
appendMd bool
|
|
||||||
force bool
|
|
||||||
maxSize int64
|
|
||||||
maxDepth int
|
|
||||||
ignores stringSlice
|
|
||||||
exts stringSlice
|
|
||||||
showVer bool
|
|
||||||
protonDrive bool
|
|
||||||
drivePath string
|
|
||||||
verticalSlices bool
|
|
||||||
skipFrontend bool
|
|
||||||
)
|
|
||||||
|
|
||||||
flag.StringVar(&input, "i", "", "single file input (append mode only)")
|
|
||||||
flag.StringVar(&output, "o", "", "output markdown file path (required)")
|
|
||||||
flag.BoolVar(&appendMd, "a", false, "append to existing output file")
|
|
||||||
flag.BoolVar(&force, "f", false, "overwrite existing output file (non-append mode)")
|
|
||||||
flag.Int64Var(&maxSize, "max-size", DefaultMaxSize, "max file size in bytes")
|
|
||||||
flag.IntVar(&maxDepth, "max-depth", DefaultMaxDepth, "max directory recursion depth")
|
|
||||||
flag.Var(&ignores, "ignore", "additional ignore patterns (glob, repeatable)")
|
|
||||||
flag.Var(&exts, "extensions", "additional file extensions to skip (repeatable)")
|
|
||||||
flag.BoolVar(&showVer, "version", false, "print version and exit")
|
|
||||||
flag.BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally")
|
|
||||||
flag.StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (requires --proton-drive)")
|
|
||||||
flag.BoolVar(&verticalSlices, "vertical-slices", false, "split output into root file + one markdown per top-level directory")
|
|
||||||
flag.BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.)")
|
|
||||||
|
|
||||||
flag.Usage = PrintUsage
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if showVer {
|
|
||||||
fmt.Printf("dirmd v%s\n", Version)
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if output == "" {
|
|
||||||
return nil, fmt.Errorf("-o is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if drivePath != "" && !protonDrive {
|
|
||||||
return nil, fmt.Errorf("--drive-path requires --proton-drive")
|
|
||||||
}
|
|
||||||
|
|
||||||
if verticalSlices && appendMd {
|
|
||||||
return nil, fmt.Errorf("--vertical-slices cannot be used with --append")
|
|
||||||
}
|
|
||||||
|
|
||||||
if verticalSlices && input != "" {
|
|
||||||
return nil, fmt.Errorf("--vertical-slices cannot be used with --input-file")
|
|
||||||
}
|
|
||||||
|
|
||||||
excludedExts := append(DefaultExts, exts...)
|
|
||||||
if skipFrontend {
|
|
||||||
excludedExts = append(excludedExts, FrontendExts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := &Config{
|
|
||||||
OutputPath: output,
|
|
||||||
AppendMode: appendMd,
|
|
||||||
Force: force,
|
|
||||||
MaxSize: maxSize,
|
|
||||||
MaxDepth: maxDepth,
|
|
||||||
Ignores: append(DefaultIgnores, ignores...),
|
|
||||||
Extensions: excludedExts,
|
|
||||||
ProtonDrive: protonDrive,
|
|
||||||
DrivePath: drivePath,
|
|
||||||
VerticalSlices: verticalSlices,
|
|
||||||
SkipFrontend: skipFrontend,
|
|
||||||
}
|
|
||||||
|
|
||||||
if input != "" {
|
|
||||||
cfg.SingleFile = true
|
|
||||||
cfg.InputPath = input
|
|
||||||
if !appendMd {
|
|
||||||
return nil, fmt.Errorf("single file input (-i) requires append mode (-a)")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
args := flag.Args()
|
|
||||||
if len(args) < 1 {
|
|
||||||
return nil, fmt.Errorf("directory argument is required when -i is not used")
|
|
||||||
}
|
|
||||||
cfg.InputPath = args[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
abs, err := filepath.Abs(cfg.InputPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to resolve absolute path: %w", err)
|
|
||||||
}
|
|
||||||
cfg.AbsRoot = abs
|
|
||||||
|
|
||||||
if !appendMd {
|
|
||||||
if _, err := os.Stat(output); err == nil && !force {
|
|
||||||
return nil, fmt.Errorf("output file %s already exists (use -f to overwrite or -a to append)", output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-11
@@ -30,7 +30,6 @@ func CheckAuth() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureRemoteParent creates the parent remote directory if it doesn't exist.
|
|
||||||
func EnsureRemoteParent(remoteDir string) error {
|
func EnsureRemoteParent(remoteDir string) error {
|
||||||
binary, err := Lookup()
|
binary, err := Lookup()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -38,37 +37,45 @@ func EnsureRemoteParent(remoteDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
parentDir := filepath.Dir(remoteDir)
|
parentDir := filepath.Dir(remoteDir)
|
||||||
|
|
||||||
// Try create-folder on the grandparent for the parent
|
|
||||||
grandparent := filepath.Dir(parentDir)
|
grandparent := filepath.Dir(parentDir)
|
||||||
parentName := filepath.Base(parentDir)
|
parentName := filepath.Base(parentDir)
|
||||||
|
|
||||||
cmd := exec.Command(binary, "filesystem", "create-folder", grandparent, parentName)
|
cmd := exec.Command(binary, "filesystem", "create-folder", grandparent, parentName)
|
||||||
cmd.Stdout = nil
|
cmd.Stdout = nil
|
||||||
cmd.Stderr = nil
|
cmd.Stderr = nil
|
||||||
cmd.Run() // Ignore errors — folder may already exist
|
cmd.Run()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadDir uploads an entire local directory to Proton Drive.
|
func CleanRemote(remoteDir string) {
|
||||||
// The local directory's basename becomes the remote directory name.
|
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 {
|
func UploadDir(localDir, remoteDir string) error {
|
||||||
binary, err := Lookup()
|
binary, err := Lookup()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("proton-drive not found in PATH: %w", err)
|
return fmt.Errorf("proton-drive not found in PATH: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure parent of remote dir exists
|
|
||||||
if err := EnsureRemoteParent(remoteDir); err != nil {
|
if err := EnsureRemoteParent(remoteDir); err != nil {
|
||||||
return fmt.Errorf("cannot prepare remote directory: %w", err)
|
return fmt.Errorf("cannot prepare remote directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload to parent — the local dir's basename creates the remote dir
|
CleanRemote(remoteDir)
|
||||||
|
|
||||||
remoteParent := filepath.Dir(remoteDir)
|
remoteParent := filepath.Dir(remoteDir)
|
||||||
|
|
||||||
cmd := exec.Command(binary, "filesystem", "upload", localDir, remoteParent,
|
cmd := exec.Command(binary, "filesystem", "upload", localDir, remoteParent,
|
||||||
"--conflict-strategy", "replace")
|
"--folder-conflict-strategy", "replace", "--file-conflict-strategy", "replace")
|
||||||
|
|
||||||
var stderr strings.Builder
|
var stderr strings.Builder
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
@@ -80,7 +87,6 @@ func UploadDir(localDir, remoteDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload uploads a single local file to a remote path on Proton Drive.
|
|
||||||
func Upload(localPath, remotePath string) error {
|
func Upload(localPath, remotePath string) error {
|
||||||
binary, err := Lookup()
|
binary, err := Lookup()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,7 +117,7 @@ func Upload(localPath, remotePath string) error {
|
|||||||
|
|
||||||
func runUpload(binary, localPath, remoteDir string) error {
|
func runUpload(binary, localPath, remoteDir string) error {
|
||||||
cmd := exec.Command(binary, "filesystem", "upload", localPath, remoteDir,
|
cmd := exec.Command(binary, "filesystem", "upload", localPath, remoteDir,
|
||||||
"--conflict-strategy", "replace")
|
"--folder-conflict-strategy", "replace", "--file-conflict-strategy", "replace")
|
||||||
|
|
||||||
var stderr strings.Builder
|
var stderr strings.Builder
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,17 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.flo.fo/FLO/dirmd/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Filter struct {
|
type Filter struct {
|
||||||
MaxSize int64
|
MaxSize int64
|
||||||
Extensions []string
|
Extensions []string
|
||||||
Ignores []string
|
Ignores []string
|
||||||
|
OmitTest bool
|
||||||
|
FrontendOnly bool
|
||||||
|
OmitMd bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Filter) ShouldSkipDir(name string) 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)
|
ext := filepath.Ext(path)
|
||||||
for _, bad := range f.Extensions {
|
for _, bad := range f.Extensions {
|
||||||
if strings.EqualFold(ext, bad) {
|
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 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
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatCommitLine_nil(t *testing.T) {
|
||||||
|
if got := FormatCommitLine(nil); got != "" {
|
||||||
|
t.Errorf("FormatCommitLine(nil) = %q; want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatCommitLine_valid(t *testing.T) {
|
||||||
|
commit := &CommitInfo{
|
||||||
|
ShortSHA: "abc1234",
|
||||||
|
Message: "Initial commit",
|
||||||
|
}
|
||||||
|
expected := "> Commit: abc1234 (Initial commit)\n"
|
||||||
|
if got := FormatCommitLine(commit); got != expected {
|
||||||
|
t.Errorf("FormatCommitLine() = %q; want %q", got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCommitInfo_no_git(t *testing.T) {
|
||||||
|
cmd := exec.Command("git", "-C", "/nonexistent", "log", "-1", "--format=%h %s")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error from nonexistent path, got %s", string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
result := GetCommitInfo("/nonexistent")
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("GetCommitInfo(/nonexistent) = %v; want nil", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCommitInfo_no_commits(t *testing.T) {
|
||||||
|
out, err := exec.Command("sh", "-c", "echo 'abc123'").Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("shell command failed")
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(strings.TrimSpace(string(out)), " ", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
t.Skip("cannot verify single-part output")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+347
-179
@@ -6,15 +6,19 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"dirmd/internal/tree"
|
"git.flo.fo/FLO/dirmd/internal/config"
|
||||||
"dirmd/internal/walker"
|
"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 = "~~~"
|
const fenceChars = "~~~"
|
||||||
|
|
||||||
// Write writes a single markdown file with tree and file contents.
|
|
||||||
func Write(result *walker.Result, outputPath string, appendMode bool, singleFile bool) error {
|
func Write(result *walker.Result, outputPath string, appendMode bool, singleFile bool) error {
|
||||||
flags := os.O_CREATE | os.O_WRONLY
|
flags := os.O_CREATE | os.O_WRONLY
|
||||||
if appendMode {
|
if appendMode {
|
||||||
@@ -36,14 +40,31 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
|
|||||||
h1Path = filepath.Join(result.AbsRoot, result.Entries[0].RelPath)
|
h1Path = filepath.Join(result.AbsRoot, result.Entries[0].RelPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(&sb, "# %s [ROOT]\n\n", h1Path)
|
fmt.Fprintf(&sb, "# %s [ROOT]\n", h1Path)
|
||||||
|
|
||||||
|
if !appendMode {
|
||||||
|
fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339))
|
||||||
|
sb.WriteString(git.FormatCommitLine(result.Commit))
|
||||||
|
|
||||||
|
if result.Instructions != "" {
|
||||||
|
sb.WriteString(instructions.Format(result.Instructions))
|
||||||
|
} else {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(FormatFlags(result.Cfg))
|
||||||
|
}
|
||||||
|
|
||||||
if !singleFile {
|
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)
|
rootName := filepath.Base(result.AbsRoot)
|
||||||
sb.WriteString("## Directory Tree\n\n")
|
sb.WriteString("## Directory Tree\n\n")
|
||||||
sb.WriteString(fenceChars + "\n")
|
sb.WriteString(fenceChars + "\n")
|
||||||
sb.WriteString(renderTreeForLLM(root, rootName))
|
sb.WriteString(renderTreeWithLegend(treeRoot, rootName))
|
||||||
sb.WriteString(fenceChars + "\n\n")
|
sb.WriteString(fenceChars + "\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,8 +75,7 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
|
|||||||
|
|
||||||
for _, entry := range result.Entries {
|
for _, entry := range result.Entries {
|
||||||
fullPath := filepath.Join(result.AbsRoot, entry.RelPath)
|
fullPath := filepath.Join(result.AbsRoot, entry.RelPath)
|
||||||
|
content, err := readFileForOutput(fullPath, result.OmitTest)
|
||||||
content, err := readFile(fullPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
||||||
continue
|
continue
|
||||||
@@ -64,7 +84,7 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
|
|||||||
language := extToLang(filepath.Ext(entry.RelPath))
|
language := extToLang(filepath.Ext(entry.RelPath))
|
||||||
lines := countLines(content)
|
lines := countLines(content)
|
||||||
|
|
||||||
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
|
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, len(content), lines)
|
||||||
fmt.Fprintf(&sb, "%s", fenceChars)
|
fmt.Fprintf(&sb, "%s", fenceChars)
|
||||||
if language != "" {
|
if language != "" {
|
||||||
fmt.Fprintf(&sb, "%s", language)
|
fmt.Fprintf(&sb, "%s", language)
|
||||||
@@ -82,9 +102,7 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteSlices generates a root markdown file plus one markdown per top-level directory.
|
func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, error) {
|
||||||
// Returns the list of generated file paths.
|
|
||||||
func WriteSlices(result *walker.Result, outputDir string) ([]string, error) {
|
|
||||||
entries := result.Entries
|
entries := result.Entries
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
return nil, fmt.Errorf("no entries to write")
|
return nil, fmt.Errorf("no entries to write")
|
||||||
@@ -107,17 +125,11 @@ func WriteSlices(result *walker.Result, outputDir string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var generated []string
|
treeRoot, err := tree.BuildFullTree(absRoot, result.Cfg)
|
||||||
|
if err != nil {
|
||||||
// Write root file as index.md
|
return nil, fmt.Errorf("cannot build full tree: %w", err)
|
||||||
rootPath := filepath.Join(outputDir, "index.md")
|
|
||||||
generated = append(generated, rootPath)
|
|
||||||
|
|
||||||
if err := writeRootSlice(rootPath, absRoot, rootName, rootFiles, topLevelDirs); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write root slice: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write each top-level directory slice into a mirroring subdirectory
|
|
||||||
var dirNames []string
|
var dirNames []string
|
||||||
for k := range topLevelDirs {
|
for k := range topLevelDirs {
|
||||||
dirNames = append(dirNames, k)
|
dirNames = append(dirNames, k)
|
||||||
@@ -125,7 +137,40 @@ func WriteSlices(result *walker.Result, outputDir string) ([]string, error) {
|
|||||||
sort.Strings(dirNames)
|
sort.Strings(dirNames)
|
||||||
|
|
||||||
for _, dirName := range dirNames {
|
for _, dirName := range dirNames {
|
||||||
sliceName := strings.TrimSuffix(dirName, "/") + ".md"
|
if node, ok := treeRoot.Children[dirName]; ok {
|
||||||
|
node.Ref = filepath.Join(dirName, dirName+"_"+repoName+".md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var generated []string
|
||||||
|
indexBasename := "index_" + repoName + ".md"
|
||||||
|
rootPath := filepath.Join(outputDir, indexBasename)
|
||||||
|
generated = append(generated, rootPath)
|
||||||
|
|
||||||
|
rootSliceBasename := "root_" + repoName + ".md"
|
||||||
|
rootSlicePath := filepath.Join(outputDir, rootSliceBasename)
|
||||||
|
generated = append(generated, rootSlicePath)
|
||||||
|
|
||||||
|
pickInfos := make([]PickInfo, 0)
|
||||||
|
|
||||||
|
if len(result.Cfg.PickPaths) > 0 {
|
||||||
|
pickInfos, err = writePickFiles(absRoot, outputDir, result.Cfg, result.OmitTest, result.Commit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write pick files: %w", err)
|
||||||
|
}
|
||||||
|
generated = append(generated, pickInfoPaths(pickInfos)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeRootIndex(rootPath, absRoot, rootName, repoName, topLevelDirs, rootFiles, result.Commit, result.Instructions, treeRoot, result.Cfg, pickInfos); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write root index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeRootSliceFile(rootSlicePath, absRoot, rootFiles, result.OmitTest, result.Commit, result.Cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write root slice: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dirName := range dirNames {
|
||||||
|
sliceName := dirName + "_" + repoName + ".md"
|
||||||
sliceDir := filepath.Join(outputDir, dirName)
|
sliceDir := filepath.Join(outputDir, dirName)
|
||||||
if err := os.MkdirAll(sliceDir, 0755); err != nil {
|
if err := os.MkdirAll(sliceDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("cannot create slice directory %s: %w", sliceDir, err)
|
return nil, fmt.Errorf("cannot create slice directory %s: %w", sliceDir, err)
|
||||||
@@ -136,21 +181,7 @@ func WriteSlices(result *walker.Result, outputDir string) ([]string, error) {
|
|||||||
absDir := filepath.Join(absRoot, dirName)
|
absDir := filepath.Join(absRoot, dirName)
|
||||||
dirEntries := topLevelDirs[dirName]
|
dirEntries := topLevelDirs[dirName]
|
||||||
|
|
||||||
// Strip dirName prefix for tree building only
|
if err := writeDirSlice(slicePath, absDir, absRoot, dirName, dirEntries, indexBasename, result.OmitTest, result.Commit, result.Cfg); err != nil {
|
||||||
treeEntries := make([]walker.Entry, len(dirEntries))
|
|
||||||
for i, e := range dirEntries {
|
|
||||||
sepIdx := strings.Index(e.RelPath, string(filepath.Separator))
|
|
||||||
if sepIdx >= 0 {
|
|
||||||
treeEntries[i] = walker.Entry{
|
|
||||||
RelPath: e.RelPath[sepIdx+1:],
|
|
||||||
Size: e.Size,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
treeEntries[i] = e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := writeDirSlice(slicePath, absDir, absRoot, dirName, treeEntries, dirEntries, "index.md"); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err)
|
return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,86 +189,227 @@ func WriteSlices(result *walker.Result, outputDir string) ([]string, error) {
|
|||||||
return generated, nil
|
return generated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeRootSlice(path, absRoot, rootName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry) error {
|
type PickInfo struct {
|
||||||
|
RelPath string
|
||||||
|
FileType string
|
||||||
|
SliceDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickInfoPaths(picks []PickInfo) []string {
|
||||||
|
var paths []string
|
||||||
|
for _, p := range picks {
|
||||||
|
paths = append(paths, filepath.Join(p.SliceDir, p.FileType))
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePickFiles(absRoot, outputDir string, cfg *config.Config, omitTest bool, commit *git.CommitInfo) ([]PickInfo, error) {
|
||||||
|
var pickInfos []PickInfo
|
||||||
|
|
||||||
|
for _, pickPath := range cfg.PickPaths {
|
||||||
|
fullPath := filepath.Join(absRoot, pickPath)
|
||||||
|
|
||||||
|
info, err := os.Stat(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot access pick path %s: %w", pickPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("pick path must be a file: %s", pickPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := filepath.Base(pickPath)
|
||||||
|
flt := &filter.Filter{
|
||||||
|
MaxSize: cfg.MaxSize,
|
||||||
|
Extensions: cfg.Extensions,
|
||||||
|
Ignores: cfg.Ignores,
|
||||||
|
OmitTest: cfg.OmitTest,
|
||||||
|
FrontendOnly: cfg.FrontendOnly,
|
||||||
|
OmitMd: cfg.OmitMd,
|
||||||
|
}
|
||||||
|
|
||||||
|
if flt.ShouldSkipFile(fullPath, name, info) {
|
||||||
|
return nil, fmt.Errorf("pick path %s filtered by active rules", pickPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(pickPath, string(filepath.Separator))
|
||||||
|
sliceDir := outputDir
|
||||||
|
if len(parts) > 1 {
|
||||||
|
sliceDir = filepath.Join(outputDir, parts[0])
|
||||||
|
if err := os.MkdirAll(sliceDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot create pick slice directory %s: %w", sliceDir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sliceName := filepath.Base(pickPath) + ".md"
|
||||||
|
pickPathOut := filepath.Join(sliceDir, sliceName)
|
||||||
|
|
||||||
|
if err := writePickFile(pickPathOut, absRoot, pickPath, omitTest, commit, cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write pick %s: %w", pickPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pickInfos = append(pickInfos, PickInfo{
|
||||||
|
RelPath: pickPath,
|
||||||
|
FileType: sliceName,
|
||||||
|
SliceDir: filepath.Base(sliceDir),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return pickInfos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePickFile(path, absRoot, relPath string, omitTest bool, commit *git.CommitInfo, cfg *config.Config) error {
|
||||||
f, err := os.Create(path)
|
f, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot create root file: %w", err)
|
return fmt.Errorf("cannot create pick file: %w", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
fmt.Fprintf(&sb, "# %s [ROOT]\n\n", absRoot)
|
fmt.Fprintf(&sb, "# %s [SLICE]\n", filepath.Join(absRoot, relPath))
|
||||||
|
fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339))
|
||||||
|
sb.WriteString(git.FormatCommitLine(commit))
|
||||||
|
sb.WriteString(FormatFlags(cfg))
|
||||||
|
fmt.Fprintf(&sb, "Root document: index_%s.md\n\n", filepath.Base(absRoot))
|
||||||
|
|
||||||
|
fullPath := filepath.Join(absRoot, relPath)
|
||||||
|
content, err := readFileForOutput(fullPath, omitTest)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot read pick file %s: %w", relPath, err)
|
||||||
|
}
|
||||||
|
language := extToLang(filepath.Ext(relPath))
|
||||||
|
lines := countLines(content)
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, "Tree: %s\n\n", tree.RenderBreadcrumb(absRoot, relPath))
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", relPath, len(content), lines)
|
||||||
|
sb.WriteString(fenceChars)
|
||||||
|
if language != "" {
|
||||||
|
sb.WriteString(language)
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(content)
|
||||||
|
if !strings.HasSuffix(content, "\n") {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars)
|
||||||
|
|
||||||
|
_, err = f.WriteString(sb.String())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[string][]walker.Entry, rootFiles []walker.Entry, commit *git.CommitInfo, instContent string, treeRoot *tree.Node, cfg *config.Config, picks []PickInfo) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot create root index: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "# %s [ROOT]\n", absRoot)
|
||||||
|
fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339))
|
||||||
|
sb.WriteString(git.FormatCommitLine(commit))
|
||||||
|
if instContent != "" {
|
||||||
|
sb.WriteString(instructions.Format(instContent))
|
||||||
|
} else {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString(FormatFlags(cfg))
|
||||||
|
|
||||||
// Build combined tree with references
|
|
||||||
treeLines := buildTreeWithRefs(rootName, topLevelDirs, rootFiles)
|
|
||||||
sb.WriteString("## Directory Tree\n\n")
|
sb.WriteString("## Directory Tree\n\n")
|
||||||
sb.WriteString(fenceChars + "\n")
|
sb.WriteString(fenceChars + "\n")
|
||||||
sb.WriteString(treeLines)
|
sb.WriteString(renderTreeWithLegend(treeRoot, rootName))
|
||||||
sb.WriteString(fenceChars + "\n\n")
|
sb.WriteString(fenceChars + "\n\n")
|
||||||
|
|
||||||
// Vertical slices index for LLM navigation
|
|
||||||
sb.WriteString("## Vertical Slices Index\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("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("Read the relevant slice file for detailed source code.\n\n")
|
||||||
sb.WriteString("| Slice File | Directory | Files | Total Bytes |\n")
|
sb.WriteString("| Slice File | Directory | Files | Total Bytes |\n")
|
||||||
sb.WriteString("|------------|-----------|-------|-------------|\n")
|
sb.WriteString("|------------|-----------|-------|-------------|\n")
|
||||||
|
|
||||||
var dirNames []string
|
rootSliceFile := "root_" + repoName + ".md"
|
||||||
for k := range topLevelDirs {
|
rootFileCount := len(rootFiles)
|
||||||
dirNames = append(dirNames, k)
|
rootTotalBytes := int64(0)
|
||||||
|
for _, e := range rootFiles {
|
||||||
|
rootTotalBytes += e.Size
|
||||||
}
|
}
|
||||||
sort.Strings(dirNames)
|
fmt.Fprintf(&sb, "| %s | (root) | %d | %d |\n", rootSliceFile, rootFileCount, rootTotalBytes)
|
||||||
|
|
||||||
for _, dirName := range dirNames {
|
for _, dirName := range sortedKeys(topLevelDirs) {
|
||||||
entries := topLevelDirs[dirName]
|
entries := topLevelDirs[dirName]
|
||||||
fileCount := len(entries)
|
fileCount := len(entries)
|
||||||
totalBytes := int64(0)
|
totalBytes := int64(0)
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
totalBytes += e.Size
|
totalBytes += e.Size
|
||||||
}
|
}
|
||||||
sliceFile := filepath.Join(dirName, strings.TrimSuffix(dirName, "/")+".md")
|
sliceFile := filepath.Join(dirName, dirName+"_"+repoName+".md")
|
||||||
fmt.Fprintf(&sb, "| %s | %s/ | %d | %d |\n", sliceFile, dirName, fileCount, totalBytes)
|
fmt.Fprintf(&sb, "| %s | %s/ | %d | %d |\n", sliceFile, dirName, fileCount, totalBytes)
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
if len(rootFiles) > 0 {
|
if len(picks) > 0 {
|
||||||
sb.WriteString("## Root Contents\n\n")
|
sb.WriteString("## Picks\n\n")
|
||||||
|
sb.WriteString("These files were explicitly selected via --pick flags.\n\n")
|
||||||
sort.Slice(rootFiles, func(i, j int) bool {
|
for _, p := range picks {
|
||||||
return rootFiles[i].RelPath < rootFiles[j].RelPath
|
fmt.Fprintf(&sb, "- `%s` (%s)\n", p.RelPath, p.SliceDir)
|
||||||
})
|
|
||||||
|
|
||||||
for _, entry := range rootFiles {
|
|
||||||
fullPath := filepath.Join(absRoot, entry.RelPath)
|
|
||||||
content, err := readFile(fullPath)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
language := extToLang(filepath.Ext(entry.RelPath))
|
|
||||||
lines := countLines(content)
|
|
||||||
|
|
||||||
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
|
|
||||||
fmt.Fprintf(&sb, "%s", fenceChars)
|
|
||||||
if language != "" {
|
|
||||||
fmt.Fprintf(&sb, "%s", language)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&sb, "\n")
|
|
||||||
fmt.Fprintf(&sb, "%s", content)
|
|
||||||
if !strings.HasSuffix(content, "\n") {
|
|
||||||
fmt.Fprintf(&sb, "\n")
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars)
|
|
||||||
}
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = f.WriteString(sb.String())
|
_, err = f.WriteString(sb.String())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntries []walker.Entry, rootFileName string) error {
|
func writeRootSliceFile(path, absRoot string, rootFiles []walker.Entry, omitTest bool, commit *git.CommitInfo, cfg *config.Config) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot create root slice file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "# %s [ROOT]\n", absRoot)
|
||||||
|
fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339))
|
||||||
|
sb.WriteString(git.FormatCommitLine(commit))
|
||||||
|
sb.WriteString(FormatFlags(cfg))
|
||||||
|
|
||||||
|
rootName := filepath.Base(absRoot)
|
||||||
|
fmt.Fprintf(&sb, "Tree: %s /\n\n", rootName)
|
||||||
|
|
||||||
|
if len(rootFiles) == 0 {
|
||||||
|
sb.WriteString("(none)\n")
|
||||||
|
_, err = f.WriteString(sb.String())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(rootFiles, func(i, j int) bool { return rootFiles[i].RelPath < rootFiles[j].RelPath })
|
||||||
|
for _, entry := range rootFiles {
|
||||||
|
fullPath := filepath.Join(absRoot, entry.RelPath)
|
||||||
|
content, err := readFileForOutput(fullPath, omitTest)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
language := extToLang(filepath.Ext(entry.RelPath))
|
||||||
|
lines := countLines(content)
|
||||||
|
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, len(content), lines)
|
||||||
|
fmt.Fprintf(&sb, "%s", fenceChars)
|
||||||
|
if language != "" {
|
||||||
|
fmt.Fprintf(&sb, "%s", language)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "\n%s", content)
|
||||||
|
if !strings.HasSuffix(content, "\n") {
|
||||||
|
fmt.Fprintf(&sb, "\n")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = f.WriteString(sb.String())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDirSlice(path, absDir, absRoot, dirName string, fileEntries []walker.Entry, rootFileName string, omitTest bool, commit *git.CommitInfo, cfg *config.Config) error {
|
||||||
f, err := os.Create(path)
|
f, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot create slice file: %w", err)
|
return fmt.Errorf("cannot create slice file: %w", err)
|
||||||
@@ -245,42 +417,32 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri
|
|||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "# %s [SLICE]\n", absDir)
|
||||||
fmt.Fprintf(&sb, "# %s [SLICE]\n\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, "Root document: %s\n\n", rootFileName)
|
||||||
|
|
||||||
sb.WriteString("## Directory Tree\n\n")
|
fmt.Fprintf(&sb, "Tree: %s / %s\n\n", filepath.Base(absRoot), dirName)
|
||||||
sb.WriteString(fenceChars + "\n")
|
|
||||||
|
|
||||||
sliceTree := buildTreeFromEntries(dirName, treeEntries)
|
|
||||||
sb.WriteString(sliceTree)
|
|
||||||
sb.WriteString(fenceChars + "\n\n")
|
|
||||||
|
|
||||||
if len(fileEntries) > 0 {
|
if len(fileEntries) > 0 {
|
||||||
sb.WriteString("## Contents\n\n")
|
sb.WriteString("## Contents\n\n")
|
||||||
|
sort.Slice(fileEntries, func(i, j int) bool { return fileEntries[i].RelPath < fileEntries[j].RelPath })
|
||||||
sort.Slice(fileEntries, func(i, j int) bool {
|
|
||||||
return fileEntries[i].RelPath < fileEntries[j].RelPath
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, entry := range fileEntries {
|
for _, entry := range fileEntries {
|
||||||
fullPath := filepath.Join(absRoot, entry.RelPath)
|
fullPath := filepath.Join(absRoot, entry.RelPath)
|
||||||
content, err := readFile(fullPath)
|
content, err := readFileForOutput(fullPath, omitTest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
language := extToLang(filepath.Ext(entry.RelPath))
|
language := extToLang(filepath.Ext(entry.RelPath))
|
||||||
lines := countLines(content)
|
lines := countLines(content)
|
||||||
|
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, len(content), lines)
|
||||||
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
|
|
||||||
fmt.Fprintf(&sb, "%s", fenceChars)
|
fmt.Fprintf(&sb, "%s", fenceChars)
|
||||||
if language != "" {
|
if language != "" {
|
||||||
fmt.Fprintf(&sb, "%s", language)
|
fmt.Fprintf(&sb, "%s", language)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "\n")
|
fmt.Fprintf(&sb, "\n%s", content)
|
||||||
fmt.Fprintf(&sb, "%s", content)
|
|
||||||
if !strings.HasSuffix(content, "\n") {
|
if !strings.HasSuffix(content, "\n") {
|
||||||
fmt.Fprintf(&sb, "\n")
|
fmt.Fprintf(&sb, "\n")
|
||||||
}
|
}
|
||||||
@@ -292,95 +454,25 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildTreeWithRefs(rootName string, topLevelDirs map[string][]walker.Entry, rootFiles []walker.Entry) string {
|
func sortedKeys(m map[string][]walker.Entry) []string {
|
||||||
var sb strings.Builder
|
keys := make([]string, 0, len(m))
|
||||||
sb.WriteString(rootName)
|
for k := range m {
|
||||||
sb.WriteString("/\n")
|
keys = append(keys, k)
|
||||||
|
|
||||||
// Collect and sort directory names
|
|
||||||
var dirNames []string
|
|
||||||
for k := range topLevelDirs {
|
|
||||||
dirNames = append(dirNames, k)
|
|
||||||
}
|
|
||||||
sort.Strings(dirNames)
|
|
||||||
|
|
||||||
// Sort root files
|
|
||||||
sort.Slice(rootFiles, func(i, j int) bool {
|
|
||||||
return rootFiles[i].RelPath < rootFiles[j].RelPath
|
|
||||||
})
|
|
||||||
|
|
||||||
// Merge directories and files into a single sorted list for display
|
|
||||||
type treeItem struct {
|
|
||||||
name string
|
|
||||||
isDir bool
|
|
||||||
sliceRef string
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]treeItem, 0, len(dirNames)+len(rootFiles))
|
|
||||||
for _, d := range dirNames {
|
|
||||||
items = append(items, treeItem{
|
|
||||||
name: d,
|
|
||||||
isDir: true,
|
|
||||||
sliceRef: filepath.Join(d, strings.TrimSuffix(d, "/")+".md"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for _, rf := range rootFiles {
|
|
||||||
items = append(items, treeItem{
|
|
||||||
name: rf.RelPath,
|
|
||||||
isDir: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(items, func(i, j int) bool {
|
|
||||||
return items[i].name < items[j].name
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, item := range items {
|
|
||||||
sb.WriteString("+- ")
|
|
||||||
sb.WriteString(item.name)
|
|
||||||
if item.isDir {
|
|
||||||
sb.WriteString("/")
|
|
||||||
sb.WriteString(" -> see ")
|
|
||||||
sb.WriteString(item.sliceRef)
|
|
||||||
}
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildTreeFromEntries(rootName string, entries []walker.Entry) string {
|
|
||||||
root := &tree.Node{IsDir: true, Children: make(map[string]*tree.Node)}
|
|
||||||
|
|
||||||
for _, e := range entries {
|
|
||||||
parts := strings.Split(e.RelPath, string(filepath.Separator))
|
|
||||||
cur := root
|
|
||||||
|
|
||||||
for i, part := range parts {
|
|
||||||
isLeaf := i == len(parts)-1
|
|
||||||
if _, ok := cur.Children[part]; !ok {
|
|
||||||
node := &tree.Node{Name: part, IsDir: !isLeaf}
|
|
||||||
if !isLeaf {
|
|
||||||
node.Children = make(map[string]*tree.Node)
|
|
||||||
}
|
|
||||||
cur.Children[part] = node
|
|
||||||
}
|
|
||||||
cur = cur.Children[part]
|
|
||||||
}
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderTreeWithLegend(root *tree.Node, rootName string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(rootName)
|
sb.WriteString(rootName)
|
||||||
sb.WriteString("/\n")
|
sb.WriteString("/\n")
|
||||||
renderNodeLLM(&sb, root, "")
|
renderNodeLLM(&sb, root, "")
|
||||||
return sb.String()
|
|
||||||
|
if hasAnyAnnotation(root) {
|
||||||
|
sb.WriteString("Legend: ~ ignored | ~ tests omitted | ~ omitted | ~ exceeded --max-depth\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTreeForLLM(root *tree.Node, rootName string) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(rootName)
|
|
||||||
sb.WriteString("/\n")
|
|
||||||
renderNodeLLM(&sb, root, "")
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +494,14 @@ func renderNodeLLM(sb *strings.Builder, node *tree.Node, prefix string) {
|
|||||||
if child.IsDir {
|
if child.IsDir {
|
||||||
sb.WriteString("/")
|
sb.WriteString("/")
|
||||||
}
|
}
|
||||||
|
if child.Annotation != "" {
|
||||||
|
sb.WriteString(" ~ ")
|
||||||
|
sb.WriteString(child.Annotation)
|
||||||
|
}
|
||||||
|
if child.Ref != "" {
|
||||||
|
sb.WriteString(" -> see ")
|
||||||
|
sb.WriteString(child.Ref)
|
||||||
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
if child.IsDir && child.Children != nil {
|
if child.IsDir && child.Children != nil {
|
||||||
@@ -416,6 +516,18 @@ func renderNodeLLM(sb *strings.Builder, node *tree.Node, prefix string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasAnyAnnotation(node *tree.Node) bool {
|
||||||
|
if node.Annotation != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, child := range node.Children {
|
||||||
|
if hasAnyAnnotation(child) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func readFile(path string) (string, error) {
|
func readFile(path string) (string, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -439,6 +551,61 @@ func readFile(path string) (string, error) {
|
|||||||
return content, nil
|
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 {
|
func extToLang(ext string) string {
|
||||||
mapping := map[string]string{
|
mapping := map[string]string{
|
||||||
".go": "go",
|
".go": "go",
|
||||||
@@ -460,6 +627,7 @@ func extToLang(ext string) string {
|
|||||||
".cpp": "cpp",
|
".cpp": "cpp",
|
||||||
".hpp": "cpp",
|
".hpp": "cpp",
|
||||||
".txt": "text",
|
".txt": "text",
|
||||||
|
".toml": "toml",
|
||||||
}
|
}
|
||||||
|
|
||||||
if lang, ok := mapping[strings.ToLower(ext)]; ok {
|
if lang, ok := mapping[strings.ToLower(ext)]; ok {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-60
@@ -1,76 +1,36 @@
|
|||||||
package tree
|
package tree
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"dirmd/internal/walker"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Node struct {
|
type Node struct {
|
||||||
Name string
|
Name string
|
||||||
IsDir bool
|
IsDir bool
|
||||||
Children map[string]*Node
|
Children map[string]*Node
|
||||||
|
Annotation string
|
||||||
|
Ref string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Build(entries []walker.Entry) *Node {
|
func splitPath(relPath string) []string {
|
||||||
root := &Node{IsDir: true, Children: make(map[string]*Node)}
|
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])
|
||||||
|
}
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
for _, e := range entries {
|
func addPath(root *Node, rel string, isDir bool) *Node {
|
||||||
parts := strings.Split(e.RelPath, "/")
|
parts := splitPath(rel)
|
||||||
cur := root
|
cur := root
|
||||||
|
|
||||||
for i, part := range parts {
|
for i, part := range parts {
|
||||||
isLeaf := i == len(parts)-1
|
|
||||||
if _, ok := cur.Children[part]; !ok {
|
if _, ok := cur.Children[part]; !ok {
|
||||||
cur.Children[part] = &Node{Name: part, IsDir: !isLeaf, Children: make(map[string]*Node)}
|
cur.Children[part] = &Node{Name: part, IsDir: i < len(parts)-1, Children: make(map[string]*Node)}
|
||||||
}
|
}
|
||||||
cur = cur.Children[part]
|
cur = cur.Children[part]
|
||||||
}
|
}
|
||||||
}
|
cur.IsDir = isDir
|
||||||
|
return cur
|
||||||
return root
|
|
||||||
}
|
|
||||||
|
|
||||||
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 + "├── ")
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"dirmd/internal/config"
|
"git.flo.fo/FLO/dirmd/internal/config"
|
||||||
"dirmd/internal/filter"
|
"git.flo.fo/FLO/dirmd/internal/filter"
|
||||||
|
"git.flo.fo/FLO/dirmd/internal/git"
|
||||||
|
"git.flo.fo/FLO/dirmd/internal/instructions"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
@@ -20,6 +22,10 @@ type Result struct {
|
|||||||
Entries []Entry
|
Entries []Entry
|
||||||
AbsRoot string
|
AbsRoot string
|
||||||
SingleFile bool
|
SingleFile bool
|
||||||
|
OmitTest bool
|
||||||
|
Cfg *config.Config
|
||||||
|
Commit *git.CommitInfo
|
||||||
|
Instructions string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(cfg *config.Config) (*Result, error) {
|
func Run(cfg *config.Config) (*Result, error) {
|
||||||
@@ -43,18 +49,29 @@ func processSingleFile(cfg *config.Config) (*Result, error) {
|
|||||||
MaxSize: cfg.MaxSize,
|
MaxSize: cfg.MaxSize,
|
||||||
Extensions: cfg.Extensions,
|
Extensions: cfg.Extensions,
|
||||||
Ignores: cfg.Ignores,
|
Ignores: cfg.Ignores,
|
||||||
|
OmitTest: cfg.OmitTest,
|
||||||
|
FrontendOnly: cfg.FrontendOnly,
|
||||||
|
OmitMd: cfg.OmitMd,
|
||||||
}
|
}
|
||||||
|
|
||||||
if flt.ShouldSkipFile(cfg.AbsRoot, filepath.Base(cfg.AbsRoot), info) {
|
if flt.ShouldSkipFile(cfg.AbsRoot, filepath.Base(cfg.AbsRoot), info) {
|
||||||
return nil, fmt.Errorf("file %s filtered out", cfg.AbsRoot)
|
return nil, fmt.Errorf("file %s filtered out", cfg.AbsRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commitInfo := git.GetCommitInfo(filepath.Dir(cfg.AbsRoot))
|
||||||
|
var instContent string
|
||||||
|
if cfg.IncludeInstructions {
|
||||||
|
instContent, _ = instructions.Read(filepath.Dir(cfg.AbsRoot))
|
||||||
|
}
|
||||||
|
|
||||||
return &Result{
|
return &Result{
|
||||||
Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}},
|
Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}},
|
||||||
AbsRoot: filepath.Dir(cfg.AbsRoot),
|
AbsRoot: filepath.Dir(cfg.AbsRoot),
|
||||||
SingleFile: true,
|
SingleFile: true,
|
||||||
|
Cfg: cfg,
|
||||||
|
Commit: commitInfo,
|
||||||
|
Instructions: instContent,
|
||||||
}, nil
|
}, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func walkDirectory(cfg *config.Config) (*Result, error) {
|
func walkDirectory(cfg *config.Config) (*Result, error) {
|
||||||
@@ -62,9 +79,23 @@ func walkDirectory(cfg *config.Config) (*Result, error) {
|
|||||||
MaxSize: cfg.MaxSize,
|
MaxSize: cfg.MaxSize,
|
||||||
Extensions: cfg.Extensions,
|
Extensions: cfg.Extensions,
|
||||||
Ignores: cfg.Ignores,
|
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 {
|
err := filepath.WalkDir(cfg.AbsRoot, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user