Skip to main content

rust_doctor/
diagnostics.rs

1#[cfg(feature = "mcp")]
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use std::time::Duration;
6
7/// Severity of a diagnostic finding.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[cfg_attr(feature = "mcp", derive(JsonSchema))]
10#[serde(rename_all = "lowercase")]
11pub enum Severity {
12    Error,
13    Warning,
14    Info,
15}
16
17impl std::fmt::Display for Severity {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        match self {
20            Self::Error => write!(f, "error"),
21            Self::Warning => write!(f, "warning"),
22            Self::Info => write!(f, "info"),
23        }
24    }
25}
26
27/// Category of a diagnostic rule.
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[cfg_attr(feature = "mcp", derive(JsonSchema))]
30#[serde(rename_all = "kebab-case")]
31pub enum Category {
32    ErrorHandling,
33    Performance,
34    Security,
35    Correctness,
36    Architecture,
37    Dependencies,
38    Async,
39    Framework,
40    Cargo,
41    Style,
42}
43
44impl std::fmt::Display for Category {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Self::ErrorHandling => write!(f, "Error Handling"),
48            Self::Performance => write!(f, "Performance"),
49            Self::Security => write!(f, "Security"),
50            Self::Correctness => write!(f, "Correctness"),
51            Self::Architecture => write!(f, "Architecture"),
52            Self::Dependencies => write!(f, "Dependencies"),
53            Self::Async => write!(f, "Async"),
54            Self::Framework => write!(f, "Framework"),
55            Self::Cargo => write!(f, "Cargo"),
56            Self::Style => write!(f, "Style"),
57        }
58    }
59}
60
61/// A machine-applicable code fix suggestion.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[cfg_attr(feature = "mcp", derive(JsonSchema))]
64pub struct CodeFix {
65    /// The text to find (exact match in the source line).
66    pub old_text: String,
67    /// The replacement text.
68    pub new_text: String,
69    /// Line number (1-based) where the fix applies.
70    pub line: u32,
71}
72
73/// A single diagnostic finding from an analysis pass.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[cfg_attr(feature = "mcp", derive(JsonSchema))]
76pub struct Diagnostic {
77    /// Path to the source file (relative to project root).
78    pub file_path: PathBuf,
79    /// Rule identifier (e.g. "unwrap-in-production", "clippy::unwrap_used").
80    pub rule: String,
81    /// Category this rule belongs to.
82    pub category: Category,
83    /// Severity of the finding.
84    pub severity: Severity,
85    /// Human-readable description of the issue.
86    pub message: String,
87    /// Actionable suggestion for how to fix the issue.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub help: Option<String>,
90    /// Line number (1-based) where the issue was found.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub line: Option<u32>,
93    /// Column number (1-based) where the issue was found.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub column: Option<u32>,
96    /// Machine-applicable fix, if available.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub fix: Option<CodeFix>,
99}
100
101/// Human-readable health assessment label.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
103#[cfg_attr(feature = "mcp", derive(JsonSchema))]
104pub enum ScoreLabel {
105    #[serde(rename = "Great")]
106    Great,
107    #[serde(rename = "Needs work")]
108    NeedsWork,
109    #[serde(rename = "Critical")]
110    Critical,
111}
112
113impl std::fmt::Display for ScoreLabel {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        match self {
116            Self::Great => write!(f, "Great"),
117            Self::NeedsWork => write!(f, "Needs work"),
118            Self::Critical => write!(f, "Critical"),
119        }
120    }
121}
122
123/// Per-dimension health scores.
124#[derive(Debug, Serialize)]
125#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
126pub struct DimensionScores {
127    /// Security dimension score (0–100). Covers Security and Dependencies (advisory) categories.
128    pub security: u32,
129    /// Reliability dimension score (0–100). Covers Correctness and ErrorHandling categories.
130    pub reliability: u32,
131    /// Maintainability dimension score (0–100). Covers Architecture and Style categories.
132    pub maintainability: u32,
133    /// Performance dimension score (0–100). Covers Performance category.
134    pub performance: u32,
135    /// Dependencies dimension score (0–100). Covers Cargo and Dependencies categories.
136    pub dependencies: u32,
137}
138
139/// Result of a complete scan across all analysis passes.
140#[derive(Debug, Serialize)]
141pub struct ScanResult {
142    /// All diagnostics found (after filtering).
143    pub diagnostics: Vec<Diagnostic>,
144    /// Health score (0–100).
145    pub score: u32,
146    /// Score label.
147    pub score_label: ScoreLabel,
148    /// Per-dimension health scores.
149    pub dimension_scores: DimensionScores,
150    /// Number of source files scanned.
151    pub source_file_count: usize,
152    /// Total scan duration.
153    #[serde(serialize_with = "serialize_duration")]
154    pub elapsed: Duration,
155    /// Names of analysis passes that were skipped or failed.
156    pub skipped_passes: Vec<String>,
157    /// Number of errors found.
158    pub error_count: usize,
159    /// Number of warnings found.
160    pub warning_count: usize,
161    /// Number of info-severity findings.
162    pub info_count: usize,
163    /// Per-pass timing information (pass name → elapsed duration).
164    #[serde(serialize_with = "serialize_pass_timings")]
165    pub pass_timings: Vec<(String, Duration)>,
166}
167
168fn serialize_duration<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
169where
170    S: serde::Serializer,
171{
172    serializer.serialize_f64(duration.as_secs_f64())
173}
174
175fn serialize_pass_timings<S>(
176    timings: &[(String, Duration)],
177    serializer: S,
178) -> Result<S::Ok, S::Error>
179where
180    S: serde::Serializer,
181{
182    use serde::ser::SerializeSeq;
183    let mut seq = serializer.serialize_seq(Some(timings.len()))?;
184    for (name, duration) in timings {
185        seq.serialize_element(&serde_json::json!({
186            "pass": name,
187            "elapsed_secs": duration.as_secs_f64()
188        }))?;
189    }
190    seq.end()
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_severity_display() {
199        assert_eq!(Severity::Error.to_string(), "error");
200        assert_eq!(Severity::Warning.to_string(), "warning");
201        assert_eq!(Severity::Info.to_string(), "info");
202    }
203
204    #[test]
205    fn test_category_display() {
206        assert_eq!(Category::ErrorHandling.to_string(), "Error Handling");
207        assert_eq!(Category::Performance.to_string(), "Performance");
208        assert_eq!(Category::Security.to_string(), "Security");
209    }
210
211    #[test]
212    fn test_diagnostic_serialize() {
213        let diag = Diagnostic {
214            file_path: PathBuf::from("src/main.rs"),
215            rule: "unwrap-in-production".to_string(),
216            category: Category::ErrorHandling,
217            severity: Severity::Warning,
218            message: "Use of .unwrap() in production code".to_string(),
219            help: Some("Use ? operator or handle the error explicitly".to_string()),
220            line: Some(42),
221            column: Some(10),
222            fix: None,
223        };
224        let json = serde_json::to_value(&diag).unwrap();
225        assert_eq!(json["rule"], "unwrap-in-production");
226        assert_eq!(json["severity"], "warning");
227        assert_eq!(json["category"], "error-handling");
228        assert_eq!(json["line"], 42);
229    }
230
231    #[test]
232    fn test_diagnostic_serialize_no_optionals() {
233        let diag = Diagnostic {
234            file_path: PathBuf::from("Cargo.toml"),
235            rule: "unused-dependency".to_string(),
236            category: Category::Dependencies,
237            severity: Severity::Warning,
238            message: "Unused dependency: serde".to_string(),
239            help: None,
240            line: None,
241            column: None,
242            fix: None,
243        };
244        let json = serde_json::to_value(&diag).unwrap();
245        assert!(json.get("help").is_none());
246        assert!(json.get("line").is_none());
247        assert!(json.get("column").is_none());
248    }
249
250    #[test]
251    fn test_scan_result_serialize() {
252        let result = ScanResult {
253            diagnostics: vec![],
254            score: 100,
255            score_label: ScoreLabel::Great,
256            dimension_scores: DimensionScores {
257                security: 100,
258                reliability: 100,
259                maintainability: 100,
260                performance: 100,
261                dependencies: 100,
262            },
263            source_file_count: 10,
264            elapsed: Duration::from_millis(1500),
265            skipped_passes: vec![],
266            error_count: 0,
267            warning_count: 0,
268            info_count: 0,
269            pass_timings: vec![
270                ("clippy".to_string(), Duration::from_millis(800)),
271                ("custom rules".to_string(), Duration::from_millis(200)),
272            ],
273        };
274        let json = serde_json::to_value(&result).unwrap();
275        assert_eq!(json["score"], 100);
276        assert_eq!(json["score_label"], "Great");
277        assert_eq!(json["source_file_count"], 10);
278        assert_eq!(json["elapsed"], 1.5);
279        assert_eq!(json["error_count"], 0);
280        // Verify pass_timings serialization
281        let timings = json["pass_timings"].as_array().unwrap();
282        assert_eq!(timings.len(), 2);
283        assert_eq!(timings[0]["pass"], "clippy");
284        assert!((timings[0]["elapsed_secs"].as_f64().unwrap() - 0.8).abs() < 0.001);
285        assert_eq!(timings[1]["pass"], "custom rules");
286    }
287}