Skip to main content

kevy_config/
lib.rs

1//! `kevy-config` — TOML subset parser + Config schema for the kevy server.
2//!
3//! Zero crates.io dependencies; `#![forbid(unsafe_code)]`. Built specifically
4//! for kevy's config file shape; not a general-purpose TOML library.
5//!
6//! Supported TOML subset:
7//! - `[section]` table headers (one level deep)
8//! - `key = value` with `value` ∈ {string, integer, boolean, size literal}
9//! - String literals: `"double-quoted"` and `'single-quoted'`
10//! - Integers: signed decimal (`123`, `-7`); prefixed forms (0x/0o/0b) NOT supported
11//! - Booleans: `true` / `false`
12//! - Size literals (kevy-specific extension): `"64mb"` / `"2gb"` / `"512kb"`
13//!   parsed via [`parse_size`] when the schema field expects bytes
14//! - `# comment` to end of line
15//!
16//! INTENTIONALLY UNSUPPORTED (TOML spec features kevy doesn't need):
17//! - dotted keys (`a.b.c = ...`)
18//! - multi-line strings (`"""…"""`)
19//! - arrays / arrays of tables
20//! - inline tables (`{ a = 1, b = 2 }`)
21//! - datetime literals
22//!
23//! See [`Config`] for the schema, [`Config::load`] for the precedence chain.
24#![forbid(unsafe_code)]
25#![warn(missing_docs)]
26
27mod apply;
28mod error;
29mod lex;
30mod parse;
31mod preserve;
32mod schema;
33mod size;
34
35pub use schema::{
36    AdvancedSection, AppendFsync, Config, ConfigError, EvictionPolicy, ExpirySection, LogLevel,
37    LogOutput, LogSection, MemorySection, NotificationFlags, NotificationSection,
38    PersistenceSection, ServerSection, SlowlogSection, parse_notification_flags,
39};
40pub use size::parse_size;
41
42use std::path::{Path, PathBuf};
43
44/// Auto-detect search order when `Config::load(None)` is called.
45const AUTODETECT_PATHS: &[&str] = &[
46    "./kevy.toml",
47    "/etc/kevy/kevy.toml",
48];
49
50impl Config {
51    /// Load config from the given explicit path, or auto-detect.
52    ///
53    /// Auto-detect order (first hit wins):
54    /// 1. `$KEVY_DIR/kevy.toml` (if `KEVY_DIR` env is set)
55    /// 2. `./kevy.toml`
56    /// 3. `/etc/kevy/kevy.toml`
57    ///
58    /// If `path` is `Some`, that file is required to exist; otherwise
59    /// returns `Ok(Config::default())` if no auto-detect path matched.
60    pub fn load(path: Option<&Path>) -> Result<Self, ConfigError> {
61        if let Some(p) = path {
62            let text = read_required(p)?;
63            return Self::from_toml_str(&text, Some(p));
64        }
65        if let Some(p) = autodetect() {
66            let text = read_required(&p)?;
67            let mut cfg = Self::from_toml_str(&text, Some(&p))?;
68            cfg.source_path = Some(p);
69            return Ok(cfg);
70        }
71        Ok(Self::default())
72    }
73
74    /// Parse a TOML string (no file I/O). `source_path` is used for error
75    /// reporting and `CONFIG REWRITE` write-back; pass `None` for in-memory.
76    pub fn from_toml_str(text: &str, source_path: Option<&Path>) -> Result<Self, ConfigError> {
77        let mut cfg = Self::default();
78        let items = parse::parse(text)?;
79        for item in items {
80            cfg.apply_item(item)?;
81        }
82        if let Some(p) = source_path {
83            cfg.source_path = Some(p.to_path_buf());
84        }
85        Ok(cfg)
86    }
87
88    /// Overlay environment variable values onto `self`. Iterates a
89    /// caller-provided `(name, value)` list so tests can pump a fixture
90    /// without touching the real env. The recognised variables match the
91    /// pre-`kevy-config` set:
92    /// - `KEVY_BIND` / `KEVY_PORT` / `KEVY_THREADS` / `KEVY_DIR` / `KEVY_AOF`
93    ///
94    /// Unknown variables are silently ignored (env may contain many
95    /// unrelated keys).
96    pub fn merge_env<I, K, V>(&mut self, env: I) -> Result<(), ConfigError>
97    where
98        I: IntoIterator<Item = (K, V)>,
99        K: AsRef<str>,
100        V: AsRef<str>,
101    {
102        for (k, v) in env {
103            self.apply_env_var(k.as_ref(), v.as_ref())?;
104        }
105        Ok(())
106    }
107
108    /// Overlay parsed-from-CLI overrides onto `self`. Pass a struct of
109    /// optional values (any `Some(_)` field overrides the corresponding
110    /// schema field). Tests pass a literal; the kevy binary builds one
111    /// from `std::env::args`.
112    pub fn merge_cli(&mut self, cli: CliOverrides) -> Result<(), ConfigError> {
113        if let Some(bind) = cli.bind {
114            self.server.bind = bind;
115        }
116        if let Some(port) = cli.port {
117            self.server.port = port;
118        }
119        if let Some(t) = cli.threads {
120            self.server.threads = t;
121        }
122        if let Some(d) = cli.data_dir {
123            self.server.data_dir = d;
124        }
125        if let Some(aof) = cli.aof {
126            self.persistence.aof = aof;
127        }
128        if let Some(cluster) = cli.cluster {
129            self.cluster.enabled = cluster;
130        }
131        Ok(())
132    }
133
134    /// Render the current config as a standard-template TOML file —
135    /// every field, in stable section/key order, with no comments. Used
136    /// by `CONFIG REWRITE`; the loss of any inline comments the user
137    /// had in their hand-edited file is the documented v1.0 trade-off
138    /// (v1.x will preserve them).
139    ///
140    /// Round-trips: feeding the output back through [`Self::from_toml_str`]
141    /// reconstructs an equivalent `Config` (modulo `source_path`).
142    pub fn to_toml_string(&self) -> String {
143        use std::fmt::Write;
144        let mut out = String::new();
145        let [a, b, c, d] = self.server.bind;
146        let _ = writeln!(out, "[server]");
147        let _ = writeln!(out, "bind     = \"{a}.{b}.{c}.{d}\"");
148        let _ = writeln!(out, "port     = {}", self.server.port);
149        let _ = writeln!(out, "threads  = {}", self.server.threads);
150        let _ = writeln!(
151            out,
152            "data_dir = \"{}\"",
153            escape_toml_basic_string(&self.server.data_dir.display().to_string()),
154        );
155        let _ = writeln!(out);
156        let _ = writeln!(out, "[persistence]");
157        let _ = writeln!(out, "aof                          = {}", self.persistence.aof);
158        let _ = writeln!(
159            out,
160            "appendfsync                  = \"{}\"",
161            self.persistence.appendfsync.as_str(),
162        );
163        let _ = writeln!(
164            out,
165            "auto_aof_rewrite_percentage  = {}",
166            self.persistence.auto_aof_rewrite_percentage,
167        );
168        let _ = writeln!(
169            out,
170            "auto_aof_rewrite_min_size    = {}",
171            self.persistence.auto_aof_rewrite_min_size,
172        );
173        let _ = writeln!(out);
174        let _ = writeln!(out, "[memory]");
175        let _ = writeln!(out, "maxmemory         = {}", self.memory.maxmemory);
176        let _ = writeln!(
177            out,
178            "maxmemory_policy  = \"{}\"",
179            self.memory.maxmemory_policy.as_str(),
180        );
181        let _ = writeln!(out);
182        let _ = writeln!(out, "[expiry]");
183        let _ = writeln!(out, "hz       = {}", self.expiry.hz);
184        let _ = writeln!(out, "sample   = {}", self.expiry.sample);
185        let _ = writeln!(out);
186        let _ = writeln!(out, "[log]");
187        let _ = writeln!(out, "level    = \"{}\"", self.log.level.as_str());
188        let _ = writeln!(
189            out,
190            "output   = \"{}\"",
191            escape_toml_basic_string(&self.log.output.as_str()),
192        );
193        let _ = writeln!(out);
194        let _ = writeln!(out, "[notification]");
195        let _ = writeln!(
196            out,
197            "notify_keyspace_events = \"{}\"",
198            escape_toml_basic_string(&self.notification.notify_keyspace_events),
199        );
200        let _ = writeln!(out);
201        let _ = writeln!(out, "[advanced]");
202        let _ = writeln!(out, "spin_limit       = {}", self.advanced.spin_limit);
203        let _ = writeln!(out, "park_timeout_ms  = {}", self.advanced.park_timeout_ms);
204        let _ = writeln!(out, "tick_check_every = {}", self.advanced.tick_check_every);
205        let _ = writeln!(out, "ring_capacity    = {}", self.advanced.ring_capacity);
206        let _ = writeln!(out);
207        let _ = writeln!(out, "[slowlog]");
208        let _ = writeln!(
209            out,
210            "slower_than_micros = {}",
211            self.slowlog.slower_than_micros,
212        );
213        let _ = writeln!(out, "max_len            = {}", self.slowlog.max_len);
214        out
215    }
216}
217
218/// Escape a string for use inside a TOML basic (double-quoted) string.
219/// `\` and `"` need backslash escape; other ASCII passes through. The
220/// values we emit (paths, enum names) never contain control characters,
221/// so this is sufficient for our serialiser.
222fn escape_toml_basic_string(s: &str) -> String {
223    let mut out = String::with_capacity(s.len());
224    for c in s.chars() {
225        match c {
226            '\\' => out.push_str("\\\\"),
227            '"' => out.push_str("\\\""),
228            other => out.push(other),
229        }
230    }
231    out
232}
233
234/// Optional CLI overrides applied via [`Config::merge_cli`].
235///
236/// Any `Some(_)` field overrides the corresponding schema field. CLI is the
237/// highest-priority source (above env vars and the TOML file).
238#[derive(Default, Debug, Clone, PartialEq, Eq)]
239pub struct CliOverrides {
240    /// Override `server.bind` (`--bind A.B.C.D`).
241    pub bind: Option<[u8; 4]>,
242    /// Override `server.port` (`--port N`).
243    pub port: Option<u16>,
244    /// Override `server.threads` (`--threads N`).
245    pub threads: Option<usize>,
246    /// Override `server.data_dir` (`--dir PATH`).
247    pub data_dir: Option<PathBuf>,
248    /// Override `persistence.aof` (`--no-aof` → `Some(false)`).
249    pub aof: Option<bool>,
250    /// Override `cluster.enabled` (`--cluster` → `Some(true)`).
251    pub cluster: Option<bool>,
252}
253
254fn read_required(p: &Path) -> Result<String, ConfigError> {
255    std::fs::read_to_string(p).map_err(|e| ConfigError::IoOpen {
256        path: p.to_path_buf(),
257        err: e.to_string(),
258    })
259}
260
261fn autodetect() -> Option<PathBuf> {
262    if let Ok(dir) = std::env::var("KEVY_DIR") {
263        let p = PathBuf::from(dir).join("kevy.toml");
264        if p.exists() {
265            return Some(p);
266        }
267    }
268    for relative in AUTODETECT_PATHS {
269        let p = PathBuf::from(relative);
270        if p.exists() {
271            return Some(p);
272        }
273    }
274    None
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn defaults_match_documented_values() {
283        let cfg = Config::default();
284        assert_eq!(cfg.server.bind, [127, 0, 0, 1]);
285        assert_eq!(cfg.server.port, 6004);
286        assert_eq!(cfg.server.threads, 0);
287        assert!(cfg.persistence.aof);
288        assert_eq!(cfg.persistence.appendfsync, AppendFsync::EverySec);
289        assert_eq!(cfg.memory.maxmemory, 0);
290        assert_eq!(cfg.memory.maxmemory_policy, EvictionPolicy::NoEviction);
291        assert_eq!(cfg.expiry.hz, 10);
292        assert_eq!(cfg.expiry.sample, 20);
293        assert_eq!(cfg.log.level, LogLevel::Info);
294    }
295
296    #[test]
297    fn cli_overrides_apply_in_order() {
298        let mut cfg = Config::default();
299        let cli = CliOverrides {
300            bind: Some([0, 0, 0, 0]),
301            port: Some(7000),
302            threads: Some(4),
303            ..CliOverrides::default()
304        };
305        cfg.merge_cli(cli).unwrap();
306        assert_eq!(cfg.server.bind, [0, 0, 0, 0]);
307        assert_eq!(cfg.server.port, 7000);
308        assert_eq!(cfg.server.threads, 4);
309    }
310
311    #[test]
312    fn env_overrides_apply() {
313        let mut cfg = Config::default();
314        cfg.merge_env([
315            ("KEVY_BIND", "0.0.0.0"),
316            ("KEVY_PORT", "7001"),
317            ("UNRELATED_VAR", "ignored"),
318        ])
319        .unwrap();
320        assert_eq!(cfg.server.bind, [0, 0, 0, 0]);
321        assert_eq!(cfg.server.port, 7001);
322    }
323
324    #[test]
325    fn to_toml_string_round_trips_through_parser() {
326        let mut original = Config::default();
327        original.server.bind = [10, 0, 0, 1];
328        original.server.port = 7779;
329        original.server.threads = 4;
330        original.server.data_dir = PathBuf::from("/var/lib/kevy");
331        original.persistence.aof = false;
332        original.persistence.appendfsync = AppendFsync::Always;
333        original.persistence.auto_aof_rewrite_percentage = 200;
334        original.persistence.auto_aof_rewrite_min_size = 128 * 1024 * 1024;
335        original.memory.maxmemory = 4 * 1024 * 1024 * 1024;
336        original.memory.maxmemory_policy = EvictionPolicy::AllKeysLfu;
337        original.expiry.hz = 100;
338        original.expiry.sample = 50;
339        original.log.level = LogLevel::Warn;
340        original.log.output = LogOutput::File(PathBuf::from("/var/log/kevy.log"));
341
342        let toml_text = original.to_toml_string();
343        let mut reparsed = Config::from_toml_str(&toml_text, None).unwrap_or_else(|e| {
344            panic!("to_toml_string output did not reparse: {e}\n--- TOML ---\n{toml_text}")
345        });
346        // Re-parsing sets source_path only when one is passed; the live
347        // config's source_path is not part of the wire format.
348        reparsed.source_path = original.source_path.clone();
349        assert_eq!(original, reparsed);
350    }
351
352    #[test]
353    fn to_toml_string_escapes_quotes_and_backslashes_in_paths() {
354        let mut cfg = Config::default();
355        cfg.server.data_dir = PathBuf::from(r#"/path with "quote" and \back"#);
356        let text = cfg.to_toml_string();
357        assert!(
358            text.contains(r#"data_dir = "/path with \"quote\" and \\back""#),
359            "did not escape correctly: {text}"
360        );
361        // Round-trip the escape.
362        let reparsed = Config::from_toml_str(&text, None).expect("reparse");
363        assert_eq!(reparsed.server.data_dir, cfg.server.data_dir);
364    }
365}