commit bdd6ace2b4c287faf873069927b0958120f8a3f6
parent ca2a2bdbe05637e12c15cbf37119dcb5f66e7e99
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Feb 2026 06:22:55 +0000
coverage: raise `radroots-runtime` to strict 100 gates
Diffstat:
6 files changed, 756 insertions(+), 17 deletions(-)
diff --git a/crates/runtime/src/backoff.rs b/crates/runtime/src/backoff.rs
@@ -98,3 +98,114 @@ fn jitter_ms(max: u64) -> u64 {
.subsec_nanos() as u64;
nanos % (max + 1)
}
+
+#[cfg(test)]
+mod tests {
+ use super::{Backoff, BackoffConfig, jitter_ms};
+ use core::time::Duration;
+
+ #[test]
+ fn default_values_round_trip() {
+ let cfg: BackoffConfig =
+ toml::from_str("").expect("backoff config defaults should deserialize");
+ assert_eq!(cfg.base_ms, 500);
+ assert_eq!(cfg.max_ms, 30_000);
+ assert_eq!(cfg.factor, 2);
+ assert_eq!(cfg.jitter_ms, 0);
+
+ let cfg_default = BackoffConfig::default();
+ assert_eq!(cfg_default.base_ms, 500);
+ assert_eq!(cfg_default.max_ms, 30_000);
+ assert_eq!(cfg_default.factor, 2);
+ assert_eq!(cfg_default.jitter_ms, 0);
+ }
+
+ #[test]
+ fn alias_fields_deserialize() {
+ let cfg: BackoffConfig = toml::from_str(
+ r#"
+reconnect_base_ms = 10
+reconnect_max_ms = 100
+reconnect_factor = 3
+reconnect_jitter_ms = 5
+"#,
+ )
+ .expect("backoff aliases should deserialize");
+
+ assert_eq!(cfg.base_ms, 10);
+ assert_eq!(cfg.max_ms, 100);
+ assert_eq!(cfg.factor, 3);
+ assert_eq!(cfg.jitter_ms, 5);
+ }
+
+ #[test]
+ fn delay_for_attempt_applies_bounds_and_factor_defaults() {
+ let cfg = BackoffConfig {
+ base_ms: 0,
+ max_ms: 0,
+ factor: 0,
+ jitter_ms: 0,
+ };
+ assert_eq!(cfg.delay_for_attempt(1), Duration::from_millis(1));
+ assert_eq!(cfg.delay_for_attempt(8), Duration::from_millis(1));
+ }
+
+ #[test]
+ fn delay_for_attempt_caps_growth_to_max() {
+ let cfg = BackoffConfig {
+ base_ms: 100,
+ max_ms: 1_000,
+ factor: 2,
+ jitter_ms: 0,
+ };
+
+ assert_eq!(cfg.delay_for_attempt(1), Duration::from_millis(100));
+ assert_eq!(cfg.delay_for_attempt(2), Duration::from_millis(200));
+ assert_eq!(cfg.delay_for_attempt(3), Duration::from_millis(400));
+ assert_eq!(cfg.delay_for_attempt(4), Duration::from_millis(800));
+ assert_eq!(cfg.delay_for_attempt(5), Duration::from_millis(1_000));
+ assert_eq!(cfg.delay_for_attempt(16), Duration::from_millis(1_000));
+ }
+
+ #[test]
+ fn delay_for_attempt_applies_jitter_without_exceeding_max() {
+ let cfg = BackoffConfig {
+ base_ms: 100,
+ max_ms: 500,
+ factor: 2,
+ jitter_ms: 50,
+ };
+
+ let delay = cfg.delay_for_attempt(2).as_millis() as u64;
+ assert!(delay >= 200);
+ assert!(delay <= 250);
+ }
+
+ #[test]
+ fn stateful_backoff_tracks_attempts_and_reset() {
+ let cfg = BackoffConfig {
+ base_ms: 5,
+ max_ms: 50,
+ factor: 2,
+ jitter_ms: 0,
+ };
+ let mut backoff = Backoff::new(cfg);
+
+ assert_eq!(backoff.attempt(), 0);
+ assert_eq!(backoff.next_delay(), Duration::from_millis(5));
+ assert_eq!(backoff.attempt(), 1);
+ assert_eq!(backoff.next_delay(), Duration::from_millis(10));
+ assert_eq!(backoff.attempt(), 2);
+
+ backoff.reset();
+ assert_eq!(backoff.attempt(), 0);
+ assert_eq!(backoff.next_delay(), Duration::from_millis(5));
+ }
+
+ #[test]
+ fn jitter_ms_bounds_output() {
+ assert_eq!(jitter_ms(0), 0);
+ let jitter = jitter_ms(7);
+ assert!(jitter <= 7);
+ }
+}
diff --git a/crates/runtime/src/config.rs b/crates/runtime/src/config.rs
@@ -86,3 +86,201 @@ where
source,
})
}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ load_required_file, load_required_file_with_env, load_required_file_with_env_and_overrides,
+ };
+ use config::{Map, Value};
+ use serde::Deserialize;
+ use tempfile::tempdir;
+
+ use crate::error::RuntimeConfigError;
+
+ #[derive(Debug, Deserialize, PartialEq)]
+ struct RuntimeCfg {
+ logs_dir: String,
+ enabled: bool,
+ }
+
+ #[derive(Debug, Deserialize, PartialEq)]
+ struct NumberCfg {
+ count: u32,
+ }
+
+ fn write_config(contents: &str) -> (tempfile::TempDir, std::path::PathBuf) {
+ let dir = tempdir().expect("tempdir");
+ let path = dir.path().join("runtime.toml");
+ std::fs::write(&path, contents).expect("write config");
+ (dir, path)
+ }
+
+ #[test]
+ fn load_required_file_reads_toml() {
+ let (_dir, path) = write_config(
+ r#"
+logs_dir = "logs"
+enabled = false
+"#,
+ );
+
+ let cfg: RuntimeCfg = load_required_file(&path).expect("load config");
+ assert_eq!(
+ cfg,
+ RuntimeCfg {
+ logs_dir: "logs".to_string(),
+ enabled: false,
+ }
+ );
+ }
+
+ #[test]
+ fn load_required_file_reports_missing_path() {
+ let path = std::path::PathBuf::from("/tmp/radroots-runtime-config-does-not-exist.toml");
+ let err = load_required_file::<RuntimeCfg>(&path).expect_err("missing config should fail");
+ match err {
+ RuntimeConfigError::Load { path: p, .. } => assert_eq!(p, path),
+ }
+ }
+
+ #[test]
+ fn load_required_file_reports_missing_path_for_number_cfg_owned_path() {
+ let path = std::path::PathBuf::from("/tmp/radroots-runtime-config-missing-number.toml");
+ let err =
+ load_required_file::<NumberCfg>(path.clone()).expect_err("missing config should fail");
+ match err {
+ RuntimeConfigError::Load { path: p, .. } => assert_eq!(p, path),
+ }
+ }
+
+ #[test]
+ fn load_required_file_reports_deserialize_failure() {
+ let (_dir, path) = write_config(
+ r#"
+count = "not-a-number"
+"#,
+ );
+
+ let err =
+ load_required_file::<NumberCfg>(path.clone()).expect_err("invalid value should fail");
+ match err {
+ RuntimeConfigError::Load { path: p, .. } => assert_eq!(p, path),
+ }
+ }
+
+ #[test]
+ fn load_required_file_with_env_path_executes_env_source() {
+ let (_dir, path) = write_config(
+ r#"
+logs_dir = "logs"
+enabled = true
+"#,
+ );
+
+ let cfg: RuntimeCfg = load_required_file_with_env(path.clone(), "RADROOTS_RUNTIME_TEST")
+ .expect("load config with env source");
+ assert_eq!(cfg.logs_dir, "logs");
+ assert!(cfg.enabled);
+ }
+
+ #[test]
+ fn load_required_file_with_env_reports_missing_path() {
+ let path =
+ std::path::PathBuf::from("/tmp/radroots-runtime-config-does-not-exist-with-env.toml");
+ let err = load_required_file_with_env::<RuntimeCfg>(path.clone(), "RADROOTS_RUNTIME_TEST")
+ .expect_err("missing config should fail");
+ match err {
+ RuntimeConfigError::Load { path: p, .. } => assert_eq!(p, path),
+ }
+ }
+
+ #[test]
+ fn load_required_file_with_env_and_overrides_applies_overrides() {
+ let (_dir, path) = write_config(
+ r#"
+logs_dir = "logs"
+enabled = false
+"#,
+ );
+
+ let mut overrides = Map::new();
+ overrides.insert("enabled".to_string(), Value::from(true));
+ let cfg: RuntimeCfg = load_required_file_with_env_and_overrides(
+ path.clone(),
+ Some("RADROOTS_RUNTIME_TEST"),
+ Some(overrides),
+ )
+ .expect("load config with overrides");
+
+ assert!(cfg.enabled);
+ assert_eq!(cfg.logs_dir, "logs");
+ }
+
+ #[test]
+ fn load_required_file_with_env_and_overrides_handles_none_overrides() {
+ let (_dir, path) = write_config(
+ r#"
+logs_dir = "logs"
+enabled = true
+"#,
+ );
+
+ let cfg: RuntimeCfg = load_required_file_with_env_and_overrides(path.clone(), None, None)
+ .expect("load config without overrides");
+ assert_eq!(cfg.logs_dir, "logs");
+ assert!(cfg.enabled);
+ }
+
+ #[test]
+ fn load_required_file_with_env_and_overrides_reports_override_error() {
+ let (_dir, path) = write_config(
+ r#"
+logs_dir = "logs"
+enabled = false
+"#,
+ );
+
+ let mut overrides = Map::new();
+ overrides.insert(String::new(), Value::from(true));
+ let err = load_required_file_with_env_and_overrides::<RuntimeCfg>(
+ path.clone(),
+ None,
+ Some(overrides),
+ )
+ .expect_err("invalid override should fail");
+
+ match err {
+ RuntimeConfigError::Load { path: p, .. } => assert_eq!(p, path),
+ }
+ }
+
+ #[test]
+ fn load_required_file_with_env_and_overrides_reports_build_error() {
+ let path = std::path::PathBuf::from(
+ "/tmp/radroots-runtime-config-does-not-exist-with-overrides.toml",
+ );
+ let err = load_required_file_with_env_and_overrides::<RuntimeCfg>(path.clone(), None, None)
+ .expect_err("missing config should fail");
+
+ match err {
+ RuntimeConfigError::Load { path: p, .. } => assert_eq!(p, path),
+ }
+ }
+
+ #[test]
+ fn load_required_file_with_env_and_overrides_reports_runtime_cfg_deserialize_error() {
+ let (_dir, path) = write_config(
+ r#"
+logs_dir = "logs"
+enabled = "invalid"
+"#,
+ );
+
+ let err = load_required_file_with_env_and_overrides::<RuntimeCfg>(path.clone(), None, None)
+ .expect_err("deserialize should fail");
+ match err {
+ RuntimeConfigError::Load { path: p, .. } => assert_eq!(p, path),
+ }
+ }
+}
diff --git a/crates/runtime/src/error.rs b/crates/runtime/src/error.rs
@@ -35,3 +35,39 @@ pub enum RuntimeError {
#[error(transparent)]
Tracing(#[from] RuntimeTracingError),
}
+
+#[cfg(test)]
+mod tests {
+ use super::{RuntimeConfigError, RuntimeError, RuntimeTracingError};
+ use std::error::Error as _;
+ use std::path::PathBuf;
+
+ #[test]
+ fn runtime_config_error_message_and_source_are_accessible() {
+ let err = RuntimeConfigError::Load {
+ path: PathBuf::from("config.toml"),
+ source: config::ConfigError::Message("invalid config".to_string()),
+ };
+ let display = err.to_string();
+ assert!(display.contains("config.toml"));
+ assert!(display.contains("invalid config"));
+ assert!(err.source().is_some());
+ }
+
+ #[test]
+ fn runtime_error_conversions_include_config_and_tracing_variants() {
+ let cfg = RuntimeConfigError::Load {
+ path: PathBuf::from("runtime.toml"),
+ source: config::ConfigError::Message("bad".to_string()),
+ };
+ let runtime_from_cfg: RuntimeError = cfg.into();
+ assert!(runtime_from_cfg.to_string().contains("runtime.toml"));
+ assert!(runtime_from_cfg.source().is_some());
+
+ let tracing =
+ RuntimeTracingError::from(radroots_log::Error::Msg("log-failure".to_string()));
+ let runtime_from_tracing: RuntimeError = tracing.into();
+ assert!(runtime_from_tracing.to_string().contains("log-failure"));
+ assert!(runtime_from_tracing.source().is_none());
+ }
+}
diff --git a/crates/runtime/src/json.rs b/crates/runtime/src/json.rs
@@ -143,3 +143,232 @@ fn atomic_write_json(
tmp.persist(path)?;
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::{JsonFile, JsonWriteOptions, RuntimeJsonError, atomic_write_json};
+ use serde::{Deserialize, Serialize, Serializer};
+ use std::path::{Path, PathBuf};
+ use tempfile::tempdir;
+
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+ struct Payload {
+ id: String,
+ count: u32,
+ }
+
+ #[derive(Debug, Clone, Deserialize)]
+ struct AlwaysSerializeError;
+
+ impl Serialize for AlwaysSerializeError {
+ fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ Err(serde::ser::Error::custom(format!(
+ "serialize error: {}",
+ core::any::type_name::<S>()
+ )))
+ }
+ }
+
+ fn payload_path(name: &str) -> (tempfile::TempDir, PathBuf) {
+ let dir = tempdir().expect("tempdir");
+ let path = dir.path().join(name);
+ (dir, path)
+ }
+
+ fn should_not_create_payload() -> Payload {
+ Payload {
+ id: "should-not-create".to_string(),
+ count: 9,
+ }
+ }
+
+ #[test]
+ fn load_reports_not_found_for_missing_path() {
+ let (_dir, path) = payload_path("missing.json");
+ let err = JsonFile::<Payload>::load(&path).expect_err("missing path should fail");
+ assert!(err.to_string().contains(path.to_string_lossy().as_ref()));
+ }
+
+ #[test]
+ fn load_reports_file_open_error_for_directory() {
+ let dir = tempdir().expect("tempdir");
+ let err = JsonFile::<Payload>::load(dir.path()).expect_err("directory path should fail");
+ assert!(err.to_string().contains("Failed to parse JSON"));
+ assert!(
+ err.to_string()
+ .contains(dir.path().to_string_lossy().as_ref())
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn load_reports_file_open_error_for_unreadable_file_path_variants() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let dir = tempdir().expect("tempdir");
+ let path = dir.path().join("unreadable.json");
+ std::fs::write(&path, "{}").expect("write json");
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000))
+ .expect("set unreadable permission");
+
+ let err_owned =
+ JsonFile::<Payload>::load(path.clone()).expect_err("owned path should fail");
+ assert!(matches!(err_owned, RuntimeJsonError::FileOpen(_, _)));
+
+ let err_ref_buf = JsonFile::<Payload>::load(&path).expect_err("pathbuf ref should fail");
+ assert!(matches!(err_ref_buf, RuntimeJsonError::FileOpen(_, _)));
+
+ let err_ref_path =
+ JsonFile::<Payload>::load(path.as_path()).expect_err("path ref should fail");
+ assert!(matches!(err_ref_path, RuntimeJsonError::FileOpen(_, _)));
+
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
+ .expect("restore permission");
+ }
+
+ #[test]
+ fn load_reports_file_parse_error_for_invalid_json() {
+ let (_dir, path) = payload_path("invalid.json");
+ std::fs::write(&path, "{invalid json").expect("write invalid json");
+ let err_ref = JsonFile::<Payload>::load(&path).expect_err("invalid json should fail");
+ assert!(matches!(err_ref, RuntimeJsonError::FileParse(_, _)));
+ let err_owned =
+ JsonFile::<Payload>::load(path.clone()).expect_err("invalid json should fail");
+ assert!(matches!(err_owned, RuntimeJsonError::FileParse(_, _)));
+ }
+
+ #[test]
+ fn load_or_create_save_modify_and_load_round_trip() {
+ let (_dir, path) = payload_path("payload.json");
+ let mut json = JsonFile::load_or_create_with(&path, || Payload {
+ id: "item-1".to_string(),
+ count: 1,
+ })
+ .expect("create json");
+
+ assert_eq!(json.path(), path);
+ assert_eq!(
+ json.value,
+ Payload {
+ id: "item-1".to_string(),
+ count: 1,
+ }
+ );
+
+ json.set_options(JsonWriteOptions {
+ pretty: true,
+ mode_unix: None,
+ });
+ json.modify(|value| {
+ value.count = 2;
+ })
+ .expect("modify json");
+
+ let raw = std::fs::read_to_string(&path).expect("read json");
+ assert!(raw.contains('\n'));
+ assert_eq!(should_not_create_payload().count, 9);
+
+ let loaded = JsonFile::<Payload>::load_or_create_with(&path, should_not_create_payload)
+ .expect("load existing json");
+ assert_eq!(
+ loaded.value,
+ Payload {
+ id: "item-1".to_string(),
+ count: 2,
+ }
+ );
+ }
+
+ #[test]
+ fn save_as_writes_to_new_path() {
+ let (_src_dir, source) = payload_path("source.json");
+ let (_dst_dir, destination) = payload_path("dest.json");
+ let json = JsonFile::load_or_create_with(&source, || Payload {
+ id: "item-2".to_string(),
+ count: 3,
+ })
+ .expect("create source json");
+
+ json.save_as(&destination).expect("save as");
+ let loaded = JsonFile::<Payload>::load(&destination).expect("load destination json");
+ assert_eq!(
+ loaded.value,
+ Payload {
+ id: "item-2".to_string(),
+ count: 3,
+ }
+ );
+ }
+
+ #[test]
+ fn save_reports_io_error_when_parent_is_not_directory() {
+ let dir = tempdir().expect("tempdir");
+ let parent_file = dir.path().join("not-a-dir");
+ std::fs::write(&parent_file, "file").expect("write parent file");
+ let target = parent_file.join("payload.json");
+
+ let json = JsonFile::load_or_create_with(dir.path().join("valid.json"), || Payload {
+ id: "item-3".to_string(),
+ count: 4,
+ })
+ .expect("create json");
+
+ let err = json.save_as(&target).expect_err("io error should surface");
+ assert!(matches!(err, RuntimeJsonError::Io(_)));
+ }
+
+ #[test]
+ fn save_reports_persist_error_when_target_is_directory() {
+ let dir = tempdir().expect("tempdir");
+ let target_dir = dir.path().join("target");
+ std::fs::create_dir_all(&target_dir).expect("create target dir");
+
+ let json = JsonFile::load_or_create_with(dir.path().join("value.json"), || Payload {
+ id: "item-4".to_string(),
+ count: 5,
+ })
+ .expect("create json");
+
+ let err = json
+ .save_as(&target_dir)
+ .expect_err("persist error should surface");
+ assert!(matches!(err, RuntimeJsonError::Persist(_)));
+ }
+
+ #[test]
+ fn save_reports_serialization_error() {
+ let (_dir, path) = payload_path("serialize-error.json");
+ let mut json = JsonFile {
+ value: AlwaysSerializeError,
+ path,
+ options: JsonWriteOptions::default(),
+ };
+ json.set_options(JsonWriteOptions {
+ pretty: true,
+ mode_unix: Some(0o600),
+ });
+
+ let err = json.save().expect_err("serialization error should surface");
+ assert!(matches!(err, RuntimeJsonError::Serialization(_)));
+ }
+
+ #[test]
+ fn atomic_write_json_honors_mode_none_and_some() {
+ let (_none_dir, path_none) = payload_path("mode-none.json");
+ atomic_write_json(&path_none, br#"{"id":"x","count":1}"#, None)
+ .expect("write without mode");
+ let (_some_dir, path_some) = payload_path("mode-some.json");
+ atomic_write_json(&path_some, br#"{"id":"y","count":2}"#, Some(0o600))
+ .expect("write with mode");
+
+ let err =
+ atomic_write_json(Path::new("/"), br#"{}"#, None).expect_err("root write should fail");
+ assert!(matches!(
+ err,
+ RuntimeJsonError::Persist(_) | RuntimeJsonError::Io(_)
+ ));
+ }
+}
diff --git a/crates/runtime/src/signals.rs b/crates/runtime/src/signals.rs
@@ -1,3 +1,4 @@
+use core::future::Future;
use tokio::signal;
use tracing::info;
@@ -19,6 +20,14 @@ pub async fn shutdown_signal() {
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
+ wait_for_shutdown(ctrl_c, terminate).await;
+}
+
+async fn wait_for_shutdown<C, T>(ctrl_c: C, terminate: T)
+where
+ C: Future<Output = ()>,
+ T: Future<Output = ()>,
+{
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
@@ -26,3 +35,53 @@ pub async fn shutdown_signal() {
info!("Shutdown signal received, terminating...");
}
+
+#[cfg(test)]
+mod tests {
+ use super::{shutdown_signal, wait_for_shutdown};
+ use core::future::{pending, ready};
+ use std::process::Command;
+ use std::time::Duration;
+
+ #[tokio::test]
+ async fn wait_for_shutdown_returns_when_ctrl_completes() {
+ wait_for_shutdown(ready(()), pending::<()>()).await;
+ }
+
+ #[tokio::test]
+ async fn wait_for_shutdown_returns_when_terminate_completes() {
+ wait_for_shutdown(pending::<()>(), ready(())).await;
+ }
+
+ #[cfg(unix)]
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn shutdown_signal_returns_on_sigterm() {
+ let pid = std::process::id();
+ let sender = std::thread::spawn(move || {
+ std::thread::sleep(Duration::from_millis(50));
+ let status = Command::new("kill")
+ .args(["-TERM", pid.to_string().as_str()])
+ .status()
+ .expect("run kill");
+ assert!(status.success());
+ });
+ shutdown_signal().await;
+ sender.join().expect("signal sender should join");
+ }
+
+ #[cfg(unix)]
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn shutdown_signal_returns_on_sigint() {
+ let pid = std::process::id();
+ let sender = std::thread::spawn(move || {
+ std::thread::sleep(Duration::from_millis(50));
+ let status = Command::new("kill")
+ .args(["-INT", pid.to_string().as_str()])
+ .status()
+ .expect("run kill");
+ assert!(status.success());
+ });
+ shutdown_signal().await;
+ sender.join().expect("signal sender should join");
+ }
+}
diff --git a/crates/runtime/src/tracing.rs b/crates/runtime/src/tracing.rs
@@ -15,42 +15,49 @@ pub fn init_with(
let env_dir = env_path("LOG_DIR").or_else(|| env_path("RADROOTS_LOG_DIR"));
let env_file = env_value("LOG_FILE").or_else(|| env_value("RADROOTS_LOG_FILE"));
let env_level = env_value("LOG_LEVEL").or_else(|| env_value("RUST_LOG"));
- let dir = env_dir.or_else(|| {
- if logs_dir.as_os_str().is_empty() {
- None
- } else {
- Some(logs_dir.to_path_buf())
- }
- });
+ let dir = resolve_log_dir(logs_dir, env_dir);
let opts = LoggingOptions {
dir,
file_name: env_file.unwrap_or_else(default_log_file_name),
stdout: true,
- default_level: env_level.or_else(|| default_level.map(str::to_string)),
+ default_level: resolve_default_level(env_level, default_level),
};
radroots_log::init_logging(opts)?;
Ok(())
}
fn default_log_file_name() -> String {
- log_name_from_exe().unwrap_or_else(|| format!("{}.log", env!("CARGO_PKG_NAME")))
+ default_log_file_name_from_exe_name(log_name_from_exe())
+}
+
+fn default_log_file_name_from_exe_name(exe_name: Option<String>) -> String {
+ exe_name.unwrap_or_else(|| format!("{}.log", env!("CARGO_PKG_NAME")))
}
fn log_name_from_exe() -> Option<String> {
let exe = std::env::current_exe().ok()?;
let name = exe.file_stem()?.to_string_lossy();
- if name.is_empty() {
+ log_name_from_stem(name.as_ref())
+}
+
+fn log_name_from_stem(stem: &str) -> Option<String> {
+ if stem.is_empty() {
None
} else {
- Some(format!("{name}.log"))
+ Some(format!("{stem}.log"))
}
}
fn env_value(key: &str) -> Option<String> {
- let value = match std::env::var(key) {
- Ok(value) => value,
- Err(_) => return None,
- };
+ let value = std::env::var(key).ok()?;
+ normalize_env_value(&value)
+}
+
+fn env_path(key: &str) -> Option<PathBuf> {
+ env_value(key).map(PathBuf::from)
+}
+
+fn normalize_env_value(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
@@ -59,6 +66,105 @@ fn env_value(key: &str) -> Option<String> {
}
}
-fn env_path(key: &str) -> Option<PathBuf> {
- env_value(key).map(PathBuf::from)
+fn resolve_log_dir(logs_dir: &Path, env_dir: Option<PathBuf>) -> Option<PathBuf> {
+ env_dir.or_else(|| {
+ if logs_dir.as_os_str().is_empty() {
+ None
+ } else {
+ Some(logs_dir.to_path_buf())
+ }
+ })
+}
+
+fn resolve_default_level(env_level: Option<String>, default_level: Option<&str>) -> Option<String> {
+ if let Some(level) = env_level {
+ Some(level)
+ } else {
+ default_level.map(str::to_string)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ default_log_file_name, default_log_file_name_from_exe_name, env_path, env_value, init,
+ init_with, log_name_from_stem, normalize_env_value, resolve_default_level, resolve_log_dir,
+ };
+ use std::path::{Path, PathBuf};
+ use tempfile::tempdir;
+
+ #[test]
+ fn normalize_env_value_handles_empty_and_non_empty_values() {
+ assert_eq!(normalize_env_value(" value "), Some("value".to_string()));
+ assert_eq!(normalize_env_value(" "), None);
+ assert_eq!(normalize_env_value(""), None);
+ }
+
+ #[test]
+ fn env_helpers_return_expected_values() {
+ assert_eq!(env_value("RADROOTS_RUNTIME_TEST_MISSING_KEY"), None);
+ let home = env_value("HOME").expect("home env");
+ assert!(!home.is_empty());
+ let home_path = env_path("HOME").expect("home path");
+ assert_eq!(home_path, PathBuf::from(home));
+ }
+
+ #[test]
+ fn log_name_helpers_cover_empty_and_non_empty_names() {
+ assert_eq!(
+ log_name_from_stem("radrootsd"),
+ Some("radrootsd.log".to_string())
+ );
+ assert_eq!(log_name_from_stem(""), None);
+ }
+
+ #[test]
+ fn default_log_file_name_helpers_cover_fallback() {
+ assert_eq!(
+ default_log_file_name_from_exe_name(Some("svc.log".to_string())),
+ "svc.log"
+ );
+ assert_eq!(
+ default_log_file_name_from_exe_name(None),
+ format!("{}.log", env!("CARGO_PKG_NAME"))
+ );
+ assert!(!default_log_file_name().trim().is_empty());
+ }
+
+ #[test]
+ fn resolve_log_dir_prefers_env_and_handles_empty_logs_dir() {
+ let fallback = resolve_log_dir(Path::new("logs"), None);
+ assert_eq!(fallback, Some(PathBuf::from("logs")));
+
+ let empty = resolve_log_dir(Path::new(""), None);
+ assert_eq!(empty, None);
+
+ let env_dir = PathBuf::from("env-logs");
+ let resolved = resolve_log_dir(Path::new("logs"), Some(env_dir.clone()));
+ assert_eq!(resolved, Some(env_dir));
+ }
+
+ #[test]
+ fn resolve_default_level_prefers_env_then_fallback() {
+ let env_value = resolve_default_level(Some("warn".to_string()), Some("info"));
+ assert_eq!(env_value, Some("warn".to_string()));
+ let fallback = resolve_default_level(None, Some("info"));
+ assert_eq!(fallback, Some("info".to_string()));
+ let none = resolve_default_level(None, None);
+ assert_eq!(none, None);
+ }
+
+ #[test]
+ fn init_paths_execute() {
+ let dir = tempdir().expect("tempdir");
+ let first = init_with(dir.path(), Some("info"));
+ assert!(first.is_ok());
+ let owned_path = dir.path().to_path_buf();
+ let third = init_with(owned_path.as_path(), None);
+ assert!(third.is_ok());
+ let fourth = init_with("logs", Some("debug"));
+ assert!(fourth.is_ok());
+ let second = init();
+ assert!(second.is_ok());
+ }
}