cli

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

commit a5566584db9b4d4a858ae66e44ea704680c7c22f
parent 40a38d8c7e84d61a3a02e0a5413ae633a3dad1bd
Author: triesap <tyson@radroots.org>
Date:   Sat, 25 Apr 2026 04:26:34 +0000

runtime: reject stdout logs for machine output

- validate logging stdout against json and ndjson output
- keep human output stdout logging behavior available
- cover flag and environment conflict paths in config tests
- prove binary failures leave machine stdout empty

Diffstat:
Msrc/runtime/config.rs | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mtests/runtime_show.rs | 26++++++++++++++++++++++++++
2 files changed, 139 insertions(+), 32 deletions(-)

diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -478,42 +478,45 @@ impl RuntimeConfig { RadrootsSecretBackend::HostVault(_) => Some(RadrootsSecretBackend::EncryptedFile), _ => None, }); + let output = OutputConfig { + format: resolve_output_format(args, env, env_file)?, + verbosity: resolve_verbosity(args)?, + color: !args.no_color, + dry_run: args.dry_run, + }; + let logging = LoggingConfig { + filter: args + .log_filter + .clone() + .or_else(|| env_value(env, env_file, &[ENV_CLI_LOG_FILTER, ENV_LOG_FILTER])) + .unwrap_or_else(|| DEFAULT_LOG_FILTER.to_owned()), + directory: args.log_dir.clone().or_else(|| { + env_value(env, env_file, &[ENV_CLI_LOG_DIR, ENV_LOG_DIR]) + .map(PathBuf::from) + .or_else(|| Some(paths.app_logs_root.clone())) + }), + stdout: resolve_bool_pair( + args.log_stdout, + args.no_log_stdout, + &[ENV_CLI_LOG_STDOUT, ENV_LOG_STDOUT], + false, + env, + env_file, + "--log-stdout", + "--no-log-stdout", + )?, + }; + validate_logging_output_contract(&output, &logging)?; Ok(Self { capability_bindings: resolve_capability_bindings( app_config.as_ref(), workspace_config.as_ref(), )?, - output: OutputConfig { - format: resolve_output_format(args, env, env_file)?, - verbosity: resolve_verbosity(args)?, - color: !args.no_color, - dry_run: args.dry_run, - }, + output, interaction: resolve_interaction_config(args, env), paths: paths.clone(), migration, - logging: LoggingConfig { - filter: args - .log_filter - .clone() - .or_else(|| env_value(env, env_file, &[ENV_CLI_LOG_FILTER, ENV_LOG_FILTER])) - .unwrap_or_else(|| DEFAULT_LOG_FILTER.to_owned()), - directory: args.log_dir.clone().or_else(|| { - env_value(env, env_file, &[ENV_CLI_LOG_DIR, ENV_LOG_DIR]) - .map(PathBuf::from) - .or_else(|| Some(paths.app_logs_root.clone())) - }), - stdout: resolve_bool_pair( - args.log_stdout, - args.no_log_stdout, - &[ENV_CLI_LOG_STDOUT, ENV_LOG_STDOUT], - false, - env, - env_file, - "--log-stdout", - "--no-log-stdout", - )?, - }, + logging, account: AccountConfig { selector: args .account @@ -1168,6 +1171,20 @@ fn resolve_interaction_config(args: &CliArgs, env: &dyn Environment) -> Interact } } +fn validate_logging_output_contract( + output: &OutputConfig, + logging: &LoggingConfig, +) -> Result<(), RuntimeError> { + if logging.stdout && matches!(output.format, OutputFormat::Json | OutputFormat::Ndjson) { + return Err(RuntimeError::Config(format!( + "stdout logging cannot be used with {} output; unset {ENV_CLI_LOG_STDOUT}/{ENV_LOG_STDOUT} or use --no-log-stdout", + output.format.as_str() + ))); + } + + Ok(()) +} + fn resolve_bool_pair( positive_flag: bool, negative_flag: bool, @@ -1423,7 +1440,8 @@ mod tests { fn flags_override_environment_values() { let args = CliArgs::parse_from([ "radroots", - "--json", + "--output", + "human", "--verbose", "--dry-run", "--no-color", @@ -1466,7 +1484,7 @@ mod tests { assert_eq!( resolved.output, OutputConfig { - format: OutputFormat::Json, + format: OutputFormat::Human, verbosity: Verbosity::Verbose, color: false, dry_run: true, @@ -1574,7 +1592,7 @@ mod tests { "debug,cli=trace".to_owned(), ), ("RADROOTS_LOG_DIR".to_owned(), "logs/runtime".to_owned()), - ("RADROOTS_LOG_STDOUT".to_owned(), "true".to_owned()), + ("RADROOTS_LOG_STDOUT".to_owned(), "false".to_owned()), ("RADROOTS_ACCOUNT".to_owned(), "acct_demo".to_owned()), ( "RADROOTS_IDENTITY_PATH".to_owned(), @@ -1617,7 +1635,7 @@ mod tests { resolved.logging.directory, Some(PathBuf::from("logs/runtime")) ); - assert!(resolved.logging.stdout); + assert!(!resolved.logging.stdout); assert_eq!(resolved.account.selector.as_deref(), Some("acct_demo")); assert_eq!( resolved.account.secret_backend, @@ -1711,6 +1729,69 @@ mod tests { } #[test] + fn machine_output_rejects_stdout_logging_flags() { + let env = MapEnvironment::new(BTreeMap::new()); + + let json_args = + CliArgs::parse_from(["radroots", "--json", "--log-stdout", "config", "show"]); + let error = + RuntimeConfig::resolve_with_env_file(&json_args, &env, &EnvFileValues::default()) + .expect_err("json stdout logging should fail"); + let message = error.to_string(); + assert!(message.contains("stdout logging")); + assert!(message.contains("json output")); + assert!(message.contains("--no-log-stdout")); + + let ndjson_args = + CliArgs::parse_from(["radroots", "--ndjson", "--log-stdout", "find", "eggs"]); + let error = + RuntimeConfig::resolve_with_env_file(&ndjson_args, &env, &EnvFileValues::default()) + .expect_err("ndjson stdout logging should fail"); + let message = error.to_string(); + assert!(message.contains("stdout logging")); + assert!(message.contains("ndjson output")); + } + + #[test] + fn machine_output_rejects_stdout_logging_environment() { + let json_args = CliArgs::parse_from(["radroots", "--json", "config", "show"]); + let env = MapEnvironment::new(BTreeMap::from([( + "RADROOTS_CLI_LOGGING_STDOUT".to_owned(), + "true".to_owned(), + )])); + let error = + RuntimeConfig::resolve_with_env_file(&json_args, &env, &EnvFileValues::default()) + .expect_err("json stdout logging from env should fail"); + let message = error.to_string(); + assert!(message.contains("RADROOTS_CLI_LOGGING_STDOUT")); + assert!(message.contains("RADROOTS_LOG_STDOUT")); + + let ndjson_env_args = CliArgs::parse_from(["radroots", "config", "show"]); + let env = MapEnvironment::new(BTreeMap::from([ + ("RADROOTS_OUTPUT".to_owned(), "ndjson".to_owned()), + ("RADROOTS_LOG_STDOUT".to_owned(), "true".to_owned()), + ])); + let error = + RuntimeConfig::resolve_with_env_file(&ndjson_env_args, &env, &EnvFileValues::default()) + .expect_err("ndjson stdout logging from env should fail"); + assert!(error.to_string().contains("ndjson output")); + } + + #[test] + fn no_log_stdout_overrides_environment_for_machine_output() { + let args = CliArgs::parse_from(["radroots", "--json", "--no-log-stdout", "config", "show"]); + let env = MapEnvironment::new(BTreeMap::from([( + "RADROOTS_LOG_STDOUT".to_owned(), + "true".to_owned(), + )])); + + let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect("resolve machine output with stdout logging disabled"); + assert_eq!(resolved.output.format, OutputFormat::Json); + assert!(!resolved.logging.stdout); + } + + #[test] fn invalid_environment_value_fails() { let args = CliArgs::parse_from(["radroots", "config", "show"]); let env = MapEnvironment::new(BTreeMap::from([( diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -349,6 +349,32 @@ fn config_show_json_reports_default_bootstrap_state() { } #[test] +fn config_show_machine_output_rejects_stdout_logging() { + let dir = tempdir().expect("tempdir"); + let output = runtime_show_command_in(dir.path()) + .args(["--json", "--log-stdout", "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("stdout logging")); + assert!(stderr.contains("json output")); + + let env_output = runtime_show_command_in(dir.path()) + .env("RADROOTS_CLI_LOGGING_STDOUT", "true") + .args(["--json", "config", "show"]) + .output() + .expect("run config show"); + + assert_eq!(env_output.status.code(), Some(2)); + assert!(env_output.stdout.is_empty()); + let stderr = String::from_utf8(env_output.stderr).expect("utf8 stderr"); + assert!(stderr.contains("RADROOTS_CLI_LOGGING_STDOUT")); +} + +#[test] fn config_show_json_reports_detected_legacy_cli_paths_without_moving_them() { let dir = tempdir().expect("tempdir"); let home = dir.path().join("home");