Skip to main content

mars_agents/
diagnostic.rs

1use serde::Serialize;
2
3/// A diagnostic message from library code.
4#[derive(Debug, Clone, Serialize)]
5pub struct Diagnostic {
6    pub level: DiagnosticLevel,
7    /// Machine-readable code, e.g. "shadow-collision", "manifest-path-dep".
8    pub code: &'static str,
9    /// Human-readable message.
10    pub message: String,
11    /// Optional context (source name, item path, etc.).
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub context: Option<String>,
14    /// Diagnostic category for tooling and structured output.
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub category: Option<DiagnosticCategory>,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "lowercase")]
21pub enum DiagnosticLevel {
22    Error,
23    Warning,
24    Info,
25}
26
27/// Broad category for a diagnostic — used in structured output and validation gates.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "lowercase")]
30pub enum DiagnosticCategory {
31    /// Compatibility and version requirement issues.
32    Compatibility,
33    /// Lossiness during lowering to a target (dropped/approximate fields).
34    Lossiness,
35    /// Schema validation and structural checks.
36    Validation,
37    /// Configuration file issues.
38    Config,
39}
40
41/// Collects diagnostics during pipeline execution.
42pub struct DiagnosticCollector {
43    diagnostics: Vec<Diagnostic>,
44}
45
46impl DiagnosticCollector {
47    pub fn new() -> Self {
48        Self {
49            diagnostics: Vec::new(),
50        }
51    }
52
53    pub fn error(&mut self, code: &'static str, message: impl Into<String>) {
54        self.diagnostics.push(Diagnostic {
55            level: DiagnosticLevel::Error,
56            code,
57            message: message.into(),
58            context: None,
59            category: None,
60        });
61    }
62
63    pub fn error_with_category(
64        &mut self,
65        code: &'static str,
66        message: impl Into<String>,
67        category: DiagnosticCategory,
68    ) {
69        self.diagnostics.push(Diagnostic {
70            level: DiagnosticLevel::Error,
71            code,
72            message: message.into(),
73            context: None,
74            category: Some(category),
75        });
76    }
77
78    pub fn warn(&mut self, code: &'static str, message: impl Into<String>) {
79        self.diagnostics.push(Diagnostic {
80            level: DiagnosticLevel::Warning,
81            code,
82            message: message.into(),
83            context: None,
84            category: None,
85        });
86    }
87
88    pub fn info(&mut self, code: &'static str, message: impl Into<String>) {
89        self.diagnostics.push(Diagnostic {
90            level: DiagnosticLevel::Info,
91            code,
92            message: message.into(),
93            context: None,
94            category: None,
95        });
96    }
97
98    pub fn warn_with_context(
99        &mut self,
100        code: &'static str,
101        message: impl Into<String>,
102        context: impl Into<String>,
103    ) {
104        self.diagnostics.push(Diagnostic {
105            level: DiagnosticLevel::Warning,
106            code,
107            message: message.into(),
108            context: Some(context.into()),
109            category: None,
110        });
111    }
112
113    pub fn extend(&mut self, diagnostics: Vec<Diagnostic>) {
114        self.diagnostics.extend(diagnostics);
115    }
116
117    pub fn drain(&mut self) -> Vec<Diagnostic> {
118        std::mem::take(&mut self.diagnostics)
119    }
120
121    pub fn is_empty(&self) -> bool {
122        self.diagnostics.is_empty()
123    }
124
125    /// Returns true if any Error-level diagnostic has been collected.
126    pub fn has_errors(&self) -> bool {
127        self.diagnostics
128            .iter()
129            .any(|d| d.level == DiagnosticLevel::Error)
130    }
131}
132
133impl Default for DiagnosticCollector {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl std::fmt::Display for Diagnostic {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        let prefix = match self.level {
142            DiagnosticLevel::Error => "error",
143            DiagnosticLevel::Warning => "warning",
144            DiagnosticLevel::Info => "info",
145        };
146        write!(f, "{prefix}: {}", self.message)
147    }
148}
149
150/// Compatibility preflight: check whether the current binary version satisfies a
151/// `min_mars_version` requirement declared by the project's mars.toml.
152///
153/// Returns `None` if compatible (or no requirement is declared).
154/// Returns `Some(Diagnostic)` with `Error` level if the binary is too old.
155///
156/// Rule:
157/// - `min_required` is `None` → always compatible (old package without requirement)
158/// - `binary_version >= min_required` → compatible
159/// - `binary_version < min_required` → error: binary too old
160pub fn compatibility_preflight(
161    binary_version: &str,
162    min_required: Option<&str>,
163) -> Option<Diagnostic> {
164    let min = min_required?;
165
166    // Parse as semver. If either fails to parse, accept and emit a warning instead.
167    let bin_ver = parse_semver(binary_version);
168    let req_ver = parse_semver(min);
169
170    match (bin_ver, req_ver) {
171        (Some(bin), Some(req)) => {
172            if bin < req {
173                Some(Diagnostic {
174                    level: DiagnosticLevel::Error,
175                    code: "compat-version",
176                    message: format!(
177                        "this project requires mars >= {min} but the installed binary is {binary_version}; \
178                         upgrade with: cargo install mars-agents"
179                    ),
180                    context: None,
181                    category: Some(DiagnosticCategory::Compatibility),
182                })
183            } else {
184                None
185            }
186        }
187        _ => {
188            // Unparseable version strings — warn and continue (forward compat: new package,
189            // unknown version scheme → don't hard-block the consumer).
190            Some(Diagnostic {
191                level: DiagnosticLevel::Warning,
192                code: "compat-version-parse",
193                message: format!(
194                    "could not compare mars version `{binary_version}` against requirement `{min}`; \
195                     proceeding with defaults"
196                ),
197                context: None,
198                category: Some(DiagnosticCategory::Compatibility),
199            })
200        }
201    }
202}
203
204/// Minimal semver parser: returns `(major, minor, patch)` tuple from "X.Y.Z" or "vX.Y.Z".
205fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
206    let s = s.trim_start_matches('v');
207    let parts: Vec<&str> = s.split('.').collect();
208    if parts.len() < 3 {
209        return None;
210    }
211    let major = parts[0].parse::<u64>().ok()?;
212    let minor = parts[1].parse::<u64>().ok()?;
213    // Allow patch to have pre-release suffix like "1-beta.1"
214    let patch_str = parts[2].split('-').next().unwrap_or(parts[2]);
215    let patch = patch_str.parse::<u64>().ok()?;
216    Some((major, minor, patch))
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn no_requirement_always_compatible() {
225        let diag = compatibility_preflight("0.5.0", None);
226        assert!(diag.is_none());
227    }
228
229    #[test]
230    fn binary_meets_requirement() {
231        let diag = compatibility_preflight("1.2.0", Some("1.0.0"));
232        assert!(diag.is_none());
233    }
234
235    #[test]
236    fn binary_exactly_meets_requirement() {
237        let diag = compatibility_preflight("1.0.0", Some("1.0.0"));
238        assert!(diag.is_none());
239    }
240
241    #[test]
242    fn binary_too_old_produces_error() {
243        let diag = compatibility_preflight("0.5.0", Some("1.0.0")).unwrap();
244        assert_eq!(diag.level, DiagnosticLevel::Error);
245        assert_eq!(diag.code, "compat-version");
246        assert_eq!(diag.category, Some(DiagnosticCategory::Compatibility));
247        assert!(diag.message.contains("0.5.0"));
248        assert!(diag.message.contains("1.0.0"));
249    }
250
251    #[test]
252    fn binary_v_prefix_handled() {
253        let diag = compatibility_preflight("v1.2.0", Some("v1.0.0"));
254        assert!(diag.is_none());
255    }
256
257    #[test]
258    fn binary_v_prefix_too_old() {
259        let diag = compatibility_preflight("v0.9.0", Some("v1.0.0")).unwrap();
260        assert_eq!(diag.level, DiagnosticLevel::Error);
261    }
262
263    #[test]
264    fn unparseable_version_produces_warning_not_error() {
265        let diag = compatibility_preflight("dev", Some("1.0.0")).unwrap();
266        assert_eq!(diag.level, DiagnosticLevel::Warning);
267        assert_eq!(diag.code, "compat-version-parse");
268    }
269
270    #[test]
271    fn unparseable_requirement_produces_warning() {
272        let diag = compatibility_preflight("1.0.0", Some("latest")).unwrap();
273        assert_eq!(diag.level, DiagnosticLevel::Warning);
274    }
275
276    #[test]
277    fn collector_has_errors_detects_error_level() {
278        let mut coll = DiagnosticCollector::new();
279        assert!(!coll.has_errors());
280        coll.warn("w", "a warning");
281        assert!(!coll.has_errors());
282        coll.error("e", "an error");
283        assert!(coll.has_errors());
284    }
285
286    #[test]
287    fn collector_error_with_category() {
288        let mut coll = DiagnosticCollector::new();
289        coll.error_with_category(
290            "compat-version",
291            "too old",
292            DiagnosticCategory::Compatibility,
293        );
294        let diags = coll.drain();
295        assert_eq!(diags.len(), 1);
296        assert_eq!(diags[0].level, DiagnosticLevel::Error);
297        assert_eq!(diags[0].category, Some(DiagnosticCategory::Compatibility));
298    }
299
300    #[test]
301    fn display_shows_error_prefix() {
302        let d = Diagnostic {
303            level: DiagnosticLevel::Error,
304            code: "test",
305            message: "something broke".to_string(),
306            context: None,
307            category: None,
308        };
309        assert_eq!(d.to_string(), "error: something broke");
310    }
311
312    #[test]
313    fn display_shows_warning_prefix() {
314        let d = Diagnostic {
315            level: DiagnosticLevel::Warning,
316            code: "test",
317            message: "heads up".to_string(),
318            context: None,
319            category: None,
320        };
321        assert_eq!(d.to_string(), "warning: heads up");
322    }
323
324    #[test]
325    fn patch_with_prerelease_suffix_parsed() {
326        // "1.2.3-beta.1" → (1, 2, 3)
327        let v = parse_semver("1.2.3-beta.1").unwrap();
328        assert_eq!(v, (1, 2, 3));
329    }
330}