semver_analyzer_core/diagnostics.rs
1//! Degradation tracking for non-fatal issues during analysis.
2//!
3//! The [`DegradationTracker`] collects issues that degrade analysis quality
4//! without causing a fatal error. It is thread-safe and accessible via
5//! `SharedFindings::degradation()` so all pipeline phases and Language
6//! implementations can record issues.
7//!
8//! At the end of a run, the CLI renders a summary of all recorded issues
9//! so the user knows what parts of the analysis may be incomplete.
10//!
11//! ## When to Record a Degradation
12//!
13//! - A pipeline phase fails but execution can continue with partial results
14//! - An external tool (LLM, CSS extraction, dep repo build) fails
15//! - Multiple per-item failures occur (batch into a single summary entry)
16//!
17//! ## When NOT to Record
18//!
19//! - Best-effort operations where failure is a normal code path
20//! (e.g., `read_git_file` returning `None` for a file that may not exist)
21//! - Cleanup/teardown failures (Drop impls, worktree removal)
22
23use std::sync::Mutex;
24
25/// Tracks non-fatal issues that degrade analysis quality.
26///
27/// Thread-safe — wrap in `Arc` and share across pipeline phases.
28/// Lives on `SharedFindings` for convenient access.
29#[derive(Debug, Default)]
30pub struct DegradationTracker {
31 issues: Mutex<Vec<DegradationIssue>>,
32}
33
34/// A single non-fatal issue recorded during analysis.
35#[derive(Debug, Clone)]
36pub struct DegradationIssue {
37 /// Short pipeline phase tag: "TD", "SD", "BU", "CSS", "LLM".
38 pub phase: String,
39 /// What happened (technical, concise).
40 pub message: String,
41 /// What the user is missing as a result (user-facing, actionable).
42 pub impact: String,
43}
44
45impl DegradationTracker {
46 /// Create a new empty tracker.
47 pub fn new() -> Self {
48 Self::default()
49 }
50
51 /// Record a non-fatal issue.
52 ///
53 /// # Arguments
54 ///
55 /// * `phase` — Short tag identifying the pipeline phase ("TD", "SD", "BU", "CSS", "LLM")
56 /// * `message` — What happened (technical, concise)
57 /// * `impact` — What the user is missing (user-facing, actionable)
58 pub fn record(
59 &self,
60 phase: impl Into<String>,
61 message: impl Into<String>,
62 impact: impl Into<String>,
63 ) {
64 self.issues.lock().unwrap().push(DegradationIssue {
65 phase: phase.into(),
66 message: message.into(),
67 impact: impact.into(),
68 });
69 }
70
71 /// Get a snapshot of all recorded issues.
72 pub fn issues(&self) -> Vec<DegradationIssue> {
73 self.issues.lock().unwrap().clone()
74 }
75
76 /// Check if any issues have been recorded.
77 pub fn has_issues(&self) -> bool {
78 !self.issues.lock().unwrap().is_empty()
79 }
80
81 /// Get the count of recorded issues.
82 pub fn issue_count(&self) -> usize {
83 self.issues.lock().unwrap().len()
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90 use std::sync::Arc;
91
92 #[test]
93 fn empty_tracker_has_no_issues() {
94 let tracker = DegradationTracker::new();
95 assert!(!tracker.has_issues());
96 assert_eq!(tracker.issue_count(), 0);
97 assert!(tracker.issues().is_empty());
98 }
99
100 #[test]
101 fn record_and_retrieve_issues() {
102 let tracker = DegradationTracker::new();
103 tracker.record(
104 "SD",
105 "Source-level analysis failed",
106 "Composition trees unavailable",
107 );
108 tracker.record(
109 "CSS",
110 "CSS extraction failed",
111 "CSS removal rules incomplete",
112 );
113
114 assert!(tracker.has_issues());
115 assert_eq!(tracker.issue_count(), 2);
116
117 let issues = tracker.issues();
118 assert_eq!(issues[0].phase, "SD");
119 assert_eq!(issues[0].message, "Source-level analysis failed");
120 assert_eq!(issues[0].impact, "Composition trees unavailable");
121 assert_eq!(issues[1].phase, "CSS");
122 }
123
124 #[test]
125 fn thread_safe_recording() {
126 let tracker = Arc::new(DegradationTracker::new());
127 let mut handles = Vec::new();
128
129 for i in 0..10 {
130 let tracker = tracker.clone();
131 handles.push(std::thread::spawn(move || {
132 tracker.record("TEST", format!("Issue {}", i), "Test impact");
133 }));
134 }
135
136 for handle in handles {
137 handle.join().unwrap();
138 }
139
140 assert_eq!(tracker.issue_count(), 10);
141 }
142}