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