use anyhow::Result;
use std::collections::HashMap;
use std::path::Path;
type GitChurnData = (HashMap<String, usize>, Vec<Vec<String>>, usize);
const MAX_LOOKBACK_DAYS: u32 = 20_000;
fn since_arg(period: u32) -> Option<String> {
(period <= MAX_LOOKBACK_DAYS).then(|| format!("--since={period} days ago"))
}
fn git_log_args(period: u32, extra: &[&str]) -> Vec<String> {
let mut args = vec!["log".to_string()];
args.extend(since_arg(period));
args.extend(extra.iter().map(|s| (*s).to_string()));
args
}
#[derive(Debug, serde::Serialize)]
struct BottleneckFile {
path: String,
touches: usize,
authors: usize,
lines: usize,
churn_ratio: f64,
pattern: String,
recommendation: String,
}
#[derive(Debug, serde::Serialize)]
struct CouplingPair {
file_a: String,
file_b: String,
co_changes: usize,
}
#[derive(Debug, serde::Serialize)]
struct BottleneckAnalysis {
period_days: u32,
total_commits: usize,
total_files_changed: usize,
bottlenecks: Vec<BottleneckFile>,
couplings: Vec<CouplingPair>,
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn handle_bottleneck(
path: &Path,
format: &crate::cli::enums::OutputFormat,
period: u32,
threshold: usize,
output: Option<&Path>,
) -> Result<()> {
use crate::cli::colors as c;
crate::status_eprintln!(
"{}",
c::dim(&format!("Analyzing git churn for last {} days...", period))
);
let analysis = analyze_bottlenecks(path, period, threshold)?;
let formatted = format_analysis(&analysis, format)?;
if let Some(output_path) = output {
std::fs::write(output_path, &formatted)?;
crate::status_eprintln!(
"{} Written to: {}",
c::pass(""),
c::path(&output_path.display().to_string())
);
} else {
println!("{formatted}");
}
Ok(())
}
fn analyze_bottlenecks(path: &Path, period: u32, threshold: usize) -> Result<BottleneckAnalysis> {
let (file_touches, commit_files, total_commits) = get_git_churn(path, period)?;
let file_sizes = get_file_sizes(path, &file_touches)?;
let file_authors = get_file_authors(path, period, &file_touches)?;
let mut bottlenecks: Vec<BottleneckFile> = file_touches
.iter()
.filter(|(_, &count)| count >= threshold)
.filter(|(path, _)| !is_generated_file(path))
.filter(|(file_path, _)| file_sizes.contains_key(file_path.as_str()))
.map(|(file_path, &touches)| {
let lines = file_sizes.get(file_path.as_str()).copied().unwrap_or(0);
let authors = file_authors.get(file_path.as_str()).copied().unwrap_or(1);
let churn_ratio = if lines > 0 {
touches as f64 / (lines as f64 / 100.0)
} else {
touches as f64
};
let pattern = classify_pattern(file_path, touches, lines);
let recommendation = get_recommendation(&pattern);
BottleneckFile {
path: file_path.clone(),
touches,
authors,
lines,
churn_ratio,
pattern,
recommendation,
}
})
.collect();
bottlenecks.sort_by(|a, b| b.touches.cmp(&a.touches).then_with(|| a.path.cmp(&b.path)));
bottlenecks.truncate(20);
let couplings = detect_coupling(&commit_files, threshold);
Ok(BottleneckAnalysis {
period_days: period,
total_commits,
total_files_changed: file_touches.len(),
bottlenecks,
couplings,
})
}
fn get_git_churn(path: &Path, period: u32) -> Result<GitChurnData> {
let output = std::process::Command::new("git")
.args(git_log_args(
period,
&["--name-only", "--pretty=format:COMMIT_SEPARATOR"],
))
.current_dir(path)
.output()?;
if !output.status.success() {
anyhow::bail!(
"git log failed in {}: {}",
path.display(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut file_touches: HashMap<String, usize> = HashMap::new();
let mut commit_files: Vec<Vec<String>> = Vec::new();
let mut current_files: Vec<String> = Vec::new();
let mut total_commits = 0;
for line in stdout.lines() {
let line = line.trim();
if line == "COMMIT_SEPARATOR" {
if !current_files.is_empty() {
commit_files.push(current_files.clone());
current_files.clear();
}
total_commits += 1;
} else if !line.is_empty() {
*file_touches.entry(line.to_string()).or_default() += 1;
current_files.push(line.to_string());
}
}
if !current_files.is_empty() {
commit_files.push(current_files);
}
Ok((file_touches, commit_files, total_commits))
}
fn get_file_sizes(path: &Path, files: &HashMap<String, usize>) -> Result<HashMap<String, usize>> {
let mut sizes = HashMap::new();
for file_path in files.keys() {
let full_path = path.join(file_path);
if full_path.exists() {
if let Ok(content) = std::fs::read_to_string(&full_path) {
sizes.insert(file_path.clone(), content.lines().count());
}
}
}
Ok(sizes)
}
fn get_file_authors(
path: &Path,
period: u32,
files: &HashMap<String, usize>,
) -> Result<HashMap<String, usize>> {
let mut author_map: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
let output = std::process::Command::new("git")
.args(git_log_args(period, &["--format=%H %an", "--name-only"]))
.current_dir(path)
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut current_author = String::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if line.len() > 41 && line.chars().nth(40) == Some(' ') {
current_author = line[41..].to_string();
} else if !current_author.is_empty() && files.contains_key(line) {
author_map
.entry(line.to_string())
.or_default()
.insert(current_author.clone());
}
}
Ok(author_map.into_iter().map(|(k, v)| (k, v.len())).collect())
}
fn is_generated_file(path: &str) -> bool {
path.contains(".pmat/")
|| path.ends_with("Cargo.lock")
|| path.ends_with(".pmat/baseline.json")
|| path.contains("target/")
|| path.ends_with(".json") && path.contains("cache")
}
fn classify_pattern(path: &str, touches: usize, lines: usize) -> String {
let filename = path.rsplit('/').next().unwrap_or(path);
if filename == "mod.rs" || filename.contains("registry") || filename.contains("dispatch") {
return "Registry/Dispatch".to_string();
}
if filename == "Cargo.toml" || filename == "Cargo.lock" {
return "Dependency Config".to_string();
}
if path.contains("workflows/") || path.contains(".github/") {
return "CI/CD Config".to_string();
}
if filename.contains("test") {
return "Test Churn".to_string();
}
if path.contains("roadmap") || path.contains("docs/") {
return "Documentation".to_string();
}
if lines > 500 && touches > 10 {
return "Monolith".to_string();
}
if touches as f64 / lines.max(1) as f64 * 100.0 > 5.0 {
return "High Churn Ratio".to_string();
}
"Feature Development".to_string()
}
fn get_recommendation(pattern: &str) -> String {
match pattern {
"Registry/Dispatch" => {
"Consider proc-macro auto-discovery (inventory/linkme) to avoid touching this file for every new feature".to_string()
}
"Dependency Config" => {
"Use workspace inheritance or cargo-edit for batch dependency updates".to_string()
}
"CI/CD Config" => {
"Use reusable workflows and test CI changes locally with `pmat ci-local`".to_string()
}
"Monolith" => {
"Split this file into focused submodules with `pmat split --auto`".to_string()
}
"High Churn Ratio" => {
"This file changes too often relative to its size — consider architectural refactoring"
.to_string()
}
_ => String::new(),
}
}
fn detect_coupling(commit_files: &[Vec<String>], min_co_changes: usize) -> Vec<CouplingPair> {
let mut co_changes: HashMap<(String, String), usize> = HashMap::new();
for files in commit_files {
if files.len() < 2 || files.len() > 10 {
continue;
}
for i in 0..files.len() {
for j in (i + 1)..files.len() {
let a = &files[i];
let b = &files[j];
if a == b {
continue;
}
let key = if a < b {
(a.clone(), b.clone())
} else {
(b.clone(), a.clone())
};
*co_changes.entry(key).or_default() += 1;
}
}
}
let mut pairs: Vec<CouplingPair> = co_changes
.into_iter()
.filter(|(_, count)| *count >= min_co_changes)
.filter(|((a, b), _)| !is_generated_file(a) && !is_generated_file(b))
.map(|((a, b), count)| CouplingPair {
file_a: a,
file_b: b,
co_changes: count,
})
.collect();
pairs.sort_by(|x, y| {
y.co_changes
.cmp(&x.co_changes)
.then_with(|| (&x.file_a, &x.file_b).cmp(&(&y.file_a, &y.file_b)))
});
pairs.truncate(15);
pairs
}
fn format_analysis(
analysis: &BottleneckAnalysis,
format: &crate::cli::enums::OutputFormat,
) -> Result<String> {
use crate::cli::enums::OutputFormat as F;
Ok(match format {
F::Json => serde_json::to_string_pretty(analysis)?,
F::Yaml => serde_yaml_ng::to_string(analysis)?,
F::Markdown => format_markdown(analysis),
F::Csv => format_csv(analysis),
F::Junit => format_junit(analysis),
F::Summary => format_summary(analysis),
F::Text | F::Plain => strip_ansi(&format_text(analysis)),
F::Table => format_text(analysis),
})
}
fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(ch) = chars.next() {
if ch == '\u{1b}' {
for esc in chars.by_ref() {
if esc.is_ascii_alphabetic() {
break;
}
}
continue;
}
out.push(ch);
}
out
}
fn csv_field(value: &str) -> String {
if value.contains([',', '"', '\n']) {
format!("\"{}\"", value.replace('"', "\"\""))
} else {
value.to_string()
}
}
fn format_csv(analysis: &BottleneckAnalysis) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(
out,
"path,touches,authors,lines,churn_ratio,pattern,recommendation"
);
for b in &analysis.bottlenecks {
let _ = writeln!(
out,
"{},{},{},{},{:.1},{},{}",
csv_field(&b.path),
b.touches,
b.authors,
b.lines,
b.churn_ratio,
csv_field(&b.pattern),
csv_field(&b.recommendation)
);
}
if !analysis.couplings.is_empty() {
let _ = writeln!(out);
let _ = writeln!(out, "file_a,file_b,co_changes");
for pair in &analysis.couplings {
let _ = writeln!(
out,
"{},{},{}",
csv_field(&pair.file_a),
csv_field(&pair.file_b),
pair.co_changes
);
}
}
out
}
fn format_markdown(analysis: &BottleneckAnalysis) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "# Architectural Bottleneck Analysis\n");
let _ = writeln!(out, "- **Period**: {} days", analysis.period_days);
let _ = writeln!(out, "- **Total commits**: {}", analysis.total_commits);
let _ = writeln!(
out,
"- **Files changed**: {}\n",
analysis.total_files_changed
);
if analysis.bottlenecks.is_empty() {
let _ = writeln!(out, "No bottleneck files detected.\n");
} else {
let _ = writeln!(out, "## Bottleneck Files\n");
let _ = writeln!(
out,
"| File | Touches | Authors | Lines | Churn ratio | Pattern | Recommendation |"
);
let _ = writeln!(out, "|---|---|---|---|---|---|---|");
for b in &analysis.bottlenecks {
let _ = writeln!(
out,
"| `{}` | {} | {} | {} | {:.1} | {} | {} |",
b.path, b.touches, b.authors, b.lines, b.churn_ratio, b.pattern, b.recommendation
);
}
let _ = writeln!(out);
}
if !analysis.couplings.is_empty() {
let _ = writeln!(out, "## Co-Change Coupling\n");
let _ = writeln!(out, "| File A | File B | Co-changes |");
let _ = writeln!(out, "|---|---|---|");
for pair in &analysis.couplings {
let _ = writeln!(
out,
"| `{}` | `{}` | {} |",
pair.file_a, pair.file_b, pair.co_changes
);
}
}
out
}
fn format_summary(analysis: &BottleneckAnalysis) -> String {
format!(
"period_days={}\ncommits={}\nfiles_changed={}\nbottlenecks={}\ncouplings={}\n",
analysis.period_days,
analysis.total_commits,
analysis.total_files_changed,
analysis.bottlenecks.len(),
analysis.couplings.len()
)
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn format_junit(analysis: &BottleneckAnalysis) -> String {
use std::fmt::Write;
let mut out = String::new();
let tests = analysis.bottlenecks.len() + analysis.couplings.len();
let _ = writeln!(out, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
let _ = writeln!(
out,
"<testsuites name=\"Architectural Bottlenecks\" tests=\"{tests}\" failures=\"{tests}\">"
);
let _ = writeln!(
out,
" <testsuite name=\"Bottleneck Files\" tests=\"{}\" failures=\"{}\">",
analysis.bottlenecks.len(),
analysis.bottlenecks.len()
);
for b in &analysis.bottlenecks {
let _ = writeln!(
out,
" <testcase name=\"{}\" classname=\"Bottleneck\">",
xml_escape(&b.path)
);
let _ = writeln!(
out,
" <failure message=\"{} ({} touches, {} lines, churn ratio {:.1})\">{}</failure>",
xml_escape(&b.pattern),
b.touches,
b.lines,
b.churn_ratio,
xml_escape(&b.recommendation)
);
let _ = writeln!(out, " </testcase>");
}
let _ = writeln!(out, " </testsuite>");
let _ = writeln!(
out,
" <testsuite name=\"Co-Change Coupling\" tests=\"{}\" failures=\"{}\">",
analysis.couplings.len(),
analysis.couplings.len()
);
for pair in &analysis.couplings {
let _ = writeln!(
out,
" <testcase name=\"{} <-> {}\" classname=\"Coupling\">",
xml_escape(&pair.file_a),
xml_escape(&pair.file_b)
);
let _ = writeln!(
out,
" <failure message=\"{} co-changes\" />",
pair.co_changes
);
let _ = writeln!(out, " </testcase>");
}
let _ = writeln!(out, " </testsuite>");
let _ = writeln!(out, "</testsuites>");
out
}
fn format_text(analysis: &BottleneckAnalysis) -> String {
use crate::cli::colors as c;
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "{}\n", c::header("Architectural Bottleneck Analysis"));
let _ = writeln!(
out,
" {}Period:{} {} days",
c::BOLD,
c::RESET,
c::number(&analysis.period_days.to_string())
);
let _ = writeln!(
out,
" {}Total commits:{} {}",
c::BOLD,
c::RESET,
c::number(&analysis.total_commits.to_string())
);
let _ = writeln!(
out,
" {}Files changed:{} {}\n",
c::BOLD,
c::RESET,
c::number(&analysis.total_files_changed.to_string())
);
if analysis.bottlenecks.is_empty() {
let _ = writeln!(out, " {}", c::pass("No bottleneck files detected"));
return out;
}
let _ = writeln!(out, "{}\n", c::subheader("Bottleneck Files"));
for (i, b) in analysis.bottlenecks.iter().enumerate() {
let pattern_color = match b.pattern.as_str() {
"Registry/Dispatch" | "Monolith" => c::RED,
"CI/CD Config" | "High Churn Ratio" => c::YELLOW,
_ => c::DIM,
};
let _ = writeln!(
out,
" {}. {} {}({})",
c::number(&(i + 1).to_string()),
c::path(&b.path),
pattern_color,
b.pattern,
);
let _ = writeln!(
out,
"{} {}Touches:{} {} {}Authors:{} {} {}Lines:{} {} {}Churn ratio:{} {:.1}",
c::RESET,
c::BOLD,
c::RESET,
c::number(&b.touches.to_string()),
c::BOLD,
c::RESET,
c::number(&b.authors.to_string()),
c::BOLD,
c::RESET,
c::number(&b.lines.to_string()),
c::BOLD,
c::RESET,
b.churn_ratio,
);
if !b.recommendation.is_empty() {
let _ = writeln!(
out,
" {}Recommendation:{} {}",
c::BOLD,
c::RESET,
b.recommendation
);
}
let _ = writeln!(out);
}
if !analysis.couplings.is_empty() {
let _ = writeln!(out, "{}\n", c::subheader("Co-Change Coupling"));
for pair in &analysis.couplings {
let _ = writeln!(
out,
" {} <-> {} ({} co-changes)",
c::path(&pair.file_a),
c::path(&pair.file_b),
c::number(&pair.co_changes.to_string()),
);
}
}
out
}
#[cfg(test)]
pub(crate) mod quiet_chatter_tests {
pub(crate) fn unguarded_stderr_lines(source: &str) -> Vec<&str> {
let needle = concat!("eprint", "ln!");
source
.lines()
.filter(|line| {
let mut from = 0;
while let Some(i) = line[from..].find(needle) {
let at = from + i;
if !line[..at].ends_with('_') {
return true;
}
from = at + needle.len();
}
false
})
.collect()
}
#[test]
fn churn_banner_obeys_quiet() {
let source = include_str!("bottleneck_handler.rs");
assert!(
source.contains("Analyzing git churn for last"),
"the banner this test pins must still exist"
);
let leaking = unguarded_stderr_lines(source);
assert!(
leaking.is_empty(),
"bottleneck's stderr is chatter only, so every line must be \
suppressible; unguarded: {leaking:?}"
);
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_classify_pattern_registry() {
assert_eq!(
classify_pattern("src/commands/mod.rs", 10, 50),
"Registry/Dispatch"
);
assert_eq!(
classify_pattern("src/registry.rs", 5, 100),
"Registry/Dispatch"
);
assert_eq!(
classify_pattern("src/dispatch.rs", 5, 100),
"Registry/Dispatch"
);
}
#[test]
fn test_classify_pattern_cargo() {
assert_eq!(classify_pattern("Cargo.toml", 10, 50), "Dependency Config");
}
#[test]
fn test_classify_pattern_ci() {
assert_eq!(
classify_pattern(".github/workflows/ci.yml", 7, 100),
"CI/CD Config"
);
}
#[test]
fn test_classify_pattern_monolith() {
assert_eq!(classify_pattern("src/big_file.rs", 12, 800), "Monolith");
}
#[test]
fn test_is_generated_file() {
assert!(is_generated_file(".pmat/baseline.json"));
assert!(is_generated_file("Cargo.lock"));
assert!(!is_generated_file("src/main.rs"));
}
#[test]
fn test_get_recommendation() {
let rec = get_recommendation("Registry/Dispatch");
assert!(rec.contains("proc-macro"));
let rec = get_recommendation("Monolith");
assert!(rec.contains("split"));
}
#[test]
fn test_detect_coupling_empty() {
let pairs = detect_coupling(&[], 3);
assert!(pairs.is_empty());
}
#[test]
fn test_detect_coupling_below_threshold() {
let commits = vec![vec!["a.rs".to_string(), "b.rs".to_string()]];
let pairs = detect_coupling(&commits, 3);
assert!(pairs.is_empty());
}
#[test]
fn test_detect_coupling_above_threshold() {
let commits = vec![
vec!["a.rs".to_string(), "b.rs".to_string()],
vec!["a.rs".to_string(), "b.rs".to_string()],
vec!["a.rs".to_string(), "b.rs".to_string()],
];
let pairs = detect_coupling(&commits, 3);
assert_eq!(pairs.len(), 1);
assert_eq!(pairs[0].co_changes, 3);
}
#[test]
fn test_format_text_empty() {
let analysis = BottleneckAnalysis {
period_days: 14,
total_commits: 0,
total_files_changed: 0,
bottlenecks: vec![],
couplings: vec![],
};
let text = format_text(&analysis);
assert!(text.contains("No bottleneck files detected"));
}
#[tokio::test]
async fn test_handle_bottleneck_runs() {
let result = handle_bottleneck(
Path::new("."),
&crate::cli::enums::OutputFormat::Json,
14,
5,
None,
)
.await;
assert!(result.is_ok());
}
fn sample_analysis() -> BottleneckAnalysis {
BottleneckAnalysis {
period_days: 30,
total_commits: 13,
total_files_changed: 709,
bottlenecks: vec![BottleneckFile {
path: "src/cli/mod.rs".to_string(),
touches: 9,
authors: 2,
lines: 1200,
churn_ratio: 0.75,
pattern: "Registry/Dispatch".to_string(),
recommendation: "Consider proc-macro auto-discovery, e.g. inventory".to_string(),
}],
couplings: vec![CouplingPair {
file_a: "a.rs".to_string(),
file_b: "b.rs".to_string(),
co_changes: 4,
}],
}
}
#[test]
fn every_advertised_format_renders_itself() {
use crate::cli::enums::OutputFormat as F;
let analysis = sample_analysis();
let yaml = format_analysis(&analysis, &F::Yaml).unwrap();
let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&yaml).expect("valid yaml");
assert_eq!(parsed["total_commits"].as_u64(), Some(13));
let csv = format_analysis(&analysis, &F::Csv).unwrap();
assert!(csv.starts_with("path,touches,authors,lines,churn_ratio,pattern,recommendation\n"));
assert!(csv.contains("src/cli/mod.rs,9,2,1200,0.8,Registry/Dispatch,"));
assert!(csv.contains("file_a,file_b,co_changes\na.rs,b.rs,4"));
let junit = format_analysis(&analysis, &F::Junit).unwrap();
assert!(junit.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
assert!(junit.contains("<testsuites"));
assert!(junit.contains("src/cli/mod.rs"));
let md = format_analysis(&analysis, &F::Markdown).unwrap();
assert!(md.starts_with("# Architectural Bottleneck Analysis"));
assert!(md.contains("| `src/cli/mod.rs` | 9 |"));
let summary = format_analysis(&analysis, &F::Summary).unwrap();
assert!(summary.contains("bottlenecks=1"));
let json = format_analysis(&analysis, &F::Json).unwrap();
let _: serde_json::Value = serde_json::from_str(&json).expect("valid json");
for (name, rendered) in [
("yaml", &yaml),
("csv", &csv),
("junit", &junit),
("markdown", &md),
("summary", &summary),
("json", &json),
] {
assert!(
!rendered.contains('\u{1b}'),
"{name} carries ANSI escapes: {rendered:?}"
);
}
}
#[test]
fn text_and_plain_are_the_table_without_escapes() {
use crate::cli::enums::OutputFormat as F;
let analysis = sample_analysis();
let text = format_analysis(&analysis, &F::Text).unwrap();
let plain = format_analysis(&analysis, &F::Plain).unwrap();
assert_eq!(text, plain);
assert!(!text.contains('\u{1b}'), "{text:?}");
assert!(text.contains("Architectural Bottleneck Analysis"));
assert!(text.contains("src/cli/mod.rs"));
}
#[test]
fn since_arg_drops_the_bound_past_the_epoch() {
assert_eq!(since_arg(30).as_deref(), Some("--since=30 days ago"));
assert_eq!(
since_arg(MAX_LOOKBACK_DAYS).as_deref(),
Some("--since=20000 days ago")
);
assert_eq!(since_arg(100_000), None);
assert_eq!(since_arg(99_999_999), None);
assert_eq!(since_arg(u32::MAX), None);
}
#[test]
fn git_log_args_omit_since_for_a_huge_period() {
let bounded = git_log_args(30, &["--name-only"]);
assert_eq!(bounded[0], "log");
assert_eq!(bounded[1], "--since=30 days ago");
assert_eq!(bounded[2], "--name-only");
let unbounded = git_log_args(99_999_999, &["--name-only"]);
assert_eq!(unbounded, vec!["log", "--name-only"]);
assert!(!unbounded.iter().any(|a| a.starts_with("--since")));
}
#[test]
fn huge_period_reports_the_same_commit_count_as_a_normal_one() {
use std::process::Command;
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let repo = temp.path();
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(repo)
.output()
.expect("git must be available")
};
if !git(&["init"]).status.success() {
return;
}
let _ = git(&["config", "user.email", "t@example.com"]);
let _ = git(&["config", "user.name", "T"]);
std::fs::write(repo.join("a.rs"), "pub fn f() {}\n").unwrap();
let _ = git(&["add", "-A"]);
if !git(&["commit", "-m", "init", "--no-verify"])
.status
.success()
{
return;
}
let (_, _, small) = get_git_churn(repo, 30).expect("small window");
let (_, _, huge) = get_git_churn(repo, 99_999_999).expect("huge window");
assert_eq!(small, 1, "the fixture repo has exactly one commit");
assert_eq!(
huge, small,
"a larger --period reported fewer commits (pre-fix: 0)"
);
}
fn tied_commits() -> Vec<Vec<String>> {
(0..30)
.map(|i| vec!["src/shared.rs".to_string(), format!("src/mod_{i:02}.rs")])
.collect()
}
#[test]
fn detect_coupling_is_deterministic_across_runs() {
let commits = tied_commits();
let first = detect_coupling(&commits, 1);
assert_eq!(first.len(), 15, "the fixture must exercise the truncation");
for run in 1..25 {
let again = detect_coupling(&commits, 1);
let as_tuples = |p: &[CouplingPair]| {
p.iter()
.map(|c| (c.file_a.clone(), c.file_b.clone(), c.co_changes))
.collect::<Vec<_>>()
};
assert_eq!(
as_tuples(&first),
as_tuples(&again),
"run {run} disagreed with run 0: coupling output depends on HashMap order"
);
}
}
#[test]
fn detect_coupling_breaks_ties_by_path() {
let pairs = detect_coupling(&tied_commits(), 1);
let mut expected: Vec<(String, String)> = pairs
.iter()
.map(|p| (p.file_a.clone(), p.file_b.clone()))
.collect();
expected.sort();
let actual: Vec<(String, String)> = pairs
.iter()
.map(|p| (p.file_a.clone(), p.file_b.clone()))
.collect();
assert_eq!(actual, expected, "tied pairs must be ordered by path");
}
#[test]
fn analyze_bottlenecks_is_deterministic_across_runs() {
use std::process::Command;
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let repo = temp.path();
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(repo)
.output()
.expect("git must be available")
};
if !git(&["init"]).status.success() {
return;
}
let _ = git(&["config", "user.email", "t@example.com"]);
let _ = git(&["config", "user.name", "T"]);
std::fs::create_dir_all(repo.join("src")).unwrap();
for i in 0..30 {
std::fs::write(
repo.join(format!("src/f{i:02}.rs")),
"pub fn f() {}\npub fn g() {}\n",
)
.unwrap();
}
let _ = git(&["add", "-A"]);
if !git(&["commit", "-m", "init", "--no-verify"])
.status
.success()
{
return;
}
let first = analyze_bottlenecks(repo, 99_999_999, 1).expect("analysis must run");
assert_eq!(
first.bottlenecks.len(),
20,
"the fixture must exercise the truncation"
);
let paths = |a: &BottleneckAnalysis| {
a.bottlenecks
.iter()
.map(|b| b.path.clone())
.collect::<Vec<_>>()
};
for run in 1..10 {
let again = analyze_bottlenecks(repo, 99_999_999, 1).expect("analysis must run");
assert_eq!(
paths(&first),
paths(&again),
"run {run} disagreed with run 0: bottleneck output depends on HashMap order"
);
}
let mut sorted = paths(&first);
sorted.sort();
assert_eq!(paths(&first), sorted, "tied files must be ordered by path");
}
}