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 model_windows: crate::commands::models::builtin_model_windows(),
787 }
788 }
789
790 #[test]
802 fn no_bundled_agent_has_a_lint_error() {
803 for agent in BUNDLED_AGENTS {
804 let manifest = agent
805 .files
806 .iter()
807 .find(|(rel, _)| *rel == "agent.leviath")
808 .map(|(_, c)| *c)
809 .expect("every bundled agent has a manifest");
810 let parsed = leviath_core::manifest::parse_manifest(manifest);
811 assert!(
812 parsed.is_ok(),
813 "bundled agent {} does not parse",
814 agent.name
815 );
816 let blueprint = parsed.expect("asserted Ok just above");
817 let rendered: Vec<(bool, String)> =
824 crate::lint::lint_manifest(manifest, &blueprint, &lint_env_for(agent))
825 .iter()
826 .map(|f| (f.is_error(), format!("{} [{}]", f.one_line(), f.code)))
827 .collect();
828 let error_count = rendered.iter().filter(|(is_error, _)| *is_error).count();
829 assert_eq!(
830 error_count, 0,
831 "bundled agent {} has lint errors, among {rendered:?}",
832 agent.name
833 );
834 }
835 }
836
837 #[test]
840 fn the_lint_invariant_catches_a_typo_and_an_orphan_permission() {
841 let manifest = r#"
842[agent]
843name = "x"
844version = "0.1.0"
845description = "x"
846
847[stages.only]
848mode = "autonomous"
849model = { provider = "anthropic", model = "claude-sonnet-5" }
850max_iterations = 5
851available_tools = ["read_file", "raed_file"]
852
853[stages.only.tool_permissions]
854write_file = "allow"
855"#;
856 let bp = leviath_core::manifest::parse_manifest(manifest)
857 .expect("the fixture parses; it is the lint that should object");
858 let env = lint_env_for(&BundledAgent {
860 name: "x",
861 version: "0.1.0",
862 files: &[],
863 });
864 let codes: Vec<&str> = crate::lint::lint_manifest(manifest, &bp, &env)
865 .iter()
866 .filter(|f| f.is_error())
867 .map(|f| f.code)
868 .collect();
869 assert_eq!(codes, ["unknown-tool", "orphan-stage-permission"]);
870 }
871
872 #[test]
873 fn bundled_agent_names_are_unique() {
874 let mut names: Vec<&str> = BUNDLED_AGENTS.iter().map(|a| a.name).collect();
875 names.sort_unstable();
876 let count = names.len();
877 names.dedup();
878 assert_eq!(count, names.len(), "duplicate bundled agent names");
879 }
880
881 #[test]
884 fn installed_version_reads_a_manifest() {
885 let dir = tempfile::tempdir().unwrap();
886 let agent = &BUNDLED_AGENTS[0];
887 install_bundled(agent, dir.path()).unwrap();
888
889 assert_eq!(
890 installed_version(dir.path(), agent.name).as_deref(),
891 Some(agent.version)
892 );
893 }
894
895 #[test]
896 fn installed_version_is_none_when_nothing_is_installed() {
897 let dir = tempfile::tempdir().unwrap();
898 assert!(installed_version(dir.path(), "not-installed").is_none());
899 }
900
901 #[test]
902 fn installed_version_is_none_for_an_unparseable_manifest() {
903 let dir = tempfile::tempdir().unwrap();
906 std::fs::create_dir_all(dir.path().join("broken")).unwrap();
907 std::fs::write(
908 dir.path().join("broken/agent.leviath"),
909 "not valid toml {{{",
910 )
911 .unwrap();
912
913 assert!(installed_version(dir.path(), "broken").is_none());
914 }
915
916 #[test]
919 fn plan_offers_to_install_everything_into_an_empty_dir() {
920 let dir = tempfile::tempdir().unwrap();
921
922 let plan = plan_agent_actions(dir.path());
923
924 assert_eq!(plan.len(), BUNDLED_AGENTS.len());
925 for (agent, action) in &plan {
926 assert_eq!(*action, AgentAction::Install);
927 assert!(action.is_change());
928 assert_eq!(
929 action.label(agent.version),
930 format!("install {}", agent.version)
931 );
932 }
933 }
934
935 #[test]
936 fn plan_reports_up_to_date_after_installing() {
937 let dir = tempfile::tempdir().unwrap();
938 for agent in BUNDLED_AGENTS {
939 install_bundled(agent, dir.path()).unwrap();
940 }
941
942 let plan = plan_agent_actions(dir.path());
943
944 for (agent, action) in &plan {
945 assert_eq!(*action, AgentAction::UpToDate, "{}", agent.name);
946 assert!(!action.is_change());
947 assert_eq!(action.label(agent.version), "up to date");
948 }
949 }
950
951 #[test]
952 fn plan_reports_an_update_when_the_installed_version_differs() {
953 let dir = tempfile::tempdir().unwrap();
954 let agent = &BUNDLED_AGENTS[0];
955 install_bundled(agent, dir.path()).unwrap();
956 let manifest_path = dir.path().join(agent.name).join("agent.leviath");
958 let manifest = std::fs::read_to_string(&manifest_path).unwrap();
959 let bumped = manifest.replacen(
960 &format!("version = \"{}\"", agent.version),
961 "version = \"9.9.9\"",
962 1,
963 );
964 std::fs::write(&manifest_path, bumped).unwrap();
965
966 let plan = plan_agent_actions(dir.path());
967 let (_, action) = plan
968 .iter()
969 .find(|(a, _)| a.name == agent.name)
970 .expect("the bundled agent is in the plan");
971
972 assert_eq!(
973 *action,
974 AgentAction::Update {
975 from: "9.9.9".to_string()
976 }
977 );
978 assert!(action.is_change());
979 assert_eq!(
980 action.label(agent.version),
981 format!("update 9.9.9 → {}", agent.version)
982 );
983 }
984
985 #[test]
989 fn plan_reports_an_edited_install_as_modified() {
990 let dir = tempfile::tempdir().unwrap();
991 let agent = &BUNDLED_AGENTS[0];
992 install_bundled(agent, dir.path()).unwrap();
993 let manifest_path = dir.path().join(agent.name).join("agent.leviath");
994 let manifest = std::fs::read_to_string(&manifest_path).unwrap();
995 std::fs::write(&manifest_path, manifest + "\n# a local edit\n").unwrap();
996
997 let action = action_for(&plan_agent_actions(dir.path()), agent.name);
998 assert_eq!(action, AgentAction::Modified);
999 assert!(action.is_change());
1002 assert!(!action.preselect());
1003 let label = action.label(agent.version);
1004 assert!(label.contains("edited locally"), "{label}");
1005 }
1006
1007 #[test]
1011 fn a_file_the_user_added_or_removed_counts_as_modified() {
1012 let agent = &BUNDLED_AGENTS[0];
1013
1014 let added = tempfile::tempdir().unwrap();
1015 install_bundled(agent, added.path()).unwrap();
1016 std::fs::write(added.path().join(agent.name).join("notes.md"), "mine").unwrap();
1017 assert_eq!(
1018 action_for(&plan_agent_actions(added.path()), agent.name),
1019 AgentAction::Modified
1020 );
1021
1022 let multi = BUNDLED_AGENTS
1026 .iter()
1027 .find(|a| a.files.len() > 1)
1028 .expect("some bundled blueprint ships more than its manifest");
1029 let removed = tempfile::tempdir().unwrap();
1030 install_bundled(multi, removed.path()).unwrap();
1031 let extra = multi
1032 .files
1033 .iter()
1034 .map(|(rel, _)| *rel)
1035 .find(|rel| *rel != "agent.leviath")
1036 .expect("a file other than the manifest");
1037 std::fs::remove_file(removed.path().join(multi.name).join(extra)).unwrap();
1038 assert_eq!(
1039 action_for(&plan_agent_actions(removed.path()), multi.name),
1040 AgentAction::Modified
1041 );
1042 }
1043
1044 #[test]
1047 fn an_unreadable_tree_is_not_up_to_date() {
1048 assert_eq!(installed_file_count(Path::new("/no/such/dir")), 0);
1049 let dir = tempfile::tempdir().unwrap();
1050 assert!(!matches_bundled(&BUNDLED_AGENTS[0], dir.path()));
1051 }
1052
1053 #[test]
1054 fn installed_file_count_walks_nested_directories() {
1055 let dir = tempfile::tempdir().unwrap();
1056 std::fs::create_dir_all(dir.path().join("a/b")).unwrap();
1057 std::fs::write(dir.path().join("top.txt"), "x").unwrap();
1058 std::fs::write(dir.path().join("a/mid.txt"), "x").unwrap();
1059 std::fs::write(dir.path().join("a/b/leaf.txt"), "x").unwrap();
1060 assert_eq!(installed_file_count(dir.path()), 3);
1061 }
1062
1063 fn action_for(plan: &[(&'static BundledAgent, AgentAction)], name: &str) -> AgentAction {
1064 plan.iter()
1065 .find(|(a, _)| a.name == name)
1066 .expect("the bundled agent is in the plan")
1067 .1
1068 .clone()
1069 }
1070
1071 #[test]
1078 fn an_installed_agent_that_will_not_load_is_named_as_out_of_date() {
1079 let dir = tempfile::tempdir().unwrap();
1080 let agent = &BUNDLED_AGENTS[0];
1081 install_bundled(agent, dir.path()).unwrap();
1082 let manifest = dir.path().join(agent.name).join("agent.leviath");
1083
1084 assert_eq!(stale_install_hint(&manifest, Some(dir.path())), None);
1087
1088 std::fs::write(&manifest, "[agent]\nname = \"x\"\nversion = \"0.0.2\"\n").unwrap();
1092 let hint =
1093 stale_install_hint(&manifest, Some(dir.path())).expect("a changed copy is named");
1094 assert!(hint.contains(agent.name), "{hint}");
1095 assert!(hint.contains("lev setup"), "{hint}");
1096 }
1097
1098 #[test]
1104 fn the_suffix_carries_the_hint_or_nothing_at_all() {
1105 let dir = tempfile::tempdir().unwrap();
1106 let agent = &BUNDLED_AGENTS[0];
1107 install_bundled(agent, dir.path()).unwrap();
1108 let manifest = dir.path().join(agent.name).join("agent.leviath");
1109
1110 assert_eq!(
1112 stale_install_suffix(&manifest, Some(dir.path()), "\n\n"),
1113 ""
1114 );
1115
1116 std::fs::write(&manifest, "[agent]\nname = \"x\"\n").unwrap();
1117 let suffix = stale_install_suffix(&manifest, Some(dir.path()), "\n\n");
1118 assert!(suffix.starts_with("\n\n"), "{suffix:?}");
1119 assert!(suffix.contains(agent.name), "{suffix:?}");
1120 assert!(
1122 stale_install_suffix(&manifest, Some(dir.path()), ". ").starts_with(". "),
1123 "the separator is the caller's choice"
1124 );
1125 }
1126
1127 #[test]
1128 fn the_hint_stays_quiet_outside_the_installed_copy() {
1129 let dir = tempfile::tempdir().unwrap();
1130 let agent = &BUNDLED_AGENTS[0];
1131 install_bundled(agent, dir.path()).unwrap();
1132
1133 let elsewhere = dir.path().join("elsewhere").join(agent.name);
1134 std::fs::create_dir_all(&elsewhere).unwrap();
1135 let mine = elsewhere.join("agent.leviath");
1136 std::fs::write(&mine, "[agent]\nname = \"mine\"\n").unwrap();
1137 assert_eq!(stale_install_hint(&mine, Some(dir.path())), None);
1138
1139 let other = dir.path().join("not-a-bundled-agent");
1141 std::fs::create_dir_all(&other).unwrap();
1142 let manifest = other.join("agent.leviath");
1143 std::fs::write(&manifest, "[agent]\nname = \"other\"\n").unwrap();
1144 assert_eq!(stale_install_hint(&manifest, Some(dir.path())), None);
1145
1146 assert_eq!(
1148 stale_install_hint(&dir.path().join(agent.name).join("agent.leviath"), None),
1149 None
1150 );
1151 }
1152
1153 #[test]
1158 fn a_stale_install_is_named_when_the_run_starts() {
1159 let dir = tempfile::tempdir().unwrap();
1160 let agent = &BUNDLED_AGENTS[0];
1161 install_bundled(agent, dir.path()).unwrap();
1162 let manifest = dir.path().join(agent.name).join("agent.leviath");
1163 let mut blueprint =
1164 leviath_core::manifest::parse_manifest(&std::fs::read_to_string(&manifest).unwrap())
1165 .unwrap();
1166
1167 assert_eq!(
1169 stale_install_note(&manifest, &blueprint, Some(dir.path())),
1170 None
1171 );
1172
1173 blueprint.version = "0.0.1".to_string();
1174 let note = stale_install_note(&manifest, &blueprint, Some(dir.path()))
1175 .expect("a behind install is named");
1176 assert!(note.contains("0.0.1"), "{note}");
1177 assert!(note.contains(agent.version), "{note}");
1178 assert!(note.contains("lev setup"), "{note}");
1179 }
1180
1181 #[test]
1185 fn a_blueprint_that_is_not_the_installed_copy_is_left_alone() {
1186 let dir = tempfile::tempdir().unwrap();
1187 let agent = &BUNDLED_AGENTS[0];
1188 install_bundled(agent, dir.path()).unwrap();
1189 let manifest = dir.path().join(agent.name).join("agent.leviath");
1190 let mut blueprint =
1191 leviath_core::manifest::parse_manifest(&std::fs::read_to_string(&manifest).unwrap())
1192 .unwrap();
1193 blueprint.version = "0.0.1".to_string();
1194
1195 let elsewhere = tempfile::tempdir().unwrap();
1197 let copy = elsewhere.path().join(agent.name).join("agent.leviath");
1198 assert_eq!(
1199 stale_install_note(©, &blueprint, Some(dir.path())),
1200 None,
1201 "not the installed copy"
1202 );
1203
1204 assert_eq!(stale_install_note(&manifest, &blueprint, None), None);
1206
1207 blueprint.name = "not-a-bundled-agent".to_string();
1209 assert_eq!(
1210 stale_install_note(
1211 &dir.path().join("not-a-bundled-agent").join("agent.leviath"),
1212 &blueprint,
1213 Some(dir.path())
1214 ),
1215 None
1216 );
1217 }
1218
1219 #[test]
1222 fn install_writes_every_file_including_nested_ones() {
1223 let dir = tempfile::tempdir().unwrap();
1224 for agent in BUNDLED_AGENTS {
1229 install_bundled(agent, dir.path()).unwrap();
1230 for (rel, contents) in agent.files {
1231 let written = std::fs::read_to_string(dir.path().join(agent.name).join(rel));
1232 assert!(written.is_ok(), "{}/{rel} was not written", agent.name);
1233 assert_eq!(written.expect("asserted Ok just above"), *contents);
1234 }
1235 }
1236 assert!(
1237 BUNDLED_AGENTS
1238 .iter()
1239 .any(|a| a.files.iter().any(|(rel, _)| rel.contains('/'))),
1240 "no bundled blueprint has a nested file, so install's mkdir path is untested"
1241 );
1242 }
1243
1244 #[test]
1245 fn install_replaces_an_existing_tree_and_drops_stale_files() {
1246 let dir = tempfile::tempdir().unwrap();
1247 let agent = &BUNDLED_AGENTS[0];
1248 install_bundled(agent, dir.path()).unwrap();
1249 let stale = dir
1250 .path()
1251 .join(agent.name)
1252 .join("stale-from-an-older-version");
1253 std::fs::write(&stale, "leftover").unwrap();
1254
1255 install_bundled(agent, dir.path()).unwrap();
1256
1257 assert!(
1258 !stale.exists(),
1259 "a reinstall must not leave files from the previous version behind"
1260 );
1261 assert!(dir.path().join(agent.name).join("agent.leviath").exists());
1262 }
1263
1264 #[test]
1265 fn install_surfaces_a_directory_creation_failure() {
1266 let dir = tempfile::tempdir().unwrap();
1269 let blocked = dir.path().join("not-a-dir");
1270 std::fs::write(&blocked, "").unwrap();
1271
1272 let result = install_bundled(&BUNDLED_AGENTS[0], &blocked);
1273
1274 assert!(result.is_err());
1275 }
1276
1277 #[test]
1278 fn install_surfaces_a_file_write_failure() {
1279 let agent = BundledAgent {
1286 name: "collides-with-its-own-directory",
1287 version: "0.0.1",
1288 files: &[("tools/a.rhai", "nested first"), ("tools", "then the dir")],
1289 };
1290 let dir = tempfile::tempdir().unwrap();
1291
1292 let result = install_bundled(&agent, dir.path());
1293
1294 assert!(result.is_err());
1295 }
1296
1297 #[test]
1298 fn install_surfaces_a_remove_failure() {
1299 let dir = tempfile::tempdir().unwrap();
1302 let agent = &BUNDLED_AGENTS[0];
1303 std::fs::write(dir.path().join(agent.name), "").unwrap();
1304
1305 let result = install_bundled(agent, dir.path());
1306
1307 assert!(result.is_err());
1308 }
1309}