commit f6723f7b3e348eb19c6036ff7fe8e61bed589f0b Author: Bartal Laearsson Date: Sun Aug 2 20:42:44 2026 +0100 initial commit diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..287e8fb --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,289 @@ +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] \n") + fmt.Fprintf(os.Stderr, " dirmd -a -i -o \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") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..66737b3 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module dirmd + +go 1.26.4