Skip to main content

leviath_cli/commands/
validate.rs

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