Skip to main content

st/formatters/
mermaid.rs

1// -----------------------------------------------------------------------------
2// MERMAID FORMATTER - Making directory trees documentation-ready! 🧜‍♀️
3//
4// This formatter outputs directory structures as Mermaid diagrams, perfect for
5// embedding in markdown documentation, GitHub READMEs, and wikis!
6//
7// "Every diagram tells a story" - Trisha from Accounting
8//
9// Brought to you by The Cheet, making documentation as beautiful as it is useful! 📊✨
10// -----------------------------------------------------------------------------
11
12use super::{Formatter, PathDisplayMode};
13use crate::scanner::{FileNode, TreeStats};
14use anyhow::Result;
15use std::collections::HashMap;
16use std::io::Write;
17
18pub struct MermaidFormatter {
19    style: MermaidStyle,
20    no_emoji: bool,
21    path_mode: PathDisplayMode,
22    max_label_length: usize,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub enum MermaidStyle {
27    Flowchart, // Traditional flowchart style (TD/LR)
28    Mindmap,   // Mind map style (great for overviews)
29    GitGraph,  // Git-like graph (good for showing relationships)
30    Treemap,   // Treemap style (perfect for showing sizes!)
31}
32
33impl MermaidFormatter {
34    pub fn new(style: MermaidStyle, no_emoji: bool, path_mode: PathDisplayMode) -> Self {
35        Self {
36            style,
37            no_emoji,
38            path_mode,
39            max_label_length: 50, // Prevent overly long labels
40        }
41    }
42
43    fn sanitize_node_id(path: &std::path::Path) -> String {
44        // Create safe node IDs for Mermaid
45        let path_str = path.to_string_lossy();
46        // Replace problematic characters
47        path_str.replace(
48            [
49                '/', '\\', '.', ' ', '-', '(', ')', '[', ']', '{', '}', ':', ';', ',', '\'', '"',
50                '`', '~', '!', '@', '#', '$', '%', '^', '&', '*', '=', '+', '|', '<', '>', '?',
51            ],
52            "_",
53        )
54    }
55
56    fn escape_label(text: &str) -> String {
57        // Escape special characters that might break Mermaid syntax
58        text.replace('|', "&#124;")
59            .replace('<', "&lt;")
60            .replace('>', "&gt;")
61            .replace('"', "&quot;")
62            .replace('\'', "&#39;")
63            .replace('[', "&#91;")
64            .replace(']', "&#93;")
65            .replace('{', "&#123;")
66            .replace('}', "&#125;")
67            .replace('(', "&#40;")
68            .replace(')', "&#41;")
69    }
70
71    fn format_label(&self, node: &FileNode) -> String {
72        let name = match self.path_mode {
73            PathDisplayMode::Off => node
74                .path
75                .file_name()
76                .and_then(|n| n.to_str())
77                .unwrap_or("?")
78                .to_string(),
79            PathDisplayMode::Relative | PathDisplayMode::Full => {
80                node.path.to_string_lossy().to_string()
81            }
82        };
83
84        // Add emoji if enabled
85        let emoji = if !self.no_emoji {
86            if node.is_dir {
87                "📁 "
88            } else {
89                match node.path.extension().and_then(|e| e.to_str()) {
90                    Some("rs") => "🦀 ",
91                    Some("py") => "🐍 ",
92                    Some("js") | Some("ts") => "📜 ",
93                    Some("md") => "📝 ",
94                    Some("toml") | Some("yaml") | Some("yml") | Some("json") => "⚙️ ",
95                    Some("png") | Some("jpg") | Some("jpeg") | Some("gif") => "🖼️ ",
96                    _ => "📄 ",
97                }
98            }
99        } else {
100            ""
101        };
102
103        // Escape the name for Mermaid
104        let escaped_name = Self::escape_label(&name);
105
106        // Truncate if too long
107        let mut label = format!("{}{}", emoji, escaped_name);
108        if label.len() > self.max_label_length {
109            label.truncate(self.max_label_length - 3);
110            label.push_str("...");
111        }
112
113        // Add size for files
114        if !node.is_dir && node.size > 0 {
115            label.push_str(&format!("<br/>{}", format_size(node.size)));
116        }
117
118        label
119    }
120
121    fn write_flowchart(
122        &self,
123        writer: &mut dyn Write,
124        nodes: &[FileNode],
125        root_path: &std::path::Path,
126    ) -> Result<()> {
127        writeln!(writer, "```mermaid")?;
128        writeln!(writer, "graph TD")?;
129        writeln!(writer, "    %% Smart Tree Directory Structure")?;
130        writeln!(writer)?;
131
132        // Build parent-child relationships
133        let mut parent_map: HashMap<String, Vec<&FileNode>> = HashMap::new();
134        let root_id = Self::sanitize_node_id(root_path);
135
136        for node in nodes {
137            let _node_id = Self::sanitize_node_id(&node.path);
138
139            // Find parent
140            if let Some(parent_path) = node.path.parent() {
141                let parent_id = if parent_path == root_path {
142                    root_id.clone()
143                } else {
144                    Self::sanitize_node_id(parent_path)
145                };
146
147                parent_map.entry(parent_id).or_default().push(node);
148            }
149        }
150
151        // Write root node with emoji handling
152        let root_emoji = if !self.no_emoji { "📁 " } else { "" };
153        let root_name = root_path
154            .file_name()
155            .unwrap_or(root_path.as_os_str())
156            .to_string_lossy();
157        let escaped_root_name = Self::escape_label(&root_name);
158        writeln!(
159            writer,
160            "    {}[\"{}{}\"]",
161            root_id, root_emoji, escaped_root_name
162        )?;
163
164        // Write all nodes and connections
165        for node in nodes {
166            let node_id = Self::sanitize_node_id(&node.path);
167            let label = self.format_label(node);
168
169            // Determine node shape based on type
170            let (open_shape, close_shape) = if node.is_dir {
171                ("[\"", "\"]") // Rectangle for directories - use quotes to handle emojis
172            } else {
173                match node.path.extension().and_then(|e| e.to_str()) {
174                    Some("md") | Some("txt") | Some("rst") => ("([\"", "\"])"), // Stadium for docs
175                    Some("rs") | Some("py") | Some("js") | Some("ts") => ("{{\"", "\"}}"), // Hexagon for code
176                    Some("toml") | Some("yaml") | Some("yml") | Some("json") => ("[\"", "\"]"), // Rectangle for config (simpler than cylinder)
177                    _ => ("[\"", "\"]"), // Rectangle for other files (safer than circles)
178                }
179            };
180
181            writeln!(
182                writer,
183                "    {}{}{}{}",
184                node_id, open_shape, label, close_shape
185            )?;
186
187            // Connect to parent
188            if let Some(parent_path) = node.path.parent() {
189                let parent_id = if parent_path == root_path {
190                    root_id.clone()
191                } else {
192                    Self::sanitize_node_id(parent_path)
193                };
194
195                writeln!(writer, "    {} --> {}", parent_id, node_id)?;
196            }
197        }
198
199        // Add styling
200        writeln!(writer)?;
201        writeln!(writer, "    %% Styling")?;
202        writeln!(
203            writer,
204            "    classDef dirStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px"
205        )?;
206        writeln!(
207            writer,
208            "    classDef codeStyle fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px"
209        )?;
210        writeln!(
211            writer,
212            "    classDef docStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px"
213        )?;
214        writeln!(
215            writer,
216            "    classDef configStyle fill:#fce4ec,stroke:#880e4f,stroke-width:2px"
217        )?;
218
219        // Apply styles
220        for node in nodes {
221            let node_id = Self::sanitize_node_id(&node.path);
222            if node.is_dir {
223                writeln!(writer, "    class {} dirStyle", node_id)?;
224            } else {
225                match node.path.extension().and_then(|e| e.to_str()) {
226                    Some("rs") | Some("py") | Some("js") | Some("ts") => {
227                        writeln!(writer, "    class {} codeStyle", node_id)?;
228                    }
229                    Some("md") | Some("txt") | Some("rst") => {
230                        writeln!(writer, "    class {} docStyle", node_id)?;
231                    }
232                    Some("toml") | Some("yaml") | Some("yml") | Some("json") => {
233                        writeln!(writer, "    class {} configStyle", node_id)?;
234                    }
235                    _ => {}
236                }
237            }
238        }
239
240        writeln!(writer, "```")?;
241        Ok(())
242    }
243
244    fn write_mindmap(
245        &self,
246        writer: &mut dyn Write,
247        nodes: &[FileNode],
248        root_path: &std::path::Path,
249    ) -> Result<()> {
250        writeln!(writer, "```mermaid")?;
251        writeln!(writer, "mindmap")?;
252        let root_name = root_path
253            .file_name()
254            .unwrap_or(root_path.as_os_str())
255            .to_string_lossy();
256        let escaped_root_name = Self::escape_label(&root_name);
257        let root_emoji = if !self.no_emoji { "📁 " } else { "" };
258        writeln!(writer, "  root(({}{}))", root_emoji, escaped_root_name)?;
259
260        // Build tree structure
261        let _current_depth = 0;
262        let _depth_stack = [root_path.to_path_buf()];
263
264        for node in nodes {
265            // Calculate depth
266            let depth = node.path.components().count() - root_path.components().count();
267
268            // Adjust indentation
269            let indent = "    ".repeat(depth + 1);
270            let label = self.format_label(node);
271
272            writeln!(writer, "{}{}", indent, label)?;
273        }
274
275        writeln!(writer, "```")?;
276        Ok(())
277    }
278
279    fn write_gitgraph(
280        &self,
281        writer: &mut dyn Write,
282        nodes: &[FileNode],
283        _root_path: &std::path::Path,
284    ) -> Result<()> {
285        writeln!(writer, "```mermaid")?;
286        writeln!(writer, "gitGraph")?;
287        writeln!(writer, "    commit id: \"Project Root\"")?;
288
289        // Group by directory
290        let _current_branch = "main";
291        let mut branch_count = 0;
292
293        for (i, node) in nodes.iter().enumerate() {
294            if node.is_dir {
295                branch_count += 1;
296                let branch_name = format!("dir{}", branch_count);
297                writeln!(writer, "    branch {}", branch_name)?;
298                writeln!(writer, "    checkout {}", branch_name)?;
299                let dir_name = node.path.file_name().unwrap_or_default().to_string_lossy();
300                let escaped_dir_name = Self::escape_label(&dir_name);
301                writeln!(writer, "    commit id: \"{}\"", escaped_dir_name)?;
302                // current_branch = &branch_name; // This was unused, so we comment it out.
303            } else if i < 20 {
304                // Limit to prevent overly complex graphs
305                let file_name = node.path.file_name().unwrap_or_default().to_string_lossy();
306                let escaped_file_name = Self::escape_label(&file_name);
307                writeln!(writer, "    commit id: \"{}\"", escaped_file_name)?;
308            }
309        }
310
311        writeln!(writer, "```")?;
312        Ok(())
313    }
314
315    fn write_treemap(
316        &self,
317        writer: &mut dyn Write,
318        nodes: &[FileNode],
319        root_path: &std::path::Path,
320    ) -> Result<()> {
321        writeln!(writer, "```mermaid")?;
322        writeln!(writer, "%%{{init: {{'theme':'dark'}}}}%%")?; // Dark theme looks better
323        writeln!(writer, "treemap-beta")?; // Treemap is a Mermaid Beta feature.
324                                           // Build directory tree with sizes
325        let root_name = root_path
326            .file_name()
327            .unwrap_or(root_path.as_os_str())
328            .to_string_lossy();
329        let escaped_root_name = Self::escape_label(&root_name);
330        let root_emoji = if !self.no_emoji { "📁 " } else { "" };
331
332        // Write in hierarchical order based on path components
333        let mut current_path = vec![root_path.to_path_buf()];
334        let mut current_depth = 0;
335        let indent_base = "    ";
336
337        writeln!(
338            writer,
339            "{}\"{}{}\"",
340            indent_base, root_emoji, escaped_root_name
341        )?;
342
343        // Sort nodes by path for consistent hierarchical output
344        let mut sorted_nodes = nodes.to_vec();
345        sorted_nodes.sort_by_key(|n| n.path.clone());
346
347        for node in &sorted_nodes {
348            // Skip the root itself
349            if node.path == *root_path {
350                continue;
351            }
352
353            // Calculate the depth of this node
354            let node_depth = node.path.components().count() - root_path.components().count();
355
356            // Adjust current path to match this node's parent path
357            while current_depth >= node_depth {
358                current_path.pop();
359                current_depth -= 1;
360            }
361
362            // Determine indent level
363            let indent = indent_base.repeat(node_depth + 1);
364
365            let name = node
366                .path
367                .file_name()
368                .and_then(|n| n.to_str())
369                .unwrap_or("?");
370            let escaped_name = Self::escape_label(name);
371
372            if node.is_dir {
373                let dir_emoji = if !self.no_emoji { "📁 " } else { "" };
374                writeln!(writer, "{}\"{}{}\"", indent, dir_emoji, escaped_name)?;
375                current_path.push(node.path.clone());
376                current_depth = node_depth;
377            } else {
378                let emoji = if !self.no_emoji {
379                    match node.path.extension().and_then(|e| e.to_str()) {
380                        Some("rs") => "🦀 ",
381                        Some("py") => "🐍 ",
382                        Some("js") | Some("ts") => "📜 ",
383                        Some("md") => "📝 ",
384                        Some("toml") | Some("yaml") | Some("yml") | Some("json") => "⚙️ ",
385                        _ => "📄 ",
386                    }
387                } else {
388                    ""
389                };
390
391                // Convert size to KB for better readability in treemap
392                let size_kb = (node.size as f64 / 1024.0).max(1.0) as u64;
393                writeln!(
394                    writer,
395                    "{}\"{}{}\": {}",
396                    indent, emoji, escaped_name, size_kb
397                )?;
398            }
399        }
400
401        writeln!(writer, "```")?;
402        Ok(())
403    }
404}
405
406impl Formatter for MermaidFormatter {
407    fn format(
408        &self,
409        writer: &mut dyn Write,
410        nodes: &[FileNode],
411        stats: &TreeStats,
412        root_path: &std::path::Path,
413    ) -> Result<()> {
414        // Header
415        writeln!(writer, "# Directory Structure Diagram")?;
416        writeln!(writer)?;
417        writeln!(
418            writer,
419            "Generated by Smart Tree - {} files, {} directories, {}",
420            stats.total_files,
421            stats.total_dirs,
422            format_size(stats.total_size)
423        )?;
424        writeln!(writer)?;
425
426        // Choose format based on style
427        match self.style {
428            MermaidStyle::Flowchart => self.write_flowchart(writer, nodes, root_path)?,
429            MermaidStyle::Mindmap => self.write_mindmap(writer, nodes, root_path)?,
430            MermaidStyle::GitGraph => self.write_gitgraph(writer, nodes, root_path)?,
431            MermaidStyle::Treemap => self.write_treemap(writer, nodes, root_path)?,
432        }
433
434        // Footer with copy instructions
435        writeln!(writer)?;
436        writeln!(
437            writer,
438            "<!-- Copy the mermaid code block above into your markdown file -->"
439        )?;
440        writeln!(
441            writer,
442            "<!-- GitHub, GitLab, and many other platforms will render it automatically! -->"
443        )?;
444
445        Ok(())
446    }
447}
448
449fn format_size(size: u64) -> String {
450    if size < 1024 {
451        format!("{} B", size)
452    } else if size < 1024 * 1024 {
453        format!("{:.1} KB", size as f64 / 1024.0)
454    } else if size < 1024 * 1024 * 1024 {
455        format!("{:.1} MB", size as f64 / 1024.0 / 1024.0)
456    } else {
457        format!("{:.1} GB", size as f64 / 1024.0 / 1024.0 / 1024.0)
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::scanner::{FileCategory, FileType, FilesystemType};
465    use std::path::PathBuf;
466    use std::time::SystemTime;
467
468    #[test]
469    fn test_sanitize_node_id() {
470        let path = PathBuf::from("/home/user/my-project/src/main.rs");
471        let id = MermaidFormatter::sanitize_node_id(&path);
472        assert!(!id.contains('/'));
473        assert!(!id.contains('.'));
474        assert!(!id.contains('-'));
475    }
476
477    #[test]
478    fn test_mermaid_flowchart() {
479        let formatter = MermaidFormatter::new(MermaidStyle::Flowchart, false, PathDisplayMode::Off);
480
481        let nodes = vec![
482            FileNode {
483                path: PathBuf::from("src"),
484                is_dir: true,
485                size: 0,
486                permissions: 0o755,
487                uid: 1000,
488                gid: 1000,
489                modified: SystemTime::now(),
490                is_symlink: false,
491                is_ignored: false,
492                search_matches: None,
493                is_hidden: false,
494                permission_denied: false,
495                depth: 1,
496                file_type: FileType::Directory,
497                category: FileCategory::Unknown,
498                filesystem_type: FilesystemType::Unknown,
499                git_branch: None,
500                traversal_context: None,
501                interest: None,
502                security_findings: Vec::new(),
503                change_status: None,
504                content_hash: None,
505            },
506            FileNode {
507                path: PathBuf::from("src/main.rs"),
508                is_dir: false,
509                size: 1024,
510                permissions: 0o644,
511                uid: 1000,
512                gid: 1000,
513                modified: SystemTime::now(),
514                is_symlink: false,
515                is_ignored: false,
516                search_matches: None,
517                is_hidden: false,
518                permission_denied: false,
519                depth: 2,
520                file_type: FileType::RegularFile,
521                category: FileCategory::Rust,
522                filesystem_type: FilesystemType::Unknown,
523                git_branch: None,
524                traversal_context: None,
525                interest: None,
526                security_findings: Vec::new(),
527                change_status: None,
528                content_hash: None,
529            },
530        ];
531
532        let mut stats = TreeStats::default();
533        for node in &nodes {
534            stats.update_file(node);
535        }
536
537        let mut output = Vec::new();
538        let result = formatter.format(&mut output, &nodes, &stats, &PathBuf::from("."));
539        assert!(result.is_ok());
540
541        let output_str = String::from_utf8(output).unwrap();
542        assert!(output_str.contains("```mermaid"));
543        assert!(output_str.contains("graph TD"));
544        assert!(output_str.contains("src"));
545        assert!(output_str.contains("main.rs"));
546    }
547}