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