#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod apply;
mod lex;
mod parse;
mod schema;
mod size;
pub use schema::{
AppendFsync, Config, ConfigError, EvictionPolicy, ExpirySection, LogLevel,
LogOutput, LogSection, MemorySection, PersistenceSection, ServerSection,
};
pub use size::parse_size;
use std::path::{Path, PathBuf};
const AUTODETECT_PATHS: &[&str] = &[
"./kevy.toml",
"/etc/kevy/kevy.toml",
];
impl Config {
pub fn load(path: Option<&Path>) -> Result<Self, ConfigError> {
if let Some(p) = path {
let text = read_required(p)?;
return Self::from_toml_str(&text, Some(p));
}
if let Some(p) = autodetect() {
let text = read_required(&p)?;
let mut cfg = Self::from_toml_str(&text, Some(&p))?;
cfg.source_path = Some(p);
return Ok(cfg);
}
Ok(Self::default())
}
pub fn from_toml_str(text: &str, source_path: Option<&Path>) -> Result<Self, ConfigError> {
let mut cfg = Self::default();
let items = parse::parse(text)?;
for item in items {
cfg.apply_item(item)?;
}
if let Some(p) = source_path {
cfg.source_path = Some(p.to_path_buf());
}
Ok(cfg)
}
pub fn merge_env<I, K, V>(&mut self, env: I) -> Result<(), ConfigError>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
for (k, v) in env {
self.apply_env_var(k.as_ref(), v.as_ref())?;
}
Ok(())
}
pub fn merge_cli(&mut self, cli: CliOverrides) -> Result<(), ConfigError> {
if let Some(bind) = cli.bind {
self.server.bind = bind;
}
if let Some(port) = cli.port {
self.server.port = port;
}
if let Some(t) = cli.threads {
self.server.threads = t;
}
if let Some(d) = cli.data_dir {
self.server.data_dir = d;
}
if let Some(aof) = cli.aof {
self.persistence.aof = aof;
}
Ok(())
}
pub fn to_toml_string(&self) -> String {
use std::fmt::Write;
let mut out = String::new();
let [a, b, c, d] = self.server.bind;
let _ = writeln!(out, "[server]");
let _ = writeln!(out, "bind = \"{a}.{b}.{c}.{d}\"");
let _ = writeln!(out, "port = {}", self.server.port);
let _ = writeln!(out, "threads = {}", self.server.threads);
let _ = writeln!(
out,
"data_dir = \"{}\"",
escape_toml_basic_string(&self.server.data_dir.display().to_string()),
);
let _ = writeln!(out);
let _ = writeln!(out, "[persistence]");
let _ = writeln!(out, "aof = {}", self.persistence.aof);
let _ = writeln!(
out,
"appendfsync = \"{}\"",
self.persistence.appendfsync.as_str(),
);
let _ = writeln!(
out,
"auto_aof_rewrite_percentage = {}",
self.persistence.auto_aof_rewrite_percentage,
);
let _ = writeln!(
out,
"auto_aof_rewrite_min_size = {}",
self.persistence.auto_aof_rewrite_min_size,
);
let _ = writeln!(out);
let _ = writeln!(out, "[memory]");
let _ = writeln!(out, "maxmemory = {}", self.memory.maxmemory);
let _ = writeln!(
out,
"maxmemory_policy = \"{}\"",
self.memory.maxmemory_policy.as_str(),
);
let _ = writeln!(out);
let _ = writeln!(out, "[expiry]");
let _ = writeln!(out, "hz = {}", self.expiry.hz);
let _ = writeln!(out, "sample = {}", self.expiry.sample);
let _ = writeln!(out);
let _ = writeln!(out, "[log]");
let _ = writeln!(out, "level = \"{}\"", self.log.level.as_str());
let _ = writeln!(
out,
"output = \"{}\"",
escape_toml_basic_string(&self.log.output.as_str()),
);
out
}
}
fn escape_toml_basic_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
other => out.push(other),
}
}
out
}
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct CliOverrides {
pub bind: Option<[u8; 4]>,
pub port: Option<u16>,
pub threads: Option<usize>,
pub data_dir: Option<PathBuf>,
pub aof: Option<bool>,
}
fn read_required(p: &Path) -> Result<String, ConfigError> {
std::fs::read_to_string(p).map_err(|e| ConfigError::IoOpen {
path: p.to_path_buf(),
err: e.to_string(),
})
}
fn autodetect() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("KEVY_DIR") {
let p = PathBuf::from(dir).join("kevy.toml");
if p.exists() {
return Some(p);
}
}
for relative in AUTODETECT_PATHS {
let p = PathBuf::from(relative);
if p.exists() {
return Some(p);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_documented_values() {
let cfg = Config::default();
assert_eq!(cfg.server.bind, [127, 0, 0, 1]);
assert_eq!(cfg.server.port, 6004);
assert_eq!(cfg.server.threads, 0);
assert!(cfg.persistence.aof);
assert_eq!(cfg.persistence.appendfsync, AppendFsync::EverySec);
assert_eq!(cfg.memory.maxmemory, 0);
assert_eq!(cfg.memory.maxmemory_policy, EvictionPolicy::NoEviction);
assert_eq!(cfg.expiry.hz, 10);
assert_eq!(cfg.expiry.sample, 20);
assert_eq!(cfg.log.level, LogLevel::Info);
}
#[test]
fn cli_overrides_apply_in_order() {
let mut cfg = Config::default();
let cli = CliOverrides {
bind: Some([0, 0, 0, 0]),
port: Some(7000),
threads: Some(4),
..CliOverrides::default()
};
cfg.merge_cli(cli).unwrap();
assert_eq!(cfg.server.bind, [0, 0, 0, 0]);
assert_eq!(cfg.server.port, 7000);
assert_eq!(cfg.server.threads, 4);
}
#[test]
fn env_overrides_apply() {
let mut cfg = Config::default();
cfg.merge_env([
("KEVY_BIND", "0.0.0.0"),
("KEVY_PORT", "7001"),
("UNRELATED_VAR", "ignored"),
])
.unwrap();
assert_eq!(cfg.server.bind, [0, 0, 0, 0]);
assert_eq!(cfg.server.port, 7001);
}
#[test]
fn to_toml_string_round_trips_through_parser() {
let mut original = Config::default();
original.server.bind = [10, 0, 0, 1];
original.server.port = 7779;
original.server.threads = 4;
original.server.data_dir = PathBuf::from("/var/lib/kevy");
original.persistence.aof = false;
original.persistence.appendfsync = AppendFsync::Always;
original.persistence.auto_aof_rewrite_percentage = 200;
original.persistence.auto_aof_rewrite_min_size = 128 * 1024 * 1024;
original.memory.maxmemory = 4 * 1024 * 1024 * 1024;
original.memory.maxmemory_policy = EvictionPolicy::AllKeysLfu;
original.expiry.hz = 100;
original.expiry.sample = 50;
original.log.level = LogLevel::Warn;
original.log.output = LogOutput::File(PathBuf::from("/var/log/kevy.log"));
let toml_text = original.to_toml_string();
let mut reparsed = Config::from_toml_str(&toml_text, None).unwrap_or_else(|e| {
panic!("to_toml_string output did not reparse: {e}\n--- TOML ---\n{toml_text}")
});
reparsed.source_path = original.source_path.clone();
assert_eq!(original, reparsed);
}
#[test]
fn to_toml_string_escapes_quotes_and_backslashes_in_paths() {
let mut cfg = Config::default();
cfg.server.data_dir = PathBuf::from(r#"/path with "quote" and \back"#);
let text = cfg.to_toml_string();
assert!(
text.contains(r#"data_dir = "/path with \"quote\" and \\back""#),
"did not escape correctly: {text}"
);
let reparsed = Config::from_toml_str(&text, None).expect("reparse");
assert_eq!(reparsed.server.data_dir, cfg.server.data_dir);
}
}