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