From b63f0ba927c52e6b7266ed0b04439fc81dd5c704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartal=20L=C3=A6arsson?= Date: Tue, 15 Sep 2026 11:42:41 +0100 Subject: [PATCH] --pick flag --- cmd/root.go | 15 +++- internal/config/config.go | 13 ++- internal/renderer/renderer.go | 158 ++++++++++++++++++++++++++++++++-- internal/tree/unfiltered.go | 19 ++++ 4 files changed, 198 insertions(+), 7 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index a9cdc1d..154c179 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -46,7 +46,10 @@ Examples: dirmd -o ./docs ~/repos/myproject --proton-drive # Interactive TUI mode - dirmd --tui`, + dirmd --tui + + # 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, } @@ -67,6 +70,8 @@ Examples: omitMd bool tuiMode bool instructions bool + pickPaths []string + smallTree bool ) func Execute() { @@ -93,6 +98,8 @@ func init() { rootCmd.Flags().BoolVar(&omitTest, "omit-test", false, "exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust") rootCmd.Flags().BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md") rootCmd.Flags().BoolVar(&instructions, "instructions", false, "include .dirmd instructions file if present in root") + rootCmd.Flags().StringArrayVar(&pickPaths, "pick", nil, "repeatable: relative path to file to include as pick (requires --vertical-slices)") + rootCmd.Flags().BoolVar(&smallTree, "small-tree", false, "use breadcrumb-only tree for picked files (default: full tree)") } func run(cmd *cobra.Command, args []string) error { @@ -120,6 +127,10 @@ func run(cmd *cobra.Command, args []string) error { return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive") } + if len(pickPaths) > 0 && !verticalSlices { + return fmt.Errorf("--pick requires --vertical-slices") + } + excludedExts := append(config.DefaultExts, extensions...) if skipFrontend { excludedExts = append(excludedExts, config.FrontendExts...) @@ -141,6 +152,8 @@ func run(cmd *cobra.Command, args []string) error { OmitTest: omitTest, OmitMd: omitMd, IncludeInstructions: instructions, + PickPaths: pickPaths, + SmallTree: smallTree, } if singleFile != "" { diff --git a/internal/config/config.go b/internal/config/config.go index 5dae068..dda3337 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,7 +11,7 @@ import ( const ( DefaultMaxSize = 512 * 1024 DefaultMaxDepth = 20 - Version = "0.1.0" + Version = "0.1.4" ) var DefaultIgnores = []string{ @@ -65,6 +65,8 @@ type Config struct { OmitTest bool OmitMd bool IncludeInstructions bool + PickPaths []string + SmallTree bool } func PrintUsage() { @@ -94,6 +96,8 @@ func Parse() (*Config, error) { omitTest bool omitMd bool includeInstructions bool + pickPaths stringSlice + smallTree bool ) flag.StringVar(&input, "i", "", "single file input (append mode only)") @@ -113,6 +117,8 @@ func Parse() (*Config, error) { flag.BoolVar(&omitTest, "omit-test", false, "exclude test files and strip #[cfg(test)] blocks") flag.BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md") flag.BoolVar(&includeInstructions, "instructions", false, "include .dirmd instructions file if present in root") + flag.Var(&pickPaths, "pick", "repeatable: relative path to file to include as pick (requires --vertical-slices)") + flag.BoolVar(&smallTree, "small-tree", false, "use breadcrumb-only tree for picked files (default: full tree)") flag.Usage = PrintUsage flag.Parse() @@ -137,6 +143,9 @@ func Parse() (*Config, error) { if skipFrontend && frontendOnly { return nil, fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive") } + if len(pickPaths) > 0 && !verticalSlices { + return nil, fmt.Errorf("--pick requires --vertical-slices") + } excludedExts := append(DefaultExts, exts...) if skipFrontend { @@ -159,6 +168,8 @@ func Parse() (*Config, error) { OmitTest: omitTest, OmitMd: omitMd, IncludeInstructions: includeInstructions, + PickPaths: pickPaths, + SmallTree: smallTree, } if input != "" { diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index c423b32..27dfda2 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -10,6 +10,7 @@ import ( "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" @@ -150,7 +151,17 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e rootSlicePath := filepath.Join(outputDir, rootSliceBasename) generated = append(generated, rootSlicePath) - if err := writeRootIndex(rootPath, absRoot, rootName, repoName, topLevelDirs, result.Commit, result.Instructions, treeRoot, result.Cfg); err != nil { + pickInfos := make([]PickInfo, 0) + + if len(result.Cfg.PickPaths) > 0 { + pickInfos, err = writePickFiles(absRoot, absRoot, outputDir, result.Cfg, result.OmitTest, result.Commit, treeRoot, result.Cfg.SmallTree) + 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, result.Commit, result.Instructions, treeRoot, result.Cfg, pickInfos); err != nil { return nil, fmt.Errorf("failed to write root index: %w", err) } @@ -178,7 +189,137 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e return generated, nil } -func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[string][]walker.Entry, commit *git.CommitInfo, instContent string, treeRoot *tree.Node, cfg *config.Config) 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, baseRoot, outputDir string, cfg *config.Config, omitTest bool, commit *git.CommitInfo, treeRoot *tree.Node, smallTree bool) ([]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) + } + + // Validate file passes filters + name := filepath.Base(pickPath) + filter := &filter.Filter{ + MaxSize: cfg.MaxSize, + Extensions: cfg.Extensions, + Ignores: cfg.Ignores, + OmitTest: cfg.OmitTest, + FrontendOnly: cfg.FrontendOnly, + OmitMd: cfg.OmitMd, + } + + if filter.ShouldSkipFile(fullPath, name, info) { + return nil, fmt.Errorf("pick path %s filtered by active rules", pickPath) + } + + // Determine slice dir from first path segment + 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) + } + } else { + // Root-level pick + sliceDir = outputDir + } + + sliceName := filepath.Base(pickPath) + ".md" + pickPathOut := filepath.Join(sliceDir, sliceName) + + if err := writePickFile(pickPathOut, absRoot, pickPath, omitTest, commit, cfg, treeRoot, smallTree); 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, treeRoot *tree.Node, smallTree bool) error { + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("cannot create pick file: %w", err) + } + defer f.Close() + + var sb strings.Builder + + fmt.Fprintf(&sb, "# %s [SLICE]\n", filepath.Join(absRoot, relPath)) + fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339)) + sb.WriteString(git.FormatCommitLine(commit)) + sb.WriteString(FormatFlags(cfg)) + + 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) + + if smallTree { + breadcrumb := tree.RenderBreadcrumb(absRoot, relPath) + fmt.Fprintf(&sb, "Tree: %s\n\n", breadcrumb) + } else { + sb.WriteString("## Directory Tree\n\n") + sb.WriteString(fenceChars + "\n") + sb.WriteString(renderTreeWithLegend(treeRoot, filepath.Base(absRoot))) + sb.WriteString(fenceChars + "\n\n") + } + + fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", relPath, infoSize(fullPath), lines) + sb.WriteString(fenceChars) + if language != "" { + sb.WriteString(language) + } + sb.WriteString("\n") + sb.WriteString(content) + if !strings.HasSuffix(content, "\n") { + sb.WriteString("\n") + } + sb.WriteString(fmt.Sprintf("%s\n--- END FILE ---\n\n", fenceChars)) + + _, err = f.WriteString(sb.String()) + return err +} + +func infoSize(path string) int64 { + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.Size() +} + +func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[string][]walker.Entry, commit *git.CommitInfo, instContent string, treeRoot *tree.Node, cfg *config.Config, picks []PickInfo) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("cannot create root index: %w", err) @@ -207,11 +348,9 @@ func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[s sb.WriteString("| Slice File | Directory | Files | Total Bytes |\n") sb.WriteString("|------------|-----------|-------|-------------|\n") - // First row: root slice rootSliceFile := "root_" + repoName + ".md" - rootFileCount := 0 + rootFileCount := len(topLevelDirs) rootTotalBytes := int64(0) - // We'll calculate this in the caller, for now placeholder fmt.Fprintf(&sb, "| %s | (root) | %d | %d |\n", rootSliceFile, rootFileCount, rootTotalBytes) for _, dirName := range sortedKeys(topLevelDirs) { @@ -226,6 +365,15 @@ func writeRootIndex(path, absRoot, rootName, repoName string, topLevelDirs map[s } sb.WriteString("\n") + if len(picks) > 0 { + sb.WriteString("## Picks\n\n") + sb.WriteString("These files were explicitly selected via --pick flags.\n\n") + for _, p := range picks { + fmt.Fprintf(&sb, "- `%s` (%s)\n", p.RelPath, p.SliceDir) + } + sb.WriteString("\n") + } + _, err = f.WriteString(sb.String()) return err } diff --git a/internal/tree/unfiltered.go b/internal/tree/unfiltered.go index 255f4ce..1aeb02f 100644 --- a/internal/tree/unfiltered.go +++ b/internal/tree/unfiltered.go @@ -168,3 +168,22 @@ var frontendExts = []string{ ".ts", ".tsx", ".vue", ".svelte", } + +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() +}