use anyhow::{bail, Context, Result};
use hotspots_core::coupling::compute_raw_coupling_for_repo;
use hotspots_core::history_signals::compute_history_signals_for_repo;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
const HIDDEN_DEP_THRESHOLD: f64 = 0.7;
const SERIALIZE_THRESHOLD: f64 = 0.7;
const CONCENTRATED_MAX_AUTHOR_COUNT: u32 = 3;
const CONCENTRATED_MAX_NORMALIZED_ENTROPY: f64 = 0.5;
pub(crate) struct CoordinateArgs {
pub files: Option<String>,
pub diff: Option<String>,
pub staged: bool,
pub path: PathBuf,
}
#[derive(Debug, Serialize)]
struct CouplingPair {
file_a: String,
file_b: String,
coupling_ratio: f64,
}
#[derive(Debug, Serialize)]
struct HiddenDependency {
file: String,
coupled_to: String,
coupling_ratio: f64,
}
#[derive(Debug, Serialize)]
struct FileOwnership {
file: String,
author_count: u32,
author_entropy: f64,
newcomer_rate: Option<f64>,
knowledge_mode: Option<&'static str>,
}
#[derive(Debug, Serialize)]
struct CoordinateOutput {
schema_version: u32,
input_files: Vec<String>,
within_set: Vec<CouplingPair>,
hidden_dependencies: Vec<HiddenDependency>,
ownership: Vec<FileOwnership>,
recommendation: String,
}
fn classify_knowledge_mode(author_count: u32, author_entropy: f64) -> Option<&'static str> {
if author_count <= 1 {
return None;
}
let normalized_entropy = author_entropy / (author_count as f64).log2();
if author_count <= CONCENTRATED_MAX_AUTHOR_COUNT
&& normalized_entropy < CONCENTRATED_MAX_NORMALIZED_ENTROPY
{
Some("concentrated")
} else {
None
}
}
fn extract_files_from_diff(diff_text: &str) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut files = Vec::new();
for line in diff_text.lines() {
let path = if let Some(rest) = line.strip_prefix("+++ b/") {
Some(rest)
} else {
line.strip_prefix("rename to ")
};
if let Some(path) = path {
if !path.is_empty() && seen.insert(path.to_string()) {
files.push(path.to_string());
}
}
}
files
}
fn staged_files(repo_root: &Path) -> Result<Vec<String>> {
let output = Command::new("git")
.arg("diff")
.arg("--cached")
.arg("--name-only")
.current_dir(repo_root)
.output()
.context("run git diff --cached --name-only")?;
if !output.status.success() {
bail!(
"git diff --cached --name-only failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect())
}
fn resolve_input_files(args: &CoordinateArgs, repo_root: &Path) -> Result<Vec<String>> {
let provided = [args.files.is_some(), args.diff.is_some(), args.staged]
.iter()
.filter(|&&p| p)
.count();
if provided > 1 {
bail!("--files, --diff, and --staged are mutually exclusive");
}
if let Some(files) = &args.files {
Ok(files
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect())
} else if let Some(diff_text) = &args.diff {
Ok(extract_files_from_diff(diff_text))
} else if args.staged {
staged_files(repo_root)
} else {
bail!("one of --files, --diff, or --staged is required");
}
}
fn classify_coupling(
coupling: &HashMap<(String, String), f64>,
input_files: &[String],
) -> (Vec<CouplingPair>, Vec<HiddenDependency>) {
let input_set: HashSet<&str> = input_files.iter().map(|s| s.as_str()).collect();
let mut within_set = Vec::new();
let mut hidden_dep_best: HashMap<String, HiddenDependency> = HashMap::new();
for ((file_a, file_b), ratio) in coupling {
let a_in = input_set.contains(file_a.as_str());
let b_in = input_set.contains(file_b.as_str());
if a_in && b_in {
within_set.push(CouplingPair {
file_a: file_a.clone(),
file_b: file_b.clone(),
coupling_ratio: *ratio,
});
} else if *ratio >= HIDDEN_DEP_THRESHOLD && (a_in || b_in) {
let (outside, inside) = if a_in {
(file_b.clone(), file_a.clone())
} else {
(file_a.clone(), file_b.clone())
};
let better = hidden_dep_best
.get(&outside)
.map(|existing| {
*ratio > existing.coupling_ratio
|| (*ratio == existing.coupling_ratio && inside < existing.coupled_to)
})
.unwrap_or(true);
if better {
hidden_dep_best.insert(
outside.clone(),
HiddenDependency {
file: outside,
coupled_to: inside,
coupling_ratio: *ratio,
},
);
}
}
}
let mut hidden_dependencies: Vec<HiddenDependency> = hidden_dep_best.into_values().collect();
hidden_dependencies.sort_by(|a, b| a.file.cmp(&b.file));
within_set.sort_by(|a, b| {
a.file_a
.cmp(&b.file_a)
.then_with(|| a.file_b.cmp(&b.file_b))
});
(within_set, hidden_dependencies)
}
fn compute_recommendation(
within_set: &[CouplingPair],
ownership: &[FileOwnership],
) -> &'static str {
let coupling_triggers_serialize = within_set
.iter()
.any(|p| p.coupling_ratio >= SERIALIZE_THRESHOLD);
let all_concentrated = !ownership.is_empty()
&& ownership
.iter()
.all(|o| o.knowledge_mode == Some("concentrated"));
if all_concentrated {
"parallel_safe"
} else if coupling_triggers_serialize {
"serialize"
} else {
"parallel_safe"
}
}
pub(crate) fn handle_coordinate(args: CoordinateArgs) -> Result<()> {
let repo_root = args.path.canonicalize().context("resolve repo path")?;
let input_files = resolve_input_files(&args, &repo_root)?;
let coupling = compute_raw_coupling_for_repo(&repo_root);
let ownership_signals = compute_history_signals_for_repo(&repo_root);
let (within_set, hidden_dependencies) = classify_coupling(&coupling, &input_files);
let ownership: Vec<FileOwnership> = input_files
.iter()
.map(|file| {
let signals = ownership_signals.get(file);
let author_count = signals.map(|s| s.author_count).unwrap_or(0);
let author_entropy = signals.map(|s| s.author_entropy).unwrap_or(0.0);
FileOwnership {
file: file.clone(),
author_count,
author_entropy,
newcomer_rate: signals.and_then(|s| s.newcomer_rate),
knowledge_mode: classify_knowledge_mode(author_count, author_entropy),
}
})
.collect();
let recommendation = compute_recommendation(&within_set, &ownership);
let output = CoordinateOutput {
schema_version: 1,
input_files,
within_set,
hidden_dependencies,
ownership,
recommendation: recommendation.to_string(),
};
println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn knowledge_mode_fires_concentrated() {
assert_eq!(classify_knowledge_mode(2, 1.0), None);
assert_eq!(classify_knowledge_mode(2, 0.1), Some("concentrated"));
assert_eq!(classify_knowledge_mode(3, 0.2), Some("concentrated"));
}
#[test]
fn knowledge_mode_does_not_fire_above_author_count_bound() {
assert_eq!(classify_knowledge_mode(5, 0.2), None);
}
#[test]
fn knowledge_mode_degenerate_single_author() {
assert_eq!(classify_knowledge_mode(1, 0.0), None);
assert_eq!(classify_knowledge_mode(0, 0.0), None);
}
#[test]
fn extract_files_addition_or_modification() {
let diff = "diff --git a/foo.rs b/foo.rs\n\
index 111..222 100644\n\
--- a/foo.rs\n\
+++ b/foo.rs\n\
@@ -1 +1 @@\n\
-old\n\
+new\n";
assert_eq!(extract_files_from_diff(diff), vec!["foo.rs"]);
}
#[test]
fn extract_files_excludes_deletion() {
let diff = "diff --git a/gone.rs b/gone.rs\n\
deleted file mode 100644\n\
index 111..000\n\
--- a/gone.rs\n\
+++ /dev/null\n\
@@ -1 +0,0 @@\n\
-bye\n";
assert!(extract_files_from_diff(diff).is_empty());
}
#[test]
fn extract_files_content_changing_rename_uses_new_path_only() {
let diff = "diff --git a/old_path.rs b/new_path.rs\n\
similarity index 90%\n\
rename from old_path.rs\n\
rename to new_path.rs\n\
index 111..222 100644\n\
--- a/old_path.rs\n\
+++ b/new_path.rs\n\
@@ -1 +1 @@\n\
-old\n\
+new\n";
assert_eq!(extract_files_from_diff(diff), vec!["new_path.rs"]);
}
#[test]
fn extract_files_pure_rename_has_no_plus_plus_plus_line() {
let diff = "diff --git a/old.rs b/new.rs\n\
similarity index 100%\n\
rename from old.rs\n\
rename to new.rs\n";
assert_eq!(extract_files_from_diff(diff), vec!["new.rs"]);
}
#[test]
fn extract_files_binary_addition_excluded() {
let diff = "diff --git a/img.png b/img.png\n\
new file mode 100644\n\
index 000..111\n\
Binary files /dev/null and b/img.png differ\n";
assert!(extract_files_from_diff(diff).is_empty());
}
#[test]
fn extract_files_deduplicates_preserving_first_occurrence() {
let diff = "+++ b/a.rs\n+++ b/a.rs\n+++ b/b.rs\n";
assert_eq!(extract_files_from_diff(diff), vec!["a.rs", "b.rs"]);
}
#[test]
fn extract_files_ignores_malformed_empty_path_line() {
let diff = "+++ b/\n+++ b/real.rs\n";
assert_eq!(extract_files_from_diff(diff), vec!["real.rs"]);
}
fn args_with(files: Option<&str>, diff: Option<&str>, staged: bool) -> CoordinateArgs {
CoordinateArgs {
files: files.map(str::to_string),
diff: diff.map(str::to_string),
staged,
path: PathBuf::from("."),
}
}
#[test]
fn resolve_input_files_from_files() {
let args = args_with(Some("a.rs, b.rs ,,c.rs"), None, false);
let files = resolve_input_files(&args, Path::new(".")).unwrap();
assert_eq!(files, vec!["a.rs", "b.rs", "c.rs"]);
}
#[test]
fn resolve_input_files_from_diff() {
let args = args_with(None, Some("+++ b/x.rs\n"), false);
let files = resolve_input_files(&args, Path::new(".")).unwrap();
assert_eq!(files, vec!["x.rs"]);
}
#[test]
fn resolve_input_files_rejects_more_than_one_source() {
let args = args_with(Some("a.rs"), Some("+++ b/x.rs\n"), false);
let err = resolve_input_files(&args, Path::new(".")).unwrap_err();
assert!(err.to_string().contains("mutually exclusive"));
}
#[test]
fn resolve_input_files_rejects_none_provided() {
let args = args_with(None, None, false);
let err = resolve_input_files(&args, Path::new(".")).unwrap_err();
assert!(err.to_string().contains("required"));
}
fn pair(a: &str, b: &str, ratio: f64) -> ((String, String), f64) {
((a.to_string(), b.to_string()), ratio)
}
#[test]
fn classify_coupling_within_set_pair() {
let coupling = HashMap::from([pair("a.rs", "b.rs", 0.9)]);
let input_files = vec!["a.rs".to_string(), "b.rs".to_string()];
let (within_set, hidden_deps) = classify_coupling(&coupling, &input_files);
assert_eq!(within_set.len(), 1);
assert_eq!(within_set[0].coupling_ratio, 0.9);
assert!(hidden_deps.is_empty());
}
#[test]
fn classify_coupling_hidden_dependency_above_threshold() {
let coupling = HashMap::from([pair("a.rs", "outside.rs", 0.8)]);
let input_files = vec!["a.rs".to_string()];
let (within_set, hidden_deps) = classify_coupling(&coupling, &input_files);
assert!(within_set.is_empty());
assert_eq!(hidden_deps.len(), 1);
assert_eq!(hidden_deps[0].file, "outside.rs");
assert_eq!(hidden_deps[0].coupled_to, "a.rs");
}
#[test]
fn classify_coupling_below_threshold_is_not_hidden_dependency() {
let coupling = HashMap::from([pair("a.rs", "outside.rs", 0.5)]);
let input_files = vec!["a.rs".to_string()];
let (within_set, hidden_deps) = classify_coupling(&coupling, &input_files);
assert!(within_set.is_empty());
assert!(hidden_deps.is_empty());
}
#[test]
fn classify_coupling_pair_entirely_outside_set_is_ignored() {
let coupling = HashMap::from([pair("outside_a.rs", "outside_b.rs", 1.0)]);
let input_files = vec!["a.rs".to_string()];
let (within_set, hidden_deps) = classify_coupling(&coupling, &input_files);
assert!(within_set.is_empty());
assert!(hidden_deps.is_empty());
}
#[test]
fn classify_coupling_keeps_strongest_partner_for_outside_file() {
let coupling = HashMap::from([
pair("a.rs", "outside.rs", 0.75),
pair("outside.rs", "b.rs", 0.95),
]);
let input_files = vec!["a.rs".to_string(), "b.rs".to_string()];
let (_within_set, hidden_deps) = classify_coupling(&coupling, &input_files);
assert_eq!(hidden_deps.len(), 1);
assert_eq!(hidden_deps[0].coupling_ratio, 0.95);
assert_eq!(hidden_deps[0].coupled_to, "b.rs");
}
#[test]
fn classify_coupling_tied_ratio_resolves_deterministically() {
let input_files = vec!["a.rs".to_string(), "b.rs".to_string()];
let coupling_a_first = HashMap::from([
pair("a.rs", "outside.rs", 0.9),
pair("outside.rs", "b.rs", 0.9),
]);
let coupling_b_first = HashMap::from([
pair("outside.rs", "b.rs", 0.9),
pair("a.rs", "outside.rs", 0.9),
]);
let (_, hidden_a) = classify_coupling(&coupling_a_first, &input_files);
let (_, hidden_b) = classify_coupling(&coupling_b_first, &input_files);
assert_eq!(hidden_a.len(), 1);
assert_eq!(hidden_b.len(), 1);
assert_eq!(hidden_a[0].coupled_to, "a.rs");
assert_eq!(hidden_b[0].coupled_to, "a.rs");
}
fn ownership_entry(file: &str, knowledge_mode: Option<&'static str>) -> FileOwnership {
FileOwnership {
file: file.to_string(),
author_count: 0,
author_entropy: 0.0,
newcomer_rate: None,
knowledge_mode,
}
}
#[test]
fn recommendation_downgrades_when_all_files_concentrated() {
let within_set = vec![CouplingPair {
file_a: "a.rs".to_string(),
file_b: "b.rs".to_string(),
coupling_ratio: 0.9,
}];
let ownership = vec![
ownership_entry("a.rs", Some("concentrated")),
ownership_entry("b.rs", Some("concentrated")),
];
assert_eq!(
compute_recommendation(&within_set, &ownership),
"parallel_safe"
);
}
#[test]
fn recommendation_stays_serialize_when_not_all_concentrated() {
let within_set = vec![CouplingPair {
file_a: "a.rs".to_string(),
file_b: "b.rs".to_string(),
coupling_ratio: 0.9,
}];
let ownership = vec![
ownership_entry("a.rs", Some("concentrated")),
ownership_entry("b.rs", None),
];
assert_eq!(compute_recommendation(&within_set, &ownership), "serialize");
}
#[test]
fn recommendation_unaffected_when_coupling_below_threshold() {
let within_set = vec![CouplingPair {
file_a: "a.rs".to_string(),
file_b: "b.rs".to_string(),
coupling_ratio: 0.2,
}];
let ownership = vec![
ownership_entry("a.rs", Some("concentrated")),
ownership_entry("b.rs", Some("concentrated")),
];
assert_eq!(
compute_recommendation(&within_set, &ownership),
"parallel_safe"
);
}
#[test]
fn recommendation_empty_ownership_does_not_downgrade() {
let within_set = vec![CouplingPair {
file_a: "a.rs".to_string(),
file_b: "b.rs".to_string(),
coupling_ratio: 0.9,
}];
let ownership: Vec<FileOwnership> = vec![];
assert_eq!(compute_recommendation(&within_set, &ownership), "serialize");
}
fn run_git(repo: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(repo)
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed");
}
#[test]
fn staged_files_lists_pure_rename_new_path_natively() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
run_git(repo, &["init", "-q"]);
run_git(repo, &["config", "user.email", "test@example.com"]);
run_git(repo, &["config", "user.name", "Test"]);
std::fs::write(repo.join("old.rs"), "fn main() {}\n").unwrap();
run_git(repo, &["add", "old.rs"]);
run_git(repo, &["commit", "-q", "-m", "init"]);
run_git(repo, &["mv", "old.rs", "new.rs"]);
let files = staged_files(repo).unwrap();
assert_eq!(files, vec!["new.rs".to_string()]);
}
}