finished, also added cobra cli

This commit is contained in:
Bartal Laearsson
2026-08-02 22:14:57 +01:00
parent f6723f7b3e
commit a9ebf41bf9
12 changed files with 754 additions and 289 deletions
+135
View File
@@ -0,0 +1,135 @@
package config
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
DefaultMaxSize = 512 * 1024
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
SingleFile bool
MaxSize int64
MaxDepth int
Ignores []string
Extensions []string
AbsRoot 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
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)")
flag.BoolVar(&showVer, "version", false, "print version and exit")
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")
}
cfg := &Config{
OutputPath: output,
AppendMode: appendMd,
MaxSize: maxSize,
MaxDepth: maxDepth,
Ignores: append(DefaultIgnores, ignores...),
Extensions: append(DefaultExts, exts...),
}
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 {
return nil, fmt.Errorf("output file %s already exists (use -a to append)", output)
}
}
return cfg, nil
}
+60
View File
@@ -0,0 +1,60 @@
package filter
import (
"os"
"path/filepath"
"strings"
)
type Filter struct {
MaxSize int64
Extensions []string
Ignores []string
}
func (f *Filter) ShouldSkipDir(name string) bool {
if strings.HasPrefix(name, ".") {
return true
}
for _, pattern := range f.Ignores {
matched, _ := filepath.Match(pattern, name)
if matched {
return true
}
}
return false
}
func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool {
if strings.HasPrefix(name, ".") {
return true
}
for _, pattern := range f.Ignores {
matched, _ := filepath.Match(pattern, name)
if matched {
return true
}
}
ext := filepath.Ext(path)
for _, bad := range f.Extensions {
if strings.EqualFold(ext, bad) {
return true
}
}
if info.Size() > f.MaxSize {
return true
}
return false
}
func IsSymlink(path string) bool {
info, err := os.Lstat(path)
if err != nil {
return false
}
return info.Mode()&os.ModeSymlink != 0
}
+133
View File
@@ -0,0 +1,133 @@
package renderer
import (
"fmt"
"os"
"path/filepath"
"strings"
"unicode/utf8"
"dirmd/internal/tree"
"dirmd/internal/walker"
)
const fenceChars = "~~~"
func Write(result *walker.Result, outputPath string, appendMode bool, singleFile bool) error {
flags := os.O_CREATE | os.O_WRONLY
if appendMode {
flags |= os.O_APPEND
} else {
flags |= os.O_TRUNC
}
f, err := os.OpenFile(outputPath, flags, 0644)
if err != nil {
return fmt.Errorf("cannot open output file: %w", err)
}
defer f.Close()
var sb strings.Builder
// Determine the correct H1 path
h1Path := result.AbsRoot
if singleFile && len(result.Entries) > 0 {
h1Path = filepath.Join(result.AbsRoot, result.Entries[0].RelPath)
}
fmt.Fprintf(&sb, "# %s\n\n", h1Path)
if !singleFile {
root := tree.Build(result.Entries)
rootName := filepath.Base(result.AbsRoot)
sb.WriteString("## Directory Tree\n\n")
sb.WriteString(fenceChars + "\n")
sb.WriteString(tree.Render(root, rootName))
sb.WriteString(fenceChars + "\n\n")
}
if len(result.Entries) > 0 {
if !singleFile {
sb.WriteString("## Contents\n\n")
}
for _, entry := range result.Entries {
fullPath := filepath.Join(result.AbsRoot, entry.RelPath)
content, err := readFile(fullPath)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
continue
}
language := extToLang(filepath.Ext(entry.RelPath))
fmt.Fprintf(&sb, "### ./%s\n\n", entry.RelPath)
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\n", fenceChars)
}
}
_, err = f.WriteString(sb.String())
return err
}
func readFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
for i, b := range data {
if b == 0 {
return "", fmt.Errorf("contains null byte at position %d", i)
}
}
if !utf8.Valid(data) {
return "", fmt.Errorf("invalid UTF-8 encoding")
}
content := string(data)
content = strings.ReplaceAll(content, "\r\n", "\n")
content = strings.ReplaceAll(content, "\r", "\n")
return content, nil
}
func extToLang(ext string) string {
mapping := map[string]string{
".go": "go",
".md": "markdown",
".yml": "yaml",
".yaml": "yaml",
".js": "javascript",
".ts": "typescript",
".css": "css",
".html": "html",
".json": "json",
".xml": "xml",
".sql": "sql",
".sh": "bash",
".py": "python",
".rs": "rust",
".c": "c",
".h": "c",
".cpp": "cpp",
".hpp": "cpp",
".txt": "text",
}
if lang, ok := mapping[strings.ToLower(ext)]; ok {
return lang
}
return ""
}
+76
View File
@@ -0,0 +1,76 @@
package tree
import (
"sort"
"strings"
"dirmd/internal/walker"
)
type Node struct {
Name string
IsDir bool
Children map[string]*Node
}
func Build(entries []walker.Entry) *Node {
root := &Node{IsDir: true, Children: make(map[string]*Node)}
for _, e := range entries {
parts := strings.Split(e.RelPath, "/")
cur := root
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]
}
}
return root
}
func Render(root *Node, rootName string) string {
var sb strings.Builder
sb.WriteString(rootName)
sb.WriteString("/\n")
renderNode(&sb, root, "")
return sb.String()
}
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 + "├── ")
}
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)
}
}
}
+123
View File
@@ -0,0 +1,123 @@
package walker
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"dirmd/internal/config"
"dirmd/internal/filter"
)
type Entry struct {
RelPath string
Size int64
}
type Result struct {
Entries []Entry
AbsRoot string
SingleFile bool
}
func Run(cfg *config.Config) (*Result, error) {
if cfg.SingleFile {
return processSingleFile(cfg)
}
return walkDirectory(cfg)
}
func processSingleFile(cfg *config.Config) (*Result, error) {
info, err := os.Stat(cfg.AbsRoot)
if err != nil {
return nil, fmt.Errorf("cannot access %s: %w", cfg.AbsRoot, err)
}
if info.IsDir() {
return walkDirectory(cfg)
}
flt := &filter.Filter{
MaxSize: cfg.MaxSize,
Extensions: cfg.Extensions,
Ignores: cfg.Ignores,
}
if flt.ShouldSkipFile(cfg.AbsRoot, filepath.Base(cfg.AbsRoot), info) {
return nil, fmt.Errorf("file %s filtered out", cfg.AbsRoot)
}
return &Result{
Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}},
AbsRoot: filepath.Dir(cfg.AbsRoot),
SingleFile: true,
}, nil
}
func walkDirectory(cfg *config.Config) (*Result, error) {
flt := &filter.Filter{
MaxSize: cfg.MaxSize,
Extensions: cfg.Extensions,
Ignores: cfg.Ignores,
}
result := &Result{AbsRoot: cfg.AbsRoot}
err := 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
}
depth := strings.Count(rel, string(filepath.Separator))
if rel != "." {
depth++
}
if depth > cfg.MaxDepth {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if filter.IsSymlink(path) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
base := filepath.Base(path)
if d.IsDir() {
if rel != "." && flt.ShouldSkipDir(base) {
return filepath.SkipDir
}
return nil
}
info, err := d.Info()
if err != nil {
fmt.Fprintf(os.Stderr, "warning: cannot stat %s: %v\n", rel, err)
return nil
}
if flt.ShouldSkipFile(path, base, info) {
return nil
}
result.Entries = append(result.Entries, Entry{RelPath: rel, Size: info.Size()})
return nil
})
return result, err
}