lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit ec32440a93480e8f8019ffbdf4b489d19b21a7bc
parent 28fdccb54c7dae642f532d10c2e92a00b3bf1075
Author: triesap <tyson@radroots.org>
Date:   Sat, 11 Apr 2026 17:23:00 +0000

runtime_manager: cover registry and helper branches

Diffstat:
Mcrates/runtime_manager/src/lib.rs | 42++++++++++++++++++++++++++++++++++++++++++
Mcrates/runtime_manager/src/lifecycle.rs | 433++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/runtime_manager/src/paths.rs | 227+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/runtime_manager/src/registry.rs | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 842 insertions(+), 3 deletions(-)

diff --git a/crates/runtime_manager/src/lib.rs b/crates/runtime_manager/src/lib.rs @@ -56,6 +56,16 @@ mod tests { resolve_shared_paths, save_registry, upsert_instance, }; + fn assert_error_contains(err: &crate::RadrootsRuntimeManagerError, parts: &[&str]) { + let rendered = err.to_string(); + for part in parts { + assert!( + rendered.contains(part), + "expected `{rendered}` to contain `{part}`" + ); + } + } + const CONTRACT: &str = r#" schema = "radroots-runtime-management" schema_version = 1 @@ -160,6 +170,22 @@ preferred_cli_binding = true } #[test] + fn parse_contract_reports_invalid_toml() { + let err = parse_contract_str("schema = [").expect_err("invalid toml should fail"); + assert_error_contains(&err, &["parse runtime management contract"]); + } + + #[test] + fn parse_contract_rejects_unexpected_schema() { + let err = parse_contract_str(&CONTRACT.replace( + "schema = \"radroots-runtime-management\"", + "schema = \"wrong-schema\"", + )) + .expect_err("unexpected schema should fail"); + assert_error_contains(&err, &["wrong-schema", crate::RUNTIME_MANAGEMENT_SCHEMA]); + } + + #[test] fn resolve_shared_paths_uses_interactive_user_roots() { let contract = parse_contract_str(CONTRACT).expect("parse contract"); let resolver = RadrootsPathResolver::new( @@ -188,9 +214,25 @@ preferred_cli_binding = true PathBuf::from("/home/treesap/.radroots/data/shared/runtime-manager/installs") ); assert_eq!( + paths.artifact_cache_dir, + PathBuf::from("/home/treesap/.radroots/cache/shared/runtime-manager/artifacts") + ); + assert_eq!( + paths.state_root, + PathBuf::from("/home/treesap/.radroots/data/shared/runtime-manager/state") + ); + assert_eq!( paths.logs_root, PathBuf::from("/home/treesap/.radroots/logs/shared/runtime-manager") ); + assert_eq!( + paths.run_root, + PathBuf::from("/home/treesap/.radroots/run/shared/runtime-manager") + ); + assert_eq!( + paths.secrets_root, + PathBuf::from("/home/treesap/.radroots/secrets/shared/runtime-manager") + ); } #[test] diff --git a/crates/runtime_manager/src/lifecycle.rs b/crates/runtime_manager/src/lifecycle.rs @@ -530,10 +530,14 @@ mod tests { use tempfile::tempdir; use super::{ - ensure_instance_layout, extract_binary_archive, install_binary, process_running, - read_secret_file, remove_instance_artifacts, start_process, stop_process, - write_instance_metadata, write_managed_file, write_secret_file, + ensure_instance_layout, ensure_parent_dir, extract_binary_archive, find_binary_with_name, + force_kill_process, install_binary, open_log_file, process_running, + process_running_for_pid, read_pid, read_secret_file, remove_instance_artifacts, + remove_path_if_exists, set_executable_mode, set_secret_mode, signal_process, start_process, + stop_process, terminate_process, write_instance_metadata, write_managed_file, + write_secret_file, }; + use crate::error::RadrootsRuntimeManagerError; use crate::model::{ManagedRuntimeInstallState, ManagedRuntimeInstanceRecord}; use crate::paths::ManagedRuntimeInstancePaths; @@ -551,6 +555,16 @@ mod tests { } } + fn assert_error_contains(err: &RadrootsRuntimeManagerError, parts: &[&str]) { + let rendered = err.to_string(); + for part in parts { + assert!( + rendered.contains(part), + "expected `{rendered}` to contain `{part}`" + ); + } + } + #[test] fn layout_and_metadata_helpers_write_expected_files() { let dir = tempdir().expect("tempdir"); @@ -627,6 +641,30 @@ mod tests { #[cfg(unix)] #[test] + fn extract_binary_archive_uses_direct_binary_when_present_at_root() { + let dir = tempdir().expect("tempdir"); + let archive_root = dir.path().join("archive"); + fs::create_dir_all(&archive_root).expect("archive dir"); + fs::write(archive_root.join("radrootsd"), "#!/bin/sh\nexit 0\n").expect("binary"); + let archive_path = dir.path().join("radrootsd.tar.gz"); + let file = File::create(&archive_path).expect("archive file"); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + builder + .append_path_with_name(archive_root.join("radrootsd"), "radrootsd") + .expect("append path"); + builder.finish().expect("finish archive"); + let encoder = builder.into_inner().expect("into encoder"); + encoder.finish().expect("finish gzip"); + + let paths = sample_paths(dir.path()); + let installed = + extract_binary_archive(&archive_path, "tar.gz", &paths, "radrootsd").expect("extract"); + assert_eq!(installed, paths.install_dir.join("radrootsd")); + } + + #[cfg(unix)] + #[test] fn start_and_stop_process_manage_pid_file() { let dir = tempdir().expect("tempdir"); let binary = dir.path().join("sleepy.sh"); @@ -654,4 +692,393 @@ mod tests { assert!(!paths.run_dir.exists()); assert!(!paths.secrets_dir.exists()); } + + #[test] + fn ensure_instance_layout_reports_directory_errors() { + let dir = tempdir().expect("tempdir"); + let paths = sample_paths(dir.path()); + fs::write(&paths.install_dir, "occupied").expect("file"); + + let err = ensure_instance_layout(&paths).expect_err("file path should fail"); + assert_error_contains( + &err, + &[ + paths.install_dir.to_string_lossy().as_ref(), + "create directory", + ], + ); + } + + #[test] + fn install_binary_reports_copy_errors() { + let dir = tempdir().expect("tempdir"); + let paths = sample_paths(dir.path()); + let err = install_binary(dir.path().join("missing"), &paths, "radrootsd") + .expect_err("missing source should fail"); + assert_error_contains( + &err, + &[ + dir.path().join("missing").to_string_lossy().as_ref(), + paths + .install_dir + .join("radrootsd") + .to_string_lossy() + .as_ref(), + "copy runtime binary", + ], + ); + } + + #[test] + fn extract_binary_archive_reports_unsupported_format() { + let dir = tempdir().expect("tempdir"); + let paths = sample_paths(dir.path()); + let archive_path = dir.path().join("radrootsd.zip"); + + let err = extract_binary_archive(&archive_path, "zip", &paths, "radrootsd") + .expect_err("unsupported archive format should fail"); + assert_error_contains(&err, &[archive_path.to_string_lossy().as_ref(), "zip"]); + } + + #[test] + fn extract_binary_archive_reports_missing_archive() { + let dir = tempdir().expect("tempdir"); + let paths = sample_paths(dir.path()); + let archive_path = dir.path().join("missing.tar.gz"); + + let err = extract_binary_archive(&archive_path, "tar.gz", &paths, "radrootsd") + .expect_err("missing archive should fail"); + assert_error_contains( + &err, + &[archive_path.to_string_lossy().as_ref(), "read managed file"], + ); + } + + #[cfg(unix)] + #[test] + fn extract_binary_archive_reports_missing_binary_in_archive() { + let dir = tempdir().expect("tempdir"); + let archive_root = dir.path().join("archive"); + fs::create_dir_all(archive_root.join("bin")).expect("archive dir"); + fs::write(archive_root.join("bin/other"), "#!/bin/sh\nexit 0\n").expect("binary"); + let archive_path = dir.path().join("radrootsd.tar.gz"); + let file = File::create(&archive_path).expect("archive file"); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + builder + .append_path_with_name(archive_root.join("bin/other"), "radrootsd/bin/other") + .expect("append path"); + builder.finish().expect("finish archive"); + let encoder = builder.into_inner().expect("into encoder"); + encoder.finish().expect("finish gzip"); + + let paths = sample_paths(dir.path()); + let err = extract_binary_archive(&archive_path, "tar.gz", &paths, "radrootsd") + .expect_err("archive should not resolve missing binary"); + assert_error_contains( + &err, + &[ + paths + .install_dir + .join("radrootsd") + .to_string_lossy() + .as_ref(), + "did not produce", + ], + ); + } + + #[cfg(unix)] + #[test] + fn extract_binary_archive_reports_unpack_errors() { + let dir = tempdir().expect("tempdir"); + let archive_path = dir.path().join("invalid.tar.gz"); + fs::write(&archive_path, "not a gzip archive").expect("write archive"); + let paths = sample_paths(dir.path()); + + let err = extract_binary_archive(&archive_path, "tar.gz", &paths, "radrootsd") + .expect_err("invalid archive should fail"); + assert_error_contains( + &err, + &[archive_path.to_string_lossy().as_ref(), "unpack archive"], + ); + } + + #[test] + fn write_managed_file_reports_write_errors() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("as-dir"); + fs::create_dir(&path).expect("create directory target"); + + let err = write_managed_file(&path, "value").expect_err("directory write should fail"); + assert_error_contains( + &err, + &[path.to_string_lossy().as_ref(), "write managed file"], + ); + } + + #[test] + fn write_secret_file_reports_write_errors() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("as-dir"); + fs::create_dir(&path).expect("create directory target"); + + let err = write_secret_file(&path, "secret").expect_err("directory write should fail"); + assert_error_contains( + &err, + &[path.to_string_lossy().as_ref(), "write managed file"], + ); + } + + #[test] + fn read_secret_file_reports_missing_path() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("missing.secret"); + let err = read_secret_file(&path).expect_err("missing secret should fail"); + assert_error_contains( + &err, + &[path.to_string_lossy().as_ref(), "read managed file"], + ); + } + + #[test] + fn write_instance_metadata_reports_write_errors() { + let dir = tempdir().expect("tempdir"); + let mut paths = sample_paths(dir.path()); + ensure_instance_layout(&paths).expect("layout"); + fs::create_dir_all(&paths.metadata_path).expect("create metadata dir"); + paths.metadata_path = paths.metadata_path.clone(); + + let err = write_instance_metadata( + &paths, + &ManagedRuntimeInstanceRecord { + runtime_id: "radrootsd".to_owned(), + instance_id: "local".to_owned(), + management_mode: "interactive_user_managed".to_owned(), + install_state: ManagedRuntimeInstallState::Configured, + binary_path: paths.install_dir.join("radrootsd"), + config_path: paths.state_dir.join("config.toml"), + logs_path: paths.logs_dir.clone(), + run_path: paths.run_dir.clone(), + installed_version: "0.1.0".to_owned(), + health_endpoint: None, + secret_material_ref: None, + last_started_at: None, + last_stopped_at: None, + notes: None, + }, + ) + .expect_err("metadata dir target should fail"); + assert_error_contains( + &err, + &[ + paths.metadata_path.to_string_lossy().as_ref(), + "write runtime instance metadata", + ], + ); + } + + #[test] + fn start_process_reports_spawn_errors() { + let dir = tempdir().expect("tempdir"); + let paths = sample_paths(dir.path()); + let err = start_process(dir.path().join("missing"), &[], &[], &paths) + .expect_err("missing binary should fail"); + assert_error_contains( + &err, + &[ + dir.path().join("missing").to_string_lossy().as_ref(), + "spawn managed runtime process", + ], + ); + } + + #[cfg(unix)] + #[test] + fn start_process_reports_pid_file_write_errors() { + let dir = tempdir().expect("tempdir"); + let binary = dir.path().join("sleepy.sh"); + fs::write(&binary, "#!/bin/sh\nexec sleep 1\n").expect("script"); + let mut paths = sample_paths(dir.path()); + paths.pid_file_path = paths.run_dir.clone(); + let installed = + install_binary(&binary, &sample_paths(dir.path()), "sleepy.sh").expect("install"); + + let err = + start_process(&installed, &[], &[], &paths).expect_err("pid file write should fail"); + assert_error_contains( + &err, + &[paths.run_dir.to_string_lossy().as_ref(), "write pid file"], + ); + } + + #[test] + fn process_running_and_stop_process_handle_missing_pid_file() { + let dir = tempdir().expect("tempdir"); + let paths = sample_paths(dir.path()); + + assert!(!process_running(&paths).expect("missing pid should be false")); + assert!(!stop_process(&paths).expect("missing pid stop should be false")); + } + + #[test] + fn process_running_reports_invalid_pid_file() { + let dir = tempdir().expect("tempdir"); + let paths = sample_paths(dir.path()); + ensure_instance_layout(&paths).expect("layout"); + fs::write(&paths.pid_file_path, "not-a-pid").expect("write pid"); + + let err = process_running(&paths).expect_err("invalid pid should fail"); + assert_error_contains( + &err, + &[paths.pid_file_path.to_string_lossy().as_ref(), "not-a-pid"], + ); + } + + #[test] + fn stop_process_clears_stale_pid_file() { + let dir = tempdir().expect("tempdir"); + let paths = sample_paths(dir.path()); + ensure_instance_layout(&paths).expect("layout"); + fs::write(&paths.pid_file_path, "999999").expect("write pid"); + + assert!(!stop_process(&paths).expect("stale pid should return false")); + assert!(!paths.pid_file_path.exists()); + } + + #[test] + fn ensure_parent_dir_without_parent_is_a_noop() { + ensure_parent_dir(Path::new("/")).expect("root path should have no parent"); + } + + #[test] + fn ensure_parent_dir_reports_directory_creation_errors() { + let dir = tempdir().expect("tempdir"); + let file_parent = dir.path().join("occupied"); + fs::write(&file_parent, "file").expect("parent file"); + + let err = + ensure_parent_dir(&file_parent.join("child")).expect_err("file parent should fail"); + assert_error_contains( + &err, + &[file_parent.to_string_lossy().as_ref(), "create directory"], + ); + } + + #[test] + fn find_binary_with_name_handles_nested_and_missing_files() { + let dir = tempdir().expect("tempdir"); + fs::create_dir_all(dir.path().join("nested")).expect("nested dir"); + fs::write(dir.path().join("nested/radrootsd"), "binary").expect("binary"); + + assert_eq!( + find_binary_with_name(dir.path(), "radrootsd"), + Some(dir.path().join("nested/radrootsd")) + ); + assert_eq!(find_binary_with_name(dir.path(), "missing"), None); + } + + #[test] + fn open_log_file_creates_file_and_reports_directory_errors() { + let dir = tempdir().expect("tempdir"); + let file_path = dir.path().join("logs/stdout.log"); + let file = open_log_file(&file_path).expect("open log"); + drop(file); + assert!(file_path.is_file()); + + let bad_path = dir.path().join("bad"); + fs::create_dir(&bad_path).expect("create dir"); + let err = open_log_file(&bad_path).expect_err("directory open should fail"); + assert_error_contains( + &err, + &[bad_path.to_string_lossy().as_ref(), "open runtime log file"], + ); + } + + #[test] + fn read_pid_handles_empty_missing_and_read_error_cases() { + let dir = tempdir().expect("tempdir"); + let mut paths = sample_paths(dir.path()); + + assert_eq!(read_pid(&paths).expect("missing pid"), None); + + ensure_instance_layout(&paths).expect("layout"); + fs::write(&paths.pid_file_path, " ").expect("write pid"); + assert_eq!(read_pid(&paths).expect("empty pid"), None); + + paths.pid_file_path = paths.run_dir.clone(); + let err = read_pid(&paths).expect_err("directory pid file should fail"); + assert_error_contains( + &err, + &[paths.run_dir.to_string_lossy().as_ref(), "read pid file"], + ); + } + + #[test] + fn remove_path_if_exists_handles_files_directories_and_missing_paths() { + let dir = tempdir().expect("tempdir"); + let file_path = dir.path().join("file.txt"); + let dir_path = dir.path().join("subdir"); + fs::write(&file_path, "data").expect("file"); + fs::create_dir(&dir_path).expect("dir"); + + remove_path_if_exists(&file_path).expect("remove file"); + remove_path_if_exists(&dir_path).expect("remove dir"); + remove_path_if_exists(dir.path().join("missing").as_path()).expect("remove missing"); + + assert!(!file_path.exists()); + assert!(!dir_path.exists()); + } + + #[test] + fn remove_pid_file_reports_directory_errors() { + let dir = tempdir().expect("tempdir"); + let mut paths = sample_paths(dir.path()); + ensure_instance_layout(&paths).expect("layout"); + paths.pid_file_path = paths.run_dir.clone(); + + let err = super::remove_pid_file(&paths).expect_err("directory pid path should fail"); + assert_error_contains( + &err, + &[ + paths.run_dir.to_string_lossy().as_ref(), + "remove managed path", + ], + ); + } + + #[cfg(unix)] + #[test] + fn set_mode_helpers_report_missing_path_errors() { + let dir = tempdir().expect("tempdir"); + let missing = dir.path().join("missing"); + + let err = set_executable_mode(&missing).expect_err("missing executable should fail"); + assert_error_contains( + &err, + &[missing.to_string_lossy().as_ref(), "read managed file"], + ); + + let err = set_secret_mode(&missing).expect_err("missing secret should fail"); + assert_error_contains( + &err, + &[missing.to_string_lossy().as_ref(), "read managed file"], + ); + } + + #[cfg(unix)] + #[test] + fn signal_helpers_cover_failure_paths() { + let missing_pid = 999_999_u32; + assert!(!process_running_for_pid(missing_pid)); + + let err = terminate_process(missing_pid).expect_err("terminate should fail"); + assert_error_contains(&err, &[&missing_pid.to_string(), "stop pid"]); + + let err = force_kill_process(missing_pid).expect_err("force kill should fail"); + assert_error_contains(&err, &[&missing_pid.to_string(), "stop pid"]); + + let err = signal_process(missing_pid, "-BOGUS").expect_err("invalid signal should fail"); + assert_error_contains(&err, &[&missing_pid.to_string(), "stop pid"]); + } } diff --git a/crates/runtime_manager/src/paths.rs b/crates/runtime_manager/src/paths.rs @@ -155,3 +155,230 @@ fn root_class_path( }; Ok(base.join(rel)) } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use radroots_runtime_paths::{ + RadrootsHostEnvironment, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPaths, RadrootsPlatform, + }; + + use super::{bootstrap_runtime, resolve_shared_paths, root_class_path}; + use crate::{ + RadrootsRuntimeManagerError, model::RadrootsRuntimeManagementContract, parse_contract_str, + }; + + const CONTRACT: &str = r#" +schema = "radroots-runtime-management" +schema_version = 1 +owner_doc = "docs/migration/radroots-modular-runtime-management-bootstrap-rcl.md" +runtime_registry = "registry.toml" +distribution_contract = "distribution.toml" +capabilities_contract = "capabilities.toml" + +[defaults] +instance_cardinality = "single_default_instance" +managed_runtime_lookup = "shared_instance_registry" +explicit_runtime_endpoint_overrides_precede_managed_instance_binding = true +global_path_mutation_forbidden = true + +[management_clients] +active = ["cli"] +defined = ["community-app-desktop"] + +[managed_runtime_targets] +active = ["radrootsd"] +defined = ["myc", "rhi"] +bootstrap_only = ["hyf"] + +[lifecycle] +actions = ["install", "uninstall", "start"] +destructive_actions = ["uninstall"] +health_states = ["not_installed", "running"] + +[mode.interactive_user_managed] +contract_state = "active" +platforms = ["linux", "macos", "windows"] +supported_profiles = ["interactive_user", "repo_local"] +service_manager_integration = false +uses_absolute_binary_paths = true +requires_explicit_pid_tracking = true +requires_explicit_log_tracking = true +default_instance_cardinality = "single_default_instance" + +[mode.service_host_managed] +contract_state = "defined" +platforms = ["linux", "macos", "windows"] +supported_profiles = ["service_host"] +service_manager_integration = true +uses_absolute_binary_paths = true +default_instance_cardinality = "single_default_instance" + +[paths.interactive_user_managed] +shared_namespace = "shared/runtime-manager" +instance_registry_root_class = "config" +instance_registry_rel = "shared/runtime-manager/instances.toml" +artifact_cache_root_class = "cache" +artifact_cache_rel = "shared/runtime-manager/artifacts" +install_root_class = "data" +install_root_rel = "shared/runtime-manager/installs" +state_root_class = "data" +state_root_rel = "shared/runtime-manager/state" +logs_root_class = "logs" +logs_root_rel = "shared/runtime-manager" +run_root_class = "run" +run_root_rel = "shared/runtime-manager" +secrets_root_class = "secrets" +secrets_namespace_rel = "shared/runtime-manager" + +[instance_metadata] +required_fields = ["runtime_id"] +optional_fields = ["notes"] + +[bootstrap.radrootsd] +runtime_id = "radrootsd" +management_mode = "interactive_user_managed" +default_instance_id = "local" +install_strategy = "archive_unpack" +config_format = "toml" +requires_bootstrap_secret = true +requires_config_bootstrap = true +requires_signer_provider = false +health_surface = "jsonrpc_status" +preferred_cli_binding = true +"#; + + fn contract() -> RadrootsRuntimeManagementContract { + parse_contract_str(CONTRACT).expect("parse contract") + } + + fn assert_error_contains(err: &RadrootsRuntimeManagerError, parts: &[&str]) { + let rendered = err.to_string(); + for part in parts { + assert!( + rendered.contains(part), + "expected `{rendered}` to contain `{part}`" + ); + } + } + + fn linux_resolver() -> RadrootsPathResolver { + RadrootsPathResolver::new( + RadrootsPlatform::Linux, + RadrootsHostEnvironment { + home_dir: Some(PathBuf::from("/home/treesap")), + ..RadrootsHostEnvironment::default() + }, + ) + } + + #[test] + fn bootstrap_lookup_reports_unknown_runtime() { + let err = bootstrap_runtime(&contract(), "missing-runtime").expect_err("missing runtime"); + assert_error_contains(&err, &["missing-runtime", "no bootstrap entry"]); + } + + #[test] + fn resolve_shared_paths_reports_unknown_management_mode() { + let err = resolve_shared_paths( + &contract(), + &linux_resolver(), + RadrootsPathProfile::InteractiveUser, + &RadrootsPathOverrides::default(), + "missing-mode", + ) + .expect_err("missing mode should fail"); + assert_error_contains(&err, &["management mode `missing-mode`"]); + } + + #[test] + fn resolve_shared_paths_reports_unsupported_profile() { + let err = resolve_shared_paths( + &contract(), + &linux_resolver(), + RadrootsPathProfile::ServiceHost, + &RadrootsPathOverrides::default(), + "interactive_user_managed", + ) + .expect_err("service_host should be unsupported for interactive mode"); + assert_error_contains(&err, &["interactive_user_managed", "service_host"]); + } + + #[test] + fn resolve_shared_paths_reports_missing_path_spec() { + let mut contract = contract(); + contract.paths.remove("interactive_user_managed"); + + let err = resolve_shared_paths( + &contract, + &linux_resolver(), + RadrootsPathProfile::InteractiveUser, + &RadrootsPathOverrides::default(), + "interactive_user_managed", + ) + .expect_err("missing path spec should fail"); + assert_error_contains( + &err, + &["interactive_user_managed", "no shared path specification"], + ); + } + + #[test] + fn resolve_shared_paths_reports_unknown_root_class() { + let mut contract = contract(); + contract + .paths + .get_mut("interactive_user_managed") + .expect("path spec") + .instance_registry_root_class = "bogus".to_string(); + + let err = resolve_shared_paths( + &contract, + &linux_resolver(), + RadrootsPathProfile::InteractiveUser, + &RadrootsPathOverrides::default(), + "interactive_user_managed", + ) + .expect_err("unknown root class should fail"); + assert_error_contains(&err, &["unknown root class `bogus`"]); + } + + #[test] + fn root_class_path_maps_all_known_classes() { + let roots = RadrootsPaths { + config: PathBuf::from("/roots/config"), + data: PathBuf::from("/roots/data"), + cache: PathBuf::from("/roots/cache"), + logs: PathBuf::from("/roots/logs"), + run: PathBuf::from("/roots/run"), + secrets: PathBuf::from("/roots/secrets"), + }; + + assert_eq!( + root_class_path(&roots, "config", "a/b").expect("config root"), + PathBuf::from("/roots/config/a/b") + ); + assert_eq!( + root_class_path(&roots, "data", "a/b").expect("data root"), + PathBuf::from("/roots/data/a/b") + ); + assert_eq!( + root_class_path(&roots, "cache", "a/b").expect("cache root"), + PathBuf::from("/roots/cache/a/b") + ); + assert_eq!( + root_class_path(&roots, "logs", "a/b").expect("logs root"), + PathBuf::from("/roots/logs/a/b") + ); + assert_eq!( + root_class_path(&roots, "run", "a/b").expect("run root"), + PathBuf::from("/roots/run/a/b") + ); + assert_eq!( + root_class_path(&roots, "secrets", "a/b").expect("secrets root"), + PathBuf::from("/roots/secrets/a/b") + ); + } +} diff --git a/crates/runtime_manager/src/registry.rs b/crates/runtime_manager/src/registry.rs @@ -91,3 +91,146 @@ pub fn remove_instance( .position(|record| record.runtime_id == runtime_id && record.instance_id == instance_id)?; Some(registry.instances.remove(index)) } + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + + use tempfile::tempdir; + + use super::{instance, load_registry, remove_instance, save_registry, upsert_instance}; + use crate::{ + ManagedRuntimeInstallState, ManagedRuntimeInstanceRecord, ManagedRuntimeInstanceRegistry, + RadrootsRuntimeManagerError, + }; + + fn sample_record(runtime_id: &str, instance_id: &str) -> ManagedRuntimeInstanceRecord { + ManagedRuntimeInstanceRecord { + runtime_id: runtime_id.to_string(), + instance_id: instance_id.to_string(), + management_mode: "interactive_user_managed".to_string(), + install_state: ManagedRuntimeInstallState::Configured, + binary_path: PathBuf::from("/tmp/radrootsd"), + config_path: PathBuf::from("/tmp/config.toml"), + logs_path: PathBuf::from("/tmp/logs"), + run_path: PathBuf::from("/tmp/run"), + installed_version: "0.1.0-alpha.1".to_string(), + health_endpoint: Some("jsonrpc_status".to_string()), + secret_material_ref: None, + last_started_at: None, + last_stopped_at: None, + notes: Some("test".to_string()), + } + } + + fn assert_error_contains(err: &RadrootsRuntimeManagerError, parts: &[&str]) { + let rendered = err.to_string(); + for part in parts { + assert!( + rendered.contains(part), + "expected `{rendered}` to contain `{part}`" + ); + } + } + + #[test] + fn load_registry_returns_default_when_file_is_missing() { + let dir = tempdir().expect("tempdir"); + let registry = load_registry(dir.path().join("missing.toml")).expect("missing registry"); + assert_eq!(registry, ManagedRuntimeInstanceRegistry::default()); + } + + #[test] + fn load_registry_reports_read_errors() { + let dir = tempdir().expect("tempdir"); + let err = load_registry(dir.path()).expect_err("directory should fail"); + assert_error_contains( + &err, + &[ + dir.path().to_string_lossy().as_ref(), + "read runtime instance registry", + ], + ); + } + + #[test] + fn load_registry_reports_parse_errors() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("instances.toml"); + fs::write(&path, "not = [valid").expect("write invalid registry"); + + let err = load_registry(&path).expect_err("invalid registry should fail"); + assert_error_contains( + &err, + &[ + path.to_string_lossy().as_ref(), + "parse runtime instance registry", + ], + ); + } + + #[test] + fn save_registry_reports_write_errors() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("registry-dir"); + fs::create_dir(&path).expect("create directory target"); + + let err = save_registry(&path, &ManagedRuntimeInstanceRegistry::default()) + .expect_err("directory path should fail"); + assert_error_contains( + &err, + &[ + path.to_string_lossy().as_ref(), + "write runtime instance registry", + ], + ); + } + + #[test] + fn save_registry_reports_parent_creation_errors() { + let dir = tempdir().expect("tempdir"); + let file_parent = dir.path().join("occupied"); + fs::write(&file_parent, "file").expect("occupied parent"); + let path = file_parent.join("instances.toml"); + + let err = save_registry(&path, &ManagedRuntimeInstanceRegistry::default()) + .expect_err("file parent should fail"); + assert_error_contains( + &err, + &[ + file_parent.to_string_lossy().as_ref(), + "create runtime instance registry parent", + ], + ); + } + + #[test] + fn upsert_instance_replaces_existing_and_sorts_new_records() { + let mut registry = ManagedRuntimeInstanceRegistry::default(); + upsert_instance(&mut registry, sample_record("radrootsd", "b")); + upsert_instance(&mut registry, sample_record("myc", "a")); + + let mut replacement = sample_record("radrootsd", "b"); + replacement.installed_version = "0.2.0".to_string(); + upsert_instance(&mut registry, replacement); + + assert_eq!(registry.instances.len(), 2); + assert_eq!(registry.instances[0].runtime_id, "myc"); + assert_eq!(registry.instances[1].runtime_id, "radrootsd"); + assert_eq!(registry.instances[1].installed_version, "0.2.0"); + } + + #[test] + fn instance_and_remove_instance_handle_missing_and_present_rows() { + let mut registry = ManagedRuntimeInstanceRegistry::default(); + upsert_instance(&mut registry, sample_record("radrootsd", "local")); + + assert!(instance(&registry, "myc", "local").is_none()); + assert!(remove_instance(&mut registry, "myc", "local").is_none()); + + let removed = remove_instance(&mut registry, "radrootsd", "local").expect("remove"); + assert_eq!(removed.runtime_id, "radrootsd"); + assert!(registry.instances.is_empty()); + } +}