1use 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, Mindmap, GitGraph, Treemap, }
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, }
41 }
42
43 fn sanitize_node_id(path: &std::path::Path) -> String {
44 let path_str = path.to_string_lossy();
46 path_str.replace(
48 [
49 '/', '\\', '.', ' ', '-', '(', ')', '[', ']', '{', '}', ':', ';', ',', '\'', '"',
50 '`', '~', '!', '@', '#', '$', '%', '^', '&', '*', '=', '+', '|', '<', '>', '?',
51 ],
52 "_",
53 )
54 }
55
56 fn escape_label(text: &str) -> String {
57 text.replace('|', "|")
59 .replace('<', "<")
60 .replace('>', ">")
61 .replace('"', """)
62 .replace('\'', "'")
63 .replace('[', "[")
64 .replace(']', "]")
65 .replace('{', "{")
66 .replace('}', "}")
67 .replace('(', "(")
68 .replace(')', ")")
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 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 let escaped_name = Self::escape_label(&name);
105
106 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 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 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 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 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 for node in nodes {
166 let node_id = Self::sanitize_node_id(&node.path);
167 let label = self.format_label(node);
168
169 let (open_shape, close_shape) = if node.is_dir {
171 ("[\"", "\"]") } else {
173 match node.path.extension().and_then(|e| e.to_str()) {
174 Some("md") | Some("txt") | Some("rst") => ("([\"", "\"])"), Some("rs") | Some("py") | Some("js") | Some("ts") => ("{{\"", "\"}}"), Some("toml") | Some("yaml") | Some("yml") | Some("json") => ("[\"", "\"]"), _ => ("[\"", "\"]"), }
179 };
180
181 writeln!(
182 writer,
183 " {}{}{}{}",
184 node_id, open_shape, label, close_shape
185 )?;
186
187 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 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 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 let _current_depth = 0;
262 let _depth_stack = [root_path.to_path_buf()];
263
264 for node in nodes {
265 let depth = node.path.components().count() - root_path.components().count();
267
268 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 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 } else if i < 20 {
304 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'}}}}%%")?; writeln!(writer, "treemap-beta")?; 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 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 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 if node.path == *root_path {
350 continue;
351 }
352
353 let node_depth = node.path.components().count() - root_path.components().count();
355
356 while current_depth >= node_depth {
358 current_path.pop();
359 current_depth -= 1;
360 }
361
362 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 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 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 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 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}