cli

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

commit a86cae13aad8bac92cb28f55c01e94c11ec5cf88
parent af3f1a213c721b7d6d9ec33a1fcd4cf073c72f73
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Apr 2026 03:07:42 +0000

land cli output contract framework

Diffstat:
Msrc/cli.rs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/commands/runtime.rs | 11++++++++---
Msrc/domain/runtime.rs | 10+++++++++-
Msrc/main.rs | 25++++++++++++++++++++++++-
Msrc/render/mod.rs | 260+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Msrc/runtime/config.rs | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mtests/identity_commands.rs | 23+++++++++++++++++++++++
Mtests/runtime_show.rs | 44+++++++++++++++++++++++++++++++++++++++++++-
8 files changed, 603 insertions(+), 75 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -1,14 +1,28 @@ use clap::{ArgAction, Args, Parser, Subcommand}; use std::path::PathBuf; +use crate::runtime::config::OutputFormat; + #[derive(Debug, Parser, Clone)] #[command(name = "radroots")] #[command(version)] pub struct CliArgs { #[arg(long, global = true, action = ArgAction::SetTrue)] pub json: bool, + #[arg(long, global = true, action = ArgAction::SetTrue)] + pub ndjson: bool, #[arg(long = "env-file", global = true)] pub env_file: Option<PathBuf>, + #[arg(long, global = true, action = ArgAction::SetTrue)] + pub quiet: bool, + #[arg(long, global = true, action = ArgAction::SetTrue)] + pub verbose: bool, + #[arg(long, global = true, action = ArgAction::SetTrue)] + pub trace: bool, + #[arg(long = "dry-run", global = true, action = ArgAction::SetTrue)] + pub dry_run: bool, + #[arg(long = "no-color", global = true, action = ArgAction::SetTrue)] + pub no_color: bool, #[arg(long, global = true)] pub log_filter: Option<String>, #[arg(long, global = true)] @@ -45,6 +59,116 @@ pub enum Command { Sync(SyncArgs), } +impl Command { + pub fn display_name(&self) -> &'static str { + match self { + Self::Account(account) => match account.command { + AccountCommand::New => "account new", + AccountCommand::Whoami => "account whoami", + AccountCommand::Ls => "account ls", + AccountCommand::Use(_) => "account use", + }, + Self::Config(config) => match config.command { + ConfigCommand::Show => "config show", + }, + Self::Doctor => "doctor", + Self::Find(_) => "find", + Self::Job(job) => match job.command { + JobCommand::Ls => "job ls", + JobCommand::Get(_) => "job get", + JobCommand::Watch(_) => "job watch", + }, + Self::Listing(listing) => match listing.command { + ListingCommand::New => "listing new", + ListingCommand::Validate => "listing validate", + ListingCommand::Get(_) => "listing get", + ListingCommand::Publish => "listing publish", + ListingCommand::Update(_) => "listing update", + ListingCommand::Archive(_) => "listing archive", + }, + Self::Local(local) => match local.command { + LocalCommand::Init => "local init", + LocalCommand::Status => "local status", + LocalCommand::Export => "local export", + LocalCommand::Backup => "local backup", + }, + Self::Myc(myc) => match myc.command { + MycCommand::Status => "myc status", + }, + Self::Net(net) => match net.command { + NetCommand::Status => "net status", + }, + Self::Order(order) => match order.command { + OrderCommand::New => "order new", + OrderCommand::Get(_) => "order get", + OrderCommand::Ls => "order ls", + OrderCommand::Submit => "order submit", + OrderCommand::Watch(_) => "order watch", + OrderCommand::Cancel(_) => "order cancel", + OrderCommand::History => "order history", + }, + Self::Relay(relay) => match relay.command { + RelayCommand::Ls => "relay ls", + }, + Self::Rpc(rpc) => match rpc.command { + RpcCommand::Status => "rpc status", + RpcCommand::Sessions => "rpc sessions", + }, + Self::Signer(signer) => match signer.command { + SignerCommand::Status => "signer status", + }, + Self::Sync(sync) => match sync.command { + SyncCommand::Status => "sync status", + SyncCommand::Pull => "sync pull", + SyncCommand::Push => "sync push", + SyncCommand::Watch => "sync watch", + }, + } + } + + pub fn supports_output_format(&self, format: OutputFormat) -> bool { + match format { + OutputFormat::Human | OutputFormat::Json => true, + OutputFormat::Ndjson => matches!( + self, + Self::Account(AccountArgs { + command: AccountCommand::Ls, + }) | Self::Relay(RelayArgs { + command: RelayCommand::Ls, + }) | Self::Job(JobArgs { + command: JobCommand::Ls, + }) | Self::Rpc(RpcArgs { + command: RpcCommand::Sessions, + }) | Self::Order(OrderArgs { + command: OrderCommand::Ls | OrderCommand::History, + }) | Self::Sync(SyncArgs { + command: SyncCommand::Watch, + }) | Self::Find(_) + ), + } + } + + pub fn supports_dry_run(&self) -> bool { + !matches!( + self, + Self::Account(AccountArgs { + command: AccountCommand::New | AccountCommand::Use(_), + }) | Self::Local(LocalArgs { + command: LocalCommand::Init | LocalCommand::Export | LocalCommand::Backup, + }) | Self::Sync(SyncArgs { + command: SyncCommand::Pull | SyncCommand::Push, + }) | Self::Listing(ListingArgs { + command: ListingCommand::New + | ListingCommand::Publish + | ListingCommand::Update(_) + | ListingCommand::Archive(_), + }) | Self::Order(OrderArgs { + command: OrderCommand::New | OrderCommand::Submit | OrderCommand::Cancel(_), + }) + ) + } +} + #[derive(Debug, Clone, Args)] pub struct ConfigArgs { #[command(subcommand)] @@ -222,6 +346,7 @@ mod tests { AccountCommand, CliArgs, Command, ConfigCommand, JobCommand, ListingCommand, LocalCommand, MycCommand, NetCommand, OrderCommand, RelayCommand, RpcCommand, SignerCommand, SyncCommand, }; + use crate::runtime::config::OutputFormat; use clap::Parser; #[test] @@ -240,6 +365,9 @@ mod tests { let parsed = CliArgs::parse_from([ "radroots", "--json", + "--verbose", + "--dry-run", + "--no-color", "--env-file", ".env.local", "--log-filter", @@ -257,6 +385,9 @@ mod tests { "show", ]); assert!(parsed.json); + assert!(parsed.verbose); + assert!(parsed.dry_run); + assert!(parsed.no_color); assert_eq!( parsed.env_file.as_deref().and_then(|path| path.to_str()), Some(".env.local") @@ -429,4 +560,32 @@ mod tests { _ => panic!("unexpected command variant"), } } + + #[test] + fn command_contract_helpers_report_supported_modes() { + let config_show = CliArgs::parse_from(["radroots", "config", "show"]); + assert!( + config_show + .command + .supports_output_format(OutputFormat::Human) + ); + assert!( + config_show + .command + .supports_output_format(OutputFormat::Json) + ); + assert!( + !config_show + .command + .supports_output_format(OutputFormat::Ndjson) + ); + assert!(config_show.command.supports_dry_run()); + + let account_new = CliArgs::parse_from(["radroots", "account", "new"]); + assert_eq!(account_new.command.display_name(), "account new"); + assert!(!account_new.command.supports_dry_run()); + + let find = CliArgs::parse_from(["radroots", "find", "eggs"]); + assert!(find.command.supports_output_format(OutputFormat::Ndjson)); + } } diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs @@ -1,13 +1,18 @@ use crate::domain::runtime::{ - AccountRuntimeView, ConfigShowView, LoggingRuntimeView, MycRuntimeView, PathsRuntimeView, - SignerRuntimeView, + AccountRuntimeView, ConfigShowView, LoggingRuntimeView, MycRuntimeView, OutputRuntimeView, + PathsRuntimeView, SignerRuntimeView, }; use crate::runtime::config::RuntimeConfig; use crate::runtime::logging::LoggingState; pub fn show(config: &RuntimeConfig, logging: &LoggingState) -> ConfigShowView { ConfigShowView { - output_format: config.output_format.as_str().to_owned(), + output: OutputRuntimeView { + format: config.output.format.as_str().to_owned(), + verbosity: config.output.verbosity.as_str().to_owned(), + color: config.output.color, + dry_run: config.output.dry_run, + }, paths: PathsRuntimeView { user_config_path: config.paths.user_config_path.display().to_string(), workspace_config_path: config.paths.workspace_config_path.display().to_string(), diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -76,7 +76,7 @@ pub enum CommandView { #[derive(Debug, Clone, Serialize)] pub struct ConfigShowView { - pub output_format: String, + pub output: OutputRuntimeView, pub paths: PathsRuntimeView, pub logging: LoggingRuntimeView, pub account: AccountRuntimeView, @@ -85,6 +85,14 @@ pub struct ConfigShowView { } #[derive(Debug, Clone, Serialize)] +pub struct OutputRuntimeView { + pub format: String, + pub verbosity: String, + pub color: bool, + pub dry_run: bool, +} + +#[derive(Debug, Clone, Serialize)] pub struct LoggingRuntimeView { pub initialized: bool, pub filter: String, diff --git a/src/main.rs b/src/main.rs @@ -29,8 +29,31 @@ fn main() -> ExitCode { fn run() -> Result<ExitCode, runtime::RuntimeError> { let args = CliArgs::parse(); let config = RuntimeConfig::from_system(&args)?; + validate_command_contracts(&args.command, &config)?; let logging = initialize_logging(&config.logging)?; let output = dispatch(&args.command, &config, &logging)?; - render_output(&output, config.output_format)?; + render_output(&output, &config.output)?; Ok(output.exit_code()) } + +fn validate_command_contracts( + command: &crate::cli::Command, + config: &RuntimeConfig, +) -> Result<(), runtime::RuntimeError> { + if !command.supports_output_format(config.output.format) { + return Err(runtime::RuntimeError::Config(format!( + "`{}` does not support --{}", + command.display_name(), + config.output.format.as_str() + ))); + } + + if config.output.dry_run && !command.supports_dry_run() { + return Err(runtime::RuntimeError::Config(format!( + "`{}` does not support --dry-run yet", + command.display_name() + ))); + } + + Ok(()) +} diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -2,17 +2,22 @@ use std::io::{self, Write}; use crate::domain::runtime::{CommandOutput, CommandView}; use crate::runtime::RuntimeError; -use crate::runtime::config::OutputFormat; +use crate::runtime::config::{OutputConfig, OutputFormat}; -pub fn render_output(output: &CommandOutput, format: OutputFormat) -> Result<(), RuntimeError> { - match format { +pub fn render_output(output: &CommandOutput, config: &OutputConfig) -> Result<(), RuntimeError> { + match config.format { OutputFormat::Human => render_human(output), OutputFormat::Json => render_json(output), + OutputFormat::Ndjson => render_ndjson(output), } } fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> { let mut stdout = io::stdout().lock(); + render_human_to(&mut stdout, output) +} + +fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), RuntimeError> { match output.view() { CommandView::AccountNew(view) => { writeln!(stdout, "account new")?; @@ -52,11 +57,15 @@ fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> { } } CommandView::MycStatus(view) => { - render_myc_status(&mut stdout, view)?; + render_myc_status(stdout, view)?; } CommandView::ConfigShow(view) => { writeln!(stdout, "config")?; - writeln!(stdout, " output format: {}", view.output_format)?; + writeln!(stdout, "output")?; + writeln!(stdout, " format: {}", view.output.format)?; + writeln!(stdout, " verbosity: {}", view.output.verbosity)?; + writeln!(stdout, " color: {}", yes_no(view.output.color))?; + writeln!(stdout, " dry run: {}", yes_no(view.output.dry_run))?; writeln!(stdout, "paths")?; writeln!(stdout, " user config: {}", view.paths.user_config_path)?; writeln!( @@ -90,16 +99,14 @@ fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> { writeln!(stdout, "signer")?; writeln!(stdout, " backend: {}", view.backend)?; writeln!(stdout, " state: {}", view.state)?; - writeln!( - stdout, - " reason: {}", - view.reason.as_deref().unwrap_or("<none>") - )?; + if let Some(reason) = &view.reason { + writeln!(stdout, " reason: {reason}")?; + } if let Some(local) = &view.local { - render_local_signer(&mut stdout, "local signer", local)?; + render_local_signer(stdout, "local signer", local)?; } if let Some(myc) = &view.myc { - render_myc_status(&mut stdout, myc)?; + render_myc_status(stdout, myc)?; } } } @@ -108,31 +115,47 @@ fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> { fn render_json(output: &CommandOutput) -> Result<(), RuntimeError> { let mut stdout = io::stdout().lock(); + render_json_to(&mut stdout, output) +} + +fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), RuntimeError> { match output.view() { CommandView::AccountNew(view) => { - serde_json::to_writer_pretty(&mut stdout, view)?; + serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } CommandView::AccountWhoami(view) => { - serde_json::to_writer_pretty(&mut stdout, view)?; + serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } CommandView::MycStatus(view) => { - serde_json::to_writer_pretty(&mut stdout, view)?; + serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } CommandView::ConfigShow(view) => { - serde_json::to_writer_pretty(&mut stdout, view)?; + serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } CommandView::SignerStatus(view) => { - serde_json::to_writer_pretty(&mut stdout, view)?; + serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } } Ok(()) } +fn render_ndjson(output: &CommandOutput) -> Result<(), RuntimeError> { + let mut stdout = io::stdout().lock(); + render_ndjson_to(&mut stdout, output) +} + +fn render_ndjson_to(_stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), RuntimeError> { + Err(RuntimeError::Config(format!( + "`{}` does not support --ndjson", + human_command_name(output.view()) + ))) +} + fn yes_no(value: bool) -> &'static str { if value { "yes" } else { "no" } } @@ -167,16 +190,12 @@ fn render_myc_status( writeln!(stdout, " executable: {}", view.executable)?; writeln!(stdout, " state: {}", view.state)?; writeln!(stdout, " ready: {}", yes_no(view.ready))?; - writeln!( - stdout, - " service status: {}", - view.service_status.as_deref().unwrap_or("<unknown>") - )?; - writeln!( - stdout, - " reason: {}", - view.reason.as_deref().unwrap_or("<none>") - )?; + if let Some(service_status) = &view.service_status { + writeln!(stdout, " service status: {service_status}")?; + } + if let Some(reason) = &view.reason { + writeln!(stdout, " reason: {reason}")?; + } if !view.reasons.is_empty() { writeln!(stdout, " reasons: {}", view.reasons.join(" | "))?; } @@ -200,43 +219,80 @@ fn render_myc_custody_identity( ) -> Result<(), RuntimeError> { writeln!(stdout, "{heading}")?; writeln!(stdout, " resolved: {}", yes_no(identity.resolved))?; - writeln!( - stdout, - " selected account id: {}", - identity.selected_account_id.as_deref().unwrap_or("<none>") - )?; - writeln!( - stdout, - " selected account state: {}", - identity - .selected_account_state - .as_deref() - .unwrap_or("<none>") - )?; - writeln!( - stdout, - " identity id: {}", - identity.identity_id.as_deref().unwrap_or("<none>") - )?; - writeln!( - stdout, - " public key hex: {}", - identity.public_key_hex.as_deref().unwrap_or("<none>") - )?; - writeln!( - stdout, - " error: {}", - identity.error.as_deref().unwrap_or("<none>") - )?; + if let Some(selected_account_id) = &identity.selected_account_id { + writeln!(stdout, " selected account id: {selected_account_id}")?; + } + if let Some(selected_account_state) = &identity.selected_account_state { + writeln!(stdout, " selected account state: {selected_account_state}")?; + } + if let Some(identity_id) = &identity.identity_id { + writeln!(stdout, " identity id: {identity_id}")?; + } + if let Some(public_key_hex) = &identity.public_key_hex { + writeln!(stdout, " public key hex: {public_key_hex}")?; + } + if let Some(error) = &identity.error { + writeln!(stdout, " error: {error}")?; + } + Ok(()) +} + +#[allow(dead_code)] +fn render_table(stdout: &mut dyn Write, table: &Table) -> Result<(), RuntimeError> { + let mut widths: Vec<usize> = table.headers.iter().map(|header| header.len()).collect(); + for row in &table.rows { + for (index, cell) in row.iter().enumerate() { + if let Some(width) = widths.get_mut(index) { + *width = (*width).max(cell.len()); + } + } + } + + for (index, header) in table.headers.iter().enumerate() { + if index > 0 { + write!(stdout, " ")?; + } + write!(stdout, "{header:width$}", width = widths[index])?; + } + writeln!(stdout)?; + + for row in &table.rows { + for (index, cell) in row.iter().enumerate() { + if index > 0 { + write!(stdout, " ")?; + } + write!(stdout, "{cell:width$}", width = widths[index])?; + } + writeln!(stdout)?; + } + Ok(()) } +#[allow(dead_code)] +struct Table { + headers: &'static [&'static str], + rows: Vec<Vec<String>>, +} + +fn human_command_name(view: &CommandView) -> &'static str { + match view { + CommandView::AccountNew(_) => "account new", + CommandView::AccountWhoami(_) => "account whoami", + CommandView::ConfigShow(_) => "config show", + CommandView::MycStatus(_) => "myc status", + CommandView::SignerStatus(_) => "signer status", + } +} + #[cfg(test)] mod tests { + use super::{Table, render_human_to, render_ndjson_to, render_table}; use crate::commands::runtime; + use crate::domain::runtime::{CommandOutput, CommandView, MycStatusView}; use crate::runtime::config::{ - IdentityConfig, LoggingConfig, MycConfig, OutputFormat, PathsConfig, RuntimeConfig, - SignerBackend, SignerConfig, + IdentityConfig, LoggingConfig, MycConfig, OutputConfig, OutputFormat, PathsConfig, + RuntimeConfig, SignerBackend, SignerConfig, Verbosity, }; use crate::runtime::logging::LoggingState; @@ -244,7 +300,12 @@ mod tests { fn human_render_contains_config_sections() { let view = runtime::show( &RuntimeConfig { - output_format: OutputFormat::Human, + output: OutputConfig { + format: OutputFormat::Human, + verbosity: Verbosity::Normal, + color: true, + dry_run: false, + }, paths: PathsConfig { user_config_path: "/home/tester/.config/radroots/config.toml".into(), workspace_config_path: "/workspace/.radroots/config.toml".into(), @@ -270,11 +331,92 @@ mod tests { current_file: None, }, ); - assert_eq!(view.output_format, "human"); + assert_eq!(view.output.format, "human"); assert_eq!( view.paths.workspace_config_path, "/workspace/.radroots/config.toml" ); assert_eq!(view.account.identity_path, "identity.json"); } + + #[test] + fn human_render_omits_placeholder_tokens() { + let output = CommandOutput::success(CommandView::MycStatus(MycStatusView { + executable: "myc".to_owned(), + state: "unavailable".to_owned(), + service_status: None, + ready: false, + reason: None, + reasons: Vec::new(), + local_signer: None, + custody: None, + })); + let mut buffer = Vec::new(); + render_human_to(&mut buffer, &output).expect("render human"); + let rendered = String::from_utf8(buffer).expect("utf8"); + assert!(!rendered.contains("<none>")); + assert!(!rendered.contains("<unknown>")); + assert!(!rendered.contains("<disabled>")); + } + + #[test] + fn ndjson_rejects_singular_views() { + let output = CommandOutput::success(CommandView::ConfigShow(runtime::show( + &RuntimeConfig { + output: OutputConfig { + format: OutputFormat::Ndjson, + verbosity: Verbosity::Trace, + color: false, + dry_run: true, + }, + paths: PathsConfig { + user_config_path: "/home/tester/.config/radroots/config.toml".into(), + workspace_config_path: "/workspace/.radroots/config.toml".into(), + user_state_root: "/home/tester/.local/share/radroots".into(), + }, + logging: LoggingConfig { + filter: "info".to_owned(), + directory: None, + stdout: false, + }, + identity: IdentityConfig { + path: "identity.json".into(), + }, + signer: SignerConfig { + backend: SignerBackend::Local, + }, + myc: MycConfig { + executable: "myc".into(), + }, + }, + &LoggingState { + initialized: true, + current_file: None, + }, + ))); + let mut buffer = Vec::new(); + let error = render_ndjson_to(&mut buffer, &output).expect_err("unsupported ndjson"); + assert!( + error + .to_string() + .contains("`config show` does not support --ndjson") + ); + } + + #[test] + fn table_renderer_aligns_columns() { + let table = Table { + headers: &["item", "status"], + rows: vec![ + vec!["alpha".to_owned(), "ready".to_owned()], + vec!["beta-long".to_owned(), "pending".to_owned()], + ], + }; + let mut buffer = Vec::new(); + render_table(&mut buffer, &table).expect("render table"); + let rendered = String::from_utf8(buffer).expect("utf8"); + assert!(rendered.contains("item status")); + assert!(rendered.contains("alpha ready")); + assert!(rendered.contains("beta-long pending")); + } } diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -39,6 +39,7 @@ const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[ pub enum OutputFormat { Human, Json, + Ndjson, } impl OutputFormat { @@ -46,10 +47,38 @@ impl OutputFormat { match self { Self::Human => "human", Self::Json => "json", + Self::Ndjson => "ndjson", } } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verbosity { + Quiet, + Normal, + Verbose, + Trace, +} + +impl Verbosity { + pub fn as_str(self) -> &'static str { + match self { + Self::Quiet => "quiet", + Self::Normal => "normal", + Self::Verbose => "verbose", + Self::Trace => "trace", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutputConfig { + pub format: OutputFormat, + pub verbosity: Verbosity, + pub color: bool, + pub dry_run: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct LoggingConfig { pub filter: String, @@ -89,7 +118,7 @@ pub struct MycConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeConfig { - pub output_format: OutputFormat, + pub output: OutputConfig, pub paths: PathsConfig, pub logging: LoggingConfig, pub identity: IdentityConfig, @@ -145,7 +174,12 @@ impl RuntimeConfig { env_file: &EnvFileValues, ) -> Result<Self, RuntimeError> { Ok(Self { - output_format: resolve_output_format(args, env, env_file)?, + output: OutputConfig { + format: resolve_output_format(args, env, env_file)?, + verbosity: resolve_verbosity(args)?, + color: !args.no_color, + dry_run: args.dry_run, + }, paths: resolve_paths(env)?, logging: LoggingConfig { filter: args @@ -224,8 +258,15 @@ fn resolve_output_format( env: &dyn Environment, env_file: &EnvFileValues, ) -> Result<OutputFormat, RuntimeError> { - if args.json { - return Ok(OutputFormat::Json); + match (args.json, args.ndjson) { + (true, true) => { + return Err(RuntimeError::Config( + "flags --json and --ndjson cannot be used together".to_owned(), + )); + } + (true, false) => return Ok(OutputFormat::Json), + (false, true) => return Ok(OutputFormat::Ndjson), + (false, false) => {} } match env_value(env, env_file, &[ENV_OUTPUT]) { Some(value) => parse_output_format(value.as_str()), @@ -233,6 +274,28 @@ fn resolve_output_format( } } +fn resolve_verbosity(args: &CliArgs) -> Result<Verbosity, RuntimeError> { + let selected = [args.quiet, args.verbose, args.trace] + .into_iter() + .filter(|selected| *selected) + .count(); + if selected > 1 { + return Err(RuntimeError::Config( + "flags --quiet, --verbose, and --trace are mutually exclusive".to_owned(), + )); + } + + if args.quiet { + Ok(Verbosity::Quiet) + } else if args.trace { + Ok(Verbosity::Trace) + } else if args.verbose { + Ok(Verbosity::Verbose) + } else { + Ok(Verbosity::Normal) + } +} + fn resolve_bool_pair( positive_flag: bool, negative_flag: bool, @@ -339,8 +402,9 @@ fn parse_output_format(value: &str) -> Result<OutputFormat, RuntimeError> { match value.trim().to_ascii_lowercase().as_str() { "human" => Ok(OutputFormat::Human), "json" => Ok(OutputFormat::Json), + "ndjson" => Ok(OutputFormat::Ndjson), other => Err(RuntimeError::Config(format!( - "{ENV_OUTPUT} must be `human` or `json`, got `{other}`" + "{ENV_OUTPUT} must be `human`, `json`, or `ndjson`, got `{other}`" ))), } } @@ -368,8 +432,8 @@ fn parse_bool_env(key: &str, value: &str) -> Result<bool, RuntimeError> { #[cfg(test)] mod tests { use super::{ - EnvFileValues, Environment, OutputFormat, PathsConfig, RuntimeConfig, SignerBackend, - parse_env_file_values, + EnvFileValues, Environment, OutputConfig, OutputFormat, PathsConfig, RuntimeConfig, + SignerBackend, Verbosity, parse_env_file_values, }; use crate::cli::CliArgs; use clap::Parser; @@ -411,6 +475,9 @@ mod tests { let args = CliArgs::parse_from([ "radroots", "--json", + "--verbose", + "--dry-run", + "--no-color", "--log-filter", "debug", "--log-stdout", @@ -437,7 +504,15 @@ mod tests { let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) .expect("resolve runtime config"); - assert_eq!(resolved.output_format, OutputFormat::Json); + assert_eq!( + resolved.output, + OutputConfig { + format: OutputFormat::Json, + verbosity: Verbosity::Verbose, + color: false, + dry_run: true, + } + ); assert_eq!( resolved.paths, PathsConfig { @@ -479,7 +554,15 @@ mod tests { let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) .expect("resolve runtime config"); - assert_eq!(resolved.output_format, OutputFormat::Json); + assert_eq!( + resolved.output, + OutputConfig { + format: OutputFormat::Json, + verbosity: Verbosity::Normal, + color: true, + dry_run: false, + } + ); assert_eq!(resolved.logging.filter, "debug,cli=trace"); assert_eq!( resolved.logging.directory, @@ -507,6 +590,35 @@ mod tests { } #[test] + fn conflicting_output_and_verbosity_flags_fail() { + let env = MapEnvironment::new(BTreeMap::new()); + + let conflicting_output = + CliArgs::parse_from(["radroots", "--json", "--ndjson", "config", "show"]); + let error = RuntimeConfig::resolve_with_env_file( + &conflicting_output, + &env, + &EnvFileValues::default(), + ) + .expect_err("conflicting output flags"); + assert!(error.to_string().contains("--json and --ndjson")); + + let conflicting_verbosity = + CliArgs::parse_from(["radroots", "--quiet", "--trace", "config", "show"]); + let error = RuntimeConfig::resolve_with_env_file( + &conflicting_verbosity, + &env, + &EnvFileValues::default(), + ) + .expect_err("conflicting verbosity flags"); + assert!( + error + .to_string() + .contains("--quiet, --verbose, and --trace") + ); + } + + #[test] fn invalid_environment_value_fails() { let args = CliArgs::parse_from(["radroots", "config", "show"]); let env = MapEnvironment::new(BTreeMap::from([( @@ -538,7 +650,7 @@ RADROOTS_MYC_EXECUTABLE=bin/myc let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &env_file).expect("resolve config"); - assert_eq!(resolved.output_format, OutputFormat::Json); + assert_eq!(resolved.output.format, OutputFormat::Json); assert_eq!(resolved.logging.filter, "debug,radroots_cli=trace"); assert_eq!( resolved.logging.directory, @@ -568,6 +680,7 @@ RADROOTS_CLI_LOGGING_STDOUT=false let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &env_file).expect("resolve config"); + assert_eq!(resolved.output.format, OutputFormat::Human); assert_eq!(resolved.logging.filter, "info"); assert!(resolved.logging.stdout); } @@ -606,4 +719,17 @@ RADROOTS_CLI_LOGGING_STDOUT=false .contains("unknown environment variable `RADROOTS_CLI_LOGGING_FILTRE`") ); } + + #[test] + fn env_output_accepts_ndjson() { + let args = CliArgs::parse_from(["radroots", "config", "show"]); + let env = MapEnvironment::new(BTreeMap::from([( + "RADROOTS_OUTPUT".to_owned(), + "ndjson".to_owned(), + )])); + + let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect("resolve runtime config"); + assert_eq!(resolved.output.format, OutputFormat::Ndjson); + } } diff --git a/tests/identity_commands.rs b/tests/identity_commands.rs @@ -56,6 +56,29 @@ fn account_new_json_creates_identity_file() { } #[test] +fn account_new_rejects_dry_run_without_creating_identity() { + let dir = tempdir().expect("tempdir"); + let identity_path = dir.path().join("identity.json"); + + let output = cli_command_in(dir.path()) + .args([ + "--dry-run", + "--identity-path", + identity_path.to_str().expect("identity path"), + "account", + "new", + ]) + .output() + .expect("run account new"); + + assert_eq!(output.status.code(), Some(2)); + assert!(!identity_path.exists()); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); + assert!(stderr.contains("`account new` does not support --dry-run yet")); +} + +#[test] fn account_whoami_json_reads_existing_public_identity() { let dir = tempdir().expect("tempdir"); let identity_path = dir.path().join("identity.json"); diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -40,7 +40,10 @@ fn config_show_json_reports_default_bootstrap_state() { assert!(output.status.success()); 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["output_format"], "json"); + assert_eq!(json["output"]["format"], "json"); + assert_eq!(json["output"]["verbosity"], "normal"); + assert_eq!(json["output"]["color"], true); + assert_eq!(json["output"]["dry_run"], false); assert_eq!( json["paths"]["user_config_path"], dir.path() @@ -90,6 +93,7 @@ fn config_show_json_reflects_environment_configuration() { assert!(output.status.success()); 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["output"]["format"], "json"); assert_eq!(json["logging"]["filter"], "debug"); assert_eq!(json["logging"]["directory"], "logs/runtime"); assert_eq!(json["account"]["identity_path"], "state/identity.json"); @@ -98,6 +102,30 @@ fn config_show_json_reflects_environment_configuration() { } #[test] +fn config_show_json_reflects_global_output_flags() { + let dir = tempdir().expect("tempdir"); + let output = runtime_show_command_in(dir.path()) + .args([ + "--json", + "--trace", + "--dry-run", + "--no-color", + "config", + "show", + ]) + .output() + .expect("run config show"); + + assert!(output.status.success()); + 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["output"]["format"], "json"); + assert_eq!(json["output"]["verbosity"], "trace"); + assert_eq!(json["output"]["color"], false); + assert_eq!(json["output"]["dry_run"], true); +} + +#[test] fn config_show_json_reads_logging_from_default_env_file() { let temp = tempdir().expect("tempdir"); let env_path = temp.path().join(".env"); @@ -127,3 +155,17 @@ fn config_show_json_reads_logging_from_default_env_file() { assert!(current_file.starts_with(logs_dir.display().to_string().as_str())); assert!(std::path::Path::new(current_file).exists()); } + +#[test] +fn config_show_rejects_ndjson_for_singular_output() { + let dir = tempdir().expect("tempdir"); + let output = runtime_show_command_in(dir.path()) + .args(["--ndjson", "config", "show"]) + .output() + .expect("run config show"); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); + assert!(stderr.contains("`config show` does not support --ndjson")); +}