Files
dirmd/cmd/cli/main.go
T
2026-08-02 20:42:44 +01:00

290 lines
6.3 KiB
Go

package main
import (
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
const (
defaultMaxSize = 512 * 1024 // 512KB
defaultMaxDepth = 20
version = "0.1.0"
)
var defaultIgnores = []string{
".git",
"node_modules",
"vendor",
"bin",
}
var defaultExts = []string{
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".ico",
".mp4", ".avi", ".mov", ".mkv", ".webm",
".mp3", ".wav", ".flac", ".ogg",
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
".exe", ".dll", ".so", ".dylib", ".wasm",
".pdf", ".doc", ".docx", ".xls", ".xlsx",
".pem", ".key", ".cert",
".db", ".sqlite", ".sqlite3",
}
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 {
inputPath string
outputPath string
appendMode bool
maxSize int64
maxDepth int
ignores []string
exts []string
absRoot string
}
func parseFlags() (*config, error) {
var (
input string
output string
appendMd bool
maxSize int64
maxDepth int
ignores stringSlice
exts stringSlice
showVer 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.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, e.g. .log)")
flag.BoolVar(&showVer, "version", false, "print version and exit")
flag.Usage = func() {
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()
}
flag.Parse()
if showVer {
fmt.Printf("dirmd v%s\n", version)
os.Exit(0)
}
// Validation
if output == "" {
return nil, fmt.Errorf("-o is required")
}
cfg := &config{
outputPath: output,
appendMode: appendMd,
maxSize: maxSize,
maxDepth: maxDepth,
ignores: append(defaultIgnores, ignores...),
exts: append(defaultExts, exts...),
}
if input != "" {
// Single file mode
cfg.inputPath = input
if !appendMd {
return nil, fmt.Errorf("single file input (-i) requires append mode (-a)")
}
} else {
// Directory mode
args := flag.Args()
if len(args) < 1 {
return nil, fmt.Errorf("directory argument is required when -i is not used")
}
cfg.inputPath = args[0]
}
// Resolve absolute path
abs, err := filepath.Abs(cfg.inputPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve absolute path: %w", err)
}
cfg.absRoot = abs
// Non-append mode: output must not exist
if !appendMd {
if _, err := os.Stat(output); err == nil {
return nil, fmt.Errorf("output file %s already exists (use -a to append)", output)
}
}
return cfg, nil
}
func isSymlink(path string) bool {
info, err := os.Lstat(path)
if err != nil {
return false
}
return info.Mode()&os.ModeSymlink != 0
}
func walkDirectory(cfg *config) error {
return filepath.WalkDir(cfg.absRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
return nil
}
rel, err := filepath.Rel(cfg.absRoot, path)
if err != nil {
return nil
}
// Calculate depth
depth := strings.Count(rel, string(filepath.Separator))
if rel == "." {
depth = 0
}
// Check max depth
if depth > cfg.maxDepth {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
// Skip symlinks
if isSymlink(path) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
// Skip dotfiles
base := filepath.Base(path)
if strings.HasPrefix(base, ".") && base != "." {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
// Check ignore patterns against full relative path
for _, pattern := range cfg.ignores {
matched, _ := filepath.Match(pattern, base)
if matched {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
}
if d.IsDir() {
fmt.Fprintf(os.Stderr, "[dir] %s (depth %d)\n", rel, depth)
return nil
}
// Check extension blacklist
ext := filepath.Ext(path)
for _, badExt := range cfg.exts {
if strings.EqualFold(ext, badExt) {
fmt.Fprintf(os.Stderr, "[skip] %s (blacklisted extension)\n", rel)
return nil
}
}
// Check file size
info, err := d.Info()
if err != nil {
fmt.Fprintf(os.Stderr, "warning: cannot stat %s: %v\n", rel, err)
return nil
}
if info.Size() > cfg.maxSize {
fmt.Fprintf(os.Stderr, "[skip] %s (size %d > %d)\n", rel, info.Size(), cfg.maxSize)
return nil
}
fmt.Fprintf(os.Stderr, "[file] %s (size %d, depth %d)\n", rel, info.Size(), depth)
return nil
})
}
func processSingleFile(cfg *config) error {
info, err := os.Stat(cfg.absRoot)
if err != nil {
return fmt.Errorf("cannot access %s: %w", cfg.absRoot, err)
}
if info.IsDir() {
// User passed a directory with -i, treat as directory walk
return walkDirectory(cfg)
}
if info.Size() > cfg.maxSize {
return fmt.Errorf("file %s exceeds max size (%d > %d)", cfg.absRoot, info.Size(), cfg.maxSize)
}
fmt.Fprintf(os.Stderr, "[file] %s (size %d)\n", cfg.absRoot, info.Size())
return nil
}
func main() {
cfg, err := parseFlags()
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n\n", err)
flag.Usage()
os.Exit(1)
}
if cfg.inputPath == "" {
fmt.Fprintln(os.Stderr, "error: no input specified")
flag.Usage()
os.Exit(1)
}
if cfg.appendMode && cfg.inputPath != "" {
// Could be single file or directory with -i in append mode
if err := processSingleFile(cfg); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
} else {
// Directory mode
info, err := os.Stat(cfg.absRoot)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if !info.IsDir() {
fmt.Fprintf(os.Stderr, "error: %s is not a directory\n", cfg.absRoot)
os.Exit(1)
}
if err := walkDirectory(cfg); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
fmt.Fprintf(os.Stderr, "done\n")
}