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 refresh_models: false,
75 no_refresh_models: false,
76 },
77 };
78
79 let min_required: Option<String> = crate::config::load(&ctx.project_root)
82 .ok()
83 .and_then(|cfg| cfg.settings.min_mars_version);
84
85 let report = crate::sync::execute(ctx, &request)?;
88
89 let binary_version = env!("CARGO_PKG_VERSION");
91 let mut all_diagnostics: Vec<Diagnostic> = report.diagnostics.clone();
92 if let Some(compat_diag) =
93 crate::diagnostic::compatibility_preflight(binary_version, min_required.as_deref())
94 {
95 all_diagnostics.insert(0, compat_diag);
97 }
98
99 let error_count = all_diagnostics
101 .iter()
102 .filter(|d| effective_level(d.level, args.strict) == DiagnosticLevel::Error)
103 .count();
104 let warning_count = all_diagnostics
105 .iter()
106 .filter(|d| effective_level(d.level, args.strict) == DiagnosticLevel::Warning)
107 .count();
108 let clean = error_count == 0;
109
110 if json {
111 let validate_diags: Vec<ValidateDiagnostic> = all_diagnostics
112 .iter()
113 .map(|d| ValidateDiagnostic::from_diagnostic(d, args.strict))
114 .collect();
115 let validate_report = ValidateReport {
116 clean,
117 diagnostics: validate_diags,
118 error_count,
119 warning_count,
120 };
121 super::output::print_json(&validate_report);
122 } else {
123 print_text_report(&all_diagnostics, args.strict);
124 println!();
125 if clean {
126 super::output::print_success("validate: clean");
127 } else {
128 super::output::print_error(&format!(
129 "validate: {error_count} error(s){}",
130 if warning_count > 0 {
131 format!(", {warning_count} warning(s)")
132 } else {
133 String::new()
134 }
135 ));
136 }
137 }
138
139 if clean { Ok(0) } else { Ok(1) }
140}
141
142fn print_text_report(diagnostics: &[Diagnostic], strict: bool) {
143 for diag in diagnostics {
144 let level = effective_level(diag.level, strict);
145 let prefix = level_str(level);
146 if let Some(ctx) = &diag.context {
147 eprintln!(" {prefix}[{}]: {} ({})", diag.code, diag.message, ctx);
148 } else {
149 eprintln!(" {prefix}[{}]: {}", diag.code, diag.message);
150 }
151 }
152}
153
154fn effective_level(level: DiagnosticLevel, strict: bool) -> DiagnosticLevel {
156 if strict && level == DiagnosticLevel::Warning {
157 DiagnosticLevel::Error
158 } else {
159 level
160 }
161}
162
163fn level_str(level: DiagnosticLevel) -> &'static str {
164 match level {
165 DiagnosticLevel::Error => "error",
166 DiagnosticLevel::Warning => "warning",
167 DiagnosticLevel::Info => "info",
168 }
169}
170
171fn category_str(cat: DiagnosticCategory) -> &'static str {
172 match cat {
173 DiagnosticCategory::Compatibility => "compatibility",
174 DiagnosticCategory::Lossiness => "lossiness",
175 DiagnosticCategory::Validation => "validation",
176 DiagnosticCategory::Config => "config",
177 }
178}
179
180#[cfg(test)]
181fn validation_warning_to_diagnostic(vw: &crate::validate::ValidationWarning) -> Diagnostic {
182 use crate::validate::ValidationWarning;
183 match vw {
184 ValidationWarning::MissingSkill {
185 agent,
186 skill_name,
187 suggestion,
188 } => {
189 let message = if let Some(s) = suggestion {
190 format!(
191 "agent `{}` references missing skill `{skill_name}` (did you mean `{s}`?)",
192 agent.name
193 )
194 } else {
195 format!(
196 "agent `{}` references missing skill `{skill_name}`",
197 agent.name
198 )
199 };
200 Diagnostic {
201 level: DiagnosticLevel::Warning,
202 code: "missing-skill",
203 message,
204 context: None,
205 category: Some(DiagnosticCategory::Validation),
206 }
207 }
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::diagnostic::DiagnosticLevel;
215
216 fn make_diag(level: DiagnosticLevel) -> Diagnostic {
217 Diagnostic {
218 level,
219 code: "test",
220 message: "test message".to_string(),
221 context: None,
222 category: None,
223 }
224 }
225
226 #[test]
227 fn strict_mode_escalates_warning_to_error() {
228 let diag = make_diag(DiagnosticLevel::Warning);
229 assert_eq!(effective_level(diag.level, true), DiagnosticLevel::Error);
230 }
231
232 #[test]
233 fn strict_mode_leaves_error_as_error() {
234 let diag = make_diag(DiagnosticLevel::Error);
235 assert_eq!(effective_level(diag.level, true), DiagnosticLevel::Error);
236 }
237
238 #[test]
239 fn non_strict_leaves_warning_as_warning() {
240 let diag = make_diag(DiagnosticLevel::Warning);
241 assert_eq!(effective_level(diag.level, false), DiagnosticLevel::Warning);
242 }
243
244 #[test]
245 fn strict_mode_leaves_info_as_info() {
246 let diag = make_diag(DiagnosticLevel::Info);
247 assert_eq!(effective_level(diag.level, true), DiagnosticLevel::Info);
248 }
249
250 #[test]
251 fn validate_diag_from_diagnostic_maps_category() {
252 let diag = Diagnostic {
253 level: DiagnosticLevel::Warning,
254 code: "compat-version",
255 message: "test".to_string(),
256 context: None,
257 category: Some(DiagnosticCategory::Compatibility),
258 };
259 let vd = ValidateDiagnostic::from_diagnostic(&diag, false);
260 assert_eq!(vd.level, "warning");
261 assert_eq!(vd.category, Some("compatibility"));
262 }
263
264 #[test]
265 fn validate_diag_strict_escalation_in_json() {
266 let diag = Diagnostic {
267 level: DiagnosticLevel::Warning,
268 code: "missing-skill",
269 message: "test".to_string(),
270 context: None,
271 category: Some(DiagnosticCategory::Validation),
272 };
273 let vd = ValidateDiagnostic::from_diagnostic(&diag, true);
274 assert_eq!(
275 vd.level, "error",
276 "warning should be escalated in strict mode"
277 );
278 }
279
280 #[test]
281 fn validation_warning_missing_skill_no_suggestion() {
282 use crate::lock::{ItemId, ItemKind};
283 use crate::types::ItemName;
284 let vw = crate::validate::ValidationWarning::MissingSkill {
285 agent: ItemId {
286 kind: ItemKind::Agent,
287 name: ItemName::from("coder".to_string()),
288 },
289 skill_name: "planning".to_string(),
290 suggestion: None,
291 };
292 let diag = validation_warning_to_diagnostic(&vw);
293 assert_eq!(diag.level, DiagnosticLevel::Warning);
294 assert!(diag.message.contains("coder"));
295 assert!(diag.message.contains("planning"));
296 assert_eq!(diag.category, Some(DiagnosticCategory::Validation));
297 }
298
299 #[test]
300 fn validation_warning_missing_skill_with_suggestion() {
301 use crate::lock::{ItemId, ItemKind};
302 use crate::types::ItemName;
303 let vw = crate::validate::ValidationWarning::MissingSkill {
304 agent: ItemId {
305 kind: ItemKind::Agent,
306 name: ItemName::from("coder".to_string()),
307 },
308 skill_name: "plan".to_string(),
309 suggestion: Some("planning".to_string()),
310 };
311 let diag = validation_warning_to_diagnostic(&vw);
312 assert!(diag.message.contains("did you mean `planning`"));
313 }
314}