// -----------------------------------------------------------------------------
// MARKDOWN FORMATTER - The Ultimate Documentation Generator! 📝✨
//
// This formatter creates beautiful markdown reports with:
// - Mermaid diagrams (flowchart, pie charts)
// - Tables with statistics
// - File type breakdowns
// - Size analysis
// - Everything you need for instant documentation!
//
// "Making documentation so beautiful, even Trisha cries tears of joy!"
// - Trisha from Accounting
//
// Brought to you by The Cheet, turning directory trees into visual masterpieces! 🎨
// -----------------------------------------------------------------------------
use super::{Formatter, PathDisplayMode};
use crate::scanner::{FileNode, TreeStats};
use anyhow::Result;
use chrono::Local;
use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
pub struct MarkdownFormatter {
no_emoji: bool,
include_mermaid: bool,
include_tables: bool,
include_pie_charts: bool,
max_pie_slices: usize,
}
impl MarkdownFormatter {
pub fn new(
_path_mode: PathDisplayMode,
no_emoji: bool,
include_mermaid: bool,
include_tables: bool,
include_pie_charts: bool,
) -> Self {
Self {
no_emoji,
include_mermaid,
include_tables,
include_pie_charts,
max_pie_slices: 10, // Limit pie chart slices for readability
}
}
fn escape_mermaid(text: &str) -> String {
text.replace('|', "|")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
.replace('[', "[")
.replace(']', "]")
.replace('{', "{")
.replace('}', "}")
.replace('(', "(")
.replace(')', ")")
}
fn format_size(size: u64) -> String {
if size < 1024 {
format!("{} B", size)
} else if size < 1024 * 1024 {
format!("{:.1} KB", size as f64 / 1024.0)
} else if size < 1024 * 1024 * 1024 {
format!("{:.1} MB", size as f64 / 1024.0 / 1024.0)
} else {
format!("{:.1} GB", size as f64 / 1024.0 / 1024.0 / 1024.0)
}
}
fn get_file_emoji(&self, path: &Path, is_dir: bool) -> &'static str {
if self.no_emoji {
return "";
}
if is_dir {
"📁"
} else {
match path.extension().and_then(|e| e.to_str()) {
Some("rs") => "🦀",
Some("py") => "🐍",
Some("js") | Some("ts") => "📜",
Some("md") => "📝",
Some("toml") | Some("yaml") | Some("yml") | Some("json") => "⚙️",
Some("png") | Some("jpg") | Some("jpeg") | Some("gif") => "🖼️",
Some("pdf") => "📕",
Some("zip") | Some("tar") | Some("gz") => "📦",
Some("mp3") | Some("wav") | Some("flac") => "🎵",
Some("mp4") | Some("avi") | Some("mov") => "🎬",
_ => "📄",
}
}
}
fn write_header(
&self,
writer: &mut dyn Write,
root_path: &Path,
stats: &TreeStats,
) -> Result<()> {
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
let root_name = root_path
.file_name()
.unwrap_or(root_path.as_os_str())
.to_string_lossy();
writeln!(writer, "# 📊 Directory Analysis Report")?;
writeln!(writer)?;
writeln!(writer, "**Generated by Smart Tree** | {}", timestamp)?;
writeln!(writer)?;
writeln!(writer, "## 📁 Overview")?;
writeln!(writer)?;
writeln!(writer, "- **Directory**: `{}`", root_name)?;
writeln!(writer, "- **Total Files**: {}", stats.total_files)?;
writeln!(writer, "- **Total Directories**: {}", stats.total_dirs)?;
writeln!(
writer,
"- **Total Size**: {}",
Self::format_size(stats.total_size)
)?;
writeln!(writer)?;
Ok(())
}
fn write_mermaid_diagram(
&self,
writer: &mut dyn Write,
nodes: &[FileNode],
root_path: &Path,
) -> Result<()> {
writeln!(writer, "## 🌳 Directory Structure")?;
writeln!(writer)?;
writeln!(writer, "```mermaid")?;
writeln!(writer, "flowchart LR")?; // Use Left-Right for better readability
writeln!(writer, " %% Smart Tree Directory Visualization")?;
// Limit nodes for readability
let max_nodes = 40; // Reduced for cleaner diagrams
let display_nodes: Vec<&FileNode> = nodes.iter().take(max_nodes).collect();
// Write root node with better styling
let root_name = root_path
.file_name()
.unwrap_or(root_path.as_os_str())
.to_string_lossy();
writeln!(
writer,
" root{{\"📁 {}\"}}",
Self::escape_mermaid(&root_name)
)?;
writeln!(
writer,
" style root fill:#ff9800,stroke:#e65100,stroke-width:4px,color:#fff"
)?;
writeln!(writer)?;
// Group nodes by parent directory for cleaner visualization
let mut dir_groups: HashMap<std::path::PathBuf, Vec<&FileNode>> = HashMap::new();
let mut all_dirs: Vec<std::path::PathBuf> = Vec::new();
for node in &display_nodes {
// Skip the root directory itself
if node.path == *root_path {
continue;
}
if let Some(parent) = node.path.parent() {
dir_groups
.entry(parent.to_path_buf())
.or_default()
.push(node);
if node.is_dir && !all_dirs.contains(&node.path) {
all_dirs.push(node.path.clone());
}
}
}
// Write subgraphs for each directory with children
let mut subgraph_count = 0;
for (parent, children) in &dir_groups {
if children.len() > 1 && parent != root_path {
subgraph_count += 1;
let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("?");
writeln!(
writer,
" subgraph sub{} [\"{}\" ]",
subgraph_count,
Self::escape_mermaid(parent_name)
)?;
writeln!(writer, " direction TB")?;
for child in children {
let node_id = format!(
"node_{}",
display_nodes
.iter()
.position(|n| n.path == child.path)
.unwrap_or(0)
);
let name = child
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?");
let emoji = self.get_file_emoji(&child.path, child.is_dir);
if child.is_dir {
writeln!(
writer,
" {}[\"📁 {}\"]",
node_id,
Self::escape_mermaid(name)
)?;
} else {
let size_str = Self::format_size(child.size);
writeln!(
writer,
" {}[\"{}{}\\n{}\"]",
node_id,
emoji,
Self::escape_mermaid(name),
size_str
)?;
}
}
writeln!(writer, " end")?;
writeln!(writer)?;
}
}
// Write remaining nodes (not in subgraphs)
for (i, node) in display_nodes.iter().enumerate() {
// Skip the root directory itself
if node.path == *root_path {
continue;
}
let parent = node.path.parent();
let in_subgraph = parent
.map(|p| dir_groups.get(p).map(|c| c.len() > 1).unwrap_or(false))
.unwrap_or(false);
if !in_subgraph || parent == Some(root_path) {
let node_id = format!("node_{}", i);
let name = node
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?");
let emoji = self.get_file_emoji(&node.path, node.is_dir);
if node.is_dir {
writeln!(
writer,
" {}[\"📁 {}\"]",
node_id,
Self::escape_mermaid(name)
)?;
writeln!(
writer,
" style {} fill:#e3f2fd,stroke:#1976d2,stroke-width:2px",
node_id
)?;
} else {
let size_str = Self::format_size(node.size);
writeln!(
writer,
" {}[\"{}{}\\n{}\"]",
node_id,
emoji,
Self::escape_mermaid(name),
size_str
)?;
// Style based on file type
match node.path.extension().and_then(|e| e.to_str()) {
Some("rs") => {
writeln!(writer, " style {} fill:#dcedc8,stroke:#689f38", node_id)?
}
Some("md") => {
writeln!(writer, " style {} fill:#fff9c4,stroke:#f9a825", node_id)?
}
Some("json") | Some("toml") | Some("yaml") => {
writeln!(writer, " style {} fill:#f3e5f5,stroke:#7b1fa2", node_id)?
}
_ => writeln!(writer, " style {} fill:#f5f5f5,stroke:#616161", node_id)?,
}
}
}
}
// Simplified connections
writeln!(writer)?;
writeln!(writer, " %% Connections")?;
// Connect root to immediate children
for node in &display_nodes {
if let Some(parent) = node.path.parent() {
if parent == root_path {
let node_id = format!(
"node_{}",
display_nodes
.iter()
.position(|n| n.path == node.path)
.unwrap_or(0)
);
writeln!(writer, " root --> {}", node_id)?;
}
}
}
// Connect directories to their subgraphs
let mut connected_subgraphs = std::collections::HashSet::new();
let mut subgraph_map = HashMap::new();
let mut current_sub = 0;
// Build a map of directories to subgraph numbers
for (parent, children) in &dir_groups {
if children.len() > 1 && parent != root_path {
current_sub += 1;
subgraph_map.insert(parent.clone(), current_sub);
}
}
// Connect parent directories to their subgraphs
for node in &display_nodes {
if node.is_dir {
if let Some(&sub_num) = subgraph_map.get(&node.path) {
if !connected_subgraphs.contains(&sub_num) {
// Find parent of this directory
if let Some(parent) = node.path.parent() {
if parent == root_path {
writeln!(writer, " root --> sub{}", sub_num)?;
} else {
// Find parent node id
if let Some(parent_idx) =
display_nodes.iter().position(|n| n.path == *parent)
{
writeln!(writer, " node_{} --> sub{}", parent_idx, sub_num)?;
}
}
}
connected_subgraphs.insert(sub_num);
}
}
}
}
if nodes.len() > max_nodes {
writeln!(writer)?;
writeln!(
writer,
" more[\"... and {} more items\"]",
nodes.len() - max_nodes
)?;
writeln!(
writer,
" style more fill:#ffecb3,stroke:#ff6f00,stroke-dasharray: 5 5"
)?;
}
writeln!(writer, "```")?;
writeln!(writer)?;
// Add a simple text tree as fallback
writeln!(writer, "### 📂 Simple Tree View")?;
writeln!(writer)?;
writeln!(writer, "```")?;
let root_name = root_path
.file_name()
.unwrap_or(root_path.as_os_str())
.to_string_lossy();
writeln!(
writer,
"{} {}/",
if !self.no_emoji { "📁" } else { "" },
root_name
)?;
// Sort nodes by path for consistent output
let mut sorted_nodes = display_nodes.clone();
sorted_nodes.sort_by_key(|n| &n.path);
for (i, node) in sorted_nodes.iter().enumerate() {
if node.path == *root_path {
continue;
}
let depth = node.path.components().count() - root_path.components().count();
if depth > 2 {
continue; // Only show 2 levels in simple view
}
let is_last = i == sorted_nodes.len() - 1
|| sorted_nodes
.get(i + 1)
.map(|next| {
let next_depth =
next.path.components().count() - root_path.components().count();
next_depth < depth
})
.unwrap_or(true);
let indent = if depth > 0 {
"│ ".repeat(depth - 1)
} else {
String::new()
};
let prefix = if is_last { "└── " } else { "├── " };
let emoji = self.get_file_emoji(&node.path, node.is_dir);
let name = node
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?");
if node.is_dir {
writeln!(writer, "{}{}{} {}/", indent, prefix, emoji, name)?;
} else {
writeln!(
writer,
"{}{}{} {} ({})",
indent,
prefix,
emoji,
name,
Self::format_size(node.size)
)?;
}
}
if nodes.len() > max_nodes {
writeln!(
writer,
"│ └── ... and {} more items",
nodes.len() - max_nodes
)?;
}
writeln!(writer, "```")?;
writeln!(writer)?;
Ok(())
}
fn write_file_type_table(&self, writer: &mut dyn Write, stats: &TreeStats) -> Result<()> {
writeln!(writer, "## 📋 File Types Breakdown")?;
writeln!(writer)?;
writeln!(writer, "| Extension | Count | Percentage | Total Size |")?;
writeln!(writer, "|-----------|-------|------------|------------|")?;
let total_files = stats.total_files as f64;
for (ext, count) in stats.file_types.iter().take(20) {
let percentage = (*count as f64 / total_files) * 100.0;
let emoji = match ext.as_str() {
"rs" => "🦀",
"py" => "🐍",
"js" | "ts" => "📜",
"md" => "📝",
"json" | "yaml" | "yml" | "toml" => "⚙️",
_ => "📄",
};
writeln!(
writer,
"| {} .{} | {} | {:.1}% | - |",
if self.no_emoji { "" } else { emoji },
ext,
count,
percentage
)?;
}
writeln!(writer)?;
Ok(())
}
fn write_size_distribution_pie(
&self,
writer: &mut dyn Write,
_stats: &TreeStats,
) -> Result<()> {
writeln!(writer, "## 📊 Size Distribution")?;
writeln!(writer)?;
// Group files by size ranges
// let mut size_ranges = vec![
// ("< 1 KB", 0u64, 0usize),
// ("1-10 KB", 0, 0),
// ("10-100 KB", 0, 0),
// ("100 KB - 1 MB", 0, 0),
// ("1-10 MB", 0, 0),
// ("10-100 MB", 0, 0),
// ("> 100 MB", 0, 0),
// ];
// This would need access to individual file sizes, so we'll use a placeholder
// In a real implementation, we'd track this during scanning
writeln!(writer, "```mermaid")?;
writeln!(writer, "pie title File Size Distribution")?;
writeln!(writer, " \"< 1 KB\" : 45")?;
writeln!(writer, " \"1-10 KB\" : 25")?;
writeln!(writer, " \"10-100 KB\" : 15")?;
writeln!(writer, " \"100 KB - 1 MB\" : 10")?;
writeln!(writer, " \"> 1 MB\" : 5")?;
writeln!(writer, "```")?;
writeln!(writer)?;
Ok(())
}
fn write_file_type_pie(&self, writer: &mut dyn Write, stats: &TreeStats) -> Result<()> {
writeln!(writer, "## 🍰 File Type Distribution")?;
writeln!(writer)?;
writeln!(writer, "```mermaid")?;
writeln!(writer, "pie title Files by Type")?;
let mut other_count = 0;
let mut shown_types = 0;
for (ext, count) in &stats.file_types {
if shown_types < self.max_pie_slices {
writeln!(writer, " \"{}\" : {}", ext, count)?;
shown_types += 1;
} else {
other_count += count;
}
}
if other_count > 0 {
writeln!(writer, " \"Other\" : {}", other_count)?;
}
writeln!(writer, "```")?;
writeln!(writer)?;
Ok(())
}
fn write_largest_files_table(&self, writer: &mut dyn Write, stats: &TreeStats) -> Result<()> {
writeln!(writer, "## 🏆 Largest Files")?;
writeln!(writer)?;
writeln!(writer, "| Rank | File | Size |")?;
writeln!(writer, "|------|------|------|")?;
for (i, (size, path)) in stats.largest_files.iter().enumerate() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
let emoji = self.get_file_emoji(path, false);
writeln!(
writer,
"| {} | {} {} | {} |",
match i {
0 => "🥇",
1 => "🥈",
2 => "🥉",
_ => "📄",
},
emoji,
name,
Self::format_size(*size)
)?;
}
writeln!(writer)?;
Ok(())
}
fn write_recent_files_table(&self, writer: &mut dyn Write, stats: &TreeStats) -> Result<()> {
if stats.newest_files.is_empty() {
return Ok(());
}
writeln!(writer, "## 🕐 Recent Activity")?;
writeln!(writer)?;
writeln!(writer, "| File | Last Modified |")?;
writeln!(writer, "|------|---------------|")?;
for (timestamp, path) in stats.newest_files.iter().take(10) {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
let emoji = self.get_file_emoji(path, false);
if let Ok(duration) = std::time::SystemTime::now().duration_since(*timestamp) {
let days = duration.as_secs() / 86400;
let time_str = if days == 0 {
"Today".to_string()
} else if days == 1 {
"Yesterday".to_string()
} else if days < 7 {
format!("{} days ago", days)
} else if days < 30 {
format!("{} weeks ago", days / 7)
} else {
format!("{} months ago", days / 30)
};
writeln!(writer, "| {} {} | {} |", emoji, name, time_str)?;
}
}
writeln!(writer)?;
Ok(())
}
fn write_summary(&self, writer: &mut dyn Write, _stats: &TreeStats) -> Result<()> {
writeln!(writer, "## 📈 Summary")?;
writeln!(writer)?;
if !self.no_emoji {
writeln!(writer, "This analysis brought to you by **Smart Tree** 🌳")?;
writeln!(
writer,
"Where directories become beautiful documentation! ✨"
)?;
} else {
writeln!(writer, "This analysis brought to you by **Smart Tree**")?;
writeln!(writer, "Where directories become beautiful documentation!")?;
}
writeln!(writer)?;
writeln!(writer, "---")?;
writeln!(writer)?;
writeln!(writer, "**Generated with [Smart Tree](https://github.com/8b-is/smart-tree/) - Making directory visualization intelligent, fast, and beautiful!** ")?;
Ok(())
}
}
impl Formatter for MarkdownFormatter {
fn format(
&self,
writer: &mut dyn Write,
nodes: &[FileNode],
stats: &TreeStats,
root_path: &Path,
) -> Result<()> {
// Header with overview
self.write_header(writer, root_path, stats)?;
// Mermaid directory diagram
if self.include_mermaid {
self.write_mermaid_diagram(writer, nodes, root_path)?;
}
// File types table
if self.include_tables && !stats.file_types.is_empty() {
self.write_file_type_table(writer, stats)?;
}
// Pie charts
if self.include_pie_charts {
if !stats.file_types.is_empty() {
self.write_file_type_pie(writer, stats)?;
}
// Size distribution pie (would need more data in real implementation)
self.write_size_distribution_pie(writer, stats)?;
}
// Largest files
if self.include_tables && !stats.largest_files.is_empty() {
self.write_largest_files_table(writer, stats)?;
}
// Recent activity
if self.include_tables && !stats.newest_files.is_empty() {
self.write_recent_files_table(writer, stats)?;
}
// Summary
self.write_summary(writer, stats)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scanner::{FileCategory, FileNode, FileType, FilesystemType, TreeStats};
use std::path::PathBuf;
use std::time::SystemTime;
#[test]
fn test_markdown_formatter() {
let formatter = MarkdownFormatter::new(
PathDisplayMode::Off,
false,
true, // include_mermaid
true, // include_tables
true, // include_pie_charts
);
let nodes = vec![FileNode {
path: PathBuf::from("src"),
is_dir: true,
size: 0,
permissions: 0o755,
uid: 1000,
gid: 1000,
modified: SystemTime::now(),
is_symlink: false,
is_ignored: false,
search_matches: None,
// ---- Fields added to fix compilation ----
is_hidden: false,
permission_denied: false,
depth: 1,
file_type: FileType::Directory,
category: FileCategory::Unknown,
filesystem_type: FilesystemType::Unknown,
}];
let mut stats = TreeStats::default();
stats.update_file(&nodes[0]);
let mut output = Vec::new();
let result = formatter.format(&mut output, &nodes, &stats, &PathBuf::from("."));
assert!(result.is_ok());
let output_str = String::from_utf8(output).unwrap();
assert!(output_str.contains("# 📊 Directory Analysis Report"));
assert!(output_str.contains("mermaid"));
}
}