finished, also added cobra cli
This commit is contained in:
-289
@@ -1,289 +0,0 @@
|
||||
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")
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"dirmd/internal/config"
|
||||
"dirmd/internal/renderer"
|
||||
"dirmd/internal/walker"
|
||||
)
|
||||
|
||||
var (
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "dirmd [flags] <directory>",
|
||||
Short: "Generate markdown documentation from directory structure",
|
||||
Long: `dirmd walks a directory and generates a markdown file with:
|
||||
|
||||
- Directory tree structure
|
||||
- All readable text files with their contents
|
||||
|
||||
Examples:
|
||||
|
||||
# Create new documentation
|
||||
dirmd -o output.md ~/repos/myproject
|
||||
|
||||
# Append a single file
|
||||
dirmd -a -i README.md -o existing.md
|
||||
|
||||
# Append another directory
|
||||
dirmd -a -o existing.md ~/repos/anotherproject`,
|
||||
RunE: run,
|
||||
}
|
||||
|
||||
outputPath string
|
||||
appendMode bool
|
||||
singleFile string
|
||||
maxSize int64
|
||||
maxDepth int
|
||||
ignorePatterns []string
|
||||
extensions []string
|
||||
)
|
||||
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output markdown file path (required)")
|
||||
rootCmd.Flags().BoolVarP(&appendMode, "append", "a", false, "append to existing output file")
|
||||
rootCmd.Flags().StringVarP(&singleFile, "input-file", "i", "", "single file input (append mode only)")
|
||||
rootCmd.Flags().Int64Var(&maxSize, "max-size", config.DefaultMaxSize, "max file size in bytes")
|
||||
rootCmd.Flags().IntVar(&maxDepth, "max-depth", config.DefaultMaxDepth, "max directory recursion depth")
|
||||
rootCmd.Flags().StringArrayVar(&ignorePatterns, "ignore", nil, "additional ignore patterns (glob)")
|
||||
rootCmd.Flags().StringArrayVar(&extensions, "extensions", nil, "additional file extensions to skip (repeatable)")
|
||||
|
||||
rootCmd.MarkPersistentFlagRequired("output")
|
||||
}
|
||||
|
||||
func run(cmd *cobra.Command, args []string) error {
|
||||
cfg := &config.Config{
|
||||
OutputPath: outputPath,
|
||||
AppendMode: appendMode,
|
||||
MaxSize: maxSize,
|
||||
MaxDepth: maxDepth,
|
||||
Ignores: append(config.DefaultIgnores, ignorePatterns...),
|
||||
Extensions: append(config.DefaultExts, extensions...),
|
||||
}
|
||||
|
||||
if singleFile != "" {
|
||||
cfg.InputPath = singleFile
|
||||
cfg.SingleFile = true
|
||||
if !appendMode {
|
||||
return fmt.Errorf("single file input (-i) requires append mode (-a)")
|
||||
}
|
||||
} else {
|
||||
if len(args) < 1 {
|
||||
return 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 fmt.Errorf("failed to resolve absolute path: %w", err)
|
||||
}
|
||||
cfg.AbsRoot = abs
|
||||
|
||||
// Non-append mode: output must not exist
|
||||
if !appendMode {
|
||||
if _, err := os.Stat(outputPath); err == nil {
|
||||
return fmt.Errorf("output file %s already exists (use -a to append)", outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := walker.Run(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(result.Entries) == 0 {
|
||||
return fmt.Errorf("no readable text files found in %s", cfg.AbsRoot)
|
||||
}
|
||||
|
||||
if err := renderer.Write(result, outputPath, appendMode, cfg.SingleFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "wrote %d entries to %s\n", len(result.Entries), outputPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user