1use clap::Args;
4use std::path::PathBuf;
5
6use crate::lint::{LintEnv, LintFinding, LintSeverity, lint_manifest};
7
8#[derive(Args)]
9pub struct ValidateArgs {
10 #[arg(default_value = ".")]
12 pub(crate) path: String,
13
14 #[arg(long)]
16 pub(crate) deny_warnings: bool,
17}
18
19#[derive(Debug)]
24enum ManifestCheckError {
25 Io(anyhow::Error),
26 Parse(String),
27 Validation(String),
28}
29
30#[derive(Debug)]
33struct CheckedManifest {
34 blueprint: leviath_core::Blueprint,
35 content: String,
36 agent_dir: PathBuf,
38}
39
40fn check_manifest(path: &std::path::Path) -> Result<CheckedManifest, ManifestCheckError> {
41 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 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
88fn 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 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 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#[derive(Debug)]
136enum ValidateOutcome {
137 Success,
138 ParseError(String),
139 ValidationError(String),
140 LintFailed {
144 errors: usize,
145 warnings: usize,
146 },
147}
148
149fn 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
175fn 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 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
216fn 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
236fn print_script_tool_report(path: &std::path::Path) {
241 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 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 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 fn parse(toml: &str) -> leviath_core::Blueprint {
332 leviath_core::manifest::parse_manifest(toml).unwrap()
333 }
334
335 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 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 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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 impl ValidateOutcome {
603 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 #[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 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 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 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 #[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 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 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 #[test]
790 fn check_manifest_verifies_custom_region_scripts() {
791 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 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 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 assert!(checked.content.contains("custom-validate"));
829 assert_eq!(checked.agent_dir, dir.path());
830 }
831
832 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 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 let missing = dir.path().join("nonexistent-subdir");
866 let err = check_manifest(&missing).unwrap_err();
867 unwrap_io_err(err);
868 }
869
870 #[test]
875 fn check_manifest_unreadable_file_is_io_error() {
876 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 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 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 let checked = check_manifest(&manifest_path).unwrap();
914 assert_eq!(checked.blueprint.name, "ok-agent");
915 }
916}