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 pub accepts_task: bool,
43 pub inputs: Vec<InputSummary>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
52pub struct InputSummary {
53 pub key: String,
56 pub region: String,
60 pub required: bool,
62}
63
64fn input_summaries(blueprint: &leviath_core::Blueprint) -> Vec<InputSummary> {
69 blueprint
70 .context_layout
71 .regions
72 .iter()
73 .filter_map(|r| match &r.seed {
74 Some(leviath_core::layout::RegionSeed::CallerInput { name }) => Some(InputSummary {
75 key: name.clone(),
76 region: r.name.clone(),
77 required: r.required,
78 }),
79 _ => None,
80 })
81 .collect()
82}
83
84fn input_lines(blueprint: &leviath_core::Blueprint) -> Vec<String> {
89 let inputs = input_summaries(blueprint);
90 if inputs.is_empty() {
91 return vec![
92 " Inputs: none - this agent takes no --task or other caller input".to_string(),
93 ];
94 }
95 let flags: Vec<String> = inputs
96 .iter()
97 .map(|i| {
98 let mut flag = format!("--{}", i.key);
99 let mut notes = Vec::new();
100 if i.required {
101 notes.push("required".to_string());
102 }
103 if i.key != i.region {
104 notes.push(format!("seeds region '{}'", i.region));
105 }
106 if !notes.is_empty() {
107 flag.push_str(&format!(" ({})", notes.join(", ")));
108 }
109 flag
110 })
111 .collect();
112 let mut lines = vec![format!(" Inputs: {}", flags.join(", "))];
113 if !blueprint.accepts_task() {
114 lines.push(format!(
115 " Note: this agent takes no --task; give it input via {}",
116 inputs
117 .iter()
118 .map(|i| format!("--{}", i.key))
119 .collect::<Vec<_>>()
120 .join(", ")
121 ));
122 }
123 lines
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
134pub struct ValidateReport {
135 pub valid: bool,
137 pub blueprint: Option<BlueprintSummary>,
139 pub error: Option<String>,
141 pub findings: Vec<LintFinding>,
143 pub errors: usize,
145 pub warnings: usize,
147 pub notes: usize,
149}
150
151impl ValidateReport {
152 fn linted(
154 blueprint: &leviath_core::Blueprint,
155 findings: Vec<LintFinding>,
156 deny_warnings: bool,
157 ) -> Self {
158 let count = |want: LintSeverity| findings.iter().filter(|f| f.severity == want).count();
159 let (errors, warnings) = (count(LintSeverity::Error), count(LintSeverity::Warning));
160 Self {
161 valid: errors == 0 && !(deny_warnings && warnings > 0),
163 blueprint: Some(BlueprintSummary {
164 name: blueprint.name.clone(),
165 version: blueprint.version.clone(),
166 description: blueprint.description.clone(),
167 entry_stage: blueprint.entry_stage.clone(),
168 stages: blueprint.stages.iter().map(|s| s.name.clone()).collect(),
169 accepts_task: blueprint.accepts_task(),
170 inputs: input_summaries(blueprint),
171 }),
172 error: None,
173 errors,
174 warnings,
175 notes: count(LintSeverity::Note),
176 findings,
177 }
178 }
179
180 fn failed(error: String) -> Self {
182 Self {
183 valid: false,
184 blueprint: None,
185 error: Some(error),
186 findings: Vec::new(),
187 errors: 1,
188 warnings: 0,
189 notes: 0,
190 }
191 }
192
193 fn print(&self) {
194 println!(
197 "{}",
198 serde_json::to_string_pretty(self).expect("a validate report serializes")
199 );
200 }
201}
202
203#[derive(Debug)]
208enum ManifestCheckError {
209 Io(anyhow::Error),
210 Parse(String),
211 Validation(String),
212}
213
214#[derive(Debug)]
217struct CheckedManifest {
218 blueprint: leviath_core::Blueprint,
219 content: String,
220 agent_dir: PathBuf,
222}
223
224fn manifest_path_for(path: &std::path::Path) -> std::path::PathBuf {
230 if path.is_file() {
231 path.to_path_buf()
232 } else {
233 path.join("agent.leviath")
234 }
235}
236
237fn check_manifest(path: &std::path::Path) -> Result<CheckedManifest, ManifestCheckError> {
238 let manifest_path = manifest_path_for(path);
239 if !manifest_path.exists() {
240 return Err(ManifestCheckError::Io(anyhow::anyhow!(
241 "No agent.leviath found at {}",
242 path.display()
243 )));
244 }
245
246 let content = std::fs::read_to_string(&manifest_path).map_err(|e| {
247 ManifestCheckError::Io(anyhow::anyhow!(
248 "Failed to read {}: {}",
249 manifest_path.display(),
250 e
251 ))
252 })?;
253
254 let blueprint = leviath_core::manifest::parse_manifest(&content)
255 .map_err(|e| ManifestCheckError::Parse(e.to_string()))?;
256
257 blueprint
258 .validate()
259 .map_err(|e| ManifestCheckError::Validation(e.to_string()))?;
260
261 crate::daemon::spawn::resolve_region_scripts(&blueprint, &manifest_path.to_string_lossy())
266 .map_err(ManifestCheckError::Validation)?;
267
268 let agent_dir = manifest_path
269 .parent()
270 .map(std::path::Path::to_path_buf)
271 .unwrap_or_default();
272 Ok(CheckedManifest {
273 blueprint,
274 content,
275 agent_dir,
276 })
277}
278
279fn print_success(blueprint: &leviath_core::Blueprint) {
281 println!("✓ Blueprint '{}' is valid.", blueprint.name);
282 println!(
283 " {} stages, version {}",
284 blueprint.stages.len(),
285 blueprint.version
286 );
287 for line in input_lines(blueprint) {
288 println!("{line}");
289 }
290
291 let is_graph = blueprint.stages.iter().any(|s| s.transitions.is_some());
293 if is_graph {
294 let entry = blueprint.resolve_entry_stage_name();
295 println!(" Graph mode: entry stage '{}'", entry);
296
297 for stage in &blueprint.stages {
299 let transitions_info = match &stage.transitions {
300 Some(t) if !t.is_empty() => {
301 let targets: Vec<&str> = t.keys().map(|k| k.as_str()).collect();
302 format!(" → {}", targets.join(", "))
303 }
304 Some(_) => " (terminal)".to_string(),
305 None => " (linear)".to_string(),
306 };
307 let revisits = stage
308 .max_revisits
309 .map(|n| format!(" (max_revisits: {})", n))
310 .unwrap_or_default();
311 println!(" - {}{}{}", stage.name, transitions_info, revisits);
312 }
313 } else {
314 println!(
315 " Linear mode: {}",
316 blueprint
317 .stages
318 .iter()
319 .map(|s| s.name.as_str())
320 .collect::<Vec<_>>()
321 .join(" → ")
322 );
323 }
324}
325
326#[derive(Debug)]
330enum ValidateOutcome {
331 Success,
332 ParseError(String),
333 ValidationError(String),
334 LintFailed {
338 errors: usize,
339 warnings: usize,
340 },
341}
342
343fn print_findings(findings: &[LintFinding]) -> (usize, usize) {
348 let mut errors = 0;
349 let mut warnings = 0;
350 for finding in findings {
351 match finding.severity {
352 LintSeverity::Error => errors += 1,
353 LintSeverity::Warning => warnings += 1,
354 LintSeverity::Note => {}
355 }
356 println!(
357 " {} {} [{}]",
358 finding.severity.label(),
359 finding.one_line(),
360 finding.code
361 );
362 if let Some(fix) = &finding.fix {
363 println!(" {fix}");
364 }
365 }
366 (errors, warnings)
367}
368
369fn execute_reporting_outcome(
376 args: &ValidateArgs,
377 config: Option<&crate::config::Config>,
378) -> anyhow::Result<ValidateOutcome> {
379 let path = PathBuf::from(&args.path);
380
381 let checked = match check_manifest(&path) {
382 Ok(c) => c,
383 Err(ManifestCheckError::Io(e)) => return Err(e),
384 Err(ManifestCheckError::Parse(e)) => {
385 if args.json {
386 ValidateReport::failed(format!("parse error: {e}")).print();
387 }
388 return Ok(ValidateOutcome::ParseError(e));
389 }
390 Err(ManifestCheckError::Validation(e)) => {
391 if args.json {
392 ValidateReport::failed(format!("validation failed: {e}")).print();
393 }
394 return Ok(ValidateOutcome::ValidationError(e));
395 }
396 };
397
398 if !args.json {
401 print_success(&checked.blueprint);
402 print_script_tool_report(&path);
403 }
404
405 let mut env = LintEnv::offline(&checked.agent_dir);
406 if let Some(config) = config {
407 let workdir = crate::commands::resolve_cwd().unwrap_or_default();
411 env = env
412 .with_providers(&checked.blueprint, config)
413 .with_read_paths(&checked.blueprint, config, &workdir);
414 }
415 let findings = lint_manifest(&checked.content, &checked.blueprint, &env);
416 let (errors, warnings) = match args.json {
417 true => {
418 let report = ValidateReport::linted(&checked.blueprint, findings, args.deny_warnings);
419 report.print();
420 (report.errors, report.warnings)
421 }
422 false => print_findings(&findings),
423 };
424
425 if errors > 0 || (args.deny_warnings && warnings > 0) {
426 return Ok(ValidateOutcome::LintFailed { errors, warnings });
427 }
428 Ok(ValidateOutcome::Success)
429}
430
431fn lint_failure_message(errors: usize, warnings: usize, deny_warnings: bool) -> String {
434 let mut parts = Vec::new();
435 if errors > 0 {
436 parts.push(format!("{errors} error{}", plural(errors)));
437 }
438 if deny_warnings && warnings > 0 {
439 parts.push(format!(
440 "{warnings} warning{} (--deny-warnings)",
441 plural(warnings)
442 ));
443 }
444 format!("✗ Blueprint has {}", parts.join(" and "))
445}
446
447fn plural(n: usize) -> &'static str {
448 if n == 1 { "" } else { "s" }
449}
450
451fn print_script_tool_report(path: &std::path::Path) {
456 let agent_dir = if path.is_file() {
458 path.parent().unwrap_or(path).to_path_buf()
459 } else {
460 path.to_path_buf()
461 };
462 let tools_dir = agent_dir.join("tools");
463 if !tools_dir.is_dir() {
464 return;
465 }
466 let (set, skipped) = leviath_scripting::ScriptToolSet::discover(&[tools_dir]);
467 if !set.is_empty() {
468 println!(" {} script tool(s) in tools/", set.len());
469 }
470 for meta in set.metas() {
473 if !crate::daemon::spawn::current_platform_satisfies(&meta.required_caps) {
474 println!(
475 " ⚠ Warning: script tool '{}' won't load here (unsatisfiable @requires: {})",
476 meta.name,
477 meta.required_caps.join(", ")
478 );
479 }
480 }
481 for s in &skipped {
482 println!(
483 " ⚠ Warning: script tool '{}' skipped: {}",
484 s.path.display(),
485 s.reason
486 );
487 }
488}
489
490pub async fn execute(args: ValidateArgs) -> anyhow::Result<()> {
492 let config = crate::config::Config::load().ok();
493 let stale = || {
497 crate::bundled::stale_install_suffix(
498 &manifest_path_for(std::path::Path::new(&args.path)),
499 crate::bundled::real_agents_dir_opt().as_deref(),
500 "\n\n",
501 )
502 };
503 match execute_reporting_outcome(&args, config.as_ref())? {
504 ValidateOutcome::Success => Ok(()),
505 ValidateOutcome::ParseError(e) => anyhow::bail!("✗ Parse error: {}{}", e, stale()),
506 ValidateOutcome::ValidationError(e) => {
507 anyhow::bail!("✗ Validation failed: {}{}", e, stale())
508 }
509 ValidateOutcome::LintFailed { errors, warnings } => {
510 anyhow::bail!(lint_failure_message(errors, warnings, args.deny_warnings))
511 }
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518 use crate::test_support::write_test_agent;
519
520 const CLEAN_MANIFEST: &str = r#"
528[agent]
529name = "ok-agent"
530version = "0.1.0"
531description = "Valid"
532
533[stages.main]
534mode = "autonomous"
535model = { models = [{ provider = "anthropic", model = "claude-sonnet-5" }, { provider = "ollama", model = "qwen3.5:9b" }] }
536description = "Main"
537max_iterations = 5
538
539[context.regions]
540system = { kind = "pinned", max_tokens = 1000 }
541conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
542"#;
543
544 fn write_manifest(dir: &std::path::Path, content: &str) -> std::path::PathBuf {
545 let path = dir.join("agent.leviath");
546 std::fs::write(&path, content).unwrap();
547 path
548 }
549
550 fn args_for(dir: &std::path::Path) -> ValidateArgs {
551 ValidateArgs {
552 path: dir.to_str().unwrap().to_string(),
553 deny_warnings: false,
554 json: false,
555 }
556 }
557
558 fn parse(toml: &str) -> leviath_core::Blueprint {
561 leviath_core::manifest::parse_manifest(toml).unwrap()
562 }
563
564 fn make_blueprint_toml(stages_toml: &str) -> String {
566 format!(
567 r#"
568[agent]
569name = "test"
570version = "0.1.0"
571description = "test blueprint"
572
573{stages_toml}
574
575[context.regions]
576system = {{ kind = "pinned", max_tokens = 1000 }}
577conversation = {{ kind = "sliding_window", max_items = 50, max_tokens = 10000 }}
578"#
579 )
580 }
581
582 #[test]
583 fn print_success_linear_mode_no_panic() {
584 let toml = make_blueprint_toml(
585 r#"
586[stages.main]
587mode = "autonomous"
588model = { provider = "anthropic", model = "claude-sonnet-4-6" }
589description = "Main stage"
590max_iterations = 5
591
592[stages.review]
593mode = "autonomous"
594model = { provider = "anthropic", model = "claude-sonnet-4-6" }
595description = "Review stage"
596max_iterations = 5
597"#,
598 );
599 print_success(&parse(&toml));
600 }
601
602 #[test]
603 fn print_success_graph_mode_with_terminal_and_revisits_no_panic() {
604 let toml = make_blueprint_toml(
605 r#"
606[stages.a]
607mode = "autonomous"
608model = { provider = "anthropic", model = "claude-sonnet-4-6" }
609description = "A"
610max_iterations = 5
611max_revisits = 3
612[stages.a.transitions]
613b = "true"
614
615[stages.b]
616mode = "autonomous"
617model = { provider = "anthropic", model = "claude-sonnet-4-6" }
618description = "B"
619max_iterations = 5
620"#,
621 );
622 print_success(&parse(&toml));
626 }
627
628 #[test]
629 fn print_success_graph_mode_terminal_stage_no_panic() {
630 let toml = make_blueprint_toml(
631 r#"
632[stages.a]
633mode = "autonomous"
634model = { provider = "anthropic", model = "claude-sonnet-4-6" }
635description = "A"
636max_iterations = 5
637[stages.a.transitions]
638b = "true"
639
640[stages.b]
641mode = "autonomous"
642model = { provider = "anthropic", model = "claude-sonnet-4-6" }
643description = "B"
644max_iterations = 5
645[stages.b.transitions]
646"#,
647 );
648 let bp = parse(&toml);
649 let b = bp.find_stage("b").unwrap();
652 assert!(matches!(&b.transitions, Some(t) if t.is_empty()));
653 print_success(&bp);
654 }
655
656 const NAMED_INPUTS_MANIFEST: &str = r#"
663[agent]
664name = "inputs-agent"
665version = "0.1.0"
666description = "Named inputs"
667
668[stages.main]
669mode = "autonomous"
670model = { provider = "anthropic", model = "claude-sonnet-5" }
671description = "Main"
672max_iterations = 5
673
674[context.regions]
675system = { kind = "pinned", max_tokens = 1000 }
676patch = { kind = "pinned", max_tokens = 2000, required = true, seed = "diff" }
677review_criteria = { kind = "pinned", max_tokens = 1000, seed = "criteria" }
678focus = { kind = "pinned", max_tokens = 500, seed = "input" }
679conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
680"#;
681
682 #[test]
685 fn input_lines_name_every_flag_and_the_missing_task() {
686 let lines = input_lines(&parse(NAMED_INPUTS_MANIFEST));
687 assert_eq!(
688 lines,
689 vec![
690 " Inputs: --diff (required, seeds region 'patch'), \
691 --criteria (seeds region 'review_criteria'), --focus"
692 .to_string(),
693 " Note: this agent takes no --task; give it input via --diff, \
694 --criteria, --focus"
695 .to_string(),
696 ]
697 );
698 }
699
700 #[test]
701 fn input_lines_of_a_task_taking_agent_skip_the_refusal_note() {
702 let toml = CLEAN_MANIFEST.replace(
703 "[context.regions]",
704 "[context.regions]\ntask = { kind = \"pinned\", max_tokens = 2000, \
705 required = true, seed = \"task\" }",
706 );
707 let blueprint = parse(&toml);
708 assert!(blueprint.accepts_task());
709 assert_eq!(
710 input_lines(&blueprint),
711 vec![" Inputs: --task (required)".to_string()],
712 "an agent that takes a task needs no note about refusing one"
713 );
714 }
715
716 #[test]
717 fn input_lines_without_any_caller_input_say_so() {
718 assert_eq!(
719 input_lines(&parse(CLEAN_MANIFEST)),
720 vec![" Inputs: none - this agent takes no --task or other caller input".to_string()]
721 );
722 }
723
724 #[test]
728 fn input_summaries_carry_key_region_and_required() {
729 let summaries = input_summaries(&parse(NAMED_INPUTS_MANIFEST));
730 assert_eq!(
731 summaries,
732 vec![
733 InputSummary {
734 key: "diff".to_string(),
735 region: "patch".to_string(),
736 required: true,
737 },
738 InputSummary {
739 key: "criteria".to_string(),
740 region: "review_criteria".to_string(),
741 required: false,
742 },
743 InputSummary {
744 key: "focus".to_string(),
745 region: "focus".to_string(),
746 required: false,
747 },
748 ]
749 );
750 }
751
752 #[test]
753 fn print_success_prints_the_input_lines_without_panicking() {
754 print_success(&parse(NAMED_INPUTS_MANIFEST));
757 }
758
759 #[test]
764 fn print_findings_counts_errors_and_warnings_but_not_notes() {
765 let findings = [
766 (LintSeverity::Error, "e"),
767 (LintSeverity::Error, "e2"),
768 (LintSeverity::Warning, "w"),
769 (LintSeverity::Note, "n"),
770 ]
771 .map(|(severity, code)| LintFinding {
772 severity,
773 code,
774 stage: Some("main".to_string()),
775 message: "something".to_string(),
776 fix: (code == "e").then(|| "do the thing".to_string()),
778 });
779 assert_eq!(print_findings(&findings), (2, 1));
780 }
781
782 #[test]
783 fn print_findings_on_an_empty_list_reports_nothing() {
784 assert_eq!(print_findings(&[]), (0, 0));
785 }
786
787 #[test]
790 fn lint_failure_message_pluralizes_and_names_the_flag() {
791 assert_eq!(lint_failure_message(1, 0, false), "✗ Blueprint has 1 error");
792 assert_eq!(
793 lint_failure_message(2, 5, false),
794 "✗ Blueprint has 2 errors",
795 "warnings are not counted unless they were asked to be"
796 );
797 assert_eq!(
798 lint_failure_message(0, 1, true),
799 "✗ Blueprint has 1 warning (--deny-warnings)"
800 );
801 assert_eq!(
802 lint_failure_message(1, 2, true),
803 "✗ Blueprint has 1 error and 2 warnings (--deny-warnings)"
804 );
805 }
806
807 #[tokio::test]
815 async fn execute_parse_error_returns_error() {
816 crate::config::with_isolated_config_path_async("validate-parse-error", |_| async {
817 let dir = tempfile::tempdir().unwrap();
818 write_manifest(dir.path(), "not valid toml [[[");
819 let err = execute(args_for(dir.path())).await.unwrap_err();
820 assert!(err.to_string().contains("Parse error"));
821 })
822 .await;
823 }
824
825 #[tokio::test]
826 async fn execute_validation_error_returns_error() {
827 crate::config::with_isolated_config_path_async("validate-validation-error", |_| async {
828 let dir = tempfile::tempdir().unwrap();
829 let manifest = r#"
830[agent]
831name = "bad-entry-agent"
832version = "0.1.0"
833description = "Entry stage does not exist"
834entry_stage = "does-not-exist"
835
836[stages.main]
837mode = "autonomous"
838model = { provider = "anthropic", model = "claude-sonnet-4-6" }
839description = "Main"
840max_iterations = 5
841
842[context.regions]
843system = { kind = "pinned", max_tokens = 1000 }
844"#;
845 write_manifest(dir.path(), manifest);
846 let err = execute(args_for(dir.path())).await.unwrap_err();
847 assert!(err.to_string().contains("Validation failed"));
848 })
849 .await;
850 }
851
852 #[tokio::test]
854 async fn execute_lint_error_fails_the_command() {
855 crate::config::with_isolated_config_path_async("validate-lint-error", |_| async {
856 let dir = tempfile::tempdir().unwrap();
857 write_manifest(
858 dir.path(),
859 &CLEAN_MANIFEST.replace(
860 "max_iterations = 5",
861 "max_iterations = 5\navailable_tools = [\"raed_file\"]",
862 ),
863 );
864 let err = execute(args_for(dir.path())).await.unwrap_err();
865 assert_eq!(err.to_string(), "✗ Blueprint has 1 error");
866 })
867 .await;
868 }
869
870 #[tokio::test]
874 async fn warnings_only_fail_when_denied() {
875 crate::config::with_isolated_config_path_async("validate-deny-warnings", |_| async {
876 let dir = tempfile::tempdir().unwrap();
877 write_manifest(
879 dir.path(),
880 &CLEAN_MANIFEST.replace("max_iterations = 5", ""),
881 );
882
883 let mut args = args_for(dir.path());
884 assert!(execute_reporting_outcome(&args, None).unwrap().is_success());
885
886 args.deny_warnings = true;
887 let err = execute(args).await.unwrap_err();
888 assert_eq!(
889 err.to_string(),
890 "✗ Blueprint has 1 warning (--deny-warnings)"
891 );
892 })
893 .await;
894 }
895
896 #[tokio::test]
897 async fn execute_no_manifest_errors() {
898 crate::config::with_isolated_config_path_async("validate-no-manifest", |_| async {
899 let dir = tempfile::tempdir().unwrap();
900 assert!(execute(args_for(dir.path())).await.is_err());
901 })
902 .await;
903 }
904
905 #[tokio::test]
907 async fn execute_valid_manifest_file_path() {
908 crate::config::with_isolated_config_path_async("validate-file-path", |_| async {
909 let dir = tempfile::tempdir().unwrap();
910 let manifest_path = write_manifest(dir.path(), CLEAN_MANIFEST);
911 let args = ValidateArgs {
912 path: manifest_path.to_str().unwrap().to_string(),
913 deny_warnings: false,
914 json: false,
915 };
916 assert!(execute(args).await.is_ok());
917 })
918 .await;
919 }
920
921 #[tokio::test]
922 async fn execute_valid_manifest_directory_path() {
923 crate::config::with_isolated_config_path_async("validate-dir-path", |_| async {
924 let dir = tempfile::tempdir().unwrap();
925 write_test_agent(dir.path(), CLEAN_MANIFEST);
926 assert!(execute(args_for(dir.path())).await.is_ok());
927 })
928 .await;
929 }
930
931 impl ValidateOutcome {
934 fn is_success(&self) -> bool {
938 matches!(self, Self::Success)
939 }
940
941 fn is_parse_error(&self) -> bool {
942 matches!(self, Self::ParseError(_))
943 }
944
945 fn is_validation_error(&self) -> bool {
946 matches!(self, Self::ValidationError(_))
947 }
948 }
949
950 #[test]
951 fn outcome_predicates_distinguish_the_variants() {
952 assert!(ValidateOutcome::Success.is_success());
953 assert!(!ValidateOutcome::Success.is_parse_error());
954 assert!(!ValidateOutcome::Success.is_validation_error());
955 assert!(ValidateOutcome::ParseError(String::new()).is_parse_error());
956 assert!(ValidateOutcome::ValidationError(String::new()).is_validation_error());
957 assert!(
958 !ValidateOutcome::LintFailed {
959 errors: 1,
960 warnings: 0
961 }
962 .is_success()
963 );
964 }
965
966 fn json_args_for(dir: &std::path::Path) -> ValidateArgs {
969 ValidateArgs {
970 json: true,
971 ..args_for(dir)
972 }
973 }
974
975 fn finding(severity: LintSeverity, code: &'static str) -> LintFinding {
979 LintFinding {
980 severity,
981 code,
982 stage: None,
983 message: format!("{code} message"),
984 fix: None,
985 }
986 }
987
988 #[test]
989 fn json_report_of_a_clean_manifest_is_valid_and_names_its_stages() {
990 let blueprint = parse(CLEAN_MANIFEST);
991 let report = ValidateReport::linted(&blueprint, Vec::new(), false);
992 assert!(report.valid);
993 assert_eq!(report.error, None);
994 let summary = report.blueprint.expect("a parsed manifest has a summary");
995 assert_eq!(summary.name, "ok-agent");
996 assert_eq!(summary.stages, vec!["main".to_string()]);
997 assert!(!summary.accepts_task);
998 assert_eq!(summary.inputs, Vec::new());
999 assert_eq!((report.errors, report.warnings, report.notes), (0, 0, 0));
1000 }
1001
1002 #[test]
1003 fn json_report_counts_each_severity_separately() {
1004 let blueprint = parse(CLEAN_MANIFEST);
1005 let findings = vec![
1006 finding(LintSeverity::Error, "a"),
1007 finding(LintSeverity::Warning, "b"),
1008 finding(LintSeverity::Note, "c"),
1009 ];
1010 let report = ValidateReport::linted(&blueprint, findings, false);
1011 assert_eq!((report.errors, report.warnings, report.notes), (1, 1, 1));
1012 assert!(!report.valid);
1014 }
1015
1016 #[test]
1017 fn json_report_is_valid_with_a_warning_until_deny_warnings() {
1018 let blueprint = parse(CLEAN_MANIFEST);
1019 let warning = || vec![finding(LintSeverity::Warning, "b")];
1020 assert!(ValidateReport::linted(&blueprint, warning(), false).valid);
1021 assert!(!ValidateReport::linted(&blueprint, warning(), true).valid);
1022 }
1023
1024 #[test]
1025 fn json_report_of_a_note_stays_valid_under_deny_warnings() {
1026 let blueprint = parse(CLEAN_MANIFEST);
1029 let notes = vec![finding(LintSeverity::Note, "c")];
1030 assert!(ValidateReport::linted(&blueprint, notes, true).valid);
1031 }
1032
1033 #[test]
1034 fn json_report_of_a_broken_manifest_carries_the_error_and_no_blueprint() {
1035 let report = ValidateReport::failed("parse error: boom".to_string());
1036 assert!(!report.valid);
1037 assert!(report.blueprint.is_none());
1038 assert_eq!(report.error.as_deref(), Some("parse error: boom"));
1039 }
1040
1041 #[test]
1042 fn json_report_serializes_every_key_a_caller_reads() {
1043 let blueprint = parse(CLEAN_MANIFEST);
1044 let report = ValidateReport::linted(
1045 &blueprint,
1046 vec![finding(LintSeverity::Error, "unknown-tool")],
1047 false,
1048 );
1049 let value: serde_json::Value =
1050 serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
1051 assert_eq!(value["valid"], serde_json::json!(false));
1052 assert_eq!(value["blueprint"]["name"], serde_json::json!("ok-agent"));
1053 assert_eq!(value["error"], serde_json::Value::Null);
1054 assert_eq!(
1057 value["findings"][0]["code"],
1058 serde_json::json!("unknown-tool")
1059 );
1060 assert_eq!(value["findings"][0]["severity"], serde_json::json!("error"));
1061 }
1062
1063 #[test]
1066 fn json_report_names_the_accepted_inputs() {
1067 let blueprint = parse(NAMED_INPUTS_MANIFEST);
1068 let report = ValidateReport::linted(&blueprint, Vec::new(), false);
1069 let value: serde_json::Value =
1070 serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
1071 assert_eq!(value["blueprint"]["accepts_task"], serde_json::json!(false));
1072 assert_eq!(
1073 value["blueprint"]["inputs"][0],
1074 serde_json::json!({"key": "diff", "region": "patch", "required": true})
1075 );
1076 assert_eq!(
1077 value["blueprint"]["inputs"][1]["key"],
1078 serde_json::json!("criteria")
1079 );
1080 }
1081
1082 #[test]
1083 fn json_mode_still_reports_a_parse_error_through_the_outcome() {
1084 let dir = tempfile::tempdir().unwrap();
1085 write_manifest(dir.path(), "not valid toml [[[");
1086 assert!(
1087 execute_reporting_outcome(&json_args_for(dir.path()), None)
1088 .unwrap()
1089 .is_parse_error()
1090 );
1091 }
1092
1093 #[test]
1094 fn json_mode_still_reports_a_validation_error_through_the_outcome() {
1095 let dir = tempfile::tempdir().unwrap();
1098 write_manifest(
1099 dir.path(),
1100 r#"
1101[agent]
1102name = "bad-entry-agent"
1103version = "0.1.0"
1104description = "Entry stage does not exist"
1105entry_stage = "does-not-exist"
1106
1107[stages.main]
1108mode = "autonomous"
1109model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1110description = "Main"
1111max_iterations = 5
1112
1113[context.regions]
1114system = { kind = "pinned", max_tokens = 1000 }
1115"#,
1116 );
1117 assert!(
1118 execute_reporting_outcome(&json_args_for(dir.path()), None)
1119 .unwrap()
1120 .is_validation_error()
1121 );
1122 }
1123
1124 #[test]
1125 fn json_mode_still_succeeds_on_a_clean_manifest() {
1126 let dir = tempfile::tempdir().unwrap();
1127 write_manifest(dir.path(), CLEAN_MANIFEST);
1128 assert!(
1129 execute_reporting_outcome(&json_args_for(dir.path()), None)
1130 .unwrap()
1131 .is_success()
1132 );
1133 }
1134
1135 #[test]
1136 fn execute_reporting_outcome_malformed_toml_is_parse_error() {
1137 let dir = tempfile::tempdir().unwrap();
1138 write_manifest(dir.path(), "not valid toml [[[");
1139 assert!(
1140 execute_reporting_outcome(&args_for(dir.path()), None)
1141 .unwrap()
1142 .is_parse_error()
1143 );
1144 }
1145
1146 #[test]
1147 fn execute_reporting_outcome_bad_entry_stage_is_validation_error() {
1148 let dir = tempfile::tempdir().unwrap();
1149 let manifest = r#"
1150[agent]
1151name = "bad-entry-agent"
1152version = "0.1.0"
1153description = "Entry stage does not exist"
1154entry_stage = "does-not-exist"
1155
1156[stages.main]
1157mode = "autonomous"
1158model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1159description = "Main"
1160max_iterations = 5
1161
1162[context.regions]
1163system = { kind = "pinned", max_tokens = 1000 }
1164"#;
1165 write_manifest(dir.path(), manifest);
1166 assert!(
1167 execute_reporting_outcome(&args_for(dir.path()), None)
1168 .unwrap()
1169 .is_validation_error()
1170 );
1171 }
1172
1173 #[test]
1174 fn execute_reporting_outcome_missing_manifest_is_io_error() {
1175 let dir = tempfile::tempdir().unwrap();
1176 assert!(execute_reporting_outcome(&args_for(dir.path()), None).is_err());
1177 }
1178
1179 #[test]
1180 fn execute_reporting_outcome_valid_manifest_is_success() {
1181 let dir = tempfile::tempdir().unwrap();
1182 write_manifest(dir.path(), CLEAN_MANIFEST);
1183 assert!(
1184 execute_reporting_outcome(&args_for(dir.path()), None)
1185 .unwrap()
1186 .is_success()
1187 );
1188 }
1189
1190 #[test]
1193 fn command_seed_regions_are_noted_without_failing() {
1194 let dir = tempfile::tempdir().unwrap();
1195 let manifest = r#"
1196[agent]
1197name = "scanner"
1198version = "0.1.0"
1199
1200[stages.main]
1201mode = "autonomous"
1202model = { provider = "anthropic", model = "claude-sonnet-5" }
1203description = "Main stage"
1204max_iterations = 5
1205
1206[context.regions]
1207facts = { kind = "pinned", max_tokens = 1000, seed = { command = "git ls-files" } }
1208conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
1209"#;
1210 write_manifest(dir.path(), manifest);
1211 let args = ValidateArgs {
1213 path: dir.path().to_str().unwrap().to_string(),
1214 deny_warnings: true,
1215 json: false,
1216 };
1217 assert!(execute_reporting_outcome(&args, None).unwrap().is_success());
1218 }
1219
1220 #[test]
1221 fn execute_reporting_outcome_reports_agent_script_tools() {
1222 let dir = tempfile::tempdir().unwrap();
1226 write_manifest(dir.path(), CLEAN_MANIFEST);
1227 let tools = dir.path().join("tools");
1228 std::fs::create_dir(&tools).unwrap();
1229 std::fs::write(tools.join("ok.rhai"), "// @tool ok\nparams.x").unwrap();
1230 std::fs::write(tools.join("bad.rhai"), "no directive\nlet").unwrap();
1231 std::fs::write(tools.join("gpu.rhai"), "// @tool gpu\n// @requires gpu\n1").unwrap();
1233 assert!(
1234 execute_reporting_outcome(&args_for(dir.path()), None)
1235 .unwrap()
1236 .is_success()
1237 );
1238 }
1239
1240 #[test]
1244 fn an_agents_own_script_tool_resolves() {
1245 let dir = tempfile::tempdir().unwrap();
1246 write_manifest(
1247 dir.path(),
1248 &CLEAN_MANIFEST.replace(
1249 "max_iterations = 5",
1250 "max_iterations = 5\navailable_tools = [\"stub_search\"]",
1251 ),
1252 );
1253 let tools = dir.path().join("tools");
1254 std::fs::create_dir(&tools).unwrap();
1255 std::fs::write(
1256 tools.join("stub_search.rhai"),
1257 "// @tool stub_search\n// @description searches\n\"found\"",
1258 )
1259 .unwrap();
1260 assert!(
1261 execute_reporting_outcome(&args_for(dir.path()), None)
1262 .unwrap()
1263 .is_success()
1264 );
1265 }
1266
1267 #[test]
1268 fn print_script_tool_report_no_tools_dir_is_silent() {
1269 let dir = tempfile::tempdir().unwrap();
1273 let manifest = write_manifest(dir.path(), "unused");
1274 print_script_tool_report(&manifest);
1275 }
1276
1277 #[test]
1278 fn print_script_tool_report_only_broken_scripts_warns_without_count() {
1279 let dir = tempfile::tempdir().unwrap();
1282 let tools = dir.path().join("tools");
1283 std::fs::create_dir(&tools).unwrap();
1284 std::fs::write(tools.join("bad.rhai"), "no directive\nlet").unwrap();
1285 print_script_tool_report(dir.path());
1286 }
1287
1288 #[test]
1291 fn check_manifest_verifies_custom_region_scripts() {
1292 let dir = tempfile::tempdir().unwrap();
1295 let toml = r#"
1296[agent]
1297name = "custom-validate"
1298version = "0.1.0"
1299description = "d"
1300
1301[stages.main]
1302mode = "autonomous"
1303model = { provider = "anthropic", model = "claude-sonnet-5" }
1304description = "Main stage"
1305
1306[context.regions]
1307system = { kind = "pinned", max_tokens = 1000 }
1308conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
1309brain = { kind = "custom", script = "hooks/brain.rhai", max_tokens = 1000 }
1310"#;
1311 let manifest_path = write_manifest(dir.path(), toml);
1312
1313 let err = format!("{:?}", check_manifest(&manifest_path).unwrap_err());
1315 assert!(err.starts_with("Validation"), "{err}");
1316 assert!(err.contains("region 'brain'"), "{err}");
1317
1318 std::fs::create_dir(dir.path().join("hooks")).unwrap();
1320 std::fs::write(
1321 dir.path().join("hooks/brain.rhai"),
1322 "fn render(ctx) { \"ok\" }",
1323 )
1324 .unwrap();
1325 let checked = check_manifest(&manifest_path).unwrap();
1326 assert_eq!(checked.blueprint.name, "custom-validate");
1327 assert!(checked.content.contains("custom-validate"));
1330 assert_eq!(checked.agent_dir, dir.path());
1331 }
1332
1333 fn unwrap_io_err(err: ManifestCheckError) -> anyhow::Error {
1336 let ManifestCheckError::Io(e) = err else {
1337 panic!("expected ManifestCheckError::Io, got {err:?}");
1338 };
1339 e
1340 }
1341
1342 #[test]
1343 #[should_panic(expected = "expected ManifestCheckError::Io")]
1344 fn unwrap_io_err_panics_on_parse_variant() {
1345 let dir = tempfile::tempdir().unwrap();
1346 write_manifest(dir.path(), "not valid toml [[[");
1347 let err = check_manifest(dir.path()).unwrap_err();
1348 unwrap_io_err(err);
1350 }
1351
1352 #[test]
1353 fn check_manifest_missing_directory_manifest_is_io_error() {
1354 let dir = tempfile::tempdir().unwrap();
1355 let err = check_manifest(dir.path()).unwrap_err();
1356 let e = unwrap_io_err(err);
1357 assert!(e.to_string().contains("No agent.leviath found"));
1358 }
1359
1360 #[test]
1361 fn check_manifest_unreadable_file_path_is_io_error() {
1362 let dir = tempfile::tempdir().unwrap();
1363 let missing = dir.path().join("nonexistent-subdir");
1367 let err = check_manifest(&missing).unwrap_err();
1368 unwrap_io_err(err);
1369 }
1370
1371 #[test]
1376 fn check_manifest_unreadable_file_is_io_error() {
1377 let dir = tempfile::tempdir().unwrap();
1381 std::fs::create_dir_all(dir.path().join("agent.leviath")).unwrap();
1382
1383 let err = check_manifest(dir.path()).unwrap_err();
1384 let e = unwrap_io_err(err);
1385 assert!(e.to_string().contains("Failed to read"));
1386 }
1387
1388 impl ManifestCheckError {
1389 fn is_parse(&self) -> bool {
1394 matches!(self, Self::Parse(_))
1395 }
1396 }
1397
1398 #[test]
1399 fn check_manifest_malformed_toml_is_parse_error() {
1400 let dir = tempfile::tempdir().unwrap();
1401 write_manifest(dir.path(), "not valid toml [[[");
1402 assert!(check_manifest(dir.path()).unwrap_err().is_parse());
1403 let empty = tempfile::tempdir().unwrap();
1406 assert!(!check_manifest(empty.path()).unwrap_err().is_parse());
1407 }
1408
1409 #[test]
1410 fn check_manifest_direct_file_path_is_accepted() {
1411 let dir = tempfile::tempdir().unwrap();
1412 let manifest_path = write_manifest(dir.path(), CLEAN_MANIFEST);
1413 let checked = check_manifest(&manifest_path).unwrap();
1415 assert_eq!(checked.blueprint.name, "ok-agent");
1416 }
1417}