lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

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:
Mcrates/runtime/src/backoff.rs | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/runtime/src/config.rs | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/runtime/src/error.rs | 36++++++++++++++++++++++++++++++++++++
Mcrates/runtime/src/json.rs | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/runtime/src/signals.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/runtime/src/tracing.rs | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
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()); + } }