1use serde::Serialize;
12
13use crate::cli::MarsContext;
14use crate::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticLevel};
15use crate::error::MarsError;
16use crate::sync::{ResolutionMode, SyncOptions, SyncRequest};
17
18#[derive(Debug, clap::Args)]
20pub struct ValidateArgs {
21 #[arg(long)]
23 pub strict: bool,
24}
25
26#[derive(Debug, Serialize)]
28pub struct ValidateReport {
29 pub clean: bool,
31 pub diagnostics: Vec<ValidateDiagnostic>,
33 pub error_count: usize,
35 pub warning_count: usize,
38}
39
40#[derive(Debug, Serialize)]
42pub struct ValidateDiagnostic {
43 pub level: &'static str,
44 pub code: &'static str,
45 pub message: String,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub context: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub category: Option<&'static str>,
50}
51
52impl ValidateDiagnostic {
53 fn from_diagnostic(d: &Diagnostic, strict: bool) -> Self {
54 let level = effective_level(d.level, strict);
55 ValidateDiagnostic {
56 level: level_str(level),
57 code: d.code,
58 message: d.message.clone(),
59 context: d.context.clone(),
60 category: d.category.map(category_str),
61 }
62 }
63}
64
65pub fn run(args: &ValidateArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
67 let request = SyncRequest {
68 resolution: ResolutionMode::Normal,
69 mutation: None,
70 options: SyncOptions {
71 force: false,
72 dry_run: true,
73 frozen: false,
74 no_refresh_models: false,
75 },
76 };
77
78 let min_required: Option<String> = crate::config::load(&ctx.project_root)
81 .ok()
82 .and_then(|cfg| cfg.settings.min_mars_version);
83
84 let report = crate::sync::execute(ctx, &request)?;
87
88 let binary_version = env!("CARGO_PKG_VERSION");
90 let mut all_diagnostics: Vec<Diagnostic> = report.diagnostics.clone();
91 if let Some(compat_diag) =
92 crate::diagnostic::compatibility_preflight(binary_version, min_required.as_deref())
93 {
94 all_diagnostics.insert(0, compat_diag);
96 }
97
98 let error_count = all_diagnostics
100 .iter()
101 .filter(|d| effective_level(d.level, args.strict) == DiagnosticLevel::Error)
102 .count();
103 let warning_count = all_diagnostics
104 .iter()
105 .filter(|d| effective_level(d.level, args.strict) == DiagnosticLevel::Warning)
106 .count();
107 let clean = error_count == 0;
108
109 if json {
110 let validate_diags: Vec<ValidateDiagnostic> = all_diagnostics
111 .iter()
112 .map(|d| ValidateDiagnostic::from_diagnostic(d, args.strict))
113 .collect();
114 let validate_report = ValidateReport {
115 clean,
116 diagnostics: validate_diags,
117 error_count,
118 warning_count,
119 };
120 super::output::print_json(&validate_report);
121 } else {
122 print_text_report(&all_diagnostics, args.strict);
123 println!();
124 if clean {
125 super::output::print_success("validate: clean");
126 } else {
127 super::output::print_error(&format!(
128 "validate: {error_count} error(s){}",
129 if warning_count > 0 {
130 format!(", {warning_count} warning(s)")
131 } else {
132 String::new()
133 }
134 ));
135 }
136 }
137
138 if clean { Ok(0) } else { Ok(1) }
139}
140
141fn print_text_report(diagnostics: &[Diagnostic], strict: bool) {
142 for diag in diagnostics {
143 let level = effective_level(diag.level, strict);
144 let prefix = level_str(level);
145 if let Some(ctx) = &diag.context {
146 eprintln!(" {prefix}[{}]: {} ({})", diag.code, diag.message, ctx);
147 } else {
148 eprintln!(" {prefix}[{}]: {}", diag.code, diag.message);
149 }
150 }
151}
152
153fn effective_level(level: DiagnosticLevel, strict: bool) -> DiagnosticLevel {
155 if strict && level == DiagnosticLevel::Warning {
156 DiagnosticLevel::Error
157 } else {
158 level
159 }
160}
161
162fn level_str(level: DiagnosticLevel) -> &'static str {
163 match level {
164 DiagnosticLevel::Error => "error",
165 DiagnosticLevel::Warning => "warning",
166 DiagnosticLevel::Info => "info",
167 }
168}
169
170fn category_str(cat: DiagnosticCategory) -> &'static str {
171 match cat {
172 DiagnosticCategory::Compatibility => "compatibility",
173 DiagnosticCategory::Lossiness => "lossiness",
174 DiagnosticCategory::Validation => "validation",
175 DiagnosticCategory::Config => "config",
176 }
177}
178
179#[cfg(test)]
180fn validation_warning_to_diagnostic(vw: &crate::validate::ValidationWarning) -> Diagnostic {
181 use crate::validate::ValidationWarning;
182 match vw {
183 ValidationWarning::MissingSkill {
184 agent,
185 skill_name,
186 suggestion,
187 } => {
188 let message = if let Some(s) = suggestion {
189 format!(
190 "agent `{}` references missing skill `{skill_name}` (did you mean `{s}`?)",
191 agent.name
192 )
193 } else {
194 format!(
195 "agent `{}` references missing skill `{skill_name}`",
196 agent.name
197 )
198 };
199 Diagnostic {
200 level: DiagnosticLevel::Warning,
201 code: "missing-skill",
202 message,
203 context: None,
204 category: Some(DiagnosticCategory::Validation),
205 }
206 }
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::diagnostic::DiagnosticLevel;
214
215 fn make_diag(level: DiagnosticLevel) -> Diagnostic {
216 Diagnostic {
217 level,
218 code: "test",
219 message: "test message".to_string(),
220 context: None,
221 category: None,
222 }
223 }
224
225 #[test]
226 fn strict_mode_escalates_warning_to_error() {
227 let diag = make_diag(DiagnosticLevel::Warning);
228 assert_eq!(effective_level(diag.level, true), DiagnosticLevel::Error);
229 }
230
231 #[test]
232 fn strict_mode_leaves_error_as_error() {
233 let diag = make_diag(DiagnosticLevel::Error);
234 assert_eq!(effective_level(diag.level, true), DiagnosticLevel::Error);
235 }
236
237 #[test]
238 fn non_strict_leaves_warning_as_warning() {
239 let diag = make_diag(DiagnosticLevel::Warning);
240 assert_eq!(effective_level(diag.level, false), DiagnosticLevel::Warning);
241 }
242
243 #[test]
244 fn strict_mode_leaves_info_as_info() {
245 let diag = make_diag(DiagnosticLevel::Info);
246 assert_eq!(effective_level(diag.level, true), DiagnosticLevel::Info);
247 }
248
249 #[test]
250 fn validate_diag_from_diagnostic_maps_category() {
251 let diag = Diagnostic {
252 level: DiagnosticLevel::Warning,
253 code: "compat-version",
254 message: "test".to_string(),
255 context: None,
256 category: Some(DiagnosticCategory::Compatibility),
257 };
258 let vd = ValidateDiagnostic::from_diagnostic(&diag, false);
259 assert_eq!(vd.level, "warning");
260 assert_eq!(vd.category, Some("compatibility"));
261 }
262
263 #[test]
264 fn validate_diag_strict_escalation_in_json() {
265 let diag = Diagnostic {
266 level: DiagnosticLevel::Warning,
267 code: "missing-skill",
268 message: "test".to_string(),
269 context: None,
270 category: Some(DiagnosticCategory::Validation),
271 };
272 let vd = ValidateDiagnostic::from_diagnostic(&diag, true);
273 assert_eq!(
274 vd.level, "error",
275 "warning should be escalated in strict mode"
276 );
277 }
278
279 #[test]
280 fn validation_warning_missing_skill_no_suggestion() {
281 use crate::lock::{ItemId, ItemKind};
282 use crate::types::ItemName;
283 let vw = crate::validate::ValidationWarning::MissingSkill {
284 agent: ItemId {
285 kind: ItemKind::Agent,
286 name: ItemName::from("coder".to_string()),
287 },
288 skill_name: "planning".to_string(),
289 suggestion: None,
290 };
291 let diag = validation_warning_to_diagnostic(&vw);
292 assert_eq!(diag.level, DiagnosticLevel::Warning);
293 assert!(diag.message.contains("coder"));
294 assert!(diag.message.contains("planning"));
295 assert_eq!(diag.category, Some(DiagnosticCategory::Validation));
296 }
297
298 #[test]
299 fn validation_warning_missing_skill_with_suggestion() {
300 use crate::lock::{ItemId, ItemKind};
301 use crate::types::ItemName;
302 let vw = crate::validate::ValidationWarning::MissingSkill {
303 agent: ItemId {
304 kind: ItemKind::Agent,
305 name: ItemName::from("coder".to_string()),
306 },
307 skill_name: "plan".to_string(),
308 suggestion: Some("planning".to_string()),
309 };
310 let diag = validation_warning_to_diagnostic(&vw);
311 assert!(diag.message.contains("did you mean `planning`"));
312 }
313}