use crate::analytics::TodoAnalytics;
use tabled::{
Table, Tabled,
settings::{Alignment, Style},
};
const DISTRIBUTION_SCALE: f64 = 5.0; const DISTRIBUTION_WIDTH: usize = 20;
#[derive(Tabled)]
struct SummaryRow {
#[tabled(rename = "Metric")]
metric: String,
#[tabled(rename = "Value")]
value: String,
}
#[derive(Tabled)]
struct KeywordRow {
#[tabled(rename = "Keyword")]
keyword: String,
#[tabled(rename = "Count")]
count: usize,
#[tabled(rename = "Percentage")]
percentage: String,
#[tabled(rename = "Distribution")]
distribution: String,
}
#[derive(Tabled)]
struct LanguageRow {
#[tabled(rename = "Language")]
language: String,
#[tabled(rename = "TODOs")]
todo_count: usize,
#[tabled(rename = "Files")]
file_count: usize,
#[tabled(rename = "Extensions")]
extensions: String,
}
#[derive(Tabled)]
struct FileRow {
#[tabled(rename = "File")]
file: String,
#[tabled(rename = "TODO Count")]
todo_count: usize,
}
pub fn print_table_report(analytics: &TodoAnalytics) {
print_header(analytics);
print_summary_table(analytics);
print_keyword_table(analytics);
print_language_table(analytics);
print_file_table(analytics);
print_footer();
}
fn print_header(analytics: &TodoAnalytics) {
println!(
"TODO Report for {}",
get_relative_path(&analytics.scan_path, &analytics.scan_path)
);
println!(
"Generated: {}",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
);
println!();
}
fn print_summary_table(analytics: &TodoAnalytics) {
let summary_data = vec![
SummaryRow {
metric: "Total TODO Comments".to_string(),
value: analytics.total_todos.to_string(),
},
SummaryRow {
metric: "Files with TODOs".to_string(),
value: analytics.files_with_todos.to_string(),
},
SummaryRow {
metric: "Total Files Scanned".to_string(),
value: analytics.total_files_scanned.to_string(),
},
SummaryRow {
metric: "TODO Density".to_string(),
value: format!("{:.1}%", analytics.todo_density()),
},
];
println!("{}", create_styled_table(summary_data));
println!();
}
fn print_keyword_table(analytics: &TodoAnalytics) {
if analytics.keyword_counts.is_empty() {
return;
}
let mut keywords: Vec<_> = analytics.keyword_counts.iter().collect();
keywords.sort_by(|a, b| b.1.cmp(a.1));
let keyword_data: Vec<KeywordRow> = keywords
.iter()
.map(|(keyword, count)| {
let percentage = if analytics.total_todos > 0 {
(**count as f64 / analytics.total_todos as f64) * 100.0
} else {
0.0
};
let bar_count = (percentage / DISTRIBUTION_SCALE) as usize;
let distribution_bars = "█".repeat(bar_count);
KeywordRow {
keyword: keyword.to_string(),
count: **count,
percentage: format!("{:.1}%", percentage),
distribution: format!("{:<width$}", distribution_bars, width = DISTRIBUTION_WIDTH),
}
})
.collect();
println!("{}", create_styled_table(keyword_data));
println!();
}
fn print_language_table(analytics: &TodoAnalytics) {
if analytics.language_stats.is_empty() {
return;
}
let mut languages: Vec<_> = analytics.language_stats.iter().collect();
languages.sort_by(|a, b| b.1.todo_count.cmp(&a.1.todo_count));
let language_data: Vec<LanguageRow> = languages
.iter()
.map(|(language, stats)| LanguageRow {
language: language.to_string(),
todo_count: stats.todo_count,
file_count: stats.file_count,
extensions: stats.extensions.join(", "),
})
.collect();
println!("{}", create_styled_table(language_data));
println!();
}
fn print_file_table(analytics: &TodoAnalytics) {
if analytics.top_files.is_empty() {
return;
}
let file_data: Vec<FileRow> = analytics
.top_files
.iter()
.map(|file_stat| FileRow {
file: get_relative_path(&file_stat.path, &analytics.scan_path),
todo_count: file_stat.todo_count,
})
.collect();
println!("{}", create_styled_table(file_data));
if analytics.files_with_todos > analytics.top_files.len() {
let omitted = analytics.files_with_todos - analytics.top_files.len();
println!("({} omitted, use --full-report to show all)", omitted);
}
}
fn print_footer() {
println!();
println!("Use 'nolo scan' to see detailed TODO list.");
}
fn create_styled_table<T: Tabled>(data: Vec<T>) -> String {
Table::new(data)
.with(Style::modern())
.with(Alignment::left())
.to_string()
}
pub fn get_relative_path(file_path: &std::path::Path, scan_path: &std::path::Path) -> String {
if scan_path.is_file() && file_path == scan_path {
return scan_path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| file_path.display().to_string());
}
if let Ok(relative) = file_path.strip_prefix(scan_path) {
let relative_str = relative.display().to_string();
if relative_str.is_empty() {
".".to_string()
} else {
relative_str
}
} else {
file_path.display().to_string()
}
}