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:
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());
+}