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