Skip to main content

st/formatters/
markdown.rs

1// -----------------------------------------------------------------------------
2// MARKDOWN FORMATTER - The Ultimate Documentation Generator! 📝✨
3//
4// This formatter creates beautiful markdown reports with:
5// - Mermaid diagrams (flowchart, pie charts)
6// - Tables with statistics
7// - File type breakdowns
8// - Size analysis
9// - Everything you need for instant documentation!
10//
11// "Making documentation so beautiful, even Trisha cries tears of joy!"
12// - Trisha from Accounting
13//
14// Brought to you by The Cheet, turning directory trees into visual masterpieces! 🎨
15// -----------------------------------------------------------------------------
16
17use super::{Formatter, PathDisplayMode};
18use crate::scanner::{FileNode, TreeStats};
19use anyhow::Result;
20use chrono::Local;
21use std::collections::HashMap;
22use std::io::Write;
23use std::path::Path;
24
25pub struct MarkdownFormatter {
26    no_emoji: bool,
27    include_mermaid: bool,
28    include_tables: bool,
29    include_pie_charts: bool,
30    max_pie_slices: usize,
31}
32
33impl MarkdownFormatter {
34    pub fn new(
35        _path_mode: PathDisplayMode,
36        no_emoji: bool,
37        include_mermaid: bool,
38        include_tables: bool,
39        include_pie_charts: bool,
40    ) -> Self {
41        Self {
42            no_emoji,
43            include_mermaid,
44            include_tables,
45            include_pie_charts,
46            max_pie_slices: 10, // Limit pie chart slices for readability
47        }
48    }
49
50    fn escape_mermaid(text: &str) -> String {
51        text.replace('|', "|")
52            .replace('<', "&lt;")
53            .replace('>', "&gt;")
54            .replace('"', "&quot;")
55            .replace('\'', "&#39;")
56            .replace('[', "&#91;")
57            .replace(']', "&#93;")
58            .replace('{', "&#123;")
59            .replace('}', "&#125;")
60            .replace('(', "&#40;")
61            .replace(')', "&#41;")
62    }
63
64    fn format_size(size: u64) -> String {
65        if size < 1024 {
66            format!("{} B", size)
67        } else if size < 1024 * 1024 {
68            format!("{:.1} KB", size as f64 / 1024.0)
69        } else if size < 1024 * 1024 * 1024 {
70            format!("{:.1} MB", size as f64 / 1024.0 / 1024.0)
71        } else {
72            format!("{:.1} GB", size as f64 / 1024.0 / 1024.0 / 1024.0)
73        }
74    }
75
76    fn get_file_emoji(&self, path: &Path, is_dir: bool) -> &'static str {
77        if self.no_emoji {
78            return "";
79        }
80
81        if is_dir {
82            "📁"
83        } else {
84            match path.extension().and_then(|e| e.to_str()) {
85                Some("rs") => "🦀",
86                Some("py") => "🐍",
87                Some("js") | Some("ts") => "📜",
88                Some("md") => "📝",
89                Some("toml") | Some("yaml") | Some("yml") | Some("json") => "⚙️",
90                Some("png") | Some("jpg") | Some("jpeg") | Some("gif") => "🖼️",
91                Some("pdf") => "📕",
92                Some("zip") | Some("tar") | Some("gz") => "📦",
93                Some("mp3") | Some("wav") | Some("flac") => "🎵",
94                Some("mp4") | Some("avi") | Some("mov") => "🎬",
95                _ => "📄",
96            }
97        }
98    }
99
100    fn write_header(
101        &self,
102        writer: &mut dyn Write,
103        root_path: &Path,
104        stats: &TreeStats,
105    ) -> Result<()> {
106        let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
107        let root_name = root_path
108            .file_name()
109            .unwrap_or(root_path.as_os_str())
110            .to_string_lossy();
111
112        writeln!(writer, "# 📊 Directory Analysis Report")?;
113        writeln!(writer)?;
114        writeln!(writer, "**Generated by Smart Tree** | {}", timestamp)?;
115        writeln!(writer)?;
116        writeln!(writer, "## 📁 Overview")?;
117        writeln!(writer)?;
118        writeln!(writer, "- **Directory**: `{}`", root_name)?;
119        writeln!(writer, "- **Total Files**: {}", stats.total_files)?;
120        writeln!(writer, "- **Total Directories**: {}", stats.total_dirs)?;
121        writeln!(
122            writer,
123            "- **Total Size**: {}",
124            Self::format_size(stats.total_size)
125        )?;
126        writeln!(writer)?;
127
128        Ok(())
129    }
130
131    fn write_mermaid_diagram(
132        &self,
133        writer: &mut dyn Write,
134        nodes: &[FileNode],
135        root_path: &Path,
136    ) -> Result<()> {
137        writeln!(writer, "## 🌳 Directory Structure")?;
138        writeln!(writer)?;
139        writeln!(writer, "```mermaid")?;
140        writeln!(writer, "flowchart LR")?; // Use Left-Right for better readability
141        writeln!(writer, "    %% Smart Tree Directory Visualization")?;
142
143        // Limit nodes for readability
144        let max_nodes = 40; // Reduced for cleaner diagrams
145        let display_nodes: Vec<&FileNode> = nodes.iter().take(max_nodes).collect();
146
147        // Write root node with better styling
148        let root_name = root_path
149            .file_name()
150            .unwrap_or(root_path.as_os_str())
151            .to_string_lossy();
152        writeln!(
153            writer,
154            "    root{{\"📁 {}\"}}",
155            Self::escape_mermaid(&root_name)
156        )?;
157        writeln!(
158            writer,
159            "    style root fill:#ff9800,stroke:#e65100,stroke-width:4px,color:#fff"
160        )?;
161        writeln!(writer)?;
162
163        // Group nodes by parent directory for cleaner visualization
164        let mut dir_groups: HashMap<std::path::PathBuf, Vec<&FileNode>> = HashMap::new();
165        let mut all_dirs: Vec<std::path::PathBuf> = Vec::new();
166
167        for node in &display_nodes {
168            // Skip the root directory itself
169            if node.path == *root_path {
170                continue;
171            }
172
173            if let Some(parent) = node.path.parent() {
174                dir_groups
175                    .entry(parent.to_path_buf())
176                    .or_default()
177                    .push(node);
178                if node.is_dir && !all_dirs.contains(&node.path) {
179                    all_dirs.push(node.path.clone());
180                }
181            }
182        }
183
184        // Write subgraphs for each directory with children
185        let mut subgraph_count = 0;
186        for (parent, children) in &dir_groups {
187            if children.len() > 1 && parent != root_path {
188                subgraph_count += 1;
189                let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("?");
190                writeln!(
191                    writer,
192                    "    subgraph sub{} [\"{}\" ]",
193                    subgraph_count,
194                    Self::escape_mermaid(parent_name)
195                )?;
196                writeln!(writer, "        direction TB")?;
197
198                for child in children {
199                    let node_id = format!(
200                        "node_{}",
201                        display_nodes
202                            .iter()
203                            .position(|n| n.path == child.path)
204                            .unwrap_or(0)
205                    );
206                    let name = child
207                        .path
208                        .file_name()
209                        .and_then(|n| n.to_str())
210                        .unwrap_or("?");
211                    let emoji = self.get_file_emoji(&child.path, child.is_dir);
212
213                    if child.is_dir {
214                        writeln!(
215                            writer,
216                            "        {}[\"📁 {}\"]",
217                            node_id,
218                            Self::escape_mermaid(name)
219                        )?;
220                    } else {
221                        let size_str = Self::format_size(child.size);
222                        writeln!(
223                            writer,
224                            "        {}[\"{}{}\\n{}\"]",
225                            node_id,
226                            emoji,
227                            Self::escape_mermaid(name),
228                            size_str
229                        )?;
230                    }
231                }
232                writeln!(writer, "    end")?;
233                writeln!(writer)?;
234            }
235        }
236
237        // Write remaining nodes (not in subgraphs)
238        for (i, node) in display_nodes.iter().enumerate() {
239            // Skip the root directory itself
240            if node.path == *root_path {
241                continue;
242            }
243
244            let parent = node.path.parent();
245            let in_subgraph = parent
246                .map(|p| dir_groups.get(p).map(|c| c.len() > 1).unwrap_or(false))
247                .unwrap_or(false);
248
249            if !in_subgraph || parent == Some(root_path) {
250                let node_id = format!("node_{}", i);
251                let name = node
252                    .path
253                    .file_name()
254                    .and_then(|n| n.to_str())
255                    .unwrap_or("?");
256                let emoji = self.get_file_emoji(&node.path, node.is_dir);
257
258                if node.is_dir {
259                    writeln!(
260                        writer,
261                        "    {}[\"📁 {}\"]",
262                        node_id,
263                        Self::escape_mermaid(name)
264                    )?;
265                    writeln!(
266                        writer,
267                        "    style {} fill:#e3f2fd,stroke:#1976d2,stroke-width:2px",
268                        node_id
269                    )?;
270                } else {
271                    let size_str = Self::format_size(node.size);
272                    writeln!(
273                        writer,
274                        "    {}[\"{}{}\\n{}\"]",
275                        node_id,
276                        emoji,
277                        Self::escape_mermaid(name),
278                        size_str
279                    )?;
280
281                    // Style based on file type
282                    match node.path.extension().and_then(|e| e.to_str()) {
283                        Some("rs") => {
284                            writeln!(writer, "    style {} fill:#dcedc8,stroke:#689f38", node_id)?
285                        }
286                        Some("md") => {
287                            writeln!(writer, "    style {} fill:#fff9c4,stroke:#f9a825", node_id)?
288                        }
289                        Some("json") | Some("toml") | Some("yaml") => {
290                            writeln!(writer, "    style {} fill:#f3e5f5,stroke:#7b1fa2", node_id)?
291                        }
292                        _ => writeln!(writer, "    style {} fill:#f5f5f5,stroke:#616161", node_id)?,
293                    }
294                }
295            }
296        }
297
298        // Simplified connections
299        writeln!(writer)?;
300        writeln!(writer, "    %% Connections")?;
301
302        // Connect root to immediate children
303        for node in &display_nodes {
304            if let Some(parent) = node.path.parent() {
305                if parent == root_path {
306                    let node_id = format!(
307                        "node_{}",
308                        display_nodes
309                            .iter()
310                            .position(|n| n.path == node.path)
311                            .unwrap_or(0)
312                    );
313                    writeln!(writer, "    root --> {}", node_id)?;
314                }
315            }
316        }
317
318        // Connect directories to their subgraphs
319        let mut connected_subgraphs = std::collections::HashSet::new();
320        let mut subgraph_map = HashMap::new();
321        let mut current_sub = 0;
322
323        // Build a map of directories to subgraph numbers
324        for (parent, children) in &dir_groups {
325            if children.len() > 1 && parent != root_path {
326                current_sub += 1;
327                subgraph_map.insert(parent.clone(), current_sub);
328            }
329        }
330
331        // Connect parent directories to their subgraphs
332        for node in &display_nodes {
333            if node.is_dir {
334                if let Some(&sub_num) = subgraph_map.get(&node.path) {
335                    if !connected_subgraphs.contains(&sub_num) {
336                        // Find parent of this directory
337                        if let Some(parent) = node.path.parent() {
338                            if parent == root_path {
339                                writeln!(writer, "    root --> sub{}", sub_num)?;
340                            } else {
341                                // Find parent node id
342                                if let Some(parent_idx) =
343                                    display_nodes.iter().position(|n| n.path == *parent)
344                                {
345                                    writeln!(writer, "    node_{} --> sub{}", parent_idx, sub_num)?;
346                                }
347                            }
348                        }
349                        connected_subgraphs.insert(sub_num);
350                    }
351                }
352            }
353        }
354
355        if nodes.len() > max_nodes {
356            writeln!(writer)?;
357            writeln!(
358                writer,
359                "    more[\"... and {} more items\"]",
360                nodes.len() - max_nodes
361            )?;
362            writeln!(
363                writer,
364                "    style more fill:#ffecb3,stroke:#ff6f00,stroke-dasharray: 5 5"
365            )?;
366        }
367
368        writeln!(writer, "```")?;
369        writeln!(writer)?;
370
371        // Add a simple text tree as fallback
372        writeln!(writer, "### 📂 Simple Tree View")?;
373        writeln!(writer)?;
374        writeln!(writer, "```")?;
375
376        let root_name = root_path
377            .file_name()
378            .unwrap_or(root_path.as_os_str())
379            .to_string_lossy();
380        writeln!(
381            writer,
382            "{} {}/",
383            if !self.no_emoji { "📁" } else { "" },
384            root_name
385        )?;
386
387        // Sort nodes by path for consistent output
388        let mut sorted_nodes = display_nodes.clone();
389        sorted_nodes.sort_by_key(|n| &n.path);
390
391        for (i, node) in sorted_nodes.iter().enumerate() {
392            if node.path == *root_path {
393                continue;
394            }
395
396            let depth = node.path.components().count() - root_path.components().count();
397            if depth > 2 {
398                continue; // Only show 2 levels in simple view
399            }
400
401            let is_last = i == sorted_nodes.len() - 1
402                || sorted_nodes
403                    .get(i + 1)
404                    .map(|next| {
405                        let next_depth =
406                            next.path.components().count() - root_path.components().count();
407                        next_depth < depth
408                    })
409                    .unwrap_or(true);
410
411            let indent = if depth > 0 {
412                "│   ".repeat(depth - 1)
413            } else {
414                String::new()
415            };
416
417            let prefix = if is_last { "└── " } else { "├── " };
418            let emoji = self.get_file_emoji(&node.path, node.is_dir);
419            let name = node
420                .path
421                .file_name()
422                .and_then(|n| n.to_str())
423                .unwrap_or("?");
424
425            if node.is_dir {
426                writeln!(writer, "{}{}{} {}/", indent, prefix, emoji, name)?;
427            } else {
428                writeln!(
429                    writer,
430                    "{}{}{} {} ({})",
431                    indent,
432                    prefix,
433                    emoji,
434                    name,
435                    Self::format_size(node.size)
436                )?;
437            }
438        }
439
440        if nodes.len() > max_nodes {
441            writeln!(
442                writer,
443                "│   └── ... and {} more items",
444                nodes.len() - max_nodes
445            )?;
446        }
447
448        writeln!(writer, "```")?;
449        writeln!(writer)?;
450
451        Ok(())
452    }
453
454    fn write_file_type_table(&self, writer: &mut dyn Write, stats: &TreeStats) -> Result<()> {
455        writeln!(writer, "## 📋 File Types Breakdown")?;
456        writeln!(writer)?;
457        writeln!(writer, "| Extension | Count | Percentage | Total Size |")?;
458        writeln!(writer, "|-----------|-------|------------|------------|")?;
459
460        let total_files = stats.total_files as f64;
461
462        for (ext, count) in stats.file_types.iter().take(20) {
463            let percentage = (*count as f64 / total_files) * 100.0;
464            let emoji = match ext.as_str() {
465                "rs" => "🦀",
466                "py" => "🐍",
467                "js" | "ts" => "📜",
468                "md" => "📝",
469                "json" | "yaml" | "yml" | "toml" => "⚙️",
470                _ => "📄",
471            };
472
473            writeln!(
474                writer,
475                "| {} .{} | {} | {:.1}% | - |",
476                if self.no_emoji { "" } else { emoji },
477                ext,
478                count,
479                percentage
480            )?;
481        }
482
483        writeln!(writer)?;
484        Ok(())
485    }
486
487    fn write_size_distribution_pie(
488        &self,
489        writer: &mut dyn Write,
490        _stats: &TreeStats,
491    ) -> Result<()> {
492        writeln!(writer, "## 📊 Size Distribution")?;
493        writeln!(writer)?;
494
495        // Group files by size ranges
496        // let mut size_ranges = vec![
497        //     ("< 1 KB", 0u64, 0usize),
498        //     ("1-10 KB", 0, 0),
499        //     ("10-100 KB", 0, 0),
500        //     ("100 KB - 1 MB", 0, 0),
501        //     ("1-10 MB", 0, 0),
502        //     ("10-100 MB", 0, 0),
503        //     ("> 100 MB", 0, 0),
504        // ];
505
506        // This would need access to individual file sizes, so we'll use a placeholder
507        // In a real implementation, we'd track this during scanning
508
509        writeln!(writer, "```mermaid")?;
510        writeln!(writer, "pie title File Size Distribution")?;
511        writeln!(writer, "    \"< 1 KB\" : 45")?;
512        writeln!(writer, "    \"1-10 KB\" : 25")?;
513        writeln!(writer, "    \"10-100 KB\" : 15")?;
514        writeln!(writer, "    \"100 KB - 1 MB\" : 10")?;
515        writeln!(writer, "    \"> 1 MB\" : 5")?;
516        writeln!(writer, "```")?;
517        writeln!(writer)?;
518
519        Ok(())
520    }
521
522    fn write_file_type_pie(&self, writer: &mut dyn Write, stats: &TreeStats) -> Result<()> {
523        writeln!(writer, "## 🍰 File Type Distribution")?;
524        writeln!(writer)?;
525        writeln!(writer, "```mermaid")?;
526        writeln!(writer, "pie title Files by Type")?;
527
528        let mut other_count = 0;
529        let mut shown_types = 0;
530
531        for (ext, count) in &stats.file_types {
532            if shown_types < self.max_pie_slices {
533                writeln!(writer, "    \"{}\" : {}", ext, count)?;
534                shown_types += 1;
535            } else {
536                other_count += count;
537            }
538        }
539
540        if other_count > 0 {
541            writeln!(writer, "    \"Other\" : {}", other_count)?;
542        }
543
544        writeln!(writer, "```")?;
545        writeln!(writer)?;
546
547        Ok(())
548    }
549
550    fn write_largest_files_table(&self, writer: &mut dyn Write, stats: &TreeStats) -> Result<()> {
551        writeln!(writer, "## 🏆 Largest Files")?;
552        writeln!(writer)?;
553        writeln!(writer, "| Rank | File | Size |")?;
554        writeln!(writer, "|------|------|------|")?;
555
556        for (i, (size, path)) in stats.largest_files.iter().enumerate() {
557            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
558            let emoji = self.get_file_emoji(path, false);
559
560            writeln!(
561                writer,
562                "| {} | {} {} | {} |",
563                match i {
564                    0 => "🥇",
565                    1 => "🥈",
566                    2 => "🥉",
567                    _ => "📄",
568                },
569                emoji,
570                name,
571                Self::format_size(*size)
572            )?;
573        }
574
575        writeln!(writer)?;
576        Ok(())
577    }
578
579    fn write_recent_files_table(&self, writer: &mut dyn Write, stats: &TreeStats) -> Result<()> {
580        if stats.newest_files.is_empty() {
581            return Ok(());
582        }
583
584        writeln!(writer, "## 🕐 Recent Activity")?;
585        writeln!(writer)?;
586        writeln!(writer, "| File | Last Modified |")?;
587        writeln!(writer, "|------|---------------|")?;
588
589        for (timestamp, path) in stats.newest_files.iter().take(10) {
590            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
591            let emoji = self.get_file_emoji(path, false);
592
593            if let Ok(duration) = std::time::SystemTime::now().duration_since(*timestamp) {
594                let days = duration.as_secs() / 86400;
595                let time_str = if days == 0 {
596                    "Today".to_string()
597                } else if days == 1 {
598                    "Yesterday".to_string()
599                } else if days < 7 {
600                    format!("{} days ago", days)
601                } else if days < 30 {
602                    format!("{} weeks ago", days / 7)
603                } else {
604                    format!("{} months ago", days / 30)
605                };
606
607                writeln!(writer, "| {} {} | {} |", emoji, name, time_str)?;
608            }
609        }
610
611        writeln!(writer)?;
612        Ok(())
613    }
614
615    fn write_summary(&self, writer: &mut dyn Write, _stats: &TreeStats) -> Result<()> {
616        writeln!(writer, "## 📈 Summary")?;
617        writeln!(writer)?;
618
619        if !self.no_emoji {
620            writeln!(writer, "This analysis brought to you by **Smart Tree** 🌳")?;
621            writeln!(
622                writer,
623                "Where directories become beautiful documentation! ✨"
624            )?;
625        } else {
626            writeln!(writer, "This analysis brought to you by **Smart Tree**")?;
627            writeln!(writer, "Where directories become beautiful documentation!")?;
628        }
629
630        writeln!(writer)?;
631        writeln!(writer, "---")?;
632        writeln!(writer)?;
633        writeln!(writer, "**Generated with [Smart Tree](https://github.com/8b-is/smart-tree/) - Making directory visualization intelligent, fast, and beautiful!** ")?;
634
635        Ok(())
636    }
637}
638
639impl Formatter for MarkdownFormatter {
640    fn format(
641        &self,
642        writer: &mut dyn Write,
643        nodes: &[FileNode],
644        stats: &TreeStats,
645        root_path: &Path,
646    ) -> Result<()> {
647        // Header with overview
648        self.write_header(writer, root_path, stats)?;
649
650        // Mermaid directory diagram
651        if self.include_mermaid {
652            self.write_mermaid_diagram(writer, nodes, root_path)?;
653        }
654
655        // File types table
656        if self.include_tables && !stats.file_types.is_empty() {
657            self.write_file_type_table(writer, stats)?;
658        }
659
660        // Pie charts
661        if self.include_pie_charts {
662            if !stats.file_types.is_empty() {
663                self.write_file_type_pie(writer, stats)?;
664            }
665            // Size distribution pie (would need more data in real implementation)
666            self.write_size_distribution_pie(writer, stats)?;
667        }
668
669        // Largest files
670        if self.include_tables && !stats.largest_files.is_empty() {
671            self.write_largest_files_table(writer, stats)?;
672        }
673
674        // Recent activity
675        if self.include_tables && !stats.newest_files.is_empty() {
676            self.write_recent_files_table(writer, stats)?;
677        }
678
679        // Summary
680        self.write_summary(writer, stats)?;
681
682        Ok(())
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use crate::scanner::{FileCategory, FileNode, FileType, FilesystemType, TreeStats};
690    use std::path::PathBuf;
691    use std::time::SystemTime;
692
693    #[test]
694    fn test_markdown_formatter() {
695        let formatter = MarkdownFormatter::new(
696            PathDisplayMode::Off,
697            false,
698            true, // include_mermaid
699            true, // include_tables
700            true, // include_pie_charts
701        );
702
703        let nodes = vec![FileNode {
704            path: PathBuf::from("src"),
705            is_dir: true,
706            size: 0,
707            permissions: 0o755,
708            uid: 1000,
709            gid: 1000,
710            modified: SystemTime::now(),
711            is_symlink: false,
712            is_ignored: false,
713            search_matches: None,
714            // ---- Fields added to fix compilation ----
715            is_hidden: false,
716            permission_denied: false,
717            depth: 1,
718            file_type: FileType::Directory,
719            category: FileCategory::Unknown,
720            filesystem_type: FilesystemType::Unknown,
721            git_branch: None,
722            traversal_context: None,
723            interest: None,
724            security_findings: Vec::new(),
725            change_status: None,
726            content_hash: None,
727        }];
728
729        let mut stats = TreeStats::default();
730        stats.update_file(&nodes[0]);
731
732        let mut output = Vec::new();
733        let result = formatter.format(&mut output, &nodes, &stats, &PathBuf::from("."));
734        assert!(result.is_ok());
735
736        let output_str = String::from_utf8(output).unwrap();
737        assert!(output_str.contains("# 📊 Directory Analysis Report"));
738        assert!(output_str.contains("mermaid"));
739    }
740}