1use serde::Serialize;
2
3#[derive(Debug, Clone, Serialize)]
5pub struct Diagnostic {
6 pub level: DiagnosticLevel,
7 pub code: &'static str,
9 pub message: String,
11 #[serde(skip_serializing_if = "Option::is_none")]
13 pub context: Option<String>,
14 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "lowercase")]
30pub enum DiagnosticCategory {
31 Compatibility,
33 Lossiness,
35 Validation,
37 Config,
39}
40
41pub 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 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
150pub fn compatibility_preflight(
161 binary_version: &str,
162 min_required: Option<&str>,
163) -> Option<Diagnostic> {
164 let min = min_required?;
165
166 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 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
204fn 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 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 let v = parse_semver("1.2.3-beta.1").unwrap();
328 assert_eq!(v, (1, 2, 3));
329 }
330}