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