Skip to main content

mars_agents/cli/
validate.rs

1//! `mars validate` — dry-run compiler that reports diagnostics without writing.
2//!
3//! Runs the same reader/compiler pipeline as `mars sync --diff` but stops
4//! before any writes and reports diagnostics with structured output options.
5//!
6//! Output modes:
7//! - Normal: print diagnostics, exit 0 if clean, exit 1 if errors present.
8//! - `--strict`: escalate warnings to errors (missing env vars, etc.).
9//! - `--json`: emit diagnostics as structured JSON for tooling.
10
11use 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/// Arguments for `mars validate`.
19#[derive(Debug, clap::Args)]
20pub struct ValidateArgs {
21    /// Escalate warnings to errors (e.g., missing env vars become errors).
22    #[arg(long)]
23    pub strict: bool,
24}
25
26/// JSON output envelope for `mars validate --json`.
27#[derive(Debug, Serialize)]
28pub struct ValidateReport {
29    /// Whether the validate run is clean (no errors after applying strictness rules).
30    pub clean: bool,
31    /// All diagnostics collected during the dry-run pipeline.
32    pub diagnostics: Vec<ValidateDiagnostic>,
33    /// Number of errors (after strictness escalation).
34    pub error_count: usize,
35    /// Number of warnings (after strictness escalation, pre-escalated warnings
36    /// that became errors are NOT counted here).
37    pub warning_count: usize,
38}
39
40/// A single diagnostic in JSON output.
41#[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
65/// Run `mars validate`.
66pub 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    // Load config to get min_mars_version for compatibility preflight.
80    // This is a lightweight read that doesn't acquire the sync lock.
81    let min_required: Option<String> = crate::config::load(&ctx.project_root)
82        .ok()
83        .and_then(|cfg| cfg.settings.min_mars_version);
84
85    // Run the pipeline in dry-run mode (no writes).
86    // ValidationWarnings are included in report.diagnostics by finalize().
87    let report = crate::sync::execute(ctx, &request)?;
88
89    // Run compatibility preflight against the binary version and project setting.
90    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        // Compatibility errors are prepended so they appear first.
96        all_diagnostics.insert(0, compat_diag);
97    }
98
99    // Compute effective counts (--strict escalates warnings to errors).
100    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
154/// When `--strict` is active, escalate Warning to Error.
155fn 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}