cli

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

config.rs (127283B)


      1 use std::collections::BTreeMap;
      2 use std::fs;
      3 use std::io::IsTerminal;
      4 use std::path::Path;
      5 use std::path::PathBuf;
      6 
      7 use radroots_local_events::{RelayUrlValidationError, normalize_relay_url};
      8 use radroots_runtime::{parse_bool_value, parse_strict_env_file, parse_u64_value};
      9 use radroots_runtime_paths::{
     10     RadrootsLegacyPathCandidate, RadrootsMigrationReport, RadrootsPathResolver,
     11     inspect_legacy_paths,
     12 };
     13 use radroots_secret_vault::{RadrootsHostVaultPolicy, RadrootsSecretBackend};
     14 use serde::Deserialize;
     15 use url::Url;
     16 
     17 use crate::cli::global::RuntimeInvocationArgs;
     18 use crate::runtime::RuntimeError;
     19 pub use crate::runtime::paths::PathsConfig;
     20 use crate::runtime::paths::{ENV_CLI_PATHS_PROFILE, ENV_CLI_PATHS_REPO_LOCAL_ROOT, resolve_paths};
     21 
     22 const DEFAULT_LOG_FILTER: &str = "info";
     23 const DEFAULT_ENV_PATH: &str = ".env";
     24 const DEFAULT_LOCAL_STATE_DIR: &str = "replica";
     25 const DEFAULT_LOCAL_DB_FILE: &str = "replica.sqlite";
     26 const DEFAULT_LOCAL_BACKUPS_DIR: &str = "backups";
     27 const DEFAULT_LOCAL_EXPORTS_DIR: &str = "exports";
     28 const DEFAULT_SHARED_ACCOUNTS_STORE_FILE: &str = "store.json";
     29 const DEFAULT_MYC_STATUS_TIMEOUT_MS: u64 = 2_000;
     30 const DEFAULT_HYF_EXECUTABLE: &str = "hyfd";
     31 const DEFAULT_RPC_URL: &str = "http://127.0.0.1:7070";
     32 const CLI_HOST_VAULT_POLICY: &str = "desktop";
     33 const CLI_DEFAULT_SECRET_BACKEND: &str = "host_vault";
     34 const CLI_DEFAULT_SECRET_FALLBACK: &str = "encrypted_file";
     35 const CLI_ALLOWED_SHARED_SECRET_BACKENDS: &[&str] = &["host_vault", "encrypted_file"];
     36 const CLI_USES_PROTECTED_STORE: bool = true;
     37 const ENV_CLI_FILE_PATH: &str = "RADROOTS_CLI_ENV_FILE";
     38 const ENV_CLI_OUTPUT_FORMAT: &str = "RADROOTS_CLI_OUTPUT_FORMAT";
     39 const ENV_CLI_LOG_FILTER: &str = "RADROOTS_CLI_LOGGING_FILTER";
     40 const ENV_CLI_LOG_DIR: &str = "RADROOTS_CLI_LOGGING_OUTPUT_DIR";
     41 const ENV_CLI_LOG_STDOUT: &str = "RADROOTS_CLI_LOGGING_STDOUT";
     42 const ENV_CLI_ACCOUNT_SELECTOR: &str = "RADROOTS_CLI_ACCOUNT_SELECTOR";
     43 const ENV_CLI_ACCOUNT_SECRET_BACKEND: &str = "RADROOTS_CLI_ACCOUNT_SECRET_BACKEND";
     44 const ENV_CLI_ACCOUNT_SECRET_FALLBACK: &str = "RADROOTS_CLI_ACCOUNT_SECRET_FALLBACK";
     45 const ENV_CLI_IDENTITY_PATH: &str = "RADROOTS_CLI_IDENTITY_PATH";
     46 const ENV_CLI_SIGNER_BACKEND: &str = "RADROOTS_CLI_SIGNER_BACKEND";
     47 const ENV_CLI_PUBLISH_TRANSPORT: &str = "RADROOTS_CLI_PUBLISH_TRANSPORT";
     48 const ENV_CLI_RELAYS_URLS: &str = "RADROOTS_CLI_RELAYS_URLS";
     49 const ENV_CLI_RADROOTSD_PROXY_URL: &str = "RADROOTS_CLI_RADROOTSD_PROXY_URL";
     50 const ENV_CLI_RADROOTSD_PROXY_TOKEN_FILE: &str = "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE";
     51 const ENV_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID: &str =
     52     "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID";
     53 const ENV_CLI_MYC_EXECUTABLE: &str = "RADROOTS_CLI_MYC_EXECUTABLE";
     54 const ENV_CLI_MYC_STATUS_TIMEOUT_MS: &str = "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS";
     55 const ENV_CLI_HYF_ENABLED: &str = "RADROOTS_CLI_HYF_ENABLED";
     56 const ENV_CLI_HYF_EXECUTABLE: &str = "RADROOTS_CLI_HYF_EXECUTABLE";
     57 const ENV_CLI_RHI_TRUSTED_WORKER_PUBKEYS: &str = "RADROOTS_CLI_RHI_TRUSTED_WORKER_PUBKEYS";
     58 const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[
     59     ENV_CLI_OUTPUT_FORMAT,
     60     ENV_CLI_LOG_FILTER,
     61     ENV_CLI_LOG_DIR,
     62     ENV_CLI_LOG_STDOUT,
     63     ENV_CLI_PATHS_PROFILE,
     64     ENV_CLI_PATHS_REPO_LOCAL_ROOT,
     65     ENV_CLI_ACCOUNT_SELECTOR,
     66     ENV_CLI_ACCOUNT_SECRET_BACKEND,
     67     ENV_CLI_ACCOUNT_SECRET_FALLBACK,
     68     ENV_CLI_IDENTITY_PATH,
     69     ENV_CLI_SIGNER_BACKEND,
     70     ENV_CLI_PUBLISH_TRANSPORT,
     71     ENV_CLI_RELAYS_URLS,
     72     ENV_CLI_RADROOTSD_PROXY_URL,
     73     ENV_CLI_RADROOTSD_PROXY_TOKEN_FILE,
     74     ENV_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID,
     75     ENV_CLI_MYC_EXECUTABLE,
     76     ENV_CLI_MYC_STATUS_TIMEOUT_MS,
     77     ENV_CLI_HYF_ENABLED,
     78     ENV_CLI_HYF_EXECUTABLE,
     79     ENV_CLI_RHI_TRUSTED_WORKER_PUBKEYS,
     80 ];
     81 
     82 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
     83 pub enum OutputFormat {
     84     Human,
     85     Json,
     86     Ndjson,
     87 }
     88 
     89 impl OutputFormat {
     90     pub fn as_str(self) -> &'static str {
     91         match self {
     92             Self::Human => "human",
     93             Self::Json => "json",
     94             Self::Ndjson => "ndjson",
     95         }
     96     }
     97 }
     98 
     99 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    100 pub enum Verbosity {
    101     Quiet,
    102     Normal,
    103     Verbose,
    104     Trace,
    105 }
    106 
    107 impl Verbosity {
    108     pub fn as_str(self) -> &'static str {
    109         match self {
    110             Self::Quiet => "quiet",
    111             Self::Normal => "normal",
    112             Self::Verbose => "verbose",
    113             Self::Trace => "trace",
    114         }
    115     }
    116 }
    117 
    118 #[derive(Debug, Clone, PartialEq, Eq)]
    119 pub struct OutputConfig {
    120     pub format: OutputFormat,
    121     pub verbosity: Verbosity,
    122     pub color: bool,
    123     pub dry_run: bool,
    124 }
    125 
    126 #[derive(Debug, Clone, PartialEq, Eq)]
    127 pub struct InteractionConfig {
    128     pub input_enabled: bool,
    129     pub assume_yes: bool,
    130     pub stdin_tty: bool,
    131     pub stdout_tty: bool,
    132     pub prompts_allowed: bool,
    133     pub confirmations_allowed: bool,
    134 }
    135 
    136 #[derive(Debug, Clone, PartialEq, Eq)]
    137 pub struct LoggingConfig {
    138     pub filter: String,
    139     pub directory: Option<PathBuf>,
    140     pub stdout: bool,
    141 }
    142 
    143 #[derive(Debug, Clone, PartialEq, Eq)]
    144 pub struct IdentityConfig {
    145     pub path: PathBuf,
    146 }
    147 
    148 #[derive(Debug, Clone, PartialEq, Eq)]
    149 pub struct AccountConfig {
    150     pub selector: Option<String>,
    151     pub store_path: PathBuf,
    152     pub secrets_dir: PathBuf,
    153     pub secret_backend: RadrootsSecretBackend,
    154     pub secret_fallback: Option<RadrootsSecretBackend>,
    155 }
    156 
    157 #[derive(Debug, Clone, PartialEq, Eq)]
    158 pub struct AccountSecretContractConfig {
    159     pub default_backend: String,
    160     pub default_fallback: Option<String>,
    161     pub allowed_backends: Vec<String>,
    162     pub host_vault_policy: Option<String>,
    163     pub uses_protected_store: bool,
    164 }
    165 
    166 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    167 pub enum SignerBackend {
    168     Local,
    169     Myc,
    170 }
    171 
    172 impl SignerBackend {
    173     pub fn as_str(self) -> &'static str {
    174         match self {
    175             Self::Local => "local",
    176             Self::Myc => "myc",
    177         }
    178     }
    179 }
    180 
    181 #[derive(Debug, Clone, PartialEq, Eq)]
    182 pub struct SignerConfig {
    183     pub backend: SignerBackend,
    184 }
    185 
    186 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    187 pub enum PublishTransport {
    188     DirectNostrRelay,
    189     RadrootsdProxy,
    190 }
    191 
    192 impl PublishTransport {
    193     pub fn as_str(self) -> &'static str {
    194         match self {
    195             Self::DirectNostrRelay => "direct_nostr_relay",
    196             Self::RadrootsdProxy => "radrootsd_proxy",
    197         }
    198     }
    199 
    200     pub fn transport_family(self) -> &'static str {
    201         match self {
    202             Self::DirectNostrRelay => "direct_nostr_relay",
    203             Self::RadrootsdProxy => "radrootsd_proxy",
    204         }
    205     }
    206 }
    207 
    208 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    209 pub enum PublishTransportSource {
    210     Flags,
    211     Environment,
    212     UserConfig,
    213     WorkspaceConfig,
    214     Defaults,
    215 }
    216 
    217 impl PublishTransportSource {
    218     pub fn as_str(self) -> &'static str {
    219         match self {
    220             Self::Flags => "cli flags · local first",
    221             Self::Environment => "environment · local first",
    222             Self::UserConfig => "user config · local first",
    223             Self::WorkspaceConfig => "workspace config · local first",
    224             Self::Defaults => "defaults · local first",
    225         }
    226     }
    227 }
    228 
    229 #[derive(Debug, Clone, PartialEq, Eq)]
    230 pub struct PublishConfig {
    231     pub transport: PublishTransport,
    232     pub source: PublishTransportSource,
    233     pub radrootsd_proxy: RadrootsdProxyConfig,
    234 }
    235 
    236 #[derive(Debug, Clone, PartialEq, Eq)]
    237 pub struct RadrootsdProxyConfig {
    238     pub url: String,
    239     pub token_file: Option<PathBuf>,
    240     pub token_secret_id: Option<String>,
    241 }
    242 
    243 impl Default for RadrootsdProxyConfig {
    244     fn default() -> Self {
    245         Self {
    246             url: DEFAULT_RPC_URL.to_owned(),
    247             token_file: None,
    248             token_secret_id: None,
    249         }
    250     }
    251 }
    252 
    253 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    254 pub enum RelayPublishPolicy {
    255     Any,
    256 }
    257 
    258 impl RelayPublishPolicy {
    259     pub fn as_str(self) -> &'static str {
    260         match self {
    261             Self::Any => "any",
    262         }
    263     }
    264 }
    265 
    266 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    267 pub enum RelayConfigSource {
    268     Flags,
    269     Environment,
    270     UserConfig,
    271     WorkspaceConfig,
    272     Defaults,
    273 }
    274 
    275 impl RelayConfigSource {
    276     pub fn as_str(self) -> &'static str {
    277         match self {
    278             Self::Flags => "cli flags · local first",
    279             Self::Environment => "environment · local first",
    280             Self::UserConfig => "user config · local first",
    281             Self::WorkspaceConfig => "workspace config · local first",
    282             Self::Defaults => "defaults · local first",
    283         }
    284     }
    285 }
    286 
    287 #[derive(Debug, Clone, PartialEq, Eq)]
    288 pub struct RelayConfig {
    289     pub urls: Vec<String>,
    290     pub publish_policy: RelayPublishPolicy,
    291     pub source: RelayConfigSource,
    292 }
    293 
    294 #[derive(Debug, Clone, PartialEq, Eq)]
    295 pub struct LocalConfig {
    296     pub root: PathBuf,
    297     pub replica_db_path: PathBuf,
    298     pub backups_dir: PathBuf,
    299     pub exports_dir: PathBuf,
    300 }
    301 
    302 #[derive(Debug, Clone, PartialEq, Eq)]
    303 pub struct MycConfig {
    304     pub executable: PathBuf,
    305     pub status_timeout_ms: u64,
    306 }
    307 
    308 #[derive(Debug, Clone, PartialEq, Eq)]
    309 pub struct HyfConfig {
    310     pub enabled: bool,
    311     pub executable: PathBuf,
    312 }
    313 
    314 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    315 pub enum CapabilityBindingTargetKind {
    316     ManagedInstance,
    317     ExplicitEndpoint,
    318 }
    319 
    320 impl CapabilityBindingTargetKind {
    321     pub fn as_str(self) -> &'static str {
    322         match self {
    323             Self::ManagedInstance => "managed_instance",
    324             Self::ExplicitEndpoint => "explicit_endpoint",
    325         }
    326     }
    327 }
    328 
    329 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    330 pub enum CapabilityBindingSource {
    331     UserConfig,
    332     WorkspaceConfig,
    333 }
    334 
    335 impl CapabilityBindingSource {
    336     pub fn as_str(self) -> &'static str {
    337         match self {
    338             Self::UserConfig => "user config [[capability_binding]]",
    339             Self::WorkspaceConfig => "workspace config [[capability_binding]]",
    340         }
    341     }
    342 }
    343 
    344 #[derive(Debug, Clone, PartialEq, Eq)]
    345 pub struct CapabilityBindingConfig {
    346     pub capability_id: String,
    347     pub provider_runtime_id: String,
    348     pub binding_model: String,
    349     pub target_kind: CapabilityBindingTargetKind,
    350     pub target: String,
    351     pub managed_account_ref: Option<String>,
    352     pub signer_session_ref: Option<String>,
    353     pub source: CapabilityBindingSource,
    354 }
    355 
    356 #[cfg(test)]
    357 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    358 pub enum CapabilityBindingInspectionState {
    359     Configured,
    360     NotConfigured,
    361     Disabled,
    362 }
    363 
    364 #[cfg(test)]
    365 #[derive(Debug, Clone, PartialEq, Eq)]
    366 pub struct CapabilityBindingInspection {
    367     pub capability_id: String,
    368     pub provider_runtime_id: String,
    369     pub binding_model: String,
    370     pub state: CapabilityBindingInspectionState,
    371     pub source: String,
    372     pub target_kind: Option<String>,
    373     pub target: Option<String>,
    374     pub managed_account_ref: Option<String>,
    375     pub signer_session_ref: Option<String>,
    376 }
    377 
    378 #[derive(Debug, Clone, PartialEq, Eq)]
    379 pub struct RpcConfig {
    380     pub url: String,
    381 }
    382 
    383 #[derive(Debug, Clone, PartialEq, Eq)]
    384 pub struct RhiConfig {
    385     pub trusted_worker_pubkeys: Vec<String>,
    386 }
    387 
    388 #[derive(Debug, Clone, PartialEq, Eq)]
    389 pub struct RuntimeConfig {
    390     pub output: OutputConfig,
    391     pub interaction: InteractionConfig,
    392     pub paths: PathsConfig,
    393     pub migration: MigrationConfig,
    394     pub logging: LoggingConfig,
    395     pub account: AccountConfig,
    396     pub account_secret_contract: AccountSecretContractConfig,
    397     pub identity: IdentityConfig,
    398     pub signer: SignerConfig,
    399     pub publish: PublishConfig,
    400     pub relay: RelayConfig,
    401     pub local: LocalConfig,
    402     pub myc: MycConfig,
    403     pub hyf: HyfConfig,
    404     pub rpc: RpcConfig,
    405     pub rhi: RhiConfig,
    406     pub capability_bindings: Vec<CapabilityBindingConfig>,
    407 }
    408 
    409 #[derive(Debug, Clone, PartialEq, Eq)]
    410 pub struct MigrationConfig {
    411     pub report: RadrootsMigrationReport,
    412 }
    413 
    414 #[derive(Debug, Default)]
    415 pub(crate) struct EnvFileValues(BTreeMap<String, String>);
    416 
    417 impl EnvFileValues {
    418     pub(crate) fn get(&self, key: &str) -> Option<&str> {
    419         self.0.get(key).map(String::as_str)
    420     }
    421 }
    422 
    423 #[derive(Debug, Default, Deserialize)]
    424 #[serde(default, deny_unknown_fields)]
    425 struct CliConfigFile {
    426     output: Option<OutputFileConfig>,
    427     logging: Option<LoggingFileConfig>,
    428     account: Option<AccountFileConfig>,
    429     identity: Option<IdentityFileConfig>,
    430     relays: Option<RelayFileConfig>,
    431     publish: Option<PublishFileConfig>,
    432     signer: Option<SignerFileConfig>,
    433     myc: Option<MycFileConfig>,
    434     hyf: Option<HyfFileConfig>,
    435     rpc: Option<RpcFileConfig>,
    436     rhi: Option<RhiFileConfig>,
    437     capability_binding: Option<Vec<CapabilityBindingFileConfig>>,
    438 }
    439 
    440 #[derive(Debug, Default, Deserialize)]
    441 #[serde(default, deny_unknown_fields)]
    442 struct OutputFileConfig {
    443     format: Option<String>,
    444 }
    445 
    446 #[derive(Debug, Default, Deserialize)]
    447 #[serde(default, deny_unknown_fields)]
    448 struct LoggingFileConfig {
    449     filter: Option<String>,
    450     output_dir: Option<PathBuf>,
    451     stdout: Option<bool>,
    452 }
    453 
    454 #[derive(Debug, Default, Deserialize)]
    455 #[serde(default, deny_unknown_fields)]
    456 struct AccountFileConfig {
    457     selector: Option<String>,
    458     secret: Option<AccountSecretFileConfig>,
    459 }
    460 
    461 #[derive(Debug, Default, Deserialize)]
    462 #[serde(default, deny_unknown_fields)]
    463 struct AccountSecretFileConfig {
    464     backend: Option<String>,
    465     fallback: Option<String>,
    466 }
    467 
    468 #[derive(Debug, Default, Deserialize)]
    469 #[serde(default, deny_unknown_fields)]
    470 struct IdentityFileConfig {
    471     path: Option<PathBuf>,
    472 }
    473 
    474 #[derive(Debug, Default, Deserialize)]
    475 #[serde(default, deny_unknown_fields)]
    476 struct RelayFileConfig {
    477     urls: Option<Vec<String>>,
    478     publish_policy: Option<String>,
    479 }
    480 
    481 #[derive(Debug, Default, Deserialize)]
    482 #[serde(default, deny_unknown_fields)]
    483 struct PublishFileConfig {
    484     transport: Option<String>,
    485     radrootsd_proxy: Option<RadrootsdProxyFileConfig>,
    486 }
    487 
    488 #[derive(Debug, Default, Deserialize)]
    489 #[serde(default, deny_unknown_fields)]
    490 struct RadrootsdProxyFileConfig {
    491     url: Option<String>,
    492     token_file: Option<PathBuf>,
    493     token_secret_id: Option<String>,
    494 }
    495 
    496 #[derive(Debug, Default, Deserialize)]
    497 #[serde(default, deny_unknown_fields)]
    498 struct RpcFileConfig {
    499     url: Option<String>,
    500 }
    501 
    502 #[derive(Debug, Default, Deserialize)]
    503 #[serde(default, deny_unknown_fields)]
    504 struct RhiFileConfig {
    505     trusted_worker_pubkeys: Option<Vec<String>>,
    506 }
    507 
    508 #[derive(Debug, Default, Deserialize)]
    509 #[serde(default, deny_unknown_fields)]
    510 struct MycFileConfig {
    511     executable: Option<PathBuf>,
    512     status_timeout_ms: Option<u64>,
    513 }
    514 
    515 #[derive(Debug, Default, Deserialize)]
    516 #[serde(default, deny_unknown_fields)]
    517 struct SignerFileConfig {
    518     backend: Option<String>,
    519 }
    520 
    521 #[derive(Debug, Default, Deserialize)]
    522 #[serde(default, deny_unknown_fields)]
    523 struct HyfFileConfig {
    524     enabled: Option<bool>,
    525     executable: Option<PathBuf>,
    526 }
    527 
    528 #[derive(Debug, Clone, Deserialize)]
    529 #[serde(deny_unknown_fields)]
    530 struct CapabilityBindingFileConfig {
    531     capability: String,
    532     provider: String,
    533     target_kind: String,
    534     target: String,
    535     managed_account_ref: Option<String>,
    536     signer_session_ref: Option<String>,
    537 }
    538 
    539 #[derive(Debug, Clone, Copy)]
    540 struct CapabilityBindingSpec {
    541     capability_id: &'static str,
    542     provider_runtime_id: &'static str,
    543     binding_model: &'static str,
    544 }
    545 
    546 pub(crate) const SIGNER_REMOTE_NIP46_CAPABILITY: &str = "signer.remote_nip46";
    547 pub(crate) const INFERENCE_HYF_STDIO_CAPABILITY: &str = "inference.hyf_stdio";
    548 const CAPABILITY_BINDING_SPECS: &[CapabilityBindingSpec] = &[
    549     CapabilityBindingSpec {
    550         capability_id: SIGNER_REMOTE_NIP46_CAPABILITY,
    551         provider_runtime_id: "myc",
    552         binding_model: "session_authorized_remote_signer",
    553     },
    554     CapabilityBindingSpec {
    555         capability_id: INFERENCE_HYF_STDIO_CAPABILITY,
    556         provider_runtime_id: "hyf",
    557         binding_model: "stdio_service",
    558     },
    559 ];
    560 
    561 pub(crate) trait Environment {
    562     fn var(&self, key: &str) -> Option<String>;
    563     fn current_dir(&self) -> Result<PathBuf, RuntimeError>;
    564     fn path_resolver(&self) -> RadrootsPathResolver;
    565     fn stdin_is_tty(&self) -> bool;
    566     fn stdout_is_tty(&self) -> bool;
    567 }
    568 
    569 pub struct SystemEnvironment;
    570 
    571 impl Environment for SystemEnvironment {
    572     fn var(&self, key: &str) -> Option<String> {
    573         std::env::var(key).ok()
    574     }
    575 
    576     fn current_dir(&self) -> Result<PathBuf, RuntimeError> {
    577         std::env::current_dir().map_err(|err| {
    578             RuntimeError::Config(format!("failed to resolve current directory: {err}"))
    579         })
    580     }
    581 
    582     fn path_resolver(&self) -> RadrootsPathResolver {
    583         RadrootsPathResolver::current()
    584     }
    585 
    586     fn stdin_is_tty(&self) -> bool {
    587         std::io::stdin().is_terminal()
    588     }
    589 
    590     fn stdout_is_tty(&self) -> bool {
    591         std::io::stdout().is_terminal()
    592     }
    593 }
    594 
    595 impl RuntimeConfig {
    596     pub fn from_system(args: &RuntimeInvocationArgs) -> Result<Self, RuntimeError> {
    597         let system = SystemEnvironment;
    598         let env_file_path = resolve_env_file_path(args, &system);
    599         let env_file = load_env_file_values(env_file_path.as_deref())?;
    600         Self::resolve_with_env_file(args, &system, &env_file)
    601     }
    602 
    603     fn resolve_with_env_file(
    604         args: &RuntimeInvocationArgs,
    605         env: &dyn Environment,
    606         env_file: &EnvFileValues,
    607     ) -> Result<Self, RuntimeError> {
    608         let paths = resolve_paths(env, env_file)?;
    609         let migration = resolve_migration(paths.clone(), env);
    610         let workspace_config = paths
    611             .workspace_config_path
    612             .as_deref()
    613             .map(load_cli_config_file)
    614             .transpose()?
    615             .flatten();
    616         let app_config = load_cli_config_file(paths.app_config_path.as_path())?;
    617         let account_secret_backend = resolve_account_secret_backend(
    618             env,
    619             env_file,
    620             app_config.as_ref(),
    621             workspace_config.as_ref(),
    622         )?
    623         .unwrap_or(RadrootsSecretBackend::HostVault(
    624             RadrootsHostVaultPolicy::desktop(),
    625         ));
    626         let account_secret_fallback = resolve_account_secret_fallback(
    627             env,
    628             env_file,
    629             app_config.as_ref(),
    630             workspace_config.as_ref(),
    631         )?
    632         .unwrap_or(match account_secret_backend {
    633             RadrootsSecretBackend::HostVault(_) => Some(RadrootsSecretBackend::EncryptedFile),
    634             _ => None,
    635         });
    636         let output = OutputConfig {
    637             format: resolve_output_format(
    638                 args,
    639                 env,
    640                 env_file,
    641                 app_config.as_ref(),
    642                 workspace_config.as_ref(),
    643             )?,
    644             verbosity: resolve_verbosity(args)?,
    645             color: !args.no_color,
    646             dry_run: args.dry_run,
    647         };
    648         let logging = LoggingConfig {
    649             filter: resolve_logging_filter(
    650                 args,
    651                 env,
    652                 env_file,
    653                 app_config.as_ref(),
    654                 workspace_config.as_ref(),
    655             ),
    656             directory: resolve_logging_directory(
    657                 args,
    658                 env,
    659                 env_file,
    660                 app_config.as_ref(),
    661                 workspace_config.as_ref(),
    662                 paths.app_logs_root.as_path(),
    663             ),
    664             stdout: resolve_logging_stdout(
    665                 args,
    666                 env,
    667                 env_file,
    668                 app_config.as_ref(),
    669                 workspace_config.as_ref(),
    670             )?,
    671         };
    672         validate_logging_output_contract(&output, &logging)?;
    673         Ok(Self {
    674             capability_bindings: resolve_capability_bindings(
    675                 app_config.as_ref(),
    676                 workspace_config.as_ref(),
    677             )?,
    678             output,
    679             interaction: resolve_interaction_config(args, env),
    680             paths: paths.clone(),
    681             migration,
    682             logging,
    683             account: AccountConfig {
    684                 selector: args
    685                     .account
    686                     .clone()
    687                     .or_else(|| env_value(env, env_file, &[ENV_CLI_ACCOUNT_SELECTOR]))
    688                     .or_else(|| {
    689                         app_config
    690                             .as_ref()
    691                             .and_then(|config| config.account.as_ref())
    692                             .and_then(|account| account.selector.clone())
    693                     })
    694                     .or_else(|| {
    695                         workspace_config
    696                             .as_ref()
    697                             .and_then(|config| config.account.as_ref())
    698                             .and_then(|account| account.selector.clone())
    699                     }),
    700                 store_path: paths
    701                     .shared_accounts_data_root
    702                     .join(DEFAULT_SHARED_ACCOUNTS_STORE_FILE),
    703                 secrets_dir: paths.shared_accounts_secrets_root.clone(),
    704                 secret_backend: account_secret_backend,
    705                 secret_fallback: account_secret_fallback,
    706             },
    707             account_secret_contract: AccountSecretContractConfig {
    708                 default_backend: CLI_DEFAULT_SECRET_BACKEND.to_owned(),
    709                 default_fallback: Some(CLI_DEFAULT_SECRET_FALLBACK.to_owned()),
    710                 allowed_backends: CLI_ALLOWED_SHARED_SECRET_BACKENDS
    711                     .iter()
    712                     .map(|value| (*value).to_owned())
    713                     .collect(),
    714                 host_vault_policy: Some(CLI_HOST_VAULT_POLICY.to_owned()),
    715                 uses_protected_store: CLI_USES_PROTECTED_STORE,
    716             },
    717             identity: IdentityConfig {
    718                 path: args
    719                     .identity_path
    720                     .clone()
    721                     .or_else(|| {
    722                         env_value(env, env_file, &[ENV_CLI_IDENTITY_PATH]).map(PathBuf::from)
    723                     })
    724                     .or_else(|| {
    725                         app_config
    726                             .as_ref()
    727                             .and_then(|config| config.identity.as_ref())
    728                             .and_then(|identity| identity.path.clone())
    729                     })
    730                     .or_else(|| {
    731                         workspace_config
    732                             .as_ref()
    733                             .and_then(|config| config.identity.as_ref())
    734                             .and_then(|identity| identity.path.clone())
    735                     })
    736                     .unwrap_or_else(|| paths.default_identity_path.clone()),
    737             },
    738             signer: resolve_signer_config(
    739                 args,
    740                 env,
    741                 env_file,
    742                 app_config.as_ref(),
    743                 workspace_config.as_ref(),
    744             )?,
    745             publish: resolve_publish_config(
    746                 args,
    747                 env,
    748                 env_file,
    749                 app_config.as_ref(),
    750                 workspace_config.as_ref(),
    751             )?,
    752             relay: resolve_relay_config(
    753                 args,
    754                 env,
    755                 env_file,
    756                 app_config.as_ref(),
    757                 workspace_config.as_ref(),
    758             )?,
    759             local: LocalConfig {
    760                 root: paths.app_data_root.join(DEFAULT_LOCAL_STATE_DIR),
    761                 replica_db_path: paths
    762                     .app_data_root
    763                     .join(DEFAULT_LOCAL_STATE_DIR)
    764                     .join(DEFAULT_LOCAL_DB_FILE),
    765                 backups_dir: paths
    766                     .app_data_root
    767                     .join(DEFAULT_LOCAL_STATE_DIR)
    768                     .join(DEFAULT_LOCAL_BACKUPS_DIR),
    769                 exports_dir: paths
    770                     .app_data_root
    771                     .join(DEFAULT_LOCAL_STATE_DIR)
    772                     .join(DEFAULT_LOCAL_EXPORTS_DIR),
    773             },
    774             myc: resolve_myc_config(
    775                 args,
    776                 env,
    777                 env_file,
    778                 app_config.as_ref(),
    779                 workspace_config.as_ref(),
    780             )?,
    781             hyf: HyfConfig {
    782                 enabled: resolve_hyf_enabled(
    783                     args,
    784                     env,
    785                     env_file,
    786                     app_config.as_ref(),
    787                     workspace_config.as_ref(),
    788                 )?,
    789                 executable: resolve_hyf_executable(
    790                     args,
    791                     env,
    792                     env_file,
    793                     app_config.as_ref(),
    794                     workspace_config.as_ref(),
    795                 ),
    796             },
    797             rpc: resolve_rpc_config(
    798                 env,
    799                 env_file,
    800                 app_config.as_ref(),
    801                 workspace_config.as_ref(),
    802             )?,
    803             rhi: resolve_rhi_config(
    804                 env,
    805                 env_file,
    806                 app_config.as_ref(),
    807                 workspace_config.as_ref(),
    808             )?,
    809         })
    810     }
    811 
    812     #[cfg(test)]
    813     pub fn inspect_capability_bindings(&self) -> Vec<CapabilityBindingInspection> {
    814         CAPABILITY_BINDING_SPECS
    815             .iter()
    816             .map(|spec| {
    817                 if let Some(binding) = self
    818                     .capability_bindings
    819                     .iter()
    820                     .find(|binding| binding.capability_id == spec.capability_id)
    821                 {
    822                     return CapabilityBindingInspection {
    823                         capability_id: binding.capability_id.clone(),
    824                         provider_runtime_id: binding.provider_runtime_id.clone(),
    825                         binding_model: binding.binding_model.clone(),
    826                         state: CapabilityBindingInspectionState::Configured,
    827                         source: binding.source.as_str().to_owned(),
    828                         target_kind: Some(binding.target_kind.as_str().to_owned()),
    829                         target: Some(binding.target.clone()),
    830                         managed_account_ref: binding.managed_account_ref.clone(),
    831                         signer_session_ref: binding.signer_session_ref.clone(),
    832                     };
    833                 }
    834 
    835                 let (state, source) = match spec.capability_id {
    836                     SIGNER_REMOTE_NIP46_CAPABILITY
    837                         if matches!(self.signer.backend, SignerBackend::Local) =>
    838                     {
    839                         (
    840                             CapabilityBindingInspectionState::Disabled,
    841                             "independent local signer mode".to_owned(),
    842                         )
    843                     }
    844                     INFERENCE_HYF_STDIO_CAPABILITY if !self.hyf.enabled => (
    845                         CapabilityBindingInspectionState::Disabled,
    846                         "hyf disabled by config".to_owned(),
    847                     ),
    848                     _ => (
    849                         CapabilityBindingInspectionState::NotConfigured,
    850                         "no explicit capability binding".to_owned(),
    851                     ),
    852                 };
    853 
    854                 CapabilityBindingInspection {
    855                     capability_id: spec.capability_id.to_owned(),
    856                     provider_runtime_id: spec.provider_runtime_id.to_owned(),
    857                     binding_model: spec.binding_model.to_owned(),
    858                     state,
    859                     source,
    860                     target_kind: None,
    861                     target: None,
    862                     managed_account_ref: None,
    863                     signer_session_ref: None,
    864                 }
    865             })
    866             .collect()
    867     }
    868 
    869     pub fn capability_binding(&self, capability_id: &str) -> Option<&CapabilityBindingConfig> {
    870         self.capability_bindings
    871             .iter()
    872             .find(|binding| binding.capability_id == capability_id)
    873     }
    874 }
    875 
    876 fn resolve_migration(paths: PathsConfig, env: &dyn Environment) -> MigrationConfig {
    877     MigrationConfig {
    878         report: inspect_legacy_paths(legacy_path_candidates(&paths, env)),
    879     }
    880 }
    881 
    882 fn legacy_path_candidates(
    883     paths: &PathsConfig,
    884     env: &dyn Environment,
    885 ) -> Vec<RadrootsLegacyPathCandidate> {
    886     let Some(home_dir) = env.var("HOME").map(PathBuf::from) else {
    887         return Vec::new();
    888     };
    889     let old_user_config = home_dir.join(".config/radroots/config.toml");
    890     let old_user_state_root = home_dir.join(".local/share/radroots");
    891 
    892     vec![
    893         RadrootsLegacyPathCandidate::new(
    894             "cli_user_config_v0",
    895             "legacy cli user config",
    896             old_user_config,
    897             Some(paths.app_config_path.clone()),
    898             "merge this config into the canonical app config path; the cli will not copy it on startup",
    899         ),
    900         RadrootsLegacyPathCandidate::new(
    901             "cli_user_state_root_v0",
    902             "legacy cli user state root",
    903             old_user_state_root,
    904             Some(paths.app_data_root.clone()),
    905             "export/import the old local state into the canonical app and shared namespaces; the cli will not move it on startup",
    906         ),
    907     ]
    908 }
    909 
    910 fn load_cli_config_file(path: &Path) -> Result<Option<CliConfigFile>, RuntimeError> {
    911     if !path.exists() {
    912         return Ok(None);
    913     }
    914 
    915     let raw = fs::read_to_string(path).map_err(|err| {
    916         RuntimeError::Config(format!(
    917             "failed to read config file {}: {err}",
    918             path.display()
    919         ))
    920     })?;
    921 
    922     if raw.trim().is_empty() {
    923         return Ok(Some(CliConfigFile::default()));
    924     }
    925 
    926     toml::from_str::<CliConfigFile>(&raw)
    927         .map(Some)
    928         .map_err(|err| {
    929             RuntimeError::Config(format!(
    930                 "failed to parse config file {}: {err}",
    931                 path.display()
    932             ))
    933         })
    934 }
    935 
    936 fn resolve_rpc_config(
    937     _env: &dyn Environment,
    938     _env_file: &EnvFileValues,
    939     user_config: Option<&CliConfigFile>,
    940     workspace_config: Option<&CliConfigFile>,
    941 ) -> Result<RpcConfig, RuntimeError> {
    942     let url = user_config
    943         .and_then(|config| config.rpc.as_ref())
    944         .and_then(|rpc| rpc.url.clone())
    945         .or_else(|| {
    946             workspace_config
    947                 .and_then(|config| config.rpc.as_ref())
    948                 .and_then(|rpc| rpc.url.clone())
    949         })
    950         .unwrap_or_else(|| DEFAULT_RPC_URL.to_owned());
    951 
    952     Ok(RpcConfig {
    953         url: validate_rpc_url(url.as_str())?,
    954     })
    955 }
    956 
    957 fn resolve_radrootsd_proxy_config(
    958     env: &dyn Environment,
    959     env_file: &EnvFileValues,
    960     user_config: Option<&CliConfigFile>,
    961     workspace_config: Option<&CliConfigFile>,
    962 ) -> Result<RadrootsdProxyConfig, RuntimeError> {
    963     let user_proxy = user_config
    964         .and_then(|config| config.publish.as_ref())
    965         .and_then(|publish| publish.radrootsd_proxy.as_ref());
    966     let workspace_proxy = workspace_config
    967         .and_then(|config| config.publish.as_ref())
    968         .and_then(|publish| publish.radrootsd_proxy.as_ref());
    969     let url = env_value(env, env_file, &[ENV_CLI_RADROOTSD_PROXY_URL])
    970         .or_else(|| user_proxy.and_then(|proxy| proxy.url.clone()))
    971         .or_else(|| workspace_proxy.and_then(|proxy| proxy.url.clone()))
    972         .unwrap_or_else(|| DEFAULT_RPC_URL.to_owned());
    973     let token_file = env_value(env, env_file, &[ENV_CLI_RADROOTSD_PROXY_TOKEN_FILE])
    974         .map(PathBuf::from)
    975         .or_else(|| user_proxy.and_then(|proxy| proxy.token_file.clone()))
    976         .or_else(|| workspace_proxy.and_then(|proxy| proxy.token_file.clone()));
    977     let token_secret_id = env_value(env, env_file, &[ENV_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID])
    978         .or_else(|| user_proxy.and_then(|proxy| proxy.token_secret_id.clone()))
    979         .or_else(|| workspace_proxy.and_then(|proxy| proxy.token_secret_id.clone()));
    980 
    981     Ok(RadrootsdProxyConfig {
    982         url: validate_rpc_url(url.as_str())?,
    983         token_file,
    984         token_secret_id,
    985     })
    986 }
    987 
    988 fn resolve_rhi_config(
    989     env: &dyn Environment,
    990     env_file: &EnvFileValues,
    991     user_config: Option<&CliConfigFile>,
    992     workspace_config: Option<&CliConfigFile>,
    993 ) -> Result<RhiConfig, RuntimeError> {
    994     let trusted_worker_pubkeys =
    995         if let Some(value) = env_value(env, env_file, &[ENV_CLI_RHI_TRUSTED_WORKER_PUBKEYS]) {
    996             parse_pubkey_env_value(value.as_str(), ENV_CLI_RHI_TRUSTED_WORKER_PUBKEYS)?
    997         } else if let Some(values) = user_config
    998             .and_then(|config| config.rhi.as_ref())
    999             .and_then(|rhi| rhi.trusted_worker_pubkeys.clone())
   1000         {
   1001             normalize_pubkeys(values, "user config [rhi].trusted_worker_pubkeys")?
   1002         } else if let Some(values) = workspace_config
   1003             .and_then(|config| config.rhi.as_ref())
   1004             .and_then(|rhi| rhi.trusted_worker_pubkeys.clone())
   1005         {
   1006             normalize_pubkeys(values, "workspace config [rhi].trusted_worker_pubkeys")?
   1007         } else {
   1008             Vec::new()
   1009         };
   1010 
   1011     Ok(RhiConfig {
   1012         trusted_worker_pubkeys,
   1013     })
   1014 }
   1015 
   1016 fn parse_pubkey_env_value(value: &str, key: &str) -> Result<Vec<String>, RuntimeError> {
   1017     let entries = value
   1018         .split(',')
   1019         .map(str::trim)
   1020         .filter(|entry| !entry.is_empty())
   1021         .map(ToOwned::to_owned)
   1022         .collect::<Vec<_>>();
   1023     normalize_pubkeys(entries, key)
   1024 }
   1025 
   1026 fn normalize_pubkeys(values: Vec<String>, source: &str) -> Result<Vec<String>, RuntimeError> {
   1027     let mut normalized = Vec::new();
   1028     for value in values {
   1029         let pubkey = validate_pubkey(value.as_str(), source)?;
   1030         if !normalized.iter().any(|existing| existing == &pubkey) {
   1031             normalized.push(pubkey);
   1032         }
   1033     }
   1034     Ok(normalized)
   1035 }
   1036 
   1037 fn validate_pubkey(value: &str, source: &str) -> Result<String, RuntimeError> {
   1038     let trimmed = value.trim();
   1039     if trimmed.len() != 64 || !trimmed.chars().all(|char| char.is_ascii_hexdigit()) {
   1040         return Err(RuntimeError::Config(format!(
   1041             "{source} must contain 64-character hex Nostr public keys"
   1042         )));
   1043     }
   1044     Ok(trimmed.to_ascii_lowercase())
   1045 }
   1046 
   1047 fn resolve_capability_bindings(
   1048     user_config: Option<&CliConfigFile>,
   1049     workspace_config: Option<&CliConfigFile>,
   1050 ) -> Result<Vec<CapabilityBindingConfig>, RuntimeError> {
   1051     let workspace = resolve_file_capability_bindings(
   1052         workspace_config.and_then(|config| config.capability_binding.as_deref()),
   1053         CapabilityBindingSource::WorkspaceConfig,
   1054     )?;
   1055     let user = resolve_file_capability_bindings(
   1056         user_config.and_then(|config| config.capability_binding.as_deref()),
   1057         CapabilityBindingSource::UserConfig,
   1058     )?;
   1059 
   1060     let mut merged = BTreeMap::new();
   1061     for binding in workspace.into_iter().chain(user) {
   1062         merged.insert(binding.capability_id.clone(), binding);
   1063     }
   1064 
   1065     Ok(CAPABILITY_BINDING_SPECS
   1066         .iter()
   1067         .filter_map(|spec| merged.remove(spec.capability_id))
   1068         .collect())
   1069 }
   1070 
   1071 fn resolve_file_capability_bindings(
   1072     bindings: Option<&[CapabilityBindingFileConfig]>,
   1073     source: CapabilityBindingSource,
   1074 ) -> Result<Vec<CapabilityBindingConfig>, RuntimeError> {
   1075     let Some(bindings) = bindings else {
   1076         return Ok(Vec::new());
   1077     };
   1078 
   1079     let mut seen = BTreeMap::new();
   1080     let mut resolved = Vec::with_capacity(bindings.len());
   1081 
   1082     for binding in bindings {
   1083         let capability = binding.capability.trim();
   1084         let provider = binding.provider.trim();
   1085         let Some(spec) = capability_binding_spec(capability) else {
   1086             return Err(RuntimeError::Config(format!(
   1087                 "unknown capability_binding capability `{capability}`"
   1088             )));
   1089         };
   1090         if provider != spec.provider_runtime_id {
   1091             return Err(RuntimeError::Config(format!(
   1092                 "capability_binding `{capability}` must use provider `{}`, got `{provider}`",
   1093                 spec.provider_runtime_id
   1094             )));
   1095         }
   1096         if seen.insert(spec.capability_id.to_owned(), ()).is_some() {
   1097             return Err(RuntimeError::Config(format!(
   1098                 "capability_binding `{capability}` is duplicated in one config file"
   1099             )));
   1100         }
   1101 
   1102         let target = binding.target.trim();
   1103         if target.is_empty() {
   1104             return Err(RuntimeError::Config(format!(
   1105                 "capability_binding `{capability}` target must not be empty"
   1106             )));
   1107         }
   1108 
   1109         let managed_account_ref = normalize_binding_ref(
   1110             binding
   1111                 .managed_account_ref
   1112                 .as_deref()
   1113                 .map(str::trim)
   1114                 .filter(|value| !value.is_empty()),
   1115         );
   1116         let signer_session_ref = normalize_binding_ref(
   1117             binding
   1118                 .signer_session_ref
   1119                 .as_deref()
   1120                 .map(str::trim)
   1121                 .filter(|value| !value.is_empty()),
   1122         );
   1123         if spec.capability_id != SIGNER_REMOTE_NIP46_CAPABILITY
   1124             && (managed_account_ref.is_some() || signer_session_ref.is_some())
   1125         {
   1126             return Err(RuntimeError::Config(format!(
   1127                 "capability_binding `{capability}` may not set managed_account_ref or signer_session_ref"
   1128             )));
   1129         }
   1130 
   1131         resolved.push(CapabilityBindingConfig {
   1132             capability_id: spec.capability_id.to_owned(),
   1133             provider_runtime_id: spec.provider_runtime_id.to_owned(),
   1134             binding_model: spec.binding_model.to_owned(),
   1135             target_kind: parse_capability_binding_target_kind(
   1136                 binding.target_kind.as_str(),
   1137                 spec.capability_id,
   1138             )?,
   1139             target: target.to_owned(),
   1140             managed_account_ref,
   1141             signer_session_ref,
   1142             source,
   1143         });
   1144     }
   1145 
   1146     Ok(resolved)
   1147 }
   1148 
   1149 fn capability_binding_spec(capability_id: &str) -> Option<CapabilityBindingSpec> {
   1150     CAPABILITY_BINDING_SPECS
   1151         .iter()
   1152         .copied()
   1153         .find(|spec| spec.capability_id == capability_id)
   1154 }
   1155 
   1156 fn parse_capability_binding_target_kind(
   1157     value: &str,
   1158     capability_id: &str,
   1159 ) -> Result<CapabilityBindingTargetKind, RuntimeError> {
   1160     match value.trim().to_ascii_lowercase().as_str() {
   1161         "managed_instance" => Ok(CapabilityBindingTargetKind::ManagedInstance),
   1162         "explicit_endpoint" => Ok(CapabilityBindingTargetKind::ExplicitEndpoint),
   1163         other => Err(RuntimeError::Config(format!(
   1164             "capability_binding `{capability_id}` target_kind must be `managed_instance` or `explicit_endpoint`, got `{other}`"
   1165         ))),
   1166     }
   1167 }
   1168 
   1169 fn normalize_binding_ref(value: Option<&str>) -> Option<String> {
   1170     value.map(ToOwned::to_owned)
   1171 }
   1172 
   1173 fn resolve_relay_config(
   1174     args: &RuntimeInvocationArgs,
   1175     env: &dyn Environment,
   1176     env_file: &EnvFileValues,
   1177     user_config: Option<&CliConfigFile>,
   1178     workspace_config: Option<&CliConfigFile>,
   1179 ) -> Result<RelayConfig, RuntimeError> {
   1180     let publish_policy = resolve_relay_publish_policy(user_config, workspace_config)?
   1181         .unwrap_or(RelayPublishPolicy::Any);
   1182 
   1183     if !args.relay.is_empty() {
   1184         return Ok(RelayConfig {
   1185             urls: normalize_relay_urls(args.relay.clone(), "--relay")?,
   1186             publish_policy,
   1187             source: RelayConfigSource::Flags,
   1188         });
   1189     }
   1190 
   1191     if let Some(value) = env_value(env, env_file, &[ENV_CLI_RELAYS_URLS]) {
   1192         return Ok(RelayConfig {
   1193             urls: parse_relay_env_value(value.as_str(), ENV_CLI_RELAYS_URLS)?,
   1194             publish_policy,
   1195             source: RelayConfigSource::Environment,
   1196         });
   1197     }
   1198 
   1199     if let Some(relay) = user_config.and_then(|config| config.relays.as_ref()) {
   1200         if let Some(urls) = relay.urls.clone() {
   1201             return Ok(RelayConfig {
   1202                 urls: normalize_relay_urls(urls, "user config [relays].urls")?,
   1203                 publish_policy,
   1204                 source: RelayConfigSource::UserConfig,
   1205             });
   1206         }
   1207     }
   1208 
   1209     if let Some(relay) = workspace_config.and_then(|config| config.relays.as_ref()) {
   1210         if let Some(urls) = relay.urls.clone() {
   1211             return Ok(RelayConfig {
   1212                 urls: normalize_relay_urls(urls, "workspace config [relays].urls")?,
   1213                 publish_policy,
   1214                 source: RelayConfigSource::WorkspaceConfig,
   1215             });
   1216         }
   1217     }
   1218 
   1219     Ok(RelayConfig {
   1220         urls: Vec::new(),
   1221         publish_policy,
   1222         source: RelayConfigSource::Defaults,
   1223     })
   1224 }
   1225 
   1226 fn resolve_signer_config(
   1227     args: &RuntimeInvocationArgs,
   1228     env: &dyn Environment,
   1229     env_file: &EnvFileValues,
   1230     user_config: Option<&CliConfigFile>,
   1231     workspace_config: Option<&CliConfigFile>,
   1232 ) -> Result<SignerConfig, RuntimeError> {
   1233     let backend = if let Some(value) = args.signer.clone() {
   1234         parse_signer_mode("internal invocation signer mode", value)?
   1235     } else if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_SIGNER_BACKEND]) {
   1236         parse_signer_mode(key.as_str(), value)?
   1237     } else if let Some(value) = user_config
   1238         .and_then(|config| config.signer.as_ref())
   1239         .and_then(|signer| signer.backend.clone())
   1240     {
   1241         parse_signer_mode("user config [signer].backend", value)?
   1242     } else if let Some(value) = workspace_config
   1243         .and_then(|config| config.signer.as_ref())
   1244         .and_then(|signer| signer.backend.clone())
   1245     {
   1246         parse_signer_mode("workspace config [signer].backend", value)?
   1247     } else {
   1248         SignerBackend::Local
   1249     };
   1250 
   1251     Ok(SignerConfig { backend })
   1252 }
   1253 
   1254 fn resolve_publish_config(
   1255     args: &RuntimeInvocationArgs,
   1256     env: &dyn Environment,
   1257     env_file: &EnvFileValues,
   1258     user_config: Option<&CliConfigFile>,
   1259     workspace_config: Option<&CliConfigFile>,
   1260 ) -> Result<PublishConfig, RuntimeError> {
   1261     let radrootsd_proxy =
   1262         resolve_radrootsd_proxy_config(env, env_file, user_config, workspace_config)?;
   1263     if let Some(value) = args.publish_transport.clone() {
   1264         return Ok(PublishConfig {
   1265             transport: parse_publish_transport("--publish-transport", value)?,
   1266             source: PublishTransportSource::Flags,
   1267             radrootsd_proxy,
   1268         });
   1269     }
   1270 
   1271     if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_PUBLISH_TRANSPORT]) {
   1272         return Ok(PublishConfig {
   1273             transport: parse_publish_transport(key.as_str(), value)?,
   1274             source: PublishTransportSource::Environment,
   1275             radrootsd_proxy,
   1276         });
   1277     }
   1278 
   1279     if let Some(value) = user_config
   1280         .and_then(|config| config.publish.as_ref())
   1281         .and_then(|publish| publish.transport.clone())
   1282     {
   1283         return Ok(PublishConfig {
   1284             transport: parse_publish_transport("user config [publish].transport", value)?,
   1285             source: PublishTransportSource::UserConfig,
   1286             radrootsd_proxy,
   1287         });
   1288     }
   1289 
   1290     if let Some(value) = workspace_config
   1291         .and_then(|config| config.publish.as_ref())
   1292         .and_then(|publish| publish.transport.clone())
   1293     {
   1294         return Ok(PublishConfig {
   1295             transport: parse_publish_transport("workspace config [publish].transport", value)?,
   1296             source: PublishTransportSource::WorkspaceConfig,
   1297             radrootsd_proxy,
   1298         });
   1299     }
   1300 
   1301     Ok(PublishConfig {
   1302         transport: PublishTransport::DirectNostrRelay,
   1303         source: PublishTransportSource::Defaults,
   1304         radrootsd_proxy,
   1305     })
   1306 }
   1307 
   1308 fn resolve_myc_config(
   1309     args: &RuntimeInvocationArgs,
   1310     env: &dyn Environment,
   1311     env_file: &EnvFileValues,
   1312     user_config: Option<&CliConfigFile>,
   1313     workspace_config: Option<&CliConfigFile>,
   1314 ) -> Result<MycConfig, RuntimeError> {
   1315     let executable = args
   1316         .myc_executable
   1317         .clone()
   1318         .or_else(|| env_value(env, env_file, &[ENV_CLI_MYC_EXECUTABLE]).map(PathBuf::from))
   1319         .or_else(|| {
   1320             user_config
   1321                 .and_then(|config| config.myc.as_ref())
   1322                 .and_then(|myc| myc.executable.clone())
   1323         })
   1324         .or_else(|| {
   1325             workspace_config
   1326                 .and_then(|config| config.myc.as_ref())
   1327                 .and_then(|myc| myc.executable.clone())
   1328         })
   1329         .unwrap_or_else(|| PathBuf::from("myc"));
   1330 
   1331     Ok(MycConfig {
   1332         executable,
   1333         status_timeout_ms: resolve_myc_status_timeout_ms(
   1334             args,
   1335             env,
   1336             env_file,
   1337             user_config,
   1338             workspace_config,
   1339         )?,
   1340     })
   1341 }
   1342 
   1343 fn resolve_myc_status_timeout_ms(
   1344     args: &RuntimeInvocationArgs,
   1345     env: &dyn Environment,
   1346     env_file: &EnvFileValues,
   1347     user_config: Option<&CliConfigFile>,
   1348     workspace_config: Option<&CliConfigFile>,
   1349 ) -> Result<u64, RuntimeError> {
   1350     if let Some(value) = args.myc_status_timeout_ms {
   1351         return validate_myc_status_timeout_ms("--myc-status-timeout-ms", value);
   1352     }
   1353 
   1354     if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_MYC_STATUS_TIMEOUT_MS]) {
   1355         let parsed = parse_u64_value(key.as_str(), value.as_str())
   1356             .map_err(|err| RuntimeError::Config(err.to_string()))?;
   1357         return validate_myc_status_timeout_ms(key.as_str(), parsed);
   1358     }
   1359 
   1360     if let Some(value) = user_config
   1361         .and_then(|config| config.myc.as_ref())
   1362         .and_then(|myc| myc.status_timeout_ms)
   1363     {
   1364         return validate_myc_status_timeout_ms("user config [myc].status_timeout_ms", value);
   1365     }
   1366 
   1367     if let Some(value) = workspace_config
   1368         .and_then(|config| config.myc.as_ref())
   1369         .and_then(|myc| myc.status_timeout_ms)
   1370     {
   1371         return validate_myc_status_timeout_ms("workspace config [myc].status_timeout_ms", value);
   1372     }
   1373 
   1374     Ok(DEFAULT_MYC_STATUS_TIMEOUT_MS)
   1375 }
   1376 
   1377 fn validate_myc_status_timeout_ms(source: &str, value: u64) -> Result<u64, RuntimeError> {
   1378     if value == 0 {
   1379         return Err(RuntimeError::Config(format!(
   1380             "{source} must be greater than zero"
   1381         )));
   1382     }
   1383     Ok(value)
   1384 }
   1385 
   1386 fn resolve_hyf_enabled(
   1387     args: &RuntimeInvocationArgs,
   1388     env: &dyn Environment,
   1389     env_file: &EnvFileValues,
   1390     user_config: Option<&CliConfigFile>,
   1391     workspace_config: Option<&CliConfigFile>,
   1392 ) -> Result<bool, RuntimeError> {
   1393     match (args.hyf_enabled, args.no_hyf_enabled) {
   1394         (true, true) => {
   1395             return Err(RuntimeError::Config(
   1396                 "flags --hyf-enabled and --no-hyf-enabled cannot be used together".to_owned(),
   1397             ));
   1398         }
   1399         (true, false) => return Ok(true),
   1400         (false, true) => return Ok(false),
   1401         (false, false) => {}
   1402     }
   1403 
   1404     if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_HYF_ENABLED]) {
   1405         return parse_bool_env(key.as_str(), value.as_str());
   1406     }
   1407 
   1408     if let Some(enabled) = user_config
   1409         .and_then(|config| config.hyf.as_ref())
   1410         .and_then(|hyf| hyf.enabled)
   1411     {
   1412         return Ok(enabled);
   1413     }
   1414 
   1415     if let Some(enabled) = workspace_config
   1416         .and_then(|config| config.hyf.as_ref())
   1417         .and_then(|hyf| hyf.enabled)
   1418     {
   1419         return Ok(enabled);
   1420     }
   1421 
   1422     Ok(false)
   1423 }
   1424 
   1425 fn resolve_hyf_executable(
   1426     args: &RuntimeInvocationArgs,
   1427     env: &dyn Environment,
   1428     env_file: &EnvFileValues,
   1429     user_config: Option<&CliConfigFile>,
   1430     workspace_config: Option<&CliConfigFile>,
   1431 ) -> PathBuf {
   1432     args.hyf_executable
   1433         .clone()
   1434         .or_else(|| env_value(env, env_file, &[ENV_CLI_HYF_EXECUTABLE]).map(PathBuf::from))
   1435         .or_else(|| {
   1436             user_config
   1437                 .and_then(|config| config.hyf.as_ref())
   1438                 .and_then(|hyf| hyf.executable.clone())
   1439         })
   1440         .or_else(|| {
   1441             workspace_config
   1442                 .and_then(|config| config.hyf.as_ref())
   1443                 .and_then(|hyf| hyf.executable.clone())
   1444         })
   1445         .unwrap_or_else(|| PathBuf::from(DEFAULT_HYF_EXECUTABLE))
   1446 }
   1447 
   1448 fn resolve_relay_publish_policy(
   1449     user_config: Option<&CliConfigFile>,
   1450     workspace_config: Option<&CliConfigFile>,
   1451 ) -> Result<Option<RelayPublishPolicy>, RuntimeError> {
   1452     if let Some(value) = user_config
   1453         .and_then(|config| config.relays.as_ref())
   1454         .and_then(|relay| relay.publish_policy.as_deref())
   1455     {
   1456         return parse_relay_publish_policy(value).map(Some);
   1457     }
   1458 
   1459     if let Some(value) = workspace_config
   1460         .and_then(|config| config.relays.as_ref())
   1461         .and_then(|relay| relay.publish_policy.as_deref())
   1462     {
   1463         return parse_relay_publish_policy(value).map(Some);
   1464     }
   1465 
   1466     Ok(None)
   1467 }
   1468 
   1469 fn parse_relay_publish_policy(value: &str) -> Result<RelayPublishPolicy, RuntimeError> {
   1470     match value.trim().to_ascii_lowercase().as_str() {
   1471         "any" => Ok(RelayPublishPolicy::Any),
   1472         other => Err(RuntimeError::Config(format!(
   1473             "[relays].publish_policy must be `any`, got `{other}`"
   1474         ))),
   1475     }
   1476 }
   1477 
   1478 fn validate_rpc_url(value: &str) -> Result<String, RuntimeError> {
   1479     let trimmed = value.trim();
   1480     if trimmed.is_empty() {
   1481         return Err(RuntimeError::Config("rpc url must not be empty".to_owned()));
   1482     }
   1483     let parsed = Url::parse(trimmed)
   1484         .map_err(|err| RuntimeError::Config(format!("rpc url `{trimmed}` is invalid: {err}")))?;
   1485     if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
   1486         return Err(RuntimeError::Config(format!(
   1487             "rpc url must use http or https, got `{trimmed}`"
   1488         )));
   1489     }
   1490     Ok(trimmed.to_owned())
   1491 }
   1492 
   1493 fn parse_relay_env_value(value: &str, key: &str) -> Result<Vec<String>, RuntimeError> {
   1494     let entries = value
   1495         .split(',')
   1496         .map(str::trim)
   1497         .map(ToOwned::to_owned)
   1498         .collect::<Vec<_>>();
   1499 
   1500     if entries.is_empty() {
   1501         return Err(RuntimeError::Config(format!(
   1502             "{key} must contain at least one websocket relay url"
   1503         )));
   1504     }
   1505 
   1506     normalize_relay_urls(entries, key)
   1507 }
   1508 
   1509 fn normalize_relay_urls(values: Vec<String>, source: &str) -> Result<Vec<String>, RuntimeError> {
   1510     let mut normalized = Vec::new();
   1511     for value in values {
   1512         let relay = validate_relay_url(value.as_str(), source)?;
   1513         if !normalized.iter().any(|existing| existing == &relay) {
   1514             normalized.push(relay);
   1515         }
   1516     }
   1517     Ok(normalized)
   1518 }
   1519 
   1520 fn validate_relay_url(value: &str, source: &str) -> Result<String, RuntimeError> {
   1521     let trimmed = value.trim();
   1522     if trimmed.is_empty() {
   1523         return Err(RuntimeError::Config(format!(
   1524             "{source} contains an empty relay url"
   1525         )));
   1526     }
   1527     normalize_relay_url(trimmed).map_err(|error| match error {
   1528         RelayUrlValidationError::UnsupportedScheme(_) => RuntimeError::Config(format!(
   1529             "{source} must use websocket relay urls, got `{trimmed}`"
   1530         )),
   1531         _ => RuntimeError::Config(format!(
   1532             "{source} contains invalid relay url `{trimmed}`: {error}"
   1533         )),
   1534     })
   1535 }
   1536 
   1537 fn resolve_env_file_path(args: &RuntimeInvocationArgs, env: &dyn Environment) -> Option<PathBuf> {
   1538     args.env_file
   1539         .clone()
   1540         .or_else(|| env.var(ENV_CLI_FILE_PATH).map(PathBuf::from))
   1541         .or_else(|| {
   1542             let default_path = PathBuf::from(DEFAULT_ENV_PATH);
   1543             default_path.exists().then_some(default_path)
   1544         })
   1545 }
   1546 
   1547 fn resolve_output_format(
   1548     args: &RuntimeInvocationArgs,
   1549     env: &dyn Environment,
   1550     env_file: &EnvFileValues,
   1551     user_config: Option<&CliConfigFile>,
   1552     workspace_config: Option<&CliConfigFile>,
   1553 ) -> Result<OutputFormat, RuntimeError> {
   1554     if args.output_format.is_some() && (args.json || args.ndjson) {
   1555         return Err(RuntimeError::Config(
   1556             "flags --output, --json, and --ndjson cannot be used together".to_owned(),
   1557         ));
   1558     }
   1559 
   1560     match (args.output_format, args.json, args.ndjson) {
   1561         (_, true, true) => {
   1562             return Err(RuntimeError::Config(
   1563                 "flags --json and --ndjson cannot be used together".to_owned(),
   1564             ));
   1565         }
   1566         (Some(format), false, false) => return Ok(format.as_output_format()),
   1567         (None, true, false) => return Ok(OutputFormat::Json),
   1568         (None, false, true) => return Ok(OutputFormat::Ndjson),
   1569         (None, false, false) => {}
   1570         (Some(_), true, false) | (Some(_), false, true) => unreachable!(),
   1571     }
   1572     if let Some(value) = env_value(env, env_file, &[ENV_CLI_OUTPUT_FORMAT]) {
   1573         return parse_output_format(value.as_str());
   1574     }
   1575 
   1576     if let Some(value) = user_config
   1577         .and_then(|config| config.output.as_ref())
   1578         .and_then(|output| output.format.as_deref())
   1579     {
   1580         return parse_output_format(value);
   1581     }
   1582 
   1583     match workspace_config
   1584         .and_then(|config| config.output.as_ref())
   1585         .and_then(|output| output.format.as_deref())
   1586     {
   1587         Some(value) => parse_output_format(value),
   1588         None => Ok(OutputFormat::Human),
   1589     }
   1590 }
   1591 
   1592 fn resolve_verbosity(args: &RuntimeInvocationArgs) -> Result<Verbosity, RuntimeError> {
   1593     let selected = [args.quiet, args.verbose, args.trace]
   1594         .into_iter()
   1595         .filter(|selected| *selected)
   1596         .count();
   1597     if selected > 1 {
   1598         return Err(RuntimeError::Config(
   1599             "flags --quiet, --verbose, and --trace are mutually exclusive".to_owned(),
   1600         ));
   1601     }
   1602 
   1603     if args.quiet {
   1604         Ok(Verbosity::Quiet)
   1605     } else if args.trace {
   1606         Ok(Verbosity::Trace)
   1607     } else if args.verbose {
   1608         Ok(Verbosity::Verbose)
   1609     } else {
   1610         Ok(Verbosity::Normal)
   1611     }
   1612 }
   1613 
   1614 fn resolve_interaction_config(
   1615     args: &RuntimeInvocationArgs,
   1616     env: &dyn Environment,
   1617 ) -> InteractionConfig {
   1618     let stdin_tty = env.stdin_is_tty();
   1619     let stdout_tty = env.stdout_is_tty();
   1620     let input_enabled = !args.no_input;
   1621     let prompts_allowed = input_enabled && stdin_tty && stdout_tty;
   1622     let confirmations_allowed = prompts_allowed && !args.yes;
   1623     InteractionConfig {
   1624         input_enabled,
   1625         assume_yes: args.yes,
   1626         stdin_tty,
   1627         stdout_tty,
   1628         prompts_allowed,
   1629         confirmations_allowed,
   1630     }
   1631 }
   1632 
   1633 fn resolve_logging_filter(
   1634     args: &RuntimeInvocationArgs,
   1635     env: &dyn Environment,
   1636     env_file: &EnvFileValues,
   1637     user_config: Option<&CliConfigFile>,
   1638     workspace_config: Option<&CliConfigFile>,
   1639 ) -> String {
   1640     args.log_filter
   1641         .clone()
   1642         .or_else(|| env_value(env, env_file, &[ENV_CLI_LOG_FILTER]))
   1643         .or_else(|| {
   1644             user_config
   1645                 .and_then(|config| config.logging.as_ref())
   1646                 .and_then(|logging| logging.filter.clone())
   1647         })
   1648         .or_else(|| {
   1649             workspace_config
   1650                 .and_then(|config| config.logging.as_ref())
   1651                 .and_then(|logging| logging.filter.clone())
   1652         })
   1653         .unwrap_or_else(|| DEFAULT_LOG_FILTER.to_owned())
   1654 }
   1655 
   1656 fn resolve_logging_directory(
   1657     args: &RuntimeInvocationArgs,
   1658     env: &dyn Environment,
   1659     env_file: &EnvFileValues,
   1660     user_config: Option<&CliConfigFile>,
   1661     workspace_config: Option<&CliConfigFile>,
   1662     default_logs_root: &Path,
   1663 ) -> Option<PathBuf> {
   1664     args.log_dir
   1665         .clone()
   1666         .or_else(|| env_value(env, env_file, &[ENV_CLI_LOG_DIR]).map(PathBuf::from))
   1667         .or_else(|| {
   1668             user_config
   1669                 .and_then(|config| config.logging.as_ref())
   1670                 .and_then(|logging| logging.output_dir.clone())
   1671         })
   1672         .or_else(|| {
   1673             workspace_config
   1674                 .and_then(|config| config.logging.as_ref())
   1675                 .and_then(|logging| logging.output_dir.clone())
   1676         })
   1677         .or_else(|| Some(default_logs_root.to_path_buf()))
   1678 }
   1679 
   1680 fn resolve_logging_stdout(
   1681     args: &RuntimeInvocationArgs,
   1682     env: &dyn Environment,
   1683     env_file: &EnvFileValues,
   1684     user_config: Option<&CliConfigFile>,
   1685     workspace_config: Option<&CliConfigFile>,
   1686 ) -> Result<bool, RuntimeError> {
   1687     resolve_bool_pair(
   1688         args.log_stdout,
   1689         args.no_log_stdout,
   1690         &[ENV_CLI_LOG_STDOUT],
   1691         user_config
   1692             .and_then(|config| config.logging.as_ref())
   1693             .and_then(|logging| logging.stdout),
   1694         workspace_config
   1695             .and_then(|config| config.logging.as_ref())
   1696             .and_then(|logging| logging.stdout),
   1697         false,
   1698         env,
   1699         env_file,
   1700         "--log-stdout",
   1701         "--no-log-stdout",
   1702     )
   1703 }
   1704 
   1705 fn validate_logging_output_contract(
   1706     output: &OutputConfig,
   1707     logging: &LoggingConfig,
   1708 ) -> Result<(), RuntimeError> {
   1709     if logging.stdout && matches!(output.format, OutputFormat::Json | OutputFormat::Ndjson) {
   1710         return Err(RuntimeError::Config(format!(
   1711             "stdout logging cannot be used with {} output; unset {ENV_CLI_LOG_STDOUT} or use --no-log-stdout",
   1712             output.format.as_str()
   1713         )));
   1714     }
   1715 
   1716     Ok(())
   1717 }
   1718 
   1719 fn resolve_bool_pair(
   1720     positive_flag: bool,
   1721     negative_flag: bool,
   1722     env_keys: &[&str],
   1723     user_value: Option<bool>,
   1724     workspace_value: Option<bool>,
   1725     default: bool,
   1726     env: &dyn Environment,
   1727     env_file: &EnvFileValues,
   1728     positive_label: &str,
   1729     negative_label: &str,
   1730 ) -> Result<bool, RuntimeError> {
   1731     match (positive_flag, negative_flag) {
   1732         (true, true) => Err(RuntimeError::Config(format!(
   1733             "flags {positive_label} and {negative_label} cannot be used together"
   1734         ))),
   1735         (true, false) => Ok(true),
   1736         (false, true) => Ok(false),
   1737         (false, false) => match env_value_entry(env, env_file, env_keys) {
   1738             Some((key, value)) => parse_bool_env(key.as_str(), value.as_str()),
   1739             None => Ok(user_value.or(workspace_value).unwrap_or(default)),
   1740         },
   1741     }
   1742 }
   1743 
   1744 fn env_value(env: &dyn Environment, env_file: &EnvFileValues, keys: &[&str]) -> Option<String> {
   1745     env_value_entry(env, env_file, keys).map(|(_, value)| value)
   1746 }
   1747 
   1748 fn env_value_entry(
   1749     env: &dyn Environment,
   1750     env_file: &EnvFileValues,
   1751     keys: &[&str],
   1752 ) -> Option<(String, String)> {
   1753     keys.iter()
   1754         .find_map(|key| env.var(key).map(|value| ((*key).to_owned(), value)))
   1755         .or_else(|| {
   1756             keys.iter().find_map(|key| {
   1757                 env_file
   1758                     .0
   1759                     .get(*key)
   1760                     .cloned()
   1761                     .map(|value| ((*key).to_owned(), value))
   1762             })
   1763         })
   1764 }
   1765 
   1766 fn load_env_file_values(path: Option<&Path>) -> Result<EnvFileValues, RuntimeError> {
   1767     let Some(path) = path else {
   1768         return Ok(EnvFileValues::default());
   1769     };
   1770     let raw = fs::read_to_string(path).map_err(|err| {
   1771         RuntimeError::Config(format!("failed to read env file {}: {err}", path.display()))
   1772     })?;
   1773     parse_env_file_values(&raw, path)
   1774 }
   1775 
   1776 fn parse_env_file_values(raw: &str, path: &Path) -> Result<EnvFileValues, RuntimeError> {
   1777     parse_strict_env_file(raw, path, SUPPORTED_ENV_FILE_KEYS)
   1778         .map(|values| EnvFileValues(values.into_inner()))
   1779         .map_err(|err| RuntimeError::Config(err.to_string()))
   1780 }
   1781 
   1782 fn parse_output_format(value: &str) -> Result<OutputFormat, RuntimeError> {
   1783     match value.trim().to_ascii_lowercase().as_str() {
   1784         "human" => Ok(OutputFormat::Human),
   1785         "json" => Ok(OutputFormat::Json),
   1786         "ndjson" => Ok(OutputFormat::Ndjson),
   1787         other => Err(RuntimeError::Config(format!(
   1788             "{ENV_CLI_OUTPUT_FORMAT} must be `human`, `json`, or `ndjson`, got `{other}`"
   1789         ))),
   1790     }
   1791 }
   1792 
   1793 fn parse_signer_mode(source: &str, value: String) -> Result<SignerBackend, RuntimeError> {
   1794     match value.trim().to_ascii_lowercase().as_str() {
   1795         "local" => Ok(SignerBackend::Local),
   1796         "myc" => Ok(SignerBackend::Myc),
   1797         other => Err(RuntimeError::Config(format!(
   1798             "{source} must be `local` or `myc`, got `{other}`"
   1799         ))),
   1800     }
   1801 }
   1802 
   1803 fn parse_publish_transport(source: &str, value: String) -> Result<PublishTransport, RuntimeError> {
   1804     match value.trim().to_ascii_lowercase().as_str() {
   1805         "direct_nostr_relay" => Ok(PublishTransport::DirectNostrRelay),
   1806         "radrootsd_proxy" => Ok(PublishTransport::RadrootsdProxy),
   1807         other => Err(RuntimeError::Config(format!(
   1808             "{source} must be `{}` or `{}`, got `{other}`",
   1809             PublishTransport::DirectNostrRelay.as_str(),
   1810             PublishTransport::RadrootsdProxy.as_str()
   1811         ))),
   1812     }
   1813 }
   1814 
   1815 fn resolve_account_secret_backend(
   1816     env: &dyn Environment,
   1817     env_file: &EnvFileValues,
   1818     user_config: Option<&CliConfigFile>,
   1819     workspace_config: Option<&CliConfigFile>,
   1820 ) -> Result<Option<RadrootsSecretBackend>, RuntimeError> {
   1821     if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_ACCOUNT_SECRET_BACKEND]) {
   1822         return parse_account_secret_backend(key.as_str(), value.as_str()).map(Some);
   1823     }
   1824 
   1825     if let Some(value) = user_config
   1826         .and_then(|config| config.account.as_ref())
   1827         .and_then(|account| account.secret.as_ref())
   1828         .and_then(|secret| secret.backend.as_deref())
   1829     {
   1830         return parse_account_secret_backend("user config [account.secret].backend", value)
   1831             .map(Some);
   1832     }
   1833 
   1834     workspace_config
   1835         .and_then(|config| config.account.as_ref())
   1836         .and_then(|account| account.secret.as_ref())
   1837         .and_then(|secret| secret.backend.as_deref())
   1838         .map(|value| {
   1839             parse_account_secret_backend("workspace config [account.secret].backend", value)
   1840         })
   1841         .transpose()
   1842 }
   1843 
   1844 fn resolve_account_secret_fallback(
   1845     env: &dyn Environment,
   1846     env_file: &EnvFileValues,
   1847     user_config: Option<&CliConfigFile>,
   1848     workspace_config: Option<&CliConfigFile>,
   1849 ) -> Result<Option<Option<RadrootsSecretBackend>>, RuntimeError> {
   1850     if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_ACCOUNT_SECRET_FALLBACK]) {
   1851         return parse_account_secret_fallback(key.as_str(), value.as_str()).map(Some);
   1852     }
   1853 
   1854     if let Some(value) = user_config
   1855         .and_then(|config| config.account.as_ref())
   1856         .and_then(|account| account.secret.as_ref())
   1857         .and_then(|secret| secret.fallback.as_deref())
   1858     {
   1859         return parse_account_secret_fallback("user config [account.secret].fallback", value)
   1860             .map(Some);
   1861     }
   1862 
   1863     workspace_config
   1864         .and_then(|config| config.account.as_ref())
   1865         .and_then(|account| account.secret.as_ref())
   1866         .and_then(|secret| secret.fallback.as_deref())
   1867         .map(|value| {
   1868             parse_account_secret_fallback("workspace config [account.secret].fallback", value)
   1869         })
   1870         .transpose()
   1871 }
   1872 
   1873 fn parse_account_secret_fallback(
   1874     key: &str,
   1875     value: &str,
   1876 ) -> Result<Option<RadrootsSecretBackend>, RuntimeError> {
   1877     match value.trim().to_ascii_lowercase().as_str() {
   1878         "none" => Ok(None),
   1879         "host_vault" => Ok(Some(RadrootsSecretBackend::HostVault(
   1880             RadrootsHostVaultPolicy::desktop(),
   1881         ))),
   1882         "encrypted_file" => Ok(Some(RadrootsSecretBackend::EncryptedFile)),
   1883         other => Err(RuntimeError::Config(format!(
   1884             "{key} must be `host_vault`, `encrypted_file`, or `none`, got `{other}`"
   1885         ))),
   1886     }
   1887 }
   1888 
   1889 fn parse_account_secret_backend(
   1890     key: &str,
   1891     value: &str,
   1892 ) -> Result<RadrootsSecretBackend, RuntimeError> {
   1893     match value.trim().to_ascii_lowercase().as_str() {
   1894         "host_vault" => Ok(RadrootsSecretBackend::HostVault(
   1895             RadrootsHostVaultPolicy::desktop(),
   1896         )),
   1897         "encrypted_file" => Ok(RadrootsSecretBackend::EncryptedFile),
   1898         other => Err(RuntimeError::Config(format!(
   1899             "{key} must be `host_vault` or `encrypted_file`, got `{other}`"
   1900         ))),
   1901     }
   1902 }
   1903 
   1904 fn parse_bool_env(key: &str, value: &str) -> Result<bool, RuntimeError> {
   1905     parse_bool_value(key, value).map_err(|err| RuntimeError::Config(err.to_string()))
   1906 }
   1907 
   1908 #[cfg(test)]
   1909 mod tests {
   1910     use super::{
   1911         AccountConfig, AccountSecretContractConfig, CapabilityBindingConfig,
   1912         CapabilityBindingSource, CapabilityBindingTargetKind, DEFAULT_HYF_EXECUTABLE,
   1913         DEFAULT_LOG_FILTER, DEFAULT_MYC_STATUS_TIMEOUT_MS, DEFAULT_RPC_URL, EnvFileValues,
   1914         Environment, HyfConfig, INFERENCE_HYF_STDIO_CAPABILITY, InteractionConfig, OutputConfig,
   1915         OutputFormat, PathsConfig, PublishConfig, PublishTransport, PublishTransportSource,
   1916         RelayConfigSource, RelayPublishPolicy, RuntimeConfig, SignerBackend, Verbosity,
   1917         parse_env_file_values,
   1918     };
   1919     use crate::cli::global::{RuntimeInvocationArgs, RuntimeOutputFormatArg};
   1920     use radroots_runtime_paths::{RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform};
   1921     use radroots_secret_vault::{RadrootsHostVaultPolicy, RadrootsSecretBackend};
   1922     use std::collections::BTreeMap;
   1923     use std::fs;
   1924     use std::path::{Path, PathBuf};
   1925     use tempfile::tempdir;
   1926 
   1927     struct MapEnvironment {
   1928         values: BTreeMap<String, String>,
   1929         current_dir: PathBuf,
   1930         path_resolver: RadrootsPathResolver,
   1931         stdin_tty: bool,
   1932         stdout_tty: bool,
   1933     }
   1934 
   1935     impl MapEnvironment {
   1936         fn new(values: BTreeMap<String, String>) -> Self {
   1937             Self {
   1938                 values,
   1939                 current_dir: PathBuf::from("/workspaces/radroots-cli"),
   1940                 path_resolver: RadrootsPathResolver::new(
   1941                     RadrootsPlatform::Linux,
   1942                     RadrootsHostEnvironment {
   1943                         home_dir: Some(PathBuf::from("/home/tester")),
   1944                         ..RadrootsHostEnvironment::default()
   1945                     },
   1946                 ),
   1947                 stdin_tty: false,
   1948                 stdout_tty: false,
   1949             }
   1950         }
   1951 
   1952         fn with_tty(mut self, stdin_tty: bool, stdout_tty: bool) -> Self {
   1953             self.stdin_tty = stdin_tty;
   1954             self.stdout_tty = stdout_tty;
   1955             self
   1956         }
   1957     }
   1958 
   1959     impl Environment for MapEnvironment {
   1960         fn var(&self, key: &str) -> Option<String> {
   1961             self.values.get(key).cloned()
   1962         }
   1963 
   1964         fn current_dir(&self) -> Result<PathBuf, crate::runtime::RuntimeError> {
   1965             Ok(self.current_dir.clone())
   1966         }
   1967 
   1968         fn path_resolver(&self) -> RadrootsPathResolver {
   1969             self.path_resolver.clone()
   1970         }
   1971 
   1972         fn stdin_is_tty(&self) -> bool {
   1973             self.stdin_tty
   1974         }
   1975 
   1976         fn stdout_is_tty(&self) -> bool {
   1977             self.stdout_tty
   1978         }
   1979     }
   1980 
   1981     fn repo_local_env(
   1982         workspace_root: PathBuf,
   1983         repo_local_root: PathBuf,
   1984         user_home: PathBuf,
   1985         mut values: BTreeMap<String, String>,
   1986     ) -> MapEnvironment {
   1987         values.insert(
   1988             "RADROOTS_CLI_PATHS_PROFILE".to_owned(),
   1989             "repo_local".to_owned(),
   1990         );
   1991         values.insert(
   1992             "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(),
   1993             repo_local_root.display().to_string(),
   1994         );
   1995 
   1996         MapEnvironment {
   1997             values,
   1998             current_dir: workspace_root,
   1999             path_resolver: RadrootsPathResolver::new(
   2000                 RadrootsPlatform::Linux,
   2001                 RadrootsHostEnvironment {
   2002                     home_dir: Some(user_home),
   2003                     ..RadrootsHostEnvironment::default()
   2004                 },
   2005             ),
   2006             stdin_tty: false,
   2007             stdout_tty: false,
   2008         }
   2009     }
   2010 
   2011     fn runtime_args() -> RuntimeInvocationArgs {
   2012         RuntimeInvocationArgs::default()
   2013     }
   2014 
   2015     #[test]
   2016     fn flags_override_environment_values() {
   2017         let args = RuntimeInvocationArgs {
   2018             output_format: Some(RuntimeOutputFormatArg::Human),
   2019             verbose: true,
   2020             dry_run: true,
   2021             no_color: true,
   2022             log_filter: Some("debug".to_owned()),
   2023             log_stdout: true,
   2024             identity_path: Some(PathBuf::from("custom-identity.json")),
   2025             signer: Some("local".to_owned()),
   2026             publish_transport: Some("direct_nostr_relay".to_owned()),
   2027             relay: vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()],
   2028             myc_executable: Some(PathBuf::from("bin/myc-cli")),
   2029             myc_status_timeout_ms: Some(2500),
   2030             hyf_enabled: true,
   2031             hyf_executable: Some(PathBuf::from("bin/hyfd-cli")),
   2032             ..runtime_args()
   2033         };
   2034         let env = MapEnvironment::new(BTreeMap::from([
   2035             ("RADROOTS_CLI_OUTPUT_FORMAT".to_owned(), "human".to_owned()),
   2036             ("RADROOTS_CLI_LOGGING_FILTER".to_owned(), "trace".to_owned()),
   2037             ("RADROOTS_CLI_LOGGING_STDOUT".to_owned(), "false".to_owned()),
   2038             (
   2039                 "RADROOTS_CLI_IDENTITY_PATH".to_owned(),
   2040                 "env-identity.json".to_owned(),
   2041             ),
   2042             ("RADROOTS_CLI_SIGNER_BACKEND".to_owned(), "myc".to_owned()),
   2043             (
   2044                 "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(),
   2045                 "radrootsd_proxy".to_owned(),
   2046             ),
   2047             (
   2048                 "RADROOTS_CLI_RELAYS_URLS".to_owned(),
   2049                 "wss://relay.env".to_owned(),
   2050             ),
   2051             (
   2052                 "RADROOTS_CLI_MYC_EXECUTABLE".to_owned(),
   2053                 "env-myc".to_owned(),
   2054             ),
   2055             (
   2056                 "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS".to_owned(),
   2057                 "9000".to_owned(),
   2058             ),
   2059             ("RADROOTS_CLI_HYF_ENABLED".to_owned(), "false".to_owned()),
   2060             (
   2061                 "RADROOTS_CLI_HYF_EXECUTABLE".to_owned(),
   2062                 "env-hyfd".to_owned(),
   2063             ),
   2064         ]));
   2065 
   2066         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   2067             .expect("resolve runtime config");
   2068         assert_eq!(
   2069             resolved.output,
   2070             OutputConfig {
   2071                 format: OutputFormat::Human,
   2072                 verbosity: Verbosity::Verbose,
   2073                 color: false,
   2074                 dry_run: true,
   2075             }
   2076         );
   2077         assert_eq!(
   2078             resolved.interaction,
   2079             InteractionConfig {
   2080                 input_enabled: true,
   2081                 assume_yes: false,
   2082                 stdin_tty: false,
   2083                 stdout_tty: false,
   2084                 prompts_allowed: false,
   2085                 confirmations_allowed: false,
   2086             }
   2087         );
   2088         assert_eq!(
   2089             resolved.paths,
   2090             PathsConfig {
   2091                 profile: "interactive_user".to_owned(),
   2092                 profile_source: "default".to_owned(),
   2093                 allowed_profiles: vec!["interactive_user".to_owned(), "repo_local".to_owned(),],
   2094                 root_source: "host_defaults".to_owned(),
   2095                 repo_local_root: None,
   2096                 repo_local_root_source: None,
   2097                 subordinate_path_override_source: "runtime_config".to_owned(),
   2098                 app_namespace: "apps/cli".to_owned(),
   2099                 shared_accounts_namespace: "shared/accounts".to_owned(),
   2100                 shared_identities_namespace: "shared/identities".to_owned(),
   2101                 app_config_path: PathBuf::from(
   2102                     "/home/tester/.radroots/config/apps/cli/config.toml"
   2103                 ),
   2104                 workspace_config_path: None,
   2105                 app_data_root: PathBuf::from("/home/tester/.radroots/data/apps/cli"),
   2106                 app_logs_root: PathBuf::from("/home/tester/.radroots/logs/apps/cli"),
   2107                 shared_accounts_data_root: PathBuf::from(
   2108                     "/home/tester/.radroots/data/shared/accounts"
   2109                 ),
   2110                 shared_accounts_secrets_root: PathBuf::from(
   2111                     "/home/tester/.radroots/secrets/shared/accounts"
   2112                 ),
   2113                 default_identity_path: PathBuf::from(
   2114                     "/home/tester/.radroots/secrets/shared/identities/default.json"
   2115                 ),
   2116             }
   2117         );
   2118         assert_eq!(resolved.logging.filter, "debug");
   2119         assert!(resolved.logging.stdout);
   2120         assert_eq!(
   2121             resolved.identity.path,
   2122             PathBuf::from("custom-identity.json")
   2123         );
   2124         assert_eq!(
   2125             resolved.account,
   2126             AccountConfig {
   2127                 selector: None,
   2128                 store_path: PathBuf::from("/home/tester/.radroots/data/shared/accounts/store.json"),
   2129                 secrets_dir: PathBuf::from("/home/tester/.radroots/secrets/shared/accounts"),
   2130                 secret_backend: RadrootsSecretBackend::HostVault(
   2131                     RadrootsHostVaultPolicy::desktop(),
   2132                 ),
   2133                 secret_fallback: Some(RadrootsSecretBackend::EncryptedFile),
   2134             }
   2135         );
   2136         assert_eq!(
   2137             resolved.account_secret_contract,
   2138             AccountSecretContractConfig {
   2139                 default_backend: "host_vault".to_owned(),
   2140                 default_fallback: Some("encrypted_file".to_owned()),
   2141                 allowed_backends: vec!["host_vault".to_owned(), "encrypted_file".to_owned(),],
   2142                 host_vault_policy: Some("desktop".to_owned()),
   2143                 uses_protected_store: true,
   2144             }
   2145         );
   2146         assert_eq!(resolved.signer.backend, SignerBackend::Local);
   2147         assert_eq!(
   2148             resolved.publish,
   2149             PublishConfig {
   2150                 transport: PublishTransport::DirectNostrRelay,
   2151                 source: PublishTransportSource::Flags,
   2152                 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(),
   2153             }
   2154         );
   2155         assert_eq!(
   2156             resolved.relay.urls,
   2157             vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()]
   2158         );
   2159         assert_eq!(resolved.relay.source, RelayConfigSource::Flags);
   2160         assert_eq!(resolved.relay.publish_policy, RelayPublishPolicy::Any);
   2161         assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc-cli"));
   2162         assert_eq!(resolved.myc.status_timeout_ms, 2500);
   2163         assert_eq!(
   2164             resolved.hyf,
   2165             HyfConfig {
   2166                 enabled: true,
   2167                 executable: PathBuf::from("bin/hyfd-cli"),
   2168             }
   2169         );
   2170     }
   2171 
   2172     #[test]
   2173     fn environment_values_fill_missing_flags() {
   2174         let args = runtime_args();
   2175         let env = MapEnvironment::new(BTreeMap::from([
   2176             ("RADROOTS_CLI_OUTPUT_FORMAT".to_owned(), "json".to_owned()),
   2177             (
   2178                 "RADROOTS_CLI_LOGGING_FILTER".to_owned(),
   2179                 "debug,cli=trace".to_owned(),
   2180             ),
   2181             (
   2182                 "RADROOTS_CLI_LOGGING_OUTPUT_DIR".to_owned(),
   2183                 "logs/runtime".to_owned(),
   2184             ),
   2185             ("RADROOTS_CLI_LOGGING_STDOUT".to_owned(), "false".to_owned()),
   2186             (
   2187                 "RADROOTS_CLI_ACCOUNT_SELECTOR".to_owned(),
   2188                 "acct_demo".to_owned(),
   2189             ),
   2190             (
   2191                 "RADROOTS_CLI_IDENTITY_PATH".to_owned(),
   2192                 "state/identity.json".to_owned(),
   2193             ),
   2194             ("RADROOTS_CLI_SIGNER_BACKEND".to_owned(), "myc".to_owned()),
   2195             (
   2196                 "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(),
   2197                 "radrootsd_proxy".to_owned(),
   2198             ),
   2199             (
   2200                 "RADROOTS_CLI_RELAYS_URLS".to_owned(),
   2201                 "wss://relay.one,wss://relay.two".to_owned(),
   2202             ),
   2203             (
   2204                 "RADROOTS_CLI_MYC_EXECUTABLE".to_owned(),
   2205                 "bin/myc".to_owned(),
   2206             ),
   2207             (
   2208                 "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS".to_owned(),
   2209                 "3500".to_owned(),
   2210             ),
   2211             ("RADROOTS_CLI_HYF_ENABLED".to_owned(), "true".to_owned()),
   2212             (
   2213                 "RADROOTS_CLI_HYF_EXECUTABLE".to_owned(),
   2214                 "bin/hyfd".to_owned(),
   2215             ),
   2216         ]));
   2217 
   2218         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   2219             .expect("resolve runtime config");
   2220         assert_eq!(
   2221             resolved.output,
   2222             OutputConfig {
   2223                 format: OutputFormat::Json,
   2224                 verbosity: Verbosity::Normal,
   2225                 color: true,
   2226                 dry_run: false,
   2227             }
   2228         );
   2229         assert_eq!(
   2230             resolved.interaction,
   2231             InteractionConfig {
   2232                 input_enabled: true,
   2233                 assume_yes: false,
   2234                 stdin_tty: false,
   2235                 stdout_tty: false,
   2236                 prompts_allowed: false,
   2237                 confirmations_allowed: false,
   2238             }
   2239         );
   2240         assert_eq!(resolved.logging.filter, "debug,cli=trace");
   2241         assert_eq!(
   2242             resolved.logging.directory,
   2243             Some(PathBuf::from("logs/runtime"))
   2244         );
   2245         assert!(!resolved.logging.stdout);
   2246         assert_eq!(resolved.account.selector.as_deref(), Some("acct_demo"));
   2247         assert_eq!(
   2248             resolved.account.secret_backend,
   2249             RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy::desktop())
   2250         );
   2251         assert_eq!(
   2252             resolved.account.secret_fallback,
   2253             Some(RadrootsSecretBackend::EncryptedFile)
   2254         );
   2255         assert_eq!(resolved.identity.path, PathBuf::from("state/identity.json"));
   2256         assert_eq!(resolved.signer.backend, SignerBackend::Myc);
   2257         assert_eq!(
   2258             resolved.publish,
   2259             PublishConfig {
   2260                 transport: PublishTransport::RadrootsdProxy,
   2261                 source: PublishTransportSource::Environment,
   2262                 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(),
   2263             }
   2264         );
   2265         assert_eq!(
   2266             resolved.relay.urls,
   2267             vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()]
   2268         );
   2269         assert_eq!(resolved.relay.source, RelayConfigSource::Environment);
   2270         assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc"));
   2271         assert_eq!(resolved.myc.status_timeout_ms, 3500);
   2272         assert_eq!(
   2273             resolved.hyf,
   2274             HyfConfig {
   2275                 enabled: true,
   2276                 executable: PathBuf::from("bin/hyfd"),
   2277             }
   2278         );
   2279     }
   2280 
   2281     #[test]
   2282     fn old_process_environment_names_have_no_effect() {
   2283         let args = runtime_args();
   2284         let env = MapEnvironment::new(BTreeMap::from([
   2285             ("RADROOTS_OUTPUT".to_owned(), "json".to_owned()),
   2286             ("RADROOTS_LOG_FILTER".to_owned(), "trace".to_owned()),
   2287             ("RADROOTS_LOG_DIR".to_owned(), "logs/old".to_owned()),
   2288             ("RADROOTS_LOG_STDOUT".to_owned(), "true".to_owned()),
   2289             ("RADROOTS_ACCOUNT".to_owned(), "old_account".to_owned()),
   2290             (
   2291                 "RADROOTS_ACCOUNT_SECRET_BACKEND".to_owned(),
   2292                 "encrypted_file".to_owned(),
   2293             ),
   2294             (
   2295                 "RADROOTS_ACCOUNT_SECRET_FALLBACK".to_owned(),
   2296                 "none".to_owned(),
   2297             ),
   2298             (
   2299                 "RADROOTS_IDENTITY_PATH".to_owned(),
   2300                 "old-identity.json".to_owned(),
   2301             ),
   2302             ("RADROOTS_SIGNER".to_owned(), "myc".to_owned()),
   2303             ("RADROOTS_PUBLISH_MODE".to_owned(), "radrootsd".to_owned()),
   2304             (
   2305                 "RADROOTS_RELAYS".to_owned(),
   2306                 "wss://old-relay.example".to_owned(),
   2307             ),
   2308             ("RADROOTS_MYC_EXECUTABLE".to_owned(), "old-myc".to_owned()),
   2309             (
   2310                 "RADROOTS_MYC_STATUS_TIMEOUT_MS".to_owned(),
   2311                 "9999".to_owned(),
   2312             ),
   2313             ("RADROOTS_HYF_ENABLED".to_owned(), "true".to_owned()),
   2314             ("RADROOTS_HYF_EXECUTABLE".to_owned(), "old-hyfd".to_owned()),
   2315             (
   2316                 "RADROOTS_RPC_URL".to_owned(),
   2317                 "http://127.0.0.1:9".to_owned(),
   2318             ),
   2319             (
   2320                 "RADROOTS_RPC_BEARER_TOKEN".to_owned(),
   2321                 "old-token".to_owned(),
   2322             ),
   2323             (
   2324                 "RADROOTS_TRUSTED_RHI_WORKER_PUBKEYS".to_owned(),
   2325                 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_owned(),
   2326             ),
   2327         ]));
   2328 
   2329         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   2330             .expect("resolve runtime config");
   2331 
   2332         assert_eq!(resolved.output.format, OutputFormat::Human);
   2333         assert_eq!(resolved.logging.filter, DEFAULT_LOG_FILTER);
   2334         assert!(!resolved.logging.stdout);
   2335         assert_eq!(resolved.account.selector, None);
   2336         assert_eq!(
   2337             resolved.account.secret_backend,
   2338             RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy::desktop())
   2339         );
   2340         assert_eq!(
   2341             resolved.account.secret_fallback,
   2342             Some(RadrootsSecretBackend::EncryptedFile)
   2343         );
   2344         assert_eq!(resolved.signer.backend, SignerBackend::Local);
   2345         assert_eq!(
   2346             resolved.publish.transport,
   2347             PublishTransport::DirectNostrRelay
   2348         );
   2349         assert_eq!(resolved.relay.urls, Vec::<String>::new());
   2350         assert_eq!(resolved.myc.executable, PathBuf::from("myc"));
   2351         assert_eq!(
   2352             resolved.myc.status_timeout_ms,
   2353             DEFAULT_MYC_STATUS_TIMEOUT_MS
   2354         );
   2355         assert!(!resolved.hyf.enabled);
   2356         assert_eq!(
   2357             resolved.hyf.executable,
   2358             PathBuf::from(DEFAULT_HYF_EXECUTABLE)
   2359         );
   2360         assert_eq!(resolved.rpc.url, DEFAULT_RPC_URL);
   2361         assert_eq!(resolved.rhi.trusted_worker_pubkeys, Vec::<String>::new());
   2362     }
   2363 
   2364     #[test]
   2365     fn toml_output_logging_account_and_identity_config_resolve() {
   2366         let temp = tempdir().expect("tempdir");
   2367         let workspace_root = temp.path().join("workspace");
   2368         let repo_local_root = workspace_root.join("infra/local/runtime/radroots");
   2369         let app_config_dir = repo_local_root.join("config/apps/cli");
   2370         let user_home = temp.path().join("home");
   2371         fs::create_dir_all(&app_config_dir).expect("app config dir");
   2372         fs::write(
   2373             app_config_dir.join("config.toml"),
   2374             r#"
   2375 [output]
   2376 format = "json"
   2377 
   2378 [logging]
   2379 filter = "debug,cli=trace"
   2380 output_dir = "logs/from-toml"
   2381 stdout = false
   2382 
   2383 [account]
   2384 selector = "acct_from_toml"
   2385 
   2386 [account.secret]
   2387 backend = "encrypted_file"
   2388 fallback = "none"
   2389 
   2390 [identity]
   2391 path = "identity/from-toml.json"
   2392 "#,
   2393         )
   2394         .expect("write user config");
   2395 
   2396         let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new());
   2397         let resolved =
   2398             RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default())
   2399                 .expect("resolve toml config");
   2400 
   2401         assert_eq!(resolved.output.format, OutputFormat::Json);
   2402         assert_eq!(resolved.logging.filter, "debug,cli=trace");
   2403         assert_eq!(
   2404             resolved.logging.directory,
   2405             Some(PathBuf::from("logs/from-toml"))
   2406         );
   2407         assert!(!resolved.logging.stdout);
   2408         assert_eq!(resolved.account.selector.as_deref(), Some("acct_from_toml"));
   2409         assert_eq!(
   2410             resolved.account.secret_backend,
   2411             RadrootsSecretBackend::EncryptedFile
   2412         );
   2413         assert_eq!(resolved.account.secret_fallback, None);
   2414         assert_eq!(
   2415             resolved.identity.path,
   2416             PathBuf::from("identity/from-toml.json")
   2417         );
   2418     }
   2419 
   2420     #[test]
   2421     fn conflicting_boolean_flags_fail() {
   2422         let args = RuntimeInvocationArgs {
   2423             log_stdout: true,
   2424             no_log_stdout: true,
   2425             ..runtime_args()
   2426         };
   2427         let env = MapEnvironment::new(BTreeMap::new());
   2428         let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   2429             .expect_err("conflicting flags");
   2430         assert!(error.to_string().contains("cannot be used together"));
   2431 
   2432         let hyf_args = RuntimeInvocationArgs {
   2433             hyf_enabled: true,
   2434             no_hyf_enabled: true,
   2435             ..runtime_args()
   2436         };
   2437         let error =
   2438             RuntimeConfig::resolve_with_env_file(&hyf_args, &env, &EnvFileValues::default())
   2439                 .expect_err("conflicting hyf flags");
   2440         assert!(error.to_string().contains("--hyf-enabled"));
   2441     }
   2442 
   2443     #[test]
   2444     fn conflicting_output_and_verbosity_flags_fail() {
   2445         let env = MapEnvironment::new(BTreeMap::new());
   2446 
   2447         let conflicting_output = RuntimeInvocationArgs {
   2448             json: true,
   2449             ndjson: true,
   2450             ..runtime_args()
   2451         };
   2452         let error = RuntimeConfig::resolve_with_env_file(
   2453             &conflicting_output,
   2454             &env,
   2455             &EnvFileValues::default(),
   2456         )
   2457         .expect_err("conflicting output flags");
   2458         assert!(error.to_string().contains("--json and --ndjson"));
   2459 
   2460         let conflicting_verbosity = RuntimeInvocationArgs {
   2461             quiet: true,
   2462             trace: true,
   2463             ..runtime_args()
   2464         };
   2465         let error = RuntimeConfig::resolve_with_env_file(
   2466             &conflicting_verbosity,
   2467             &env,
   2468             &EnvFileValues::default(),
   2469         )
   2470         .expect_err("conflicting verbosity flags");
   2471         assert!(
   2472             error
   2473                 .to_string()
   2474                 .contains("--quiet, --verbose, and --trace")
   2475         );
   2476 
   2477         let conflicting_aliases = RuntimeInvocationArgs {
   2478             output_format: Some(RuntimeOutputFormatArg::Json),
   2479             json: true,
   2480             ..runtime_args()
   2481         };
   2482         let error = RuntimeConfig::resolve_with_env_file(
   2483             &conflicting_aliases,
   2484             &env,
   2485             &EnvFileValues::default(),
   2486         )
   2487         .expect_err("conflicting output aliases");
   2488         assert!(error.to_string().contains("--output, --json, and --ndjson"));
   2489     }
   2490 
   2491     #[test]
   2492     fn machine_output_rejects_stdout_logging_flags() {
   2493         let env = MapEnvironment::new(BTreeMap::new());
   2494 
   2495         let json_args = RuntimeInvocationArgs {
   2496             json: true,
   2497             log_stdout: true,
   2498             ..runtime_args()
   2499         };
   2500         let error =
   2501             RuntimeConfig::resolve_with_env_file(&json_args, &env, &EnvFileValues::default())
   2502                 .expect_err("json stdout logging should fail");
   2503         let message = error.to_string();
   2504         assert!(message.contains("stdout logging"));
   2505         assert!(message.contains("json output"));
   2506         assert!(message.contains("--no-log-stdout"));
   2507 
   2508         let ndjson_args = RuntimeInvocationArgs {
   2509             ndjson: true,
   2510             log_stdout: true,
   2511             ..runtime_args()
   2512         };
   2513         let error =
   2514             RuntimeConfig::resolve_with_env_file(&ndjson_args, &env, &EnvFileValues::default())
   2515                 .expect_err("ndjson stdout logging should fail");
   2516         let message = error.to_string();
   2517         assert!(message.contains("stdout logging"));
   2518         assert!(message.contains("ndjson output"));
   2519     }
   2520 
   2521     #[test]
   2522     fn machine_output_rejects_stdout_logging_environment() {
   2523         let json_args = RuntimeInvocationArgs {
   2524             json: true,
   2525             ..runtime_args()
   2526         };
   2527         let env = MapEnvironment::new(BTreeMap::from([(
   2528             "RADROOTS_CLI_LOGGING_STDOUT".to_owned(),
   2529             "true".to_owned(),
   2530         )]));
   2531         let error =
   2532             RuntimeConfig::resolve_with_env_file(&json_args, &env, &EnvFileValues::default())
   2533                 .expect_err("json stdout logging from env should fail");
   2534         let message = error.to_string();
   2535         assert!(message.contains("RADROOTS_CLI_LOGGING_STDOUT"));
   2536         assert!(message.contains("RADROOTS_CLI_LOGGING_STDOUT"));
   2537 
   2538         let ndjson_env_args = runtime_args();
   2539         let env = MapEnvironment::new(BTreeMap::from([
   2540             ("RADROOTS_CLI_OUTPUT_FORMAT".to_owned(), "ndjson".to_owned()),
   2541             ("RADROOTS_CLI_LOGGING_STDOUT".to_owned(), "true".to_owned()),
   2542         ]));
   2543         let error =
   2544             RuntimeConfig::resolve_with_env_file(&ndjson_env_args, &env, &EnvFileValues::default())
   2545                 .expect_err("ndjson stdout logging from env should fail");
   2546         assert!(error.to_string().contains("ndjson output"));
   2547     }
   2548 
   2549     #[test]
   2550     fn no_log_stdout_overrides_environment_for_machine_output() {
   2551         let args = RuntimeInvocationArgs {
   2552             json: true,
   2553             no_log_stdout: true,
   2554             ..runtime_args()
   2555         };
   2556         let env = MapEnvironment::new(BTreeMap::from([(
   2557             "RADROOTS_CLI_LOGGING_STDOUT".to_owned(),
   2558             "true".to_owned(),
   2559         )]));
   2560 
   2561         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   2562             .expect("resolve machine output with stdout logging disabled");
   2563         assert_eq!(resolved.output.format, OutputFormat::Json);
   2564         assert!(!resolved.logging.stdout);
   2565     }
   2566 
   2567     #[test]
   2568     fn invalid_environment_value_fails() {
   2569         let args = runtime_args();
   2570         let env = MapEnvironment::new(BTreeMap::from([(
   2571             "RADROOTS_CLI_LOGGING_STDOUT".to_owned(),
   2572             "maybe".to_owned(),
   2573         )]));
   2574         let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   2575             .expect_err("invalid bool");
   2576         assert!(error.to_string().contains("RADROOTS_CLI_LOGGING_STDOUT"));
   2577 
   2578         let env = MapEnvironment::new(BTreeMap::from([(
   2579             "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS".to_owned(),
   2580             "slow".to_owned(),
   2581         )]));
   2582         let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   2583             .expect_err("invalid myc timeout");
   2584         assert!(
   2585             error
   2586                 .to_string()
   2587                 .contains("RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS")
   2588         );
   2589 
   2590         let env = MapEnvironment::new(BTreeMap::from([(
   2591             "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(),
   2592             "relay".to_owned(),
   2593         )]));
   2594         let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   2595             .expect_err("invalid publish transport");
   2596         assert!(error.to_string().contains("RADROOTS_CLI_PUBLISH_TRANSPORT"));
   2597         assert!(error.to_string().contains("direct_nostr_relay"));
   2598         assert!(error.to_string().contains("radrootsd_proxy"));
   2599 
   2600         let args = RuntimeInvocationArgs {
   2601             myc_status_timeout_ms: Some(0),
   2602             ..runtime_args()
   2603         };
   2604         let env = MapEnvironment::new(BTreeMap::new());
   2605         let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   2606             .expect_err("zero myc timeout");
   2607         assert!(error.to_string().contains("greater than zero"));
   2608     }
   2609 
   2610     #[test]
   2611     fn env_file_values_fill_missing_flags() {
   2612         let args = runtime_args();
   2613         let env = MapEnvironment::new(BTreeMap::new());
   2614         let env_file = parse_env_file_values(
   2615             r#"
   2616 RADROOTS_CLI_OUTPUT_FORMAT=json
   2617 RADROOTS_CLI_LOGGING_FILTER="debug,radroots_cli=trace"
   2618 RADROOTS_CLI_LOGGING_OUTPUT_DIR=/tmp/radroots-cli-logs
   2619 RADROOTS_CLI_LOGGING_STDOUT=false
   2620 RADROOTS_CLI_ACCOUNT_SELECTOR=acct_env_file
   2621 RADROOTS_CLI_IDENTITY_PATH=state/identity.json
   2622 RADROOTS_CLI_SIGNER_BACKEND=myc
   2623 RADROOTS_CLI_PUBLISH_TRANSPORT=radrootsd_proxy
   2624 RADROOTS_CLI_RELAYS_URLS=wss://relay.env-file
   2625 RADROOTS_CLI_MYC_EXECUTABLE=bin/myc
   2626 RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS=4500
   2627 RADROOTS_CLI_HYF_ENABLED=true
   2628 RADROOTS_CLI_HYF_EXECUTABLE=bin/hyfd
   2629 "#,
   2630             Path::new(".env.test"),
   2631         )
   2632         .expect("parse env file");
   2633 
   2634         let resolved =
   2635             RuntimeConfig::resolve_with_env_file(&args, &env, &env_file).expect("resolve config");
   2636         assert_eq!(resolved.output.format, OutputFormat::Json);
   2637         assert_eq!(resolved.logging.filter, "debug,radroots_cli=trace");
   2638         assert_eq!(
   2639             resolved.logging.directory,
   2640             Some(PathBuf::from("/tmp/radroots-cli-logs"))
   2641         );
   2642         assert!(!resolved.logging.stdout);
   2643         assert_eq!(resolved.account.selector.as_deref(), Some("acct_env_file"));
   2644         assert_eq!(resolved.identity.path, PathBuf::from("state/identity.json"));
   2645         assert_eq!(resolved.signer.backend, SignerBackend::Myc);
   2646         assert_eq!(
   2647             resolved.publish,
   2648             PublishConfig {
   2649                 transport: PublishTransport::RadrootsdProxy,
   2650                 source: PublishTransportSource::Environment,
   2651                 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(),
   2652             }
   2653         );
   2654         assert_eq!(resolved.relay.urls, vec!["wss://relay.env-file".to_owned()]);
   2655         assert_eq!(resolved.relay.source, RelayConfigSource::Environment);
   2656         assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc"));
   2657         assert_eq!(resolved.myc.status_timeout_ms, 4500);
   2658         assert_eq!(
   2659             resolved.hyf,
   2660             HyfConfig {
   2661                 enabled: true,
   2662                 executable: PathBuf::from("bin/hyfd"),
   2663             }
   2664         );
   2665     }
   2666 
   2667     #[test]
   2668     fn explicit_output_flag_overrides_environment_output() {
   2669         let args = RuntimeInvocationArgs {
   2670             output_format: Some(RuntimeOutputFormatArg::Ndjson),
   2671             ..runtime_args()
   2672         };
   2673         let env = MapEnvironment::new(BTreeMap::from([(
   2674             "RADROOTS_CLI_OUTPUT_FORMAT".to_owned(),
   2675             "json".to_owned(),
   2676         )]));
   2677 
   2678         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   2679             .expect("resolve runtime config");
   2680         assert_eq!(resolved.output.format, OutputFormat::Ndjson);
   2681     }
   2682 
   2683     #[test]
   2684     fn interaction_config_reflects_tty_and_flags() {
   2685         let args = RuntimeInvocationArgs {
   2686             no_input: true,
   2687             yes: true,
   2688             ..runtime_args()
   2689         };
   2690         let env = MapEnvironment::new(BTreeMap::new()).with_tty(true, true);
   2691 
   2692         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   2693             .expect("resolve runtime config");
   2694         assert_eq!(
   2695             resolved.interaction,
   2696             InteractionConfig {
   2697                 input_enabled: false,
   2698                 assume_yes: true,
   2699                 stdin_tty: true,
   2700                 stdout_tty: true,
   2701                 prompts_allowed: false,
   2702                 confirmations_allowed: false,
   2703             }
   2704         );
   2705 
   2706         let interactive_args = runtime_args();
   2707         let interactive = RuntimeConfig::resolve_with_env_file(
   2708             &interactive_args,
   2709             &env,
   2710             &EnvFileValues::default(),
   2711         )
   2712         .expect("resolve interactive runtime config");
   2713         assert_eq!(
   2714             interactive.interaction,
   2715             InteractionConfig {
   2716                 input_enabled: true,
   2717                 assume_yes: false,
   2718                 stdin_tty: true,
   2719                 stdout_tty: true,
   2720                 prompts_allowed: true,
   2721                 confirmations_allowed: true,
   2722             }
   2723         );
   2724     }
   2725 
   2726     #[test]
   2727     fn process_environment_overrides_env_file_values() {
   2728         let args = runtime_args();
   2729         let env = MapEnvironment::new(BTreeMap::from([
   2730             ("RADROOTS_CLI_LOGGING_FILTER".to_owned(), "info".to_owned()),
   2731             ("RADROOTS_CLI_LOGGING_STDOUT".to_owned(), "true".to_owned()),
   2732         ]));
   2733         let env_file = parse_env_file_values(
   2734             r#"
   2735 RADROOTS_CLI_LOGGING_FILTER=debug
   2736 RADROOTS_CLI_LOGGING_STDOUT=false
   2737 "#,
   2738             Path::new(".env.test"),
   2739         )
   2740         .expect("parse env file");
   2741 
   2742         let resolved =
   2743             RuntimeConfig::resolve_with_env_file(&args, &env, &env_file).expect("resolve config");
   2744         assert_eq!(resolved.output.format, OutputFormat::Human);
   2745         assert_eq!(resolved.logging.filter, "info");
   2746         assert!(resolved.logging.stdout);
   2747     }
   2748 
   2749     #[test]
   2750     fn user_relay_config_overrides_workspace_relay_config() {
   2751         let temp = tempdir().expect("tempdir");
   2752         let workspace_root = temp.path().join("workspace");
   2753         let repo_local_root = workspace_root.join("infra/local/runtime/radroots");
   2754         let app_config_dir = repo_local_root.join("config/apps/cli");
   2755         let user_home = temp.path().join("home");
   2756         fs::create_dir_all(&repo_local_root).expect("workspace config dir");
   2757         fs::create_dir_all(&app_config_dir).expect("app config dir");
   2758         fs::write(
   2759             repo_local_root.join("config.toml"),
   2760             "[relays]\nurls = [\"wss://relay.workspace\"]\npublish_policy = \"any\"\n",
   2761         )
   2762         .expect("write workspace config");
   2763         fs::write(
   2764             app_config_dir.join("config.toml"),
   2765             "[relays]\nurls = [\"wss://relay.user\", \"wss://relay.workspace\"]\n",
   2766         )
   2767         .expect("write user config");
   2768 
   2769         let env = MapEnvironment {
   2770             values: BTreeMap::from([
   2771                 (
   2772                     "RADROOTS_CLI_PATHS_PROFILE".to_owned(),
   2773                     "repo_local".to_owned(),
   2774                 ),
   2775                 (
   2776                     "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(),
   2777                     repo_local_root.display().to_string(),
   2778                 ),
   2779             ]),
   2780             current_dir: workspace_root,
   2781             path_resolver: RadrootsPathResolver::new(
   2782                 RadrootsPlatform::Linux,
   2783                 RadrootsHostEnvironment {
   2784                     home_dir: Some(user_home),
   2785                     ..RadrootsHostEnvironment::default()
   2786                 },
   2787             ),
   2788             stdin_tty: false,
   2789             stdout_tty: false,
   2790         };
   2791         let args = runtime_args();
   2792 
   2793         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   2794             .expect("resolve config");
   2795         assert_eq!(
   2796             resolved.relay.urls,
   2797             vec![
   2798                 "wss://relay.user".to_owned(),
   2799                 "wss://relay.workspace".to_owned()
   2800             ]
   2801         );
   2802         assert_eq!(resolved.relay.source, RelayConfigSource::UserConfig);
   2803         assert_eq!(resolved.relay.publish_policy, RelayPublishPolicy::Any);
   2804     }
   2805 
   2806     #[test]
   2807     fn publish_transport_precedence_tracks_source() {
   2808         let temp = tempdir().expect("tempdir");
   2809         let workspace_root = temp.path().join("workspace");
   2810         let repo_local_root = workspace_root.join("infra/local/runtime/radroots");
   2811         let app_config_dir = repo_local_root.join("config/apps/cli");
   2812         let user_home = temp.path().join("home");
   2813         fs::create_dir_all(&repo_local_root).expect("workspace config dir");
   2814         fs::create_dir_all(&app_config_dir).expect("app config dir");
   2815         fs::write(
   2816             repo_local_root.join("config.toml"),
   2817             "[publish]\ntransport = \"radrootsd_proxy\"\n",
   2818         )
   2819         .expect("write workspace config");
   2820         fs::write(
   2821             app_config_dir.join("config.toml"),
   2822             "[publish]\ntransport = \"direct_nostr_relay\"\n",
   2823         )
   2824         .expect("write user config");
   2825 
   2826         let env = repo_local_env(
   2827             workspace_root.clone(),
   2828             repo_local_root.clone(),
   2829             user_home.clone(),
   2830             BTreeMap::from([(
   2831                 "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(),
   2832                 "radrootsd_proxy".to_owned(),
   2833             )]),
   2834         );
   2835         let args = RuntimeInvocationArgs {
   2836             publish_transport: Some("direct_nostr_relay".to_owned()),
   2837             ..runtime_args()
   2838         };
   2839         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   2840             .expect("resolve flag publish transport");
   2841         assert_eq!(
   2842             resolved.publish,
   2843             PublishConfig {
   2844                 transport: PublishTransport::DirectNostrRelay,
   2845                 source: PublishTransportSource::Flags,
   2846                 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(),
   2847             }
   2848         );
   2849 
   2850         let env = repo_local_env(
   2851             workspace_root.clone(),
   2852             repo_local_root.clone(),
   2853             user_home.clone(),
   2854             BTreeMap::from([(
   2855                 "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(),
   2856                 "radrootsd_proxy".to_owned(),
   2857             )]),
   2858         );
   2859         let resolved =
   2860             RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default())
   2861                 .expect("resolve environment publish transport");
   2862         assert_eq!(
   2863             resolved.publish,
   2864             PublishConfig {
   2865                 transport: PublishTransport::RadrootsdProxy,
   2866                 source: PublishTransportSource::Environment,
   2867                 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(),
   2868             }
   2869         );
   2870 
   2871         let env = repo_local_env(
   2872             workspace_root.clone(),
   2873             repo_local_root.clone(),
   2874             user_home.clone(),
   2875             BTreeMap::new(),
   2876         );
   2877         let resolved =
   2878             RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default())
   2879                 .expect("resolve user publish transport");
   2880         assert_eq!(
   2881             resolved.publish,
   2882             PublishConfig {
   2883                 transport: PublishTransport::DirectNostrRelay,
   2884                 source: PublishTransportSource::UserConfig,
   2885                 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(),
   2886             }
   2887         );
   2888 
   2889         fs::remove_file(app_config_dir.join("config.toml")).expect("remove user config");
   2890         let env = repo_local_env(
   2891             workspace_root.clone(),
   2892             repo_local_root.clone(),
   2893             user_home.clone(),
   2894             BTreeMap::new(),
   2895         );
   2896         let resolved =
   2897             RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default())
   2898                 .expect("resolve workspace publish transport");
   2899         assert_eq!(
   2900             resolved.publish,
   2901             PublishConfig {
   2902                 transport: PublishTransport::RadrootsdProxy,
   2903                 source: PublishTransportSource::WorkspaceConfig,
   2904                 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(),
   2905             }
   2906         );
   2907 
   2908         fs::remove_file(repo_local_root.join("config.toml")).expect("remove workspace config");
   2909         let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new());
   2910         let resolved =
   2911             RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default())
   2912                 .expect("resolve default publish transport");
   2913         assert_eq!(
   2914             resolved.publish,
   2915             PublishConfig {
   2916                 transport: PublishTransport::DirectNostrRelay,
   2917                 source: PublishTransportSource::Defaults,
   2918                 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(),
   2919             }
   2920         );
   2921     }
   2922 
   2923     #[test]
   2924     fn user_hyf_config_overrides_workspace_hyf_config() {
   2925         let temp = tempdir().expect("tempdir");
   2926         let workspace_root = temp.path().join("workspace");
   2927         let repo_local_root = workspace_root.join("infra/local/runtime/radroots");
   2928         let app_config_dir = repo_local_root.join("config/apps/cli");
   2929         let user_home = temp.path().join("home");
   2930         fs::create_dir_all(&repo_local_root).expect("workspace config dir");
   2931         fs::create_dir_all(&app_config_dir).expect("app config dir");
   2932         fs::write(
   2933             repo_local_root.join("config.toml"),
   2934             "[hyf]\nenabled = false\nexecutable = \"workspace-hyfd\"\n",
   2935         )
   2936         .expect("write workspace config");
   2937         fs::write(
   2938             app_config_dir.join("config.toml"),
   2939             "[hyf]\nenabled = true\nexecutable = \"user-hyfd\"\n",
   2940         )
   2941         .expect("write user config");
   2942 
   2943         let env = MapEnvironment {
   2944             values: BTreeMap::from([
   2945                 (
   2946                     "RADROOTS_CLI_PATHS_PROFILE".to_owned(),
   2947                     "repo_local".to_owned(),
   2948                 ),
   2949                 (
   2950                     "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(),
   2951                     repo_local_root.display().to_string(),
   2952                 ),
   2953             ]),
   2954             current_dir: workspace_root,
   2955             path_resolver: RadrootsPathResolver::new(
   2956                 RadrootsPlatform::Linux,
   2957                 RadrootsHostEnvironment {
   2958                     home_dir: Some(user_home),
   2959                     ..RadrootsHostEnvironment::default()
   2960                 },
   2961             ),
   2962             stdin_tty: false,
   2963             stdout_tty: false,
   2964         };
   2965         let args = runtime_args();
   2966 
   2967         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   2968             .expect("resolve config");
   2969         assert_eq!(
   2970             resolved.hyf,
   2971             HyfConfig {
   2972                 enabled: true,
   2973                 executable: PathBuf::from("user-hyfd"),
   2974             }
   2975         );
   2976     }
   2977 
   2978     #[test]
   2979     fn user_myc_config_overrides_workspace_myc_config() {
   2980         let temp = tempdir().expect("tempdir");
   2981         let workspace_root = temp.path().join("workspace");
   2982         let repo_local_root = workspace_root.join("infra/local/runtime/radroots");
   2983         let app_config_dir = repo_local_root.join("config/apps/cli");
   2984         let user_home = temp.path().join("home");
   2985         fs::create_dir_all(&repo_local_root).expect("workspace config dir");
   2986         fs::create_dir_all(&app_config_dir).expect("app config dir");
   2987         fs::write(
   2988             repo_local_root.join("config.toml"),
   2989             "[myc]\nexecutable = \"workspace-myc\"\nstatus_timeout_ms = 9000\n",
   2990         )
   2991         .expect("write workspace config");
   2992         fs::write(
   2993             app_config_dir.join("config.toml"),
   2994             "[myc]\nexecutable = \"user-myc\"\nstatus_timeout_ms = 3000\n",
   2995         )
   2996         .expect("write user config");
   2997 
   2998         let env = MapEnvironment {
   2999             values: BTreeMap::from([
   3000                 (
   3001                     "RADROOTS_CLI_PATHS_PROFILE".to_owned(),
   3002                     "repo_local".to_owned(),
   3003                 ),
   3004                 (
   3005                     "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(),
   3006                     repo_local_root.display().to_string(),
   3007                 ),
   3008             ]),
   3009             current_dir: workspace_root,
   3010             path_resolver: RadrootsPathResolver::new(
   3011                 RadrootsPlatform::Linux,
   3012                 RadrootsHostEnvironment {
   3013                     home_dir: Some(user_home),
   3014                     ..RadrootsHostEnvironment::default()
   3015                 },
   3016             ),
   3017             stdin_tty: false,
   3018             stdout_tty: false,
   3019         };
   3020         let args = runtime_args();
   3021 
   3022         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   3023             .expect("resolve config");
   3024         assert_eq!(resolved.myc.executable, PathBuf::from("user-myc"));
   3025         assert_eq!(resolved.myc.status_timeout_ms, 3000);
   3026     }
   3027 
   3028     #[test]
   3029     fn user_signer_config_overrides_workspace_signer_config() {
   3030         let temp = tempdir().expect("tempdir");
   3031         let workspace_root = temp.path().join("workspace");
   3032         let repo_local_root = workspace_root.join("infra/local/runtime/radroots");
   3033         let app_config_dir = repo_local_root.join("config/apps/cli");
   3034         let user_home = temp.path().join("home");
   3035         fs::create_dir_all(&app_config_dir).expect("app config dir");
   3036         fs::write(
   3037             repo_local_root.join("config.toml"),
   3038             "[signer]\nbackend = \"myc\"\n",
   3039         )
   3040         .expect("write workspace config");
   3041         fs::write(
   3042             app_config_dir.join("config.toml"),
   3043             "[signer]\nbackend = \"local\"\n",
   3044         )
   3045         .expect("write user config");
   3046 
   3047         let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new());
   3048         let args = runtime_args();
   3049 
   3050         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   3051             .expect("resolve config");
   3052         assert_eq!(resolved.signer.backend, SignerBackend::Local);
   3053     }
   3054 
   3055     #[test]
   3056     fn environment_signer_overrides_user_signer_config() {
   3057         let temp = tempdir().expect("tempdir");
   3058         let workspace_root = temp.path().join("workspace");
   3059         let repo_local_root = workspace_root.join("infra/local/runtime/radroots");
   3060         let app_config_dir = repo_local_root.join("config/apps/cli");
   3061         let user_home = temp.path().join("home");
   3062         fs::create_dir_all(&app_config_dir).expect("app config dir");
   3063         fs::write(
   3064             app_config_dir.join("config.toml"),
   3065             "[signer]\nbackend = \"local\"\n",
   3066         )
   3067         .expect("write user config");
   3068 
   3069         let env = repo_local_env(
   3070             workspace_root,
   3071             repo_local_root,
   3072             user_home,
   3073             BTreeMap::from([("RADROOTS_CLI_SIGNER_BACKEND".to_owned(), "myc".to_owned())]),
   3074         );
   3075         let args = runtime_args();
   3076 
   3077         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   3078             .expect("resolve config");
   3079         assert_eq!(resolved.signer.backend, SignerBackend::Myc);
   3080     }
   3081 
   3082     #[test]
   3083     fn invalid_signer_config_reports_config_source() {
   3084         let temp = tempdir().expect("tempdir");
   3085         let workspace_root = temp.path().join("workspace");
   3086         let repo_local_root = workspace_root.join("infra/local/runtime/radroots");
   3087         let user_home = temp.path().join("home");
   3088         fs::create_dir_all(&repo_local_root).expect("workspace config dir");
   3089         fs::write(
   3090             repo_local_root.join("config.toml"),
   3091             "[signer]\nbackend = \"remote\"\n",
   3092         )
   3093         .expect("write workspace config");
   3094 
   3095         let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new());
   3096         let args = runtime_args();
   3097 
   3098         let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   3099             .expect_err("invalid signer mode");
   3100         let message = error.to_string();
   3101         assert!(message.contains("workspace config [signer].backend"));
   3102         assert!(!message.contains("--signer"));
   3103     }
   3104 
   3105     #[test]
   3106     fn invalid_publish_config_reports_config_source() {
   3107         let temp = tempdir().expect("tempdir");
   3108         let workspace_root = temp.path().join("workspace");
   3109         let repo_local_root = workspace_root.join("infra/local/runtime/radroots");
   3110         let user_home = temp.path().join("home");
   3111         fs::create_dir_all(&repo_local_root).expect("workspace config dir");
   3112         fs::write(
   3113             repo_local_root.join("config.toml"),
   3114             "[publish]\ntransport = \"nostr\"\n",
   3115         )
   3116         .expect("write workspace config");
   3117 
   3118         let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new());
   3119         let error =
   3120             RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default())
   3121                 .expect_err("invalid publish transport");
   3122         let message = error.to_string();
   3123         assert!(message.contains("workspace config [publish].transport"));
   3124         assert!(message.contains("direct_nostr_relay"));
   3125         assert!(message.contains("radrootsd_proxy"));
   3126     }
   3127 
   3128     #[test]
   3129     fn user_capability_binding_overrides_workspace_binding() {
   3130         let temp = tempdir().expect("tempdir");
   3131         let workspace_root = temp.path().join("workspace");
   3132         let repo_local_root = workspace_root.join("infra/local/runtime/radroots");
   3133         let app_config_dir = repo_local_root.join("config/apps/cli");
   3134         let user_home = temp.path().join("home");
   3135         fs::create_dir_all(&repo_local_root).expect("workspace config dir");
   3136         fs::create_dir_all(&app_config_dir).expect("app config dir");
   3137         fs::write(
   3138             repo_local_root.join("config.toml"),
   3139             r#"
   3140 [[capability_binding]]
   3141 capability = "inference.hyf_stdio"
   3142 provider = "hyf"
   3143 target_kind = "managed_instance"
   3144 target = "workspace-hyf"
   3145 "#,
   3146         )
   3147         .expect("write workspace config");
   3148         fs::write(
   3149             app_config_dir.join("config.toml"),
   3150             r#"
   3151 [[capability_binding]]
   3152 capability = "inference.hyf_stdio"
   3153 provider = "hyf"
   3154 target_kind = "explicit_endpoint"
   3155 target = "bin/user-hyfd"
   3156 "#,
   3157         )
   3158         .expect("write user config");
   3159 
   3160         let env = MapEnvironment {
   3161             values: BTreeMap::from([
   3162                 (
   3163                     "RADROOTS_CLI_PATHS_PROFILE".to_owned(),
   3164                     "repo_local".to_owned(),
   3165                 ),
   3166                 (
   3167                     "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(),
   3168                     repo_local_root.display().to_string(),
   3169                 ),
   3170             ]),
   3171             current_dir: workspace_root,
   3172             path_resolver: RadrootsPathResolver::new(
   3173                 RadrootsPlatform::Linux,
   3174                 RadrootsHostEnvironment {
   3175                     home_dir: Some(user_home),
   3176                     ..RadrootsHostEnvironment::default()
   3177                 },
   3178             ),
   3179             stdin_tty: false,
   3180             stdout_tty: false,
   3181         };
   3182         let args = runtime_args();
   3183 
   3184         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   3185             .expect("resolve config");
   3186         assert_eq!(resolved.capability_bindings.len(), 1);
   3187         assert_eq!(
   3188             resolved.capability_bindings[0],
   3189             CapabilityBindingConfig {
   3190                 capability_id: INFERENCE_HYF_STDIO_CAPABILITY.to_owned(),
   3191                 provider_runtime_id: "hyf".to_owned(),
   3192                 binding_model: "stdio_service".to_owned(),
   3193                 target_kind: CapabilityBindingTargetKind::ExplicitEndpoint,
   3194                 target: "bin/user-hyfd".to_owned(),
   3195                 managed_account_ref: None,
   3196                 signer_session_ref: None,
   3197                 source: CapabilityBindingSource::UserConfig,
   3198             }
   3199         );
   3200     }
   3201 
   3202     #[test]
   3203     fn daemon_and_workflow_capability_bindings_are_rejected() {
   3204         let temp = tempdir().expect("tempdir");
   3205         let workspace_root = temp.path().join("workspace");
   3206         let repo_local_root = workspace_root.join("infra/local/runtime/radroots");
   3207         let user_home = temp.path().join("home");
   3208         fs::create_dir_all(&repo_local_root).expect("workspace config dir");
   3209         fs::create_dir_all(user_home.join(".radroots/config/apps/cli")).expect("app config dir");
   3210         fs::write(
   3211             repo_local_root.join("config.toml"),
   3212             r#"
   3213 [[capability_binding]]
   3214 capability = "write_plane.trade_jsonrpc"
   3215 provider = "radrootsd"
   3216 target_kind = "explicit_endpoint"
   3217 target = "https://rpc.workspace.test/jsonrpc"
   3218 "#,
   3219         )
   3220         .expect("write workspace config");
   3221 
   3222         let env = MapEnvironment {
   3223             values: BTreeMap::from([
   3224                 (
   3225                     "RADROOTS_CLI_PATHS_PROFILE".to_owned(),
   3226                     "repo_local".to_owned(),
   3227                 ),
   3228                 (
   3229                     "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(),
   3230                     repo_local_root.display().to_string(),
   3231                 ),
   3232             ]),
   3233             current_dir: workspace_root,
   3234             path_resolver: RadrootsPathResolver::new(
   3235                 RadrootsPlatform::Linux,
   3236                 RadrootsHostEnvironment {
   3237                     home_dir: Some(user_home),
   3238                     ..RadrootsHostEnvironment::default()
   3239                 },
   3240             ),
   3241             stdin_tty: false,
   3242             stdout_tty: false,
   3243         };
   3244         let args = runtime_args();
   3245 
   3246         let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   3247             .expect_err("rejected daemon capability binding");
   3248         assert!(
   3249             error
   3250                 .to_string()
   3251                 .contains("unknown capability_binding capability `write_plane.trade_jsonrpc`")
   3252         );
   3253 
   3254         fs::write(
   3255             repo_local_root.join("config.toml"),
   3256             r#"
   3257 [[capability_binding]]
   3258 capability = "workflow.trade"
   3259 provider = "rhi"
   3260 target_kind = "managed_instance"
   3261 target = "workflow-default"
   3262 "#,
   3263         )
   3264         .expect("write workflow config");
   3265 
   3266         let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   3267             .expect_err("rejected workflow capability binding");
   3268         assert!(
   3269             error
   3270                 .to_string()
   3271                 .contains("unknown capability_binding capability `workflow.trade`")
   3272         );
   3273     }
   3274 
   3275     #[test]
   3276     fn invalid_relay_url_fails() {
   3277         for relay in [
   3278             "https://not-a-websocket.example.com",
   3279             "wss://",
   3280             "wss://user@relay.example",
   3281             "wss://relay.example:abc",
   3282             " ",
   3283         ] {
   3284             let args = RuntimeInvocationArgs {
   3285                 relay: vec![relay.to_owned()],
   3286                 ..runtime_args()
   3287             };
   3288             let env = MapEnvironment::new(BTreeMap::new());
   3289             let error =
   3290                 RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   3291                     .expect_err("invalid relay url");
   3292             assert!(
   3293                 error.to_string().contains("relay url")
   3294                     || error.to_string().contains("websocket relay urls"),
   3295                 "unexpected error for {relay}: {error}"
   3296             );
   3297         }
   3298     }
   3299 
   3300     #[test]
   3301     fn relay_env_value_rejects_empty_entries() {
   3302         let env = MapEnvironment::new(BTreeMap::from([(
   3303             super::ENV_CLI_RELAYS_URLS.to_owned(),
   3304             "wss://relay.example,,wss://relay-two.example".to_owned(),
   3305         )]));
   3306         let error =
   3307             RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default())
   3308                 .expect_err("empty relay entry");
   3309 
   3310         assert!(error.to_string().contains("empty relay url"));
   3311     }
   3312 
   3313     #[test]
   3314     fn valid_ipv6_relay_url_resolves() {
   3315         let args = RuntimeInvocationArgs {
   3316             relay: vec![" wss://[2001:db8::1]:443/relay ".to_owned()],
   3317             ..runtime_args()
   3318         };
   3319         let env = MapEnvironment::new(BTreeMap::new());
   3320         let config = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   3321             .expect("valid relay url");
   3322 
   3323         assert_eq!(config.relay.urls, vec!["wss://[2001:db8::1]:443/relay"]);
   3324     }
   3325 
   3326     #[test]
   3327     fn state_roots_are_resolved_from_home_and_workspace() {
   3328         let args = runtime_args();
   3329         let env = MapEnvironment::new(BTreeMap::new());
   3330         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   3331             .expect("resolve runtime config");
   3332 
   3333         assert_eq!(
   3334             resolved.paths.app_config_path,
   3335             PathBuf::from("/home/tester/.radroots/config/apps/cli/config.toml")
   3336         );
   3337         assert_eq!(resolved.paths.profile_source, "default");
   3338         assert_eq!(resolved.paths.root_source, "host_defaults");
   3339         assert_eq!(resolved.paths.repo_local_root, None);
   3340         assert_eq!(resolved.paths.repo_local_root_source, None);
   3341         assert_eq!(
   3342             resolved.paths.subordinate_path_override_source,
   3343             "runtime_config"
   3344         );
   3345         assert_eq!(resolved.paths.app_namespace, "apps/cli");
   3346         assert_eq!(resolved.paths.shared_accounts_namespace, "shared/accounts");
   3347         assert_eq!(
   3348             resolved.paths.shared_identities_namespace,
   3349             "shared/identities"
   3350         );
   3351         assert_eq!(resolved.paths.workspace_config_path, None);
   3352         assert_eq!(
   3353             resolved.paths.app_data_root,
   3354             PathBuf::from("/home/tester/.radroots/data/apps/cli")
   3355         );
   3356         assert_eq!(
   3357             resolved.paths.allowed_profiles,
   3358             vec!["interactive_user".to_owned(), "repo_local".to_owned(),]
   3359         );
   3360     }
   3361 
   3362     #[test]
   3363     fn windows_roots_use_native_user_directories() {
   3364         let args = runtime_args();
   3365         let env = MapEnvironment {
   3366             values: BTreeMap::new(),
   3367             current_dir: PathBuf::from(r"C:\workspaces\radroots-cli"),
   3368             path_resolver: RadrootsPathResolver::new(
   3369                 RadrootsPlatform::Windows,
   3370                 RadrootsHostEnvironment {
   3371                     appdata_dir: Some(PathBuf::from(r"C:\Users\tester\AppData\Roaming")),
   3372                     localappdata_dir: Some(PathBuf::from(r"C:\Users\tester\AppData\Local")),
   3373                     ..RadrootsHostEnvironment::default()
   3374                 },
   3375             ),
   3376             stdin_tty: false,
   3377             stdout_tty: false,
   3378         };
   3379 
   3380         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   3381             .expect("resolve runtime config");
   3382 
   3383         assert_eq!(
   3384             resolved.paths.app_config_path,
   3385             PathBuf::from(r"C:\Users\tester\AppData\Roaming")
   3386                 .join("Radroots")
   3387                 .join("config")
   3388                 .join("apps")
   3389                 .join("cli")
   3390                 .join("config.toml")
   3391         );
   3392         assert_eq!(
   3393             resolved.paths.app_data_root,
   3394             PathBuf::from(r"C:\Users\tester\AppData\Local")
   3395                 .join("Radroots")
   3396                 .join("data")
   3397                 .join("apps")
   3398                 .join("cli")
   3399         );
   3400         assert_eq!(
   3401             resolved.paths.shared_accounts_data_root,
   3402             PathBuf::from(r"C:\Users\tester\AppData\Local")
   3403                 .join("Radroots")
   3404                 .join("data")
   3405                 .join("shared")
   3406                 .join("accounts")
   3407         );
   3408         assert_eq!(
   3409             resolved.paths.default_identity_path,
   3410             PathBuf::from(r"C:\Users\tester\AppData\Roaming")
   3411                 .join("Radroots")
   3412                 .join("secrets")
   3413                 .join("shared")
   3414                 .join("identities")
   3415                 .join("default.json")
   3416         );
   3417     }
   3418 
   3419     #[test]
   3420     fn repo_local_profile_uses_explicit_repo_local_root() {
   3421         let args = runtime_args();
   3422         let env = MapEnvironment::new(BTreeMap::from([
   3423             (
   3424                 "RADROOTS_CLI_PATHS_PROFILE".to_owned(),
   3425                 "repo_local".to_owned(),
   3426             ),
   3427             (
   3428                 "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(),
   3429                 ".local/radroots/dev".to_owned(),
   3430             ),
   3431         ]));
   3432 
   3433         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   3434             .expect("resolve runtime config");
   3435 
   3436         assert_eq!(resolved.paths.profile, "repo_local");
   3437         assert_eq!(
   3438             resolved.paths.profile_source,
   3439             "process_env:RADROOTS_CLI_PATHS_PROFILE"
   3440         );
   3441         assert_eq!(resolved.paths.root_source, "repo_local_root");
   3442         assert_eq!(
   3443             resolved.paths.repo_local_root,
   3444             Some(PathBuf::from(
   3445                 "/workspaces/radroots-cli/.local/radroots/dev"
   3446             ))
   3447         );
   3448         assert_eq!(
   3449             resolved.paths.repo_local_root_source,
   3450             Some("process_env:RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned())
   3451         );
   3452         assert_eq!(
   3453             resolved.paths.app_config_path,
   3454             PathBuf::from(
   3455                 "/workspaces/radroots-cli/.local/radroots/dev/config/apps/cli/config.toml"
   3456             )
   3457         );
   3458         assert_eq!(
   3459             resolved.paths.workspace_config_path,
   3460             Some(PathBuf::from(
   3461                 "/workspaces/radroots-cli/.local/radroots/dev/config.toml"
   3462             ))
   3463         );
   3464         assert_eq!(
   3465             resolved.paths.app_data_root,
   3466             PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/data/apps/cli")
   3467         );
   3468         assert_eq!(
   3469             resolved.paths.app_logs_root,
   3470             PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/logs/apps/cli")
   3471         );
   3472         assert_eq!(
   3473             resolved.paths.shared_accounts_data_root,
   3474             PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/data/shared/accounts")
   3475         );
   3476         assert_eq!(
   3477             resolved.paths.default_identity_path,
   3478             PathBuf::from(
   3479                 "/workspaces/radroots-cli/.local/radroots/dev/secrets/shared/identities/default.json"
   3480             )
   3481         );
   3482     }
   3483 
   3484     #[test]
   3485     fn repo_local_profile_requires_explicit_root() {
   3486         let args = runtime_args();
   3487         let env = MapEnvironment::new(BTreeMap::from([(
   3488             "RADROOTS_CLI_PATHS_PROFILE".to_owned(),
   3489             "repo_local".to_owned(),
   3490         )]));
   3491 
   3492         let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   3493             .expect_err("repo_local should require an explicit root");
   3494         assert!(
   3495             error
   3496                 .to_string()
   3497                 .contains("RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT")
   3498         );
   3499     }
   3500 
   3501     #[test]
   3502     fn env_file_can_select_repo_local_profile() {
   3503         let args = runtime_args();
   3504         let env = MapEnvironment::new(BTreeMap::new());
   3505         let env_file = parse_env_file_values(
   3506             r#"
   3507 RADROOTS_CLI_PATHS_PROFILE=repo_local
   3508 RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT=.local/radroots/dev
   3509 "#,
   3510             Path::new(".env.test"),
   3511         )
   3512         .expect("parse env file");
   3513 
   3514         let resolved =
   3515             RuntimeConfig::resolve_with_env_file(&args, &env, &env_file).expect("resolve config");
   3516         assert_eq!(resolved.paths.profile, "repo_local");
   3517         assert_eq!(
   3518             resolved.paths.app_data_root,
   3519             PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/data/apps/cli")
   3520         );
   3521         assert_eq!(
   3522             resolved.paths.workspace_config_path,
   3523             Some(PathBuf::from(
   3524                 "/workspaces/radroots-cli/.local/radroots/dev/config.toml"
   3525             ))
   3526         );
   3527     }
   3528 
   3529     #[test]
   3530     fn unknown_env_file_variable_fails() {
   3531         let error = parse_env_file_values(
   3532             "RADROOTS_CLI_LOGGING_FILTRE=debug\n",
   3533             Path::new(".env.test"),
   3534         )
   3535         .expect_err("unknown env variable");
   3536         assert!(
   3537             error
   3538                 .to_string()
   3539                 .contains("unknown environment variable `RADROOTS_CLI_LOGGING_FILTRE`")
   3540         );
   3541     }
   3542 
   3543     #[test]
   3544     fn old_env_file_variable_fails() {
   3545         let error = parse_env_file_values("RADROOTS_OUTPUT=json\n", Path::new(".env.test"))
   3546             .expect_err("old env variable should fail");
   3547         assert!(
   3548             error
   3549                 .to_string()
   3550                 .contains("unknown environment variable `RADROOTS_OUTPUT`")
   3551         );
   3552     }
   3553 
   3554     #[test]
   3555     fn duplicate_env_file_variable_fails() {
   3556         let error = parse_env_file_values(
   3557             "RADROOTS_CLI_OUTPUT_FORMAT=json\nRADROOTS_CLI_OUTPUT_FORMAT=human\n",
   3558             Path::new(".env.test"),
   3559         )
   3560         .expect_err("duplicate env variable should fail");
   3561         assert!(
   3562             error
   3563                 .to_string()
   3564                 .contains("duplicate environment variable `RADROOTS_CLI_OUTPUT_FORMAT`")
   3565         );
   3566     }
   3567 
   3568     #[test]
   3569     fn old_toml_groups_and_fields_fail() {
   3570         let temp = tempdir().expect("tempdir");
   3571         let workspace_root = temp.path().join("workspace");
   3572         let repo_local_root = workspace_root.join("infra/local/runtime/radroots");
   3573         let user_home = temp.path().join("home");
   3574         fs::create_dir_all(&repo_local_root).expect("workspace config dir");
   3575 
   3576         for (raw, expected) in [
   3577             (
   3578                 "[relay]\nurls = [\"wss://relay.old\"]\n",
   3579                 "unknown field `relay`",
   3580             ),
   3581             ("[signer]\nmode = \"local\"\n", "unknown field `mode`"),
   3582             (
   3583                 "[relays]\nurls = [\"wss://relay.example\"]\nextra = true\n",
   3584                 "unknown field `extra`",
   3585             ),
   3586         ] {
   3587             fs::write(repo_local_root.join("config.toml"), raw).expect("write config");
   3588             let env = repo_local_env(
   3589                 workspace_root.clone(),
   3590                 repo_local_root.clone(),
   3591                 user_home.clone(),
   3592                 BTreeMap::new(),
   3593             );
   3594             let error = RuntimeConfig::resolve_with_env_file(
   3595                 &runtime_args(),
   3596                 &env,
   3597                 &EnvFileValues::default(),
   3598             )
   3599             .expect_err("old toml shape should fail");
   3600             assert!(
   3601                 error.to_string().contains(expected),
   3602                 "expected {expected}, got {error}"
   3603             );
   3604         }
   3605     }
   3606 
   3607     #[test]
   3608     fn env_output_accepts_ndjson() {
   3609         let args = runtime_args();
   3610         let env = MapEnvironment::new(BTreeMap::from([(
   3611             "RADROOTS_CLI_OUTPUT_FORMAT".to_owned(),
   3612             "ndjson".to_owned(),
   3613         )]));
   3614 
   3615         let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
   3616             .expect("resolve runtime config");
   3617         assert_eq!(resolved.output.format, OutputFormat::Ndjson);
   3618     }
   3619 }