use anyhow::{bail, Context, Result};
use hotspots_core::history_signals::compute_history_signals_for_repo;
use hotspots_core::snapshot::SNAPSHOT_SCHEMA_VERSION;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::Command;
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 FileOwnership {
file: String,
author_count: u32,
author_entropy: f64,
newcomer_rate: Option<f64>,
knowledge_mode: Option<&'static str>,
}
#[derive(Debug, Serialize)]
struct CoordinateSection {
input_files: Vec<String>,
ownership: Vec<FileOwnership>,
}
#[derive(Debug, Serialize)]
struct CoordinateEnvelope {
schema_version: u32,
coordinate: CoordinateSection,
}
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");
}
}
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 ownership_signals = compute_history_signals_for_repo(&repo_root);
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 output = CoordinateEnvelope {
schema_version: SNAPSHOT_SCHEMA_VERSION,
coordinate: CoordinateSection {
input_files,
ownership,
},
};
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 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()]);
}
}