1include!(concat!(env!("OUT_DIR"), "/bundled_agents.rs"));
15
16use std::path::Path;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum AgentAction {
22 Install,
24 Update {
26 from: String,
28 },
29 Modified,
32 UpToDate,
34}
35
36impl AgentAction {
37 pub fn is_change(&self) -> bool {
39 !matches!(self, Self::UpToDate)
40 }
41
42 pub fn preselect(&self) -> bool {
49 matches!(self, Self::Install | Self::Update { .. })
50 }
51
52 pub fn label(&self, to: &str) -> String {
54 match self {
55 Self::Install => format!("install {to}"),
56 Self::Update { from } => format!("update {from} → {to}"),
57 Self::Modified => format!("{to}, edited locally - reinstall overwrites"),
58 Self::UpToDate => "up to date".to_string(),
59 }
60 }
61}
62
63pub fn installed_version(agents_dir: &Path, name: &str) -> Option<String> {
71 let manifest = std::fs::read_to_string(agents_dir.join(name).join("agent.leviath")).ok()?;
72 leviath_core::manifest::parse_manifest(&manifest)
73 .ok()
74 .map(|bp| bp.version)
75}
76
77fn matches_bundled(agent: &BundledAgent, agents_dir: &Path) -> bool {
88 let dest = agents_dir.join(agent.name);
89 for (rel, contents) in agent.files {
90 match std::fs::read_to_string(dest.join(rel)) {
91 Ok(on_disk) if on_disk == *contents => {}
92 _ => return false,
93 }
94 }
95 installed_file_count(&dest) == agent.files.len()
98}
99
100fn installed_file_count(dir: &Path) -> usize {
106 let Ok(entries) = std::fs::read_dir(dir) else {
107 return 0;
108 };
109 entries
110 .map(|entry| match entry.map(|e| e.path()) {
111 Ok(path) if path.is_dir() => installed_file_count(&path),
112 _ => 1,
113 })
114 .sum()
115}
116
117pub fn plan_agent_actions(agents_dir: &Path) -> Vec<(&'static BundledAgent, AgentAction)> {
132 BUNDLED_AGENTS
133 .iter()
134 .map(|agent| {
135 let action = match installed_version(agents_dir, agent.name) {
136 None => AgentAction::Install,
137 Some(v) if v != agent.version => AgentAction::Update { from: v },
138 Some(_) if matches_bundled(agent, agents_dir) => AgentAction::UpToDate,
139 Some(_) => AgentAction::Modified,
140 };
141 (agent, action)
142 })
143 .collect()
144}
145
146pub fn stale_install_note(
158 manifest_path: &Path,
159 blueprint: &leviath_core::Blueprint,
160 agents_dir: Option<&Path>,
161) -> Option<String> {
162 let installed = agents_dir?.join(&blueprint.name);
163 if !manifest_path.starts_with(&installed) {
164 return None;
165 }
166 let bundled = BUNDLED_AGENTS.iter().find(|a| a.name == blueprint.name)?;
167 if bundled.version == blueprint.version {
168 return None;
169 }
170 Some(format!(
171 "note: '{}' is installed at {}, and this build ships {}. \
172 Run `lev setup` to update it.",
173 blueprint.name, blueprint.version, bundled.version
174 ))
175}
176
177pub fn stale_install_hint(manifest_path: &Path, agents_dir: Option<&Path>) -> Option<String> {
191 let agents_dir = agents_dir?;
192 let bundled = BUNDLED_AGENTS
193 .iter()
194 .find(|a| manifest_path.starts_with(agents_dir.join(a.name)))?;
195 if matches_bundled(bundled, agents_dir) {
201 return None;
204 }
205 Some(format!(
206 "this is the installed copy of the bundled '{}' agent, and it differs from the one this \
207 build ships, so it is most likely out of date rather than broken. Run `lev setup` to \
208 reinstall it, or `lev add <path>` if you meant to keep your own edits.",
209 bundled.name
210 ))
211}
212
213pub fn stale_install_suffix(
220 manifest_path: &Path,
221 agents_dir: Option<&Path>,
222 separator: &str,
223) -> String {
224 match stale_install_hint(manifest_path, agents_dir) {
225 Some(hint) => format!("{separator}{hint}"),
226 None => String::new(),
227 }
228}
229
230pub fn real_agents_dir_opt() -> Option<std::path::PathBuf> {
236 dirs::home_dir().map(|h| crate::commands::setup::real_agents_dir(Some(&h)))
237}
238
239pub fn install_bundled(agent: &BundledAgent, agents_dir: &Path) -> anyhow::Result<()> {
247 let dest = agents_dir.join(agent.name);
248 if dest.exists() {
249 std::fs::remove_dir_all(&dest)?;
250 }
251 for (rel, contents) in agent.files {
252 let parent = match rel.rsplit_once('/') {
258 Some((dir, _)) => dest.join(dir),
259 None => dest.clone(),
260 };
261 std::fs::create_dir_all(&parent)?;
262 std::fs::write(dest.join(rel), contents)?;
263 }
264 Ok(())
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
275 fn every_bundled_agent_has_a_name_version_and_manifest() {
276 assert!(
277 !BUNDLED_AGENTS.is_empty(),
278 "the binary shipped with no blueprints -- build.rs found no agents/ directory"
279 );
280 for agent in BUNDLED_AGENTS {
281 assert!(!agent.name.is_empty(), "a bundled agent has an empty name");
282 assert!(
283 !agent.version.is_empty(),
284 "bundled agent {} has an empty version",
285 agent.name
286 );
287 assert!(
288 agent.files.iter().any(|(rel, _)| *rel == "agent.leviath"),
289 "bundled agent {} has no agent.leviath",
290 agent.name
291 );
292 for (rel, contents) in agent.files {
293 assert!(
294 !rel.is_empty(),
295 "bundled agent {} has an empty path",
296 agent.name
297 );
298 assert!(
299 !contents.is_empty(),
300 "bundled agent {} has an empty file {rel}",
301 agent.name
302 );
303 }
304 }
305 }
306
307 #[test]
321 fn a_tool_script_shared_by_several_agents_is_identical_in_all_of_them() {
322 use std::collections::HashMap;
323
324 let mut first_seen: HashMap<&str, (&str, &str)> = HashMap::new();
326 for agent in BUNDLED_AGENTS {
327 for (rel, contents) in agent.files {
328 let Some(filename) = rel.strip_prefix("tools/") else {
329 continue;
330 };
331 match first_seen.get(filename) {
332 Some((other, expected)) => assert!(
333 expected == contents,
334 "tools/{filename} differs between bundled agents {other} and {} - \
335 a change to one copy was not applied to the others",
336 agent.name
337 ),
338 None => {
339 first_seen.insert(filename, (agent.name, contents));
340 }
341 }
342 }
343 }
344 assert!(
347 !first_seen.is_empty(),
348 "no bundled agent ships a tools/ script - this invariant is not being tested"
349 );
350 }
351
352 #[test]
353 fn every_bundled_manifest_parses_and_agrees_with_its_recorded_version() {
354 for agent in BUNDLED_AGENTS {
357 let manifest = agent
358 .files
359 .iter()
360 .find(|(rel, _)| *rel == "agent.leviath")
361 .map(|(_, c)| *c)
362 .expect("checked above");
363 let parsed = leviath_core::manifest::parse_manifest(manifest);
369 assert!(
370 parsed.is_ok(),
371 "bundled agent {} does not parse",
372 agent.name
373 );
374 let blueprint = parsed.expect("asserted Ok just above");
375 assert_eq!(blueprint.version, agent.version);
376 assert_eq!(blueprint.name, agent.name);
377 }
378 }
379
380 #[test]
393 fn every_bundled_agent_ends_by_handing_something_back() {
394 for agent in BUNDLED_AGENTS {
395 let manifest = agent
396 .files
397 .iter()
398 .find(|(rel, _)| *rel == "agent.leviath")
399 .map(|(_, c)| *c)
400 .expect("checked above");
401 let blueprint = leviath_core::manifest::parse_manifest(manifest)
402 .expect("checked by every_bundled_manifest_parses");
403
404 let outputs: Vec<&leviath_core::Stage> = blueprint
405 .stages
406 .iter()
407 .filter(|s| s.mode == leviath_core::blueprint::StageMode::Output)
408 .collect();
409 assert!(
410 !outputs.is_empty(),
411 "bundled agent {} has no output stage, so a run of it hands back nothing",
412 agent.name
413 );
414
415 for stage in &outputs {
416 assert!(stage.require_output, "{} output stage", agent.name);
419 assert!(
420 stage
421 .available_tools
422 .iter()
423 .any(|t| t == leviath_core::blueprint::SUBMIT_OUTPUT_TOOL),
424 "{} output stage cannot submit",
425 agent.name
426 );
427 assert!(
429 !stage.available_tools.iter().any(|t| {
430 leviath_core::blueprint::MODIFYING_TOOLS
431 .contains(&leviath_tools::canonical_tool_name(t))
432 }),
433 "{} output stage can modify files",
434 agent.name
435 );
436 }
437
438 for stage in &blueprint.stages {
439 assert!(
440 !stage.allow_complete
441 || stage.mode == leviath_core::blueprint::StageMode::Output,
442 "bundled agent {}: stage '{}' may end the run, skipping the output stage",
443 agent.name,
444 stage.name
445 );
446 }
447 }
448 }
449
450 const SETUP_PROVIDERS: &[&str] = &["anthropic", "openai", "google", "openrouter", "ollama"];
453
454 const BLUEPRINT_SCHEMA: &str = include_str!("../../../docs/schema/blueprint.schema.json");
460
461 fn schema_problems(
467 validator: &jsonschema::Validator,
468 value: &serde_json::Value,
469 ) -> Vec<String> {
470 validator
471 .iter_errors(value)
472 .map(|e| format!("{}: {e}", e.instance_path()))
473 .collect()
474 }
475
476 fn toml_to_json(value: &toml::Value) -> serde_json::Value {
478 match value {
479 toml::Value::String(s) => serde_json::Value::String(s.clone()),
480 toml::Value::Integer(i) => serde_json::Value::from(*i),
481 toml::Value::Float(f) => serde_json::Value::from(*f),
482 toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
483 toml::Value::Datetime(d) => serde_json::Value::String(d.to_string()),
487 toml::Value::Array(items) => {
488 serde_json::Value::Array(items.iter().map(toml_to_json).collect())
489 }
490 toml::Value::Table(table) => serde_json::Value::Object(
491 table
492 .iter()
493 .map(|(k, v)| (k.clone(), toml_to_json(v)))
494 .collect(),
495 ),
496 }
497 }
498
499 #[test]
500 fn toml_converts_to_json_for_every_value_kind() {
501 let source = concat!(
506 "s = \"text\"\n",
507 "i = 7\n",
508 "f = 0.5\n",
509 "b = true\n",
510 "d = 1979-05-27T07:32:00Z\n",
511 "a = [1, \"two\"]\n",
512 "[t]\n",
513 "nested = 1\n"
514 );
515 let parsed: toml::Value = toml::from_str(source).expect("valid TOML");
516 let json = toml_to_json(&parsed);
517 assert_eq!(json["s"], serde_json::json!("text"));
518 assert_eq!(json["i"], serde_json::json!(7));
519 assert_eq!(json["f"], serde_json::json!(0.5));
520 assert_eq!(json["b"], serde_json::json!(true));
521 assert!(json["d"].is_string());
523 assert_eq!(json["a"], serde_json::json!([1, "two"]));
524 assert_eq!(json["t"]["nested"], serde_json::json!(1));
525 }
526
527 #[test]
528 fn every_bundled_blueprint_validates_against_the_published_schema() {
529 let schema: serde_json::Value =
534 serde_json::from_str(BLUEPRINT_SCHEMA).expect("the schema is valid JSON");
535 let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
536
537 for agent in BUNDLED_AGENTS {
538 let manifest = agent
539 .files
540 .iter()
541 .find(|(rel, _)| *rel == "agent.leviath")
542 .map(|(_, c)| *c)
543 .expect("every bundled agent has a manifest");
544 let parsed: toml::Value = toml::from_str(manifest).expect("the manifest is valid TOML");
545 let json = toml_to_json(&parsed);
546
547 assert_eq!(
548 schema_problems(&validator, &json),
549 Vec::<String>::new(),
550 "{} does not match blueprint.schema.json",
551 agent.name
552 );
553 }
554 }
555
556 #[test]
557 fn the_blueprint_schema_accepts_every_region_kind_the_parser_names() {
558 let err = leviath_core::manifest::parse_manifest(
568 "[agent]\nname = \"a\"\n\n[context.regions]\nx = { kind = \"not-a-kind\" }\n",
569 )
570 .expect_err("an unknown region kind is a load error")
571 .to_string();
572 let listed = err
573 .split("valid kinds:")
574 .nth(1)
575 .expect("the error names the valid kinds")
576 .trim()
577 .trim_end_matches(')')
578 .split(',')
579 .map(str::trim)
580 .filter(|k| !k.is_empty())
581 .collect::<Vec<_>>();
582 assert!(
583 listed.len() > 5,
584 "the error should list every kind: {listed:?}"
585 );
586
587 let schema: serde_json::Value =
588 serde_json::from_str(BLUEPRINT_SCHEMA).expect("the schema is valid JSON");
589 let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
590 for kind in listed {
591 let manifest = format!(
592 "[agent]\nname = \"a\"\n\n[context.regions]\nx = {{ kind = \"{kind}\" }}\n"
593 );
594 let parsed: toml::Value = toml::from_str(&manifest).expect("valid TOML");
595 assert_eq!(
596 schema_problems(&validator, &toml_to_json(&parsed)),
597 Vec::<String>::new(),
598 "the schema rejects region kind \"{kind}\", which the parser accepts"
599 );
600 }
601 }
602
603 #[test]
604 fn the_blueprint_schema_accepts_every_transition_condition_the_parser_names() {
605 let err = leviath_core::manifest::parse_manifest(
611 "[agent]\nname = \"a\"\n\n[stages.main.transitions.other]\ncondition = \"whenever\"\n",
612 )
613 .expect_err("an unknown condition is a load error")
614 .to_string();
615 let listed = err
616 .split("(valid:")
617 .nth(1)
618 .expect("the error names the valid conditions")
619 .trim()
620 .trim_end_matches(')')
621 .split(',')
622 .map(str::trim)
623 .filter(|c| !c.is_empty())
624 .collect::<Vec<_>>();
625 assert!(
626 listed.len() > 3,
627 "the error should list every condition: {listed:?}"
628 );
629
630 let schema: serde_json::Value =
631 serde_json::from_str(BLUEPRINT_SCHEMA).expect("the schema is valid JSON");
632 let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
633 for condition in listed {
634 let manifest = format!(
635 "[agent]\nname = \"a\"\n\n[stages.main.transitions.other]\ncondition = \"{condition}\"\n"
636 );
637 let parsed: toml::Value = toml::from_str(&manifest).expect("valid TOML");
638 assert_eq!(
639 schema_problems(&validator, &toml_to_json(&parsed)),
640 Vec::<String>::new(),
641 "the schema rejects condition \"{condition}\", which the parser accepts"
642 );
643 }
644 }
645
646 #[test]
647 fn the_blueprint_schema_accepts_stage_hooks() {
648 let schema: serde_json::Value =
653 serde_json::from_str(BLUEPRINT_SCHEMA).expect("the schema is valid JSON");
654 let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
655 let manifest = "[agent]\nname = \"a\"\n\n[stages.main.hooks]\n\
656 on_stage_enter = \"hooks/enter.rhai\"\n\
657 on_error = \"hooks/error.rhai\"\n";
658 let parsed: toml::Value = toml::from_str(manifest).expect("valid TOML");
659 assert_eq!(
660 schema_problems(&validator, &toml_to_json(&parsed)),
661 Vec::<String>::new(),
662 "the schema rejects [stages.<name>.hooks], which the parser accepts"
663 );
664 }
665
666 #[test]
667 fn the_blueprint_schema_rejects_what_the_parser_rejects() {
668 let schema: serde_json::Value =
672 serde_json::from_str(BLUEPRINT_SCHEMA).expect("the schema is valid JSON");
673 let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
674 let rejects = |manifest: &str| {
678 let parsed: toml::Value = toml::from_str(manifest).expect("valid TOML");
679 !schema_problems(&validator, &toml_to_json(&parsed)).is_empty()
680 };
681
682 assert!(
683 rejects("[stages.main]\nmode = \"autonomous\"\n"),
684 "no [agent]"
685 );
686 assert!(
687 rejects("[agent]\nname = \"a\"\n\n[context.regions]\nx = { kind = \"nonsense\" }\n"),
688 "unknown region kind"
689 );
690 assert!(
691 rejects(
692 "[agent]\nname = \"a\"\n\n[stages.main.transitions.other]\ncondition = \"whenever\"\n"
693 ),
694 "unknown transition condition"
695 );
696 assert!(
697 rejects("[agent]\nname = \"a\"\n\n[stages.main]\nmax_iteratoins = 5\n"),
698 "a typo'd stage key"
699 );
700 assert!(
701 rejects("[agent]\nname = \"a\"\n\n[tool_permissions]\nshell = \"maybe\"\n"),
702 "an invalid tool policy"
703 );
704 assert!(!rejects("[agent]\nname = \"a\"\n"), "a minimal manifest");
707 }
708
709 #[test]
710 fn every_bundled_stage_offers_every_provider_setup_can_configure() {
711 for agent in BUNDLED_AGENTS {
718 let manifest = agent
719 .files
720 .iter()
721 .find(|(rel, _)| *rel == "agent.leviath")
722 .map(|(_, c)| *c)
723 .expect("every bundled agent has a manifest");
724 let blueprint =
725 leviath_core::manifest::parse_manifest(manifest).expect("manifest parses");
726
727 for stage in &blueprint.stages {
728 let stage_name = &stage.name;
729 let listed: Vec<&str> = stage
730 .model
731 .models
732 .iter()
733 .map(|entry| entry.provider.as_str())
734 .collect();
735 for provider in SETUP_PROVIDERS {
736 assert!(
737 listed.contains(provider),
738 "{}/{} omits provider {}",
739 agent.name,
740 stage_name,
741 provider
742 );
743 }
744 assert_eq!(
748 listed.last().copied(),
749 Some("ollama"),
750 "{}/{} must list ollama last",
751 agent.name,
752 stage_name
753 );
754 }
755 }
756 }
757
758 fn lint_env_for(agent: &BundledAgent) -> crate::lint::LintEnv {
765 let mut known_tools: std::collections::HashSet<String> = leviath_tools::BuiltinTools::new(
766 leviath_tools::ToolContext::new(std::path::PathBuf::from(".")),
767 )
768 .names()
769 .into_iter()
770 .collect();
771 known_tools.extend(leviath_tools::BuiltinTools::subagent_tool_names());
772 known_tools.extend(
773 agent
774 .files
775 .iter()
776 .filter_map(|(rel, _)| rel.strip_prefix("tools/"))
777 .filter_map(|f| f.strip_suffix(".rhai"))
778 .map(str::to_string),
779 );
780 crate::lint::LintEnv {
781 known_tools,
782 known_models: crate::commands::models::closed_catalog_models(),
783 available_providers: None,
784 read_paths: None,
785 safe_commands_granted: None,
786 }
787 }
788
789 #[test]
801 fn no_bundled_agent_has_a_lint_error() {
802 for agent in BUNDLED_AGENTS {
803 let manifest = agent
804 .files
805 .iter()
806 .find(|(rel, _)| *rel == "agent.leviath")
807 .map(|(_, c)| *c)
808 .expect("every bundled agent has a manifest");
809 let parsed = leviath_core::manifest::parse_manifest(manifest);
810 assert!(
811 parsed.is_ok(),
812 "bundled agent {} does not parse",
813 agent.name
814 );
815 let blueprint = parsed.expect("asserted Ok just above");
816 let rendered: Vec<(bool, String)> =
823 crate::lint::lint_manifest(manifest, &blueprint, &lint_env_for(agent))
824 .iter()
825 .map(|f| (f.is_error(), format!("{} [{}]", f.one_line(), f.code)))
826 .collect();
827 let error_count = rendered.iter().filter(|(is_error, _)| *is_error).count();
828 assert_eq!(
829 error_count, 0,
830 "bundled agent {} has lint errors, among {rendered:?}",
831 agent.name
832 );
833 }
834 }
835
836 #[test]
839 fn the_lint_invariant_catches_a_typo_and_an_orphan_permission() {
840 let manifest = r#"
841[agent]
842name = "x"
843version = "0.1.0"
844description = "x"
845
846[stages.only]
847mode = "autonomous"
848model = { provider = "anthropic", model = "claude-sonnet-5" }
849max_iterations = 5
850available_tools = ["read_file", "raed_file"]
851
852[stages.only.tool_permissions]
853write_file = "allow"
854"#;
855 let bp = leviath_core::manifest::parse_manifest(manifest)
856 .expect("the fixture parses; it is the lint that should object");
857 let env = lint_env_for(&BundledAgent {
859 name: "x",
860 version: "0.1.0",
861 files: &[],
862 });
863 let codes: Vec<&str> = crate::lint::lint_manifest(manifest, &bp, &env)
864 .iter()
865 .filter(|f| f.is_error())
866 .map(|f| f.code)
867 .collect();
868 assert_eq!(codes, ["unknown-tool", "orphan-stage-permission"]);
869 }
870
871 #[test]
872 fn bundled_agent_names_are_unique() {
873 let mut names: Vec<&str> = BUNDLED_AGENTS.iter().map(|a| a.name).collect();
874 names.sort_unstable();
875 let count = names.len();
876 names.dedup();
877 assert_eq!(count, names.len(), "duplicate bundled agent names");
878 }
879
880 #[test]
883 fn installed_version_reads_a_manifest() {
884 let dir = tempfile::tempdir().unwrap();
885 let agent = &BUNDLED_AGENTS[0];
886 install_bundled(agent, dir.path()).unwrap();
887
888 assert_eq!(
889 installed_version(dir.path(), agent.name).as_deref(),
890 Some(agent.version)
891 );
892 }
893
894 #[test]
895 fn installed_version_is_none_when_nothing_is_installed() {
896 let dir = tempfile::tempdir().unwrap();
897 assert!(installed_version(dir.path(), "not-installed").is_none());
898 }
899
900 #[test]
901 fn installed_version_is_none_for_an_unparseable_manifest() {
902 let dir = tempfile::tempdir().unwrap();
905 std::fs::create_dir_all(dir.path().join("broken")).unwrap();
906 std::fs::write(
907 dir.path().join("broken/agent.leviath"),
908 "not valid toml {{{",
909 )
910 .unwrap();
911
912 assert!(installed_version(dir.path(), "broken").is_none());
913 }
914
915 #[test]
918 fn plan_offers_to_install_everything_into_an_empty_dir() {
919 let dir = tempfile::tempdir().unwrap();
920
921 let plan = plan_agent_actions(dir.path());
922
923 assert_eq!(plan.len(), BUNDLED_AGENTS.len());
924 for (agent, action) in &plan {
925 assert_eq!(*action, AgentAction::Install);
926 assert!(action.is_change());
927 assert_eq!(
928 action.label(agent.version),
929 format!("install {}", agent.version)
930 );
931 }
932 }
933
934 #[test]
935 fn plan_reports_up_to_date_after_installing() {
936 let dir = tempfile::tempdir().unwrap();
937 for agent in BUNDLED_AGENTS {
938 install_bundled(agent, dir.path()).unwrap();
939 }
940
941 let plan = plan_agent_actions(dir.path());
942
943 for (agent, action) in &plan {
944 assert_eq!(*action, AgentAction::UpToDate, "{}", agent.name);
945 assert!(!action.is_change());
946 assert_eq!(action.label(agent.version), "up to date");
947 }
948 }
949
950 #[test]
951 fn plan_reports_an_update_when_the_installed_version_differs() {
952 let dir = tempfile::tempdir().unwrap();
953 let agent = &BUNDLED_AGENTS[0];
954 install_bundled(agent, dir.path()).unwrap();
955 let manifest_path = dir.path().join(agent.name).join("agent.leviath");
957 let manifest = std::fs::read_to_string(&manifest_path).unwrap();
958 let bumped = manifest.replacen(
959 &format!("version = \"{}\"", agent.version),
960 "version = \"9.9.9\"",
961 1,
962 );
963 std::fs::write(&manifest_path, bumped).unwrap();
964
965 let plan = plan_agent_actions(dir.path());
966 let (_, action) = plan
967 .iter()
968 .find(|(a, _)| a.name == agent.name)
969 .expect("the bundled agent is in the plan");
970
971 assert_eq!(
972 *action,
973 AgentAction::Update {
974 from: "9.9.9".to_string()
975 }
976 );
977 assert!(action.is_change());
978 assert_eq!(
979 action.label(agent.version),
980 format!("update 9.9.9 → {}", agent.version)
981 );
982 }
983
984 #[test]
988 fn plan_reports_an_edited_install_as_modified() {
989 let dir = tempfile::tempdir().unwrap();
990 let agent = &BUNDLED_AGENTS[0];
991 install_bundled(agent, dir.path()).unwrap();
992 let manifest_path = dir.path().join(agent.name).join("agent.leviath");
993 let manifest = std::fs::read_to_string(&manifest_path).unwrap();
994 std::fs::write(&manifest_path, manifest + "\n# a local edit\n").unwrap();
995
996 let action = action_for(&plan_agent_actions(dir.path()), agent.name);
997 assert_eq!(action, AgentAction::Modified);
998 assert!(action.is_change());
1001 assert!(!action.preselect());
1002 let label = action.label(agent.version);
1003 assert!(label.contains("edited locally"), "{label}");
1004 }
1005
1006 #[test]
1010 fn a_file_the_user_added_or_removed_counts_as_modified() {
1011 let agent = &BUNDLED_AGENTS[0];
1012
1013 let added = tempfile::tempdir().unwrap();
1014 install_bundled(agent, added.path()).unwrap();
1015 std::fs::write(added.path().join(agent.name).join("notes.md"), "mine").unwrap();
1016 assert_eq!(
1017 action_for(&plan_agent_actions(added.path()), agent.name),
1018 AgentAction::Modified
1019 );
1020
1021 let multi = BUNDLED_AGENTS
1025 .iter()
1026 .find(|a| a.files.len() > 1)
1027 .expect("some bundled blueprint ships more than its manifest");
1028 let removed = tempfile::tempdir().unwrap();
1029 install_bundled(multi, removed.path()).unwrap();
1030 let extra = multi
1031 .files
1032 .iter()
1033 .map(|(rel, _)| *rel)
1034 .find(|rel| *rel != "agent.leviath")
1035 .expect("a file other than the manifest");
1036 std::fs::remove_file(removed.path().join(multi.name).join(extra)).unwrap();
1037 assert_eq!(
1038 action_for(&plan_agent_actions(removed.path()), multi.name),
1039 AgentAction::Modified
1040 );
1041 }
1042
1043 #[test]
1046 fn an_unreadable_tree_is_not_up_to_date() {
1047 assert_eq!(installed_file_count(Path::new("/no/such/dir")), 0);
1048 let dir = tempfile::tempdir().unwrap();
1049 assert!(!matches_bundled(&BUNDLED_AGENTS[0], dir.path()));
1050 }
1051
1052 #[test]
1053 fn installed_file_count_walks_nested_directories() {
1054 let dir = tempfile::tempdir().unwrap();
1055 std::fs::create_dir_all(dir.path().join("a/b")).unwrap();
1056 std::fs::write(dir.path().join("top.txt"), "x").unwrap();
1057 std::fs::write(dir.path().join("a/mid.txt"), "x").unwrap();
1058 std::fs::write(dir.path().join("a/b/leaf.txt"), "x").unwrap();
1059 assert_eq!(installed_file_count(dir.path()), 3);
1060 }
1061
1062 fn action_for(plan: &[(&'static BundledAgent, AgentAction)], name: &str) -> AgentAction {
1063 plan.iter()
1064 .find(|(a, _)| a.name == name)
1065 .expect("the bundled agent is in the plan")
1066 .1
1067 .clone()
1068 }
1069
1070 #[test]
1077 fn an_installed_agent_that_will_not_load_is_named_as_out_of_date() {
1078 let dir = tempfile::tempdir().unwrap();
1079 let agent = &BUNDLED_AGENTS[0];
1080 install_bundled(agent, dir.path()).unwrap();
1081 let manifest = dir.path().join(agent.name).join("agent.leviath");
1082
1083 assert_eq!(stale_install_hint(&manifest, Some(dir.path())), None);
1086
1087 std::fs::write(&manifest, "[agent]\nname = \"x\"\nversion = \"0.0.2\"\n").unwrap();
1091 let hint =
1092 stale_install_hint(&manifest, Some(dir.path())).expect("a changed copy is named");
1093 assert!(hint.contains(agent.name), "{hint}");
1094 assert!(hint.contains("lev setup"), "{hint}");
1095 }
1096
1097 #[test]
1103 fn the_suffix_carries_the_hint_or_nothing_at_all() {
1104 let dir = tempfile::tempdir().unwrap();
1105 let agent = &BUNDLED_AGENTS[0];
1106 install_bundled(agent, dir.path()).unwrap();
1107 let manifest = dir.path().join(agent.name).join("agent.leviath");
1108
1109 assert_eq!(
1111 stale_install_suffix(&manifest, Some(dir.path()), "\n\n"),
1112 ""
1113 );
1114
1115 std::fs::write(&manifest, "[agent]\nname = \"x\"\n").unwrap();
1116 let suffix = stale_install_suffix(&manifest, Some(dir.path()), "\n\n");
1117 assert!(suffix.starts_with("\n\n"), "{suffix:?}");
1118 assert!(suffix.contains(agent.name), "{suffix:?}");
1119 assert!(
1121 stale_install_suffix(&manifest, Some(dir.path()), ". ").starts_with(". "),
1122 "the separator is the caller's choice"
1123 );
1124 }
1125
1126 #[test]
1127 fn the_hint_stays_quiet_outside_the_installed_copy() {
1128 let dir = tempfile::tempdir().unwrap();
1129 let agent = &BUNDLED_AGENTS[0];
1130 install_bundled(agent, dir.path()).unwrap();
1131
1132 let elsewhere = dir.path().join("elsewhere").join(agent.name);
1133 std::fs::create_dir_all(&elsewhere).unwrap();
1134 let mine = elsewhere.join("agent.leviath");
1135 std::fs::write(&mine, "[agent]\nname = \"mine\"\n").unwrap();
1136 assert_eq!(stale_install_hint(&mine, Some(dir.path())), None);
1137
1138 let other = dir.path().join("not-a-bundled-agent");
1140 std::fs::create_dir_all(&other).unwrap();
1141 let manifest = other.join("agent.leviath");
1142 std::fs::write(&manifest, "[agent]\nname = \"other\"\n").unwrap();
1143 assert_eq!(stale_install_hint(&manifest, Some(dir.path())), None);
1144
1145 assert_eq!(
1147 stale_install_hint(&dir.path().join(agent.name).join("agent.leviath"), None),
1148 None
1149 );
1150 }
1151
1152 #[test]
1157 fn a_stale_install_is_named_when_the_run_starts() {
1158 let dir = tempfile::tempdir().unwrap();
1159 let agent = &BUNDLED_AGENTS[0];
1160 install_bundled(agent, dir.path()).unwrap();
1161 let manifest = dir.path().join(agent.name).join("agent.leviath");
1162 let mut blueprint =
1163 leviath_core::manifest::parse_manifest(&std::fs::read_to_string(&manifest).unwrap())
1164 .unwrap();
1165
1166 assert_eq!(
1168 stale_install_note(&manifest, &blueprint, Some(dir.path())),
1169 None
1170 );
1171
1172 blueprint.version = "0.0.1".to_string();
1173 let note = stale_install_note(&manifest, &blueprint, Some(dir.path()))
1174 .expect("a behind install is named");
1175 assert!(note.contains("0.0.1"), "{note}");
1176 assert!(note.contains(agent.version), "{note}");
1177 assert!(note.contains("lev setup"), "{note}");
1178 }
1179
1180 #[test]
1184 fn a_blueprint_that_is_not_the_installed_copy_is_left_alone() {
1185 let dir = tempfile::tempdir().unwrap();
1186 let agent = &BUNDLED_AGENTS[0];
1187 install_bundled(agent, dir.path()).unwrap();
1188 let manifest = dir.path().join(agent.name).join("agent.leviath");
1189 let mut blueprint =
1190 leviath_core::manifest::parse_manifest(&std::fs::read_to_string(&manifest).unwrap())
1191 .unwrap();
1192 blueprint.version = "0.0.1".to_string();
1193
1194 let elsewhere = tempfile::tempdir().unwrap();
1196 let copy = elsewhere.path().join(agent.name).join("agent.leviath");
1197 assert_eq!(
1198 stale_install_note(©, &blueprint, Some(dir.path())),
1199 None,
1200 "not the installed copy"
1201 );
1202
1203 assert_eq!(stale_install_note(&manifest, &blueprint, None), None);
1205
1206 blueprint.name = "not-a-bundled-agent".to_string();
1208 assert_eq!(
1209 stale_install_note(
1210 &dir.path().join("not-a-bundled-agent").join("agent.leviath"),
1211 &blueprint,
1212 Some(dir.path())
1213 ),
1214 None
1215 );
1216 }
1217
1218 #[test]
1221 fn install_writes_every_file_including_nested_ones() {
1222 let dir = tempfile::tempdir().unwrap();
1223 for agent in BUNDLED_AGENTS {
1228 install_bundled(agent, dir.path()).unwrap();
1229 for (rel, contents) in agent.files {
1230 let written = std::fs::read_to_string(dir.path().join(agent.name).join(rel));
1231 assert!(written.is_ok(), "{}/{rel} was not written", agent.name);
1232 assert_eq!(written.expect("asserted Ok just above"), *contents);
1233 }
1234 }
1235 assert!(
1236 BUNDLED_AGENTS
1237 .iter()
1238 .any(|a| a.files.iter().any(|(rel, _)| rel.contains('/'))),
1239 "no bundled blueprint has a nested file, so install's mkdir path is untested"
1240 );
1241 }
1242
1243 #[test]
1244 fn install_replaces_an_existing_tree_and_drops_stale_files() {
1245 let dir = tempfile::tempdir().unwrap();
1246 let agent = &BUNDLED_AGENTS[0];
1247 install_bundled(agent, dir.path()).unwrap();
1248 let stale = dir
1249 .path()
1250 .join(agent.name)
1251 .join("stale-from-an-older-version");
1252 std::fs::write(&stale, "leftover").unwrap();
1253
1254 install_bundled(agent, dir.path()).unwrap();
1255
1256 assert!(
1257 !stale.exists(),
1258 "a reinstall must not leave files from the previous version behind"
1259 );
1260 assert!(dir.path().join(agent.name).join("agent.leviath").exists());
1261 }
1262
1263 #[test]
1264 fn install_surfaces_a_directory_creation_failure() {
1265 let dir = tempfile::tempdir().unwrap();
1268 let blocked = dir.path().join("not-a-dir");
1269 std::fs::write(&blocked, "").unwrap();
1270
1271 let result = install_bundled(&BUNDLED_AGENTS[0], &blocked);
1272
1273 assert!(result.is_err());
1274 }
1275
1276 #[test]
1277 fn install_surfaces_a_file_write_failure() {
1278 let agent = BundledAgent {
1285 name: "collides-with-its-own-directory",
1286 version: "0.0.1",
1287 files: &[("tools/a.rhai", "nested first"), ("tools", "then the dir")],
1288 };
1289 let dir = tempfile::tempdir().unwrap();
1290
1291 let result = install_bundled(&agent, dir.path());
1292
1293 assert!(result.is_err());
1294 }
1295
1296 #[test]
1297 fn install_surfaces_a_remove_failure() {
1298 let dir = tempfile::tempdir().unwrap();
1301 let agent = &BUNDLED_AGENTS[0];
1302 std::fs::write(dir.path().join(agent.name), "").unwrap();
1303
1304 let result = install_bundled(agent, dir.path());
1305
1306 assert!(result.is_err());
1307 }
1308}