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::ConfigError;
16use crate::error::MarsError;
17use crate::sync::{ResolutionMode, SyncOptions, SyncRequest};
18
19/// Arguments for `mars validate`.
20#[derive(Debug, clap::Args)]
21pub struct ValidateArgs {
22    /// Escalate warnings to errors (e.g., missing env vars become errors).
23    #[arg(long)]
24    pub strict: bool,
25}
26
27/// JSON output envelope for `mars validate --json`.
28#[derive(Debug, Serialize)]
29pub struct ValidateReport {
30    /// Whether the validate run is clean (no errors after applying strictness rules).
31    pub clean: bool,
32    /// All diagnostics collected during the dry-run pipeline.
33    pub diagnostics: Vec<ValidateDiagnostic>,
34    /// Number of errors (after strictness escalation).
35    pub error_count: usize,
36    /// Number of warnings (after strictness escalation, pre-escalated warnings
37    /// that became errors are NOT counted here).
38    pub warning_count: usize,
39}
40
41/// A single diagnostic in JSON output.
42#[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
66/// Run `mars validate`.
67pub 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    // Load config to get min_mars_version for compatibility preflight.
81    // This is a lightweight read that doesn't acquire the sync lock.
82    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    // Run the pipeline in dry-run mode (no writes).
90    // ValidationWarnings are included in report.diagnostics by finalize().
91    let report = crate::sync::execute(ctx, &request)?;
92
93    // Run compatibility preflight against the binary version and project setting.
94    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        // Compatibility errors are prepended so they appear first.
100        all_diagnostics.insert(0, compat_diag);
101    }
102
103    // Compute effective counts (--strict escalates warnings to errors).
104    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
158/// When `--strict` is active, escalate Warning to Error.
159fn 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}