commit a86cae13aad8bac92cb28f55c01e94c11ec5cf88
parent af3f1a213c721b7d6d9ec33a1fcd4cf073c72f73
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Apr 2026 03:07:42 +0000
land cli output contract framework
Diffstat:
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"));
+}