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 metrics.dead_config_patterns.clear();
348
349 if metrics.modules.is_empty() {
350 return Err(HistoryError::Analysis(
351 "no modules found at this revision".to_string(),
352 ));
353 }
354
355 if params.use_git {
356 let mut volatility = VolatilityAnalyzer::new(params.months);
357 if volatility.analyze(&analysis_path).is_ok() {
358 if let Ok(temporal) = volatility.analyze_temporal_coupling(&analysis_path) {
359 metrics.temporal_couplings = temporal;
360 }
361 metrics.file_changes = volatility.file_changes;
362 metrics.update_volatility_from_git();
363 }
364 }
365
366 if config.has_volatility_overrides() || config.has_subdomain_config() {
367 metrics.apply_config_volatility_overrides(&mut config);
368 }
369
370 let report = analyze_project_balance_with_thresholds(&metrics, params.thresholds);
371 rebase_metrics_paths(&mut metrics, &worktree.dir, repo_root);
372
373 let total_files = metrics.total_files;
374 let module_count = metrics.modules.len();
375 let total_couplings = metrics.couplings.len();
376
377 Ok(RefAnalysis {
378 git_ref: git_ref.to_string(),
379 metrics,
380 report,
381 total_files,
382 module_count,
383 total_couplings,
384 })
385}
386
387fn rebase_metrics_paths(metrics: &mut ProjectMetrics, worktree_root: &Path, repo_root: &Path) {
388 for module in metrics.modules.values_mut() {
389 if let Ok(relative) = module.path.strip_prefix(worktree_root) {
390 module.path = repo_root.join(relative);
391 }
392 }
393
394 for coupling in &mut metrics.couplings {
395 if let Some(path) = &coupling.location.file_path
396 && let Ok(relative) = path.strip_prefix(worktree_root)
397 {
398 coupling.location.file_path = Some(repo_root.join(relative));
399 }
400 }
401}
402
403fn rebase_config_root(
404 config: &CompiledConfig,
405 repo_root: &Path,
406 worktree_root: &Path,
407) -> CompiledConfig {
408 let mut config = config.clone();
409 let Some(config_root) = config.config_root() else {
410 return config;
411 };
412
413 if let Ok(relative) = config_root.strip_prefix(repo_root) {
414 config.set_config_root(Some(worktree_root.join(relative)));
415 }
416
417 config
418}
419
420struct Worktree {
422 repo_root: PathBuf,
423 dir: PathBuf,
424}
425
426impl Worktree {
427 fn add(repo_root: &Path, commit: &str, seq: usize) -> Result<Self, HistoryError> {
428 let dir = std::env::temp_dir().join(format!(
429 "cargo-coupling-hist-{}-{}-{}",
430 std::process::id(),
431 seq,
432 short_hash(commit)
433 ));
434
435 let output = Command::new("git")
436 .args(["worktree", "add", "--detach"])
437 .arg(&dir)
438 .arg(commit)
439 .current_dir(repo_root)
440 .output()?;
441
442 if !output.status.success() {
443 return Err(HistoryError::Git(
444 String::from_utf8_lossy(&output.stderr).trim().to_string(),
445 ));
446 }
447
448 Ok(Worktree {
449 repo_root: repo_root.to_path_buf(),
450 dir,
451 })
452 }
453}
454
455impl Drop for Worktree {
456 fn drop(&mut self) {
457 let _ = Command::new("git")
458 .args(["worktree", "remove", "--force"])
459 .arg(&self.dir)
460 .current_dir(&self.repo_root)
461 .output();
462 }
463}
464
465fn short_hash(hash: &str) -> String {
467 hash.chars()
468 .take(7)
469 .map(|c| {
470 if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
471 c
472 } else {
473 '_'
474 }
475 })
476 .collect()
477}
478
479pub(crate) fn sample_evenly(len: usize, max: usize) -> Vec<usize> {
484 if len <= max {
487 return (0..len).collect();
488 }
489 if max == 1 {
490 return vec![len - 1];
491 }
492
493 let mut indices = Vec::with_capacity(max);
494 for k in 0..max {
495 let idx = k * (len - 1) / (max - 1);
497 if indices.last() != Some(&idx) {
498 indices.push(idx);
499 }
500 }
501 indices
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 #[test]
509 fn sample_returns_all_when_under_limit() {
510 assert_eq!(sample_evenly(3, 10), vec![0, 1, 2]);
511 assert_eq!(sample_evenly(5, 5), vec![0, 1, 2, 3, 4]);
512 }
513
514 #[test]
515 fn sample_includes_endpoints() {
516 let s = sample_evenly(100, 5);
517 assert_eq!(s.first(), Some(&0));
518 assert_eq!(s.last(), Some(&99));
519 assert!(s.len() <= 5);
520 }
521
522 #[test]
523 fn sample_is_monotonic_and_unique() {
524 let s = sample_evenly(50, 8);
525 for w in s.windows(2) {
526 assert!(w[0] < w[1], "indices must be strictly increasing: {:?}", s);
527 }
528 }
529
530 #[test]
531 fn sample_edge_cases() {
532 assert_eq!(sample_evenly(0, 5), Vec::<usize>::new());
533 assert_eq!(sample_evenly(5, 0), Vec::<usize>::new());
534 assert_eq!(sample_evenly(10, 1), vec![9]);
535 assert_eq!(sample_evenly(1, 5), vec![0]);
536 }
537
538 #[test]
539 fn short_hash_truncates() {
540 assert_eq!(short_hash("0123456789abcdef"), "0123456");
541 assert_eq!(short_hash("abc"), "abc");
542 assert_eq!(short_hash("feat/foo"), "feat_fo");
543 }
544
545 #[test]
546 fn error_display_messages() {
547 assert_eq!(
548 HistoryError::NotGitRepo.to_string(),
549 "path is not inside a git repository"
550 );
551 assert_eq!(
552 HistoryError::NoCommits.to_string(),
553 "no commits found in the requested time window"
554 );
555 assert!(
556 HistoryError::Git("boom".into())
557 .to_string()
558 .contains("boom")
559 );
560 }
561
562 #[test]
563 fn relative_subpath_strips_root() {
564 let sub = relative_subpath(Path::new("/repo"), Path::new("/repo/src")).unwrap();
567 assert_eq!(sub, Path::new("src"));
568 }
569
570 #[test]
571 fn relative_subpath_errors_outside_root() {
572 let err = relative_subpath(Path::new("/repo"), Path::new("/elsewhere/src")).unwrap_err();
573 assert!(err.to_string().contains("is not under git repository root"));
574 }
575}