cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 9ef72a6ecac979ff381de92c5b29685124e1f4c4
parent a076044a979d489b6b7a09f50123fdd3eea5c914
Author: triesap <tyson@radroots.org>
Date:   Thu,  9 Apr 2026 16:55:37 +0000

runtime: report cli path provenance

Diffstat:
Msrc/commands/runtime.rs | 9+++++++++
Msrc/domain/runtime.rs | 7+++++++
Msrc/render/mod.rs | 10++++++++++
Msrc/runtime/config.rs | 28++++++++++++++++++++++++++++
Msrc/runtime/paths.rs | 103++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mtests/runtime_show.rs | 21+++++++++++++++++++++
6 files changed, 156 insertions(+), 22 deletions(-)

diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs @@ -26,7 +26,16 @@ pub fn show( }, paths: PathsRuntimeView { profile: config.paths.profile.clone(), + profile_source: config.paths.profile_source.clone(), allowed_profiles: config.paths.allowed_profiles.clone(), + root_source: config.paths.root_source.clone(), + repo_local_root: config + .paths + .repo_local_root + .as_ref() + .map(|path| path.display().to_string()), + repo_local_root_source: config.paths.repo_local_root_source.clone(), + subordinate_path_override_source: config.paths.subordinate_path_override_source.clone(), app_namespace: config.paths.app_namespace.clone(), shared_accounts_namespace: config.paths.shared_accounts_namespace.clone(), shared_identities_namespace: config.paths.shared_identities_namespace.clone(), diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -147,7 +147,14 @@ pub struct LoggingRuntimeView { #[derive(Debug, Clone, Serialize)] pub struct PathsRuntimeView { pub profile: String, + pub profile_source: String, pub allowed_profiles: Vec<String>, + pub root_source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub repo_local_root: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub repo_local_root_source: Option<String>, + pub subordinate_path_override_source: String, pub app_namespace: String, pub shared_accounts_namespace: String, pub shared_identities_namespace: String, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -2165,7 +2165,12 @@ mod tests { }, paths: PathsConfig { profile: "interactive_user".into(), + profile_source: "default".into(), allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], + root_source: "host_defaults".into(), + repo_local_root: None, + repo_local_root_source: None, + subordinate_path_override_source: "runtime_config".into(), app_namespace: "apps/cli".into(), shared_accounts_namespace: "shared/accounts".into(), shared_identities_namespace: "shared/identities".into(), @@ -2302,7 +2307,12 @@ mod tests { }, paths: PathsConfig { profile: "interactive_user".into(), + profile_source: "default".into(), allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], + root_source: "host_defaults".into(), + repo_local_root: None, + repo_local_root_source: None, + subordinate_path_override_source: "runtime_config".into(), app_namespace: "apps/cli".into(), shared_accounts_namespace: "shared/accounts".into(), shared_identities_namespace: "shared/identities".into(), diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -1056,7 +1056,12 @@ mod tests { resolved.paths, PathsConfig { profile: "interactive_user".to_owned(), + profile_source: "default".to_owned(), allowed_profiles: vec!["interactive_user".to_owned(), "repo_local".to_owned(),], + root_source: "host_defaults".to_owned(), + repo_local_root: None, + repo_local_root_source: None, + subordinate_path_override_source: "runtime_config".to_owned(), app_namespace: "apps/cli".to_owned(), shared_accounts_namespace: "shared/accounts".to_owned(), shared_identities_namespace: "shared/identities".to_owned(), @@ -1447,6 +1452,14 @@ RADROOTS_CLI_LOGGING_STDOUT=false resolved.paths.app_config_path, PathBuf::from("/home/tester/.radroots/config/apps/cli/config.toml") ); + assert_eq!(resolved.paths.profile_source, "default"); + assert_eq!(resolved.paths.root_source, "host_defaults"); + assert_eq!(resolved.paths.repo_local_root, None); + assert_eq!(resolved.paths.repo_local_root_source, None); + assert_eq!( + resolved.paths.subordinate_path_override_source, + "runtime_config" + ); assert_eq!(resolved.paths.app_namespace, "apps/cli"); assert_eq!(resolved.paths.shared_accounts_namespace, "shared/accounts"); assert_eq!( @@ -1541,6 +1554,21 @@ RADROOTS_CLI_LOGGING_STDOUT=false assert_eq!(resolved.paths.profile, "repo_local"); assert_eq!( + resolved.paths.profile_source, + "process_env:RADROOTS_CLI_PATHS_PROFILE" + ); + assert_eq!(resolved.paths.root_source, "repo_local_root"); + assert_eq!( + resolved.paths.repo_local_root, + Some(PathBuf::from( + "/workspaces/radroots-cli/.local/radroots/dev" + )) + ); + assert_eq!( + resolved.paths.repo_local_root_source, + Some("process_env:RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned()) + ); + assert_eq!( resolved.paths.app_config_path, PathBuf::from( "/workspaces/radroots-cli/.local/radroots/dev/config/apps/cli/config.toml" diff --git a/src/runtime/paths.rs b/src/runtime/paths.rs @@ -24,7 +24,12 @@ pub(crate) const ENV_CLI_PATHS_REPO_LOCAL_ROOT: &str = "RADROOTS_CLI_PATHS_REPO_ #[derive(Debug, Clone, PartialEq, Eq)] pub struct PathsConfig { pub profile: String, + pub profile_source: String, pub allowed_profiles: Vec<String>, + pub root_source: String, + pub repo_local_root: Option<PathBuf>, + pub repo_local_root_source: Option<String>, + pub subordinate_path_override_source: String, pub app_namespace: String, pub shared_accounts_namespace: String, pub shared_identities_namespace: String, @@ -43,10 +48,11 @@ pub(crate) fn resolve_paths( ) -> Result<PathsConfig, RuntimeError> { let current_dir = env.current_dir()?; let resolver = env.path_resolver(); - let (profile, profile_label) = resolve_cli_path_profile(env, env_file)?; - let overrides = resolve_cli_path_overrides(current_dir.as_path(), env, env_file, profile)?; + let (profile, profile_label, profile_source) = resolve_cli_path_profile(env, env_file)?; + let override_selection = + resolve_cli_path_overrides(current_dir.as_path(), env, env_file, profile)?; let resolved = resolver - .resolve(profile, &overrides) + .resolve(profile, &override_selection.overrides) .map_err(|err| RuntimeError::Config(format!("resolve Radroots path roots: {err}")))?; let app_namespace = RadrootsRuntimeNamespace::app(CLI_APP_NAMESPACE_VALUE) .map_err(|err| RuntimeError::Config(format!("resolve cli namespace: {err}")))?; @@ -60,15 +66,21 @@ pub(crate) fn resolve_paths( })?; let app_paths = resolved.namespaced(&app_namespace); let shared_accounts_paths = resolved.namespaced(&shared_accounts_namespace); - let default_identity_path = default_shared_identity_path(&resolver, profile, &overrides) - .map_err(|err| RuntimeError::Config(format!("resolve shared identity path: {err}")))?; + let default_identity_path = + default_shared_identity_path(&resolver, profile, &override_selection.overrides) + .map_err(|err| RuntimeError::Config(format!("resolve shared identity path: {err}")))?; Ok(PathsConfig { profile: profile_label.to_owned(), + profile_source, allowed_profiles: CLI_ALLOWED_PROFILES .iter() .map(|value| (*value).to_owned()) .collect(), + root_source: path_root_source(profile).to_owned(), + repo_local_root: override_selection.repo_local_root, + repo_local_root_source: override_selection.repo_local_root_source, + subordinate_path_override_source: "runtime_config".to_owned(), app_namespace: app_namespace.relative_path().display().to_string(), shared_accounts_namespace: shared_accounts_namespace .relative_path() @@ -91,10 +103,15 @@ pub(crate) fn resolve_paths( fn resolve_cli_path_profile( env: &dyn Environment, env_file: &EnvFileValues, -) -> Result<(RadrootsPathProfile, &'static str), RuntimeError> { +) -> Result<(RadrootsPathProfile, &'static str, String), RuntimeError> { match path_env_value_entry(env, env_file, &[ENV_CLI_PATHS_PROFILE]) { - Some((key, value)) => parse_cli_path_profile(key.as_str(), value.as_str()), - None => Ok((RadrootsPathProfile::InteractiveUser, CLI_DEFAULT_PROFILE)), + Some(entry) => parse_cli_path_profile(entry.key.as_str(), entry.value.as_str()) + .map(|(profile, label)| (profile, label, entry.source_label())), + None => Ok(( + RadrootsPathProfile::InteractiveUser, + CLI_DEFAULT_PROFILE, + "default".to_owned(), + )), } } @@ -111,30 +128,43 @@ fn parse_cli_path_profile( } } +struct CliPathOverrideSelection { + overrides: RadrootsPathOverrides, + repo_local_root: Option<PathBuf>, + repo_local_root_source: Option<String>, +} + fn resolve_cli_path_overrides( current_dir: &Path, env: &dyn Environment, env_file: &EnvFileValues, profile: RadrootsPathProfile, -) -> Result<RadrootsPathOverrides, RuntimeError> { +) -> Result<CliPathOverrideSelection, RuntimeError> { match profile { - RadrootsPathProfile::InteractiveUser => Ok(RadrootsPathOverrides::default()), + RadrootsPathProfile::InteractiveUser => Ok(CliPathOverrideSelection { + overrides: RadrootsPathOverrides::default(), + repo_local_root: None, + repo_local_root_source: None, + }), RadrootsPathProfile::RepoLocal => { - let Some((key, value)) = - path_env_value_entry(env, env_file, &[ENV_CLI_PATHS_REPO_LOCAL_ROOT]) + let Some(entry) = path_env_value_entry(env, env_file, &[ENV_CLI_PATHS_REPO_LOCAL_ROOT]) else { return Err(RuntimeError::Config(format!( "{ENV_CLI_PATHS_REPO_LOCAL_ROOT} must be set when {ENV_CLI_PATHS_PROFILE}=repo_local" ))); }; - if value.trim().is_empty() { + if entry.value.trim().is_empty() { return Err(RuntimeError::Config(format!( - "{key} must not be empty when {ENV_CLI_PATHS_PROFILE}=repo_local" + "{} must not be empty when {ENV_CLI_PATHS_PROFILE}=repo_local", + entry.key ))); } - Ok(RadrootsPathOverrides::repo_local( - normalize_explicit_path_root(current_dir, value.as_str()), - )) + let repo_local_root = normalize_explicit_path_root(current_dir, entry.value.as_str()); + Ok(CliPathOverrideSelection { + overrides: RadrootsPathOverrides::repo_local(repo_local_root.as_path()), + repo_local_root: Some(repo_local_root), + repo_local_root_source: Some(entry.source_label()), + }) } _ => Err(RuntimeError::Config( "cli only supports interactive_user and repo_local path profiles".to_owned(), @@ -142,6 +172,15 @@ fn resolve_cli_path_overrides( } } +fn path_root_source(profile: RadrootsPathProfile) -> &'static str { + match profile { + RadrootsPathProfile::InteractiveUser => "host_defaults", + RadrootsPathProfile::RepoLocal => "repo_local_root", + RadrootsPathProfile::ServiceHost => "service_host_defaults", + RadrootsPathProfile::MobileNative => "mobile_native_defaults", + } +} + fn normalize_explicit_path_root(current_dir: &Path, value: &str) -> PathBuf { let root = PathBuf::from(value.trim()); if root.is_absolute() { @@ -151,18 +190,38 @@ fn normalize_explicit_path_root(current_dir: &Path, value: &str) -> PathBuf { } } +struct PathEnvValueEntry { + key: String, + value: String, + source_kind: &'static str, +} + +impl PathEnvValueEntry { + fn source_label(&self) -> String { + format!("{}:{}", self.source_kind, self.key) + } +} + fn path_env_value_entry( env: &dyn Environment, env_file: &EnvFileValues, keys: &[&str], -) -> Option<(String, String)> { +) -> Option<PathEnvValueEntry> { keys.iter() - .find_map(|key| env.var(key).map(|value| ((*key).to_owned(), value))) + .find_map(|key| { + env.var(key).map(|value| PathEnvValueEntry { + key: (*key).to_owned(), + value, + source_kind: "process_env", + }) + }) .or_else(|| { keys.iter().find_map(|key| { - env_file - .get(key) - .map(|value| ((*key).to_owned(), value.to_owned())) + env_file.get(key).map(|value| PathEnvValueEntry { + key: (*key).to_owned(), + value: value.to_owned(), + source_kind: "env_file", + }) }) }) } diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -106,6 +106,14 @@ fn config_show_json_reports_default_bootstrap_state() { assert_eq!(json["output"]["color"], true); assert_eq!(json["output"]["dry_run"], false); assert_eq!(json["paths"]["profile"], "interactive_user"); + assert_eq!(json["paths"]["profile_source"], "default"); + assert_eq!(json["paths"]["root_source"], "host_defaults"); + assert_eq!(json["paths"]["repo_local_root"], Value::Null); + assert_eq!(json["paths"]["repo_local_root_source"], Value::Null); + assert_eq!( + json["paths"]["subordinate_path_override_source"], + "runtime_config" + ); assert_eq!(json["paths"]["allowed_profiles"][0], "interactive_user"); assert_eq!(json["paths"]["allowed_profiles"][1], "repo_local"); assert_eq!(json["paths"]["app_namespace"], "apps/cli"); @@ -267,6 +275,19 @@ fn config_show_json_reports_repo_local_paths_when_requested() { let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); assert_eq!(json["paths"]["profile"], "repo_local"); + assert_eq!( + json["paths"]["profile_source"], + "process_env:RADROOTS_CLI_PATHS_PROFILE" + ); + assert_eq!(json["paths"]["root_source"], "repo_local_root"); + assert_eq!( + json["paths"]["repo_local_root"], + repo_local_root.display().to_string() + ); + assert_eq!( + json["paths"]["repo_local_root_source"], + "process_env:RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT" + ); assert_eq!(json["paths"]["allowed_profiles"][0], "interactive_user"); assert_eq!(json["paths"]["allowed_profiles"][1], "repo_local"); assert_eq!(