use serde::Deserialize;
use std::path::Path;
use toml::Table;
#[derive(Debug, Clone)]
pub struct Config {
pub data: ConfigData,
pub table: Table,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ConfigLog {
pub dir: String,
pub level: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ConfigData {
pub cache_cap: Option<i64>,
pub tick_interval_secs: Option<i64>,
pub max_message_retry_times: Option<i32>,
pub max_node_run_times: Option<i64>,
pub log: Option<ConfigLog>,
}
impl Default for Config {
fn default() -> Self {
Self {
data: ConfigData::default(),
table: Table::new(),
}
}
}
impl Config {
pub fn create(path: &Path) -> Self {
#[allow(clippy::expect_fun_call)]
let data =
std::fs::read_to_string(path).expect(&format!("failed to load config file {path:?}"));
#[allow(clippy::expect_fun_call)]
let table = toml::from_str::<Table>(data.as_str())
.expect(&format!("failed to parse the toml file({path:?})"));
let data = ConfigData::deserialize(table.clone()).unwrap();
Self {
table: table.clone(),
data,
}
}
pub fn get<'de, T>(&self, name: &str) -> crate::Result<T>
where
T: Deserialize<'de>,
{
let value = self
.table
.get(name)
.ok_or_else(|| crate::ActError::Config(format!("config '{name}' does not exist")))?
.clone();
T::deserialize(value)
.map_err(|err| crate::ActError::Config(format!("failed to get '{name}' config: {err}")))
}
pub fn has(&self, name: &str) -> bool {
self.table.contains_key(name)
}
pub fn overlay_file(&mut self, path: &Path) -> crate::Result<()> {
if !path.exists() {
return Ok(());
}
let text = std::fs::read_to_string(path).map_err(|err| {
crate::ActError::Config(format!(
"failed to load config file {}: {err}",
path.display()
))
})?;
let table = toml::from_str::<Table>(&text).map_err(|err| {
crate::ActError::Config(format!(
"failed to parse the toml file({}): {err}",
path.display()
))
})?;
merge_table(&mut self.table, table);
self.data = ConfigData::deserialize(self.table.clone()).map_err(|err| {
crate::ActError::Config(format!("failed to parse the merged config: {err}"))
})?;
Ok(())
}
pub fn cache_cap(&self) -> i64 {
self.data.cache_cap.unwrap_or(1024)
}
pub fn max_message_retry_times(&self) -> i32 {
self.data.max_message_retry_times.unwrap_or(20)
}
pub fn max_node_run_times(&self) -> i64 {
self.data.max_node_run_times.unwrap_or(1000)
}
pub fn tick_interval_secs(&self) -> i64 {
self.data.tick_interval_secs.unwrap_or(15)
}
pub fn log(&self) -> ConfigLog {
self.data.log.clone().unwrap_or(ConfigLog {
dir: "log".to_string(),
level: "INFO".to_string(),
})
}
}
fn merge_table(base: &mut Table, over: Table) {
for (key, value) in over {
if let Some(existing) = base.get_mut(&key) {
match (existing, value) {
(toml::Value::Table(base_table), toml::Value::Table(over_table)) => {
merge_table(base_table, over_table);
}
(slot, value) => *slot = value,
}
} else {
base.insert(key, value);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MissingParamAction {
#[default]
Skip,
Error,
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> std::path::PathBuf {
let thread = std::thread::current()
.name()
.unwrap_or("t")
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect::<String>();
let dir = std::env::temp_dir().join(format!(
"acts-config-{}-{}-{}",
std::process::id(),
name,
thread
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write(dir: &Path, name: &str, body: &str) -> std::path::PathBuf {
let path = dir.join(name);
std::fs::write(&path, body).unwrap();
path
}
#[test]
fn overlay_file_merges_nested_tables_field_by_field() {
let dir = scratch("merge");
let base = write(
&dir,
"base.toml",
r#"
cache_cap = 1024
[log]
dir = "data"
level = "INFO"
"#,
);
let over = write(
&dir,
"over.toml",
r#"
[log]
dir = "other"
"#,
);
let mut config = Config::create(&base);
config.overlay_file(&over).unwrap();
assert_eq!(config.log().dir, "other");
assert_eq!(config.log().level, "INFO");
assert_eq!(config.cache_cap(), 1024);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn overlay_file_replaces_scalars_and_adds_new_keys() {
let dir = scratch("scalar");
let base = write(
&dir,
"base.toml",
"cache_cap = 1024\n[log]\ndir = \"data\"\nlevel = \"INFO\"\n",
);
let over = write(&dir, "over.toml", "cache_cap = 512\n[web]\nport = 10082\n");
let mut config = Config::create(&base);
config.overlay_file(&over).unwrap();
assert_eq!(config.cache_cap(), 512);
assert!(config.has("web"));
assert_eq!(config.log().dir, "data");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn overlay_file_missing_is_a_noop_and_unparsable_is_an_error() {
let dir = scratch("missing");
let base = write(
&dir,
"base.toml",
"[log]\ndir = \"data\"\nlevel = \"INFO\"\n",
);
let mut config = Config::create(&base);
config.overlay_file(&dir.join("nope.toml")).unwrap();
assert_eq!(config.log().level, "INFO");
let bad = write(&dir, "bad.toml", "this is not [ valid toml");
assert!(config.overlay_file(&bad).is_err());
std::fs::remove_dir_all(&dir).ok();
}
}