Skip to main content

leviath_cli/commands/
validate.rs

1//! `lev validate` - Validate an agent blueprint.
2
3use clap::Args;
4use std::path::PathBuf;
5
6use crate::lint::{LintEnv, LintFinding, LintSeverity, lint_manifest};
7
8#[derive(Args)]
9pub struct ValidateArgs {
10    /// Path to the agent directory or agent.leviath file
11    #[arg(default_value = ".")]
12    pub(crate) path: String,
13
14    /// Fail on warnings too, not only errors. Notes never fail.
15    #[arg(long)]
16    pub(crate) deny_warnings: bool,
17
18    /// Report the blueprint and every finding as JSON instead of prose. The
19    /// exit status is unchanged, so a caller can branch on either.
20    #[arg(long)]
21    pub(crate) json: bool,
22}
23
24/// The blueprint itself, for a caller that wants to know what it just validated.
25#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
26pub struct BlueprintSummary {
27    pub name: String,
28    pub version: String,
29    pub description: String,
30    /// Null when the manifest names no `entry_stage`, in which case the first
31    /// stage is the entry.
32    pub entry_stage: Option<String>,
33    /// Stage names in blueprint order.
34    pub stages: Vec<String>,
35}
36
37/// What `lev validate --json` prints.
38///
39/// One shape for every outcome, so a caller parses once and branches on
40/// `valid`. A manifest that did not parse fills `error` and leaves `blueprint`
41/// null; one that did fills `blueprint` and leaves `error` null. `code` on each
42/// finding is a stable slug to branch on, where the prose line is written to be
43/// read.
44#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
45pub struct ValidateReport {
46    /// True when nothing would have failed the command.
47    pub valid: bool,
48    /// Present when the manifest parsed and validated.
49    pub blueprint: Option<BlueprintSummary>,
50    /// Present when it did not.
51    pub error: Option<String>,
52    pub findings: Vec<LintFinding>,
53    pub errors: usize,
54    pub warnings: usize,
55    pub notes: usize,
56}
57
58impl ValidateReport {
59    /// The report for a manifest that got as far as linting.
60    fn linted(
61        blueprint: &leviath_core::Blueprint,
62        findings: Vec<LintFinding>,
63        deny_warnings: bool,
64    ) -> Self {
65        let count = |want: LintSeverity| findings.iter().filter(|f| f.severity == want).count();
66        let (errors, warnings) = (count(LintSeverity::Error), count(LintSeverity::Warning));
67        Self {
68            // Mirrors the exit-status rule exactly: notes never fail a build.
69            valid: errors == 0 && !(deny_warnings && warnings > 0),
70            blueprint: Some(BlueprintSummary {
71                name: blueprint.name.clone(),
72                version: blueprint.version.clone(),
73                description: blueprint.description.clone(),
74                entry_stage: blueprint.entry_stage.clone(),
75                stages: blueprint.stages.iter().map(|s| s.name.clone()).collect(),
76            }),
77            error: None,
78            errors,
79            warnings,
80            notes: count(LintSeverity::Note),
81            findings,
82        }
83    }
84
85    /// The report for a manifest that never parsed or never validated.
86    fn failed(error: String) -> Self {
87        Self {
88            valid: false,
89            blueprint: None,
90            error: Some(error),
91            findings: Vec::new(),
92            errors: 1,
93            warnings: 0,
94            notes: 0,
95        }
96    }
97
98    fn print(&self) {
99        // Owned scalars and vectors with no map keys to reject, so this cannot
100        // fail.
101        println!(
102            "{}",
103            serde_json::to_string_pretty(self).expect("a validate report serializes")
104        );
105    }
106}
107
108/// Resolve, read, parse, and validate the manifest at `path`. Distinguishes
109/// I/O failures (propagated as a normal error) from parse/validation
110/// failures (which `execute()` reports specially and exits(1) on) so the
111/// core logic can be unit tested without killing the test process.
112#[derive(Debug)]
113enum ManifestCheckError {
114    Io(anyhow::Error),
115    Parse(String),
116    Validation(String),
117}
118
119/// A manifest that parsed and validated, kept alongside the text it came from
120/// so the linter can ask what the author actually wrote.
121#[derive(Debug)]
122struct CheckedManifest {
123    blueprint: leviath_core::Blueprint,
124    content: String,
125    /// The directory holding the manifest: where its `tools/` live.
126    agent_dir: PathBuf,
127}
128
129fn check_manifest(path: &std::path::Path) -> Result<CheckedManifest, ManifestCheckError> {
130    // Resolve manifest path
131    let manifest_path = if path.is_file() {
132        path.to_path_buf()
133    } else {
134        let p = path.join("agent.leviath");
135        if !p.exists() {
136            return Err(ManifestCheckError::Io(anyhow::anyhow!(
137                "No agent.leviath found at {}",
138                path.display()
139            )));
140        }
141        p
142    };
143
144    let content = std::fs::read_to_string(&manifest_path).map_err(|e| {
145        ManifestCheckError::Io(anyhow::anyhow!(
146            "Failed to read {}: {}",
147            manifest_path.display(),
148            e
149        ))
150    })?;
151
152    let blueprint = leviath_core::manifest::parse_manifest(&content)
153        .map_err(|e| ManifestCheckError::Parse(e.to_string()))?;
154
155    blueprint
156        .validate()
157        .map_err(|e| ManifestCheckError::Validation(e.to_string()))?;
158
159    // Custom regions' Rhai scripts must resolve to readable, compilable
160    // files with a well-formed `fn render(ctx)` - the same check a spawn
161    // performs, surfaced here where a typo'd path or syntax error is cheap
162    // to find.
163    crate::daemon::spawn::resolve_region_scripts(&blueprint, &manifest_path.to_string_lossy())
164        .map_err(ManifestCheckError::Validation)?;
165
166    let agent_dir = manifest_path
167        .parent()
168        .map(std::path::Path::to_path_buf)
169        .unwrap_or_default();
170    Ok(CheckedManifest {
171        blueprint,
172        content,
173        agent_dir,
174    })
175}
176
177/// Print the "valid blueprint" summary + non-fatal warnings.
178fn print_success(blueprint: &leviath_core::Blueprint) {
179    println!("✓ Blueprint '{}' is valid.", blueprint.name);
180    println!(
181        "  {} stages, version {}",
182        blueprint.stages.len(),
183        blueprint.version
184    );
185
186    // Check if graph mode
187    let is_graph = blueprint.stages.iter().any(|s| s.transitions.is_some());
188    if is_graph {
189        let entry = blueprint.resolve_entry_stage_name();
190        println!("  Graph mode: entry stage '{}'", entry);
191
192        // List stages and their transitions
193        for stage in &blueprint.stages {
194            let transitions_info = match &stage.transitions {
195                Some(t) if !t.is_empty() => {
196                    let targets: Vec<&str> = t.keys().map(|k| k.as_str()).collect();
197                    format!(" → {}", targets.join(", "))
198                }
199                Some(_) => " (terminal)".to_string(),
200                None => " (linear)".to_string(),
201            };
202            let revisits = stage
203                .max_revisits
204                .map(|n| format!(" (max_revisits: {})", n))
205                .unwrap_or_default();
206            println!("  - {}{}{}", stage.name, transitions_info, revisits);
207        }
208    } else {
209        println!(
210            "  Linear mode: {}",
211            blueprint
212                .stages
213                .iter()
214                .map(|s| s.name.as_str())
215                .collect::<Vec<_>>()
216                .join(" → ")
217        );
218    }
219}
220
221/// Outcome of the real, testable logic in [`execute`]. Kept distinct from
222/// the actual failure reporting so `execute_reporting_outcome` - and therefore
223/// every branch of `check_manifest`'s error handling - can be unit tested.
224#[derive(Debug)]
225enum ValidateOutcome {
226    Success,
227    ParseError(String),
228    ValidationError(String),
229    /// The manifest is structurally fine but the lint found something fatal:
230    /// how many errors, and how many warnings (which only count when
231    /// `--deny-warnings` was passed).
232    LintFailed {
233        errors: usize,
234        warnings: usize,
235    },
236}
237
238/// Print `findings` worst-first, one per line with its fix indented under it.
239///
240/// Returns the counts so the caller can decide the exit status without walking
241/// the list again.
242fn print_findings(findings: &[LintFinding]) -> (usize, usize) {
243    let mut errors = 0;
244    let mut warnings = 0;
245    for finding in findings {
246        match finding.severity {
247            LintSeverity::Error => errors += 1,
248            LintSeverity::Warning => warnings += 1,
249            LintSeverity::Note => {}
250        }
251        println!(
252            "  {} {} [{}]",
253            finding.severity.label(),
254            finding.one_line(),
255            finding.code
256        );
257        if let Some(fix) = &finding.fix {
258            println!("       {fix}");
259        }
260    }
261    (errors, warnings)
262}
263
264/// The command core. `config` is the user's configuration when it could be
265/// loaded, and is only used to answer "can this install reach the providers
266/// this blueprint names" - a config that will not load is not a reason to
267/// refuse to lint, it only means that one check has nothing to say. Taking it
268/// as an argument keeps this function hermetic; the real
269/// [`Config::load`](crate::config::Config::load) happens in [`execute`].
270fn execute_reporting_outcome(
271    args: &ValidateArgs,
272    config: Option<&crate::config::Config>,
273) -> anyhow::Result<ValidateOutcome> {
274    let path = PathBuf::from(&args.path);
275
276    let checked = match check_manifest(&path) {
277        Ok(c) => c,
278        Err(ManifestCheckError::Io(e)) => return Err(e),
279        Err(ManifestCheckError::Parse(e)) => {
280            if args.json {
281                ValidateReport::failed(format!("parse error: {e}")).print();
282            }
283            return Ok(ValidateOutcome::ParseError(e));
284        }
285        Err(ManifestCheckError::Validation(e)) => {
286            if args.json {
287                ValidateReport::failed(format!("validation failed: {e}")).print();
288            }
289            return Ok(ValidateOutcome::ValidationError(e));
290        }
291    };
292
293    // The human report is three separate printers. JSON is one document, so it
294    // is built after the lint and emitted once, and none of these run.
295    if !args.json {
296        print_success(&checked.blueprint);
297        print_script_tool_report(&path);
298    }
299
300    let mut env = LintEnv::offline(&checked.agent_dir);
301    if let Some(config) = config {
302        // The directory the command was run from is the workdir a `lev run`
303        // would default to, so it is what relative `[read_paths]` entries
304        // resolve against.
305        let workdir = crate::commands::resolve_cwd().unwrap_or_default();
306        env = env
307            .with_providers(&checked.blueprint, config)
308            .with_read_paths(&checked.blueprint, config, &workdir);
309    }
310    let findings = lint_manifest(&checked.content, &checked.blueprint, &env);
311    let (errors, warnings) = match args.json {
312        true => {
313            let report = ValidateReport::linted(&checked.blueprint, findings, args.deny_warnings);
314            report.print();
315            (report.errors, report.warnings)
316        }
317        false => print_findings(&findings),
318    };
319
320    if errors > 0 || (args.deny_warnings && warnings > 0) {
321        return Ok(ValidateOutcome::LintFailed { errors, warnings });
322    }
323    Ok(ValidateOutcome::Success)
324}
325
326/// The failure line for a lint that came back fatal. Split out so its
327/// pluralization is assertable without capturing stdout.
328fn lint_failure_message(errors: usize, warnings: usize, deny_warnings: bool) -> String {
329    let mut parts = Vec::new();
330    if errors > 0 {
331        parts.push(format!("{errors} error{}", plural(errors)));
332    }
333    if deny_warnings && warnings > 0 {
334        parts.push(format!(
335            "{warnings} warning{} (--deny-warnings)",
336            plural(warnings)
337        ));
338    }
339    format!("✗ Blueprint has {}", parts.join(" and "))
340}
341
342fn plural(n: usize) -> &'static str {
343    if n == 1 { "" } else { "s" }
344}
345
346/// Validate the agent's own Rhai script tools: discover the agent
347/// directory's `tools/` and report how many compiled, warning (non-fatal, like
348/// the daemon's own skip-and-warn) about any that failed. A missing `tools/` dir
349/// prints nothing.
350fn print_script_tool_report(path: &std::path::Path) {
351    // The agent dir is the manifest's parent (file path) or the path itself (dir).
352    let agent_dir = if path.is_file() {
353        path.parent().unwrap_or(path).to_path_buf()
354    } else {
355        path.to_path_buf()
356    };
357    let tools_dir = agent_dir.join("tools");
358    if !tools_dir.is_dir() {
359        return;
360    }
361    let (set, skipped) = leviath_scripting::ScriptToolSet::discover(&[tools_dir]);
362    if !set.is_empty() {
363        println!("  {} script tool(s) in tools/", set.len());
364    }
365    // A tool that compiles but whose `@requires` the platform can't satisfy won't
366    // load - flag it (this also catches an unknown/typo'd capability name).
367    for meta in set.metas() {
368        if !crate::daemon::spawn::current_platform_satisfies(&meta.required_caps) {
369            println!(
370                "  ⚠ Warning: script tool '{}' won't load here (unsatisfiable @requires: {})",
371                meta.name,
372                meta.required_caps.join(", ")
373            );
374        }
375    }
376    for s in &skipped {
377        println!(
378            "  ⚠ Warning: script tool '{}' skipped: {}",
379            s.path.display(),
380            s.reason
381        );
382    }
383}
384
385pub async fn execute(args: ValidateArgs) -> anyhow::Result<()> {
386    let config = crate::config::Config::load().ok();
387    match execute_reporting_outcome(&args, config.as_ref())? {
388        ValidateOutcome::Success => Ok(()),
389        ValidateOutcome::ParseError(e) => anyhow::bail!("✗ Parse error: {}", e),
390        ValidateOutcome::ValidationError(e) => anyhow::bail!("✗ Validation failed: {}", e),
391        ValidateOutcome::LintFailed { errors, warnings } => {
392            anyhow::bail!(lint_failure_message(errors, warnings, args.deny_warnings))
393        }
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::test_support::write_test_agent;
401
402    /// A minimal manifest that lints clean, so a test can add exactly the one
403    /// defect it is about.
404    ///
405    /// Ollama is last in the models list because it registers with no
406    /// credential: under the isolated config these tests run against, a
407    /// blueprint naming only keyed providers would (correctly) warn that
408    /// nothing in its list is reachable.
409    const CLEAN_MANIFEST: &str = r#"
410[agent]
411name = "ok-agent"
412version = "0.1.0"
413description = "Valid"
414
415[stages.main]
416mode = "autonomous"
417model = { models = [{ provider = "anthropic", model = "claude-sonnet-5" }, { provider = "ollama", model = "qwen3.5:9b" }] }
418description = "Main"
419max_iterations = 5
420
421[context.regions]
422system = { kind = "pinned", max_tokens = 1000 }
423conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
424"#;
425
426    fn write_manifest(dir: &std::path::Path, content: &str) -> std::path::PathBuf {
427        let path = dir.join("agent.leviath");
428        std::fs::write(&path, content).unwrap();
429        path
430    }
431
432    fn args_for(dir: &std::path::Path) -> ValidateArgs {
433        ValidateArgs {
434            path: dir.to_str().unwrap().to_string(),
435            deny_warnings: false,
436            json: false,
437        }
438    }
439
440    // ─── print_success ───────────────────────────────────────────────────
441
442    fn parse(toml: &str) -> leviath_core::Blueprint {
443        leviath_core::manifest::parse_manifest(toml).unwrap()
444    }
445
446    /// Helper to create a minimal valid blueprint TOML with given stages.
447    fn make_blueprint_toml(stages_toml: &str) -> String {
448        format!(
449            r#"
450[agent]
451name = "test"
452version = "0.1.0"
453description = "test blueprint"
454
455{stages_toml}
456
457[context.regions]
458system = {{ kind = "pinned", max_tokens = 1000 }}
459conversation = {{ kind = "sliding_window", max_items = 50, max_tokens = 10000 }}
460"#
461        )
462    }
463
464    #[test]
465    fn print_success_linear_mode_no_panic() {
466        let toml = make_blueprint_toml(
467            r#"
468[stages.main]
469mode = "autonomous"
470model = { provider = "anthropic", model = "claude-sonnet-4-6" }
471description = "Main stage"
472max_iterations = 5
473
474[stages.review]
475mode = "autonomous"
476model = { provider = "anthropic", model = "claude-sonnet-4-6" }
477description = "Review stage"
478max_iterations = 5
479"#,
480        );
481        print_success(&parse(&toml));
482    }
483
484    #[test]
485    fn print_success_graph_mode_with_terminal_and_revisits_no_panic() {
486        let toml = make_blueprint_toml(
487            r#"
488[stages.a]
489mode = "autonomous"
490model = { provider = "anthropic", model = "claude-sonnet-4-6" }
491description = "A"
492max_iterations = 5
493entry = true
494max_revisits = 3
495[stages.a.transitions]
496b = "true"
497
498[stages.b]
499mode = "autonomous"
500model = { provider = "anthropic", model = "claude-sonnet-4-6" }
501description = "B"
502max_iterations = 5
503"#,
504        );
505        // Exercises: graph mode header, an edge with a target ("-> b"), and
506        // stage "b" which has transitions = None ("(linear)" branch) as well
507        // as the max_revisits formatting on stage "a".
508        print_success(&parse(&toml));
509    }
510
511    #[test]
512    fn print_success_graph_mode_terminal_stage_no_panic() {
513        let toml = make_blueprint_toml(
514            r#"
515[stages.a]
516mode = "autonomous"
517model = { provider = "anthropic", model = "claude-sonnet-4-6" }
518description = "A"
519max_iterations = 5
520entry = true
521[stages.a.transitions]
522b = "true"
523
524[stages.b]
525mode = "autonomous"
526model = { provider = "anthropic", model = "claude-sonnet-4-6" }
527description = "B"
528max_iterations = 5
529[stages.b.transitions]
530"#,
531        );
532        let bp = parse(&toml);
533        // Stage "b" has an explicitly-empty transitions table -> Some(empty
534        // map) -> exercises the "(terminal)" formatting branch.
535        let b = bp.find_stage("b").unwrap();
536        assert!(matches!(&b.transitions, Some(t) if t.is_empty()));
537        print_success(&bp);
538    }
539
540    // ─── print_findings ──────────────────────────────────────────────────
541
542    /// One finding of each severity: the counts returned are errors and
543    /// warnings only, because a note must never fail anything.
544    #[test]
545    fn print_findings_counts_errors_and_warnings_but_not_notes() {
546        let findings = [
547            (LintSeverity::Error, "e"),
548            (LintSeverity::Error, "e2"),
549            (LintSeverity::Warning, "w"),
550            (LintSeverity::Note, "n"),
551        ]
552        .map(|(severity, code)| LintFinding {
553            severity,
554            code,
555            stage: Some("main".to_string()),
556            message: "something".to_string(),
557            // Alternating so both the with-fix and without-fix print arms run.
558            fix: (code == "e").then(|| "do the thing".to_string()),
559        });
560        assert_eq!(print_findings(&findings), (2, 1));
561    }
562
563    #[test]
564    fn print_findings_on_an_empty_list_reports_nothing() {
565        assert_eq!(print_findings(&[]), (0, 0));
566    }
567
568    // ─── lint_failure_message ────────────────────────────────────────────
569
570    #[test]
571    fn lint_failure_message_pluralizes_and_names_the_flag() {
572        assert_eq!(lint_failure_message(1, 0, false), "✗ Blueprint has 1 error");
573        assert_eq!(
574            lint_failure_message(2, 5, false),
575            "✗ Blueprint has 2 errors",
576            "warnings are not counted unless they were asked to be"
577        );
578        assert_eq!(
579            lint_failure_message(0, 1, true),
580            "✗ Blueprint has 1 warning (--deny-warnings)"
581        );
582        assert_eq!(
583            lint_failure_message(1, 2, true),
584            "✗ Blueprint has 1 error and 2 warnings (--deny-warnings)"
585        );
586    }
587
588    // ─── execute ─────────────────────────────────────────────────────────
589    //
590    // `execute` loads the real config, so each of these runs inside
591    // `with_isolated_config_path_async`: it points the load at a scratch
592    // directory and takes the same process-wide lock every other env-touching
593    // test holds.
594
595    #[tokio::test]
596    async fn execute_parse_error_returns_error() {
597        crate::config::with_isolated_config_path_async("validate-parse-error", |_| async {
598            let dir = tempfile::tempdir().unwrap();
599            write_manifest(dir.path(), "not valid toml [[[");
600            let err = execute(args_for(dir.path())).await.unwrap_err();
601            assert!(err.to_string().contains("Parse error"));
602        })
603        .await;
604    }
605
606    #[tokio::test]
607    async fn execute_validation_error_returns_error() {
608        crate::config::with_isolated_config_path_async("validate-validation-error", |_| async {
609            let dir = tempfile::tempdir().unwrap();
610            let manifest = r#"
611[agent]
612name = "bad-entry-agent"
613version = "0.1.0"
614description = "Entry stage does not exist"
615entry_stage = "does-not-exist"
616
617[stages.main]
618mode = "autonomous"
619model = { provider = "anthropic", model = "claude-sonnet-4-6" }
620description = "Main"
621max_iterations = 5
622
623[context.regions]
624system = { kind = "pinned", max_tokens = 1000 }
625"#;
626            write_manifest(dir.path(), manifest);
627            let err = execute(args_for(dir.path())).await.unwrap_err();
628            assert!(err.to_string().contains("Validation failed"));
629        })
630        .await;
631    }
632
633    /// A tool name matching nothing is fatal, and the failure line says so.
634    #[tokio::test]
635    async fn execute_lint_error_fails_the_command() {
636        crate::config::with_isolated_config_path_async("validate-lint-error", |_| async {
637            let dir = tempfile::tempdir().unwrap();
638            write_manifest(
639                dir.path(),
640                &CLEAN_MANIFEST.replace(
641                    "max_iterations = 5",
642                    "max_iterations = 5\navailable_tools = [\"raed_file\"]",
643                ),
644            );
645            let err = execute(args_for(dir.path())).await.unwrap_err();
646            assert_eq!(err.to_string(), "✗ Blueprint has 1 error");
647        })
648        .await;
649    }
650
651    /// A warning alone exits zero, and the same manifest fails under
652    /// `--deny-warnings`. Asserted as a pair, since the whole point of the flag
653    /// is the difference between the two.
654    #[tokio::test]
655    async fn warnings_only_fail_when_denied() {
656        crate::config::with_isolated_config_path_async("validate-deny-warnings", |_| async {
657            let dir = tempfile::tempdir().unwrap();
658            // No max_iterations on the one stage: exactly one warning, no errors.
659            write_manifest(
660                dir.path(),
661                &CLEAN_MANIFEST.replace("max_iterations = 5", ""),
662            );
663
664            let mut args = args_for(dir.path());
665            assert!(execute_reporting_outcome(&args, None).unwrap().is_success());
666
667            args.deny_warnings = true;
668            let err = execute(args).await.unwrap_err();
669            assert_eq!(
670                err.to_string(),
671                "✗ Blueprint has 1 warning (--deny-warnings)"
672            );
673        })
674        .await;
675    }
676
677    #[tokio::test]
678    async fn execute_no_manifest_errors() {
679        crate::config::with_isolated_config_path_async("validate-no-manifest", |_| async {
680            let dir = tempfile::tempdir().unwrap();
681            assert!(execute(args_for(dir.path())).await.is_err());
682        })
683        .await;
684    }
685
686    /// The manifest may be named directly rather than by its directory.
687    #[tokio::test]
688    async fn execute_valid_manifest_file_path() {
689        crate::config::with_isolated_config_path_async("validate-file-path", |_| async {
690            let dir = tempfile::tempdir().unwrap();
691            let manifest_path = write_manifest(dir.path(), CLEAN_MANIFEST);
692            let args = ValidateArgs {
693                path: manifest_path.to_str().unwrap().to_string(),
694                deny_warnings: false,
695                json: false,
696            };
697            assert!(execute(args).await.is_ok());
698        })
699        .await;
700    }
701
702    #[tokio::test]
703    async fn execute_valid_manifest_directory_path() {
704        crate::config::with_isolated_config_path_async("validate-dir-path", |_| async {
705            let dir = tempfile::tempdir().unwrap();
706            write_test_agent(dir.path(), CLEAN_MANIFEST);
707            assert!(execute(args_for(dir.path())).await.is_ok());
708        })
709        .await;
710    }
711
712    // ─── execute_reporting_outcome ───────────────────────────────────────
713
714    impl ValidateOutcome {
715        /// Whether this is [`ValidateOutcome::Success`]. A method rather than a
716        /// `matches!` in each test: the never-taken arm of an inline `matches!`
717        /// reads to llvm-cov as an uncovered region.
718        fn is_success(&self) -> bool {
719            matches!(self, Self::Success)
720        }
721
722        fn is_parse_error(&self) -> bool {
723            matches!(self, Self::ParseError(_))
724        }
725
726        fn is_validation_error(&self) -> bool {
727            matches!(self, Self::ValidationError(_))
728        }
729    }
730
731    #[test]
732    fn outcome_predicates_distinguish_the_variants() {
733        assert!(ValidateOutcome::Success.is_success());
734        assert!(!ValidateOutcome::Success.is_parse_error());
735        assert!(!ValidateOutcome::Success.is_validation_error());
736        assert!(ValidateOutcome::ParseError(String::new()).is_parse_error());
737        assert!(ValidateOutcome::ValidationError(String::new()).is_validation_error());
738        assert!(
739            !ValidateOutcome::LintFailed {
740                errors: 1,
741                warnings: 0
742            }
743            .is_success()
744        );
745    }
746
747    // ─── --json ──────────────────────────────────────────────────────────
748
749    fn json_args_for(dir: &std::path::Path) -> ValidateArgs {
750        ValidateArgs {
751            json: true,
752            ..args_for(dir)
753        }
754    }
755
756    /// A finding of a given severity. `LintFinding::new` is private to `lint`,
757    /// but the fields are public, so the report can be exercised from here
758    /// without widening that API for a test.
759    fn finding(severity: LintSeverity, code: &'static str) -> LintFinding {
760        LintFinding {
761            severity,
762            code,
763            stage: None,
764            message: format!("{code} message"),
765            fix: None,
766        }
767    }
768
769    #[test]
770    fn json_report_of_a_clean_manifest_is_valid_and_names_its_stages() {
771        let blueprint = parse(CLEAN_MANIFEST);
772        let report = ValidateReport::linted(&blueprint, Vec::new(), false);
773        assert!(report.valid);
774        assert_eq!(report.error, None);
775        let summary = report.blueprint.expect("a parsed manifest has a summary");
776        assert_eq!(summary.name, "ok-agent");
777        assert_eq!(summary.stages, vec!["main".to_string()]);
778        assert_eq!((report.errors, report.warnings, report.notes), (0, 0, 0));
779    }
780
781    #[test]
782    fn json_report_counts_each_severity_separately() {
783        let blueprint = parse(CLEAN_MANIFEST);
784        let findings = vec![
785            finding(LintSeverity::Error, "a"),
786            finding(LintSeverity::Warning, "b"),
787            finding(LintSeverity::Note, "c"),
788        ];
789        let report = ValidateReport::linted(&blueprint, findings, false);
790        assert_eq!((report.errors, report.warnings, report.notes), (1, 1, 1));
791        // An error is fatal whatever --deny-warnings says.
792        assert!(!report.valid);
793    }
794
795    #[test]
796    fn json_report_is_valid_with_a_warning_until_deny_warnings() {
797        let blueprint = parse(CLEAN_MANIFEST);
798        let warning = || vec![finding(LintSeverity::Warning, "b")];
799        assert!(ValidateReport::linted(&blueprint, warning(), false).valid);
800        assert!(!ValidateReport::linted(&blueprint, warning(), true).valid);
801    }
802
803    #[test]
804    fn json_report_of_a_note_stays_valid_under_deny_warnings() {
805        // Notes never fail a build. This is the rule most likely to drift, since
806        // the JSON `valid` flag restates it in a second place.
807        let blueprint = parse(CLEAN_MANIFEST);
808        let notes = vec![finding(LintSeverity::Note, "c")];
809        assert!(ValidateReport::linted(&blueprint, notes, true).valid);
810    }
811
812    #[test]
813    fn json_report_of_a_broken_manifest_carries_the_error_and_no_blueprint() {
814        let report = ValidateReport::failed("parse error: boom".to_string());
815        assert!(!report.valid);
816        assert!(report.blueprint.is_none());
817        assert_eq!(report.error.as_deref(), Some("parse error: boom"));
818    }
819
820    #[test]
821    fn json_report_serializes_every_key_a_caller_reads() {
822        let blueprint = parse(CLEAN_MANIFEST);
823        let report = ValidateReport::linted(
824            &blueprint,
825            vec![finding(LintSeverity::Error, "unknown-tool")],
826            false,
827        );
828        let value: serde_json::Value =
829            serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
830        assert_eq!(value["valid"], serde_json::json!(false));
831        assert_eq!(value["blueprint"]["name"], serde_json::json!("ok-agent"));
832        assert_eq!(value["error"], serde_json::Value::Null);
833        // `code` is the stable slug a caller branches on, and `severity` is
834        // lowercase rather than the padded table label.
835        assert_eq!(
836            value["findings"][0]["code"],
837            serde_json::json!("unknown-tool")
838        );
839        assert_eq!(value["findings"][0]["severity"], serde_json::json!("error"));
840    }
841
842    #[test]
843    fn json_mode_still_reports_a_parse_error_through_the_outcome() {
844        let dir = tempfile::tempdir().unwrap();
845        write_manifest(dir.path(), "not valid toml [[[");
846        assert!(
847            execute_reporting_outcome(&json_args_for(dir.path()), None)
848                .unwrap()
849                .is_parse_error()
850        );
851    }
852
853    #[test]
854    fn json_mode_still_reports_a_validation_error_through_the_outcome() {
855        // A manifest that parses but names an entry stage that does not exist:
856        // the other half of the failure path, and a different report line.
857        let dir = tempfile::tempdir().unwrap();
858        write_manifest(
859            dir.path(),
860            r#"
861[agent]
862name = "bad-entry-agent"
863version = "0.1.0"
864description = "Entry stage does not exist"
865entry_stage = "does-not-exist"
866
867[stages.main]
868mode = "autonomous"
869model = { provider = "anthropic", model = "claude-sonnet-4-6" }
870description = "Main"
871max_iterations = 5
872
873[context.regions]
874system = { kind = "pinned", max_tokens = 1000 }
875"#,
876        );
877        assert!(
878            execute_reporting_outcome(&json_args_for(dir.path()), None)
879                .unwrap()
880                .is_validation_error()
881        );
882    }
883
884    #[test]
885    fn json_mode_still_succeeds_on_a_clean_manifest() {
886        let dir = tempfile::tempdir().unwrap();
887        write_manifest(dir.path(), CLEAN_MANIFEST);
888        assert!(
889            execute_reporting_outcome(&json_args_for(dir.path()), None)
890                .unwrap()
891                .is_success()
892        );
893    }
894
895    #[test]
896    fn execute_reporting_outcome_malformed_toml_is_parse_error() {
897        let dir = tempfile::tempdir().unwrap();
898        write_manifest(dir.path(), "not valid toml [[[");
899        assert!(
900            execute_reporting_outcome(&args_for(dir.path()), None)
901                .unwrap()
902                .is_parse_error()
903        );
904    }
905
906    #[test]
907    fn execute_reporting_outcome_bad_entry_stage_is_validation_error() {
908        let dir = tempfile::tempdir().unwrap();
909        let manifest = r#"
910[agent]
911name = "bad-entry-agent"
912version = "0.1.0"
913description = "Entry stage does not exist"
914entry_stage = "does-not-exist"
915
916[stages.main]
917mode = "autonomous"
918model = { provider = "anthropic", model = "claude-sonnet-4-6" }
919description = "Main"
920max_iterations = 5
921
922[context.regions]
923system = { kind = "pinned", max_tokens = 1000 }
924"#;
925        write_manifest(dir.path(), manifest);
926        assert!(
927            execute_reporting_outcome(&args_for(dir.path()), None)
928                .unwrap()
929                .is_validation_error()
930        );
931    }
932
933    #[test]
934    fn execute_reporting_outcome_missing_manifest_is_io_error() {
935        let dir = tempfile::tempdir().unwrap();
936        assert!(execute_reporting_outcome(&args_for(dir.path()), None).is_err());
937    }
938
939    #[test]
940    fn execute_reporting_outcome_valid_manifest_is_success() {
941        let dir = tempfile::tempdir().unwrap();
942        write_manifest(dir.path(), CLEAN_MANIFEST);
943        assert!(
944            execute_reporting_outcome(&args_for(dir.path()), None)
945                .unwrap()
946                .is_success()
947        );
948    }
949
950    /// A blueprint whose regions run shell commands at spawn: the note lands in
951    /// the findings, and does not fail the command.
952    #[test]
953    fn command_seed_regions_are_noted_without_failing() {
954        let dir = tempfile::tempdir().unwrap();
955        let manifest = r#"
956[agent]
957name = "scanner"
958version = "0.1.0"
959
960[stages.main]
961mode = "autonomous"
962model = { provider = "anthropic", model = "claude-sonnet-5" }
963description = "Main stage"
964max_iterations = 5
965
966[context.regions]
967facts = { kind = "pinned", max_tokens = 1000, seed = { command = "git ls-files" } }
968conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
969"#;
970        write_manifest(dir.path(), manifest);
971        // Even under --deny-warnings, a note is not a warning.
972        let args = ValidateArgs {
973            path: dir.path().to_str().unwrap().to_string(),
974            deny_warnings: true,
975            json: false,
976        };
977        assert!(execute_reporting_outcome(&args, None).unwrap().is_success());
978    }
979
980    #[test]
981    fn execute_reporting_outcome_reports_agent_script_tools() {
982        // A valid agent whose `tools/` dir holds one good and one broken script:
983        // validation still succeeds, and the script report's count + warning
984        // branches both run.
985        let dir = tempfile::tempdir().unwrap();
986        write_manifest(dir.path(), CLEAN_MANIFEST);
987        let tools = dir.path().join("tools");
988        std::fs::create_dir(&tools).unwrap();
989        std::fs::write(tools.join("ok.rhai"), "// @tool ok\nparams.x").unwrap();
990        std::fs::write(tools.join("bad.rhai"), "no directive\nlet").unwrap();
991        // Compiles but requires an unsatisfiable capability → the won't-load warning.
992        std::fs::write(tools.join("gpu.rhai"), "// @tool gpu\n// @requires gpu\n1").unwrap();
993        assert!(
994            execute_reporting_outcome(&args_for(dir.path()), None)
995                .unwrap()
996                .is_success()
997        );
998    }
999
1000    /// A tool the agent defines itself resolves, so granting it is not an
1001    /// unknown-tool error. This is the reason the lint env is built from the
1002    /// agent's own directory rather than from the built-ins alone.
1003    #[test]
1004    fn an_agents_own_script_tool_resolves() {
1005        let dir = tempfile::tempdir().unwrap();
1006        write_manifest(
1007            dir.path(),
1008            &CLEAN_MANIFEST.replace(
1009                "max_iterations = 5",
1010                "max_iterations = 5\navailable_tools = [\"stub_search\"]",
1011            ),
1012        );
1013        let tools = dir.path().join("tools");
1014        std::fs::create_dir(&tools).unwrap();
1015        std::fs::write(
1016            tools.join("stub_search.rhai"),
1017            "// @tool stub_search\n// @description searches\n\"found\"",
1018        )
1019        .unwrap();
1020        assert!(
1021            execute_reporting_outcome(&args_for(dir.path()), None)
1022                .unwrap()
1023                .is_success()
1024        );
1025    }
1026
1027    #[test]
1028    fn print_script_tool_report_no_tools_dir_is_silent() {
1029        // No `tools/` dir → the early return (covered by most success tests, but
1030        // asserted here directly against a file path, which exercises the
1031        // `path.is_file()` → parent arm).
1032        let dir = tempfile::tempdir().unwrap();
1033        let manifest = write_manifest(dir.path(), "unused");
1034        print_script_tool_report(&manifest);
1035    }
1036
1037    #[test]
1038    fn print_script_tool_report_only_broken_scripts_warns_without_count() {
1039        // A `tools/` dir with only a broken script: `set` is empty (no count
1040        // line - the `!set.is_empty()` false arm) but the skipped warning runs.
1041        let dir = tempfile::tempdir().unwrap();
1042        let tools = dir.path().join("tools");
1043        std::fs::create_dir(&tools).unwrap();
1044        std::fs::write(tools.join("bad.rhai"), "no directive\nlet").unwrap();
1045        print_script_tool_report(dir.path());
1046    }
1047
1048    // ─── check_manifest ──────────────────────────────────────────────────
1049
1050    #[test]
1051    fn check_manifest_verifies_custom_region_scripts() {
1052        // A custom region's script must exist and compile; the same failure a
1053        // spawn would hit, surfaced by `lev validate`.
1054        let dir = tempfile::tempdir().unwrap();
1055        let toml = r#"
1056[agent]
1057name = "custom-validate"
1058version = "0.1.0"
1059description = "d"
1060
1061[stages.main]
1062mode = "autonomous"
1063model = { provider = "anthropic", model = "claude-sonnet-5" }
1064description = "Main stage"
1065
1066[context.regions]
1067system = { kind = "pinned", max_tokens = 1000 }
1068conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
1069brain = { kind = "custom", script = "hooks/brain.rhai", max_tokens = 1000 }
1070"#;
1071        let manifest_path = write_manifest(dir.path(), toml);
1072
1073        // Missing script file → validation error naming region + path.
1074        let err = format!("{:?}", check_manifest(&manifest_path).unwrap_err());
1075        assert!(err.starts_with("Validation"), "{err}");
1076        assert!(err.contains("region 'brain'"), "{err}");
1077
1078        // Present + compilable → passes.
1079        std::fs::create_dir(dir.path().join("hooks")).unwrap();
1080        std::fs::write(
1081            dir.path().join("hooks/brain.rhai"),
1082            "fn render(ctx) { \"ok\" }",
1083        )
1084        .unwrap();
1085        let checked = check_manifest(&manifest_path).unwrap();
1086        assert_eq!(checked.blueprint.name, "custom-validate");
1087        // The text is carried through for the linter, and the agent dir points
1088        // at the manifest's own directory rather than the manifest file.
1089        assert!(checked.content.contains("custom-validate"));
1090        assert_eq!(checked.agent_dir, dir.path());
1091    }
1092
1093    /// Extract the inner `anyhow::Error` from a `ManifestCheckError::Io`,
1094    /// panicking with a diagnostic message for any other variant.
1095    fn unwrap_io_err(err: ManifestCheckError) -> anyhow::Error {
1096        let ManifestCheckError::Io(e) = err else {
1097            panic!("expected ManifestCheckError::Io, got {err:?}");
1098        };
1099        e
1100    }
1101
1102    #[test]
1103    #[should_panic(expected = "expected ManifestCheckError::Io")]
1104    fn unwrap_io_err_panics_on_parse_variant() {
1105        let dir = tempfile::tempdir().unwrap();
1106        write_manifest(dir.path(), "not valid toml [[[");
1107        let err = check_manifest(dir.path()).unwrap_err();
1108        // err is ManifestCheckError::Parse - this should panic
1109        unwrap_io_err(err);
1110    }
1111
1112    #[test]
1113    fn check_manifest_missing_directory_manifest_is_io_error() {
1114        let dir = tempfile::tempdir().unwrap();
1115        let err = check_manifest(dir.path()).unwrap_err();
1116        let e = unwrap_io_err(err);
1117        assert!(e.to_string().contains("No agent.leviath found"));
1118    }
1119
1120    #[test]
1121    fn check_manifest_unreadable_file_path_is_io_error() {
1122        let dir = tempfile::tempdir().unwrap();
1123        // Pass a path to a file that doesn't exist directly (is_file() is
1124        // false, and it's not a directory either) - falls through to the
1125        // "join agent.leviath" branch, which also won't exist.
1126        let missing = dir.path().join("nonexistent-subdir");
1127        let err = check_manifest(&missing).unwrap_err();
1128        unwrap_io_err(err);
1129    }
1130
1131    // Distinct from the two "file doesn't exist" IO-error cases above: this
1132    // exercises `std::fs::read_to_string`'s own `Err` arm (a manifest file
1133    // that *is* found via `path.is_file()`/`.exists()`, but can't actually
1134    // be read), which no other test reaches.
1135    #[test]
1136    fn check_manifest_unreadable_file_is_io_error() {
1137        // `agent.leviath` exists but is a *directory*, so it's found via
1138        // `.exists()` yet `read_to_string` fails on every platform, exercising
1139        // the read_to_string map_err arm.
1140        let dir = tempfile::tempdir().unwrap();
1141        std::fs::create_dir_all(dir.path().join("agent.leviath")).unwrap();
1142
1143        let err = check_manifest(dir.path()).unwrap_err();
1144        let e = unwrap_io_err(err);
1145        assert!(e.to_string().contains("Failed to read"));
1146    }
1147
1148    impl ManifestCheckError {
1149        /// Whether this is a parse failure. A method rather than an inline
1150        /// `matches!` in the test: the arm the passing run does not take reads
1151        /// to llvm-cov as an uncovered region, and so does a `{err:?}` argument
1152        /// that only a failing assertion would format.
1153        fn is_parse(&self) -> bool {
1154            matches!(self, Self::Parse(_))
1155        }
1156    }
1157
1158    #[test]
1159    fn check_manifest_malformed_toml_is_parse_error() {
1160        let dir = tempfile::tempdir().unwrap();
1161        write_manifest(dir.path(), "not valid toml [[[");
1162        assert!(check_manifest(dir.path()).unwrap_err().is_parse());
1163        // And the other arm: a missing manifest is an I/O failure, not a parse
1164        // one, so the predicate is deciding rather than always agreeing.
1165        let empty = tempfile::tempdir().unwrap();
1166        assert!(!check_manifest(empty.path()).unwrap_err().is_parse());
1167    }
1168
1169    #[test]
1170    fn check_manifest_direct_file_path_is_accepted() {
1171        let dir = tempfile::tempdir().unwrap();
1172        let manifest_path = write_manifest(dir.path(), CLEAN_MANIFEST);
1173        // Pass the *file* path directly, not the directory.
1174        let checked = check_manifest(&manifest_path).unwrap();
1175        assert_eq!(checked.blueprint.name, "ok-agent");
1176    }
1177}