Skip to main content

ai_agent/services/
diagnostic_tracking.rs

1//! Diagnostic tracking service - tracks IDE diagnostics before/after edits.
2//!
3//! Translates diagnosticTracking.ts from claude code.
4
5use std::collections::HashMap;
6
7pub const MAX_DIAGNOSTICS_SUMMARY_CHARS: usize = 4000;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum DiagnosticSeverity {
11    Error,
12    Warning,
13    Info,
14    Hint,
15}
16
17#[derive(Debug, Clone)]
18pub struct DiagnosticRange {
19    pub start: DiagnosticPosition,
20    pub end: DiagnosticPosition,
21}
22
23#[derive(Debug, Clone)]
24pub struct DiagnosticPosition {
25    pub line: u32,
26    pub character: u32,
27}
28
29#[derive(Debug, Clone)]
30pub struct Diagnostic {
31    pub message: String,
32    pub severity: DiagnosticSeverity,
33    pub range: DiagnosticRange,
34    pub source: Option<String>,
35    pub code: Option<String>,
36}
37
38#[derive(Debug, Clone)]
39pub struct DiagnosticFile {
40    pub uri: String,
41    pub diagnostics: Vec<Diagnostic>,
42}
43
44pub struct DiagnosticTrackingService {
45    initialized: bool,
46    baseline: HashMap<String, Vec<Diagnostic>>,
47    right_file_diagnostics_state: HashMap<String, Vec<Diagnostic>>,
48    last_processed_timestamps: HashMap<String, u64>,
49}
50
51impl DiagnosticTrackingService {
52    pub fn new() -> Self {
53        Self {
54            initialized: false,
55            baseline: HashMap::new(),
56            right_file_diagnostics_state: HashMap::new(),
57            last_processed_timestamps: HashMap::new(),
58        }
59    }
60
61    pub fn initialize(&mut self) {
62        self.initialized = true;
63    }
64
65    pub fn shutdown(&mut self) {
66        self.initialized = false;
67        self.baseline.clear();
68        self.right_file_diagnostics_state.clear();
69        self.last_processed_timestamps.clear();
70    }
71
72    pub fn reset(&mut self) {
73        self.baseline.clear();
74        self.right_file_diagnostics_state.clear();
75        self.last_processed_timestamps.clear();
76    }
77
78    fn normalize_file_uri(&self, file_uri: &str) -> String {
79        let prefixes = ["file://", "_claude_fs_right:", "_claude_fs_left:"];
80
81        let mut normalized = file_uri.to_string();
82        for prefix in &prefixes {
83            if file_uri.starts_with(prefix) {
84                normalized = file_uri
85                    .strip_prefix(prefix)
86                    .unwrap_or(file_uri)
87                    .to_string();
88                break;
89            }
90        }
91
92        normalized
93    }
94
95    pub fn before_file_edited(&mut self, file_path: &str, timestamp: u64) {
96        if !self.initialized {
97            return;
98        }
99
100        let normalized_path = self.normalize_file_uri(file_path);
101        self.baseline.insert(normalized_path.clone(), Vec::new());
102        self.last_processed_timestamps
103            .insert(normalized_path, timestamp);
104    }
105
106    pub fn set_baseline_diagnostics(&mut self, file_path: &str, diagnostics: Vec<Diagnostic>) {
107        let normalized_path = self.normalize_file_uri(file_path);
108        self.baseline.insert(normalized_path, diagnostics);
109    }
110
111    pub fn get_baseline(&self, file_path: &str) -> Option<&Vec<Diagnostic>> {
112        let normalized_path = self.normalize_file_uri(file_path);
113        self.baseline.get(&normalized_path)
114    }
115
116    fn are_diagnostics_equal(&self, a: &Diagnostic, b: &Diagnostic) -> bool {
117        a.message == b.message
118            && a.severity == b.severity
119            && a.source == b.source
120            && a.code == b.code
121            && a.range.start.line == b.range.start.line
122            && a.range.start.character == b.range.start.character
123            && a.range.end.line == b.range.end.line
124            && a.range.end.character == b.range.end.character
125    }
126
127    fn are_diagnostic_arrays_equal(&self, a: &[Diagnostic], b: &[Diagnostic]) -> bool {
128        if a.len() != b.len() {
129            return false;
130        }
131
132        a.iter().all(|diag_a| {
133            b.iter()
134                .any(|diag_b| self.are_diagnostics_equal(diag_a, diag_b))
135        }) && b.iter().all(|diag_b| {
136            a.iter()
137                .any(|diag_a| self.are_diagnostics_equal(diag_a, diag_b))
138        })
139    }
140
141    pub fn format_diagnostics_summary(files: &[DiagnosticFile]) -> String {
142        let truncation_marker = "…[truncated]";
143
144        let result: String = files
145            .iter()
146            .map(|file| {
147                let filename = file.uri.split('/').last().unwrap_or(&file.uri);
148                let diagnostics: String = file
149                    .diagnostics
150                    .iter()
151                    .map(|d| {
152                        let severity_symbol = Self::get_severity_symbol(&d.severity);
153                        let code_str = d
154                            .code
155                            .as_ref()
156                            .map(|c| format!(" [{}]", c))
157                            .unwrap_or_default();
158                        let source_str = d
159                            .source
160                            .as_ref()
161                            .map(|s| format!(" ({})", s))
162                            .unwrap_or_default();
163                        format!(
164                            "  {} [Line {}:{}] {}{}{}",
165                            severity_symbol,
166                            d.range.start.line + 1,
167                            d.range.start.character + 1,
168                            d.message,
169                            code_str,
170                            source_str
171                        )
172                    })
173                    .collect::<Vec<_>>()
174                    .join("\n");
175
176                format!("{}:\n{}", filename, diagnostics)
177            })
178            .collect::<Vec<_>>()
179            .join("\n\n");
180
181        if result.len() > MAX_DIAGNOSTICS_SUMMARY_CHARS {
182            return result[..MAX_DIAGNOSTICS_SUMMARY_CHARS - truncation_marker.len()].to_string()
183                + truncation_marker;
184        }
185
186        result
187    }
188
189    pub fn get_severity_symbol(severity: &DiagnosticSeverity) -> &'static str {
190        match severity {
191            DiagnosticSeverity::Error => "✗",
192            DiagnosticSeverity::Warning => "⚠",
193            DiagnosticSeverity::Info => "ℹ",
194            DiagnosticSeverity::Hint => "★",
195        }
196    }
197}
198
199impl Default for DiagnosticTrackingService {
200    fn default() -> Self {
201        Self::new()
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_normalize_file_uri() {
211        let service = DiagnosticTrackingService::new();
212
213        assert_eq!(
214            service.normalize_file_uri("file:///path/to/file.ts"),
215            "/path/to/file.ts"
216        );
217        assert_eq!(
218            service.normalize_file_uri("_claude_fs_right:/path/to/file.ts"),
219            "/path/to/file.ts"
220        );
221    }
222
223    #[test]
224    fn test_severity_symbols() {
225        assert_eq!(
226            DiagnosticTrackingService::get_severity_symbol(&DiagnosticSeverity::Error),
227            "✗"
228        );
229        assert_eq!(
230            DiagnosticTrackingService::get_severity_symbol(&DiagnosticSeverity::Warning),
231            "⚠"
232        );
233        assert_eq!(
234            DiagnosticTrackingService::get_severity_symbol(&DiagnosticSeverity::Info),
235            "ℹ"
236        );
237        assert_eq!(
238            DiagnosticTrackingService::get_severity_symbol(&DiagnosticSeverity::Hint),
239            "★"
240        );
241    }
242
243    #[test]
244    fn test_diagnostic_tracking_service() {
245        let mut service = DiagnosticTrackingService::new();
246
247        assert!(!service.initialized);
248
249        service.initialize();
250        assert!(service.initialized);
251
252        service.reset();
253        assert!(service.baseline.is_empty());
254
255        service.shutdown();
256        assert!(!service.initialized);
257    }
258}