lib

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

service.rs (23473B)


      1 use std::{ffi::OsString, path::PathBuf};
      2 
      3 use serde::Serialize;
      4 use thiserror::Error;
      5 
      6 use crate::{
      7     RadrootsMigrationReport, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver,
      8     RadrootsPaths, RadrootsRuntimeNamespace, RadrootsRuntimePathsError,
      9 };
     10 
     11 #[derive(Debug, Clone, PartialEq, Eq)]
     12 pub struct RadrootsRuntimePathSelection {
     13     pub profile: RadrootsPathProfile,
     14     pub profile_source: String,
     15     pub repo_local_root: Option<PathBuf>,
     16     pub repo_local_root_source: Option<String>,
     17 }
     18 
     19 #[derive(Debug, Clone, PartialEq, Eq)]
     20 pub struct RadrootsRuntimePathConfigEntry {
     21     pub key: String,
     22     pub value: String,
     23     pub source_label: String,
     24 }
     25 
     26 impl RadrootsRuntimePathConfigEntry {
     27     #[must_use]
     28     pub fn new(
     29         key: impl Into<String>,
     30         value: impl Into<String>,
     31         source_label: impl Into<String>,
     32     ) -> Self {
     33         Self {
     34             key: key.into(),
     35             value: value.into(),
     36             source_label: source_label.into(),
     37         }
     38     }
     39 }
     40 
     41 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     42 pub struct RadrootsRuntimeSelectionContract {
     43     pub active_profile: String,
     44     pub allowed_profiles: Vec<String>,
     45     pub path_overrides: RadrootsRuntimeSelectionOverrideContract,
     46 }
     47 
     48 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     49 pub struct RadrootsRuntimeSelectionOverrideContract {
     50     pub profile_source: String,
     51     pub root_source: String,
     52     #[serde(skip_serializing_if = "Option::is_none")]
     53     pub repo_local_root: Option<PathBuf>,
     54     #[serde(skip_serializing_if = "Option::is_none")]
     55     pub repo_local_root_source: Option<String>,
     56     pub subordinate_path_override_source: String,
     57     pub subordinate_path_override_keys: Vec<String>,
     58 }
     59 
     60 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     61 pub struct RadrootsRuntimePathPolicyContract {
     62     pub canonical_root_selection: String,
     63     pub canonical_subordinate_path_override: String,
     64     pub leaf_path_env_posture: String,
     65     pub compatibility_leaf_path_keys: Vec<String>,
     66 }
     67 
     68 impl RadrootsRuntimePathPolicyContract {
     69     pub fn new(
     70         canonical_root_selection: &str,
     71         canonical_subordinate_path_override: &str,
     72         leaf_path_env_posture: &str,
     73         compatibility_leaf_path_keys: &[&str],
     74     ) -> Self {
     75         Self {
     76             canonical_root_selection: canonical_root_selection.to_owned(),
     77             canonical_subordinate_path_override: canonical_subordinate_path_override.to_owned(),
     78             leaf_path_env_posture: leaf_path_env_posture.to_owned(),
     79             compatibility_leaf_path_keys: compatibility_leaf_path_keys
     80                 .iter()
     81                 .map(|entry| (*entry).to_owned())
     82                 .collect(),
     83         }
     84     }
     85 }
     86 
     87 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     88 pub struct RadrootsRuntimeMigrationContract {
     89     pub posture: String,
     90     pub state: String,
     91     pub silent_startup_relocation: bool,
     92     pub compatibility_window: String,
     93     pub detected_legacy_paths: Vec<RadrootsRuntimeLegacyPathContract>,
     94 }
     95 
     96 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     97 pub struct RadrootsRuntimeLegacyPathContract {
     98     pub id: String,
     99     pub description: String,
    100     pub path: PathBuf,
    101     #[serde(skip_serializing_if = "Option::is_none")]
    102     pub destination: Option<PathBuf>,
    103     pub import_hint: String,
    104 }
    105 
    106 pub fn runtime_migration_contract(
    107     report: RadrootsMigrationReport,
    108 ) -> RadrootsRuntimeMigrationContract {
    109     RadrootsRuntimeMigrationContract {
    110         posture: report.posture.to_owned(),
    111         state: report.state.to_owned(),
    112         silent_startup_relocation: report.silent_startup_relocation,
    113         compatibility_window: report.compatibility_window.to_owned(),
    114         detected_legacy_paths: report
    115             .detected_legacy_paths
    116             .into_iter()
    117             .map(|path| RadrootsRuntimeLegacyPathContract {
    118                 id: path.id,
    119                 description: path.description,
    120                 path: path.path,
    121                 destination: path.destination,
    122                 import_hint: path.import_hint,
    123             })
    124             .collect(),
    125     }
    126 }
    127 
    128 #[derive(Debug, Error, Clone, PartialEq, Eq)]
    129 pub enum RadrootsRuntimePathSelectionError {
    130     #[error("{env_var} must be valid utf-8 when set")]
    131     NonUnicodeEnv { env_var: String },
    132 
    133     #[error(
    134         "{env_var} must be `interactive_user`, `service_host`, or `repo_local`; found `{value}`"
    135     )]
    136     InvalidProfileEnv { env_var: String, value: String },
    137 
    138     #[error(
    139         "profile must be `interactive_user`, `service_host`, `repo_local`, or `mobile_native`; found `{value}`"
    140     )]
    141     InvalidProfileValue { value: String },
    142 
    143     #[error("{repo_local_root_env} must be set when {profile_env}=repo_local")]
    144     MissingRepoLocalRoot {
    145         profile_env: String,
    146         repo_local_root_env: String,
    147     },
    148 
    149     #[error(transparent)]
    150     Paths(#[from] RadrootsRuntimePathsError),
    151 }
    152 
    153 impl RadrootsRuntimePathSelection {
    154     pub fn caller(profile: RadrootsPathProfile, repo_local_root: Option<PathBuf>) -> Self {
    155         Self {
    156             profile,
    157             profile_source: "caller".to_owned(),
    158             repo_local_root_source: repo_local_root.as_ref().map(|_| "caller".to_owned()),
    159             repo_local_root,
    160         }
    161     }
    162 
    163     pub fn from_profile_value(
    164         profile: &str,
    165         repo_local_root: Option<PathBuf>,
    166     ) -> Result<Self, RadrootsRuntimePathSelectionError> {
    167         Ok(Self::caller(parse_profile_value(profile)?, repo_local_root))
    168     }
    169 
    170     pub fn from_config_entries(
    171         profile_entry: Option<RadrootsRuntimePathConfigEntry>,
    172         repo_local_root_entry: Option<RadrootsRuntimePathConfigEntry>,
    173         default_profile: RadrootsPathProfile,
    174     ) -> Result<Self, RadrootsRuntimePathSelectionError> {
    175         let (profile, profile_source) = match profile_entry {
    176             Some(entry) => (
    177                 parse_profile(entry.key.as_str(), entry.value.as_str())?,
    178                 entry.source_label,
    179             ),
    180             None => (default_profile, "default".to_owned()),
    181         };
    182         let (repo_local_root, repo_local_root_source) = match repo_local_root_entry {
    183             Some(entry) => (Some(PathBuf::from(entry.value)), Some(entry.source_label)),
    184             None => (None, None),
    185         };
    186         Ok(Self {
    187             profile,
    188             profile_source,
    189             repo_local_root,
    190             repo_local_root_source,
    191         })
    192     }
    193 
    194     pub fn from_env(
    195         profile_env: &'static str,
    196         repo_local_root_env: &'static str,
    197         default_profile: RadrootsPathProfile,
    198     ) -> Result<Self, RadrootsRuntimePathSelectionError> {
    199         Self::from_env_values(
    200             profile_env,
    201             std::env::var(profile_env),
    202             repo_local_root_env,
    203             std::env::var_os(repo_local_root_env),
    204             default_profile,
    205         )
    206     }
    207 
    208     fn from_env_values(
    209         profile_env: &'static str,
    210         profile_value: Result<String, std::env::VarError>,
    211         repo_local_root_env: &'static str,
    212         repo_local_root_raw: Option<OsString>,
    213         default_profile: RadrootsPathProfile,
    214     ) -> Result<Self, RadrootsRuntimePathSelectionError> {
    215         let (profile, profile_source) = match profile_value {
    216             Ok(value) => (
    217                 parse_profile(profile_env, value.as_str())?,
    218                 format!("process_env:{profile_env}"),
    219             ),
    220             Err(std::env::VarError::NotPresent) => (default_profile, "default".to_owned()),
    221             Err(std::env::VarError::NotUnicode(_)) => {
    222                 return Err(RadrootsRuntimePathSelectionError::NonUnicodeEnv {
    223                     env_var: profile_env.to_owned(),
    224                 });
    225             }
    226         };
    227         let repo_local_root = repo_local_root_raw.as_ref().map(PathBuf::from);
    228         Ok(Self {
    229             profile,
    230             profile_source,
    231             repo_local_root,
    232             repo_local_root_source: repo_local_root_raw
    233                 .as_ref()
    234                 .map(|_| format!("process_env:{repo_local_root_env}")),
    235         })
    236     }
    237 
    238     pub fn root_source(&self) -> &'static str {
    239         match self.profile {
    240             RadrootsPathProfile::InteractiveUser => "host_defaults",
    241             RadrootsPathProfile::ServiceHost => "service_host_defaults",
    242             RadrootsPathProfile::RepoLocal => "repo_local_root",
    243             RadrootsPathProfile::MobileNative => "mobile_native_defaults",
    244         }
    245     }
    246 
    247     pub fn overrides(
    248         &self,
    249         profile_env: &'static str,
    250         repo_local_root_env: &'static str,
    251     ) -> Result<RadrootsPathOverrides, RadrootsRuntimePathSelectionError> {
    252         self.overrides_with_labels(profile_env, repo_local_root_env)
    253     }
    254 
    255     pub fn caller_overrides(
    256         &self,
    257     ) -> Result<RadrootsPathOverrides, RadrootsRuntimePathSelectionError> {
    258         self.overrides_with_labels("caller_profile", "caller_repo_local_root")
    259     }
    260 
    261     pub fn contract(
    262         &self,
    263         allowed_profiles: &[&str],
    264         subordinate_path_override_source: &str,
    265         subordinate_path_override_keys: &[&str],
    266     ) -> RadrootsRuntimeSelectionContract {
    267         RadrootsRuntimeSelectionContract {
    268             active_profile: self.profile.to_string(),
    269             allowed_profiles: allowed_profiles
    270                 .iter()
    271                 .map(|entry| (*entry).to_owned())
    272                 .collect(),
    273             path_overrides: RadrootsRuntimeSelectionOverrideContract {
    274                 profile_source: self.profile_source.clone(),
    275                 root_source: self.root_source().to_owned(),
    276                 repo_local_root: self.repo_local_root.clone(),
    277                 repo_local_root_source: self.repo_local_root_source.clone(),
    278                 subordinate_path_override_source: subordinate_path_override_source.to_owned(),
    279                 subordinate_path_override_keys: subordinate_path_override_keys
    280                     .iter()
    281                     .map(|entry| (*entry).to_owned())
    282                     .collect(),
    283             },
    284         }
    285     }
    286 
    287     fn overrides_with_labels(
    288         &self,
    289         profile_label: &str,
    290         repo_local_root_label: &str,
    291     ) -> Result<RadrootsPathOverrides, RadrootsRuntimePathSelectionError> {
    292         match self.profile {
    293             RadrootsPathProfile::RepoLocal => {
    294                 let Some(repo_local_root) = self.repo_local_root.as_ref() else {
    295                     return Err(RadrootsRuntimePathSelectionError::MissingRepoLocalRoot {
    296                         profile_env: profile_label.to_owned(),
    297                         repo_local_root_env: repo_local_root_label.to_owned(),
    298                     });
    299                 };
    300                 Ok(RadrootsPathOverrides::repo_local(repo_local_root))
    301             }
    302             _ => Ok(RadrootsPathOverrides::default()),
    303         }
    304     }
    305 
    306     pub fn resolve_service_roots(
    307         &self,
    308         resolver: &RadrootsPathResolver,
    309         service_id: &str,
    310         profile_env: &'static str,
    311         repo_local_root_env: &'static str,
    312     ) -> Result<RadrootsPaths, RadrootsRuntimePathSelectionError> {
    313         let namespace = RadrootsRuntimeNamespace::service(service_id)?;
    314         let overrides = self.overrides(profile_env, repo_local_root_env)?;
    315         let roots = resolver.resolve(self.profile, &overrides)?;
    316         Ok(roots.namespaced(&namespace))
    317     }
    318 }
    319 
    320 fn parse_profile(
    321     env_var: &str,
    322     value: &str,
    323 ) -> Result<RadrootsPathProfile, RadrootsRuntimePathSelectionError> {
    324     parse_profile_value(value).map_err(|_| RadrootsRuntimePathSelectionError::InvalidProfileEnv {
    325         env_var: env_var.to_owned(),
    326         value: value.to_owned(),
    327     })
    328 }
    329 
    330 fn parse_profile_value(
    331     value: &str,
    332 ) -> Result<RadrootsPathProfile, RadrootsRuntimePathSelectionError> {
    333     match value {
    334         "interactive_user" => Ok(RadrootsPathProfile::InteractiveUser),
    335         "service_host" => Ok(RadrootsPathProfile::ServiceHost),
    336         "repo_local" => Ok(RadrootsPathProfile::RepoLocal),
    337         "mobile_native" => Ok(RadrootsPathProfile::MobileNative),
    338         other => Err(RadrootsRuntimePathSelectionError::InvalidProfileValue {
    339             value: other.to_owned(),
    340         }),
    341     }
    342 }
    343 
    344 #[cfg(test)]
    345 mod tests {
    346     use std::path::PathBuf;
    347 
    348     use crate::{
    349         RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform,
    350     };
    351 
    352     use super::{
    353         RadrootsRuntimePathConfigEntry, RadrootsRuntimePathPolicyContract,
    354         RadrootsRuntimePathSelection, RadrootsRuntimePathSelectionError,
    355         runtime_migration_contract,
    356     };
    357     use crate::{RadrootsLegacyPathDetection, RadrootsMigrationReport};
    358 
    359     #[test]
    360     fn caller_selection_preserves_profile_and_sources() {
    361         let selection =
    362             RadrootsRuntimePathSelection::caller(RadrootsPathProfile::InteractiveUser, None);
    363         assert_eq!(selection.profile, RadrootsPathProfile::InteractiveUser);
    364         assert_eq!(selection.profile_source, "caller");
    365         assert_eq!(selection.repo_local_root, None);
    366         assert_eq!(selection.repo_local_root_source, None);
    367         assert_eq!(selection.root_source(), "host_defaults");
    368 
    369         let overrides = selection
    370             .caller_overrides()
    371             .expect("non-repo-local caller overrides should be empty");
    372         assert_eq!(overrides.repo_local_root, None);
    373 
    374         let service_selection =
    375             RadrootsRuntimePathSelection::caller(RadrootsPathProfile::ServiceHost, None);
    376         assert_eq!(service_selection.root_source(), "service_host_defaults");
    377     }
    378 
    379     #[test]
    380     fn caller_selection_marks_repo_local_source() {
    381         let selection = RadrootsRuntimePathSelection::caller(
    382             RadrootsPathProfile::RepoLocal,
    383             Some(PathBuf::from("/repo/.local/radroots")),
    384         );
    385 
    386         assert_eq!(selection.profile_source, "caller");
    387         assert_eq!(selection.repo_local_root_source.as_deref(), Some("caller"));
    388         assert_eq!(selection.root_source(), "repo_local_root");
    389 
    390         let overrides = selection
    391             .caller_overrides()
    392             .expect("caller overrides should use repo-local root");
    393         assert_eq!(
    394             overrides.repo_local_root,
    395             Some(PathBuf::from("/repo/.local/radroots"))
    396         );
    397     }
    398 
    399     #[test]
    400     fn resolve_service_roots_uses_repo_local_override() {
    401         let selection = RadrootsRuntimePathSelection::caller(
    402             RadrootsPathProfile::RepoLocal,
    403             Some(PathBuf::from("/repo/.local/radroots")),
    404         );
    405         let resolver =
    406             RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default());
    407 
    408         let roots = selection
    409             .resolve_service_roots(&resolver, "radrootsd", "PROFILE_ENV", "ROOT_ENV")
    410             .expect("service roots");
    411 
    412         assert_eq!(
    413             roots.config,
    414             PathBuf::from("/repo/.local/radroots/config/services/radrootsd")
    415         );
    416         assert_eq!(
    417             roots.data,
    418             PathBuf::from("/repo/.local/radroots/data/services/radrootsd")
    419         );
    420         assert_eq!(
    421             roots.logs,
    422             PathBuf::from("/repo/.local/radroots/logs/services/radrootsd")
    423         );
    424         assert_eq!(
    425             roots.run,
    426             PathBuf::from("/repo/.local/radroots/run/services/radrootsd")
    427         );
    428         assert_eq!(
    429             roots.secrets,
    430             PathBuf::from("/repo/.local/radroots/secrets/services/radrootsd")
    431         );
    432     }
    433 
    434     #[test]
    435     fn overrides_require_repo_local_root_for_repo_local_profile() {
    436         let selection = RadrootsRuntimePathSelection::caller(RadrootsPathProfile::RepoLocal, None);
    437         let err = selection
    438             .overrides("RADROOTS_TEST_PROFILE", "RADROOTS_TEST_ROOT")
    439             .expect_err("repo local root");
    440 
    441         assert_eq!(
    442             err,
    443             RadrootsRuntimePathSelectionError::MissingRepoLocalRoot {
    444                 profile_env: "RADROOTS_TEST_PROFILE".to_owned(),
    445                 repo_local_root_env: "RADROOTS_TEST_ROOT".to_owned(),
    446             }
    447         );
    448     }
    449 
    450     #[test]
    451     fn profile_value_selection_accepts_mobile_native() {
    452         let selection = RadrootsRuntimePathSelection::from_profile_value("mobile_native", None)
    453             .expect("mobile native profile");
    454 
    455         assert_eq!(selection.profile, RadrootsPathProfile::MobileNative);
    456         assert_eq!(selection.profile_source, "caller");
    457         assert_eq!(selection.root_source(), "mobile_native_defaults");
    458 
    459         let selection = RadrootsRuntimePathSelection::from_profile_value("interactive_user", None)
    460             .expect("interactive profile");
    461         assert_eq!(selection.profile, RadrootsPathProfile::InteractiveUser);
    462 
    463         let selection = RadrootsRuntimePathSelection::from_profile_value("service_host", None)
    464             .expect("service-host profile");
    465         assert_eq!(selection.profile, RadrootsPathProfile::ServiceHost);
    466     }
    467 
    468     #[test]
    469     fn env_selection_covers_absent_present_and_error_paths() {
    470         let selection = RadrootsRuntimePathSelection::from_env(
    471             "RADROOTS_RUNTIME_PATHS_TEST_UNSET_PROFILE_DFA3ED5D",
    472             "RADROOTS_RUNTIME_PATHS_TEST_UNSET_ROOT_DFA3ED5D",
    473             RadrootsPathProfile::ServiceHost,
    474         )
    475         .expect("absent env selection should use default profile");
    476         assert_eq!(selection.profile, RadrootsPathProfile::ServiceHost);
    477         assert_eq!(selection.profile_source, "default");
    478         assert_eq!(selection.repo_local_root, None);
    479         assert_eq!(selection.repo_local_root_source, None);
    480 
    481         let selection = RadrootsRuntimePathSelection::from_env_values(
    482             "RADROOTS_TEST_PROFILE",
    483             Ok("repo_local".to_owned()),
    484             "RADROOTS_TEST_ROOT",
    485             Some(std::ffi::OsString::from("/repo/.local/radroots")),
    486             RadrootsPathProfile::InteractiveUser,
    487         )
    488         .expect("present env values should select repo-local profile");
    489         assert_eq!(selection.profile, RadrootsPathProfile::RepoLocal);
    490         assert_eq!(
    491             selection.profile_source,
    492             "process_env:RADROOTS_TEST_PROFILE"
    493         );
    494         assert_eq!(
    495             selection.repo_local_root,
    496             Some(PathBuf::from("/repo/.local/radroots"))
    497         );
    498         assert_eq!(
    499             selection.repo_local_root_source.as_deref(),
    500             Some("process_env:RADROOTS_TEST_ROOT")
    501         );
    502 
    503         let err = RadrootsRuntimePathSelection::from_env_values(
    504             "RADROOTS_TEST_PROFILE",
    505             Err(std::env::VarError::NotUnicode(std::ffi::OsString::from(
    506                 "not-unicode",
    507             ))),
    508             "RADROOTS_TEST_ROOT",
    509             None,
    510             RadrootsPathProfile::InteractiveUser,
    511         )
    512         .expect_err("non-unicode profile env should fail");
    513         assert_eq!(
    514             err,
    515             RadrootsRuntimePathSelectionError::NonUnicodeEnv {
    516                 env_var: "RADROOTS_TEST_PROFILE".to_owned()
    517             }
    518         );
    519 
    520         let err = RadrootsRuntimePathSelection::from_env_values(
    521             "RADROOTS_TEST_PROFILE",
    522             Ok("unknown".to_owned()),
    523             "RADROOTS_TEST_ROOT",
    524             None,
    525             RadrootsPathProfile::InteractiveUser,
    526         )
    527         .expect_err("invalid profile env should fail");
    528         assert_eq!(
    529             err,
    530             RadrootsRuntimePathSelectionError::InvalidProfileEnv {
    531                 env_var: "RADROOTS_TEST_PROFILE".to_owned(),
    532                 value: "unknown".to_owned()
    533             }
    534         );
    535     }
    536 
    537     #[test]
    538     fn config_entry_selection_preserves_sources() {
    539         let selection = RadrootsRuntimePathSelection::from_config_entries(
    540             Some(RadrootsRuntimePathConfigEntry::new(
    541                 "RADROOTS_CLI_PATHS_PROFILE",
    542                 "repo_local",
    543                 "env_file:RADROOTS_CLI_PATHS_PROFILE",
    544             )),
    545             Some(RadrootsRuntimePathConfigEntry::new(
    546                 "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT",
    547                 ".local/radroots",
    548                 "env_file:RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT",
    549             )),
    550             RadrootsPathProfile::InteractiveUser,
    551         )
    552         .expect("config entries should select paths");
    553 
    554         assert_eq!(selection.profile, RadrootsPathProfile::RepoLocal);
    555         assert_eq!(
    556             selection.profile_source,
    557             "env_file:RADROOTS_CLI_PATHS_PROFILE"
    558         );
    559         assert_eq!(
    560             selection.repo_local_root,
    561             Some(PathBuf::from(".local/radroots"))
    562         );
    563         assert_eq!(
    564             selection.repo_local_root_source.as_deref(),
    565             Some("env_file:RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT")
    566         );
    567     }
    568 
    569     #[test]
    570     fn config_entry_selection_uses_default_profile_without_sources() {
    571         let selection = RadrootsRuntimePathSelection::from_config_entries(
    572             None,
    573             None,
    574             RadrootsPathProfile::InteractiveUser,
    575         )
    576         .expect("default selection");
    577 
    578         assert_eq!(selection.profile, RadrootsPathProfile::InteractiveUser);
    579         assert_eq!(selection.profile_source, "default");
    580         assert_eq!(selection.repo_local_root, None);
    581         assert_eq!(selection.repo_local_root_source, None);
    582     }
    583 
    584     #[test]
    585     fn contract_captures_selection_sources() {
    586         let selection = RadrootsRuntimePathSelection::caller(
    587             RadrootsPathProfile::RepoLocal,
    588             Some(PathBuf::from("/repo/.local/radroots")),
    589         );
    590 
    591         let contract = selection.contract(
    592             &["interactive_user", "repo_local"],
    593             "config_artifact",
    594             &["config.service.logs_dir"],
    595         );
    596 
    597         assert_eq!(contract.active_profile, "repo_local");
    598         assert_eq!(
    599             contract.allowed_profiles,
    600             vec!["interactive_user".to_owned(), "repo_local".to_owned()]
    601         );
    602         assert_eq!(contract.path_overrides.profile_source, "caller");
    603         assert_eq!(
    604             contract.path_overrides.repo_local_root,
    605             Some(PathBuf::from("/repo/.local/radroots"))
    606         );
    607     }
    608 
    609     #[test]
    610     fn path_policy_contract_preserves_policy_strings() {
    611         let contract = RadrootsRuntimePathPolicyContract::new(
    612             "profile_root_env_or_repo_wrapper",
    613             "config_artifact",
    614             "compatibility_break_glass",
    615             &["MYC_PATHS_STATE_DIR"],
    616         );
    617 
    618         assert_eq!(
    619             contract.canonical_root_selection,
    620             "profile_root_env_or_repo_wrapper"
    621         );
    622         assert_eq!(
    623             contract.compatibility_leaf_path_keys,
    624             vec!["MYC_PATHS_STATE_DIR".to_owned()]
    625         );
    626     }
    627 
    628     #[test]
    629     fn runtime_migration_contract_maps_detected_paths() {
    630         let report = RadrootsMigrationReport {
    631             posture: "explicit_operator_import_required",
    632             state: "legacy_state_detected",
    633             silent_startup_relocation: false,
    634             compatibility_window: "detect_and_report_only",
    635             detected_legacy_paths: vec![RadrootsLegacyPathDetection {
    636                 id: "legacy_path".to_owned(),
    637                 description: "legacy path".to_owned(),
    638                 path: PathBuf::from("/tmp/legacy"),
    639                 destination: Some(PathBuf::from("/tmp/new")),
    640                 import_hint: "copy it manually".to_owned(),
    641             }],
    642         };
    643 
    644         let contract = runtime_migration_contract(report);
    645 
    646         assert_eq!(contract.posture, "explicit_operator_import_required");
    647         assert_eq!(contract.detected_legacy_paths.len(), 1);
    648         assert_eq!(contract.detected_legacy_paths[0].id, "legacy_path");
    649     }
    650 }