use colored::*;
use std::collections::BTreeMap;
use std::io::Write as _;
use std::path::Path;
use crate::models::{Breakdown, FileInfo, ScanResult};
fn fmt_num(n: usize) -> String {
let s = n.to_string();
let mut result = String::new();
for (i, ch) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(ch);
}
result.chars().rev().collect()
}
fn fmt_percent(part: usize, total: usize) -> String {
if total == 0 {
return " 0.00%".to_string();
}
format!("{:>7.2}%", part as f64 / total as f64 * 100.0)
}
enum TreeNode<'a> {
File(&'a FileInfo),
Dir(BTreeMap<String, TreeNode<'a>>),
}
fn insert_into_tree<'a>(
tree: &mut BTreeMap<String, TreeNode<'a>>,
parts: &[&str],
info: &'a FileInfo,
) {
if parts.is_empty() {
return;
}
let mut current_level = tree;
let (dirs, file_name) = parts.split_at(parts.len() - 1);
for &dir in dirs {
let node = current_level
.entry(dir.to_string())
.or_insert_with(|| TreeNode::Dir(BTreeMap::new()));
match node {
TreeNode::Dir(children) => {
current_level = children;
}
TreeNode::File(_) => {
return;
}
}
}
if let Some(&name) = file_name.first() {
current_level.insert(name.to_string(), TreeNode::File(info));
}
}
fn build_tree<'a>(files: &'a [FileInfo], root: &Path) -> BTreeMap<String, TreeNode<'a>> {
let mut tree: BTreeMap<String, TreeNode> = BTreeMap::new();
for fi in files {
if let Ok(rel) = fi.path.strip_prefix(root) {
let parts: Vec<&str> = rel.iter().filter_map(|c| c.to_str()).collect();
insert_into_tree(&mut tree, &parts, fi);
}
}
tree
}
fn print_tree_node(
name: &str,
node: &TreeNode,
prefix: &str,
is_last: bool,
show_binary: bool,
warn_size: Option<usize>,
) -> usize {
let connector = if is_last { "└── " } else { "├── " };
let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
let mut total = 0;
match node {
TreeNode::File(fi) => {
if fi.is_binary && !show_binary {
return 0;
}
let name_colored = if fi.is_binary {
name.yellow().to_string()
} else if fi.is_lockfile {
name.bright_black().to_string()
} else if fi.lines == 0 {
name.cyan().to_string()
} else {
name.green().to_string()
};
let binary_tag = if fi.is_binary {
format!(" {}", "[binary]".yellow())
} else {
String::new()
};
let lockfile_tag = if fi.is_lockfile {
format!(" {}", "[lockfile]".bright_black())
} else {
String::new()
};
let warn_tag = if warn_size.map(|w| fi.lines > w).unwrap_or(false) {
format!(" {}", "⚠ LARGE".red().bold())
} else {
String::new()
};
let func_tag = if fi.function_count() > 0 {
format!(" {}", format!("[{} fn]", fi.function_count()).magenta())
} else {
String::new()
};
let date_tag = fi
.last_modified
.map(|d| format!(" {}", format!("[{}]", d.format("%Y-%m-%d")).dimmed()))
.unwrap_or_default();
let lines_tag = if fi.is_binary || fi.is_lockfile {
String::new()
} else {
format!(" {}", format!("({})", fmt_num(fi.lines)).bright_black())
};
println!(
"{}{}{}{}{}{}{}{}{}",
prefix,
connector,
name_colored,
lines_tag,
lockfile_tag,
func_tag,
date_tag,
binary_tag,
warn_tag
);
if !fi.is_lockfile {
total += fi.lines;
}
}
TreeNode::Dir(children) => {
println!("{}{}{}", prefix, connector, name.blue().bold());
let count = children.len();
for (i, (child_name, child_node)) in children.iter().enumerate() {
let last = i == count - 1;
total += print_tree_node(
child_name,
child_node,
&child_prefix,
last,
show_binary,
warn_size,
);
}
}
}
total
}
pub fn display_results(
result: &ScanResult,
root: &Path,
show_details: bool,
show_binary: bool,
show_tree: bool,
warn_size: Option<usize>,
show_functions: bool,
) {
let total_lines: usize = result.files.iter().map(|f| f.lines).sum();
let text_files = result.text_file_count();
let bin_files = result.binary_file_count();
let lockfile_count = result.lockfile_count();
let total_fns = result.total_functions();
let total_cls = result.total_classes();
if show_tree {
println!();
println!("{}", "Project Structure:".bold());
println!();
let tree = build_tree(&result.files, root);
let count = tree.len();
for (i, (name, node)) in tree.iter().enumerate() {
let last = i == count - 1;
print_tree_node(name, node, "", last, show_binary, warn_size);
}
println!("{}", "━".repeat(90).bright_black());
}
println!();
println!(" {}", "LOC-RS ANALYSIS SUMMARY".bold().cyan());
println!(" {}", "─".repeat(76).bright_black());
println!(
" Total Lines of Code : {:<16} Text Files : {:<16}",
fmt_num(total_lines).green().bold(),
fmt_num(text_files).blue()
);
println!(
" Code / Comment / Blank : {} / {} / {}",
fmt_num(result.total_code()).green(),
fmt_num(result.total_comment()).magenta(),
fmt_num(result.total_blank()).dimmed()
);
if lockfile_count > 0 {
println!(
" Lockfiles : {:<16}",
fmt_num(lockfile_count).bright_black()
);
}
if show_functions {
println!(
" Functions : {:<16} Binary Files : {:<16}",
fmt_num(total_fns).magenta(),
fmt_num(bin_files).yellow()
);
} else if bin_files > 0 {
println!(
" Binary Files : {:<16}",
fmt_num(bin_files).yellow()
);
}
if show_functions && total_cls > 0 {
println!(
" Classes/Structs : {:<16} ",
fmt_num(total_cls).magenta()
);
}
if let Some(ws) = warn_size {
let large_files = result.files.iter().filter(|f| f.lines > ws).count();
if large_files > 0 {
println!(
" {} {}",
"⚠ ".yellow().bold(),
format!(
"{} files exceed the threshold of {} lines",
large_files,
fmt_num(ws)
)
.yellow()
);
}
}
println!(" {}", "─".repeat(76).bright_black());
println!();
if show_details {
display_breakdown(&result.breakdown, total_lines, show_functions);
}
}
fn display_breakdown(breakdown: &Breakdown, total_lines: usize, has_functions: bool) {
println!("{}", "Breakdown by Extension:".bold().underline());
println!();
let mut sorted: Vec<_> = breakdown.iter().collect();
sorted.sort_by(|a, b| b.1.lines.cmp(&a.1.lines));
if has_functions {
println!(
" {:<18} {:>10} {:>10} {:>10} {:>10} {:>10}",
"Extension".dimmed(),
"Code".dimmed(),
"Comment".dimmed(),
"Blank".dimmed(),
"Functions".dimmed(),
"Share".dimmed()
);
println!(" {}", "─".repeat(74).bright_black());
} else {
println!(
" {:<18} {:>10} {:>10} {:>10} {:>10}",
"Extension".dimmed(),
"Code".dimmed(),
"Comment".dimmed(),
"Blank".dimmed(),
"Share".dimmed()
);
println!(" {}", "─".repeat(62).bright_black());
}
for (ext, stats) in &sorted {
let ext_colored = match ext.as_str() {
"rs" => ext.green(),
"py" => ext.yellow(),
"js" | "ts" => ext.cyan(),
"go" => ext.blue(),
"c" | "cpp" => ext.red(),
_ => ext.white(),
};
if has_functions {
println!(
" {:<18} {:>10} {:>10} {:>10} {:>10} {:>10}",
ext_colored,
fmt_num(stats.code).bold(),
fmt_num(stats.comment).magenta(),
fmt_num(stats.blank).dimmed(),
fmt_num(stats.functions),
fmt_percent(stats.lines, total_lines).bright_black(),
);
} else {
println!(
" {:<18} {:>10} {:>10} {:>10} {:>10}",
ext_colored,
fmt_num(stats.code).bold(),
fmt_num(stats.comment).magenta(),
fmt_num(stats.blank).dimmed(),
fmt_percent(stats.lines, total_lines).bright_black(),
);
}
}
println!();
}
pub fn display_function_analysis(result: &ScanResult, root: &Path) {
let files_with_fns: Vec<_> = result
.files
.iter()
.filter(|f| f.function_count() > 0)
.collect();
if files_with_fns.is_empty() {
println!(
"{}",
"[WARN] No functions found in analyzed files.".yellow()
);
return;
}
println!("\n{}", "[INFO] Function Analysis Report".blue().bold());
println!("{}", "=".repeat(90));
println!();
display_overall_stats(result, &files_with_fns);
display_largest_functions(&files_with_fns, root);
display_complex_functions(&files_with_fns, root);
display_top_files(&files_with_fns, root);
println!("{}", "=".repeat(90));
println!();
}
fn display_overall_stats(result: &ScanResult, files_with_fns: &[&FileInfo]) {
let total_fns = result.total_functions();
let total_cls = result.total_classes();
let non_class_fns: Vec<_> = files_with_fns
.iter()
.flat_map(|f| f.functions.iter().filter(|fn_| !fn_.is_class))
.collect();
let avg_len = if non_class_fns.is_empty() {
0.0
} else {
non_class_fns.iter().map(|f| f.line_count()).sum::<usize>() as f64
/ non_class_fns.len() as f64
};
println!("{}", "Overall Statistics:".bold());
println!(" Total Functions/Methods : {}", fmt_num(total_fns));
println!(" Total Classes/Structs : {}", fmt_num(total_cls));
println!(" Average Function Length : {:.1} lines\n", avg_len);
}
fn display_largest_functions(files_with_fns: &[&FileInfo], root: &Path) {
let mut all_fns: Vec<(&Path, &crate::models::FunctionInfo)> = files_with_fns
.iter()
.flat_map(|fi| {
fi.functions
.iter()
.filter(|f| !f.is_class)
.map(move |f| (fi.path.as_path(), f))
})
.collect();
all_fns.sort_by(|a, b| b.1.line_count().cmp(&a.1.line_count()));
if all_fns.is_empty() {
return;
}
println!("{}", "Top 10 Largest Functions:".bold());
println!(
"{:<42} {:<32} {:>8} {:>12}",
"Function", "File", "Lines", "Complexity"
);
println!("{}", "-".repeat(96));
for (path, func) in all_fns.iter().take(10) {
let rel = path
.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| path.display().to_string());
let complexity_str = if func.complexity > 10 {
format!("{:>12}", func.complexity).red().to_string()
} else if func.complexity > 5 {
format!("{:>12}", func.complexity).yellow().to_string()
} else {
format!("{:>12}", func.complexity).green().to_string()
};
println!(
"{:<42} {:<32} {:>8} {}",
truncate(&func.name, 40),
truncate(&rel, 30),
fmt_num(func.line_count()),
complexity_str
);
}
println!();
}
fn display_complex_functions(files_with_fns: &[&FileInfo], root: &Path) {
let mut complex_fns: Vec<_> = files_with_fns
.iter()
.flat_map(|fi| {
fi.functions
.iter()
.filter(|f| !f.is_class && f.complexity > 10)
.map(move |f| (fi.path.as_path(), f))
})
.collect();
if complex_fns.is_empty() {
return;
}
complex_fns.sort_by(|a, b| b.1.complexity.cmp(&a.1.complexity));
println!("{}", "High Complexity Functions (>10):".bold());
println!("{:<42} {:<32} {:>12}", "Function", "File", "Complexity");
println!("{}", "-".repeat(86));
for (path, func) in complex_fns.iter().take(15) {
let rel = path
.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| path.display().to_string());
println!(
"{:<42} {:<32} {}",
truncate(&func.name, 40),
truncate(&rel, 30),
format!("{:>12}", func.complexity).red()
);
}
println!();
}
fn display_top_files(files_with_fns: &[&FileInfo], root: &Path) {
let mut sorted_files = files_with_fns.to_vec();
sorted_files.sort_by_key(|b| std::cmp::Reverse(b.function_count()));
println!("{}", "Top 10 Files by Function Count:\n".bold());
for fi in sorted_files.iter().take(10) {
let rel = fi
.path
.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| fi.path.display().to_string());
println!("{}", rel.cyan());
println!(
" Functions: {}, Classes: {}, Avg length: {:.1} lines",
fi.function_count(),
fi.class_count(),
fi.avg_function_length()
);
for func in fi.functions.iter().take(5) {
let kind = match (func.is_class, func.is_async, func.is_method) {
(true, _, _) => "class ",
(_, true, _) => "async fn",
(_, _, true) => "method ",
_ => "fn ",
};
let params: Vec<_> = func.parameters.iter().take(3).cloned().collect();
let ellipsis = if func.parameters.len() > 3 {
", ..."
} else {
""
};
let complexity_note = if func.complexity > 5 {
format!(" {}", format!("[cc={}]", func.complexity).yellow())
} else {
String::new()
};
println!(
" {} {}({}{}) — {} lines{}",
kind.green(),
func.name,
params.join(", "),
ellipsis,
func.line_count(),
complexity_note,
);
}
if fi.functions.len() > 5 {
println!(
" {} and {} more ...",
"~".dimmed(),
fi.functions.len() - 5
);
}
println!();
}
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("...{}", &s[s.len().saturating_sub(max - 3)..])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::FileInfo;
use std::path::PathBuf;
#[test]
fn test_iterative_tree_building_deep() {
let mut tree = BTreeMap::new();
let mut path_parts = Vec::new();
for i in 0..26 {
path_parts.push(Box::leak(format!("{}", (b'a' + i) as char).into_boxed_str()) as &str);
}
let info = FileInfo::new(
PathBuf::from("a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/file.rs"),
10,
10,
0,
0,
false,
None,
);
insert_into_tree(&mut tree, &path_parts, &info);
let mut node = &tree["a"];
for i in 1..26 {
match node {
TreeNode::Dir(children) => {
node = &children[path_parts[i]];
}
_ => panic!("Expected directory at depth {}", i),
}
}
match node {
TreeNode::File(fi) => assert_eq!(fi.lines, 10),
_ => panic!("Expected file at the leaf"),
}
}
}
pub fn display_agent_tsv(
result: &ScanResult,
root: &Path,
show_details: bool,
show_tree: bool,
show_functions: bool,
warn_size: Option<usize>,
) {
let stdout = std::io::stdout();
let mut w = std::io::BufWriter::new(stdout.lock());
writeln!(w, "# SUMMARY").ok();
writeln!(w, "metric\tvalue").ok();
writeln!(w, "total_lines\t{}", result.total_lines()).ok();
writeln!(w, "total_code\t{}", result.total_code()).ok();
writeln!(w, "total_comment\t{}", result.total_comment()).ok();
writeln!(w, "total_blank\t{}", result.total_blank()).ok();
writeln!(w, "text_files\t{}", result.text_file_count()).ok();
writeln!(w, "binary_files\t{}", result.binary_file_count()).ok();
writeln!(w, "lockfiles\t{}", result.lockfile_count()).ok();
writeln!(w, "total_functions\t{}", result.total_functions()).ok();
writeln!(w, "total_classes\t{}", result.total_classes()).ok();
writeln!(w, "scan_dir\t{}", root.display()).ok();
if let Some(ws) = warn_size {
let large = result.files.iter().filter(|f| f.lines > ws).count();
writeln!(w, "large_files_over_{}\t{}", ws, large).ok();
}
if show_details {
writeln!(w).ok();
writeln!(w, "# BREAKDOWN").ok();
if show_functions {
writeln!(w, "extension\tfiles\tlines\tcode\tcomment\tblank\tfunctions\tpct_lines").ok();
} else {
writeln!(w, "extension\tfiles\tlines\tcode\tcomment\tblank\tpct_lines").ok();
}
let total_lines = result.total_lines();
let mut entries: Vec<_> = result.breakdown.iter().collect();
entries.sort_by(|a, b| b.1.lines.cmp(&a.1.lines));
for (ext, stats) in &entries {
let pct = if total_lines > 0 {
format!("{:.2}%", stats.lines as f64 / total_lines as f64 * 100.0)
} else {
"0.00%".to_string()
};
if show_functions {
writeln!(
w,
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
ext, stats.files, stats.lines, stats.code,
stats.comment, stats.blank, stats.functions, pct
).ok();
} else {
writeln!(
w,
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
ext, stats.files, stats.lines, stats.code,
stats.comment, stats.blank, pct
).ok();
}
}
}
if show_tree {
writeln!(w).ok();
writeln!(w, "# FILES").ok();
if show_functions {
writeln!(
w,
"path\tlines\tcode\tcomment\tblank\textension\t\
is_binary\tis_lockfile\tfunctions\tclasses\tavg_fn_length\tlast_modified"
).ok();
} else {
writeln!(
w,
"path\tlines\tcode\tcomment\tblank\textension\t\
is_binary\tis_lockfile\tlast_modified"
).ok();
}
for fi in &result.files {
let rel = fi
.path
.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| fi.path.display().to_string());
let modified = fi.last_modified.map(|d| d.to_rfc3339()).unwrap_or_default();
if show_functions {
writeln!(
w,
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{:.2}\t{}",
rel, fi.lines, fi.code, fi.comment, fi.blank,
fi.extension(), fi.is_binary, fi.is_lockfile,
fi.function_count(), fi.class_count(),
fi.avg_function_length(), modified,
).ok();
} else {
writeln!(
w,
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
rel, fi.lines, fi.code, fi.comment, fi.blank,
fi.extension(), fi.is_binary, fi.is_lockfile, modified,
).ok();
}
}
}
}
pub fn display_agent_function_analysis(result: &ScanResult, root: &Path) {
let stdout = std::io::stdout();
let mut w = std::io::BufWriter::new(stdout.lock());
let files_with_fns: Vec<_> = result
.files
.iter()
.filter(|f| f.function_count() > 0)
.collect();
writeln!(w).ok();
writeln!(w, "# FUNCTION_STATS").ok();
writeln!(w, "metric\tvalue").ok();
writeln!(w, "total_functions\t{}", result.total_functions()).ok();
writeln!(w, "total_classes\t{}", result.total_classes()).ok();
let non_class_fns: Vec<_> = files_with_fns
.iter()
.flat_map(|f| f.functions.iter().filter(|fn_| !fn_.is_class))
.collect();
let avg_len = if non_class_fns.is_empty() {
0.0_f64
} else {
non_class_fns.iter().map(|f| f.line_count()).sum::<usize>() as f64
/ non_class_fns.len() as f64
};
writeln!(w, "avg_function_length\t{:.2}", avg_len).ok();
if files_with_fns.is_empty() {
return;
}
let mut all_fns: Vec<(&Path, &crate::models::FunctionInfo)> = files_with_fns
.iter()
.flat_map(|fi| {
fi.functions
.iter()
.filter(|f| !f.is_class)
.map(move |f| (fi.path.as_path(), f))
})
.collect();
all_fns.sort_by(|a, b| b.1.line_count().cmp(&a.1.line_count()));
writeln!(w).ok();
writeln!(w, "# LARGEST_FUNCTIONS").ok();
writeln!(w, "function\tfile\tlines\tcomplexity\tparams").ok();
for (path, func) in all_fns.iter().take(10) {
let rel = path
.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| path.display().to_string());
writeln!(
w,
"{}\t{}\t{}\t{}\t{}",
func.name,
rel,
func.line_count(),
func.complexity,
func.parameters.join(", "),
).ok();
}
let mut complex: Vec<_> = files_with_fns
.iter()
.flat_map(|fi| {
fi.functions
.iter()
.filter(|f| !f.is_class && f.complexity > 10)
.map(move |f| (fi.path.as_path(), f))
})
.collect();
if !complex.is_empty() {
complex.sort_by(|a, b| b.1.complexity.cmp(&a.1.complexity));
writeln!(w).ok();
writeln!(w, "# HIGH_COMPLEXITY").ok();
writeln!(w, "function\tfile\tcomplexity").ok();
for (path, func) in complex.iter().take(15) {
let rel = path
.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| path.display().to_string());
writeln!(w, "{}\t{}\t{}", func.name, rel, func.complexity).ok();
}
}
let mut sorted_files = files_with_fns.clone();
sorted_files.sort_by_key(|f| std::cmp::Reverse(f.function_count()));
writeln!(w).ok();
writeln!(w, "# TOP_FILES").ok();
writeln!(w, "file\tfunctions\tclasses\tavg_fn_length").ok();
for fi in sorted_files.iter().take(10) {
let rel = fi
.path
.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| fi.path.display().to_string());
writeln!(
w,
"{}\t{}\t{}\t{:.2}",
rel,
fi.function_count(),
fi.class_count(),
fi.avg_function_length(),
).ok();
}
}
pub fn display_quiet(result: &ScanResult, root: &Path) {
for fi in &result.files {
let rel = fi
.path
.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| fi.path.display().to_string());
println!("{}", rel);
}
}