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 manifest_path_for(path: &std::path::Path) -> std::path::PathBuf {
143 if path.is_file() {
144 path.to_path_buf()
145 } else {
146 path.join("agent.leviath")
147 }
148}
149
150fn check_manifest(path: &std::path::Path) -> Result<CheckedManifest, ManifestCheckError> {
151 let manifest_path = manifest_path_for(path);
152 if !manifest_path.exists() {
153 return Err(ManifestCheckError::Io(anyhow::anyhow!(
154 "No agent.leviath found at {}",
155 path.display()
156 )));
157 }
158
159 let content = std::fs::read_to_string(&manifest_path).map_err(|e| {
160 ManifestCheckError::Io(anyhow::anyhow!(
161 "Failed to read {}: {}",
162 manifest_path.display(),
163 e
164 ))
165 })?;
166
167 let blueprint = leviath_core::manifest::parse_manifest(&content)
168 .map_err(|e| ManifestCheckError::Parse(e.to_string()))?;
169
170 blueprint
171 .validate()
172 .map_err(|e| ManifestCheckError::Validation(e.to_string()))?;
173
174 crate::daemon::spawn::resolve_region_scripts(&blueprint, &manifest_path.to_string_lossy())
179 .map_err(ManifestCheckError::Validation)?;
180
181 let agent_dir = manifest_path
182 .parent()
183 .map(std::path::Path::to_path_buf)
184 .unwrap_or_default();
185 Ok(CheckedManifest {
186 blueprint,
187 content,
188 agent_dir,
189 })
190}
191
192fn print_success(blueprint: &leviath_core::Blueprint) {
194 println!("✓ Blueprint '{}' is valid.", blueprint.name);
195 println!(
196 " {} stages, version {}",
197 blueprint.stages.len(),
198 blueprint.version
199 );
200
201 let is_graph = blueprint.stages.iter().any(|s| s.transitions.is_some());
203 if is_graph {
204 let entry = blueprint.resolve_entry_stage_name();
205 println!(" Graph mode: entry stage '{}'", entry);
206
207 for stage in &blueprint.stages {
209 let transitions_info = match &stage.transitions {
210 Some(t) if !t.is_empty() => {
211 let targets: Vec<&str> = t.keys().map(|k| k.as_str()).collect();
212 format!(" → {}", targets.join(", "))
213 }
214 Some(_) => " (terminal)".to_string(),
215 None => " (linear)".to_string(),
216 };
217 let revisits = stage
218 .max_revisits
219 .map(|n| format!(" (max_revisits: {})", n))
220 .unwrap_or_default();
221 println!(" - {}{}{}", stage.name, transitions_info, revisits);
222 }
223 } else {
224 println!(
225 " Linear mode: {}",
226 blueprint
227 .stages
228 .iter()
229 .map(|s| s.name.as_str())
230 .collect::<Vec<_>>()
231 .join(" → ")
232 );
233 }
234}
235
236#[derive(Debug)]
240enum ValidateOutcome {
241 Success,
242 ParseError(String),
243 ValidationError(String),
244 LintFailed {
248 errors: usize,
249 warnings: usize,
250 },
251}
252
253fn print_findings(findings: &[LintFinding]) -> (usize, usize) {
258 let mut errors = 0;
259 let mut warnings = 0;
260 for finding in findings {
261 match finding.severity {
262 LintSeverity::Error => errors += 1,
263 LintSeverity::Warning => warnings += 1,
264 LintSeverity::Note => {}
265 }
266 println!(
267 " {} {} [{}]",
268 finding.severity.label(),
269 finding.one_line(),
270 finding.code
271 );
272 if let Some(fix) = &finding.fix {
273 println!(" {fix}");
274 }
275 }
276 (errors, warnings)
277}
278
279fn execute_reporting_outcome(
286 args: &ValidateArgs,
287 config: Option<&crate::config::Config>,
288) -> anyhow::Result<ValidateOutcome> {
289 let path = PathBuf::from(&args.path);
290
291 let checked = match check_manifest(&path) {
292 Ok(c) => c,
293 Err(ManifestCheckError::Io(e)) => return Err(e),
294 Err(ManifestCheckError::Parse(e)) => {
295 if args.json {
296 ValidateReport::failed(format!("parse error: {e}")).print();
297 }
298 return Ok(ValidateOutcome::ParseError(e));
299 }
300 Err(ManifestCheckError::Validation(e)) => {
301 if args.json {
302 ValidateReport::failed(format!("validation failed: {e}")).print();
303 }
304 return Ok(ValidateOutcome::ValidationError(e));
305 }
306 };
307
308 if !args.json {
311 print_success(&checked.blueprint);
312 print_script_tool_report(&path);
313 }
314
315 let mut env = LintEnv::offline(&checked.agent_dir);
316 if let Some(config) = config {
317 let workdir = crate::commands::resolve_cwd().unwrap_or_default();
321 env = env
322 .with_providers(&checked.blueprint, config)
323 .with_read_paths(&checked.blueprint, config, &workdir);
324 }
325 let findings = lint_manifest(&checked.content, &checked.blueprint, &env);
326 let (errors, warnings) = match args.json {
327 true => {
328 let report = ValidateReport::linted(&checked.blueprint, findings, args.deny_warnings);
329 report.print();
330 (report.errors, report.warnings)
331 }
332 false => print_findings(&findings),
333 };
334
335 if errors > 0 || (args.deny_warnings && warnings > 0) {
336 return Ok(ValidateOutcome::LintFailed { errors, warnings });
337 }
338 Ok(ValidateOutcome::Success)
339}
340
341fn lint_failure_message(errors: usize, warnings: usize, deny_warnings: bool) -> String {
344 let mut parts = Vec::new();
345 if errors > 0 {
346 parts.push(format!("{errors} error{}", plural(errors)));
347 }
348 if deny_warnings && warnings > 0 {
349 parts.push(format!(
350 "{warnings} warning{} (--deny-warnings)",
351 plural(warnings)
352 ));
353 }
354 format!("✗ Blueprint has {}", parts.join(" and "))
355}
356
357fn plural(n: usize) -> &'static str {
358 if n == 1 { "" } else { "s" }
359}
360
361fn print_script_tool_report(path: &std::path::Path) {
366 let agent_dir = if path.is_file() {
368 path.parent().unwrap_or(path).to_path_buf()
369 } else {
370 path.to_path_buf()
371 };
372 let tools_dir = agent_dir.join("tools");
373 if !tools_dir.is_dir() {
374 return;
375 }
376 let (set, skipped) = leviath_scripting::ScriptToolSet::discover(&[tools_dir]);
377 if !set.is_empty() {
378 println!(" {} script tool(s) in tools/", set.len());
379 }
380 for meta in set.metas() {
383 if !crate::daemon::spawn::current_platform_satisfies(&meta.required_caps) {
384 println!(
385 " ⚠ Warning: script tool '{}' won't load here (unsatisfiable @requires: {})",
386 meta.name,
387 meta.required_caps.join(", ")
388 );
389 }
390 }
391 for s in &skipped {
392 println!(
393 " ⚠ Warning: script tool '{}' skipped: {}",
394 s.path.display(),
395 s.reason
396 );
397 }
398}
399
400pub async fn execute(args: ValidateArgs) -> anyhow::Result<()> {
402 let config = crate::config::Config::load().ok();
403 let stale = || {
407 crate::bundled::stale_install_suffix(
408 &manifest_path_for(std::path::Path::new(&args.path)),
409 crate::bundled::real_agents_dir_opt().as_deref(),
410 "\n\n",
411 )
412 };
413 match execute_reporting_outcome(&args, config.as_ref())? {
414 ValidateOutcome::Success => Ok(()),
415 ValidateOutcome::ParseError(e) => anyhow::bail!("✗ Parse error: {}{}", e, stale()),
416 ValidateOutcome::ValidationError(e) => {
417 anyhow::bail!("✗ Validation failed: {}{}", e, stale())
418 }
419 ValidateOutcome::LintFailed { errors, warnings } => {
420 anyhow::bail!(lint_failure_message(errors, warnings, args.deny_warnings))
421 }
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use crate::test_support::write_test_agent;
429
430 const CLEAN_MANIFEST: &str = r#"
438[agent]
439name = "ok-agent"
440version = "0.1.0"
441description = "Valid"
442
443[stages.main]
444mode = "autonomous"
445model = { models = [{ provider = "anthropic", model = "claude-sonnet-5" }, { provider = "ollama", model = "qwen3.5:9b" }] }
446description = "Main"
447max_iterations = 5
448
449[context.regions]
450system = { kind = "pinned", max_tokens = 1000 }
451conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
452"#;
453
454 fn write_manifest(dir: &std::path::Path, content: &str) -> std::path::PathBuf {
455 let path = dir.join("agent.leviath");
456 std::fs::write(&path, content).unwrap();
457 path
458 }
459
460 fn args_for(dir: &std::path::Path) -> ValidateArgs {
461 ValidateArgs {
462 path: dir.to_str().unwrap().to_string(),
463 deny_warnings: false,
464 json: false,
465 }
466 }
467
468 fn parse(toml: &str) -> leviath_core::Blueprint {
471 leviath_core::manifest::parse_manifest(toml).unwrap()
472 }
473
474 fn make_blueprint_toml(stages_toml: &str) -> String {
476 format!(
477 r#"
478[agent]
479name = "test"
480version = "0.1.0"
481description = "test blueprint"
482
483{stages_toml}
484
485[context.regions]
486system = {{ kind = "pinned", max_tokens = 1000 }}
487conversation = {{ kind = "sliding_window", max_items = 50, max_tokens = 10000 }}
488"#
489 )
490 }
491
492 #[test]
493 fn print_success_linear_mode_no_panic() {
494 let toml = make_blueprint_toml(
495 r#"
496[stages.main]
497mode = "autonomous"
498model = { provider = "anthropic", model = "claude-sonnet-4-6" }
499description = "Main stage"
500max_iterations = 5
501
502[stages.review]
503mode = "autonomous"
504model = { provider = "anthropic", model = "claude-sonnet-4-6" }
505description = "Review stage"
506max_iterations = 5
507"#,
508 );
509 print_success(&parse(&toml));
510 }
511
512 #[test]
513 fn print_success_graph_mode_with_terminal_and_revisits_no_panic() {
514 let toml = make_blueprint_toml(
515 r#"
516[stages.a]
517mode = "autonomous"
518model = { provider = "anthropic", model = "claude-sonnet-4-6" }
519description = "A"
520max_iterations = 5
521max_revisits = 3
522[stages.a.transitions]
523b = "true"
524
525[stages.b]
526mode = "autonomous"
527model = { provider = "anthropic", model = "claude-sonnet-4-6" }
528description = "B"
529max_iterations = 5
530"#,
531 );
532 print_success(&parse(&toml));
536 }
537
538 #[test]
539 fn print_success_graph_mode_terminal_stage_no_panic() {
540 let toml = make_blueprint_toml(
541 r#"
542[stages.a]
543mode = "autonomous"
544model = { provider = "anthropic", model = "claude-sonnet-4-6" }
545description = "A"
546max_iterations = 5
547[stages.a.transitions]
548b = "true"
549
550[stages.b]
551mode = "autonomous"
552model = { provider = "anthropic", model = "claude-sonnet-4-6" }
553description = "B"
554max_iterations = 5
555[stages.b.transitions]
556"#,
557 );
558 let bp = parse(&toml);
559 let b = bp.find_stage("b").unwrap();
562 assert!(matches!(&b.transitions, Some(t) if t.is_empty()));
563 print_success(&bp);
564 }
565
566 #[test]
571 fn print_findings_counts_errors_and_warnings_but_not_notes() {
572 let findings = [
573 (LintSeverity::Error, "e"),
574 (LintSeverity::Error, "e2"),
575 (LintSeverity::Warning, "w"),
576 (LintSeverity::Note, "n"),
577 ]
578 .map(|(severity, code)| LintFinding {
579 severity,
580 code,
581 stage: Some("main".to_string()),
582 message: "something".to_string(),
583 fix: (code == "e").then(|| "do the thing".to_string()),
585 });
586 assert_eq!(print_findings(&findings), (2, 1));
587 }
588
589 #[test]
590 fn print_findings_on_an_empty_list_reports_nothing() {
591 assert_eq!(print_findings(&[]), (0, 0));
592 }
593
594 #[test]
597 fn lint_failure_message_pluralizes_and_names_the_flag() {
598 assert_eq!(lint_failure_message(1, 0, false), "✗ Blueprint has 1 error");
599 assert_eq!(
600 lint_failure_message(2, 5, false),
601 "✗ Blueprint has 2 errors",
602 "warnings are not counted unless they were asked to be"
603 );
604 assert_eq!(
605 lint_failure_message(0, 1, true),
606 "✗ Blueprint has 1 warning (--deny-warnings)"
607 );
608 assert_eq!(
609 lint_failure_message(1, 2, true),
610 "✗ Blueprint has 1 error and 2 warnings (--deny-warnings)"
611 );
612 }
613
614 #[tokio::test]
622 async fn execute_parse_error_returns_error() {
623 crate::config::with_isolated_config_path_async("validate-parse-error", |_| async {
624 let dir = tempfile::tempdir().unwrap();
625 write_manifest(dir.path(), "not valid toml [[[");
626 let err = execute(args_for(dir.path())).await.unwrap_err();
627 assert!(err.to_string().contains("Parse error"));
628 })
629 .await;
630 }
631
632 #[tokio::test]
633 async fn execute_validation_error_returns_error() {
634 crate::config::with_isolated_config_path_async("validate-validation-error", |_| async {
635 let dir = tempfile::tempdir().unwrap();
636 let manifest = r#"
637[agent]
638name = "bad-entry-agent"
639version = "0.1.0"
640description = "Entry stage does not exist"
641entry_stage = "does-not-exist"
642
643[stages.main]
644mode = "autonomous"
645model = { provider = "anthropic", model = "claude-sonnet-4-6" }
646description = "Main"
647max_iterations = 5
648
649[context.regions]
650system = { kind = "pinned", max_tokens = 1000 }
651"#;
652 write_manifest(dir.path(), manifest);
653 let err = execute(args_for(dir.path())).await.unwrap_err();
654 assert!(err.to_string().contains("Validation failed"));
655 })
656 .await;
657 }
658
659 #[tokio::test]
661 async fn execute_lint_error_fails_the_command() {
662 crate::config::with_isolated_config_path_async("validate-lint-error", |_| async {
663 let dir = tempfile::tempdir().unwrap();
664 write_manifest(
665 dir.path(),
666 &CLEAN_MANIFEST.replace(
667 "max_iterations = 5",
668 "max_iterations = 5\navailable_tools = [\"raed_file\"]",
669 ),
670 );
671 let err = execute(args_for(dir.path())).await.unwrap_err();
672 assert_eq!(err.to_string(), "✗ Blueprint has 1 error");
673 })
674 .await;
675 }
676
677 #[tokio::test]
681 async fn warnings_only_fail_when_denied() {
682 crate::config::with_isolated_config_path_async("validate-deny-warnings", |_| async {
683 let dir = tempfile::tempdir().unwrap();
684 write_manifest(
686 dir.path(),
687 &CLEAN_MANIFEST.replace("max_iterations = 5", ""),
688 );
689
690 let mut args = args_for(dir.path());
691 assert!(execute_reporting_outcome(&args, None).unwrap().is_success());
692
693 args.deny_warnings = true;
694 let err = execute(args).await.unwrap_err();
695 assert_eq!(
696 err.to_string(),
697 "✗ Blueprint has 1 warning (--deny-warnings)"
698 );
699 })
700 .await;
701 }
702
703 #[tokio::test]
704 async fn execute_no_manifest_errors() {
705 crate::config::with_isolated_config_path_async("validate-no-manifest", |_| async {
706 let dir = tempfile::tempdir().unwrap();
707 assert!(execute(args_for(dir.path())).await.is_err());
708 })
709 .await;
710 }
711
712 #[tokio::test]
714 async fn execute_valid_manifest_file_path() {
715 crate::config::with_isolated_config_path_async("validate-file-path", |_| async {
716 let dir = tempfile::tempdir().unwrap();
717 let manifest_path = write_manifest(dir.path(), CLEAN_MANIFEST);
718 let args = ValidateArgs {
719 path: manifest_path.to_str().unwrap().to_string(),
720 deny_warnings: false,
721 json: false,
722 };
723 assert!(execute(args).await.is_ok());
724 })
725 .await;
726 }
727
728 #[tokio::test]
729 async fn execute_valid_manifest_directory_path() {
730 crate::config::with_isolated_config_path_async("validate-dir-path", |_| async {
731 let dir = tempfile::tempdir().unwrap();
732 write_test_agent(dir.path(), CLEAN_MANIFEST);
733 assert!(execute(args_for(dir.path())).await.is_ok());
734 })
735 .await;
736 }
737
738 impl ValidateOutcome {
741 fn is_success(&self) -> bool {
745 matches!(self, Self::Success)
746 }
747
748 fn is_parse_error(&self) -> bool {
749 matches!(self, Self::ParseError(_))
750 }
751
752 fn is_validation_error(&self) -> bool {
753 matches!(self, Self::ValidationError(_))
754 }
755 }
756
757 #[test]
758 fn outcome_predicates_distinguish_the_variants() {
759 assert!(ValidateOutcome::Success.is_success());
760 assert!(!ValidateOutcome::Success.is_parse_error());
761 assert!(!ValidateOutcome::Success.is_validation_error());
762 assert!(ValidateOutcome::ParseError(String::new()).is_parse_error());
763 assert!(ValidateOutcome::ValidationError(String::new()).is_validation_error());
764 assert!(
765 !ValidateOutcome::LintFailed {
766 errors: 1,
767 warnings: 0
768 }
769 .is_success()
770 );
771 }
772
773 fn json_args_for(dir: &std::path::Path) -> ValidateArgs {
776 ValidateArgs {
777 json: true,
778 ..args_for(dir)
779 }
780 }
781
782 fn finding(severity: LintSeverity, code: &'static str) -> LintFinding {
786 LintFinding {
787 severity,
788 code,
789 stage: None,
790 message: format!("{code} message"),
791 fix: None,
792 }
793 }
794
795 #[test]
796 fn json_report_of_a_clean_manifest_is_valid_and_names_its_stages() {
797 let blueprint = parse(CLEAN_MANIFEST);
798 let report = ValidateReport::linted(&blueprint, Vec::new(), false);
799 assert!(report.valid);
800 assert_eq!(report.error, None);
801 let summary = report.blueprint.expect("a parsed manifest has a summary");
802 assert_eq!(summary.name, "ok-agent");
803 assert_eq!(summary.stages, vec!["main".to_string()]);
804 assert_eq!((report.errors, report.warnings, report.notes), (0, 0, 0));
805 }
806
807 #[test]
808 fn json_report_counts_each_severity_separately() {
809 let blueprint = parse(CLEAN_MANIFEST);
810 let findings = vec![
811 finding(LintSeverity::Error, "a"),
812 finding(LintSeverity::Warning, "b"),
813 finding(LintSeverity::Note, "c"),
814 ];
815 let report = ValidateReport::linted(&blueprint, findings, false);
816 assert_eq!((report.errors, report.warnings, report.notes), (1, 1, 1));
817 assert!(!report.valid);
819 }
820
821 #[test]
822 fn json_report_is_valid_with_a_warning_until_deny_warnings() {
823 let blueprint = parse(CLEAN_MANIFEST);
824 let warning = || vec![finding(LintSeverity::Warning, "b")];
825 assert!(ValidateReport::linted(&blueprint, warning(), false).valid);
826 assert!(!ValidateReport::linted(&blueprint, warning(), true).valid);
827 }
828
829 #[test]
830 fn json_report_of_a_note_stays_valid_under_deny_warnings() {
831 let blueprint = parse(CLEAN_MANIFEST);
834 let notes = vec![finding(LintSeverity::Note, "c")];
835 assert!(ValidateReport::linted(&blueprint, notes, true).valid);
836 }
837
838 #[test]
839 fn json_report_of_a_broken_manifest_carries_the_error_and_no_blueprint() {
840 let report = ValidateReport::failed("parse error: boom".to_string());
841 assert!(!report.valid);
842 assert!(report.blueprint.is_none());
843 assert_eq!(report.error.as_deref(), Some("parse error: boom"));
844 }
845
846 #[test]
847 fn json_report_serializes_every_key_a_caller_reads() {
848 let blueprint = parse(CLEAN_MANIFEST);
849 let report = ValidateReport::linted(
850 &blueprint,
851 vec![finding(LintSeverity::Error, "unknown-tool")],
852 false,
853 );
854 let value: serde_json::Value =
855 serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
856 assert_eq!(value["valid"], serde_json::json!(false));
857 assert_eq!(value["blueprint"]["name"], serde_json::json!("ok-agent"));
858 assert_eq!(value["error"], serde_json::Value::Null);
859 assert_eq!(
862 value["findings"][0]["code"],
863 serde_json::json!("unknown-tool")
864 );
865 assert_eq!(value["findings"][0]["severity"], serde_json::json!("error"));
866 }
867
868 #[test]
869 fn json_mode_still_reports_a_parse_error_through_the_outcome() {
870 let dir = tempfile::tempdir().unwrap();
871 write_manifest(dir.path(), "not valid toml [[[");
872 assert!(
873 execute_reporting_outcome(&json_args_for(dir.path()), None)
874 .unwrap()
875 .is_parse_error()
876 );
877 }
878
879 #[test]
880 fn json_mode_still_reports_a_validation_error_through_the_outcome() {
881 let dir = tempfile::tempdir().unwrap();
884 write_manifest(
885 dir.path(),
886 r#"
887[agent]
888name = "bad-entry-agent"
889version = "0.1.0"
890description = "Entry stage does not exist"
891entry_stage = "does-not-exist"
892
893[stages.main]
894mode = "autonomous"
895model = { provider = "anthropic", model = "claude-sonnet-4-6" }
896description = "Main"
897max_iterations = 5
898
899[context.regions]
900system = { kind = "pinned", max_tokens = 1000 }
901"#,
902 );
903 assert!(
904 execute_reporting_outcome(&json_args_for(dir.path()), None)
905 .unwrap()
906 .is_validation_error()
907 );
908 }
909
910 #[test]
911 fn json_mode_still_succeeds_on_a_clean_manifest() {
912 let dir = tempfile::tempdir().unwrap();
913 write_manifest(dir.path(), CLEAN_MANIFEST);
914 assert!(
915 execute_reporting_outcome(&json_args_for(dir.path()), None)
916 .unwrap()
917 .is_success()
918 );
919 }
920
921 #[test]
922 fn execute_reporting_outcome_malformed_toml_is_parse_error() {
923 let dir = tempfile::tempdir().unwrap();
924 write_manifest(dir.path(), "not valid toml [[[");
925 assert!(
926 execute_reporting_outcome(&args_for(dir.path()), None)
927 .unwrap()
928 .is_parse_error()
929 );
930 }
931
932 #[test]
933 fn execute_reporting_outcome_bad_entry_stage_is_validation_error() {
934 let dir = tempfile::tempdir().unwrap();
935 let manifest = r#"
936[agent]
937name = "bad-entry-agent"
938version = "0.1.0"
939description = "Entry stage does not exist"
940entry_stage = "does-not-exist"
941
942[stages.main]
943mode = "autonomous"
944model = { provider = "anthropic", model = "claude-sonnet-4-6" }
945description = "Main"
946max_iterations = 5
947
948[context.regions]
949system = { kind = "pinned", max_tokens = 1000 }
950"#;
951 write_manifest(dir.path(), manifest);
952 assert!(
953 execute_reporting_outcome(&args_for(dir.path()), None)
954 .unwrap()
955 .is_validation_error()
956 );
957 }
958
959 #[test]
960 fn execute_reporting_outcome_missing_manifest_is_io_error() {
961 let dir = tempfile::tempdir().unwrap();
962 assert!(execute_reporting_outcome(&args_for(dir.path()), None).is_err());
963 }
964
965 #[test]
966 fn execute_reporting_outcome_valid_manifest_is_success() {
967 let dir = tempfile::tempdir().unwrap();
968 write_manifest(dir.path(), CLEAN_MANIFEST);
969 assert!(
970 execute_reporting_outcome(&args_for(dir.path()), None)
971 .unwrap()
972 .is_success()
973 );
974 }
975
976 #[test]
979 fn command_seed_regions_are_noted_without_failing() {
980 let dir = tempfile::tempdir().unwrap();
981 let manifest = r#"
982[agent]
983name = "scanner"
984version = "0.1.0"
985
986[stages.main]
987mode = "autonomous"
988model = { provider = "anthropic", model = "claude-sonnet-5" }
989description = "Main stage"
990max_iterations = 5
991
992[context.regions]
993facts = { kind = "pinned", max_tokens = 1000, seed = { command = "git ls-files" } }
994conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
995"#;
996 write_manifest(dir.path(), manifest);
997 let args = ValidateArgs {
999 path: dir.path().to_str().unwrap().to_string(),
1000 deny_warnings: true,
1001 json: false,
1002 };
1003 assert!(execute_reporting_outcome(&args, None).unwrap().is_success());
1004 }
1005
1006 #[test]
1007 fn execute_reporting_outcome_reports_agent_script_tools() {
1008 let dir = tempfile::tempdir().unwrap();
1012 write_manifest(dir.path(), CLEAN_MANIFEST);
1013 let tools = dir.path().join("tools");
1014 std::fs::create_dir(&tools).unwrap();
1015 std::fs::write(tools.join("ok.rhai"), "// @tool ok\nparams.x").unwrap();
1016 std::fs::write(tools.join("bad.rhai"), "no directive\nlet").unwrap();
1017 std::fs::write(tools.join("gpu.rhai"), "// @tool gpu\n// @requires gpu\n1").unwrap();
1019 assert!(
1020 execute_reporting_outcome(&args_for(dir.path()), None)
1021 .unwrap()
1022 .is_success()
1023 );
1024 }
1025
1026 #[test]
1030 fn an_agents_own_script_tool_resolves() {
1031 let dir = tempfile::tempdir().unwrap();
1032 write_manifest(
1033 dir.path(),
1034 &CLEAN_MANIFEST.replace(
1035 "max_iterations = 5",
1036 "max_iterations = 5\navailable_tools = [\"stub_search\"]",
1037 ),
1038 );
1039 let tools = dir.path().join("tools");
1040 std::fs::create_dir(&tools).unwrap();
1041 std::fs::write(
1042 tools.join("stub_search.rhai"),
1043 "// @tool stub_search\n// @description searches\n\"found\"",
1044 )
1045 .unwrap();
1046 assert!(
1047 execute_reporting_outcome(&args_for(dir.path()), None)
1048 .unwrap()
1049 .is_success()
1050 );
1051 }
1052
1053 #[test]
1054 fn print_script_tool_report_no_tools_dir_is_silent() {
1055 let dir = tempfile::tempdir().unwrap();
1059 let manifest = write_manifest(dir.path(), "unused");
1060 print_script_tool_report(&manifest);
1061 }
1062
1063 #[test]
1064 fn print_script_tool_report_only_broken_scripts_warns_without_count() {
1065 let dir = tempfile::tempdir().unwrap();
1068 let tools = dir.path().join("tools");
1069 std::fs::create_dir(&tools).unwrap();
1070 std::fs::write(tools.join("bad.rhai"), "no directive\nlet").unwrap();
1071 print_script_tool_report(dir.path());
1072 }
1073
1074 #[test]
1077 fn check_manifest_verifies_custom_region_scripts() {
1078 let dir = tempfile::tempdir().unwrap();
1081 let toml = r#"
1082[agent]
1083name = "custom-validate"
1084version = "0.1.0"
1085description = "d"
1086
1087[stages.main]
1088mode = "autonomous"
1089model = { provider = "anthropic", model = "claude-sonnet-5" }
1090description = "Main stage"
1091
1092[context.regions]
1093system = { kind = "pinned", max_tokens = 1000 }
1094conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
1095brain = { kind = "custom", script = "hooks/brain.rhai", max_tokens = 1000 }
1096"#;
1097 let manifest_path = write_manifest(dir.path(), toml);
1098
1099 let err = format!("{:?}", check_manifest(&manifest_path).unwrap_err());
1101 assert!(err.starts_with("Validation"), "{err}");
1102 assert!(err.contains("region 'brain'"), "{err}");
1103
1104 std::fs::create_dir(dir.path().join("hooks")).unwrap();
1106 std::fs::write(
1107 dir.path().join("hooks/brain.rhai"),
1108 "fn render(ctx) { \"ok\" }",
1109 )
1110 .unwrap();
1111 let checked = check_manifest(&manifest_path).unwrap();
1112 assert_eq!(checked.blueprint.name, "custom-validate");
1113 assert!(checked.content.contains("custom-validate"));
1116 assert_eq!(checked.agent_dir, dir.path());
1117 }
1118
1119 fn unwrap_io_err(err: ManifestCheckError) -> anyhow::Error {
1122 let ManifestCheckError::Io(e) = err else {
1123 panic!("expected ManifestCheckError::Io, got {err:?}");
1124 };
1125 e
1126 }
1127
1128 #[test]
1129 #[should_panic(expected = "expected ManifestCheckError::Io")]
1130 fn unwrap_io_err_panics_on_parse_variant() {
1131 let dir = tempfile::tempdir().unwrap();
1132 write_manifest(dir.path(), "not valid toml [[[");
1133 let err = check_manifest(dir.path()).unwrap_err();
1134 unwrap_io_err(err);
1136 }
1137
1138 #[test]
1139 fn check_manifest_missing_directory_manifest_is_io_error() {
1140 let dir = tempfile::tempdir().unwrap();
1141 let err = check_manifest(dir.path()).unwrap_err();
1142 let e = unwrap_io_err(err);
1143 assert!(e.to_string().contains("No agent.leviath found"));
1144 }
1145
1146 #[test]
1147 fn check_manifest_unreadable_file_path_is_io_error() {
1148 let dir = tempfile::tempdir().unwrap();
1149 let missing = dir.path().join("nonexistent-subdir");
1153 let err = check_manifest(&missing).unwrap_err();
1154 unwrap_io_err(err);
1155 }
1156
1157 #[test]
1162 fn check_manifest_unreadable_file_is_io_error() {
1163 let dir = tempfile::tempdir().unwrap();
1167 std::fs::create_dir_all(dir.path().join("agent.leviath")).unwrap();
1168
1169 let err = check_manifest(dir.path()).unwrap_err();
1170 let e = unwrap_io_err(err);
1171 assert!(e.to_string().contains("Failed to read"));
1172 }
1173
1174 impl ManifestCheckError {
1175 fn is_parse(&self) -> bool {
1180 matches!(self, Self::Parse(_))
1181 }
1182 }
1183
1184 #[test]
1185 fn check_manifest_malformed_toml_is_parse_error() {
1186 let dir = tempfile::tempdir().unwrap();
1187 write_manifest(dir.path(), "not valid toml [[[");
1188 assert!(check_manifest(dir.path()).unwrap_err().is_parse());
1189 let empty = tempfile::tempdir().unwrap();
1192 assert!(!check_manifest(empty.path()).unwrap_err().is_parse());
1193 }
1194
1195 #[test]
1196 fn check_manifest_direct_file_path_is_accepted() {
1197 let dir = tempfile::tempdir().unwrap();
1198 let manifest_path = write_manifest(dir.path(), CLEAN_MANIFEST);
1199 let checked = check_manifest(&manifest_path).unwrap();
1201 assert_eq!(checked.blueprint.name, "ok-agent");
1202 }
1203}