cli

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

commit f97e6106c21f54f8fd6c8e4463eb4f37b5f02773
parent 35329b336ccff3ee4b7e31552cfdad7ee6c5c174
Author: triesap <tyson@radroots.org>
Date:   Mon,  6 Apr 2026 23:19:44 +0000

cli: add typed command exit outcomes

Diffstat:
Msrc/commands/mod.rs | 18++++++++++++------
Msrc/commands/myc.rs | 15++++++++++++---
Msrc/commands/signer.rs | 15++++++++++++---
Msrc/domain/mod.rs | 2--
Msrc/domain/runtime.rs | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/main.rs | 6+++---
Msrc/render/mod.rs | 26+++++++++++++-------------
Mtests/myc_status.rs | 4++--
Mtests/signer_status.rs | 2+-
9 files changed, 132 insertions(+), 34 deletions(-)

diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -4,7 +4,7 @@ pub mod runtime; pub mod signer; use crate::cli::{Command, IdentityCommand, MycCommand, RuntimeCommand, SignerCommand}; -use crate::domain::CommandOutput; +use crate::domain::runtime::{CommandOutput, CommandView}; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::runtime::logging::LoggingState; @@ -16,17 +16,23 @@ pub fn dispatch( ) -> Result<CommandOutput, RuntimeError> { match command { Command::Identity(identity) => match identity.command { - IdentityCommand::Init => Ok(CommandOutput::IdentityInit(identity::init(config)?)), - IdentityCommand::Show => Ok(CommandOutput::IdentityShow(identity::show(config)?)), + IdentityCommand::Init => Ok(CommandOutput::success(CommandView::IdentityInit( + identity::init(config)?, + ))), + IdentityCommand::Show => Ok(CommandOutput::success(CommandView::IdentityShow( + identity::show(config)?, + ))), }, Command::Myc(myc) => match myc.command { - MycCommand::Status => Ok(CommandOutput::MycStatus(myc::status(config))), + MycCommand::Status => Ok(myc::status(config)), }, Command::Runtime(runtime) => match runtime.command { - RuntimeCommand::Show => Ok(CommandOutput::RuntimeShow(runtime::show(config, logging))), + RuntimeCommand::Show => Ok(CommandOutput::success(CommandView::RuntimeShow( + runtime::show(config, logging), + ))), }, Command::Signer(signer) => match signer.command { - SignerCommand::Status => Ok(CommandOutput::SignerStatus(signer::status(config))), + SignerCommand::Status => Ok(signer::status(config)), }, } } diff --git a/src/commands/myc.rs b/src/commands/myc.rs @@ -1,6 +1,15 @@ -use crate::domain::runtime::MycStatusView; +use crate::domain::runtime::{CommandDisposition, CommandOutput, CommandView, MycStatusView}; use crate::runtime::config::RuntimeConfig; -pub fn status(config: &RuntimeConfig) -> MycStatusView { - crate::runtime::myc::resolve_status(&config.myc) +pub fn status(config: &RuntimeConfig) -> CommandOutput { + let view: MycStatusView = crate::runtime::myc::resolve_status(&config.myc); + match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::MycStatus(view)), + CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::MycStatus(view)) + } + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::MycStatus(view)) + } + } } diff --git a/src/commands/signer.rs b/src/commands/signer.rs @@ -1,7 +1,16 @@ -use crate::domain::runtime::SignerStatusView; +use crate::domain::runtime::{CommandDisposition, CommandOutput, CommandView, SignerStatusView}; use crate::runtime::config::RuntimeConfig; use crate::runtime::signer::resolve_signer_status; -pub fn status(config: &RuntimeConfig) -> SignerStatusView { - resolve_signer_status(config) +pub fn status(config: &RuntimeConfig) -> CommandOutput { + let view: SignerStatusView = resolve_signer_status(config); + match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::SignerStatus(view)), + CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::SignerStatus(view)) + } + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::SignerStatus(view)) + } + } } diff --git a/src/domain/mod.rs b/src/domain/mod.rs @@ -1,3 +1 @@ pub mod runtime; - -pub use runtime::CommandOutput; diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1,7 +1,63 @@ +use std::process::ExitCode; + use serde::Serialize; #[derive(Debug, Clone)] -pub enum CommandOutput { +pub struct CommandOutput { + disposition: CommandDisposition, + view: CommandView, +} + +impl CommandOutput { + pub fn success(view: CommandView) -> Self { + Self { + disposition: CommandDisposition::Success, + view, + } + } + + pub fn unconfigured(view: CommandView) -> Self { + Self { + disposition: CommandDisposition::Unconfigured, + view, + } + } + + pub fn external_unavailable(view: CommandView) -> Self { + Self { + disposition: CommandDisposition::ExternalUnavailable, + view, + } + } + + pub fn exit_code(&self) -> ExitCode { + self.disposition.exit_code() + } + + pub fn view(&self) -> &CommandView { + &self.view + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandDisposition { + Success, + Unconfigured, + ExternalUnavailable, +} + +impl CommandDisposition { + pub fn exit_code(self) -> ExitCode { + match self { + Self::Success => ExitCode::SUCCESS, + Self::Unconfigured => ExitCode::from(3), + Self::ExternalUnavailable => ExitCode::from(4), + } + } +} + +#[derive(Debug, Clone)] +pub enum CommandView { IdentityInit(IdentityInitView), IdentityShow(IdentityShowView), MycStatus(MycStatusView), @@ -82,6 +138,16 @@ pub struct SignerStatusView { pub myc: Option<MycStatusView>, } +impl SignerStatusView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + "unavailable" => CommandDisposition::ExternalUnavailable, + _ => CommandDisposition::Success, + } + } +} + #[derive(Debug, Clone, Serialize)] pub struct LocalSignerStatusView { pub account_id: String, @@ -105,6 +171,16 @@ pub struct MycStatusView { pub custody: Option<MycCustodyView>, } +impl MycStatusView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + "unavailable" => CommandDisposition::ExternalUnavailable, + _ => CommandDisposition::Success, + } + } +} + #[derive(Debug, Clone, Serialize)] pub struct MycCustodyView { pub signer: MycCustodyIdentityView, diff --git a/src/main.rs b/src/main.rs @@ -18,7 +18,7 @@ use crate::runtime::logging::initialize_logging; fn main() -> ExitCode { match run() { - Ok(()) => ExitCode::SUCCESS, + Ok(exit_code) => exit_code, Err(error) => { let _ = writeln!(std::io::stderr(), "{error}"); error.exit_code() @@ -26,11 +26,11 @@ fn main() -> ExitCode { } } -fn run() -> Result<(), runtime::RuntimeError> { +fn run() -> Result<ExitCode, runtime::RuntimeError> { let args = CliArgs::parse(); let config = RuntimeConfig::from_system(&args)?; let logging = initialize_logging(&config.logging)?; let output = dispatch(&args.command, &config, &logging)?; render_output(&output, config.output_format)?; - Ok(()) + Ok(output.exit_code()) } diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -1,6 +1,6 @@ use std::io::{self, Write}; -use crate::domain::runtime::CommandOutput; +use crate::domain::runtime::{CommandOutput, CommandView}; use crate::runtime::RuntimeError; use crate::runtime::config::OutputFormat; @@ -13,8 +13,8 @@ pub fn render_output(output: &CommandOutput, format: OutputFormat) -> Result<(), fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> { let mut stdout = io::stdout().lock(); - match output { - CommandOutput::IdentityInit(view) => { + match output.view() { + CommandView::IdentityInit(view) => { writeln!(stdout, "identity init")?; writeln!(stdout, " path: {}", view.path)?; writeln!(stdout, " created: {}", yes_no(view.created))?; @@ -30,7 +30,7 @@ fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> { view.public_identity.public_key_npub )?; } - CommandOutput::IdentityShow(view) => { + CommandView::IdentityShow(view) => { writeln!(stdout, "identity")?; writeln!(stdout, " path: {}", view.path)?; writeln!(stdout, " id: {}", view.public_identity.id)?; @@ -45,10 +45,10 @@ fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> { view.public_identity.public_key_npub )?; } - CommandOutput::MycStatus(view) => { + CommandView::MycStatus(view) => { render_myc_status(&mut stdout, view)?; } - CommandOutput::RuntimeShow(view) => { + CommandView::RuntimeShow(view) => { writeln!(stdout, "runtime")?; writeln!(stdout, " output format: {}", view.output_format)?; writeln!(stdout, "logging")?; @@ -81,7 +81,7 @@ fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> { writeln!(stdout, "myc")?; writeln!(stdout, " executable: {}", view.myc.executable)?; } - CommandOutput::SignerStatus(view) => { + CommandView::SignerStatus(view) => { writeln!(stdout, "signer")?; writeln!(stdout, " backend: {}", view.backend)?; writeln!(stdout, " state: {}", view.state)?; @@ -103,24 +103,24 @@ fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> { fn render_json(output: &CommandOutput) -> Result<(), RuntimeError> { let mut stdout = io::stdout().lock(); - match output { - CommandOutput::IdentityInit(view) => { + match output.view() { + CommandView::IdentityInit(view) => { serde_json::to_writer_pretty(&mut stdout, view)?; writeln!(stdout)?; } - CommandOutput::IdentityShow(view) => { + CommandView::IdentityShow(view) => { serde_json::to_writer_pretty(&mut stdout, view)?; writeln!(stdout)?; } - CommandOutput::MycStatus(view) => { + CommandView::MycStatus(view) => { serde_json::to_writer_pretty(&mut stdout, view)?; writeln!(stdout)?; } - CommandOutput::RuntimeShow(view) => { + CommandView::RuntimeShow(view) => { serde_json::to_writer_pretty(&mut stdout, view)?; writeln!(stdout)?; } - CommandOutput::SignerStatus(view) => { + CommandView::SignerStatus(view) => { serde_json::to_writer_pretty(&mut stdout, view)?; writeln!(stdout)?; } diff --git a/tests/myc_status.rs b/tests/myc_status.rs @@ -58,7 +58,7 @@ fn myc_status_reports_unavailable_for_invalid_status_payload() { .output() .expect("run myc status"); - assert!(output.status.success()); + assert_eq!(output.status.code(), Some(4)); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); assert_eq!(json["state"], "unavailable"); @@ -123,7 +123,7 @@ fn myc_status_reports_unavailable_when_executable_is_missing() { .output() .expect("run myc status"); - assert!(output.status.success()); + assert_eq!(output.status.code(), Some(4)); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); assert_eq!(json["state"], "unavailable"); diff --git a/tests/signer_status.rs b/tests/signer_status.rs @@ -65,7 +65,7 @@ fn signer_status_reports_local_unconfigured_when_identity_is_missing() { .output() .expect("run signer status"); - assert!(output.status.success()); + assert_eq!(output.status.code(), Some(3)); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); assert_eq!(json["backend"], "local");