1use 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, }
48 }
49
50 fn escape_mermaid(text: &str) -> String {
51 text.replace('|', "|")
52 .replace('<', "<")
53 .replace('>', ">")
54 .replace('"', """)
55 .replace('\'', "'")
56 .replace('[', "[")
57 .replace(']', "]")
58 .replace('{', "{")
59 .replace('}', "}")
60 .replace('(', "(")
61 .replace(')', ")")
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")?; writeln!(writer, " %% Smart Tree Directory Visualization")?;
142
143 let max_nodes = 40; let display_nodes: Vec<&FileNode> = nodes.iter().take(max_nodes).collect();
146
147 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 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 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 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 for (i, node) in display_nodes.iter().enumerate() {
239 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 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 writeln!(writer)?;
300 writeln!(writer, " %% Connections")?;
301
302 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 let mut connected_subgraphs = std::collections::HashSet::new();
320 let mut subgraph_map = HashMap::new();
321 let mut current_sub = 0;
322
323 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 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 if let Some(parent) = node.path.parent() {
338 if parent == root_path {
339 writeln!(writer, " root --> sub{}", sub_num)?;
340 } else {
341 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 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 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; }
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 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 self.write_header(writer, root_path, stats)?;
649
650 if self.include_mermaid {
652 self.write_mermaid_diagram(writer, nodes, root_path)?;
653 }
654
655 if self.include_tables && !stats.file_types.is_empty() {
657 self.write_file_type_table(writer, stats)?;
658 }
659
660 if self.include_pie_charts {
662 if !stats.file_types.is_empty() {
663 self.write_file_type_pie(writer, stats)?;
664 }
665 self.write_size_distribution_pie(writer, stats)?;
667 }
668
669 if self.include_tables && !stats.largest_files.is_empty() {
671 self.write_largest_files_table(writer, stats)?;
672 }
673
674 if self.include_tables && !stats.newest_files.is_empty() {
676 self.write_recent_files_table(writer, stats)?;
677 }
678
679 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, true, true, );
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 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}