use anyhow::{Context, Result};
use console::style;
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::run::detect_project_type;
pub struct MetricsOptions {
pub churn: bool,
pub tests: bool,
pub json: bool,
pub limit: usize,
}
#[derive(Debug, Serialize)]
struct MetricsReport {
project_type: String,
summary: LocSummary,
languages: Vec<LanguageStats>,
#[serde(skip_serializing_if = "Option::is_none")]
churn: Option<Vec<ChurnEntry>>,
#[serde(skip_serializing_if = "Option::is_none")]
tests: Option<TestStats>,
}
#[derive(Debug, Serialize)]
struct LocSummary {
files: usize,
lines: usize,
code: usize,
blank: usize,
comment: usize,
}
#[derive(Debug, Serialize)]
struct LanguageStats {
language: String,
files: usize,
lines: usize,
code: usize,
blank: usize,
comment: usize,
}
#[derive(Debug, Serialize)]
struct ChurnEntry {
file: String,
commits: usize,
}
#[derive(Debug, Serialize)]
struct TestStats {
test_files: usize,
source_files: usize,
ratio: f64,
test_paths: Vec<String>,
}
pub fn run(opts: MetricsOptions) -> Result<()> {
let project_dir = std::env::current_dir().context("getting current directory")?;
let project_type = detect_project_type(&project_dir);
if opts.churn {
return run_churn(&project_dir, opts.limit, opts.json);
}
if opts.tests {
return run_tests(&project_dir, project_type, opts.json);
}
let file_entries = collect_files(&project_dir)?;
let mut lang_map: HashMap<String, LanguageStats> = HashMap::new();
for entry in &file_entries {
let lang = detect_language(&entry.path);
if lang == "unknown" {
continue;
}
let counts = count_lines(&entry.path, &lang);
let stats = lang_map
.entry(lang.clone())
.or_insert_with(|| LanguageStats {
language: lang,
files: 0,
lines: 0,
code: 0,
blank: 0,
comment: 0,
});
stats.files += 1;
stats.lines += counts.lines;
stats.code += counts.code;
stats.blank += counts.blank;
stats.comment += counts.comment;
}
let mut languages: Vec<LanguageStats> = lang_map.into_values().collect();
languages.sort_by_key(|b| std::cmp::Reverse(b.code));
let summary = LocSummary {
files: languages.iter().map(|l| l.files).sum(),
lines: languages.iter().map(|l| l.lines).sum(),
code: languages.iter().map(|l| l.code).sum(),
blank: languages.iter().map(|l| l.blank).sum(),
comment: languages.iter().map(|l| l.comment).sum(),
};
if opts.json {
let report = MetricsReport {
project_type: project_type.to_string(),
summary,
languages,
churn: None,
tests: None,
};
println!("{}", serde_json::to_string_pretty(&report)?);
return Ok(());
}
println!("\n{}\n", style("fledge metrics").bold());
println!(
" {} {}\n",
style("Project type:").dim(),
style(project_type).cyan()
);
println!(" {}", style("Lines of Code").bold());
println!(
" {:<16} {:>8} {:>8} {:>8} {:>8} {:>8}",
style("Language").underlined(),
style("Files").underlined(),
style("Lines").underlined(),
style("Code").underlined(),
style("Blank").underlined(),
style("Comment").underlined(),
);
for lang in &languages {
println!(
" {:<16} {:>8} {:>8} {:>8} {:>8} {:>8}",
style(&lang.language).cyan(),
lang.files,
lang.lines,
style(lang.code).green(),
lang.blank,
lang.comment,
);
}
println!(
" {:<16} {:>8} {:>8} {:>8} {:>8} {:>8}",
style("Total").bold(),
summary.files,
summary.lines,
style(summary.code).green().bold(),
summary.blank,
summary.comment,
);
println!();
Ok(())
}
fn run_churn(project_dir: &Path, limit: usize, json: bool) -> Result<()> {
let output = Command::new("git")
.args(["log", "--format=", "--name-only", "--diff-filter=AMRC"])
.current_dir(project_dir)
.output()
.context("running git log for churn analysis")?;
if !output.status.success() {
anyhow::bail!("git log failed — are you in a git repository?");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut file_counts: HashMap<String, usize> = HashMap::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
*file_counts.entry(line.to_string()).or_default() += 1;
}
let mut entries: Vec<ChurnEntry> = file_counts
.into_iter()
.filter(|(path, _)| Path::new(path).exists())
.map(|(file, commits)| ChurnEntry { file, commits })
.collect();
entries.sort_by_key(|b| std::cmp::Reverse(b.commits));
entries.truncate(limit);
if json {
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(());
}
println!("\n{}\n", style("fledge metrics --churn").bold());
println!(
" {:<8} {}",
style("Commits").underlined(),
style("File").underlined(),
);
for entry in &entries {
println!(
" {:<8} {}",
style(entry.commits).yellow(),
style(&entry.file).cyan(),
);
}
println!();
Ok(())
}
fn run_tests(project_dir: &Path, project_type: &str, json: bool) -> Result<()> {
let file_entries = collect_files(project_dir)?;
let mut test_paths = Vec::new();
let mut source_count = 0usize;
for entry in &file_entries {
let lang = detect_language(&entry.path);
if lang == "unknown" {
continue;
}
if is_test_file(&entry.rel_path, project_type) {
test_paths.push(entry.rel_path.clone());
} else {
source_count += 1;
}
}
let ratio = if source_count > 0 {
test_paths.len() as f64 / source_count as f64
} else {
0.0
};
test_paths.sort();
let stats = TestStats {
test_files: test_paths.len(),
source_files: source_count,
ratio,
test_paths: test_paths.clone(),
};
if json {
println!("{}", serde_json::to_string_pretty(&stats)?);
return Ok(());
}
println!("\n{}\n", style("fledge metrics --tests").bold());
println!(
" {} {}",
style("Test files:").dim(),
style(stats.test_files).green(),
);
println!(
" {} {}",
style("Source files:").dim(),
style(stats.source_files).cyan(),
);
println!(
" {} {:.1}%",
style("Test ratio:").dim(),
style(stats.ratio * 100.0).yellow(),
);
if !test_paths.is_empty() {
println!("\n {}", style("Test files:").bold());
for path in &test_paths {
println!(" {}", style(path).dim());
}
}
println!();
Ok(())
}
struct FileEntry {
path: PathBuf,
rel_path: String,
}
fn collect_files(project_dir: &Path) -> Result<Vec<FileEntry>> {
let mut entries = Vec::new();
for result in walkdir::WalkDir::new(project_dir)
.into_iter()
.filter_entry(|e| !is_ignored_dir(e.file_name().to_str().unwrap_or("")))
{
let entry = result?;
if !entry.file_type().is_file() {
continue;
}
let rel = entry
.path()
.strip_prefix(project_dir)
.unwrap_or(entry.path());
entries.push(FileEntry {
path: entry.path().to_path_buf(),
rel_path: rel.to_string_lossy().to_string(),
});
}
Ok(entries)
}
fn is_ignored_dir(name: &str) -> bool {
matches!(
name,
".git"
| "node_modules"
| "target"
| "vendor"
| "dist"
| "build"
| ".next"
| "__pycache__"
| ".tox"
| "venv"
| ".venv"
| "deps"
| "_build"
| ".gradle"
| ".idea"
| ".vscode"
)
}
fn detect_language(path: &Path) -> String {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
match ext {
"rs" => "Rust",
"ts" | "tsx" => "TypeScript",
"js" | "jsx" | "mjs" | "cjs" => "JavaScript",
"py" => "Python",
"go" => "Go",
"rb" => "Ruby",
"java" => "Java",
"kt" | "kts" => "Kotlin",
"swift" => "Swift",
"c" => "C",
"cpp" | "cc" | "cxx" => "C++",
"h" | "hpp" => "C/C++ Header",
"cs" => "C#",
"html" | "htm" => "HTML",
"css" => "CSS",
"scss" | "sass" => "SCSS",
"json" => "JSON",
"yaml" | "yml" => "YAML",
"toml" => "TOML",
"md" => "Markdown",
"sh" | "bash" | "zsh" => "Shell",
"sql" => "SQL",
"ex" | "exs" => "Elixir",
"erl" | "hrl" => "Erlang",
"zig" => "Zig",
"lua" => "Lua",
"r" | "R" => "R",
"dart" => "Dart",
"vue" => "Vue",
"svelte" => "Svelte",
_ => "unknown",
}
.to_string()
}
struct LineCounts {
lines: usize,
code: usize,
blank: usize,
comment: usize,
}
fn count_lines(path: &Path, language: &str) -> LineCounts {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
eprintln!(
" {} skipping {}: {}",
console::style("⚠").yellow().bold(),
path.display(),
e
);
return LineCounts {
lines: 0,
code: 0,
blank: 0,
comment: 0,
};
}
};
let comment_prefix = match language {
"Rust" | "TypeScript" | "JavaScript" | "Go" | "Java" | "Kotlin" | "Swift" | "C" | "C++"
| "C/C++ Header" | "C#" | "Dart" | "Zig" => "//",
"Python" | "Ruby" | "Shell" | "Elixir" | "R" | "YAML" | "TOML" => "#",
"Lua" | "SQL" => "--",
"Erlang" => "%",
"HTML" | "CSS" | "SCSS" | "Markdown" | "JSON" | "Vue" | "Svelte" => "",
_ => "",
};
let mut lines = 0usize;
let mut code = 0usize;
let mut blank = 0usize;
let mut comment = 0usize;
for line in content.lines() {
lines += 1;
let trimmed = line.trim();
if trimmed.is_empty() {
blank += 1;
} else if !comment_prefix.is_empty() && trimmed.starts_with(comment_prefix) {
comment += 1;
} else {
code += 1;
}
}
LineCounts {
lines,
code,
blank,
comment,
}
}
fn is_test_file(rel_path: &str, project_type: &str) -> bool {
let path = Path::new(rel_path);
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let file_stem = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
if rel_path.contains("/test/")
|| rel_path.contains("/tests/")
|| rel_path.contains("/__tests__/")
|| rel_path.starts_with("test/")
|| rel_path.starts_with("tests/")
|| rel_path.starts_with("__tests__/")
{
return true;
}
match project_type {
"rust" => file_name.ends_with("_test.rs") || rel_path.contains("/tests/"),
"node" => {
file_name.contains(".test.")
|| file_name.contains(".spec.")
|| file_stem.ends_with("_test")
}
"go" => file_name.ends_with("_test.go"),
"python" => file_name.starts_with("test_") || file_name.ends_with("_test.py"),
"ruby" => file_name.ends_with("_test.rb") || file_name.ends_with("_spec.rb"),
"java" | "gradle" | "maven" => {
file_name.ends_with("Test.java") || file_name.ends_with("Tests.java")
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_language_rust() {
assert_eq!(detect_language(Path::new("src/main.rs")), "Rust");
}
#[test]
fn detect_language_typescript() {
assert_eq!(detect_language(Path::new("src/app.ts")), "TypeScript");
assert_eq!(detect_language(Path::new("src/app.tsx")), "TypeScript");
}
#[test]
fn detect_language_unknown() {
assert_eq!(detect_language(Path::new("file.xyz")), "unknown");
}
#[test]
fn count_lines_basic() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.rs");
std::fs::write(
&file,
"// comment\nfn main() {\n println!(\"hi\");\n}\n\n",
)
.unwrap();
let counts = count_lines(&file, "Rust");
assert_eq!(counts.lines, 5);
assert_eq!(counts.code, 3);
assert_eq!(counts.blank, 1);
assert_eq!(counts.comment, 1);
}
#[test]
fn count_lines_python_comments() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.py");
std::fs::write(&file, "# comment\ndef foo():\n pass\n").unwrap();
let counts = count_lines(&file, "Python");
assert_eq!(counts.code, 2);
assert_eq!(counts.comment, 1);
}
#[test]
fn count_lines_empty_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("empty.rs");
std::fs::write(&file, "").unwrap();
let counts = count_lines(&file, "Rust");
assert_eq!(counts.lines, 0);
assert_eq!(counts.code, 0);
}
#[test]
fn count_lines_nonexistent_file() {
let counts = count_lines(Path::new("/nonexistent/file.rs"), "Rust");
assert_eq!(counts.lines, 0);
}
#[test]
fn is_test_file_rust() {
assert!(!is_test_file("src/main.rs", "rust"));
assert!(is_test_file("tests/integration.rs", "rust"));
assert!(is_test_file("src/foo_test.rs", "rust"));
}
#[test]
fn is_test_file_node() {
assert!(is_test_file("src/app.test.ts", "node"));
assert!(is_test_file("src/app.spec.js", "node"));
assert!(is_test_file("__tests__/app.ts", "node"));
assert!(!is_test_file("src/app.ts", "node"));
}
#[test]
fn is_test_file_go() {
assert!(is_test_file("pkg/foo_test.go", "go"));
assert!(!is_test_file("pkg/foo.go", "go"));
}
#[test]
fn is_test_file_python() {
assert!(is_test_file("test_foo.py", "python"));
assert!(is_test_file("foo_test.py", "python"));
assert!(is_test_file("tests/test_bar.py", "python"));
assert!(!is_test_file("foo.py", "python"));
}
#[test]
fn is_test_file_generic_dirs() {
assert!(is_test_file("test/something.rs", "rust"));
assert!(is_test_file("tests/something.go", "go"));
}
#[test]
fn is_ignored_dir_common() {
assert!(is_ignored_dir("node_modules"));
assert!(is_ignored_dir("target"));
assert!(is_ignored_dir(".git"));
assert!(!is_ignored_dir("src"));
}
#[test]
fn collect_files_tempdir() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
std::fs::write(dir.path().join("sub/lib.rs"), "pub fn foo() {}").unwrap();
let entries = collect_files(dir.path()).unwrap();
assert_eq!(entries.len(), 2);
}
#[test]
fn collect_files_skips_ignored() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
std::fs::create_dir(dir.path().join("target")).unwrap();
std::fs::write(dir.path().join("target/debug.rs"), "").unwrap();
let entries = collect_files(dir.path()).unwrap();
assert_eq!(entries.len(), 1);
}
}