cli

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

commit 6b1cc67dd752708e03a9ad810562ea6cdf2c092b
parent ad99087e5f470951d93e97c13855eef09bc4e5a1
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Apr 2026 01:21:37 +0000

cli: add env-file logging support

Diffstat:
A.env.example | 4++++
M.gitignore | 1+
Msrc/cli.rs | 8++++++++
Msrc/runtime/config.rs | 202++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mtests/runtime_show.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
5 files changed, 254 insertions(+), 26 deletions(-)

diff --git a/.env.example b/.env.example @@ -0,0 +1,4 @@ +# copy to .env for local cli development +RADROOTS_CLI_LOGGING_FILTER=info +RADROOTS_CLI_LOGGING_OUTPUT_DIR=/absolute/path/to/radroots-platform-v1/logs/services/local/radroots-cli +RADROOTS_CLI_LOGGING_STDOUT=false diff --git a/.gitignore b/.gitignore @@ -3,6 +3,7 @@ # Local environment files .env .env.* +!.env.example # OS and editor files .DS_Store diff --git a/src/cli.rs b/src/cli.rs @@ -7,6 +7,8 @@ use std::path::PathBuf; pub struct CliArgs { #[arg(long, global = true, action = ArgAction::SetTrue)] pub json: bool, + #[arg(long = "env-file", global = true)] + pub env_file: Option<PathBuf>, #[arg(long, global = true)] pub log_filter: Option<String>, #[arg(long, global = true)] @@ -101,6 +103,8 @@ mod tests { let parsed = CliArgs::parse_from([ "radroots", "--json", + "--env-file", + ".env.local", "--log-filter", "debug,radroots_cli=trace", "--log-dir", @@ -117,6 +121,10 @@ mod tests { ]); assert!(parsed.json); assert_eq!( + parsed.env_file.as_deref().and_then(|path| path.to_str()), + Some(".env.local") + ); + assert_eq!( parsed.log_filter.as_deref(), Some("debug,radroots_cli=trace") ); diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -1,10 +1,17 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; use std::path::PathBuf; use crate::cli::CliArgs; use crate::runtime::RuntimeError; const DEFAULT_LOG_FILTER: &str = "info"; +const ENV_FILE_PATH: &str = "RADROOTS_ENV_FILE"; const ENV_OUTPUT: &str = "RADROOTS_OUTPUT"; +const ENV_CLI_LOG_FILTER: &str = "RADROOTS_CLI_LOGGING_FILTER"; +const ENV_CLI_LOG_DIR: &str = "RADROOTS_CLI_LOGGING_OUTPUT_DIR"; +const ENV_CLI_LOG_STDOUT: &str = "RADROOTS_CLI_LOGGING_STDOUT"; const ENV_LOG_FILTER: &str = "RADROOTS_LOG_FILTER"; const ENV_LOG_DIR: &str = "RADROOTS_LOG_DIR"; const ENV_LOG_STDOUT: &str = "RADROOTS_LOG_STDOUT"; @@ -73,6 +80,9 @@ pub struct RuntimeConfig { pub myc: MycConfig, } +#[derive(Debug, Default)] +struct EnvFileValues(BTreeMap<String, String>); + pub trait Environment { fn var(&self, key: &str) -> Option<String>; } @@ -87,28 +97,35 @@ impl Environment for SystemEnvironment { impl RuntimeConfig { pub fn from_system(args: &CliArgs) -> Result<Self, RuntimeError> { - Self::resolve(args, &SystemEnvironment) + let system = SystemEnvironment; + let env_file_path = resolve_env_file_path(args, &system); + let env_file = load_env_file_values(env_file_path.as_deref())?; + Self::resolve_with_env_file(args, &system, &env_file) } - pub fn resolve(args: &CliArgs, env: &dyn Environment) -> Result<Self, RuntimeError> { + fn resolve_with_env_file( + args: &CliArgs, + env: &dyn Environment, + env_file: &EnvFileValues, + ) -> Result<Self, RuntimeError> { Ok(Self { - output_format: resolve_output_format(args, env)?, + output_format: resolve_output_format(args, env, env_file)?, logging: LoggingConfig { filter: args .log_filter .clone() - .or_else(|| env.var(ENV_LOG_FILTER)) + .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.var(ENV_LOG_DIR).map(PathBuf::from)), + directory: args.log_dir.clone().or_else(|| { + env_value(env, env_file, &[ENV_CLI_LOG_DIR, ENV_LOG_DIR]).map(PathBuf::from) + }), stdout: resolve_bool_pair( args.log_stdout, args.no_log_stdout, - ENV_LOG_STDOUT, + &[ENV_CLI_LOG_STDOUT, ENV_LOG_STDOUT], false, env, + env_file, "--log-stdout", "--no-log-stdout", )?, @@ -117,14 +134,14 @@ impl RuntimeConfig { path: args .identity_path .clone() - .or_else(|| env.var(ENV_IDENTITY_PATH).map(PathBuf::from)) + .or_else(|| env_value(env, env_file, &[ENV_IDENTITY_PATH]).map(PathBuf::from)) .unwrap_or_else(|| PathBuf::from("identity.json")), }, signer: SignerConfig { backend: args .signer_backend .clone() - .or_else(|| env.var(ENV_SIGNER_BACKEND)) + .or_else(|| env_value(env, env_file, &[ENV_SIGNER_BACKEND])) .map(parse_signer_backend) .transpose()? .unwrap_or(SignerBackend::Local), @@ -133,21 +150,28 @@ impl RuntimeConfig { executable: args .myc_executable .clone() - .or_else(|| env.var(ENV_MYC_EXECUTABLE).map(PathBuf::from)) + .or_else(|| env_value(env, env_file, &[ENV_MYC_EXECUTABLE]).map(PathBuf::from)) .unwrap_or_else(|| PathBuf::from("myc")), }, }) } } +fn resolve_env_file_path(args: &CliArgs, env: &dyn Environment) -> Option<PathBuf> { + args.env_file + .clone() + .or_else(|| env.var(ENV_FILE_PATH).map(PathBuf::from)) +} + fn resolve_output_format( args: &CliArgs, env: &dyn Environment, + env_file: &EnvFileValues, ) -> Result<OutputFormat, RuntimeError> { if args.json { return Ok(OutputFormat::Json); } - match env.var(ENV_OUTPUT) { + match env_value(env, env_file, &[ENV_OUTPUT]) { Some(value) => parse_output_format(value.as_str()), None => Ok(OutputFormat::Human), } @@ -156,9 +180,10 @@ fn resolve_output_format( fn resolve_bool_pair( positive_flag: bool, negative_flag: bool, - env_key: &str, + env_keys: &[&str], default: bool, env: &dyn Environment, + env_file: &EnvFileValues, positive_label: &str, negative_label: &str, ) -> Result<bool, RuntimeError> { @@ -168,13 +193,85 @@ fn resolve_bool_pair( ))), (true, false) => Ok(true), (false, true) => Ok(false), - (false, false) => match env.var(env_key) { - Some(value) => parse_bool_env(env_key, value.as_str()), + (false, false) => match env_value_entry(env, env_file, env_keys) { + Some((key, value)) => parse_bool_env(key.as_str(), value.as_str()), None => Ok(default), }, } } +fn env_value(env: &dyn Environment, env_file: &EnvFileValues, keys: &[&str]) -> Option<String> { + env_value_entry(env, env_file, keys).map(|(_, value)| value) +} + +fn env_value_entry( + env: &dyn Environment, + env_file: &EnvFileValues, + keys: &[&str], +) -> Option<(String, String)> { + keys.iter() + .find_map(|key| env.var(key).map(|value| ((*key).to_owned(), value))) + .or_else(|| { + keys.iter().find_map(|key| { + env_file + .0 + .get(*key) + .cloned() + .map(|value| ((*key).to_owned(), value)) + }) + }) +} + +fn load_env_file_values(path: Option<&Path>) -> Result<EnvFileValues, RuntimeError> { + let Some(path) = path else { + return Ok(EnvFileValues::default()); + }; + let raw = fs::read_to_string(path).map_err(|err| { + RuntimeError::Config(format!("failed to read env file {}: {err}", path.display())) + })?; + parse_env_file_values(&raw, path) +} + +fn parse_env_file_values(raw: &str, path: &Path) -> Result<EnvFileValues, RuntimeError> { + let mut values = BTreeMap::new(); + + for (index, line) in raw.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let Some((key, value)) = trimmed.split_once('=') else { + return Err(RuntimeError::Config(format!( + "invalid env file {} line {}: expected KEY=VALUE", + path.display(), + index + 1 + ))); + }; + let key = key.trim(); + if key.is_empty() { + return Err(RuntimeError::Config(format!( + "invalid env file {} line {}: empty key", + path.display(), + index + 1 + ))); + } + values.insert(key.to_owned(), normalize_env_value(value.trim())); + } + + Ok(EnvFileValues(values)) +} + +fn normalize_env_value(value: &str) -> String { + if value.len() >= 2 { + let first = value.as_bytes()[0]; + let last = value.as_bytes()[value.len() - 1]; + if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') { + return value[1..value.len() - 1].to_owned(); + } + } + value.to_owned() +} + fn parse_output_format(value: &str) -> Result<OutputFormat, RuntimeError> { match value.trim().to_ascii_lowercase().as_str() { "human" => Ok(OutputFormat::Human), @@ -207,11 +304,14 @@ fn parse_bool_env(key: &str, value: &str) -> Result<bool, RuntimeError> { #[cfg(test)] mod tests { - use super::{Environment, OutputFormat, RuntimeConfig, SignerBackend}; + use super::{ + EnvFileValues, Environment, OutputFormat, RuntimeConfig, SignerBackend, + parse_env_file_values, + }; use crate::cli::CliArgs; use clap::Parser; use std::collections::BTreeMap; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; struct MapEnvironment(BTreeMap<String, String>); @@ -250,7 +350,8 @@ mod tests { ("RADROOTS_MYC_EXECUTABLE".to_owned(), "env-myc".to_owned()), ])); - let resolved = RuntimeConfig::resolve(&args, &env).expect("resolve runtime config"); + 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.logging.filter, "debug"); assert!(resolved.logging.stdout); @@ -281,7 +382,8 @@ mod tests { ("RADROOTS_MYC_EXECUTABLE".to_owned(), "bin/myc".to_owned()), ])); - let resolved = RuntimeConfig::resolve(&args, &env).expect("resolve runtime config"); + 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.logging.filter, "debug,cli=trace"); assert_eq!( @@ -304,7 +406,8 @@ mod tests { "show", ]); let env = MapEnvironment(BTreeMap::new()); - let error = RuntimeConfig::resolve(&args, &env).expect_err("conflicting flags"); + let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect_err("conflicting flags"); assert!(error.to_string().contains("cannot be used together")); } @@ -315,7 +418,62 @@ mod tests { "RADROOTS_LOG_STDOUT".to_owned(), "maybe".to_owned(), )])); - let error = RuntimeConfig::resolve(&args, &env).expect_err("invalid bool"); + let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect_err("invalid bool"); assert!(error.to_string().contains("RADROOTS_LOG_STDOUT")); } + + #[test] + fn env_file_values_fill_missing_flags() { + let args = CliArgs::parse_from(["radroots", "runtime", "show"]); + let env = MapEnvironment(BTreeMap::new()); + let env_file = parse_env_file_values( + r#" +RADROOTS_OUTPUT=json +RADROOTS_CLI_LOGGING_FILTER="debug,radroots_cli=trace" +RADROOTS_CLI_LOGGING_OUTPUT_DIR=/tmp/radroots-cli-logs +RADROOTS_CLI_LOGGING_STDOUT=false +RADROOTS_IDENTITY_PATH=state/identity.json +RADROOTS_SIGNER_BACKEND=myc +RADROOTS_MYC_EXECUTABLE=bin/myc +"#, + Path::new(".env.test"), + ) + .expect("parse env file"); + + let resolved = + RuntimeConfig::resolve_with_env_file(&args, &env, &env_file).expect("resolve config"); + assert_eq!(resolved.output_format, OutputFormat::Json); + assert_eq!(resolved.logging.filter, "debug,radroots_cli=trace"); + assert_eq!( + resolved.logging.directory, + Some(PathBuf::from("/tmp/radroots-cli-logs")) + ); + assert!(!resolved.logging.stdout); + assert_eq!(resolved.identity.path, PathBuf::from("state/identity.json")); + assert_eq!(resolved.signer.backend, SignerBackend::Myc); + assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc")); + } + + #[test] + fn process_environment_overrides_env_file_values() { + let args = CliArgs::parse_from(["radroots", "runtime", "show"]); + let env = MapEnvironment(BTreeMap::from([ + ("RADROOTS_LOG_FILTER".to_owned(), "info".to_owned()), + ("RADROOTS_LOG_STDOUT".to_owned(), "true".to_owned()), + ])); + let env_file = parse_env_file_values( + r#" +RADROOTS_CLI_LOGGING_FILTER=debug +RADROOTS_CLI_LOGGING_STDOUT=false +"#, + Path::new(".env.test"), + ) + .expect("parse env file"); + + let resolved = + RuntimeConfig::resolve_with_env_file(&args, &env, &env_file).expect("resolve config"); + assert_eq!(resolved.logging.filter, "info"); + assert!(resolved.logging.stdout); + } } diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -1,12 +1,33 @@ +use std::fs; use std::process::Command; use assert_cmd::prelude::*; use serde_json::Value; +use tempfile::tempdir; + +fn runtime_show_command() -> Command { + let mut command = Command::cargo_bin("radroots").expect("binary"); + for key in [ + "RADROOTS_ENV_FILE", + "RADROOTS_OUTPUT", + "RADROOTS_CLI_LOGGING_FILTER", + "RADROOTS_CLI_LOGGING_OUTPUT_DIR", + "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_LOG_FILTER", + "RADROOTS_LOG_DIR", + "RADROOTS_LOG_STDOUT", + "RADROOTS_IDENTITY_PATH", + "RADROOTS_SIGNER_BACKEND", + "RADROOTS_MYC_EXECUTABLE", + ] { + command.env_remove(key); + } + command +} #[test] fn runtime_show_json_reports_default_bootstrap_state() { - let output = Command::cargo_bin("radroots") - .expect("binary") + let output = runtime_show_command() .args(["--json", "runtime", "show"]) .output() .expect("run runtime show"); @@ -25,8 +46,7 @@ fn runtime_show_json_reports_default_bootstrap_state() { #[test] fn runtime_show_json_reflects_environment_configuration() { - let output = Command::cargo_bin("radroots") - .expect("binary") + let output = runtime_show_command() .env("RADROOTS_OUTPUT", "json") .env("RADROOTS_LOG_FILTER", "debug") .env("RADROOTS_LOG_DIR", "logs/runtime") @@ -47,3 +67,40 @@ fn runtime_show_json_reflects_environment_configuration() { assert_eq!(json["signer"]["backend"], "myc"); assert_eq!(json["myc"]["executable"], "bin/myc"); } + +#[test] +fn runtime_show_json_reads_logging_from_env_file() { + let temp = tempdir().expect("tempdir"); + let env_path = temp.path().join("radroots-cli.env"); + let logs_dir = temp.path().join("logs").join("radroots-cli"); + fs::write( + &env_path, + format!( + "RADROOTS_CLI_LOGGING_FILTER=debug,radroots_cli=trace\nRADROOTS_CLI_LOGGING_OUTPUT_DIR={}\nRADROOTS_CLI_LOGGING_STDOUT=false\n", + logs_dir.display() + ), + ) + .expect("write env file"); + + let output = runtime_show_command() + .args([ + "--json", + "--env-file", + env_path.to_str().expect("env path"), + "runtime", + "show", + ]) + .output() + .expect("run runtime 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["logging"]["filter"], "debug,radroots_cli=trace"); + assert_eq!(json["logging"]["directory"], logs_dir.display().to_string()); + let current_file = json["logging"]["current_file"] + .as_str() + .expect("current log file"); + assert!(current_file.starts_with(logs_dir.display().to_string().as_str())); + assert!(std::path::Path::new(current_file).exists()); +}