use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use rivet_foundation::ConfigValue;
use super::daily::Daily;
use super::single::Single;
use super::stack::Stack;
use super::stdout::Stdout;
use super::Handler;
const DEFAULT_CHANNEL: &str = "single";
const DEFAULT_DAILY_PATH: &str = "rivet.log";
const DEFAULT_DAILY_DAYS: usize = 14;
pub fn build_handler_from_config(
log_config: &ConfigValue,
base_path: impl AsRef<Path>,
) -> io::Result<Arc<dyn Handler>> {
let base_path = base_path.as_ref();
let root = as_object(log_config, "log")?;
let default_channel = root
.get("default")
.map(|value| as_string(value, "log.default"))
.transpose()?
.unwrap_or_else(|| DEFAULT_CHANNEL.to_string());
let channels = as_object_entry(root, "channels", "log.channels")?;
build_channel(&default_channel, channels, base_path, &mut Vec::new())
}
pub fn build_handler_for_channel_from_config(
log_config: &ConfigValue,
base_path: impl AsRef<Path>,
channel: &str,
) -> io::Result<Arc<dyn Handler>> {
let base_path = base_path.as_ref();
let root = as_object(log_config, "log")?;
let channels = as_object_entry(root, "channels", "log.channels")?;
build_channel(channel, channels, base_path, &mut Vec::new())
}
pub fn build_all_handlers_from_config(
log_config: &ConfigValue,
base_path: impl AsRef<Path>,
) -> io::Result<BTreeMap<String, Arc<dyn Handler>>> {
let base_path = base_path.as_ref();
let root = as_object(log_config, "log")?;
let channels = as_object_entry(root, "channels", "log.channels")?;
let mut resolved = BTreeMap::new();
for channel in channels.keys() {
let handler = build_channel(channel, channels, base_path, &mut Vec::new())?;
resolved.insert(channel.clone(), handler);
}
Ok(resolved)
}
fn build_channel(
channel: &str,
channels: &BTreeMap<String, ConfigValue>,
base_path: &Path,
visit_stack: &mut Vec<String>,
) -> io::Result<Arc<dyn Handler>> {
if visit_stack.iter().any(|entry| entry == channel) {
let mut cycle = visit_stack.clone();
cycle.push(channel.to_string());
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("log channel cycle detected: {}", cycle.join(" -> ")),
));
}
let channel_value = channels.get(channel).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("log channel '{channel}' is not defined"),
)
})?;
let channel_config = as_object(channel_value, &format!("log.channels.{channel}"))?;
let driver = as_string_entry(
channel_config,
"driver",
&format!("log.channels.{channel}.driver"),
)?;
visit_stack.push(channel.to_string());
let result = match driver.as_str() {
"single" => build_single(channel_config, base_path, channel),
"daily" => build_daily(channel_config, base_path, channel),
"stdout" => Ok(Arc::new(Stdout::new()) as Arc<dyn Handler>),
"stack" => build_stack(channel_config, channels, base_path, visit_stack, channel),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unsupported log channel driver '{driver}' for channel '{channel}'"),
)),
};
let _ = visit_stack.pop();
result
}
fn build_single(
config: &BTreeMap<String, ConfigValue>,
base_path: &Path,
channel: &str,
) -> io::Result<Arc<dyn Handler>> {
let path = as_string_entry(config, "path", &format!("log.channels.{channel}.path"))?;
let path = normalize_path(base_path, &path);
Single::new(path).map(|handler| Arc::new(handler) as Arc<dyn Handler>)
}
fn build_daily(
config: &BTreeMap<String, ConfigValue>,
base_path: &Path,
channel: &str,
) -> io::Result<Arc<dyn Handler>> {
let path = config
.get("path")
.map(|value| as_string(value, &format!("log.channels.{channel}.path")))
.transpose()?
.unwrap_or_else(|| DEFAULT_DAILY_PATH.to_string());
let path = normalize_path(base_path, &path);
let days = config
.get("days")
.map(|value| as_usize(value, &format!("log.channels.{channel}.days")))
.transpose()?
.unwrap_or(DEFAULT_DAILY_DAYS);
if days == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("log.channels.{channel}.days must be >= 1"),
));
}
Daily::new(path, days).map(|handler| Arc::new(handler) as Arc<dyn Handler>)
}
fn build_stack(
config: &BTreeMap<String, ConfigValue>,
channels: &BTreeMap<String, ConfigValue>,
base_path: &Path,
visit_stack: &mut Vec<String>,
channel: &str,
) -> io::Result<Arc<dyn Handler>> {
let children = as_string_list_entry(
config,
"channels",
&format!("log.channels.{channel}.channels"),
)?;
if children.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("log.channels.{channel}.channels must contain at least one channel"),
));
}
let mut resolved = Vec::with_capacity(children.len());
for child in children {
resolved.push(build_channel(&child, channels, base_path, visit_stack)?);
}
Ok(Arc::new(Stack::new(resolved)))
}
fn normalize_path(base_path: &Path, value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else {
base_path.join(path)
}
}
fn as_object<'a>(
value: &'a ConfigValue,
key: &str,
) -> io::Result<&'a BTreeMap<String, ConfigValue>> {
match value {
ConfigValue::Object(value) => Ok(value),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{key} must be an object"),
)),
}
}
fn as_object_entry<'a>(
object: &'a BTreeMap<String, ConfigValue>,
key: &str,
full_key: &str,
) -> io::Result<&'a BTreeMap<String, ConfigValue>> {
let value = object.get(key).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{full_key} is missing"),
)
})?;
as_object(value, full_key)
}
fn as_string(value: &ConfigValue, key: &str) -> io::Result<String> {
match value {
ConfigValue::String(value) => Ok(value.clone()),
ConfigValue::Integer(value) => Ok(value.to_string()),
ConfigValue::Float(value) => Ok(value.to_string()),
ConfigValue::Boolean(value) => Ok(value.to_string()),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{key} must be a string-compatible value"),
)),
}
}
fn as_string_entry(
object: &BTreeMap<String, ConfigValue>,
key: &str,
full_key: &str,
) -> io::Result<String> {
let value = object.get(key).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{full_key} is missing"),
)
})?;
as_string(value, full_key)
}
fn as_usize(value: &ConfigValue, key: &str) -> io::Result<usize> {
let parsed = match value {
ConfigValue::Integer(value) => (*value).try_into().ok(),
ConfigValue::String(value) => value.parse::<usize>().ok(),
_ => None,
};
parsed.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{key} must be a non-negative integer"),
)
})
}
fn as_string_list_entry(
object: &BTreeMap<String, ConfigValue>,
key: &str,
full_key: &str,
) -> io::Result<Vec<String>> {
let value = object.get(key).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{full_key} is missing"),
)
})?;
match value {
ConfigValue::Array(values) => values
.iter()
.map(|value| as_string(value, full_key))
.collect(),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{full_key} must be an array"),
)),
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use rivet_foundation::ConfigValue;
use super::{build_handler_for_channel_from_config, build_handler_from_config};
fn unique_temp_dir(prefix: &str) -> std::path::PathBuf {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!("{prefix}-{stamp}"));
fs::create_dir_all(&path).expect("temp dir should be created");
path
}
fn string(value: &str) -> ConfigValue {
ConfigValue::String(value.to_string())
}
fn channel(driver: &str, entries: Vec<(&str, ConfigValue)>) -> ConfigValue {
let mut map = BTreeMap::new();
map.insert("driver".to_string(), string(driver));
for (key, value) in entries {
map.insert(key.to_string(), value);
}
ConfigValue::Object(map)
}
fn log_config(default: &str, channels: BTreeMap<String, ConfigValue>) -> ConfigValue {
let mut log = BTreeMap::new();
log.insert("default".to_string(), string(default));
log.insert("channels".to_string(), ConfigValue::Object(channels));
ConfigValue::Object(log)
}
#[test]
fn builds_single_handler() {
let dir = unique_temp_dir("rivet-handler-build-single");
let mut channels = BTreeMap::new();
channels.insert(
"single".to_string(),
channel("single", vec![("path", string("single.log"))]),
);
let config = log_config("single", channels);
let handler = build_handler_from_config(&config, &dir).expect("single should resolve");
handler.log("single line").expect("single should write");
let output = fs::read_to_string(dir.join("single.log")).expect("single log should exist");
assert_eq!(output, "single line\n");
}
#[test]
fn builds_daily_handler() {
let dir = unique_temp_dir("rivet-handler-build-daily");
let mut channels = BTreeMap::new();
channels.insert(
"daily".to_string(),
channel(
"daily",
vec![
("path", string("daily.log")),
("days", ConfigValue::Integer(5)),
],
),
);
let config = log_config("daily", channels);
let handler = build_handler_from_config(&config, &dir).expect("daily should resolve");
handler.log("daily line").expect("daily should write");
let files: Vec<_> = fs::read_dir(&dir)
.expect("daily dir should be readable")
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().to_string())
.collect();
assert!(
files.iter().any(|name| name.starts_with("daily-")),
"expected daily rotated file, found {files:?}"
);
}
#[test]
fn builds_only_requested_channel() {
let dir = unique_temp_dir("rivet-handler-build-requested-only");
let mut channels = BTreeMap::new();
channels.insert(
"single".to_string(),
channel("single", vec![("path", string("single.log"))]),
);
channels.insert(
"daily".to_string(),
channel(
"daily",
vec![
("path", string("daily.log")),
("days", ConfigValue::Integer(5)),
],
),
);
let config = log_config("single", channels);
let handler = build_handler_for_channel_from_config(&config, &dir, "single")
.expect("single should resolve by channel");
handler.log("single only").expect("single should write");
let single = dir.join("single.log");
assert!(single.exists(), "single file should exist");
let files: Vec<_> = fs::read_dir(&dir)
.expect("dir should be readable")
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().to_string())
.collect();
assert!(
!files.iter().any(|name| name.starts_with("daily-")),
"daily file should not be created when single is requested, found {files:?}"
);
}
#[test]
fn builds_stdout_handler() {
let dir = unique_temp_dir("rivet-handler-build-stdout");
let mut channels = BTreeMap::new();
channels.insert("stdout".to_string(), channel("stdout", vec![]));
let config = log_config("stdout", channels);
let _ = build_handler_from_config(&config, &dir).expect("stdout should resolve");
}
#[test]
fn builds_nested_stack_handler() {
let dir = unique_temp_dir("rivet-handler-build-stack");
let mut channels = BTreeMap::new();
channels.insert(
"single-a".to_string(),
channel("single", vec![("path", string("a.log"))]),
);
channels.insert(
"single-b".to_string(),
channel("single", vec![("path", string("b.log"))]),
);
channels.insert(
"fanout".to_string(),
channel(
"stack",
vec![(
"channels",
ConfigValue::Array(vec![string("single-a"), string("single-b")]),
)],
),
);
channels.insert(
"outer".to_string(),
channel(
"stack",
vec![("channels", ConfigValue::Array(vec![string("fanout")]))],
),
);
let config = log_config("outer", channels);
let handler = build_handler_from_config(&config, &dir).expect("stack should resolve");
handler.log("fan-out").expect("stack should write");
let a = fs::read_to_string(dir.join("a.log")).expect("a.log should exist");
let b = fs::read_to_string(dir.join("b.log")).expect("b.log should exist");
assert_eq!(a, "fan-out\n");
assert_eq!(b, "fan-out\n");
}
#[test]
fn fails_for_missing_channel_reference() {
let dir = unique_temp_dir("rivet-handler-build-missing");
let mut channels = BTreeMap::new();
channels.insert(
"stack".to_string(),
channel(
"stack",
vec![("channels", ConfigValue::Array(vec![string("missing")]))],
),
);
let config = log_config("stack", channels);
let err = match build_handler_from_config(&config, &dir) {
Ok(_) => panic!("missing channel should fail"),
Err(err) => err,
};
assert!(err.to_string().contains("not defined"));
}
#[test]
fn fails_for_cycle() {
let dir = unique_temp_dir("rivet-handler-build-cycle");
let mut channels = BTreeMap::new();
channels.insert(
"a".to_string(),
channel(
"stack",
vec![("channels", ConfigValue::Array(vec![string("b")]))],
),
);
channels.insert(
"b".to_string(),
channel(
"stack",
vec![("channels", ConfigValue::Array(vec![string("a")]))],
),
);
let config = log_config("a", channels);
let err = match build_handler_from_config(&config, &dir) {
Ok(_) => panic!("cycle should fail"),
Err(err) => err,
};
assert!(err.to_string().contains("cycle detected"));
}
#[test]
fn fails_for_invalid_days() {
let dir = unique_temp_dir("rivet-handler-build-days");
let mut channels = BTreeMap::new();
channels.insert(
"daily".to_string(),
channel(
"daily",
vec![
("path", string("daily.log")),
("days", ConfigValue::Integer(0)),
],
),
);
let config = log_config("daily", channels);
let err = match build_handler_from_config(&config, &dir) {
Ok(_) => panic!("days=0 should fail"),
Err(err) => err,
};
assert!(err.to_string().contains("days must be >= 1"));
}
#[test]
fn supports_default_channel_fallback() {
let dir = unique_temp_dir("rivet-handler-build-default");
let mut channels = BTreeMap::new();
channels.insert(
"single".to_string(),
channel("single", vec![("path", string("fallback.log"))]),
);
let mut log = BTreeMap::new();
log.insert("channels".to_string(), ConfigValue::Object(channels));
let config = ConfigValue::Object(log);
let handler = build_handler_from_config(&config, &dir).expect("fallback should resolve");
handler.log("ok").expect("fallback handler should write");
let output =
fs::read_to_string(dir.join("fallback.log")).expect("fallback log should exist");
assert_eq!(output, "ok\n");
}
#[test]
fn supports_stack_channel_push_semantics() {
let dir = unique_temp_dir("rivet-handler-build-stack-order");
let mut channels = BTreeMap::new();
channels.insert(
"single".to_string(),
channel("single", vec![("path", string("single.log"))]),
);
channels.insert(
"stack".to_string(),
channel(
"stack",
vec![("channels", ConfigValue::Array(vec![string("single")]))],
),
);
let config = log_config("stack", channels);
let handler = build_handler_from_config(&config, &dir).expect("stack should resolve");
handler.log("line").expect("stack handler should write");
let output =
fs::read_to_string(dir.join("single.log")).expect("stack single output should exist");
assert_eq!(output, "line\n");
}
}