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