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
6#[derive(Args)]
7pub struct ValidateArgs {
8    /// Path to the agent directory or agent.leviath file
9    #[arg(default_value = ".")]
10    pub(crate) path: String,
11}
12
13/// Resolve, read, parse, and validate the manifest at `path`. Distinguishes
14/// I/O failures (propagated as a normal error) from parse/validation
15/// failures (which `execute()` reports specially and exits(1) on) so the
16/// core logic can be unit tested without killing the test process.
17#[derive(Debug)]
18enum ManifestCheckError {
19    Io(anyhow::Error),
20    Parse(String),
21    Validation(String),
22}
23
24fn check_manifest(path: &std::path::Path) -> Result<leviath_core::Blueprint, ManifestCheckError> {
25    // Resolve manifest path
26    let manifest_path = if path.is_file() {
27        path.to_path_buf()
28    } else {
29        let p = path.join("agent.leviath");
30        if !p.exists() {
31            return Err(ManifestCheckError::Io(anyhow::anyhow!(
32                "No agent.leviath found at {}",
33                path.display()
34            )));
35        }
36        p
37    };
38
39    let content = std::fs::read_to_string(&manifest_path).map_err(|e| {
40        ManifestCheckError::Io(anyhow::anyhow!(
41            "Failed to read {}: {}",
42            manifest_path.display(),
43            e
44        ))
45    })?;
46
47    let blueprint = leviath_core::manifest::parse_manifest(&content)
48        .map_err(|e| ManifestCheckError::Parse(e.to_string()))?;
49
50    blueprint
51        .validate()
52        .map_err(|e| ManifestCheckError::Validation(e.to_string()))?;
53
54    // Custom regions' Rhai scripts must resolve to readable, compilable
55    // files with a well-formed `fn render(ctx)` - the same check a spawn
56    // performs, surfaced here where a typo'd path or syntax error is cheap
57    // to find.
58    crate::daemon::spawn::resolve_region_scripts(&blueprint, &manifest_path.to_string_lossy())
59        .map_err(ManifestCheckError::Validation)?;
60
61    Ok(blueprint)
62}
63
64/// Print the "valid blueprint" summary + non-fatal warnings.
65fn print_success(blueprint: &leviath_core::Blueprint) {
66    println!("✓ Blueprint '{}' is valid.", blueprint.name);
67    println!(
68        "  {} stages, version {}",
69        blueprint.stages.len(),
70        blueprint.version
71    );
72
73    // Check if graph mode
74    let is_graph = blueprint.stages.iter().any(|s| s.transitions.is_some());
75    if is_graph {
76        let entry = blueprint.resolve_entry_stage_name();
77        println!("  Graph mode: entry stage '{}'", entry);
78
79        // List stages and their transitions
80        for stage in &blueprint.stages {
81            let transitions_info = match &stage.transitions {
82                Some(t) if !t.is_empty() => {
83                    let targets: Vec<&str> = t.keys().map(|k| k.as_str()).collect();
84                    format!(" → {}", targets.join(", "))
85                }
86                Some(_) => " (terminal)".to_string(),
87                None => " (linear)".to_string(),
88            };
89            let revisits = stage
90                .max_revisits
91                .map(|n| format!(" (max_revisits: {})", n))
92                .unwrap_or_default();
93            println!("  - {}{}{}", stage.name, transitions_info, revisits);
94        }
95    } else {
96        println!(
97            "  Linear mode: {}",
98            blueprint
99                .stages
100                .iter()
101                .map(|s| s.name.as_str())
102                .collect::<Vec<_>>()
103                .join(" → ")
104        );
105    }
106
107    // Command seeds execute at spawn - surface them before anything else, so
108    // `lev validate` is a real audit step before `lev add`.
109    for line in command_seed_report(blueprint) {
110        println!("{line}");
111    }
112
113    // Warnings (non-fatal)
114    print_warnings(blueprint);
115}
116
117/// The report lines for a blueprint's `seed = { command = "..." }` regions.
118///
119/// Split out from the printer so it is directly assertable. Empty when the
120/// blueprint declares none - the overwhelmingly common case, which should print
121/// nothing at all.
122fn command_seed_report(blueprint: &leviath_core::Blueprint) -> Vec<String> {
123    let seeds: Vec<(&str, &str)> = blueprint
124        .context_layout
125        .regions
126        .iter()
127        .filter_map(|r| match &r.seed {
128            Some(leviath_core::layout::RegionSeed::Command { command }) => {
129                Some((r.name.as_str(), command.as_str()))
130            }
131            _ => None,
132        })
133        .collect();
134    if seeds.is_empty() {
135        return Vec::new();
136    }
137    let mut lines = vec![format!(
138        "  ⚠ {} region(s) run a shell command at spawn, before the first \
139         inference and before any tool-approval prompt:",
140        seeds.len()
141    )];
142    lines.extend(
143        seeds
144            .iter()
145            .map(|(region, command)| format!("      {region}: {command}")),
146    );
147    lines.push(
148        "    Disable with `--no-seed-commands`, or machine-wide via \
149         `[security] allow_seed_commands = false`."
150            .to_string(),
151    );
152    lines
153}
154
155/// Outcome of the real, testable logic in [`execute`]. Kept distinct from
156/// the actual `std::process::exit(1)` calls (which would kill the test
157/// process if exercised directly) so `execute_reporting_outcome` - and
158/// therefore every branch of `check_manifest`'s error handling - can be
159/// unit tested; only the thin `execute` wrapper below ever calls `exit`.
160enum ValidateOutcome {
161    Success,
162    ParseError(String),
163    ValidationError(String),
164}
165
166fn execute_reporting_outcome(args: &ValidateArgs) -> anyhow::Result<ValidateOutcome> {
167    let path = PathBuf::from(&args.path);
168
169    let blueprint = match check_manifest(&path) {
170        Ok(bp) => bp,
171        Err(ManifestCheckError::Io(e)) => return Err(e),
172        Err(ManifestCheckError::Parse(e)) => return Ok(ValidateOutcome::ParseError(e)),
173        Err(ManifestCheckError::Validation(e)) => return Ok(ValidateOutcome::ValidationError(e)),
174    };
175
176    print_success(&blueprint);
177    print_script_tool_report(&path);
178    Ok(ValidateOutcome::Success)
179}
180
181/// Validate the agent's own Rhai script tools: discover the agent
182/// directory's `tools/` and report how many compiled, warning (non-fatal, like
183/// the daemon's own skip-and-warn) about any that failed. A missing `tools/` dir
184/// prints nothing.
185fn print_script_tool_report(path: &std::path::Path) {
186    // The agent dir is the manifest's parent (file path) or the path itself (dir).
187    let agent_dir = if path.is_file() {
188        path.parent().unwrap_or(path).to_path_buf()
189    } else {
190        path.to_path_buf()
191    };
192    let tools_dir = agent_dir.join("tools");
193    if !tools_dir.is_dir() {
194        return;
195    }
196    let (set, skipped) = leviath_scripting::ScriptToolSet::discover(&[tools_dir]);
197    if !set.is_empty() {
198        println!("  {} script tool(s) in tools/", set.len());
199    }
200    // A tool that compiles but whose `@requires` the platform can't satisfy won't
201    // load - flag it (this also catches an unknown/typo'd capability name).
202    for meta in set.metas() {
203        if !crate::daemon::spawn::current_platform_satisfies(&meta.required_caps) {
204            println!(
205                "  ⚠ Warning: script tool '{}' won't load here (unsatisfiable @requires: {})",
206                meta.name,
207                meta.required_caps.join(", ")
208            );
209        }
210    }
211    for s in &skipped {
212        println!(
213            "  ⚠ Warning: script tool '{}' skipped: {}",
214            s.path.display(),
215            s.reason
216        );
217    }
218}
219
220pub async fn execute(args: ValidateArgs) -> anyhow::Result<()> {
221    match execute_reporting_outcome(&args)? {
222        ValidateOutcome::Success => Ok(()),
223        ValidateOutcome::ParseError(e) => anyhow::bail!("✗ Parse error: {}", e),
224        ValidateOutcome::ValidationError(e) => anyhow::bail!("✗ Validation failed: {}", e),
225    }
226}
227
228fn print_warnings(blueprint: &leviath_core::Blueprint) {
229    let stage_names: std::collections::HashSet<&str> =
230        blueprint.stages.iter().map(|s| s.name.as_str()).collect();
231
232    let is_graph = blueprint.stages.iter().any(|s| s.transitions.is_some());
233    if !is_graph {
234        return;
235    }
236
237    let entry = blueprint.resolve_entry_stage_name();
238
239    // Check reachability via BFS from entry stage
240    let mut reachable = std::collections::HashSet::new();
241    let mut queue = std::collections::VecDeque::new();
242    queue.push_back(entry.clone());
243    while let Some(name) = queue.pop_front() {
244        if !reachable.insert(name.clone()) {
245            continue;
246        }
247        let Some(stage) = blueprint.find_stage(&name) else {
248            continue;
249        };
250        let Some(ref transitions) = stage.transitions else {
251            continue;
252        };
253        for target in transitions.keys() {
254            if !reachable.contains(target.as_str()) && stage_names.contains(target.as_str()) {
255                queue.push_back(target.clone());
256            }
257        }
258    }
259
260    for stage in &blueprint.stages {
261        if !reachable.contains(stage.name.as_str()) {
262            println!(
263                "  ⚠ Warning: stage '{}' is unreachable from entry stage '{}'",
264                stage.name, entry
265            );
266        }
267    }
268
269    // Check for loops without max_revisits
270    for stage in &blueprint.stages {
271        let Some(ref transitions) = stage.transitions else {
272            continue;
273        };
274        for target in transitions.keys() {
275            if target == &stage.name {
276                continue;
277            }
278            // Check if target can reach back to this stage (cycle)
279            let Some(target_stage) = blueprint.find_stage(target) else {
280                continue;
281            };
282            let Some(ref t2) = target_stage.transitions else {
283                continue;
284            };
285            if t2.contains_key(&stage.name) && target_stage.max_revisits.is_none() {
286                #[rustfmt::skip]
287                println!("  ⚠ Warning: stage '{}' is in a cycle but has no max_revisits set", target);
288            }
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::test_support::write_test_agent;
297
298    /// Helper to create a minimal valid blueprint TOML with given stages.
299    fn make_blueprint_toml(stages_toml: &str) -> String {
300        format!(
301            r#"
302[agent]
303name = "test"
304version = "0.1.0"
305description = "test blueprint"
306
307{}
308
309[context.regions]
310system = {{ kind = "pinned", max_tokens = 1000 }}
311conversation = {{ kind = "sliding_window", max_items = 50, max_tokens = 10000 }}
312"#,
313            stages_toml
314        )
315    }
316
317    fn parse(toml: &str) -> leviath_core::Blueprint {
318        leviath_core::manifest::parse_manifest(toml).unwrap()
319    }
320
321    #[test]
322    fn check_manifest_verifies_custom_region_scripts() {
323        // A custom region's script must exist and compile; the same failure a
324        // spawn would hit, surfaced by `lev validate`.
325        let dir = tempfile::tempdir().unwrap();
326        let manifest_path = dir.path().join("agent.leviath");
327        let toml = r#"
328[agent]
329name = "custom-validate"
330version = "0.1.0"
331description = "d"
332
333[stages.main]
334mode = "autonomous"
335model = { provider = "anthropic", model = "claude-sonnet-5" }
336description = "Main stage"
337
338[context.regions]
339system = { kind = "pinned", max_tokens = 1000 }
340conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
341brain = { kind = "custom", script = "hooks/brain.rhai", max_tokens = 1000 }
342"#;
343        std::fs::write(&manifest_path, toml).unwrap();
344
345        // Missing script file → validation error naming region + path.
346        let err = format!("{:?}", check_manifest(&manifest_path).unwrap_err());
347        assert!(err.starts_with("Validation"), "{err}");
348        assert!(err.contains("region 'brain'"), "{err}");
349
350        // Present + compilable → passes.
351        std::fs::create_dir(dir.path().join("hooks")).unwrap();
352        std::fs::write(
353            dir.path().join("hooks/brain.rhai"),
354            "fn render(ctx) { \"ok\" }",
355        )
356        .unwrap();
357        let bp = check_manifest(&manifest_path).unwrap();
358        assert_eq!(bp.name, "custom-validate");
359    }
360
361    #[test]
362    fn command_seed_report_is_empty_without_command_seeds() {
363        let bp = parse(&make_blueprint_toml(
364            r#"
365[stages.main]
366mode = "autonomous"
367model = { provider = "anthropic", model = "claude-sonnet-5" }
368description = "Main stage"
369"#,
370        ));
371        assert!(command_seed_report(&bp).is_empty());
372    }
373
374    #[test]
375    fn command_seed_report_names_every_region_and_command() {
376        let toml = r#"
377[agent]
378name = "scanner"
379version = "0.1.0"
380
381[stages.main]
382mode = "autonomous"
383model = { provider = "anthropic", model = "claude-sonnet-5" }
384description = "Main stage"
385
386[context.regions]
387facts = { kind = "pinned", max_tokens = 1000, seed = { command = "git ls-files" } }
388tests = { kind = "pinned", max_tokens = 1000, seed = { command = "ls tests" } }
389plain = { kind = "pinned", max_tokens = 1000 }
390conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
391"#;
392        let report = command_seed_report(&parse(toml)).join("\n");
393        assert!(report.contains("2 region(s)"), "got: {report}");
394        assert!(report.contains("facts: git ls-files"), "got: {report}");
395        assert!(report.contains("tests: ls tests"), "got: {report}");
396        // The escape hatches are named so the reader knows how to refuse.
397        assert!(report.contains("--no-seed-commands"), "got: {report}");
398        assert!(report.contains("allow_seed_commands"), "got: {report}");
399        // A region without a command seed is not reported.
400        assert!(!report.contains("plain"), "got: {report}");
401        // And the printer itself runs.
402        print_success(&parse(toml));
403    }
404
405    #[test]
406    fn print_warnings_linear_mode_no_panic() {
407        let toml = make_blueprint_toml(
408            r#"
409[stages.main]
410mode = "autonomous"
411model = { provider = "anthropic", model = "claude-sonnet-4-6" }
412description = "Main stage"
413max_iterations = 10
414"#,
415        );
416        let bp = parse(&toml);
417        // Should not panic even though there's no graph
418        print_warnings(&bp);
419    }
420
421    #[test]
422    fn print_warnings_graph_all_reachable() {
423        let toml = make_blueprint_toml(
424            r#"
425[stages.a]
426mode = "autonomous"
427model = { provider = "anthropic", model = "claude-sonnet-4-6" }
428description = "Stage A"
429max_iterations = 5
430entry = true
431[stages.a.transitions]
432b = "true"
433
434[stages.b]
435mode = "autonomous"
436model = { provider = "anthropic", model = "claude-sonnet-4-6" }
437description = "Stage B"
438max_iterations = 5
439"#,
440        );
441        let bp = parse(&toml);
442        // No unreachable stages - should run without issues
443        print_warnings(&bp);
444    }
445
446    #[test]
447    fn validate_args_default_path() {
448        // ValidateArgs can be constructed with default path
449        let args = ValidateArgs {
450            path: ".".to_string(),
451        };
452        assert_eq!(args.path, ".");
453    }
454
455    // ─── print_warnings: unreachable stage ──────────────────────────────
456
457    #[test]
458    fn print_warnings_unreachable_stage_no_panic() {
459        let toml = make_blueprint_toml(
460            r#"
461[stages.a]
462mode = "autonomous"
463model = { provider = "anthropic", model = "claude-sonnet-4-6" }
464description = "Stage A"
465max_iterations = 5
466entry = true
467[stages.a.transitions]
468b = "true"
469
470[stages.b]
471mode = "autonomous"
472model = { provider = "anthropic", model = "claude-sonnet-4-6" }
473description = "Stage B"
474max_iterations = 5
475
476[stages.orphan]
477mode = "autonomous"
478model = { provider = "anthropic", model = "claude-sonnet-4-6" }
479description = "Unreachable stage"
480max_iterations = 5
481"#,
482        );
483        let bp = parse(&toml);
484        // Should not panic; orphan stage is unreachable
485        print_warnings(&bp);
486    }
487
488    // ─── print_warnings: cycle without max_revisits ─────────────────────
489
490    #[test]
491    fn print_warnings_cycle_without_max_revisits_no_panic() {
492        let toml = make_blueprint_toml(
493            r#"
494[stages.a]
495mode = "autonomous"
496model = { provider = "anthropic", model = "claude-sonnet-4-6" }
497description = "Stage A"
498max_iterations = 5
499entry = true
500[stages.a.transitions]
501b = "true"
502
503[stages.b]
504mode = "autonomous"
505model = { provider = "anthropic", model = "claude-sonnet-4-6" }
506description = "Stage B"
507max_iterations = 5
508[stages.b.transitions]
509a = "true"
510"#,
511        );
512        let bp = parse(&toml);
513        // Should print warning about cycle but not panic
514        print_warnings(&bp);
515    }
516
517    // ─── print_warnings: cycle with max_revisits set ────────────────────
518
519    #[test]
520    fn print_warnings_cycle_with_max_revisits_no_panic() {
521        let toml = make_blueprint_toml(
522            r#"
523[stages.a]
524mode = "autonomous"
525model = { provider = "anthropic", model = "claude-sonnet-4-6" }
526description = "Stage A"
527max_iterations = 5
528entry = true
529[stages.a.transitions]
530b = "true"
531
532[stages.b]
533mode = "autonomous"
534model = { provider = "anthropic", model = "claude-sonnet-4-6" }
535description = "Stage B"
536max_iterations = 5
537max_revisits = 3
538[stages.b.transitions]
539a = "true"
540"#,
541        );
542        let bp = parse(&toml);
543        print_warnings(&bp);
544    }
545
546    // ─── print_warnings: terminal stage with empty transitions ──────────
547
548    #[test]
549    fn print_warnings_terminal_stage_no_panic() {
550        let toml = make_blueprint_toml(
551            r#"
552[stages.a]
553mode = "autonomous"
554model = { provider = "anthropic", model = "claude-sonnet-4-6" }
555description = "Stage A"
556max_iterations = 5
557entry = true
558[stages.a.transitions]
559b = "true"
560
561[stages.b]
562mode = "autonomous"
563model = { provider = "anthropic", model = "claude-sonnet-4-6" }
564description = "Terminal stage"
565max_iterations = 5
566[stages.b.transitions]
567"#,
568        );
569        let bp = parse(&toml);
570        print_warnings(&bp);
571    }
572
573    // ─── print_warnings: self-loop cycle (target == stage.name skip) ────
574
575    #[test]
576    fn print_warnings_self_loop_with_max_revisits_no_panic() {
577        let toml = make_blueprint_toml(
578            r#"
579[stages.a]
580mode = "autonomous"
581model = { provider = "anthropic", model = "claude-sonnet-4-6" }
582description = "Stage A"
583max_iterations = 5
584entry = true
585max_revisits = 3
586[stages.a.transitions]
587a = "true"
588"#,
589        );
590        let bp = parse(&toml);
591        // Self-loop transition should hit the `target == stage.name` skip in
592        // the cycle-detection loop without panicking or false-warning.
593        print_warnings(&bp);
594    }
595
596    // ─── print_warnings: Blueprint constructed directly (not via parse +
597    // validate), so it can carry invariants `Blueprint::validate` would
598    // normally reject. `print_warnings` takes a bare `&Blueprint` and has
599    // no way to know whether its caller validated it first, so these
600    // "malformed but structurally valid Rust" shapes are reachable through
601    // its public API even though `execute_reporting_outcome` (the only
602    // production caller) always validates first. ────────────────────────
603
604    fn make_model() -> leviath_core::blueprint::ModelConfig {
605        leviath_core::blueprint::ModelConfig::new(
606            "anthropic".to_string(),
607            "claude-sonnet-4-6".to_string(),
608        )
609    }
610
611    #[test]
612    fn print_warnings_entry_stage_missing_no_panic() {
613        use leviath_core::{Blueprint, ContextLayout, Stage};
614
615        // Stage "a" is valid on its own, but the blueprint's entry_stage
616        // points at a name that doesn't exist among `stages` - impossible
617        // via `Blueprint::validate`, but not impossible via this struct's
618        // public fields/constructors.
619        let mut stage_a = Stage::new("a".to_string(), make_model());
620        stage_a.transitions = Some(std::collections::HashMap::new());
621
622        let layout = ContextLayout::new(Vec::new(), 1000);
623        let mut bp = Blueprint::new(
624            "test".to_string(),
625            "test".to_string(),
626            vec![stage_a],
627            layout,
628        );
629        bp.entry_stage = Some("ghost".to_string());
630
631        // Hits the BFS's `find_stage(&name) else { continue }` arm: "ghost"
632        // is queued as the entry but resolves to no real stage.
633        print_warnings(&bp);
634    }
635
636    #[test]
637    fn print_warnings_transition_target_missing_no_panic() {
638        use leviath_core::{Blueprint, ContextLayout, Stage, TransitionEdge};
639
640        // Stage "a" transitions to "ghost", a name with no corresponding
641        // Stage entry - impossible via `Blueprint::validate` (which
642        // requires every transition target to exist), but constructible
643        // directly since `transitions` is a public field.
644        let mut transitions = std::collections::HashMap::new();
645        transitions.insert(
646            "ghost".to_string(),
647            TransitionEdge {
648                target: "ghost".to_string(),
649                condition: Default::default(),
650                hint: None,
651                transform: Default::default(),
652                gate: None,
653                stuck: None,
654            },
655        );
656        let mut stage_a = Stage::new("a".to_string(), make_model());
657        stage_a.transitions = Some(transitions);
658
659        let layout = ContextLayout::new(Vec::new(), 1000);
660        let bp = Blueprint::new(
661            "test".to_string(),
662            "test".to_string(),
663            vec![stage_a],
664            layout,
665        );
666
667        // Hits the cycle-check loop's `find_stage(target) else { continue }`
668        // arm: "ghost" is a transition target but not a real stage.
669        print_warnings(&bp);
670    }
671
672    // ─── execute: parse and validation error arms ──────────────────────────
673    //
674    // These call execute() directly (not execute_reporting_outcome) so the
675    // ParseError and ValidationError match arms in execute() are exercised.
676
677    #[tokio::test]
678    async fn execute_parse_error_returns_error() {
679        let dir = tempfile::tempdir().unwrap();
680        write_manifest(dir.path(), "not valid toml [[[");
681        let args = ValidateArgs {
682            path: dir.path().to_str().unwrap().to_string(),
683        };
684        let err = execute(args).await.unwrap_err();
685        assert!(err.to_string().contains("Parse error"));
686    }
687
688    #[tokio::test]
689    async fn execute_validation_error_returns_error() {
690        let dir = tempfile::tempdir().unwrap();
691        let manifest = r#"
692[agent]
693name = "bad-entry-agent"
694version = "0.1.0"
695description = "Entry stage does not exist"
696entry_stage = "does-not-exist"
697
698[stages.main]
699mode = "autonomous"
700model = { provider = "anthropic", model = "claude-sonnet-4-6" }
701description = "Main"
702max_iterations = 5
703
704[context.regions]
705system = { kind = "pinned", max_tokens = 1000 }
706"#;
707        write_manifest(dir.path(), manifest);
708        let args = ValidateArgs {
709            path: dir.path().to_str().unwrap().to_string(),
710        };
711        let err = execute(args).await.unwrap_err();
712        assert!(err.to_string().contains("Validation failed"));
713    }
714
715    // ─── execute: no manifest ──────────────────────────────────────────
716
717    #[tokio::test]
718    async fn execute_no_manifest_errors() {
719        let dir = tempfile::tempdir().unwrap();
720        let args = ValidateArgs {
721            path: dir.path().to_str().unwrap().to_string(),
722        };
723        let result = execute(args).await;
724        assert!(result.is_err());
725    }
726
727    // ─── execute: with file path pointing to manifest ───────────────────
728
729    #[tokio::test]
730    async fn execute_valid_manifest_file_path() {
731        let dir = tempfile::tempdir().unwrap();
732        let manifest = r#"
733[agent]
734name = "test-agent"
735version = "0.1.0"
736description = "A test agent"
737
738[stages.main]
739mode = "autonomous"
740model = { provider = "anthropic", model = "claude-sonnet-4-6" }
741description = "Main"
742max_iterations = 5
743
744[context.regions]
745system = { kind = "pinned", max_tokens = 1000 }
746conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
747"#;
748        let manifest_path = dir.path().join("agent.leviath");
749        std::fs::write(&manifest_path, manifest).unwrap();
750
751        let args = ValidateArgs {
752            path: manifest_path.to_str().unwrap().to_string(),
753        };
754        let result = execute(args).await;
755        assert!(result.is_ok());
756    }
757
758    // ─── execute: with directory path ───────────────────────────────────
759
760    #[tokio::test]
761    async fn execute_valid_manifest_directory_path() {
762        let dir = tempfile::tempdir().unwrap();
763        let manifest = r#"
764[agent]
765name = "dir-agent"
766version = "0.2.0"
767description = "A directory agent"
768
769[stages.main]
770mode = "autonomous"
771model = { provider = "anthropic", model = "claude-sonnet-4-6" }
772description = "Main"
773max_iterations = 5
774
775[context.regions]
776system = { kind = "pinned", max_tokens = 1000 }
777conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
778"#;
779        write_test_agent(dir.path(), manifest);
780
781        let args = ValidateArgs {
782            path: dir.path().to_str().unwrap().to_string(),
783        };
784        let result = execute(args).await;
785        assert!(result.is_ok());
786    }
787
788    // ─── execute_reporting_outcome: Parse/Validation paths ──────────────
789    //
790    // `execute()` itself calls `std::process::exit(1)` on these two
791    // branches, which would kill the test process - `execute_reporting_outcome`
792    // exists precisely so these can be exercised without that.
793
794    fn assert_is_parse_error(outcome: &ValidateOutcome) {
795        assert!(matches!(outcome, ValidateOutcome::ParseError(_)));
796    }
797
798    #[test]
799    #[should_panic(expected = "assertion failed")]
800    fn assert_is_parse_error_panics_on_non_parse_error() {
801        assert_is_parse_error(&ValidateOutcome::Success);
802    }
803
804    #[test]
805    fn execute_reporting_outcome_malformed_toml_is_parse_error() {
806        let dir = tempfile::tempdir().unwrap();
807        write_manifest(dir.path(), "not valid toml [[[");
808        let args = ValidateArgs {
809            path: dir.path().to_str().unwrap().to_string(),
810        };
811        let outcome = execute_reporting_outcome(&args).unwrap();
812        assert_is_parse_error(&outcome);
813    }
814
815    #[test]
816    fn execute_reporting_outcome_bad_entry_stage_is_validation_error() {
817        let dir = tempfile::tempdir().unwrap();
818        let manifest = r#"
819[agent]
820name = "bad-entry-agent"
821version = "0.1.0"
822description = "Entry stage does not exist"
823entry_stage = "does-not-exist"
824
825[stages.main]
826mode = "autonomous"
827model = { provider = "anthropic", model = "claude-sonnet-4-6" }
828description = "Main"
829max_iterations = 5
830
831[context.regions]
832system = { kind = "pinned", max_tokens = 1000 }
833"#;
834        write_manifest(dir.path(), manifest);
835        let args = ValidateArgs {
836            path: dir.path().to_str().unwrap().to_string(),
837        };
838        let outcome = execute_reporting_outcome(&args).unwrap();
839        assert_is_validation_error(&outcome);
840    }
841
842    fn assert_is_validation_error(outcome: &ValidateOutcome) {
843        assert!(matches!(outcome, ValidateOutcome::ValidationError(_)));
844    }
845
846    #[test]
847    #[should_panic(expected = "assertion failed")]
848    fn assert_is_validation_error_panics_on_non_validation_error() {
849        assert_is_validation_error(&ValidateOutcome::Success);
850    }
851
852    #[test]
853    fn execute_reporting_outcome_missing_manifest_is_io_error() {
854        let dir = tempfile::tempdir().unwrap();
855        let args = ValidateArgs {
856            path: dir.path().to_str().unwrap().to_string(),
857        };
858        assert!(execute_reporting_outcome(&args).is_err());
859    }
860
861    #[test]
862    fn execute_reporting_outcome_valid_manifest_is_success() {
863        let dir = tempfile::tempdir().unwrap();
864        let manifest = r#"
865[agent]
866name = "ok-agent"
867version = "0.1.0"
868description = "Valid"
869
870[stages.main]
871mode = "autonomous"
872model = { provider = "anthropic", model = "claude-sonnet-4-6" }
873description = "Main"
874max_iterations = 5
875
876[context.regions]
877system = { kind = "pinned", max_tokens = 1000 }
878conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
879"#;
880        write_manifest(dir.path(), manifest);
881        let args = ValidateArgs {
882            path: dir.path().to_str().unwrap().to_string(),
883        };
884        let outcome = execute_reporting_outcome(&args).unwrap();
885        assert_is_success(&outcome);
886    }
887
888    fn assert_is_success(outcome: &ValidateOutcome) {
889        assert!(matches!(outcome, ValidateOutcome::Success));
890    }
891
892    #[test]
893    fn execute_reporting_outcome_reports_agent_script_tools() {
894        // A valid agent whose `tools/` dir holds one good and one broken script:
895        // validation still succeeds, and the script report's count + warning
896        // branches both run.
897        let dir = tempfile::tempdir().unwrap();
898        let manifest = r#"
899[agent]
900name = "with-tools"
901version = "0.1.0"
902description = "has script tools"
903
904[stages.main]
905mode = "autonomous"
906model = { provider = "anthropic", model = "claude-sonnet-4-6" }
907description = "Main"
908max_iterations = 5
909
910[context.regions]
911system = { kind = "pinned", max_tokens = 1000 }
912"#;
913        write_manifest(dir.path(), manifest);
914        let tools = dir.path().join("tools");
915        std::fs::create_dir(&tools).unwrap();
916        std::fs::write(tools.join("ok.rhai"), "// @tool ok\nparams.x").unwrap();
917        std::fs::write(tools.join("bad.rhai"), "no directive\nlet").unwrap();
918        // Compiles but requires an unsatisfiable capability → the won't-load warning.
919        std::fs::write(tools.join("gpu.rhai"), "// @tool gpu\n// @requires gpu\n1").unwrap();
920        let args = ValidateArgs {
921            path: dir.path().to_str().unwrap().to_string(),
922        };
923        let outcome = execute_reporting_outcome(&args).unwrap();
924        assert_is_success(&outcome);
925    }
926
927    #[test]
928    fn print_script_tool_report_no_tools_dir_is_silent() {
929        // No `tools/` dir → the early return (covered by most success tests, but
930        // asserted here directly against a file path, which exercises the
931        // `path.is_file()` → parent arm).
932        let dir = tempfile::tempdir().unwrap();
933        let manifest = write_manifest(dir.path(), "unused");
934        print_script_tool_report(&manifest);
935    }
936
937    #[test]
938    fn print_script_tool_report_only_broken_scripts_warns_without_count() {
939        // A `tools/` dir with only a broken script: `set` is empty (no count
940        // line - the `!set.is_empty()` false arm) but the skipped warning runs.
941        let dir = tempfile::tempdir().unwrap();
942        let tools = dir.path().join("tools");
943        std::fs::create_dir(&tools).unwrap();
944        std::fs::write(tools.join("bad.rhai"), "no directive\nlet").unwrap();
945        print_script_tool_report(dir.path());
946    }
947
948    #[test]
949    #[should_panic(expected = "assertion failed")]
950    fn assert_is_success_panics_on_non_success() {
951        assert_is_success(&ValidateOutcome::ParseError("x".to_string()));
952    }
953
954    // ─── print_warnings: multiple stages all reachable ──────────────────
955
956    #[test]
957    fn print_warnings_chain_all_reachable() {
958        let toml = make_blueprint_toml(
959            r#"
960[stages.a]
961mode = "autonomous"
962model = { provider = "anthropic", model = "claude-sonnet-4-6" }
963description = "A"
964max_iterations = 5
965entry = true
966[stages.a.transitions]
967b = "true"
968
969[stages.b]
970mode = "autonomous"
971model = { provider = "anthropic", model = "claude-sonnet-4-6" }
972description = "B"
973max_iterations = 5
974[stages.b.transitions]
975c = "true"
976
977[stages.c]
978mode = "autonomous"
979model = { provider = "anthropic", model = "claude-sonnet-4-6" }
980description = "C"
981max_iterations = 5
982"#,
983        );
984        let bp = parse(&toml);
985        print_warnings(&bp);
986    }
987
988    // ─── print_warnings: BFS revisits an already-reached node (diamond) ──
989    //
990    // `entry` transitions to both `b` and `c`, and both `b` and `c`
991    // transition to `d` - `d` gets queued twice, so the *second* pop hits
992    // the `if !reachable.insert(name.clone()) { continue; }` early-exit that
993    // a simple linear chain or single-path graph never reaches.
994
995    #[test]
996    fn print_warnings_diamond_graph_revisits_shared_target_no_panic() {
997        let toml = make_blueprint_toml(
998            r#"
999[stages.entry]
1000mode = "autonomous"
1001model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1002description = "Entry"
1003max_iterations = 5
1004entry = true
1005[stages.entry.transitions]
1006b = "true"
1007c = "true"
1008
1009[stages.b]
1010mode = "autonomous"
1011model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1012description = "B"
1013max_iterations = 5
1014[stages.b.transitions]
1015d = "true"
1016
1017[stages.c]
1018mode = "autonomous"
1019model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1020description = "C"
1021max_iterations = 5
1022[stages.c.transitions]
1023d = "true"
1024
1025[stages.d]
1026mode = "autonomous"
1027model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1028description = "D"
1029max_iterations = 5
1030"#,
1031        );
1032        let bp = parse(&toml);
1033        // All 4 stages reachable, no unreachable warnings expected; the
1034        // point of this test is exercising the revisit-skip branch itself.
1035        print_warnings(&bp);
1036    }
1037
1038    // ─── check_manifest ──────────────────────────────────────────────────
1039
1040    fn write_manifest(dir: &std::path::Path, content: &str) -> std::path::PathBuf {
1041        let path = dir.join("agent.leviath");
1042        std::fs::write(&path, content).unwrap();
1043        path
1044    }
1045
1046    /// Extract the inner `anyhow::Error` from a `ManifestCheckError::Io`,
1047    /// panicking with a diagnostic message for any other variant.
1048    fn unwrap_io_err(err: ManifestCheckError) -> anyhow::Error {
1049        let ManifestCheckError::Io(e) = err else {
1050            panic!("expected ManifestCheckError::Io, got {err:?}");
1051        };
1052        e
1053    }
1054
1055    #[test]
1056    #[should_panic(expected = "expected ManifestCheckError::Io")]
1057    fn unwrap_io_err_panics_on_parse_variant() {
1058        let dir = tempfile::tempdir().unwrap();
1059        write_manifest(dir.path(), "not valid toml [[[");
1060        let err = check_manifest(dir.path()).unwrap_err();
1061        // err is ManifestCheckError::Parse - this should panic
1062        unwrap_io_err(err);
1063    }
1064
1065    #[test]
1066    fn check_manifest_missing_directory_manifest_is_io_error() {
1067        let dir = tempfile::tempdir().unwrap();
1068        let err = check_manifest(dir.path()).unwrap_err();
1069        let e = unwrap_io_err(err);
1070        assert!(e.to_string().contains("No agent.leviath found"));
1071    }
1072
1073    #[test]
1074    fn check_manifest_unreadable_file_path_is_io_error() {
1075        let dir = tempfile::tempdir().unwrap();
1076        // Pass a path to a file that doesn't exist directly (is_file() is
1077        // false, and it's not a directory either) - falls through to the
1078        // "join agent.leviath" branch, which also won't exist.
1079        let missing = dir.path().join("nonexistent-subdir");
1080        let err = check_manifest(&missing).unwrap_err();
1081        unwrap_io_err(err);
1082    }
1083
1084    // Distinct from the two "file doesn't exist" IO-error cases above: this
1085    // exercises `std::fs::read_to_string`'s own `Err` arm (a manifest file
1086    // that *is* found via `path.is_file()`/`.exists()`, but can't actually
1087    // be read), which no other test reaches.
1088    #[test]
1089    fn check_manifest_unreadable_file_is_io_error() {
1090        // `agent.leviath` exists but is a *directory*, so it's found via
1091        // `.exists()` yet `read_to_string` fails on every platform, exercising
1092        // the read_to_string map_err arm.
1093        let dir = tempfile::tempdir().unwrap();
1094        std::fs::create_dir_all(dir.path().join("agent.leviath")).unwrap();
1095
1096        let result = check_manifest(dir.path());
1097
1098        let err = result.unwrap_err();
1099        let e = unwrap_io_err(err);
1100        assert!(e.to_string().contains("Failed to read"));
1101    }
1102
1103    #[test]
1104    fn check_manifest_malformed_toml_is_parse_error() {
1105        let dir = tempfile::tempdir().unwrap();
1106        write_manifest(dir.path(), "not valid toml [[[");
1107        let err = check_manifest(dir.path()).unwrap_err();
1108        assert_is_manifest_parse_error(&err);
1109    }
1110
1111    fn assert_is_manifest_parse_error(err: &ManifestCheckError) {
1112        assert!(matches!(err, ManifestCheckError::Parse(_)));
1113    }
1114
1115    #[test]
1116    #[should_panic(expected = "assertion failed")]
1117    fn assert_is_manifest_parse_error_panics_on_non_parse_error() {
1118        assert_is_manifest_parse_error(&ManifestCheckError::Io(anyhow::anyhow!("x")));
1119    }
1120
1121    #[test]
1122    fn check_manifest_direct_file_path_is_accepted() {
1123        let dir = tempfile::tempdir().unwrap();
1124        let toml = make_blueprint_toml(
1125            r#"
1126[stages.main]
1127mode = "autonomous"
1128model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1129description = "Main stage"
1130max_iterations = 5
1131"#,
1132        );
1133        let manifest_path = write_manifest(dir.path(), &toml);
1134        // Pass the *file* path directly, not the directory.
1135        let blueprint = check_manifest(&manifest_path).unwrap();
1136        assert_eq!(blueprint.name, "test");
1137    }
1138
1139    #[test]
1140    fn check_manifest_valid_linear_blueprint_succeeds() {
1141        let dir = tempfile::tempdir().unwrap();
1142        let toml = make_blueprint_toml(
1143            r#"
1144[stages.main]
1145mode = "autonomous"
1146model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1147description = "Main stage"
1148max_iterations = 5
1149"#,
1150        );
1151        write_manifest(dir.path(), &toml);
1152        let blueprint = check_manifest(dir.path()).unwrap();
1153        assert_eq!(blueprint.name, "test");
1154        assert_eq!(blueprint.stages.len(), 1);
1155    }
1156
1157    // ─── print_success ───────────────────────────────────────────────────
1158
1159    #[test]
1160    fn print_success_linear_mode_no_panic() {
1161        let toml = make_blueprint_toml(
1162            r#"
1163[stages.main]
1164mode = "autonomous"
1165model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1166description = "Main stage"
1167max_iterations = 5
1168
1169[stages.review]
1170mode = "autonomous"
1171model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1172description = "Review stage"
1173max_iterations = 5
1174"#,
1175        );
1176        let bp = parse(&toml);
1177        print_success(&bp);
1178    }
1179
1180    #[test]
1181    fn print_success_graph_mode_with_terminal_and_revisits_no_panic() {
1182        let toml = make_blueprint_toml(
1183            r#"
1184[stages.a]
1185mode = "autonomous"
1186model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1187description = "A"
1188max_iterations = 5
1189entry = true
1190max_revisits = 3
1191[stages.a.transitions]
1192b = "true"
1193
1194[stages.b]
1195mode = "autonomous"
1196model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1197description = "B"
1198max_iterations = 5
1199"#,
1200        );
1201        let bp = parse(&toml);
1202        // Exercises: graph mode header, an edge with a target ("-> b"), and
1203        // stage "b" which has transitions = None ("(linear)" branch) as well
1204        // as the max_revisits formatting on stage "a".
1205        print_success(&bp);
1206    }
1207
1208    #[test]
1209    fn print_success_graph_mode_terminal_stage_no_panic() {
1210        let toml = make_blueprint_toml(
1211            r#"
1212[stages.a]
1213mode = "autonomous"
1214model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1215description = "A"
1216max_iterations = 5
1217entry = true
1218[stages.a.transitions]
1219b = "true"
1220
1221[stages.b]
1222mode = "autonomous"
1223model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1224description = "B"
1225max_iterations = 5
1226[stages.b.transitions]
1227"#,
1228        );
1229        let bp = parse(&toml);
1230        // Stage "b" has an explicitly-empty transitions table -> Some(empty
1231        // map) -> exercises the "(terminal)" formatting branch.
1232        let b = bp.find_stage("b").unwrap();
1233        assert!(matches!(&b.transitions, Some(t) if t.is_empty()));
1234        print_success(&bp);
1235    }
1236}