use crate::comment::TodoComment;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoAnalytics {
pub total_todos: usize,
pub files_with_todos: usize,
pub total_files_scanned: usize,
pub keyword_counts: HashMap<String, usize>,
pub language_stats: HashMap<String, LanguageStats>,
pub top_files: Vec<FileStats>,
pub scan_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LanguageStats {
pub todo_count: usize,
pub file_count: usize,
pub extensions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileStats {
pub path: PathBuf,
pub todo_count: usize,
}
impl TodoAnalytics {
pub fn new(scan_path: PathBuf) -> Self {
Self {
total_todos: 0,
files_with_todos: 0,
total_files_scanned: 0,
keyword_counts: HashMap::new(),
language_stats: HashMap::new(),
top_files: Vec::new(),
scan_path,
}
}
pub fn todo_density(&self) -> f64 {
if self.total_files_scanned == 0 {
0.0
} else {
(self.files_with_todos as f64 / self.total_files_scanned as f64) * 100.0
}
}
}
pub fn analyze_todos(
file_todos: Vec<(PathBuf, Vec<TodoComment>)>,
total_files_scanned: usize,
scan_path: PathBuf,
display_limit: Option<usize>,
keywords: &[String],
) -> TodoAnalytics {
let mut analytics = TodoAnalytics::new(scan_path.clone());
analytics.total_files_scanned = total_files_scanned;
let mut file_stats: Vec<FileStats> = Vec::new();
for (file_path, todos) in file_todos {
if !todos.is_empty() {
analytics.files_with_todos += 1;
analytics.total_todos += todos.len();
let relative_path = if scan_path.is_file() && file_path == scan_path {
scan_path
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| file_path.clone())
} else if let Ok(relative) = file_path.strip_prefix(&scan_path) {
relative.to_path_buf()
} else {
file_path.clone()
};
file_stats.push(FileStats {
path: relative_path,
todo_count: todos.len(),
});
for todo in &todos {
let keyword = extract_keyword_from_text(&todo.text, keywords);
*analytics.keyword_counts.entry(keyword).or_insert(0) += 1;
}
let language = detect_language(&file_path);
let extension = file_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_string();
let lang_stats =
analytics
.language_stats
.entry(language.clone())
.or_insert(LanguageStats {
todo_count: 0,
file_count: 0,
extensions: Vec::new(),
});
lang_stats.todo_count += todos.len();
lang_stats.file_count += 1;
if !extension.is_empty() && !lang_stats.extensions.contains(&format!(".{}", extension))
{
lang_stats.extensions.push(format!(".{}", extension));
}
}
}
file_stats.sort_by(|a, b| b.todo_count.cmp(&a.todo_count));
analytics.top_files = match display_limit {
Some(limit) => file_stats.into_iter().take(limit).collect(),
None => file_stats,
};
analytics
}
fn extract_keyword_from_text(text: &str, keywords: &[String]) -> String {
let text_upper = text.to_uppercase();
for keyword in keywords {
let keyword_upper = keyword.to_uppercase();
if text_upper.starts_with(&keyword_upper) {
return keyword_upper;
}
}
for keyword in keywords {
let keyword_upper = keyword.to_uppercase();
if text_upper.contains(&keyword_upper) {
return keyword_upper;
}
}
"UNKNOWN".to_string()
}
fn detect_language(path: &Path) -> String {
if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
match extension {
"rs" => "Rust".to_string(),
"py" => "Python".to_string(),
"js" | "mjs" => "JavaScript".to_string(),
"ts" => "TypeScript".to_string(),
"jsx" => "React JSX".to_string(),
"tsx" => "React TSX".to_string(),
"c" => "C".to_string(),
"cpp" | "cc" | "cxx" => "C++".to_string(),
"h" | "hpp" => "C/C++ Header".to_string(),
"java" => "Java".to_string(),
"go" => "Go".to_string(),
"cs" => "C#".to_string(),
"sh" | "bash" => "Shell".to_string(),
"zsh" => "Zsh".to_string(),
"fish" => "Fish".to_string(),
"rb" => "Ruby".to_string(),
"pl" => "Perl".to_string(),
"yaml" | "yml" => "YAML".to_string(),
"toml" => "TOML".to_string(),
"nix" => "Nix".to_string(),
"html" | "htm" => "HTML".to_string(),
"xml" => "XML".to_string(),
"svg" => "SVG".to_string(),
_ => format!("Unknown (.{})", extension),
}
} else {
"No Extension".to_string()
}
}