Compare commits
16
Commits
v0.1.1
..
2bd14fa514
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd14fa514 | ||
|
|
1f434b1970 | ||
|
|
d7d94b26a0 | ||
|
|
9562be5e34 | ||
|
|
9fa410e3a1 | ||
|
|
c5dfbb3502 | ||
|
|
84bea485ec | ||
|
|
b63f0ba927 | ||
|
|
c72c0d0b79 | ||
|
|
35c61baa8d | ||
|
|
4396a6f619 | ||
|
|
6c365e9cde | ||
|
|
f908bffc41 | ||
|
|
a1638bd944 | ||
|
|
93d830d500 | ||
|
|
89b7eb78a7 |
@@ -1,3 +1,4 @@
|
|||||||
bin/
|
bin/
|
||||||
.env
|
.env
|
||||||
vendor/
|
vendor/
|
||||||
|
.dirmd
|
||||||
|
|||||||
@@ -4,248 +4,38 @@ Generate markdown documentation from directory structure. Optimized for LLM inge
|
|||||||
|
|
||||||
<video src="./docs/videos/dirmd.webm" autoplay loop muted playsinline></video>
|
<video src="./docs/videos/dirmd.webm" autoplay loop muted playsinline></video>
|
||||||
|
|
||||||
## Features
|
## Install
|
||||||
|
|
||||||
- Recursive directory traversal
|
go install git.flo.fo/FLO/dirmd@latest
|
||||||
- ASCII tree visualization (LLM-friendly, minimal token overhead)
|
|
||||||
- Full file contents with explicit delimiters and metadata
|
|
||||||
- Filters dotfiles, binaries, and blacklisted extensions
|
|
||||||
- Vertical slices mode: root file + one markdown per top-level directory
|
|
||||||
- Append mode for single files or directories
|
|
||||||
- Force overwrite of existing output files
|
|
||||||
- Configurable depth, file size limits, ignore patterns
|
|
||||||
- Optional upload to Proton Drive after generating output
|
|
||||||
- Skip frontend files (`.html`, `.css`, `.js`, etc.)
|
|
||||||
- Frontend only mode (include only frontend files and `README.md`)
|
|
||||||
- Omit test files and inline test blocks (Go, Java, Rust)
|
|
||||||
|
|
||||||
## Installation
|
Linux only. Run `dirmd -h` for all flags.
|
||||||
Only for linux.
|
|
||||||
Download binary from release page or install with go
|
|
||||||
```bash
|
|
||||||
go install git.flo.fo/FLO/dirmd@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use with Git Post Hook
|
## Why
|
||||||
combine with git post hook to sync with proton drive on each commit
|
|
||||||
|
|
||||||
add something like this in ./.git/hooks/post-commit
|
LLMs don't need 40k tokens of preamble. dirmd dumps a repo as markdown: tree + file contents, minus the noise. Everything filtered out stays visible in the tree (`~ tests omitted`, `~ ignored`) so nothing vanishes silently — the model knows what exists and can ask for it.
|
||||||
|
|
||||||
```bash
|
## Three ways to use it
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
**One file — small repos.**
|
||||||
|
For small projects that fit in a context window comfortably:
|
||||||
|
|
||||||
if ! command -v dirmd &>/dev/null; then
|
dirmd -o out.md ~/repos/myproject
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
Everything lands in a single markdown file. Simplest, works fine until it doesn't.
|
||||||
REPO_NAME=$(basename "$REPO_ROOT")
|
|
||||||
|
|
||||||
dirmd -o "/tmp/${REPO_NAME}" "$REPO_ROOT" -f --vertical-slices --proton-drive
|
**Vertical slices — large repos.**
|
||||||
```
|
Once a single file gets too big to paste repeatedly, split it:
|
||||||
|
|
||||||
|
dirmd -o docs ~/repos/myproject --vertical-slices
|
||||||
|
|
||||||
## Build
|
This produces an index file (metadata + full tree + slice table) and one file per top-level directory. The full tree exists only in the index; every other file carries a one-line breadcrumb pointing back to it. Workflow: paste the index, then paste only the slice you need.
|
||||||
|
|
||||||
go build -o dirmd .
|
**Slices + picks — you already know which files matter.**
|
||||||
|
Sometimes you need one specific file without its whole directory:
|
||||||
|
|
||||||
## Usage
|
dirmd -o docs ~/repos/myproject --vertical-slices \
|
||||||
|
--pick common/src/config.rs --pick init/src/main.rs
|
||||||
|
|
||||||
# Create new documentation
|
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.
|
||||||
dirmd -o output.md ~/repos/myproject
|
|
||||||
|
|
||||||
# Overwrite existing output file
|
Rule of thumb: one file for tiny repos, slices when you need to browse, picks when you already know the answer.
|
||||||
dirmd -o output.md ~/repos/myproject -f
|
|
||||||
|
|
||||||
# Append a single file
|
|
||||||
dirmd -a -i README.md -o existing.md
|
|
||||||
|
|
||||||
# Append another directory
|
|
||||||
dirmd -a ~/repos/anotherproject -o existing.md
|
|
||||||
|
|
||||||
# Generate with vertical slices
|
|
||||||
dirmd -o output.md ~/repos/myproject --vertical-slices
|
|
||||||
|
|
||||||
# Skip frontend files (.html, .css, .js, etc.)
|
|
||||||
dirmd -o output.md ~/repos/myproject --skip-frontend
|
|
||||||
|
|
||||||
# Frontend only (include only frontend files and README.md)
|
|
||||||
dirmd -o output.md ~/repos/myproject --frontend-only
|
|
||||||
|
|
||||||
# Omit test files and inline test blocks
|
|
||||||
# Go: excludes *_test.go
|
|
||||||
# Java: excludes *Test.java, *Tests.java, src/test/java/*
|
|
||||||
# Rust: strips #[cfg(test)] mod tests { ... } blocks
|
|
||||||
dirmd -o output.md ~/repos/myproject --omit-test
|
|
||||||
|
|
||||||
# Combine filters
|
|
||||||
dirmd -o output.md ~/repos/myproject --frontend-only --omit-test
|
|
||||||
|
|
||||||
# Generate and upload to Proton Drive (default: /my-files/md/<filename>)
|
|
||||||
dirmd -o output.md ~/repos/myproject --proton-drive
|
|
||||||
|
|
||||||
# Generate and upload to a specific remote path
|
|
||||||
dirmd -o output.md ~/repos/myproject --proton-drive --drive-path /my-files/docs/api.md
|
|
||||||
|
|
||||||
## Test File Omission
|
|
||||||
|
|
||||||
The `--omit-test` flag removes test files and inline test blocks to reduce token overhead in LLM context windows. Behavior varies by language:
|
|
||||||
|
|
||||||
| Language | Exclusion Strategy | What Gets Removed |
|
|
||||||
|----------|-------------------|-------------------|
|
|
||||||
| **Go** | File exclusion | All `*_test.go` files |
|
|
||||||
| **Java** | File exclusion | `*Test.java`, `*Tests.java`, files under `src/test/java/` |
|
|
||||||
| **Rust** | Content stripping | Entire `#[cfg(test)] mod tests { ... }` blocks removed from `.rs` files |
|
|
||||||
|
|
||||||
The Rust stripping works by detecting the `#[cfg(test)]` attribute, then tracking brace depth until the module closes. Nested braces in string literals may occasionally throw off the count, but this is acceptable for RAG use cases.
|
|
||||||
|
|
||||||
## Frontend Filtering
|
|
||||||
|
|
||||||
Two complementary flags control frontend file inclusion:
|
|
||||||
|
|
||||||
| Flag | Effect |
|
|
||||||
|------|--------|
|
|
||||||
| `--skip-frontend` | Exclude frontend files, keep everything else |
|
|
||||||
| `--frontend-only` | Include only frontend files and `README.md`, exclude everything else |
|
|
||||||
|
|
||||||
Frontend extensions covered:
|
|
||||||
|
|
||||||
| Type | Extensions |
|
|
||||||
|------|------------|
|
|
||||||
| Templates | `.html`, `.htm`, `.gohtml`, `.tmpl` |
|
|
||||||
| Stylesheets | `.css`, `.scss`, `.sass`, `.less` |
|
|
||||||
| JavaScript | `.js`, `.jsx`, `.mjs`, `.cjs` |
|
|
||||||
| TypeScript | `.ts`, `.tsx` |
|
|
||||||
| Frameworks | `.vue`, `.svelte` |
|
|
||||||
|
|
||||||
`--skip-frontend` and `--frontend-only` are mutually exclusive. Both can be combined with `--omit-test`.
|
|
||||||
|
|
||||||
## Vertical Slices Mode
|
|
||||||
|
|
||||||
When `--vertical-slices` is passed, dirmd splits output into multiple files instead of one monolithic document:
|
|
||||||
|
|
||||||
- **Root file** (`-o output.md`): Full directory tree with cross-references to slice files, a vertical slices index table (file counts, total bytes per directory), and full contents of all root-level files.
|
|
||||||
- **Slice files** (`cmd.md`, `internal.md`, etc.): One per top-level directory, placed alongside the root file. Each contains its own tree, a back-reference to the root document, and full recursive file contents for that directory.
|
|
||||||
|
|
||||||
This mode cannot be combined with `--append` or `--input-file`.
|
|
||||||
|
|
||||||
Example output with `dirmd -o docs.md ~/repos/myproject --vertical-slices`:
|
|
||||||
|
|
||||||
docs/
|
|
||||||
├── docs.md ← root: tree + slices index + root file contents
|
|
||||||
├── cmd.md ← slice: tree + contents of cmd/
|
|
||||||
└── internal.md ← slice: tree + contents of internal/
|
|
||||||
|
|
||||||
## LLM-Optimized Output
|
|
||||||
|
|
||||||
Output is designed for RAG ingestion and LLM context windows, not human presentation:
|
|
||||||
|
|
||||||
- **ASCII tree format** (`+-` instead of Unicode box-drawing) to reduce token overhead
|
|
||||||
- **Explicit file delimiters**: `--- FILE: ./path (bytes, lines) ---` and `--- END FILE ---`
|
|
||||||
- **Document type markers**: `[ROOT]` and `[SLICE]` in H1 headers
|
|
||||||
- **Cross-references**: Directory entries in root tree show `-> see cmd.md`; slice files reference their root document
|
|
||||||
- **Slices index table**: File counts and total bytes per slice for informed retrieval decisions
|
|
||||||
- **Tilde fences** (`~~~`) to avoid conflicts with backticks in source files
|
|
||||||
|
|
||||||
## Flags
|
|
||||||
|
|
||||||
| Flag | Description | Default |
|
|
||||||
|------|-------------|---------|
|
|
||||||
| `-o`, `--output` | Output markdown file path (required) | |
|
|
||||||
| `-a`, `--append` | Append to existing output file | false |
|
|
||||||
| `-f`, `--force` | Overwrite existing output file (non-append mode) | false |
|
|
||||||
| `-i`, `--input-file` | Single file input (append mode only) | |
|
|
||||||
| `--vertical-slices` | Split output into root file + one markdown per top-level directory | false |
|
|
||||||
| `--max-size` | Max file size in bytes | 524288 |
|
|
||||||
| `--max-depth` | Max directory recursion depth | 20 |
|
|
||||||
| `--ignore` | Additional ignore patterns (glob) | |
|
|
||||||
| `--extensions` | Additional file extensions to skip | |
|
|
||||||
| `--proton-drive` | Upload output to Proton Drive after writing locally | false |
|
|
||||||
| `--drive-path` | Full remote path on Proton Drive (e.g. /my-files/md/report.md) | /my-files/md/<filename> |
|
|
||||||
| `--skip-frontend` | Omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.) | false |
|
|
||||||
| `--frontend-only` | Include only frontend file types and README.md | false |
|
|
||||||
| `--omit-test` | Exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust | false |
|
|
||||||
|
|
||||||
## Proton Drive Integration
|
|
||||||
|
|
||||||
dirmd can optionally upload the generated markdown file(s) to Proton Drive using the [Proton Drive CLI](https://proton.me/support/drive-cli). The upload is performed only when `--proton-drive` is passed. Without it, dirmd behaves exactly as before — no Proton Drive dependency is required.
|
|
||||||
|
|
||||||
In vertical slices mode, all generated files (root + slices) are uploaded to `/my-files/md/`. The `--drive-path` flag is ignored in slices mode ; each file is uploaded by its basename.
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
1. Install the Proton Drive CLI — see the [official guide](https://proton.me/support/drive-cli) for download and installation instructions.
|
|
||||||
2. Authenticate by running `proton-drive auth login`.
|
|
||||||
3. Ensure `proton-drive` is in your PATH.
|
|
||||||
|
|
||||||
If the CLI is not installed or you are not logged in, dirmd will report the error to stderr and exit with a non-zero code. Local files are always written successfully before the upload is attempted.
|
|
||||||
|
|
||||||
For full documentation on the Proton Drive CLI, including installation, authentication, and troubleshooting, refer to the [official Proton Drive CLI support page](https://proton.me/support/drive-cli).
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
# Custom depth and size limit
|
|
||||||
dirmd -o output.md ~/repos/project --max-depth 5 --max-size 262144
|
|
||||||
|
|
||||||
# Ignore specific directories
|
|
||||||
dirmd -o output.md ~/repos/project --ignore __pycache__ --ignore .venv
|
|
||||||
|
|
||||||
# Skip additional file extensions
|
|
||||||
dirmd -o output.md ~/repos/project --extensions .log --extensions .tmp
|
|
||||||
|
|
||||||
# Vertical slices with overwrite
|
|
||||||
dirmd -o docs.md ~/repos/project -f --vertical-slices
|
|
||||||
|
|
||||||
# Skip frontend and omit tests
|
|
||||||
dirmd -o docs.md ~/repos/project -f --skip-frontend --omit-test
|
|
||||||
|
|
||||||
# Frontend only with test omission
|
|
||||||
dirmd -o docs.md ~/repos/project -f --frontend-only --omit-test
|
|
||||||
|
|
||||||
# Generate, then upload to default remote location
|
|
||||||
dirmd -o docs.md ~/repos/project --proton-drive
|
|
||||||
|
|
||||||
# Generate, then upload to a custom remote location
|
|
||||||
dirmd -o docs.md ~/repos/project --proton-drive --drive-path /my-files/projects/docs.md
|
|
||||||
|
|
||||||
# Overwrite existing local file and upload to Drive
|
|
||||||
dirmd -o docs.md ~/repos/project -f --proton-drive
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
|
|
||||||
Produces markdown with:
|
|
||||||
|
|
||||||
1. Absolute path as H1 header with `[ROOT]` or `[SLICE]` marker
|
|
||||||
2. ASCII directory tree (indented with `+-` and `|` characters)
|
|
||||||
3. Vertical slices index table (sliced mode only)
|
|
||||||
4. All text files with content in fenced code blocks
|
|
||||||
5. Explicit `--- FILE: ---` and `--- END FILE ---` delimiters with byte and line counts
|
|
||||||
6. Language detection from file extensions
|
|
||||||
|
|
||||||
Example output structure (non-sliced mode):
|
|
||||||
|
|
||||||
# /home/user/repos/myproject [ROOT]
|
|
||||||
|
|
||||||
## Directory Tree
|
|
||||||
|
|
||||||
~~~
|
|
||||||
myproject/
|
|
||||||
+- cmd/
|
|
||||||
| +- main.go
|
|
||||||
+- internal/
|
|
||||||
| +- config.go
|
|
||||||
~~~
|
|
||||||
|
|
||||||
## Contents
|
|
||||||
|
|
||||||
--- FILE: ./cmd/main.go (45 bytes, 3 lines) ---
|
|
||||||
~~~go
|
|
||||||
package main
|
|
||||||
|
|
||||||
func main() {}
|
|
||||||
~~~
|
|
||||||
--- END FILE ---
|
|
||||||
|
|
||||||
Note: Uses tilde fences (~~~) internally to avoid conflicts with nested backticks in source files.
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
version: "3"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
build:
|
||||||
|
desc: Build dirmd with injected semantic version (task build VERSION=v0.1.4)
|
||||||
|
deps: [vet]
|
||||||
|
preconditions:
|
||||||
|
- sh: printf '%s' '{{.VERSION}}' | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'
|
||||||
|
msg: "VERSION is required and must be a semantic tag like v0.1.4 — task build VERSION=v0.1.4"
|
||||||
|
cmds:
|
||||||
|
- rm ./bin/dirmd
|
||||||
|
- go build -trimpath -ldflags "-s -w -X git.flo.fo/FLO/dirmd/internal/config.Version={{.VERSION}}" -o bin/dirmd .
|
||||||
|
|
||||||
|
vet:
|
||||||
|
desc: Run go vet
|
||||||
|
cmds:
|
||||||
|
- go vet ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
desc: Run all tests
|
||||||
|
cmds:
|
||||||
|
- go test ./...
|
||||||
+45
-44
@@ -16,8 +16,9 @@ import (
|
|||||||
|
|
||||||
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
|
||||||
@@ -27,35 +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
|
||||||
|
|
||||||
# Frontend only
|
|
||||||
dirmd -o ./docs ~/repos/myproject --frontend-only
|
|
||||||
|
|
||||||
# Omit test files and test blocks
|
|
||||||
dirmd -o ./docs ~/repos/myproject --omit-test
|
|
||||||
|
|
||||||
# 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
|
|
||||||
dirmd -o ./docs ~/repos/myproject --proton-drive --drive-path /my-files/docs/api
|
|
||||||
|
|
||||||
# Interactive TUI mode
|
# Interactive TUI mode
|
||||||
dirmd --tui`,
|
dirmd -i
|
||||||
|
|
||||||
|
# Pick specific files in vertical slices mode
|
||||||
|
dirmd -o ./docs ~/repos/myproject --vertical-slices --pick common/src/lib.rs --pick mssql-extractor/src/processor.rs`,
|
||||||
RunE: run,
|
RunE: run,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +70,8 @@ Examples:
|
|||||||
omitTest bool
|
omitTest bool
|
||||||
omitMd bool
|
omitMd bool
|
||||||
tuiMode bool
|
tuiMode bool
|
||||||
|
instructions bool
|
||||||
|
pickPaths []string
|
||||||
)
|
)
|
||||||
|
|
||||||
func Execute() {
|
func Execute() {
|
||||||
@@ -86,7 +83,7 @@ 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().StringVar(&singleFile, "input-file", "", "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().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")
|
||||||
@@ -100,6 +97,8 @@ func init() {
|
|||||||
rootCmd.Flags().BoolVar(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md")
|
rootCmd.Flags().BoolVar(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md")
|
||||||
rootCmd.Flags().BoolVar(&omitTest, "omit-test", false, "exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust")
|
rootCmd.Flags().BoolVar(&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(&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 {
|
||||||
@@ -108,7 +107,7 @@ func run(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if outputPath == "" {
|
if outputPath == "" {
|
||||||
return fmt.Errorf("-o is required (or use --tui for interactive mode)")
|
return fmt.Errorf("-o is required (or use --interactive for interactive mode)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if drivePath != "" && !protonDrive {
|
if drivePath != "" && !protonDrive {
|
||||||
@@ -127,26 +126,32 @@ func run(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
|
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...)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
OutputPath: outputPath,
|
OutputPath: outputPath,
|
||||||
AppendMode: appendMode,
|
AppendMode: appendMode,
|
||||||
Force: force,
|
Force: force,
|
||||||
MaxSize: maxSize,
|
MaxSize: maxSize,
|
||||||
MaxDepth: maxDepth,
|
MaxDepth: maxDepth,
|
||||||
Ignores: append(config.DefaultIgnores, ignorePatterns...),
|
Ignores: append(config.DefaultIgnores, ignorePatterns...),
|
||||||
Extensions: excludedExts,
|
Extensions: excludedExts,
|
||||||
ProtonDrive: protonDrive,
|
ProtonDrive: protonDrive,
|
||||||
DrivePath: drivePath,
|
DrivePath: drivePath,
|
||||||
VerticalSlices: verticalSlices,
|
VerticalSlices: verticalSlices,
|
||||||
SkipFrontend: skipFrontend,
|
SkipFrontend: skipFrontend,
|
||||||
FrontendOnly: frontendOnly,
|
FrontendOnly: frontendOnly,
|
||||||
OmitTest: omitTest,
|
OmitTest: omitTest,
|
||||||
OmitMd: omitMd,
|
OmitMd: omitMd,
|
||||||
|
IncludeInstructions: instructions,
|
||||||
|
PickPaths: pickPaths,
|
||||||
}
|
}
|
||||||
|
|
||||||
if singleFile != "" {
|
if singleFile != "" {
|
||||||
@@ -174,8 +179,13 @@ func run(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !appendMode {
|
if !appendMode {
|
||||||
if _, err := os.Stat(absOutput); err == nil && !force {
|
if _, err := os.Stat(absOutput); err == nil {
|
||||||
return fmt.Errorf("output directory %s already exists (use -f to overwrite)", absOutput)
|
if !force {
|
||||||
|
return fmt.Errorf("output directory %s already exists (use -f to overwrite)", absOutput)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(absOutput); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove existing output directory: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +212,6 @@ func run(cmd *cobra.Command, args []string) error {
|
|||||||
if err != nil {
|
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 {
|
||||||
@@ -210,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, indexBasename)
|
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
|
||||||
}
|
}
|
||||||
@@ -242,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-181
@@ -1,36 +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",
|
|
||||||
"go.mod",
|
|
||||||
"go.sum",
|
|
||||||
"target",
|
|
||||||
"build",
|
|
||||||
"dist",
|
|
||||||
"Cargo.lock",
|
|
||||||
".idea",
|
|
||||||
".vscode",
|
|
||||||
"dependency-reduced-pom.xml",
|
|
||||||
"__pycache__",
|
|
||||||
"venv",
|
|
||||||
"env",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var DefaultExts = []string{
|
var DefaultExts = []string{
|
||||||
@@ -42,10 +22,7 @@ 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",
|
|
||||||
".war",
|
|
||||||
".ear",
|
|
||||||
".pyc", ".pyo",
|
".pyc", ".pyo",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,157 +34,24 @@ 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
|
||||||
AppendMode bool
|
AppendMode bool
|
||||||
Force bool
|
Force bool
|
||||||
SingleFile bool
|
SingleFile bool
|
||||||
MaxSize int64
|
MaxSize int64
|
||||||
MaxDepth int
|
MaxDepth int
|
||||||
Ignores []string
|
Ignores []string
|
||||||
Extensions []string
|
Extensions []string
|
||||||
AbsRoot string
|
AbsRoot string
|
||||||
ProtonDrive bool
|
ProtonDrive bool
|
||||||
DrivePath string
|
DrivePath string
|
||||||
VerticalSlices bool
|
VerticalSlices bool
|
||||||
SkipFrontend bool
|
SkipFrontend bool
|
||||||
FrontendOnly bool
|
FrontendOnly bool
|
||||||
OmitTest bool
|
OmitTest bool
|
||||||
OmitMd bool
|
OmitMd bool
|
||||||
}
|
IncludeInstructions bool
|
||||||
|
PickPaths []string
|
||||||
func PrintUsage() {
|
|
||||||
fmt.Fprintf(os.Stderr, "dirmd v%s\n\n", Version)
|
|
||||||
fmt.Fprintf(os.Stderr, "Usage: dirmd [flags] <directory>\n")
|
|
||||||
fmt.Fprintf(os.Stderr, " dirmd -a -i <file> -o <output>\n\n")
|
|
||||||
fmt.Fprintf(os.Stderr, "Flags:\n")
|
|
||||||
flag.PrintDefaults()
|
|
||||||
}
|
|
||||||
|
|
||||||
func Parse() (*Config, error) {
|
|
||||||
var (
|
|
||||||
input string
|
|
||||||
output string
|
|
||||||
appendMd bool
|
|
||||||
force bool
|
|
||||||
maxSize int64
|
|
||||||
maxDepth int
|
|
||||||
ignores stringSlice
|
|
||||||
exts stringSlice
|
|
||||||
showVer bool
|
|
||||||
protonDrive bool
|
|
||||||
drivePath string
|
|
||||||
verticalSlices bool
|
|
||||||
skipFrontend bool
|
|
||||||
frontendOnly bool
|
|
||||||
omitTest bool
|
|
||||||
omitMd bool
|
|
||||||
)
|
|
||||||
|
|
||||||
flag.StringVar(&input, "i", "", "single file input (append mode only)")
|
|
||||||
flag.StringVar(&output, "o", "", "output markdown file path (required)")
|
|
||||||
flag.BoolVar(&appendMd, "a", false, "append to existing output file")
|
|
||||||
flag.BoolVar(&force, "f", false, "overwrite existing output file (non-append mode)")
|
|
||||||
flag.Int64Var(&maxSize, "max-size", DefaultMaxSize, "max file size in bytes")
|
|
||||||
flag.IntVar(&maxDepth, "max-depth", DefaultMaxDepth, "max directory recursion depth")
|
|
||||||
flag.Var(&ignores, "ignore", "additional ignore patterns (glob, repeatable)")
|
|
||||||
flag.Var(&exts, "extensions", "additional file extensions to skip (repeatable)")
|
|
||||||
flag.BoolVar(&showVer, "version", false, "print version and exit")
|
|
||||||
flag.BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally")
|
|
||||||
flag.StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (requires --proton-drive")
|
|
||||||
flag.BoolVar(&verticalSlices, "vertical-slices", false, "split output into root file + one markdown per top-level directory")
|
|
||||||
flag.BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.)")
|
|
||||||
flag.BoolVar(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md")
|
|
||||||
flag.BoolVar(&omitTest, "omit-test", false, "exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust")
|
|
||||||
flag.BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md")
|
|
||||||
|
|
||||||
flag.Usage = PrintUsage
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if showVer {
|
|
||||||
fmt.Printf("dirmd v%s\n", Version)
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if output == "" {
|
|
||||||
return nil, fmt.Errorf("-o is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if drivePath != "" && !protonDrive {
|
|
||||||
return nil, fmt.Errorf("--drive-path requires --proton-drive")
|
|
||||||
}
|
|
||||||
|
|
||||||
if verticalSlices && appendMd {
|
|
||||||
return nil, fmt.Errorf("--vertical-slices cannot be used with --append")
|
|
||||||
}
|
|
||||||
|
|
||||||
if verticalSlices && input != "" {
|
|
||||||
return nil, fmt.Errorf("--vertical-slices cannot be used with --input-file")
|
|
||||||
}
|
|
||||||
|
|
||||||
if skipFrontend && frontendOnly {
|
|
||||||
return nil, fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
|
|
||||||
}
|
|
||||||
|
|
||||||
excludedExts := append(DefaultExts, exts...)
|
|
||||||
if skipFrontend {
|
|
||||||
excludedExts = append(excludedExts, FrontendExts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := &Config{
|
|
||||||
OutputPath: output,
|
|
||||||
AppendMode: appendMd,
|
|
||||||
Force: force,
|
|
||||||
MaxSize: maxSize,
|
|
||||||
MaxDepth: maxDepth,
|
|
||||||
Ignores: append(DefaultIgnores, ignores...),
|
|
||||||
Extensions: excludedExts,
|
|
||||||
ProtonDrive: protonDrive,
|
|
||||||
DrivePath: drivePath,
|
|
||||||
VerticalSlices: verticalSlices,
|
|
||||||
SkipFrontend: skipFrontend,
|
|
||||||
FrontendOnly: frontendOnly,
|
|
||||||
OmitTest: omitTest,
|
|
||||||
OmitMd: omitMd,
|
|
||||||
}
|
|
||||||
|
|
||||||
if input != "" {
|
|
||||||
cfg.SingleFile = true
|
|
||||||
cfg.InputPath = input
|
|
||||||
if !appendMd {
|
|
||||||
return nil, fmt.Errorf("single file input (-i) requires append mode (-a)")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
args := flag.Args()
|
|
||||||
if len(args) < 1 {
|
|
||||||
return nil, fmt.Errorf("directory argument is required when -i is not used")
|
|
||||||
}
|
|
||||||
cfg.InputPath = args[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
abs, err := filepath.Abs(cfg.InputPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to resolve absolute path: %w", err)
|
|
||||||
}
|
|
||||||
cfg.AbsRoot = abs
|
|
||||||
|
|
||||||
if !appendMd {
|
|
||||||
if _, err := os.Stat(output); err == nil && !force {
|
|
||||||
return nil, fmt.Errorf("output file %s already exists (use -f to overwrite or -a to append)", output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package drive
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBinaryName_constant(t *testing.T) {
|
||||||
|
if binaryName != "proton-drive" {
|
||||||
|
t.Errorf("binaryName = %q; want \"proton-drive\"", binaryName)
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-10
@@ -46,15 +46,15 @@ func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.OmitMd && isNonReadmeMarkdown(path, name) {
|
if f.OmitMd && IsNonReadmeMarkdown(path, name) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.FrontendOnly && !isFrontendOrReadme(path, name) {
|
if f.FrontendOnly && !IsFrontendOrReadme(path, name) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.OmitTest && isTestFile(path, name) {
|
if f.OmitTest && IsTestFile(path, name) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,25 +68,37 @@ func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func isNonReadmeMarkdown(path, name string) bool {
|
func IsNonReadmeMarkdown(path, name string) bool {
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
if ext != ".md" && ext != ".markdown" {
|
if ext != ".md" && ext != ".markdown" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return !strings.EqualFold(name, "README.md")
|
return !strings.EqualFold(name, "README.md") && !strings.EqualFold(name, "README.markdown")
|
||||||
}
|
}
|
||||||
|
|
||||||
func isFrontendOrReadme(path, name string) bool {
|
func IsFrontendOrReadme(path, name string) bool {
|
||||||
|
if IsFrontendExt(path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.EqualFold(name, "README.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsFrontendExt(path string) bool {
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
for _, fe := range config.FrontendExts {
|
for _, fe := range config.FrontendExts {
|
||||||
if strings.EqualFold(ext, fe) {
|
if strings.EqualFold(ext, fe) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return strings.EqualFold(name, "README.md")
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func isTestFile(path, name string) bool {
|
func IsTestDir(path string) bool {
|
||||||
|
slashPath := filepath.ToSlash(path)
|
||||||
|
return strings.Contains(slashPath, "/tests/") || strings.HasPrefix(slashPath, "tests/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsTestFile(path, name string) bool {
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
@@ -107,8 +119,7 @@ func isTestFile(path, name string) bool {
|
|||||||
if strings.EqualFold(name, "conftest.py") {
|
if strings.EqualFold(name, "conftest.py") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
slashPath := filepath.ToSlash(path)
|
return IsTestDir(path)
|
||||||
return strings.Contains(slashPath, "/tests/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+292
-177
@@ -6,8 +6,13 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.flo.fo/FLO/dirmd/internal/config"
|
||||||
|
"git.flo.fo/FLO/dirmd/internal/filter"
|
||||||
|
"git.flo.fo/FLO/dirmd/internal/git"
|
||||||
|
"git.flo.fo/FLO/dirmd/internal/instructions"
|
||||||
"git.flo.fo/FLO/dirmd/internal/tree"
|
"git.flo.fo/FLO/dirmd/internal/tree"
|
||||||
"git.flo.fo/FLO/dirmd/internal/walker"
|
"git.flo.fo/FLO/dirmd/internal/walker"
|
||||||
)
|
)
|
||||||
@@ -35,14 +40,31 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
|
|||||||
h1Path = filepath.Join(result.AbsRoot, result.Entries[0].RelPath)
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +75,6 @@ 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 := readFileForOutput(fullPath, result.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)
|
||||||
@@ -63,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)
|
||||||
@@ -104,14 +125,9 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var generated []string
|
treeRoot, err := tree.BuildFullTree(absRoot, result.Cfg)
|
||||||
|
if err != nil {
|
||||||
indexBasename := "index_" + repoName + ".md"
|
return nil, fmt.Errorf("cannot build full tree: %w", err)
|
||||||
rootPath := filepath.Join(outputDir, indexBasename)
|
|
||||||
generated = append(generated, rootPath)
|
|
||||||
|
|
||||||
if err := writeRootSlice(rootPath, absRoot, rootName, repoName, rootFiles, topLevelDirs, result.OmitTest); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write root slice: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var dirNames []string
|
var dirNames []string
|
||||||
@@ -121,7 +137,40 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e
|
|||||||
sort.Strings(dirNames)
|
sort.Strings(dirNames)
|
||||||
|
|
||||||
for _, dirName := range dirNames {
|
for _, dirName := range dirNames {
|
||||||
sliceName := strings.TrimSuffix(dirName, "/") + "_" + repoName + ".md"
|
if node, ok := treeRoot.Children[dirName]; ok {
|
||||||
|
node.Ref = filepath.Join(dirName, dirName+"_"+repoName+".md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var generated []string
|
||||||
|
indexBasename := "index_" + repoName + ".md"
|
||||||
|
rootPath := filepath.Join(outputDir, indexBasename)
|
||||||
|
generated = append(generated, rootPath)
|
||||||
|
|
||||||
|
rootSliceBasename := "root_" + repoName + ".md"
|
||||||
|
rootSlicePath := filepath.Join(outputDir, rootSliceBasename)
|
||||||
|
generated = append(generated, rootSlicePath)
|
||||||
|
|
||||||
|
pickInfos := make([]PickInfo, 0)
|
||||||
|
|
||||||
|
if len(result.Cfg.PickPaths) > 0 {
|
||||||
|
pickInfos, err = writePickFiles(absRoot, outputDir, result.Cfg, result.OmitTest, result.Commit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write pick files: %w", err)
|
||||||
|
}
|
||||||
|
generated = append(generated, pickInfoPaths(pickInfos)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeRootIndex(rootPath, absRoot, rootName, repoName, topLevelDirs, rootFiles, result.Commit, result.Instructions, treeRoot, result.Cfg, pickInfos); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write root index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeRootSliceFile(rootSlicePath, absRoot, rootFiles, result.OmitTest, result.Commit, result.Cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write root slice: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dirName := range dirNames {
|
||||||
|
sliceName := dirName + "_" + repoName + ".md"
|
||||||
sliceDir := filepath.Join(outputDir, dirName)
|
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)
|
||||||
@@ -132,20 +181,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e
|
|||||||
absDir := filepath.Join(absRoot, dirName)
|
absDir := filepath.Join(absRoot, dirName)
|
||||||
dirEntries := topLevelDirs[dirName]
|
dirEntries := topLevelDirs[dirName]
|
||||||
|
|
||||||
treeEntries := make([]walker.Entry, len(dirEntries))
|
if err := writeDirSlice(slicePath, absDir, absRoot, dirName, dirEntries, indexBasename, result.OmitTest, result.Commit, result.Cfg); err != nil {
|
||||||
for i, e := range dirEntries {
|
|
||||||
sepIdx := strings.Index(e.RelPath, string(filepath.Separator))
|
|
||||||
if sepIdx >= 0 {
|
|
||||||
treeEntries[i] = walker.Entry{
|
|
||||||
RelPath: e.RelPath[sepIdx+1:],
|
|
||||||
Size: e.Size,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
treeEntries[i] = e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := writeDirSlice(slicePath, absDir, absRoot, dirName, treeEntries, dirEntries, indexBasename, result.OmitTest); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err)
|
return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,21 +189,137 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e
|
|||||||
return generated, nil
|
return generated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry, omitTest bool) error {
|
type PickInfo struct {
|
||||||
|
RelPath string
|
||||||
|
FileType string
|
||||||
|
SliceDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickInfoPaths(picks []PickInfo) []string {
|
||||||
|
var paths []string
|
||||||
|
for _, p := range picks {
|
||||||
|
paths = append(paths, filepath.Join(p.SliceDir, p.FileType))
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePickFiles(absRoot, outputDir string, cfg *config.Config, omitTest bool, commit *git.CommitInfo) ([]PickInfo, error) {
|
||||||
|
var pickInfos []PickInfo
|
||||||
|
|
||||||
|
for _, pickPath := range cfg.PickPaths {
|
||||||
|
fullPath := filepath.Join(absRoot, pickPath)
|
||||||
|
|
||||||
|
info, err := os.Stat(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot access pick path %s: %w", pickPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("pick path must be a file: %s", pickPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := filepath.Base(pickPath)
|
||||||
|
flt := &filter.Filter{
|
||||||
|
MaxSize: cfg.MaxSize,
|
||||||
|
Extensions: cfg.Extensions,
|
||||||
|
Ignores: cfg.Ignores,
|
||||||
|
OmitTest: cfg.OmitTest,
|
||||||
|
FrontendOnly: cfg.FrontendOnly,
|
||||||
|
OmitMd: cfg.OmitMd,
|
||||||
|
}
|
||||||
|
|
||||||
|
if flt.ShouldSkipFile(fullPath, name, info) {
|
||||||
|
return nil, fmt.Errorf("pick path %s filtered by active rules", pickPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(pickPath, string(filepath.Separator))
|
||||||
|
sliceDir := outputDir
|
||||||
|
if len(parts) > 1 {
|
||||||
|
sliceDir = filepath.Join(outputDir, parts[0])
|
||||||
|
if err := os.MkdirAll(sliceDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot create pick slice directory %s: %w", sliceDir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sliceName := filepath.Base(pickPath) + ".md"
|
||||||
|
pickPathOut := filepath.Join(sliceDir, sliceName)
|
||||||
|
|
||||||
|
if err := writePickFile(pickPathOut, absRoot, pickPath, omitTest, commit, cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write pick %s: %w", pickPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pickInfos = append(pickInfos, PickInfo{
|
||||||
|
RelPath: pickPath,
|
||||||
|
FileType: sliceName,
|
||||||
|
SliceDir: filepath.Base(sliceDir),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return pickInfos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePickFile(path, absRoot, relPath string, omitTest bool, commit *git.CommitInfo, cfg *config.Config) error {
|
||||||
f, err := os.Create(path)
|
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))
|
||||||
|
|
||||||
treeLines := buildTreeWithRefs(rootName, repoName, 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")
|
||||||
|
|
||||||
sb.WriteString("## Vertical Slices Index\n\n")
|
sb.WriteString("## Vertical Slices Index\n\n")
|
||||||
@@ -176,61 +328,88 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker
|
|||||||
sb.WriteString("| Slice File | Directory | Files | Total Bytes |\n")
|
sb.WriteString("| 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, "/")+"_"+repoName+".md")
|
sliceFile := filepath.Join(dirName, dirName+"_"+repoName+".md")
|
||||||
fmt.Fprintf(&sb, "| %s | %s/ | %d | %d |\n", sliceFile, dirName, fileCount, totalBytes)
|
fmt.Fprintf(&sb, "| %s | %s/ | %d | %d |\n", sliceFile, dirName, fileCount, totalBytes)
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
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 := readFileForOutput(fullPath, omitTest)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
language := extToLang(filepath.Ext(entry.RelPath))
|
|
||||||
lines := countLines(content)
|
|
||||||
|
|
||||||
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
|
|
||||||
fmt.Fprintf(&sb, "%s", fenceChars)
|
|
||||||
if language != "" {
|
|
||||||
fmt.Fprintf(&sb, "%s", language)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&sb, "\n")
|
|
||||||
fmt.Fprintf(&sb, "%s", content)
|
|
||||||
if !strings.HasSuffix(content, "\n") {
|
|
||||||
fmt.Fprintf(&sb, "\n")
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars)
|
|
||||||
}
|
}
|
||||||
|
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, omitTest bool) error {
|
func writeRootSliceFile(path, absRoot string, rootFiles []walker.Entry, omitTest bool, commit *git.CommitInfo, cfg *config.Config) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot create root slice file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "# %s [ROOT]\n", absRoot)
|
||||||
|
fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339))
|
||||||
|
sb.WriteString(git.FormatCommitLine(commit))
|
||||||
|
sb.WriteString(FormatFlags(cfg))
|
||||||
|
|
||||||
|
rootName := filepath.Base(absRoot)
|
||||||
|
fmt.Fprintf(&sb, "Tree: %s /\n\n", rootName)
|
||||||
|
|
||||||
|
if len(rootFiles) == 0 {
|
||||||
|
sb.WriteString("(none)\n")
|
||||||
|
_, err = f.WriteString(sb.String())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(rootFiles, func(i, j int) bool { return rootFiles[i].RelPath < rootFiles[j].RelPath })
|
||||||
|
for _, entry := range rootFiles {
|
||||||
|
fullPath := filepath.Join(absRoot, entry.RelPath)
|
||||||
|
content, err := readFileForOutput(fullPath, omitTest)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
language := extToLang(filepath.Ext(entry.RelPath))
|
||||||
|
lines := countLines(content)
|
||||||
|
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, len(content), lines)
|
||||||
|
fmt.Fprintf(&sb, "%s", fenceChars)
|
||||||
|
if language != "" {
|
||||||
|
fmt.Fprintf(&sb, "%s", language)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "\n%s", content)
|
||||||
|
if !strings.HasSuffix(content, "\n") {
|
||||||
|
fmt.Fprintf(&sb, "\n")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = f.WriteString(sb.String())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDirSlice(path, absDir, absRoot, dirName string, fileEntries []walker.Entry, rootFileName string, omitTest bool, commit *git.CommitInfo, cfg *config.Config) error {
|
||||||
f, err := os.Create(path)
|
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)
|
||||||
@@ -238,24 +417,17 @@ 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 := readFileForOutput(fullPath, omitTest)
|
content, err := readFileForOutput(fullPath, omitTest)
|
||||||
@@ -263,17 +435,14 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri
|
|||||||
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
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")
|
||||||
}
|
}
|
||||||
@@ -285,93 +454,25 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildTreeWithRefs(rootName, repoName 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)
|
||||||
|
|
||||||
var dirNames []string
|
|
||||||
for k := range topLevelDirs {
|
|
||||||
dirNames = append(dirNames, k)
|
|
||||||
}
|
}
|
||||||
sort.Strings(dirNames)
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
sort.Slice(rootFiles, func(i, j int) bool {
|
|
||||||
return rootFiles[i].RelPath < rootFiles[j].RelPath
|
|
||||||
})
|
|
||||||
|
|
||||||
type treeItem struct {
|
|
||||||
name string
|
|
||||||
isDir bool
|
|
||||||
sliceRef string
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]treeItem, 0, len(dirNames)+len(rootFiles))
|
|
||||||
for _, d := range dirNames {
|
|
||||||
sliceRef := filepath.Join(d, strings.TrimSuffix(d, "/")+"_"+repoName+".md")
|
|
||||||
items = append(items, treeItem{
|
|
||||||
name: d,
|
|
||||||
isDir: true,
|
|
||||||
sliceRef: sliceRef,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for _, rf := range rootFiles {
|
|
||||||
items = append(items, treeItem{
|
|
||||||
name: rf.RelPath,
|
|
||||||
isDir: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(items, func(i, j int) bool {
|
|
||||||
return items[i].name < items[j].name
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, item := range items {
|
|
||||||
sb.WriteString("+- ")
|
|
||||||
sb.WriteString(item.name)
|
|
||||||
if item.isDir {
|
|
||||||
sb.WriteString("/")
|
|
||||||
sb.WriteString(" -> see ")
|
|
||||||
sb.WriteString(item.sliceRef)
|
|
||||||
}
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildTreeFromEntries(rootName string, entries []walker.Entry) string {
|
func renderTreeWithLegend(root *tree.Node, rootName string) 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]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderTreeForLLM(root *tree.Node, rootName string) string {
|
if hasAnyAnnotation(root) {
|
||||||
var sb strings.Builder
|
sb.WriteString("Legend: ~ ignored | ~ tests omitted | ~ omitted | ~ exceeded --max-depth\n")
|
||||||
sb.WriteString(rootName)
|
}
|
||||||
sb.WriteString("/\n")
|
|
||||||
renderNodeLLM(&sb, root, "")
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,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 {
|
||||||
@@ -407,6 +516,18 @@ func renderNodeLLM(sb *strings.Builder, node *tree.Node, prefix string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasAnyAnnotation(node *tree.Node) bool {
|
||||||
|
if node.Annotation != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, child := range node.Children {
|
||||||
|
if hasAnyAnnotation(child) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func readFile(path string) (string, error) {
|
func readFile(path string) (string, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -456,20 +577,13 @@ func stripTestBlocks(content string) string {
|
|||||||
|
|
||||||
for i < len(lines) {
|
for i < len(lines) {
|
||||||
line := lines[i]
|
line := lines[i]
|
||||||
|
|
||||||
if !foundBrace {
|
|
||||||
if strings.Contains(line, "{") {
|
|
||||||
braceDepth += strings.Count(line, "{") - strings.Count(line, "}")
|
|
||||||
foundBrace = true
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
braceDepth += strings.Count(line, "{") - strings.Count(line, "}")
|
braceDepth += strings.Count(line, "{") - strings.Count(line, "}")
|
||||||
|
if strings.Contains(line, "{") {
|
||||||
|
foundBrace = true
|
||||||
|
}
|
||||||
i++
|
i++
|
||||||
|
|
||||||
if braceDepth <= 0 {
|
if foundBrace && braceDepth <= 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -513,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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-63
@@ -1,76 +1,36 @@
|
|||||||
package tree
|
package tree
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.flo.fo/FLO/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 _, e := range entries {
|
for i := 0; i <= len(relPath); i++ {
|
||||||
parts := strings.Split(e.RelPath, "/")
|
if i == len(relPath) || relPath[i] == '/' || relPath[i] == '\\' {
|
||||||
cur := root
|
if i > start {
|
||||||
|
parts = append(parts, relPath[start:i])
|
||||||
for i, part := range parts {
|
|
||||||
isLeaf := i == len(parts)-1
|
|
||||||
if _, ok := cur.Children[part]; !ok {
|
|
||||||
cur.Children[part] = &Node{Name: part, IsDir: !isLeaf, Children: make(map[string]*Node)}
|
|
||||||
}
|
}
|
||||||
cur = cur.Children[part]
|
start = i + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return parts
|
||||||
return root
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Render(root *Node, rootName string) string {
|
func addPath(root *Node, rel string, isDir bool) *Node {
|
||||||
var sb strings.Builder
|
parts := splitPath(rel)
|
||||||
sb.WriteString(rootName)
|
cur := root
|
||||||
sb.WriteString("/\n")
|
for i, part := range parts {
|
||||||
renderNode(&sb, root, "")
|
if _, ok := cur.Children[part]; !ok {
|
||||||
return sb.String()
|
cur.Children[part] = &Node{Name: part, IsDir: i < len(parts)-1, Children: make(map[string]*Node)}
|
||||||
}
|
|
||||||
|
|
||||||
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 + "├── ")
|
|
||||||
}
|
}
|
||||||
|
cur = cur.Children[part]
|
||||||
sb.WriteString(child.Name)
|
|
||||||
if child.IsDir {
|
|
||||||
sb.WriteString("/")
|
|
||||||
}
|
|
||||||
sb.WriteString("\n")
|
|
||||||
|
|
||||||
if child.IsDir {
|
|
||||||
newPrefix := prefix
|
|
||||||
if isLast {
|
|
||||||
newPrefix += " "
|
|
||||||
} else {
|
|
||||||
newPrefix += "│ "
|
|
||||||
}
|
|
||||||
renderNode(sb, child, newPrefix)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
cur.IsDir = isDir
|
||||||
|
return cur
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
+19
-16
@@ -56,8 +56,8 @@ func Run() error {
|
|||||||
|
|
||||||
km.MultiSelect.Next = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next field"))
|
km.MultiSelect.Next = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next field"))
|
||||||
km.MultiSelect.Prev = key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev field"))
|
km.MultiSelect.Prev = key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev field"))
|
||||||
km.MultiSelect.Up = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up"))
|
km.MultiSelect.Up = key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up"))
|
||||||
km.MultiSelect.Down = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"))
|
km.MultiSelect.Down = key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down"))
|
||||||
km.MultiSelect.Toggle = key.NewBinding(key.WithKeys("enter", " "), key.WithHelp("enter/space", "toggle"))
|
km.MultiSelect.Toggle = key.NewBinding(key.WithKeys("enter", " "), key.WithHelp("enter/space", "toggle"))
|
||||||
km.MultiSelect.Submit = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next"))
|
km.MultiSelect.Submit = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next"))
|
||||||
km.MultiSelect.Filter = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter"))
|
km.MultiSelect.Filter = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter"))
|
||||||
@@ -79,6 +79,7 @@ func Run() error {
|
|||||||
huh.NewOption("Omit test files", "omit-test"),
|
huh.NewOption("Omit test files", "omit-test"),
|
||||||
huh.NewOption("Omit non-README markdown", "omit-md"),
|
huh.NewOption("Omit non-README markdown", "omit-md"),
|
||||||
huh.NewOption("Upload to Proton Drive", "proton-drive"),
|
huh.NewOption("Upload to Proton Drive", "proton-drive"),
|
||||||
|
huh.NewOption("Include .dirmd instructions", "instructions"),
|
||||||
).
|
).
|
||||||
Value(&options).
|
Value(&options).
|
||||||
Height(10),
|
Height(10),
|
||||||
@@ -115,6 +116,7 @@ func Run() error {
|
|||||||
omitTest := containsOption(options, "omit-test")
|
omitTest := containsOption(options, "omit-test")
|
||||||
omitMd := containsOption(options, "omit-md")
|
omitMd := containsOption(options, "omit-md")
|
||||||
protonDrive := containsOption(options, "proton-drive")
|
protonDrive := containsOption(options, "proton-drive")
|
||||||
|
includeInstructions := containsOption(options, "instructions")
|
||||||
|
|
||||||
if skipFrontend && frontendOnly {
|
if skipFrontend && frontendOnly {
|
||||||
return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
|
return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
|
||||||
@@ -155,20 +157,21 @@ func Run() error {
|
|||||||
allIgnores = append(allIgnores, ignores...)
|
allIgnores = append(allIgnores, ignores...)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
InputPath: inputDir,
|
InputPath: inputDir,
|
||||||
OutputPath: outputDir,
|
OutputPath: outputDir,
|
||||||
Force: force,
|
Force: force,
|
||||||
MaxSize: int64(maxSizeKB) * 1024,
|
MaxSize: int64(maxSizeKB) * 1024,
|
||||||
MaxDepth: maxDepth,
|
MaxDepth: maxDepth,
|
||||||
Ignores: allIgnores,
|
Ignores: allIgnores,
|
||||||
Extensions: excludedExts,
|
Extensions: excludedExts,
|
||||||
ProtonDrive: protonDrive,
|
ProtonDrive: protonDrive,
|
||||||
DrivePath: drivePath,
|
DrivePath: drivePath,
|
||||||
VerticalSlices: verticalSlices,
|
VerticalSlices: verticalSlices,
|
||||||
SkipFrontend: skipFrontend,
|
SkipFrontend: skipFrontend,
|
||||||
FrontendOnly: frontendOnly,
|
FrontendOnly: frontendOnly,
|
||||||
OmitTest: omitTest,
|
OmitTest: omitTest,
|
||||||
OmitMd: omitMd,
|
OmitMd: omitMd,
|
||||||
|
IncludeInstructions: includeInstructions,
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := buildCommand(cfg)
|
cmd := buildCommand(cfg)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
|
|
||||||
"git.flo.fo/FLO/dirmd/internal/config"
|
"git.flo.fo/FLO/dirmd/internal/config"
|
||||||
"git.flo.fo/FLO/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 {
|
||||||
@@ -17,10 +19,13 @@ type Entry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Entries []Entry
|
Entries []Entry
|
||||||
AbsRoot string
|
AbsRoot string
|
||||||
SingleFile bool
|
SingleFile bool
|
||||||
OmitTest 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) {
|
||||||
@@ -53,11 +58,19 @@ func processSingleFile(cfg *config.Config) (*Result, error) {
|
|||||||
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,
|
||||||
OmitTest: cfg.OmitTest,
|
Cfg: cfg,
|
||||||
|
Commit: commitInfo,
|
||||||
|
Instructions: instContent,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +84,18 @@ func walkDirectory(cfg *config.Config) (*Result, error) {
|
|||||||
OmitMd: cfg.OmitMd,
|
OmitMd: cfg.OmitMd,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &Result{AbsRoot: cfg.AbsRoot, OmitTest: cfg.OmitTest}
|
result := &Result{AbsRoot: cfg.AbsRoot, Cfg: cfg, OmitTest: cfg.OmitTest}
|
||||||
|
|
||||||
|
result.Commit = git.GetCommitInfo(cfg.AbsRoot)
|
||||||
|
|
||||||
|
if cfg.IncludeInstructions {
|
||||||
|
instContent, err := instructions.Read(cfg.AbsRoot)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: could not read .dirmd: %v\n", err)
|
||||||
|
} else {
|
||||||
|
result.Instructions = instContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err := filepath.WalkDir(cfg.AbsRoot, func(path string, d fs.DirEntry, err error) error {
|
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