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