package renderer import ( "fmt" "os" "path/filepath" "sort" "strings" "time" "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" "git.flo.fo/FLO/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 h1Path := result.AbsRoot if singleFile && len(result.Entries) > 0 { h1Path = filepath.Join(result.AbsRoot, result.Entries[0].RelPath) } fmt.Fprintf(&sb, "# %s [ROOT]\n", h1Path) if !appendMode { fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339)) sb.WriteString(git.FormatCommitLine(result.Commit)) if result.Instructions != "" { sb.WriteString(instructions.Format(result.Instructions)) } else { sb.WriteString("\n") } sb.WriteString(FormatFlags(result.Cfg)) } if !singleFile { treeRoot, err := tree.BuildFullTree(result.AbsRoot, result.Cfg) if err != nil { return fmt.Errorf("cannot build tree: %w", err) } rootName := filepath.Base(result.AbsRoot) sb.WriteString("## Directory Tree\n\n") sb.WriteString(fenceChars + "\n") sb.WriteString(renderTreeWithLegend(treeRoot, 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 := readFileForOutput(fullPath, result.OmitTest) if err != nil { fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err) continue } language := extToLang(filepath.Ext(entry.RelPath)) lines := countLines(content) fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines) 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--- END FILE ---\n\n", fenceChars) } } _, err = f.WriteString(sb.String()) return err } func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, error) { entries := result.Entries if len(entries) == 0 { return nil, fmt.Errorf("no entries to write") } absRoot := result.AbsRoot rootName := filepath.Base(absRoot) topLevelDirs := make(map[string][]walker.Entry) rootFiles := make([]walker.Entry, 0) for _, e := range entries { rel := e.RelPath parts := strings.Split(rel, string(filepath.Separator)) if len(parts) == 1 { rootFiles = append(rootFiles, e) } else { firstPart := parts[0] topLevelDirs[firstPart] = append(topLevelDirs[firstPart], e) } } treeRoot, err := tree.BuildFullTree(absRoot, result.Cfg) if err != nil { return nil, fmt.Errorf("cannot build full tree: %w", err) } var dirNames []string for k := range topLevelDirs { dirNames = append(dirNames, k) } sort.Strings(dirNames) for _, dirName := range dirNames { if node, ok := treeRoot.Children[dirName]; ok { node.Ref = filepath.Join(dirName, dirName+"_"+repoName+".md") } } var generated []string indexBasename := "index_" + repoName + ".md" rootPath := filepath.Join(outputDir, indexBasename) generated = append(generated, rootPath) rootSliceBasename := "root_" + repoName + ".md" rootSlicePath := filepath.Join(outputDir, rootSliceBasename) generated = append(generated, rootSlicePath) pickInfos := make([]PickInfo, 0) if len(result.Cfg.PickPaths) > 0 { pickInfos, err = writePickFiles(absRoot, outputDir, result.Cfg, result.OmitTest, result.Commit) 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) } if err := writeRootSliceFile(rootSlicePath, absRoot, rootFiles, result.OmitTest, result.Commit, result.Cfg); err != nil { return nil, fmt.Errorf("failed to write root slice: %w", err) } for _, dirName := range dirNames { sliceName := strings.TrimSuffix(dirName, "/") + "_" + repoName + ".md" sliceDir := filepath.Join(outputDir, dirName) if err := os.MkdirAll(sliceDir, 0755); err != nil { return nil, fmt.Errorf("cannot create slice directory %s: %w", sliceDir, err) } slicePath := filepath.Join(sliceDir, sliceName) generated = append(generated, slicePath) absDir := filepath.Join(absRoot, dirName) dirEntries := topLevelDirs[dirName] if err := writeDirSlice(slicePath, absDir, absRoot, dirName, dirEntries, indexBasename, result.OmitTest, result.Commit, result.Cfg); err != nil { return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err) } } return generated, nil } 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, outputDir string, cfg *config.Config, omitTest bool, commit *git.CommitInfo) ([]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) } name := filepath.Base(pickPath) flt := &filter.Filter{ MaxSize: cfg.MaxSize, Extensions: cfg.Extensions, Ignores: cfg.Ignores, OmitTest: cfg.OmitTest, FrontendOnly: cfg.FrontendOnly, OmitMd: cfg.OmitMd, } if flt.ShouldSkipFile(fullPath, name, info) { return nil, fmt.Errorf("pick path %s filtered by active rules", pickPath) } 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) } } sliceName := filepath.Base(pickPath) + ".md" pickPathOut := filepath.Join(sliceDir, sliceName) if err := writePickFile(pickPathOut, absRoot, pickPath, omitTest, commit, cfg); 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) 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)) fmt.Fprintf(&sb, "Root document: index_%s.md\n\n", filepath.Base(absRoot)) 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) fmt.Fprintf(&sb, "Tree: %s\n\n", tree.RenderBreadcrumb(absRoot, relPath)) 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") } fmt.Fprintf(&sb, "%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) } defer f.Close() var sb strings.Builder fmt.Fprintf(&sb, "# %s [ROOT]\n", absRoot) fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339)) sb.WriteString(git.FormatCommitLine(commit)) if instContent != "" { sb.WriteString(instructions.Format(instContent)) } else { sb.WriteString("\n") } sb.WriteString(FormatFlags(cfg)) sb.WriteString("## Directory Tree\n\n") sb.WriteString(fenceChars + "\n") sb.WriteString(renderTreeWithLegend(treeRoot, rootName)) sb.WriteString(fenceChars + "\n\n") sb.WriteString("## Vertical Slices Index\n\n") sb.WriteString("These files contain the full contents of each top-level directory.\n") sb.WriteString("Read the relevant slice file for detailed source code.\n\n") sb.WriteString("| Slice File | Directory | Files | Total Bytes |\n") sb.WriteString("|------------|-----------|-------|-------------|\n") rootSliceFile := "root_" + repoName + ".md" rootFileCount := len(topLevelDirs) rootTotalBytes := int64(0) fmt.Fprintf(&sb, "| %s | (root) | %d | %d |\n", rootSliceFile, rootFileCount, rootTotalBytes) for _, dirName := range sortedKeys(topLevelDirs) { entries := topLevelDirs[dirName] fileCount := len(entries) totalBytes := int64(0) for _, e := range entries { totalBytes += e.Size } sliceFile := filepath.Join(dirName, strings.TrimSuffix(dirName, "/")+"_"+repoName+".md") fmt.Fprintf(&sb, "| %s | %s/ | %d | %d |\n", sliceFile, dirName, fileCount, totalBytes) } 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 } func writeRootSliceFile(path, absRoot string, rootFiles []walker.Entry, omitTest bool, commit *git.CommitInfo, cfg *config.Config) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("cannot create root slice file: %w", err) } defer f.Close() var sb strings.Builder fmt.Fprintf(&sb, "# %s [ROOT]\n", absRoot) fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339)) sb.WriteString(git.FormatCommitLine(commit)) sb.WriteString(FormatFlags(cfg)) rootName := filepath.Base(absRoot) fmt.Fprintf(&sb, "Tree: %s /\n\n", rootName) if len(rootFiles) == 0 { sb.WriteString("(none)\n") _, err = f.WriteString(sb.String()) return err } sort.Slice(rootFiles, func(i, j int) bool { return rootFiles[i].RelPath < rootFiles[j].RelPath }) for _, entry := range rootFiles { fullPath := filepath.Join(absRoot, entry.RelPath) content, err := readFileForOutput(fullPath, omitTest) if err != nil { fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err) continue } language := extToLang(filepath.Ext(entry.RelPath)) lines := countLines(content) fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines) fmt.Fprintf(&sb, "%s", fenceChars) if language != "" { fmt.Fprintf(&sb, "%s", language) } fmt.Fprintf(&sb, "\n%s", content) if !strings.HasSuffix(content, "\n") { fmt.Fprintf(&sb, "\n") } fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars) } _, err = f.WriteString(sb.String()) return err } func writeDirSlice(path, absDir, absRoot, dirName string, fileEntries []walker.Entry, rootFileName string, omitTest bool, commit *git.CommitInfo, cfg *config.Config) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("cannot create slice file: %w", err) } defer f.Close() var sb strings.Builder fmt.Fprintf(&sb, "# %s [SLICE]\n", absDir) fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339)) sb.WriteString(git.FormatCommitLine(commit)) sb.WriteString(FormatFlags(cfg)) fmt.Fprintf(&sb, "Root document: %s\n\n", rootFileName) fmt.Fprintf(&sb, "Tree: %s / %s\n\n", filepath.Base(absRoot), dirName) if len(fileEntries) > 0 { sb.WriteString("## Contents\n\n") sort.Slice(fileEntries, func(i, j int) bool { return fileEntries[i].RelPath < fileEntries[j].RelPath }) for _, entry := range fileEntries { fullPath := filepath.Join(absRoot, entry.RelPath) content, err := readFileForOutput(fullPath, omitTest) if err != nil { fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err) continue } language := extToLang(filepath.Ext(entry.RelPath)) lines := countLines(content) fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines) fmt.Fprintf(&sb, "%s", fenceChars) if language != "" { fmt.Fprintf(&sb, "%s", language) } fmt.Fprintf(&sb, "\n%s", content) if !strings.HasSuffix(content, "\n") { fmt.Fprintf(&sb, "\n") } fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars) } } _, err = f.WriteString(sb.String()) return err } func sortedKeys(m map[string][]walker.Entry) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys } func renderTreeWithLegend(root *tree.Node, rootName string) string { var sb strings.Builder sb.WriteString(rootName) sb.WriteString("/\n") renderNodeLLM(&sb, root, "") if hasAnyAnnotation(root) { sb.WriteString("Legend: ~ ignored | ~ tests omitted | ~ omitted | ~ exceeded --max-depth\n") } return sb.String() } func renderNodeLLM(sb *strings.Builder, node *tree.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 sb.WriteString(prefix) sb.WriteString("+- ") sb.WriteString(child.Name) if child.IsDir { sb.WriteString("/") } if child.Annotation != "" { sb.WriteString(" ~ ") sb.WriteString(child.Annotation) } if child.Ref != "" { sb.WriteString(" -> see ") sb.WriteString(child.Ref) } sb.WriteString("\n") if child.IsDir && child.Children != nil { newPrefix := prefix if isLast { newPrefix += " " } else { newPrefix += "| " } renderNodeLLM(sb, child, newPrefix) } } } func hasAnyAnnotation(node *tree.Node) bool { if node.Annotation != "" { return true } for _, child := range node.Children { if hasAnyAnnotation(child) { return true } } return false } 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 readFileForOutput(fullPath string, omitTest bool) (string, error) { content, err := readFile(fullPath) if err != nil { return "", err } if omitTest && strings.EqualFold(filepath.Ext(fullPath), ".rs") { content = stripTestBlocks(content) } return content, nil } func stripTestBlocks(content string) string { lines := strings.Split(content, "\n") var result []string i := 0 for i < len(lines) { line := lines[i] trimmed := strings.TrimSpace(line) if isCfgTestAttr(trimmed) { braceDepth := 0 foundBrace := false for i < len(lines) { line := lines[i] if !foundBrace { if strings.Contains(line, "{") { braceDepth += strings.Count(line, "{") - strings.Count(line, "}") foundBrace = true } i++ continue } braceDepth += strings.Count(line, "{") - strings.Count(line, "}") i++ if braceDepth <= 0 { break } } continue } result = append(result, line) i++ } if len(result) == 0 { return "" } return strings.Join(result, "\n") } func isCfgTestAttr(line string) bool { return strings.Contains(line, "#[cfg(test)]") || strings.Contains(line, "#[cfg(all(test") || strings.Contains(line, "#[cfg(any(test") } 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 "" } func countLines(content string) int { if content == "" { return 0 } count := strings.Count(content, "\n") if len(content) > 0 && content[len(content)-1] != '\n' { count++ } return count }