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 #[arg(long)]
21 pub(crate) json: bool,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
26pub struct BlueprintSummary {
27 pub name: String,
28 pub version: String,
29 pub description: String,
30 pub entry_stage: Option<String>,
33 pub stages: Vec<String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
45pub struct ValidateReport {
46 pub valid: bool,
48 pub blueprint: Option<BlueprintSummary>,
50 pub error: Option<String>,
52 pub findings: Vec<LintFinding>,
53 pub errors: usize,
54 pub warnings: usize,
55 pub notes: usize,
56}
57
58impl ValidateReport {
59 fn linted(
61 blueprint: &leviath_core::Blueprint,
62 findings: Vec<LintFinding>,
63 deny_warnings: bool,
64 ) -> Self {
65 let count = |want: LintSeverity| findings.iter().filter(|f| f.severity == want).count();
66 let (errors, warnings) = (count(LintSeverity::Error), count(LintSeverity::Warning));
67 Self {
68 valid: errors == 0 && !(deny_warnings && warnings > 0),
70 blueprint: Some(BlueprintSummary {
71 name: blueprint.name.clone(),
72 version: blueprint.version.clone(),
73 description: blueprint.description.clone(),
74 entry_stage: blueprint.entry_stage.clone(),
75 stages: blueprint.stages.iter().map(|s| s.name.clone()).collect(),
76 }),
77 error: None,
78 errors,
79 warnings,
80 notes: count(LintSeverity::Note),
81 findings,
82 }
83 }
84
85 fn failed(error: String) -> Self {
87 Self {
88 valid: false,
89 blueprint: None,
90 error: Some(error),
91 findings: Vec::new(),
92 errors: 1,
93 warnings: 0,
94 notes: 0,
95 }
96 }
97
98 fn print(&self) {
99 println!(
102 "{}",
103 serde_json::to_string_pretty(self).expect("a validate report serializes")
104 );
105 }
106}
107
108#[derive(Debug)]
113enum ManifestCheckError {
114 Io(anyhow::Error),
115 Parse(String),
116 Validation(String),
117}
118
119#[derive(Debug)]
122struct CheckedManifest {
123 blueprint: leviath_core::Blueprint,
124 content: String,
125 agent_dir: PathBuf,
127}
128
129fn check_manifest(path: &std::path::Path) -> Result<CheckedManifest, ManifestCheckError> {
130 let manifest_path = if path.is_file() {
132 path.to_path_buf()
133 } else {
134 let p = path.join("agent.leviath");
135 if !p.exists() {
136 return Err(ManifestCheckError::Io(anyhow::anyhow!(
137 "No agent.leviath found at {}",
138 path.display()
139 )));
140 }
141 p
142 };
143
144 let content = std::fs::read_to_string(&manifest_path).map_err(|e| {
145 ManifestCheckError::Io(anyhow::anyhow!(
146 "Failed to read {}: {}",
147 manifest_path.display(),
148 e
149 ))
150 })?;
151
152 let blueprint = leviath_core::manifest::parse_manifest(&content)
153 .map_err(|e| ManifestCheckError::Parse(e.to_string()))?;
154
155 blueprint
156 .validate()
157 .map_err(|e| ManifestCheckError::Validation(e.to_string()))?;
158
159 crate::daemon::spawn::resolve_region_scripts(&blueprint, &manifest_path.to_string_lossy())
164 .map_err(ManifestCheckError::Validation)?;
165
166 let agent_dir = manifest_path
167 .parent()
168 .map(std::path::Path::to_path_buf)
169 .unwrap_or_default();
170 Ok(CheckedManifest {
171 blueprint,
172 content,
173 agent_dir,
174 })
175}
176
177fn print_success(blueprint: &leviath_core::Blueprint) {
179 println!("✓ Blueprint '{}' is valid.", blueprint.name);
180 println!(
181 " {} stages, version {}",
182 blueprint.stages.len(),
183 blueprint.version
184 );
185
186 let is_graph = blueprint.stages.iter().any(|s| s.transitions.is_some());
188 if is_graph {
189 let entry = blueprint.resolve_entry_stage_name();
190 println!(" Graph mode: entry stage '{}'", entry);
191
192 for stage in &blueprint.stages {
194 let transitions_info = match &stage.transitions {
195 Some(t) if !t.is_empty() => {
196 let targets: Vec<&str> = t.keys().map(|k| k.as_str()).collect();
197 format!(" → {}", targets.join(", "))
198 }
199 Some(_) => " (terminal)".to_string(),
200 None => " (linear)".to_string(),
201 };
202 let revisits = stage
203 .max_revisits
204 .map(|n| format!(" (max_revisits: {})", n))
205 .unwrap_or_default();
206 println!(" - {}{}{}", stage.name, transitions_info, revisits);
207 }
208 } else {
209 println!(
210 " Linear mode: {}",
211 blueprint
212 .stages
213 .iter()
214 .map(|s| s.name.as_str())
215 .collect::<Vec<_>>()
216 .join(" → ")
217 );
218 }
219}
220
221#[derive(Debug)]
225enum ValidateOutcome {
226 Success,
227 ParseError(String),
228 ValidationError(String),
229 LintFailed {
233 errors: usize,
234 warnings: usize,
235 },
236}
237
238fn print_findings(findings: &[LintFinding]) -> (usize, usize) {
243 let mut errors = 0;
244 let mut warnings = 0;
245 for finding in findings {
246 match finding.severity {
247 LintSeverity::Error => errors += 1,
248 LintSeverity::Warning => warnings += 1,
249 LintSeverity::Note => {}
250 }
251 println!(
252 " {} {} [{}]",
253 finding.severity.label(),
254 finding.one_line(),
255 finding.code
256 );
257 if let Some(fix) = &finding.fix {
258 println!(" {fix}");
259 }
260 }
261 (errors, warnings)
262}
263
264fn execute_reporting_outcome(
271 args: &ValidateArgs,
272 config: Option<&crate::config::Config>,
273) -> anyhow::Result<ValidateOutcome> {
274 let path = PathBuf::from(&args.path);
275
276 let checked = match check_manifest(&path) {
277 Ok(c) => c,
278 Err(ManifestCheckError::Io(e)) => return Err(e),
279 Err(ManifestCheckError::Parse(e)) => {
280 if args.json {
281 ValidateReport::failed(format!("parse error: {e}")).print();
282 }
283 return Ok(ValidateOutcome::ParseError(e));
284 }
285 Err(ManifestCheckError::Validation(e)) => {
286 if args.json {
287 ValidateReport::failed(format!("validation failed: {e}")).print();
288 }
289 return Ok(ValidateOutcome::ValidationError(e));
290 }
291 };
292
293 if !args.json {
296 print_success(&checked.blueprint);
297 print_script_tool_report(&path);
298 }
299
300 let mut env = LintEnv::offline(&checked.agent_dir);
301 if let Some(config) = config {
302 let workdir = crate::commands::resolve_cwd().unwrap_or_default();
306 env = env
307 .with_providers(&checked.blueprint, config)
308 .with_read_paths(&checked.blueprint, config, &workdir);
309 }
310 let findings = lint_manifest(&checked.content, &checked.blueprint, &env);
311 let (errors, warnings) = match args.json {
312 true => {
313 let report = ValidateReport::linted(&checked.blueprint, findings, args.deny_warnings);
314 report.print();
315 (report.errors, report.warnings)
316 }
317 false => print_findings(&findings),
318 };
319
320 if errors > 0 || (args.deny_warnings && warnings > 0) {
321 return Ok(ValidateOutcome::LintFailed { errors, warnings });
322 }
323 Ok(ValidateOutcome::Success)
324}
325
326fn lint_failure_message(errors: usize, warnings: usize, deny_warnings: bool) -> String {
329 let mut parts = Vec::new();
330 if errors > 0 {
331 parts.push(format!("{errors} error{}", plural(errors)));
332 }
333 if deny_warnings && warnings > 0 {
334 parts.push(format!(
335 "{warnings} warning{} (--deny-warnings)",
336 plural(warnings)
337 ));
338 }
339 format!("✗ Blueprint has {}", parts.join(" and "))
340}
341
342fn plural(n: usize) -> &'static str {
343 if n == 1 { "" } else { "s" }
344}
345
346fn print_script_tool_report(path: &std::path::Path) {
351 let agent_dir = if path.is_file() {
353 path.parent().unwrap_or(path).to_path_buf()
354 } else {
355 path.to_path_buf()
356 };
357 let tools_dir = agent_dir.join("tools");
358 if !tools_dir.is_dir() {
359 return;
360 }
361 let (set, skipped) = leviath_scripting::ScriptToolSet::discover(&[tools_dir]);
362 if !set.is_empty() {
363 println!(" {} script tool(s) in tools/", set.len());
364 }
365 for meta in set.metas() {
368 if !crate::daemon::spawn::current_platform_satisfies(&meta.required_caps) {
369 println!(
370 " ⚠ Warning: script tool '{}' won't load here (unsatisfiable @requires: {})",
371 meta.name,
372 meta.required_caps.join(", ")
373 );
374 }
375 }
376 for s in &skipped {
377 println!(
378 " ⚠ Warning: script tool '{}' skipped: {}",
379 s.path.display(),
380 s.reason
381 );
382 }
383}
384
385pub async fn execute(args: ValidateArgs) -> anyhow::Result<()> {
386 let config = crate::config::Config::load().ok();
387 match execute_reporting_outcome(&args, config.as_ref())? {
388 ValidateOutcome::Success => Ok(()),
389 ValidateOutcome::ParseError(e) => anyhow::bail!("✗ Parse error: {}", e),
390 ValidateOutcome::ValidationError(e) => anyhow::bail!("✗ Validation failed: {}", e),
391 ValidateOutcome::LintFailed { errors, warnings } => {
392 anyhow::bail!(lint_failure_message(errors, warnings, args.deny_warnings))
393 }
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use crate::test_support::write_test_agent;
401
402 const CLEAN_MANIFEST: &str = r#"
410[agent]
411name = "ok-agent"
412version = "0.1.0"
413description = "Valid"
414
415[stages.main]
416mode = "autonomous"
417model = { models = [{ provider = "anthropic", model = "claude-sonnet-5" }, { provider = "ollama", model = "qwen3.5:9b" }] }
418description = "Main"
419max_iterations = 5
420
421[context.regions]
422system = { kind = "pinned", max_tokens = 1000 }
423conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
424"#;
425
426 fn write_manifest(dir: &std::path::Path, content: &str) -> std::path::PathBuf {
427 let path = dir.join("agent.leviath");
428 std::fs::write(&path, content).unwrap();
429 path
430 }
431
432 fn args_for(dir: &std::path::Path) -> ValidateArgs {
433 ValidateArgs {
434 path: dir.to_str().unwrap().to_string(),
435 deny_warnings: false,
436 json: false,
437 }
438 }
439
440 fn parse(toml: &str) -> leviath_core::Blueprint {
443 leviath_core::manifest::parse_manifest(toml).unwrap()
444 }
445
446 fn make_blueprint_toml(stages_toml: &str) -> String {
448 format!(
449 r#"
450[agent]
451name = "test"
452version = "0.1.0"
453description = "test blueprint"
454
455{stages_toml}
456
457[context.regions]
458system = {{ kind = "pinned", max_tokens = 1000 }}
459conversation = {{ kind = "sliding_window", max_items = 50, max_tokens = 10000 }}
460"#
461 )
462 }
463
464 #[test]
465 fn print_success_linear_mode_no_panic() {
466 let toml = make_blueprint_toml(
467 r#"
468[stages.main]
469mode = "autonomous"
470model = { provider = "anthropic", model = "claude-sonnet-4-6" }
471description = "Main stage"
472max_iterations = 5
473
474[stages.review]
475mode = "autonomous"
476model = { provider = "anthropic", model = "claude-sonnet-4-6" }
477description = "Review stage"
478max_iterations = 5
479"#,
480 );
481 print_success(&parse(&toml));
482 }
483
484 #[test]
485 fn print_success_graph_mode_with_terminal_and_revisits_no_panic() {
486 let toml = make_blueprint_toml(
487 r#"
488[stages.a]
489mode = "autonomous"
490model = { provider = "anthropic", model = "claude-sonnet-4-6" }
491description = "A"
492max_iterations = 5
493entry = true
494max_revisits = 3
495[stages.a.transitions]
496b = "true"
497
498[stages.b]
499mode = "autonomous"
500model = { provider = "anthropic", model = "claude-sonnet-4-6" }
501description = "B"
502max_iterations = 5
503"#,
504 );
505 print_success(&parse(&toml));
509 }
510
511 #[test]
512 fn print_success_graph_mode_terminal_stage_no_panic() {
513 let toml = make_blueprint_toml(
514 r#"
515[stages.a]
516mode = "autonomous"
517model = { provider = "anthropic", model = "claude-sonnet-4-6" }
518description = "A"
519max_iterations = 5
520entry = true
521[stages.a.transitions]
522b = "true"
523
524[stages.b]
525mode = "autonomous"
526model = { provider = "anthropic", model = "claude-sonnet-4-6" }
527description = "B"
528max_iterations = 5
529[stages.b.transitions]
530"#,
531 );
532 let bp = parse(&toml);
533 let b = bp.find_stage("b").unwrap();
536 assert!(matches!(&b.transitions, Some(t) if t.is_empty()));
537 print_success(&bp);
538 }
539
540 #[test]
545 fn print_findings_counts_errors_and_warnings_but_not_notes() {
546 let findings = [
547 (LintSeverity::Error, "e"),
548 (LintSeverity::Error, "e2"),
549 (LintSeverity::Warning, "w"),
550 (LintSeverity::Note, "n"),
551 ]
552 .map(|(severity, code)| LintFinding {
553 severity,
554 code,
555 stage: Some("main".to_string()),
556 message: "something".to_string(),
557 fix: (code == "e").then(|| "do the thing".to_string()),
559 });
560 assert_eq!(print_findings(&findings), (2, 1));
561 }
562
563 #[test]
564 fn print_findings_on_an_empty_list_reports_nothing() {
565 assert_eq!(print_findings(&[]), (0, 0));
566 }
567
568 #[test]
571 fn lint_failure_message_pluralizes_and_names_the_flag() {
572 assert_eq!(lint_failure_message(1, 0, false), "✗ Blueprint has 1 error");
573 assert_eq!(
574 lint_failure_message(2, 5, false),
575 "✗ Blueprint has 2 errors",
576 "warnings are not counted unless they were asked to be"
577 );
578 assert_eq!(
579 lint_failure_message(0, 1, true),
580 "✗ Blueprint has 1 warning (--deny-warnings)"
581 );
582 assert_eq!(
583 lint_failure_message(1, 2, true),
584 "✗ Blueprint has 1 error and 2 warnings (--deny-warnings)"
585 );
586 }
587
588 #[tokio::test]
596 async fn execute_parse_error_returns_error() {
597 crate::config::with_isolated_config_path_async("validate-parse-error", |_| async {
598 let dir = tempfile::tempdir().unwrap();
599 write_manifest(dir.path(), "not valid toml [[[");
600 let err = execute(args_for(dir.path())).await.unwrap_err();
601 assert!(err.to_string().contains("Parse error"));
602 })
603 .await;
604 }
605
606 #[tokio::test]
607 async fn execute_validation_error_returns_error() {
608 crate::config::with_isolated_config_path_async("validate-validation-error", |_| async {
609 let dir = tempfile::tempdir().unwrap();
610 let manifest = r#"
611[agent]
612name = "bad-entry-agent"
613version = "0.1.0"
614description = "Entry stage does not exist"
615entry_stage = "does-not-exist"
616
617[stages.main]
618mode = "autonomous"
619model = { provider = "anthropic", model = "claude-sonnet-4-6" }
620description = "Main"
621max_iterations = 5
622
623[context.regions]
624system = { kind = "pinned", max_tokens = 1000 }
625"#;
626 write_manifest(dir.path(), manifest);
627 let err = execute(args_for(dir.path())).await.unwrap_err();
628 assert!(err.to_string().contains("Validation failed"));
629 })
630 .await;
631 }
632
633 #[tokio::test]
635 async fn execute_lint_error_fails_the_command() {
636 crate::config::with_isolated_config_path_async("validate-lint-error", |_| async {
637 let dir = tempfile::tempdir().unwrap();
638 write_manifest(
639 dir.path(),
640 &CLEAN_MANIFEST.replace(
641 "max_iterations = 5",
642 "max_iterations = 5\navailable_tools = [\"raed_file\"]",
643 ),
644 );
645 let err = execute(args_for(dir.path())).await.unwrap_err();
646 assert_eq!(err.to_string(), "✗ Blueprint has 1 error");
647 })
648 .await;
649 }
650
651 #[tokio::test]
655 async fn warnings_only_fail_when_denied() {
656 crate::config::with_isolated_config_path_async("validate-deny-warnings", |_| async {
657 let dir = tempfile::tempdir().unwrap();
658 write_manifest(
660 dir.path(),
661 &CLEAN_MANIFEST.replace("max_iterations = 5", ""),
662 );
663
664 let mut args = args_for(dir.path());
665 assert!(execute_reporting_outcome(&args, None).unwrap().is_success());
666
667 args.deny_warnings = true;
668 let err = execute(args).await.unwrap_err();
669 assert_eq!(
670 err.to_string(),
671 "✗ Blueprint has 1 warning (--deny-warnings)"
672 );
673 })
674 .await;
675 }
676
677 #[tokio::test]
678 async fn execute_no_manifest_errors() {
679 crate::config::with_isolated_config_path_async("validate-no-manifest", |_| async {
680 let dir = tempfile::tempdir().unwrap();
681 assert!(execute(args_for(dir.path())).await.is_err());
682 })
683 .await;
684 }
685
686 #[tokio::test]
688 async fn execute_valid_manifest_file_path() {
689 crate::config::with_isolated_config_path_async("validate-file-path", |_| async {
690 let dir = tempfile::tempdir().unwrap();
691 let manifest_path = write_manifest(dir.path(), CLEAN_MANIFEST);
692 let args = ValidateArgs {
693 path: manifest_path.to_str().unwrap().to_string(),
694 deny_warnings: false,
695 json: false,
696 };
697 assert!(execute(args).await.is_ok());
698 })
699 .await;
700 }
701
702 #[tokio::test]
703 async fn execute_valid_manifest_directory_path() {
704 crate::config::with_isolated_config_path_async("validate-dir-path", |_| async {
705 let dir = tempfile::tempdir().unwrap();
706 write_test_agent(dir.path(), CLEAN_MANIFEST);
707 assert!(execute(args_for(dir.path())).await.is_ok());
708 })
709 .await;
710 }
711
712 impl ValidateOutcome {
715 fn is_success(&self) -> bool {
719 matches!(self, Self::Success)
720 }
721
722 fn is_parse_error(&self) -> bool {
723 matches!(self, Self::ParseError(_))
724 }
725
726 fn is_validation_error(&self) -> bool {
727 matches!(self, Self::ValidationError(_))
728 }
729 }
730
731 #[test]
732 fn outcome_predicates_distinguish_the_variants() {
733 assert!(ValidateOutcome::Success.is_success());
734 assert!(!ValidateOutcome::Success.is_parse_error());
735 assert!(!ValidateOutcome::Success.is_validation_error());
736 assert!(ValidateOutcome::ParseError(String::new()).is_parse_error());
737 assert!(ValidateOutcome::ValidationError(String::new()).is_validation_error());
738 assert!(
739 !ValidateOutcome::LintFailed {
740 errors: 1,
741 warnings: 0
742 }
743 .is_success()
744 );
745 }
746
747 fn json_args_for(dir: &std::path::Path) -> ValidateArgs {
750 ValidateArgs {
751 json: true,
752 ..args_for(dir)
753 }
754 }
755
756 fn finding(severity: LintSeverity, code: &'static str) -> LintFinding {
760 LintFinding {
761 severity,
762 code,
763 stage: None,
764 message: format!("{code} message"),
765 fix: None,
766 }
767 }
768
769 #[test]
770 fn json_report_of_a_clean_manifest_is_valid_and_names_its_stages() {
771 let blueprint = parse(CLEAN_MANIFEST);
772 let report = ValidateReport::linted(&blueprint, Vec::new(), false);
773 assert!(report.valid);
774 assert_eq!(report.error, None);
775 let summary = report.blueprint.expect("a parsed manifest has a summary");
776 assert_eq!(summary.name, "ok-agent");
777 assert_eq!(summary.stages, vec!["main".to_string()]);
778 assert_eq!((report.errors, report.warnings, report.notes), (0, 0, 0));
779 }
780
781 #[test]
782 fn json_report_counts_each_severity_separately() {
783 let blueprint = parse(CLEAN_MANIFEST);
784 let findings = vec![
785 finding(LintSeverity::Error, "a"),
786 finding(LintSeverity::Warning, "b"),
787 finding(LintSeverity::Note, "c"),
788 ];
789 let report = ValidateReport::linted(&blueprint, findings, false);
790 assert_eq!((report.errors, report.warnings, report.notes), (1, 1, 1));
791 assert!(!report.valid);
793 }
794
795 #[test]
796 fn json_report_is_valid_with_a_warning_until_deny_warnings() {
797 let blueprint = parse(CLEAN_MANIFEST);
798 let warning = || vec![finding(LintSeverity::Warning, "b")];
799 assert!(ValidateReport::linted(&blueprint, warning(), false).valid);
800 assert!(!ValidateReport::linted(&blueprint, warning(), true).valid);
801 }
802
803 #[test]
804 fn json_report_of_a_note_stays_valid_under_deny_warnings() {
805 let blueprint = parse(CLEAN_MANIFEST);
808 let notes = vec![finding(LintSeverity::Note, "c")];
809 assert!(ValidateReport::linted(&blueprint, notes, true).valid);
810 }
811
812 #[test]
813 fn json_report_of_a_broken_manifest_carries_the_error_and_no_blueprint() {
814 let report = ValidateReport::failed("parse error: boom".to_string());
815 assert!(!report.valid);
816 assert!(report.blueprint.is_none());
817 assert_eq!(report.error.as_deref(), Some("parse error: boom"));
818 }
819
820 #[test]
821 fn json_report_serializes_every_key_a_caller_reads() {
822 let blueprint = parse(CLEAN_MANIFEST);
823 let report = ValidateReport::linted(
824 &blueprint,
825 vec![finding(LintSeverity::Error, "unknown-tool")],
826 false,
827 );
828 let value: serde_json::Value =
829 serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
830 assert_eq!(value["valid"], serde_json::json!(false));
831 assert_eq!(value["blueprint"]["name"], serde_json::json!("ok-agent"));
832 assert_eq!(value["error"], serde_json::Value::Null);
833 assert_eq!(
836 value["findings"][0]["code"],
837 serde_json::json!("unknown-tool")
838 );
839 assert_eq!(value["findings"][0]["severity"], serde_json::json!("error"));
840 }
841
842 #[test]
843 fn json_mode_still_reports_a_parse_error_through_the_outcome() {
844 let dir = tempfile::tempdir().unwrap();
845 write_manifest(dir.path(), "not valid toml [[[");
846 assert!(
847 execute_reporting_outcome(&json_args_for(dir.path()), None)
848 .unwrap()
849 .is_parse_error()
850 );
851 }
852
853 #[test]
854 fn json_mode_still_reports_a_validation_error_through_the_outcome() {
855 let dir = tempfile::tempdir().unwrap();
858 write_manifest(
859 dir.path(),
860 r#"
861[agent]
862name = "bad-entry-agent"
863version = "0.1.0"
864description = "Entry stage does not exist"
865entry_stage = "does-not-exist"
866
867[stages.main]
868mode = "autonomous"
869model = { provider = "anthropic", model = "claude-sonnet-4-6" }
870description = "Main"
871max_iterations = 5
872
873[context.regions]
874system = { kind = "pinned", max_tokens = 1000 }
875"#,
876 );
877 assert!(
878 execute_reporting_outcome(&json_args_for(dir.path()), None)
879 .unwrap()
880 .is_validation_error()
881 );
882 }
883
884 #[test]
885 fn json_mode_still_succeeds_on_a_clean_manifest() {
886 let dir = tempfile::tempdir().unwrap();
887 write_manifest(dir.path(), CLEAN_MANIFEST);
888 assert!(
889 execute_reporting_outcome(&json_args_for(dir.path()), None)
890 .unwrap()
891 .is_success()
892 );
893 }
894
895 #[test]
896 fn execute_reporting_outcome_malformed_toml_is_parse_error() {
897 let dir = tempfile::tempdir().unwrap();
898 write_manifest(dir.path(), "not valid toml [[[");
899 assert!(
900 execute_reporting_outcome(&args_for(dir.path()), None)
901 .unwrap()
902 .is_parse_error()
903 );
904 }
905
906 #[test]
907 fn execute_reporting_outcome_bad_entry_stage_is_validation_error() {
908 let dir = tempfile::tempdir().unwrap();
909 let manifest = r#"
910[agent]
911name = "bad-entry-agent"
912version = "0.1.0"
913description = "Entry stage does not exist"
914entry_stage = "does-not-exist"
915
916[stages.main]
917mode = "autonomous"
918model = { provider = "anthropic", model = "claude-sonnet-4-6" }
919description = "Main"
920max_iterations = 5
921
922[context.regions]
923system = { kind = "pinned", max_tokens = 1000 }
924"#;
925 write_manifest(dir.path(), manifest);
926 assert!(
927 execute_reporting_outcome(&args_for(dir.path()), None)
928 .unwrap()
929 .is_validation_error()
930 );
931 }
932
933 #[test]
934 fn execute_reporting_outcome_missing_manifest_is_io_error() {
935 let dir = tempfile::tempdir().unwrap();
936 assert!(execute_reporting_outcome(&args_for(dir.path()), None).is_err());
937 }
938
939 #[test]
940 fn execute_reporting_outcome_valid_manifest_is_success() {
941 let dir = tempfile::tempdir().unwrap();
942 write_manifest(dir.path(), CLEAN_MANIFEST);
943 assert!(
944 execute_reporting_outcome(&args_for(dir.path()), None)
945 .unwrap()
946 .is_success()
947 );
948 }
949
950 #[test]
953 fn command_seed_regions_are_noted_without_failing() {
954 let dir = tempfile::tempdir().unwrap();
955 let manifest = r#"
956[agent]
957name = "scanner"
958version = "0.1.0"
959
960[stages.main]
961mode = "autonomous"
962model = { provider = "anthropic", model = "claude-sonnet-5" }
963description = "Main stage"
964max_iterations = 5
965
966[context.regions]
967facts = { kind = "pinned", max_tokens = 1000, seed = { command = "git ls-files" } }
968conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
969"#;
970 write_manifest(dir.path(), manifest);
971 let args = ValidateArgs {
973 path: dir.path().to_str().unwrap().to_string(),
974 deny_warnings: true,
975 json: false,
976 };
977 assert!(execute_reporting_outcome(&args, None).unwrap().is_success());
978 }
979
980 #[test]
981 fn execute_reporting_outcome_reports_agent_script_tools() {
982 let dir = tempfile::tempdir().unwrap();
986 write_manifest(dir.path(), CLEAN_MANIFEST);
987 let tools = dir.path().join("tools");
988 std::fs::create_dir(&tools).unwrap();
989 std::fs::write(tools.join("ok.rhai"), "// @tool ok\nparams.x").unwrap();
990 std::fs::write(tools.join("bad.rhai"), "no directive\nlet").unwrap();
991 std::fs::write(tools.join("gpu.rhai"), "// @tool gpu\n// @requires gpu\n1").unwrap();
993 assert!(
994 execute_reporting_outcome(&args_for(dir.path()), None)
995 .unwrap()
996 .is_success()
997 );
998 }
999
1000 #[test]
1004 fn an_agents_own_script_tool_resolves() {
1005 let dir = tempfile::tempdir().unwrap();
1006 write_manifest(
1007 dir.path(),
1008 &CLEAN_MANIFEST.replace(
1009 "max_iterations = 5",
1010 "max_iterations = 5\navailable_tools = [\"stub_search\"]",
1011 ),
1012 );
1013 let tools = dir.path().join("tools");
1014 std::fs::create_dir(&tools).unwrap();
1015 std::fs::write(
1016 tools.join("stub_search.rhai"),
1017 "// @tool stub_search\n// @description searches\n\"found\"",
1018 )
1019 .unwrap();
1020 assert!(
1021 execute_reporting_outcome(&args_for(dir.path()), None)
1022 .unwrap()
1023 .is_success()
1024 );
1025 }
1026
1027 #[test]
1028 fn print_script_tool_report_no_tools_dir_is_silent() {
1029 let dir = tempfile::tempdir().unwrap();
1033 let manifest = write_manifest(dir.path(), "unused");
1034 print_script_tool_report(&manifest);
1035 }
1036
1037 #[test]
1038 fn print_script_tool_report_only_broken_scripts_warns_without_count() {
1039 let dir = tempfile::tempdir().unwrap();
1042 let tools = dir.path().join("tools");
1043 std::fs::create_dir(&tools).unwrap();
1044 std::fs::write(tools.join("bad.rhai"), "no directive\nlet").unwrap();
1045 print_script_tool_report(dir.path());
1046 }
1047
1048 #[test]
1051 fn check_manifest_verifies_custom_region_scripts() {
1052 let dir = tempfile::tempdir().unwrap();
1055 let toml = r#"
1056[agent]
1057name = "custom-validate"
1058version = "0.1.0"
1059description = "d"
1060
1061[stages.main]
1062mode = "autonomous"
1063model = { provider = "anthropic", model = "claude-sonnet-5" }
1064description = "Main stage"
1065
1066[context.regions]
1067system = { kind = "pinned", max_tokens = 1000 }
1068conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
1069brain = { kind = "custom", script = "hooks/brain.rhai", max_tokens = 1000 }
1070"#;
1071 let manifest_path = write_manifest(dir.path(), toml);
1072
1073 let err = format!("{:?}", check_manifest(&manifest_path).unwrap_err());
1075 assert!(err.starts_with("Validation"), "{err}");
1076 assert!(err.contains("region 'brain'"), "{err}");
1077
1078 std::fs::create_dir(dir.path().join("hooks")).unwrap();
1080 std::fs::write(
1081 dir.path().join("hooks/brain.rhai"),
1082 "fn render(ctx) { \"ok\" }",
1083 )
1084 .unwrap();
1085 let checked = check_manifest(&manifest_path).unwrap();
1086 assert_eq!(checked.blueprint.name, "custom-validate");
1087 assert!(checked.content.contains("custom-validate"));
1090 assert_eq!(checked.agent_dir, dir.path());
1091 }
1092
1093 fn unwrap_io_err(err: ManifestCheckError) -> anyhow::Error {
1096 let ManifestCheckError::Io(e) = err else {
1097 panic!("expected ManifestCheckError::Io, got {err:?}");
1098 };
1099 e
1100 }
1101
1102 #[test]
1103 #[should_panic(expected = "expected ManifestCheckError::Io")]
1104 fn unwrap_io_err_panics_on_parse_variant() {
1105 let dir = tempfile::tempdir().unwrap();
1106 write_manifest(dir.path(), "not valid toml [[[");
1107 let err = check_manifest(dir.path()).unwrap_err();
1108 unwrap_io_err(err);
1110 }
1111
1112 #[test]
1113 fn check_manifest_missing_directory_manifest_is_io_error() {
1114 let dir = tempfile::tempdir().unwrap();
1115 let err = check_manifest(dir.path()).unwrap_err();
1116 let e = unwrap_io_err(err);
1117 assert!(e.to_string().contains("No agent.leviath found"));
1118 }
1119
1120 #[test]
1121 fn check_manifest_unreadable_file_path_is_io_error() {
1122 let dir = tempfile::tempdir().unwrap();
1123 let missing = dir.path().join("nonexistent-subdir");
1127 let err = check_manifest(&missing).unwrap_err();
1128 unwrap_io_err(err);
1129 }
1130
1131 #[test]
1136 fn check_manifest_unreadable_file_is_io_error() {
1137 let dir = tempfile::tempdir().unwrap();
1141 std::fs::create_dir_all(dir.path().join("agent.leviath")).unwrap();
1142
1143 let err = check_manifest(dir.path()).unwrap_err();
1144 let e = unwrap_io_err(err);
1145 assert!(e.to_string().contains("Failed to read"));
1146 }
1147
1148 impl ManifestCheckError {
1149 fn is_parse(&self) -> bool {
1154 matches!(self, Self::Parse(_))
1155 }
1156 }
1157
1158 #[test]
1159 fn check_manifest_malformed_toml_is_parse_error() {
1160 let dir = tempfile::tempdir().unwrap();
1161 write_manifest(dir.path(), "not valid toml [[[");
1162 assert!(check_manifest(dir.path()).unwrap_err().is_parse());
1163 let empty = tempfile::tempdir().unwrap();
1166 assert!(!check_manifest(empty.path()).unwrap_err().is_parse());
1167 }
1168
1169 #[test]
1170 fn check_manifest_direct_file_path_is_accepted() {
1171 let dir = tempfile::tempdir().unwrap();
1172 let manifest_path = write_manifest(dir.path(), CLEAN_MANIFEST);
1173 let checked = check_manifest(&manifest_path).unwrap();
1175 assert_eq!(checked.blueprint.name, "ok-agent");
1176 }
1177}