rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

cli_v1.rs (47675B)


      1 //! One-pass command-line admission for the hardened RHI command contract.
      2 
      3 use std::error::Error;
      4 use std::ffi::OsString;
      5 use std::fmt;
      6 use std::path::{Component, Path, PathBuf};
      7 
      8 use clap::{Parser, Subcommand, ValueEnum};
      9 use radroots_runtime_paths::InstanceId;
     10 
     11 /// The exact bootstrap profile selected by the operator.
     12 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     13 pub enum RhiBootstrapProfileV1 {
     14     ServiceHost,
     15     Interactive,
     16     RepoLocal,
     17 }
     18 
     19 /// The only two governed command-result encodings.
     20 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
     21 pub enum RhiCliOutputModeV1 {
     22     #[default]
     23     Human,
     24     Json,
     25 }
     26 
     27 /// The exact governed top-level RHI command inventory.
     28 #[derive(PartialEq, Eq)]
     29 pub enum RhiCommandV1 {
     30     Run,
     31     Config(RhiConfigCommandV1),
     32     State(RhiStateCommandV1),
     33     Identity(RhiIdentityCommandV1),
     34     Status,
     35     Metrics(RhiMetricsCommandV1),
     36     Reconciliation(RhiReconciliationCommandV1),
     37     Sources(RhiSourcesCommandV1),
     38     Trade(RhiTradeCommandV1),
     39     Publication(RhiPublicationCommandV1),
     40     Presence(RhiPresenceCommandV1),
     41     Doctor,
     42 }
     43 
     44 /// Governed configuration commands.
     45 #[derive(PartialEq, Eq)]
     46 pub enum RhiConfigCommandV1 {
     47     Init,
     48     Validate,
     49     Show,
     50     Schema,
     51     Apply(RhiConfigApplyArgsV1),
     52 }
     53 
     54 /// Governed state commands.
     55 #[derive(PartialEq, Eq)]
     56 pub enum RhiStateCommandV1 {
     57     Init,
     58     Status,
     59     Backup(RhiStateBackupArgsV1),
     60     Restore(RhiStateRestoreArgsV1),
     61     Verify,
     62     Migrate,
     63 }
     64 
     65 /// Governed service-identity commands.
     66 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     67 pub enum RhiIdentityCommandV1 {
     68     Init,
     69     Status,
     70     ExportPublic,
     71 }
     72 
     73 /// Governed metrics commands.
     74 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     75 pub enum RhiMetricsCommandV1 {
     76     Snapshot,
     77 }
     78 
     79 /// Governed reconciliation commands.
     80 #[derive(PartialEq, Eq)]
     81 pub enum RhiReconciliationCommandV1 {
     82     Status,
     83     Jobs(RhiPageQueryArgsV1),
     84     Refresh(RhiReconciliationRefreshArgsV1),
     85 }
     86 
     87 /// Governed evidence-source commands.
     88 #[derive(PartialEq, Eq)]
     89 pub enum RhiSourcesCommandV1 {
     90     List(RhiPageQueryArgsV1),
     91 }
     92 
     93 /// Governed trade-query commands.
     94 #[derive(PartialEq, Eq)]
     95 pub enum RhiTradeCommandV1 {
     96     Projection(RhiTradeArgsV1),
     97     ReportCurrent(RhiTradeArgsV1),
     98     Reports(RhiTradePageArgsV1),
     99 }
    100 
    101 /// Governed publication commands.
    102 #[derive(PartialEq, Eq)]
    103 pub enum RhiPublicationCommandV1 {
    104     Backlog(RhiPageQueryArgsV1),
    105     Targets(RhiPageQueryArgsV1),
    106     Retry(RhiPublicationRetryArgsV1),
    107 }
    108 
    109 /// Governed desired-presence commands.
    110 #[derive(PartialEq, Eq)]
    111 pub enum RhiPresenceCommandV1 {
    112     Desired,
    113     Render(RhiPresenceMutationArgsV1),
    114     Refresh(RhiPresenceMutationArgsV1),
    115 }
    116 
    117 /// Exact offline configuration-apply input.
    118 #[derive(PartialEq, Eq)]
    119 pub struct RhiConfigApplyArgsV1 {
    120     candidate_config: PathBuf,
    121 }
    122 
    123 impl RhiConfigApplyArgsV1 {
    124     #[must_use]
    125     pub fn candidate_config(&self) -> &Path {
    126         &self.candidate_config
    127     }
    128 }
    129 
    130 /// Exact live state-backup input.
    131 #[derive(PartialEq, Eq)]
    132 pub struct RhiStateBackupArgsV1 {
    133     operation_id: Box<str>,
    134     target: PathBuf,
    135     expected_generation: u64,
    136 }
    137 
    138 impl RhiStateBackupArgsV1 {
    139     #[must_use]
    140     pub fn operation_id(&self) -> &str {
    141         &self.operation_id
    142     }
    143 
    144     #[must_use]
    145     pub fn target(&self) -> &Path {
    146         &self.target
    147     }
    148 
    149     #[must_use]
    150     pub const fn expected_generation(&self) -> u64 {
    151         self.expected_generation
    152     }
    153 }
    154 
    155 /// Exact offline state-restore input.
    156 #[derive(PartialEq, Eq)]
    157 pub struct RhiStateRestoreArgsV1 {
    158     manifest: PathBuf,
    159     manifest_sha256: Box<str>,
    160     bundle: PathBuf,
    161     maximum_state_bytes: u64,
    162 }
    163 
    164 impl RhiStateRestoreArgsV1 {
    165     #[must_use]
    166     pub fn manifest(&self) -> &Path {
    167         &self.manifest
    168     }
    169 
    170     #[must_use]
    171     pub fn manifest_sha256(&self) -> &str {
    172         &self.manifest_sha256
    173     }
    174 
    175     #[must_use]
    176     pub fn bundle(&self) -> &Path {
    177         &self.bundle
    178     }
    179 
    180     #[must_use]
    181     pub const fn maximum_state_bytes(&self) -> u64 {
    182         self.maximum_state_bytes
    183     }
    184 }
    185 
    186 /// Bounded stable pagination input shared by list commands.
    187 #[derive(PartialEq, Eq)]
    188 pub struct RhiPageQueryArgsV1 {
    189     limit: u16,
    190     cursor: Option<Box<str>>,
    191 }
    192 
    193 impl RhiPageQueryArgsV1 {
    194     #[must_use]
    195     pub const fn limit(&self) -> u16 {
    196         self.limit
    197     }
    198 
    199     #[must_use]
    200     pub fn cursor(&self) -> Option<&str> {
    201         self.cursor.as_deref()
    202     }
    203 }
    204 
    205 /// Exact trade selection for one live query.
    206 #[derive(PartialEq, Eq)]
    207 pub struct RhiTradeArgsV1 {
    208     trade_id: Box<str>,
    209 }
    210 
    211 impl RhiTradeArgsV1 {
    212     #[must_use]
    213     pub fn trade_id(&self) -> &str {
    214         &self.trade_id
    215     }
    216 }
    217 
    218 /// Exact trade selection plus bounded report pagination.
    219 #[derive(PartialEq, Eq)]
    220 pub struct RhiTradePageArgsV1 {
    221     trade_id: Box<str>,
    222     page: RhiPageQueryArgsV1,
    223 }
    224 
    225 impl RhiTradePageArgsV1 {
    226     #[must_use]
    227     pub fn trade_id(&self) -> &str {
    228         &self.trade_id
    229     }
    230 
    231     #[must_use]
    232     pub const fn page(&self) -> &RhiPageQueryArgsV1 {
    233         &self.page
    234     }
    235 }
    236 
    237 /// Exact refresh request and idempotency identity.
    238 #[derive(PartialEq, Eq)]
    239 pub struct RhiReconciliationRefreshArgsV1 {
    240     operation_id: Box<str>,
    241     trade_id: Box<str>,
    242     expected_dirty_generation: u64,
    243 }
    244 
    245 impl RhiReconciliationRefreshArgsV1 {
    246     #[must_use]
    247     pub fn operation_id(&self) -> &str {
    248         &self.operation_id
    249     }
    250 
    251     #[must_use]
    252     pub fn trade_id(&self) -> &str {
    253         &self.trade_id
    254     }
    255 
    256     #[must_use]
    257     pub const fn expected_dirty_generation(&self) -> u64 {
    258         self.expected_dirty_generation
    259     }
    260 }
    261 
    262 /// Exact publication retry request and idempotency identity.
    263 #[derive(PartialEq, Eq)]
    264 pub struct RhiPublicationRetryArgsV1 {
    265     operation_id: Box<str>,
    266     workflow_id: Box<str>,
    267     expected_generation: u64,
    268 }
    269 
    270 impl RhiPublicationRetryArgsV1 {
    271     #[must_use]
    272     pub fn operation_id(&self) -> &str {
    273         &self.operation_id
    274     }
    275 
    276     #[must_use]
    277     pub fn workflow_id(&self) -> &str {
    278         &self.workflow_id
    279     }
    280 
    281     #[must_use]
    282     pub const fn expected_generation(&self) -> u64 {
    283         self.expected_generation
    284     }
    285 }
    286 
    287 /// Exact presence mutation request and idempotency identity.
    288 #[derive(PartialEq, Eq)]
    289 pub struct RhiPresenceMutationArgsV1 {
    290     operation_id: Box<str>,
    291     expected_generation: u64,
    292 }
    293 
    294 impl RhiPresenceMutationArgsV1 {
    295     #[must_use]
    296     pub fn operation_id(&self) -> &str {
    297         &self.operation_id
    298     }
    299 
    300     #[must_use]
    301     pub const fn expected_generation(&self) -> u64 {
    302         self.expected_generation
    303     }
    304 }
    305 
    306 macro_rules! redacted_debug {
    307     ($($type:ty),+ $(,)?) => {
    308         $(
    309             impl fmt::Debug for $type {
    310                 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    311                     formatter.write_str(concat!(stringify!($type), "([redacted])"))
    312                 }
    313             }
    314         )+
    315     };
    316 }
    317 
    318 redacted_debug!(
    319     RhiConfigApplyArgsV1,
    320     RhiStateBackupArgsV1,
    321     RhiStateRestoreArgsV1,
    322     RhiPageQueryArgsV1,
    323     RhiTradeArgsV1,
    324     RhiTradePageArgsV1,
    325     RhiReconciliationRefreshArgsV1,
    326     RhiPublicationRetryArgsV1,
    327     RhiPresenceMutationArgsV1,
    328 );
    329 
    330 impl fmt::Debug for RhiCommandV1 {
    331     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    332         formatter.write_str(match self {
    333             Self::Run => "RhiCommandV1::Run",
    334             Self::Config(_) => "RhiCommandV1::Config([redacted])",
    335             Self::State(_) => "RhiCommandV1::State([redacted])",
    336             Self::Identity(_) => "RhiCommandV1::Identity([redacted])",
    337             Self::Status => "RhiCommandV1::Status",
    338             Self::Metrics(_) => "RhiCommandV1::Metrics([redacted])",
    339             Self::Reconciliation(_) => "RhiCommandV1::Reconciliation([redacted])",
    340             Self::Sources(_) => "RhiCommandV1::Sources([redacted])",
    341             Self::Trade(_) => "RhiCommandV1::Trade([redacted])",
    342             Self::Publication(_) => "RhiCommandV1::Publication([redacted])",
    343             Self::Presence(_) => "RhiCommandV1::Presence([redacted])",
    344             Self::Doctor => "RhiCommandV1::Doctor",
    345         })
    346     }
    347 }
    348 
    349 /// The only three process authorities selected by the hardened CLI.
    350 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    351 pub enum RhiCliPrimaryAuthorityV1 {
    352     Daemon,
    353     Offline,
    354     LiveUnixAdmin,
    355 }
    356 
    357 /// The closed offline operation classes selected before any state access.
    358 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    359 pub enum RhiCliOfflineOperationV1 {
    360     Config,
    361     StateExclusive,
    362     IdentityExclusive,
    363     Doctor,
    364 }
    365 
    366 /// The closed Unix-admin operations reachable from the command inventory.
    367 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    368 pub enum RhiCliAdminOperationV1 {
    369     Status,
    370     EffectiveConfig,
    371     IdentityStatus,
    372     IdentityPublic,
    373     StateStatus,
    374     StateBackup,
    375     MetricsSnapshot,
    376     ReconciliationStatus,
    377     ReconciliationJobs,
    378     ReconciliationRefresh,
    379     Sources,
    380     TradeProjection,
    381     TradeReportCurrent,
    382     TradeReports,
    383     PublicationBacklog,
    384     PublicationTargets,
    385     PublicationRetry,
    386     PresenceDesired,
    387     PresenceRender,
    388     PresenceRefresh,
    389 }
    390 
    391 #[cfg(any(target_os = "linux", target_os = "macos"))]
    392 impl RhiCliAdminOperationV1 {
    393     /// Returns the exact native Unix-admin route selected by this operation.
    394     #[must_use]
    395     pub const fn route(self) -> crate::RhiAdminRoute {
    396         match self {
    397             Self::Status => crate::RhiAdminRoute::Status,
    398             Self::EffectiveConfig => crate::RhiAdminRoute::EffectiveConfig,
    399             Self::IdentityStatus => crate::RhiAdminRoute::IdentityStatus,
    400             Self::IdentityPublic => crate::RhiAdminRoute::IdentityPublic,
    401             Self::StateStatus => crate::RhiAdminRoute::StateStatus,
    402             Self::StateBackup => crate::RhiAdminRoute::StateBackup,
    403             Self::MetricsSnapshot => crate::RhiAdminRoute::MetricsSnapshot,
    404             Self::ReconciliationStatus => crate::RhiAdminRoute::ReconciliationStatus,
    405             Self::ReconciliationJobs => crate::RhiAdminRoute::ReconciliationJobs,
    406             Self::ReconciliationRefresh => crate::RhiAdminRoute::ReconciliationRefresh,
    407             Self::Sources => crate::RhiAdminRoute::Sources,
    408             Self::TradeProjection => crate::RhiAdminRoute::TradeProjection,
    409             Self::TradeReportCurrent => crate::RhiAdminRoute::TradeReportCurrent,
    410             Self::TradeReports => crate::RhiAdminRoute::TradeReports,
    411             Self::PublicationBacklog => crate::RhiAdminRoute::PublicationBacklog,
    412             Self::PublicationTargets => crate::RhiAdminRoute::PublicationTargets,
    413             Self::PublicationRetry => crate::RhiAdminRoute::PublicationRetry,
    414             Self::PresenceDesired => crate::RhiAdminRoute::PresenceDesired,
    415             Self::PresenceRender => crate::RhiAdminRoute::PresenceRender,
    416             Self::PresenceRefresh => crate::RhiAdminRoute::PresenceRefresh,
    417         }
    418     }
    419 }
    420 
    421 /// A sealed, side-effect-free execution plan for one admitted CLI invocation.
    422 ///
    423 /// Construction is owned by [`plan_rhi_cli_v1`]. Live commands carry only a
    424 /// governed Unix-admin operation and never receive an offline or direct-SQLite
    425 /// fallback.
    426 ///
    427 /// ```compile_fail
    428 /// use rhi::{RhiCliExecutionPlanV1, RhiCliPrimaryAuthorityV1};
    429 ///
    430 /// let _ = RhiCliExecutionPlanV1 {
    431 ///     primary_authority: RhiCliPrimaryAuthorityV1::Offline,
    432 ///     offline_operation: None,
    433 ///     admin_operation: None,
    434 /// };
    435 /// ```
    436 #[derive(Clone, Copy, PartialEq, Eq)]
    437 pub struct RhiCliExecutionPlanV1 {
    438     primary_authority: RhiCliPrimaryAuthorityV1,
    439     offline_operation: Option<RhiCliOfflineOperationV1>,
    440     admin_operation: Option<RhiCliAdminOperationV1>,
    441 }
    442 
    443 impl RhiCliExecutionPlanV1 {
    444     /// Returns the sole selected process authority.
    445     #[must_use]
    446     pub const fn primary_authority(&self) -> RhiCliPrimaryAuthorityV1 {
    447         self.primary_authority
    448     }
    449 
    450     /// Returns the bounded offline operation, when the plan admits one.
    451     #[must_use]
    452     pub const fn offline_operation(&self) -> Option<RhiCliOfflineOperationV1> {
    453         self.offline_operation
    454     }
    455 
    456     /// Returns the bounded Unix-admin operation, when the plan admits one.
    457     #[must_use]
    458     pub const fn admin_operation(&self) -> Option<RhiCliAdminOperationV1> {
    459         self.admin_operation
    460     }
    461 }
    462 
    463 impl fmt::Debug for RhiCliExecutionPlanV1 {
    464     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    465         formatter
    466             .debug_struct("RhiCliExecutionPlanV1")
    467             .field("primary_authority", &self.primary_authority)
    468             .field("offline_operation", &self.offline_operation)
    469             .field("admin_operation", &self.admin_operation)
    470             .finish()
    471     }
    472 }
    473 
    474 /// Stable source-free classification for command-line admission failures.
    475 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    476 pub enum RhiCliV1ErrorKind {
    477     InvalidArguments,
    478     InvalidInstance,
    479     InvalidRepoLocalRoot,
    480     UnexpectedRepoLocalRoot,
    481     InvalidConfigPath,
    482     InvalidCommandInput,
    483 }
    484 
    485 impl RhiCliV1ErrorKind {
    486     const fn message(self) -> &'static str {
    487         match self {
    488             Self::InvalidArguments => "command-line arguments are invalid",
    489             Self::InvalidInstance => "instance identifier is invalid",
    490             Self::InvalidRepoLocalRoot => "repo-local profile requires a valid absolute root",
    491             Self::UnexpectedRepoLocalRoot => {
    492                 "repo-local root is forbidden outside the repo-local profile"
    493             }
    494             Self::InvalidConfigPath => "configuration path must be absolute without traversal",
    495             Self::InvalidCommandInput => "command input is invalid",
    496         }
    497     }
    498 }
    499 
    500 /// One safe command-line admission failure.
    501 #[derive(Clone, Copy, PartialEq, Eq)]
    502 pub struct RhiCliV1Error {
    503     kind: RhiCliV1ErrorKind,
    504 }
    505 
    506 impl RhiCliV1Error {
    507     const fn new(kind: RhiCliV1ErrorKind) -> Self {
    508         Self { kind }
    509     }
    510 
    511     /// Returns the stable failure classification.
    512     #[must_use]
    513     pub const fn kind(self) -> RhiCliV1ErrorKind {
    514         self.kind
    515     }
    516 }
    517 
    518 impl fmt::Debug for RhiCliV1Error {
    519     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    520         formatter
    521             .debug_struct("RhiCliV1Error")
    522             .field("kind", &self.kind)
    523             .finish()
    524     }
    525 }
    526 
    527 impl fmt::Display for RhiCliV1Error {
    528     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    529         formatter.write_str(self.kind.message())
    530     }
    531 }
    532 
    533 impl Error for RhiCliV1Error {}
    534 
    535 /// A validated one-pass RHI bootstrap and command selection.
    536 pub struct RhiCliInvocationV1 {
    537     profile: RhiBootstrapProfileV1,
    538     instance: InstanceId,
    539     repo_local_root: Option<PathBuf>,
    540     config_path: Option<PathBuf>,
    541     output_mode: RhiCliOutputModeV1,
    542     command: RhiCommandV1,
    543 }
    544 
    545 impl RhiCliInvocationV1 {
    546     /// Returns the explicitly selected bootstrap profile.
    547     #[must_use]
    548     pub const fn profile(&self) -> RhiBootstrapProfileV1 {
    549         self.profile
    550     }
    551 
    552     /// Returns the validated instance identifier.
    553     #[must_use]
    554     pub const fn instance(&self) -> &InstanceId {
    555         &self.instance
    556     }
    557 
    558     /// Returns the explicit repo-local root, when selected.
    559     #[must_use]
    560     pub fn repo_local_root(&self) -> Option<&Path> {
    561         self.repo_local_root.as_deref()
    562     }
    563 
    564     /// Returns the optional explicit configuration path.
    565     #[must_use]
    566     pub fn config_path(&self) -> Option<&Path> {
    567         self.config_path.as_deref()
    568     }
    569 
    570     /// Returns the selected human or governed machine-result encoding.
    571     #[must_use]
    572     pub const fn output_mode(&self) -> RhiCliOutputModeV1 {
    573         self.output_mode
    574     }
    575 
    576     /// Returns the exact governed command selection.
    577     #[must_use]
    578     pub const fn command(&self) -> &RhiCommandV1 {
    579         &self.command
    580     }
    581 }
    582 
    583 impl fmt::Debug for RhiCliInvocationV1 {
    584     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    585         formatter
    586             .debug_struct("RhiCliInvocationV1")
    587             .field("profile", &self.profile)
    588             .field("instance", &"[redacted]")
    589             .field(
    590                 "repo_local_root",
    591                 &self.repo_local_root.as_ref().map(|_| "[redacted]"),
    592             )
    593             .field(
    594                 "config_path",
    595                 &self.config_path.as_ref().map(|_| "[redacted]"),
    596             )
    597             .field("output_mode", &self.output_mode)
    598             .field("command", &self.command)
    599             .finish()
    600     }
    601 }
    602 
    603 /// Parses the exact hardened RHI bootstrap and command tree once.
    604 ///
    605 /// The iterator must include the program name as its first element. Clap's
    606 /// dependency-owned diagnostic is deliberately discarded so caller-controlled
    607 /// argument text cannot escape through this crate's stable error boundary.
    608 pub fn parse_rhi_cli_v1_from<I, T>(arguments: I) -> Result<RhiCliInvocationV1, RhiCliV1Error>
    609 where
    610     I: IntoIterator<Item = T>,
    611     T: Into<OsString> + Clone,
    612 {
    613     let parsed = RawRhiCliV1::try_parse_from(arguments)
    614         .map_err(|_| RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidArguments))?;
    615     let profile = parsed
    616         .profile
    617         .ok_or_else(|| RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidArguments))?
    618         .into();
    619     let instance = parsed
    620         .instance
    621         .ok_or_else(|| RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidArguments))?;
    622     let instance = InstanceId::new(instance)
    623         .map_err(|_| RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidInstance))?;
    624     validate_bootstrap_paths(
    625         profile,
    626         parsed.repo_local_root.as_deref(),
    627         parsed.config.as_deref(),
    628     )?;
    629 
    630     Ok(RhiCliInvocationV1 {
    631         profile,
    632         instance,
    633         repo_local_root: parsed.repo_local_root,
    634         config_path: parsed.config,
    635         output_mode: parsed.output.into(),
    636         command: admit_command(parsed.command)?,
    637     })
    638 }
    639 
    640 /// Selects the sole permitted execution authority for an admitted command.
    641 ///
    642 /// This function performs no filesystem, database, socket, environment, task,
    643 /// or process work. Later executors consume the plan without reparsing process
    644 /// arguments. No live command receives direct SQLite or offline fallback
    645 /// authority.
    646 #[must_use]
    647 pub const fn plan_rhi_cli_v1(invocation: &RhiCliInvocationV1) -> RhiCliExecutionPlanV1 {
    648     match &invocation.command {
    649         RhiCommandV1::Run => daemon_plan(),
    650         RhiCommandV1::Config(RhiConfigCommandV1::Init)
    651         | RhiCommandV1::Config(RhiConfigCommandV1::Validate)
    652         | RhiCommandV1::Config(RhiConfigCommandV1::Schema)
    653         | RhiCommandV1::Config(RhiConfigCommandV1::Apply(_)) => {
    654             offline_plan(RhiCliOfflineOperationV1::Config)
    655         }
    656         RhiCommandV1::Config(RhiConfigCommandV1::Show) => {
    657             admin_plan(RhiCliAdminOperationV1::EffectiveConfig)
    658         }
    659         RhiCommandV1::State(RhiStateCommandV1::Init)
    660         | RhiCommandV1::State(RhiStateCommandV1::Restore(_))
    661         | RhiCommandV1::State(RhiStateCommandV1::Verify)
    662         | RhiCommandV1::State(RhiStateCommandV1::Migrate) => {
    663             offline_plan(RhiCliOfflineOperationV1::StateExclusive)
    664         }
    665         RhiCommandV1::State(RhiStateCommandV1::Status) => {
    666             admin_plan(RhiCliAdminOperationV1::StateStatus)
    667         }
    668         RhiCommandV1::State(RhiStateCommandV1::Backup(_)) => {
    669             admin_plan(RhiCliAdminOperationV1::StateBackup)
    670         }
    671         RhiCommandV1::Identity(RhiIdentityCommandV1::Init) => {
    672             offline_plan(RhiCliOfflineOperationV1::IdentityExclusive)
    673         }
    674         RhiCommandV1::Identity(RhiIdentityCommandV1::Status) => {
    675             admin_plan(RhiCliAdminOperationV1::IdentityStatus)
    676         }
    677         RhiCommandV1::Identity(RhiIdentityCommandV1::ExportPublic) => {
    678             admin_plan(RhiCliAdminOperationV1::IdentityPublic)
    679         }
    680         RhiCommandV1::Status => admin_plan(RhiCliAdminOperationV1::Status),
    681         RhiCommandV1::Metrics(RhiMetricsCommandV1::Snapshot) => {
    682             admin_plan(RhiCliAdminOperationV1::MetricsSnapshot)
    683         }
    684         RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Status) => {
    685             admin_plan(RhiCliAdminOperationV1::ReconciliationStatus)
    686         }
    687         RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Jobs(_)) => {
    688             admin_plan(RhiCliAdminOperationV1::ReconciliationJobs)
    689         }
    690         RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Refresh(_)) => {
    691             admin_plan(RhiCliAdminOperationV1::ReconciliationRefresh)
    692         }
    693         RhiCommandV1::Sources(RhiSourcesCommandV1::List(_)) => {
    694             admin_plan(RhiCliAdminOperationV1::Sources)
    695         }
    696         RhiCommandV1::Trade(RhiTradeCommandV1::Projection(_)) => {
    697             admin_plan(RhiCliAdminOperationV1::TradeProjection)
    698         }
    699         RhiCommandV1::Trade(RhiTradeCommandV1::ReportCurrent(_)) => {
    700             admin_plan(RhiCliAdminOperationV1::TradeReportCurrent)
    701         }
    702         RhiCommandV1::Trade(RhiTradeCommandV1::Reports(_)) => {
    703             admin_plan(RhiCliAdminOperationV1::TradeReports)
    704         }
    705         RhiCommandV1::Publication(RhiPublicationCommandV1::Backlog(_)) => {
    706             admin_plan(RhiCliAdminOperationV1::PublicationBacklog)
    707         }
    708         RhiCommandV1::Publication(RhiPublicationCommandV1::Targets(_)) => {
    709             admin_plan(RhiCliAdminOperationV1::PublicationTargets)
    710         }
    711         RhiCommandV1::Publication(RhiPublicationCommandV1::Retry(_)) => {
    712             admin_plan(RhiCliAdminOperationV1::PublicationRetry)
    713         }
    714         RhiCommandV1::Presence(RhiPresenceCommandV1::Desired) => {
    715             admin_plan(RhiCliAdminOperationV1::PresenceDesired)
    716         }
    717         RhiCommandV1::Presence(RhiPresenceCommandV1::Render(_)) => {
    718             admin_plan(RhiCliAdminOperationV1::PresenceRender)
    719         }
    720         RhiCommandV1::Presence(RhiPresenceCommandV1::Refresh(_)) => {
    721             admin_plan(RhiCliAdminOperationV1::PresenceRefresh)
    722         }
    723         RhiCommandV1::Doctor => offline_plan(RhiCliOfflineOperationV1::Doctor),
    724     }
    725 }
    726 
    727 const fn daemon_plan() -> RhiCliExecutionPlanV1 {
    728     RhiCliExecutionPlanV1 {
    729         primary_authority: RhiCliPrimaryAuthorityV1::Daemon,
    730         offline_operation: None,
    731         admin_operation: None,
    732     }
    733 }
    734 
    735 const fn offline_plan(operation: RhiCliOfflineOperationV1) -> RhiCliExecutionPlanV1 {
    736     RhiCliExecutionPlanV1 {
    737         primary_authority: RhiCliPrimaryAuthorityV1::Offline,
    738         offline_operation: Some(operation),
    739         admin_operation: None,
    740     }
    741 }
    742 
    743 const fn admin_plan(operation: RhiCliAdminOperationV1) -> RhiCliExecutionPlanV1 {
    744     RhiCliExecutionPlanV1 {
    745         primary_authority: RhiCliPrimaryAuthorityV1::LiveUnixAdmin,
    746         offline_operation: None,
    747         admin_operation: Some(operation),
    748     }
    749 }
    750 
    751 fn validate_bootstrap_paths(
    752     profile: RhiBootstrapProfileV1,
    753     repo_local_root: Option<&Path>,
    754     config_path: Option<&Path>,
    755 ) -> Result<(), RhiCliV1Error> {
    756     match (profile, repo_local_root) {
    757         (RhiBootstrapProfileV1::RepoLocal, Some(root)) if valid_absolute_path(root, true) => {}
    758         (RhiBootstrapProfileV1::RepoLocal, _) => {
    759             return Err(RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidRepoLocalRoot));
    760         }
    761         (_, Some(_)) => {
    762             return Err(RhiCliV1Error::new(
    763                 RhiCliV1ErrorKind::UnexpectedRepoLocalRoot,
    764             ));
    765         }
    766         (_, None) => {}
    767     }
    768 
    769     if config_path.is_some_and(|path| !valid_absolute_path(path, true)) {
    770         return Err(RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidConfigPath));
    771     }
    772     Ok(())
    773 }
    774 
    775 fn valid_absolute_path(path: &Path, require_non_root: bool) -> bool {
    776     path.is_absolute()
    777         && (!require_non_root || path.parent().is_some())
    778         && path
    779             .to_str()
    780             .is_some_and(|value| !value.is_empty() && value.len() <= 4_096)
    781         && !path
    782             .components()
    783             .any(|component| matches!(component, Component::ParentDir))
    784 }
    785 
    786 #[derive(Parser)]
    787 #[command(name = "rhi", disable_help_subcommand = true)]
    788 struct RawRhiCliV1 {
    789     #[arg(long, global = true, value_enum)]
    790     profile: Option<RawProfile>,
    791     #[arg(long, global = true)]
    792     instance: Option<String>,
    793     #[arg(long = "repo-local-root", global = true)]
    794     repo_local_root: Option<PathBuf>,
    795     #[arg(long, global = true)]
    796     config: Option<PathBuf>,
    797     #[arg(long, global = true, value_enum, default_value_t = RawOutputMode::Human)]
    798     output: RawOutputMode,
    799     #[command(subcommand)]
    800     command: RawCommand,
    801 }
    802 
    803 #[derive(Clone, Copy, ValueEnum)]
    804 enum RawProfile {
    805     ServiceHost,
    806     Interactive,
    807     RepoLocal,
    808 }
    809 
    810 impl From<RawProfile> for RhiBootstrapProfileV1 {
    811     fn from(value: RawProfile) -> Self {
    812         match value {
    813             RawProfile::ServiceHost => Self::ServiceHost,
    814             RawProfile::Interactive => Self::Interactive,
    815             RawProfile::RepoLocal => Self::RepoLocal,
    816         }
    817     }
    818 }
    819 
    820 #[derive(Clone, Copy, Default, ValueEnum)]
    821 enum RawOutputMode {
    822     #[default]
    823     Human,
    824     Json,
    825 }
    826 
    827 impl From<RawOutputMode> for RhiCliOutputModeV1 {
    828     fn from(value: RawOutputMode) -> Self {
    829         match value {
    830             RawOutputMode::Human => Self::Human,
    831             RawOutputMode::Json => Self::Json,
    832         }
    833     }
    834 }
    835 
    836 #[derive(Subcommand)]
    837 enum RawCommand {
    838     Run,
    839     Config {
    840         #[command(subcommand)]
    841         command: RawConfigCommand,
    842     },
    843     State {
    844         #[command(subcommand)]
    845         command: RawStateCommand,
    846     },
    847     Identity {
    848         #[command(subcommand)]
    849         command: RawIdentityCommand,
    850     },
    851     Status,
    852     Metrics {
    853         #[command(subcommand)]
    854         command: RawMetricsCommand,
    855     },
    856     Reconciliation {
    857         #[command(subcommand)]
    858         command: RawReconciliationCommand,
    859     },
    860     Sources {
    861         #[command(subcommand)]
    862         command: RawSourcesCommand,
    863     },
    864     Trade {
    865         #[command(subcommand)]
    866         command: RawTradeCommand,
    867     },
    868     Publication {
    869         #[command(subcommand)]
    870         command: RawPublicationCommand,
    871     },
    872     Presence {
    873         #[command(subcommand)]
    874         command: RawPresenceCommand,
    875     },
    876     Doctor,
    877 }
    878 
    879 macro_rules! command_enum {
    880     ($raw:ident, $public:ident, { $($variant:ident),+ $(,)? }) => {
    881         #[derive(Subcommand)]
    882         enum $raw {
    883             $($variant),+
    884         }
    885 
    886         impl From<$raw> for $public {
    887             fn from(value: $raw) -> Self {
    888                 match value {
    889                     $($raw::$variant => Self::$variant),+
    890                 }
    891             }
    892         }
    893     };
    894 }
    895 
    896 #[derive(Subcommand)]
    897 enum RawConfigCommand {
    898     Init,
    899     Validate,
    900     Show,
    901     Schema,
    902     Apply {
    903         #[arg(long = "candidate-config")]
    904         candidate_config: PathBuf,
    905     },
    906 }
    907 
    908 #[derive(Subcommand)]
    909 enum RawStateCommand {
    910     Init,
    911     Status,
    912     Backup {
    913         #[arg(long = "operation-id")]
    914         operation_id: String,
    915         #[arg(long)]
    916         target: PathBuf,
    917         #[arg(long = "expected-generation")]
    918         expected_generation: u64,
    919         #[arg(long, required = true)]
    920         confirm: bool,
    921     },
    922     Restore {
    923         #[arg(long)]
    924         manifest: PathBuf,
    925         #[arg(long = "manifest-sha256")]
    926         manifest_sha256: String,
    927         #[arg(long)]
    928         bundle: PathBuf,
    929         #[arg(long = "maximum-state-bytes")]
    930         maximum_state_bytes: u64,
    931         #[arg(long, required = true)]
    932         confirm: bool,
    933     },
    934     Verify,
    935     Migrate,
    936 }
    937 
    938 command_enum!(RawIdentityCommand, RhiIdentityCommandV1, {
    939     Init,
    940     Status,
    941     ExportPublic,
    942 });
    943 command_enum!(RawMetricsCommand, RhiMetricsCommandV1, { Snapshot });
    944 
    945 #[derive(Subcommand)]
    946 enum RawReconciliationCommand {
    947     Status,
    948     Jobs {
    949         #[command(flatten)]
    950         page: RawPageQuery,
    951     },
    952     Refresh {
    953         #[arg(long = "operation-id")]
    954         operation_id: String,
    955         #[arg(long = "trade-id")]
    956         trade_id: String,
    957         #[arg(long = "expected-dirty-generation")]
    958         expected_dirty_generation: u64,
    959     },
    960 }
    961 
    962 #[derive(Subcommand)]
    963 enum RawSourcesCommand {
    964     List {
    965         #[command(flatten)]
    966         page: RawPageQuery,
    967     },
    968 }
    969 
    970 #[derive(Subcommand)]
    971 enum RawTradeCommand {
    972     Projection {
    973         #[arg(long = "trade-id")]
    974         trade_id: String,
    975     },
    976     ReportCurrent {
    977         #[arg(long = "trade-id")]
    978         trade_id: String,
    979     },
    980     Reports {
    981         #[arg(long = "trade-id")]
    982         trade_id: String,
    983         #[command(flatten)]
    984         page: RawPageQuery,
    985     },
    986 }
    987 
    988 #[derive(Subcommand)]
    989 enum RawPublicationCommand {
    990     Backlog {
    991         #[command(flatten)]
    992         page: RawPageQuery,
    993     },
    994     Targets {
    995         #[command(flatten)]
    996         page: RawPageQuery,
    997     },
    998     Retry {
    999         #[arg(long = "operation-id")]
   1000         operation_id: String,
   1001         #[arg(long = "workflow-id")]
   1002         workflow_id: String,
   1003         #[arg(long = "expected-generation")]
   1004         expected_generation: u64,
   1005     },
   1006 }
   1007 
   1008 #[derive(Subcommand)]
   1009 enum RawPresenceCommand {
   1010     Desired,
   1011     Render {
   1012         #[arg(long = "operation-id")]
   1013         operation_id: String,
   1014         #[arg(long = "expected-generation")]
   1015         expected_generation: u64,
   1016     },
   1017     Refresh {
   1018         #[arg(long = "operation-id")]
   1019         operation_id: String,
   1020         #[arg(long = "expected-generation")]
   1021         expected_generation: u64,
   1022     },
   1023 }
   1024 
   1025 #[derive(clap::Args)]
   1026 struct RawPageQuery {
   1027     #[arg(long, default_value_t = 100)]
   1028     limit: u16,
   1029     #[arg(long)]
   1030     cursor: Option<String>,
   1031 }
   1032 
   1033 fn admit_command(command: RawCommand) -> Result<RhiCommandV1, RhiCliV1Error> {
   1034     let invalid = || RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidCommandInput);
   1035     let page = |value: RawPageQuery| {
   1036         if !(1..=200).contains(&value.limit)
   1037             || value.cursor.as_deref().is_some_and(|cursor| {
   1038                 cursor.is_empty()
   1039                     || cursor.len() > 512
   1040                     || cursor != cursor.trim()
   1041                     || cursor.chars().any(char::is_control)
   1042             })
   1043         {
   1044             return Err(invalid());
   1045         }
   1046         Ok(RhiPageQueryArgsV1 {
   1047             limit: value.limit,
   1048             cursor: value.cursor.map(String::into_boxed_str),
   1049         })
   1050     };
   1051     let bounded_id = |value: String| {
   1052         if value.is_empty()
   1053             || value.len() > 128
   1054             || value != value.trim()
   1055             || value.chars().any(char::is_control)
   1056         {
   1057             Err(invalid())
   1058         } else {
   1059             Ok(value.into_boxed_str())
   1060         }
   1061     };
   1062     let trade_id = |value: String| match radroots_event::id::TradeId::parse(&value) {
   1063         Ok(parsed) if parsed.to_hex() == value => Ok(value.into_boxed_str()),
   1064         Ok(_) | Err(_) => Err(invalid()),
   1065     };
   1066     Ok(match command {
   1067         RawCommand::Run => RhiCommandV1::Run,
   1068         RawCommand::Config { command } => RhiCommandV1::Config(match command {
   1069             RawConfigCommand::Init => RhiConfigCommandV1::Init,
   1070             RawConfigCommand::Validate => RhiConfigCommandV1::Validate,
   1071             RawConfigCommand::Show => RhiConfigCommandV1::Show,
   1072             RawConfigCommand::Schema => RhiConfigCommandV1::Schema,
   1073             RawConfigCommand::Apply { candidate_config }
   1074                 if valid_absolute_path(&candidate_config, true) =>
   1075             {
   1076                 RhiConfigCommandV1::Apply(RhiConfigApplyArgsV1 { candidate_config })
   1077             }
   1078             RawConfigCommand::Apply { .. } => return Err(invalid()),
   1079         }),
   1080         RawCommand::State { command } => RhiCommandV1::State(match command {
   1081             RawStateCommand::Init => RhiStateCommandV1::Init,
   1082             RawStateCommand::Status => RhiStateCommandV1::Status,
   1083             RawStateCommand::Backup {
   1084                 operation_id,
   1085                 target,
   1086                 expected_generation,
   1087                 confirm: true,
   1088             } if valid_absolute_path(&target, true) => {
   1089                 RhiStateCommandV1::Backup(RhiStateBackupArgsV1 {
   1090                     operation_id: bounded_id(operation_id)?,
   1091                     target,
   1092                     expected_generation,
   1093                 })
   1094             }
   1095             RawStateCommand::Backup { .. } => return Err(invalid()),
   1096             RawStateCommand::Restore {
   1097                 manifest,
   1098                 manifest_sha256,
   1099                 bundle,
   1100                 maximum_state_bytes,
   1101                 confirm: true,
   1102             } if valid_absolute_path(&manifest, true)
   1103                 && valid_absolute_path(&bundle, true)
   1104                 && maximum_state_bytes != 0
   1105                 && is_lower_hex(&manifest_sha256, 64) =>
   1106             {
   1107                 RhiStateCommandV1::Restore(RhiStateRestoreArgsV1 {
   1108                     manifest,
   1109                     manifest_sha256: manifest_sha256.into_boxed_str(),
   1110                     bundle,
   1111                     maximum_state_bytes,
   1112                 })
   1113             }
   1114             RawStateCommand::Restore { .. } => return Err(invalid()),
   1115             RawStateCommand::Verify => RhiStateCommandV1::Verify,
   1116             RawStateCommand::Migrate => RhiStateCommandV1::Migrate,
   1117         }),
   1118         RawCommand::Identity { command } => RhiCommandV1::Identity(command.into()),
   1119         RawCommand::Status => RhiCommandV1::Status,
   1120         RawCommand::Metrics { command } => RhiCommandV1::Metrics(command.into()),
   1121         RawCommand::Reconciliation { command } => RhiCommandV1::Reconciliation(match command {
   1122             RawReconciliationCommand::Status => RhiReconciliationCommandV1::Status,
   1123             RawReconciliationCommand::Jobs { page: value } => {
   1124                 RhiReconciliationCommandV1::Jobs(page(value)?)
   1125             }
   1126             RawReconciliationCommand::Refresh {
   1127                 operation_id,
   1128                 trade_id: selected_trade,
   1129                 expected_dirty_generation,
   1130             } => RhiReconciliationCommandV1::Refresh(RhiReconciliationRefreshArgsV1 {
   1131                 operation_id: bounded_id(operation_id)?,
   1132                 trade_id: trade_id(selected_trade)?,
   1133                 expected_dirty_generation,
   1134             }),
   1135         }),
   1136         RawCommand::Sources { command } => RhiCommandV1::Sources(match command {
   1137             RawSourcesCommand::List { page: value } => RhiSourcesCommandV1::List(page(value)?),
   1138         }),
   1139         RawCommand::Trade { command } => RhiCommandV1::Trade(match command {
   1140             RawTradeCommand::Projection { trade_id: value } => {
   1141                 RhiTradeCommandV1::Projection(RhiTradeArgsV1 {
   1142                     trade_id: trade_id(value)?,
   1143                 })
   1144             }
   1145             RawTradeCommand::ReportCurrent { trade_id: value } => {
   1146                 RhiTradeCommandV1::ReportCurrent(RhiTradeArgsV1 {
   1147                     trade_id: trade_id(value)?,
   1148                 })
   1149             }
   1150             RawTradeCommand::Reports {
   1151                 trade_id: value,
   1152                 page: selected_page,
   1153             } => RhiTradeCommandV1::Reports(RhiTradePageArgsV1 {
   1154                 trade_id: trade_id(value)?,
   1155                 page: page(selected_page)?,
   1156             }),
   1157         }),
   1158         RawCommand::Publication { command } => RhiCommandV1::Publication(match command {
   1159             RawPublicationCommand::Backlog { page: value } => {
   1160                 RhiPublicationCommandV1::Backlog(page(value)?)
   1161             }
   1162             RawPublicationCommand::Targets { page: value } => {
   1163                 RhiPublicationCommandV1::Targets(page(value)?)
   1164             }
   1165             RawPublicationCommand::Retry {
   1166                 operation_id,
   1167                 workflow_id,
   1168                 expected_generation,
   1169             } => RhiPublicationCommandV1::Retry(RhiPublicationRetryArgsV1 {
   1170                 operation_id: bounded_id(operation_id)?,
   1171                 workflow_id: bounded_id(workflow_id)?,
   1172                 expected_generation,
   1173             }),
   1174         }),
   1175         RawCommand::Presence { command } => RhiCommandV1::Presence(match command {
   1176             RawPresenceCommand::Desired => RhiPresenceCommandV1::Desired,
   1177             RawPresenceCommand::Render {
   1178                 operation_id,
   1179                 expected_generation,
   1180             } => RhiPresenceCommandV1::Render(RhiPresenceMutationArgsV1 {
   1181                 operation_id: bounded_id(operation_id)?,
   1182                 expected_generation,
   1183             }),
   1184             RawPresenceCommand::Refresh {
   1185                 operation_id,
   1186                 expected_generation,
   1187             } => RhiPresenceCommandV1::Refresh(RhiPresenceMutationArgsV1 {
   1188                 operation_id: bounded_id(operation_id)?,
   1189                 expected_generation,
   1190             }),
   1191         }),
   1192         RawCommand::Doctor => RhiCommandV1::Doctor,
   1193     })
   1194 }
   1195 
   1196 fn is_lower_hex(value: &str, length: usize) -> bool {
   1197     value.len() == length
   1198         && value
   1199             .bytes()
   1200             .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
   1201 }
   1202 
   1203 #[cfg(test)]
   1204 mod tests {
   1205     use super::*;
   1206 
   1207     fn parse(command: &[&str]) -> Result<RhiCliInvocationV1, RhiCliV1Error> {
   1208         let mut arguments = vec!["rhi", "--profile", "service-host", "--instance", "default"];
   1209         arguments.extend_from_slice(command);
   1210         parse_rhi_cli_v1_from(arguments)
   1211     }
   1212 
   1213     #[test]
   1214     fn exact_command_inventory_parses() {
   1215         let trade = "00000000000000000000000000000000";
   1216         let vectors = [
   1217             vec!["run"],
   1218             vec!["config", "init"],
   1219             vec!["config", "validate"],
   1220             vec!["config", "show"],
   1221             vec!["config", "schema"],
   1222             vec![
   1223                 "config",
   1224                 "apply",
   1225                 "--candidate-config",
   1226                 "/tmp/candidate.toml",
   1227             ],
   1228             vec!["state", "init"],
   1229             vec!["state", "status"],
   1230             vec![
   1231                 "state",
   1232                 "backup",
   1233                 "--operation-id",
   1234                 "backup-1",
   1235                 "--target",
   1236                 "/tmp/backup",
   1237                 "--expected-generation",
   1238                 "1",
   1239                 "--confirm",
   1240             ],
   1241             vec![
   1242                 "state",
   1243                 "restore",
   1244                 "--manifest",
   1245                 "/tmp/manifest.json",
   1246                 "--manifest-sha256",
   1247                 "0000000000000000000000000000000000000000000000000000000000000000",
   1248                 "--bundle",
   1249                 "/tmp/backup",
   1250                 "--maximum-state-bytes",
   1251                 "1048576",
   1252                 "--confirm",
   1253             ],
   1254             vec!["state", "verify"],
   1255             vec!["state", "migrate"],
   1256             vec!["identity", "init"],
   1257             vec!["identity", "status"],
   1258             vec!["identity", "export-public"],
   1259             vec!["status"],
   1260             vec!["metrics", "snapshot"],
   1261             vec!["reconciliation", "status"],
   1262             vec!["reconciliation", "jobs"],
   1263             vec![
   1264                 "reconciliation",
   1265                 "refresh",
   1266                 "--operation-id",
   1267                 "refresh-1",
   1268                 "--trade-id",
   1269                 trade,
   1270                 "--expected-dirty-generation",
   1271                 "1",
   1272             ],
   1273             vec!["sources", "list"],
   1274             vec!["trade", "projection", "--trade-id", trade],
   1275             vec!["trade", "report-current", "--trade-id", trade],
   1276             vec!["trade", "reports", "--trade-id", trade],
   1277             vec!["publication", "backlog"],
   1278             vec!["publication", "targets"],
   1279             vec![
   1280                 "publication",
   1281                 "retry",
   1282                 "--operation-id",
   1283                 "retry-1",
   1284                 "--workflow-id",
   1285                 "workflow-1",
   1286                 "--expected-generation",
   1287                 "1",
   1288             ],
   1289             vec!["presence", "desired"],
   1290             vec![
   1291                 "presence",
   1292                 "render",
   1293                 "--operation-id",
   1294                 "render-1",
   1295                 "--expected-generation",
   1296                 "1",
   1297             ],
   1298             vec![
   1299                 "presence",
   1300                 "refresh",
   1301                 "--operation-id",
   1302                 "presence-1",
   1303                 "--expected-generation",
   1304                 "1",
   1305             ],
   1306             vec!["doctor"],
   1307         ];
   1308         for arguments in vectors {
   1309             parse(&arguments).expect("command");
   1310         }
   1311     }
   1312 
   1313     #[test]
   1314     fn profiles_paths_and_output_are_cross_bound() {
   1315         for profile in ["service-host", "interactive"] {
   1316             let invocation = parse_rhi_cli_v1_from([
   1317                 "rhi",
   1318                 "--profile",
   1319                 profile,
   1320                 "--instance",
   1321                 "north-01",
   1322                 "--config",
   1323                 "/etc/radroots/rhi.toml",
   1324                 "--output",
   1325                 "json",
   1326                 "run",
   1327             ])
   1328             .expect("host profile");
   1329             assert_eq!(invocation.instance().as_str(), "north-01");
   1330             assert_eq!(
   1331                 invocation.config_path(),
   1332                 Some(Path::new("/etc/radroots/rhi.toml"))
   1333             );
   1334             assert_eq!(invocation.output_mode(), RhiCliOutputModeV1::Json);
   1335             assert!(invocation.repo_local_root().is_none());
   1336         }
   1337 
   1338         let repo_local = parse_rhi_cli_v1_from([
   1339             "rhi",
   1340             "--profile",
   1341             "repo-local",
   1342             "--instance",
   1343             "dev",
   1344             "--repo-local-root",
   1345             "/repo/radroots",
   1346             "config",
   1347             "validate",
   1348         ])
   1349         .expect("repo local");
   1350         assert_eq!(repo_local.profile(), RhiBootstrapProfileV1::RepoLocal);
   1351         assert_eq!(
   1352             repo_local.repo_local_root(),
   1353             Some(Path::new("/repo/radroots"))
   1354         );
   1355         assert_eq!(repo_local.output_mode(), RhiCliOutputModeV1::Human);
   1356     }
   1357 
   1358     #[test]
   1359     fn invalid_bootstrap_values_fail_with_stable_kinds() {
   1360         for value in ["Upper", "north-", "north.west"] {
   1361             let error = parse_rhi_cli_v1_from([
   1362                 "rhi",
   1363                 "--profile",
   1364                 "service-host",
   1365                 "--instance",
   1366                 value,
   1367                 "run",
   1368             ])
   1369             .expect_err("invalid instance");
   1370             assert_eq!(error.kind(), RhiCliV1ErrorKind::InvalidInstance);
   1371         }
   1372 
   1373         let exact = "a".repeat(radroots_runtime_paths::INSTANCE_ID_MAX_BYTES);
   1374         assert!(
   1375             parse_rhi_cli_v1_from([
   1376                 "rhi",
   1377                 "--profile",
   1378                 "service-host",
   1379                 "--instance",
   1380                 exact.as_str(),
   1381                 "run",
   1382             ])
   1383             .is_ok()
   1384         );
   1385         let overlong = "a".repeat(radroots_runtime_paths::INSTANCE_ID_MAX_BYTES + 1);
   1386         assert_eq!(
   1387             parse_rhi_cli_v1_from([
   1388                 "rhi",
   1389                 "--profile",
   1390                 "service-host",
   1391                 "--instance",
   1392                 overlong.as_str(),
   1393                 "run",
   1394             ])
   1395             .expect_err("overlong instance")
   1396             .kind(),
   1397             RhiCliV1ErrorKind::InvalidInstance
   1398         );
   1399 
   1400         assert_eq!(
   1401             parse_rhi_cli_v1_from(["rhi", "--profile", "repo-local", "--instance", "dev", "run"])
   1402                 .expect_err("missing root")
   1403                 .kind(),
   1404             RhiCliV1ErrorKind::InvalidRepoLocalRoot
   1405         );
   1406         assert_eq!(
   1407             parse_rhi_cli_v1_from([
   1408                 "rhi",
   1409                 "--profile",
   1410                 "interactive",
   1411                 "--instance",
   1412                 "dev",
   1413                 "--repo-local-root",
   1414                 "/repo/radroots",
   1415                 "run",
   1416             ])
   1417             .expect_err("unexpected root")
   1418             .kind(),
   1419             RhiCliV1ErrorKind::UnexpectedRepoLocalRoot
   1420         );
   1421         for invalid in ["relative", "/", "/repo/../escape"] {
   1422             assert_eq!(
   1423                 parse_rhi_cli_v1_from([
   1424                     "rhi",
   1425                     "--profile",
   1426                     "repo-local",
   1427                     "--instance",
   1428                     "dev",
   1429                     "--repo-local-root",
   1430                     invalid,
   1431                     "run",
   1432                 ])
   1433                 .expect_err("invalid root")
   1434                 .kind(),
   1435                 RhiCliV1ErrorKind::InvalidRepoLocalRoot
   1436             );
   1437         }
   1438         for invalid in ["relative.toml", "/", "/etc/../secret.toml"] {
   1439             assert_eq!(
   1440                 parse_rhi_cli_v1_from([
   1441                     "rhi",
   1442                     "--profile",
   1443                     "service-host",
   1444                     "--instance",
   1445                     "default",
   1446                     "--config",
   1447                     invalid,
   1448                     "run",
   1449                 ])
   1450                 .expect_err("invalid config")
   1451                 .kind(),
   1452                 RhiCliV1ErrorKind::InvalidConfigPath
   1453             );
   1454         }
   1455     }
   1456 
   1457     #[test]
   1458     fn removed_and_unknown_inputs_fail_without_sources() {
   1459         for arguments in [
   1460             vec!["rhi", "run"],
   1461             vec!["rhi", "--profile", "service-host", "run"],
   1462             vec!["rhi", "--profile", "service-host", "--instance", "default"],
   1463             vec![
   1464                 "rhi",
   1465                 "--profile",
   1466                 "production",
   1467                 "--instance",
   1468                 "default",
   1469                 "run",
   1470             ],
   1471             vec![
   1472                 "rhi",
   1473                 "--profile",
   1474                 "service-host",
   1475                 "--instance",
   1476                 "default",
   1477                 "--identity",
   1478                 "/secret",
   1479                 "run",
   1480             ],
   1481             vec![
   1482                 "rhi",
   1483                 "--profile",
   1484                 "service-host",
   1485                 "--instance",
   1486                 "default",
   1487                 "--allow-generate-identity",
   1488                 "run",
   1489             ],
   1490             vec![
   1491                 "rhi",
   1492                 "--profile",
   1493                 "service-host",
   1494                 "--instance",
   1495                 "default",
   1496                 "--logs-dir",
   1497                 "/tmp/logs",
   1498                 "run",
   1499             ],
   1500             vec![
   1501                 "rhi",
   1502                 "--profile",
   1503                 "service-host",
   1504                 "--instance",
   1505                 "default",
   1506                 "--worker",
   1507                 "legacy",
   1508                 "run",
   1509             ],
   1510             vec![
   1511                 "rhi",
   1512                 "--profile",
   1513                 "service-host",
   1514                 "--instance",
   1515                 "default",
   1516                 "identity",
   1517                 "rekey",
   1518             ],
   1519             vec![
   1520                 "rhi",
   1521                 "--profile",
   1522                 "service-host",
   1523                 "--instance",
   1524                 "default",
   1525                 "identity",
   1526                 "replace",
   1527             ],
   1528         ] {
   1529             let failure = parse_rhi_cli_v1_from(arguments).expect_err("arguments must fail");
   1530             assert_eq!(failure.kind(), RhiCliV1ErrorKind::InvalidArguments);
   1531             assert!(Error::source(&failure).is_none());
   1532         }
   1533     }
   1534 
   1535     #[test]
   1536     fn debug_and_errors_do_not_render_caller_values() {
   1537         let instance = "sensitive-instance";
   1538         let config = "/sensitive/config.toml";
   1539         let invocation = parse_rhi_cli_v1_from([
   1540             "rhi",
   1541             "--profile",
   1542             "service-host",
   1543             "--instance",
   1544             instance,
   1545             "--config",
   1546             config,
   1547             "doctor",
   1548         ])
   1549         .expect("invocation");
   1550         let debug = format!("{invocation:?}");
   1551         assert!(!debug.contains(instance));
   1552         assert!(!debug.contains(config));
   1553 
   1554         let secret = "secret-cli-value";
   1555         let failure =
   1556             parse_rhi_cli_v1_from(["rhi", "--profile", secret, "--instance", "default", "run"])
   1557                 .expect_err("invalid profile");
   1558         let rendered = format!("{failure} {failure:?}");
   1559         assert!(!rendered.contains(secret));
   1560         assert!(Error::source(&failure).is_none());
   1561     }
   1562 }