1use std::path::{Path, PathBuf};
13use std::process::Command;
14use std::sync::atomic::{AtomicUsize, Ordering};
15
16use crate::balance::grade::{HealthGrade, ProjectBalanceReport};
17use crate::balance::project::analyze_project_balance_with_thresholds;
18use crate::balance::severity::Severity;
19use crate::config::CompiledConfig;
20use crate::metrics::project::ProjectMetrics;
21use crate::{IssueThresholds, VolatilityAnalyzer, analyze_workspace_with_config};
22
23static WORKTREE_SEQ: AtomicUsize = AtomicUsize::new(0);
24
25#[derive(Debug, Clone)]
27pub struct HistoryPoint {
28 pub commit: String,
30 pub date: String,
32 pub grade: HealthGrade,
34 pub average_score: f64,
36 pub total_couplings: usize,
38 pub module_count: usize,
40 pub critical: usize,
42 pub high: usize,
44}
45
46#[derive(Debug, Clone)]
48pub struct SkippedRevision {
49 pub commit: String,
51 pub date: String,
53 pub reason: String,
55}
56
57#[derive(Debug, Default)]
59pub struct HistoryReport {
60 pub points: Vec<HistoryPoint>,
62 pub skipped: Vec<SkippedRevision>,
64 pub months: usize,
66}
67
68#[derive(Debug)]
70pub struct RefAnalysis {
71 pub git_ref: String,
73 pub metrics: ProjectMetrics,
75 pub report: ProjectBalanceReport,
77 pub total_files: usize,
79 pub module_count: usize,
81 pub total_couplings: usize,
83}
84
85impl HistoryReport {
86 pub fn endpoints(&self) -> Option<(&HistoryPoint, &HistoryPoint)> {
88 match (self.points.first(), self.points.last()) {
89 (Some(first), Some(last)) => Some((first, last)),
90 _ => None,
91 }
92 }
93}
94
95#[derive(Debug)]
97pub enum HistoryError {
98 NotGitRepo,
100 Git(String),
102 Io(std::io::Error),
104 NoCommits,
106 Analysis(String),
108}
109
110impl std::fmt::Display for HistoryError {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 match self {
113 HistoryError::NotGitRepo => write!(f, "path is not inside a git repository"),
114 HistoryError::Git(msg) => write!(f, "git command failed: {}", msg),
115 HistoryError::Io(e) => write!(f, "I/O error: {}", e),
116 HistoryError::NoCommits => write!(f, "no commits found in the requested time window"),
117 HistoryError::Analysis(msg) => write!(f, "analysis failed: {}", msg),
118 }
119 }
120}
121
122impl std::error::Error for HistoryError {}
123
124impl From<std::io::Error> for HistoryError {
125 fn from(e: std::io::Error) -> Self {
126 HistoryError::Io(e)
127 }
128}
129
130pub fn analyze_history(
135 path: &Path,
136 config: &CompiledConfig,
137 thresholds: &IssueThresholds,
138 months: usize,
139 max_points: usize,
140) -> Result<HistoryReport, HistoryError> {
141 let repo_root = repo_root(path)?;
142 let subpath = relative_subpath(&repo_root, path)?;
143
144 let commits = list_commits(&repo_root, months)?;
145 if commits.is_empty() {
146 return Err(HistoryError::NoCommits);
147 }
148
149 let sampled = sample_evenly(commits.len(), max_points);
150
151 let mut report = HistoryReport {
152 months,
153 ..Default::default()
154 };
155
156 for (idx, &i) in sampled.iter().enumerate() {
157 let (commit, date) = &commits[i];
158 match analyze_revision(
159 &repo_root, &subpath, commit, config, thresholds, months, idx,
160 ) {
161 Ok(point) => report.points.push(HistoryPoint {
162 date: date.clone(),
163 ..point
164 }),
165 Err(reason) => report.skipped.push(SkippedRevision {
166 commit: short_hash(commit),
167 date: date.clone(),
168 reason,
169 }),
170 }
171 }
172
173 Ok(report)
174}
175
176pub fn analyze_ref(
182 path: &Path,
183 config: &CompiledConfig,
184 thresholds: &IssueThresholds,
185 git_ref: &str,
186 months: usize,
187 use_git: bool,
188) -> Result<RefAnalysis, HistoryError> {
189 let repo_root = repo_root(path)?;
190 let subpath = relative_subpath(&repo_root, path)?;
191 let seq = WORKTREE_SEQ.fetch_add(1, Ordering::Relaxed);
192 analyze_ref_in_repo(
193 &repo_root,
194 &subpath,
195 git_ref,
196 RefAnalysisParams {
197 config,
198 thresholds,
199 months,
200 use_git,
201 seq,
202 },
203 )
204}
205
206fn repo_root(path: &Path) -> Result<PathBuf, HistoryError> {
208 let output = Command::new("git")
209 .args(["rev-parse", "--show-toplevel"])
210 .current_dir(path)
211 .output()?;
212
213 if !output.status.success() {
214 return Err(HistoryError::NotGitRepo);
215 }
216
217 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
218 if root.is_empty() {
219 return Err(HistoryError::NotGitRepo);
220 }
221 let root = PathBuf::from(root);
222 Ok(root.canonicalize().unwrap_or(root))
225}
226
227fn relative_subpath(repo_root: &Path, path: &Path) -> Result<PathBuf, HistoryError> {
229 let abs = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
230 abs.strip_prefix(repo_root)
231 .map(|p| p.to_path_buf())
232 .map_err(|_| {
233 HistoryError::Analysis(format!(
234 "analysis path '{}' is not under git repository root '{}'",
235 abs.display(),
236 repo_root.display()
237 ))
238 })
239}
240
241fn list_commits(repo_root: &Path, months: usize) -> Result<Vec<(String, String)>, HistoryError> {
243 let output = Command::new("git")
244 .args([
245 "log",
246 "--reverse",
247 "--pretty=format:%H|%cs",
248 &format!("--since={} months ago", months),
249 "--",
250 "*.rs",
251 ])
252 .current_dir(repo_root)
253 .output()?;
254
255 if !output.status.success() {
256 return Err(HistoryError::Git(
257 String::from_utf8_lossy(&output.stderr).trim().to_string(),
258 ));
259 }
260
261 let commits = String::from_utf8_lossy(&output.stdout)
262 .lines()
263 .filter_map(|line| {
264 let (hash, date) = line.split_once('|')?;
265 if hash.is_empty() {
266 return None;
267 }
268 Some((hash.to_string(), date.to_string()))
269 })
270 .collect();
271
272 Ok(commits)
273}
274
275fn analyze_revision(
277 repo_root: &Path,
278 subpath: &Path,
279 commit: &str,
280 config: &CompiledConfig,
281 thresholds: &IssueThresholds,
282 months: usize,
283 seq: usize,
284) -> Result<HistoryPoint, String> {
285 let analysis = analyze_ref_in_repo(
286 repo_root,
287 subpath,
288 commit,
289 RefAnalysisParams {
290 config,
291 thresholds,
292 months,
293 use_git: true,
294 seq,
295 },
296 )
297 .map_err(|e| e.to_string())?;
298 let report = analysis.report;
299 let critical = *report
300 .issues_by_severity
301 .get(&Severity::Critical)
302 .unwrap_or(&0);
303 let high = *report.issues_by_severity.get(&Severity::High).unwrap_or(&0);
304
305 Ok(HistoryPoint {
306 commit: short_hash(commit),
307 date: String::new(), grade: report.health_grade,
309 average_score: report.average_score,
310 total_couplings: analysis.total_couplings,
311 module_count: analysis.module_count,
312 critical,
313 high,
314 })
315}
316
317struct RefAnalysisParams<'a> {
318 config: &'a CompiledConfig,
319 thresholds: &'a IssueThresholds,
320 months: usize,
321 use_git: bool,
322 seq: usize,
323}
324
325fn analyze_ref_in_repo(
326 repo_root: &Path,
327 subpath: &Path,
328 git_ref: &str,
329 params: RefAnalysisParams<'_>,
330) -> Result<RefAnalysis, HistoryError> {
331 let worktree = Worktree::add(repo_root, git_ref, params.seq)?;
332
333 let analysis_path = worktree.dir.join(subpath);
334 if !analysis_path.exists() {
335 return Err(HistoryError::Analysis(
336 "analysis path does not exist at this revision".to_string(),
337 ));
338 }
339
340 let mut config = rebase_config_root(params.config, repo_root, &worktree.dir);
341 let mut metrics = analyze_workspace_with_config(&analysis_path, &config)
342 .map_err(|e| HistoryError::Analysis(e.to_string()))?;
343
344 if metrics.modules.is_empty() {
345 return Err(HistoryError::Analysis(
346 "no modules found at this revision".to_string(),
347 ));
348 }
349
350 if params.use_git {
351 let mut volatility = VolatilityAnalyzer::new(params.months);
352 if volatility.analyze(&analysis_path).is_ok() {
353 if let Ok(temporal) = volatility.analyze_temporal_coupling(&analysis_path) {
354 metrics.temporal_couplings = temporal;
355 }
356 metrics.file_changes = volatility.file_changes;
357 metrics.update_volatility_from_git();
358 }
359 }
360
361 if config.has_volatility_overrides() || config.has_subdomain_config() {
362 metrics.apply_config_volatility_overrides(&mut config);
363 }
364
365 let report = analyze_project_balance_with_thresholds(&metrics, params.thresholds);
366 rebase_metrics_paths(&mut metrics, &worktree.dir, repo_root);
367
368 let total_files = metrics.total_files;
369 let module_count = metrics.modules.len();
370 let total_couplings = metrics.couplings.len();
371
372 Ok(RefAnalysis {
373 git_ref: git_ref.to_string(),
374 metrics,
375 report,
376 total_files,
377 module_count,
378 total_couplings,
379 })
380}
381
382fn rebase_metrics_paths(metrics: &mut ProjectMetrics, worktree_root: &Path, repo_root: &Path) {
383 for module in metrics.modules.values_mut() {
384 if let Ok(relative) = module.path.strip_prefix(worktree_root) {
385 module.path = repo_root.join(relative);
386 }
387 }
388
389 for coupling in &mut metrics.couplings {
390 if let Some(path) = &coupling.location.file_path
391 && let Ok(relative) = path.strip_prefix(worktree_root)
392 {
393 coupling.location.file_path = Some(repo_root.join(relative));
394 }
395 }
396}
397
398fn rebase_config_root(
399 config: &CompiledConfig,
400 repo_root: &Path,
401 worktree_root: &Path,
402) -> CompiledConfig {
403 let mut config = config.clone();
404 let Some(config_root) = config.config_root() else {
405 return config;
406 };
407
408 if let Ok(relative) = config_root.strip_prefix(repo_root) {
409 config.set_config_root(Some(worktree_root.join(relative)));
410 }
411
412 config
413}
414
415struct Worktree {
417 repo_root: PathBuf,
418 dir: PathBuf,
419}
420
421impl Worktree {
422 fn add(repo_root: &Path, commit: &str, seq: usize) -> Result<Self, HistoryError> {
423 let dir = std::env::temp_dir().join(format!(
424 "cargo-coupling-hist-{}-{}-{}",
425 std::process::id(),
426 seq,
427 short_hash(commit)
428 ));
429
430 let output = Command::new("git")
431 .args(["worktree", "add", "--detach"])
432 .arg(&dir)
433 .arg(commit)
434 .current_dir(repo_root)
435 .output()?;
436
437 if !output.status.success() {
438 return Err(HistoryError::Git(
439 String::from_utf8_lossy(&output.stderr).trim().to_string(),
440 ));
441 }
442
443 Ok(Worktree {
444 repo_root: repo_root.to_path_buf(),
445 dir,
446 })
447 }
448}
449
450impl Drop for Worktree {
451 fn drop(&mut self) {
452 let _ = Command::new("git")
453 .args(["worktree", "remove", "--force"])
454 .arg(&self.dir)
455 .current_dir(&self.repo_root)
456 .output();
457 }
458}
459
460fn short_hash(hash: &str) -> String {
462 hash.chars()
463 .take(7)
464 .map(|c| {
465 if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
466 c
467 } else {
468 '_'
469 }
470 })
471 .collect()
472}
473
474pub(crate) fn sample_evenly(len: usize, max: usize) -> Vec<usize> {
479 if len <= max {
482 return (0..len).collect();
483 }
484 if max == 1 {
485 return vec![len - 1];
486 }
487
488 let mut indices = Vec::with_capacity(max);
489 for k in 0..max {
490 let idx = k * (len - 1) / (max - 1);
492 if indices.last() != Some(&idx) {
493 indices.push(idx);
494 }
495 }
496 indices
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
504 fn sample_returns_all_when_under_limit() {
505 assert_eq!(sample_evenly(3, 10), vec![0, 1, 2]);
506 assert_eq!(sample_evenly(5, 5), vec![0, 1, 2, 3, 4]);
507 }
508
509 #[test]
510 fn sample_includes_endpoints() {
511 let s = sample_evenly(100, 5);
512 assert_eq!(s.first(), Some(&0));
513 assert_eq!(s.last(), Some(&99));
514 assert!(s.len() <= 5);
515 }
516
517 #[test]
518 fn sample_is_monotonic_and_unique() {
519 let s = sample_evenly(50, 8);
520 for w in s.windows(2) {
521 assert!(w[0] < w[1], "indices must be strictly increasing: {:?}", s);
522 }
523 }
524
525 #[test]
526 fn sample_edge_cases() {
527 assert_eq!(sample_evenly(0, 5), Vec::<usize>::new());
528 assert_eq!(sample_evenly(5, 0), Vec::<usize>::new());
529 assert_eq!(sample_evenly(10, 1), vec![9]);
530 assert_eq!(sample_evenly(1, 5), vec![0]);
531 }
532
533 #[test]
534 fn short_hash_truncates() {
535 assert_eq!(short_hash("0123456789abcdef"), "0123456");
536 assert_eq!(short_hash("abc"), "abc");
537 assert_eq!(short_hash("feat/foo"), "feat_fo");
538 }
539
540 #[test]
541 fn error_display_messages() {
542 assert_eq!(
543 HistoryError::NotGitRepo.to_string(),
544 "path is not inside a git repository"
545 );
546 assert_eq!(
547 HistoryError::NoCommits.to_string(),
548 "no commits found in the requested time window"
549 );
550 assert!(
551 HistoryError::Git("boom".into())
552 .to_string()
553 .contains("boom")
554 );
555 }
556
557 #[test]
558 fn relative_subpath_strips_root() {
559 let sub = relative_subpath(Path::new("/repo"), Path::new("/repo/src")).unwrap();
562 assert_eq!(sub, Path::new("src"));
563 }
564
565 #[test]
566 fn relative_subpath_errors_outside_root() {
567 let err = relative_subpath(Path::new("/repo"), Path::new("/elsewhere/src")).unwrap_err();
568 assert!(err.to_string().contains("is not under git repository root"));
569 }
570}