Skip to main content

mitosis/
config.rs

1use crate::clone_backend::BackendKind;
2use crate::error::{ErrorCode, MitosisError, Result};
3use crate::names::parse_duration;
4use crate::paths::Home;
5use serde::Serialize;
6use std::io::Write;
7use std::net::{IpAddr, Ipv4Addr};
8use std::ops::RangeInclusive;
9use std::os::unix::fs::OpenOptionsExt;
10use std::time::Duration;
11
12#[derive(Clone, Debug)]
13pub struct Config {
14    pub max_clones: usize,
15    pub min_free_bytes: u64,
16    /// Total memory the running clones may reserve; `None` means no limit.
17    pub ram_budget: Option<u64>,
18    /// Total disk the clones may use; `None` means no limit.
19    pub disk_budget: Option<u64>,
20    /// How long a create waits for room under a budget before it fails.
21    pub queue_wait: Duration,
22    /// A clone that grows past this many bytes gets a warning.
23    pub clone_disk_warn: u64,
24    pub port_range: RangeInclusive<u16>,
25    pub default_max_age_secs: u64,
26    pub ready_timeout: Duration,
27    pub stop_timeout: Duration,
28    /// How often the template scheduler looks for work; not user-settable, tests shorten it.
29    pub scheduler_tick: Duration,
30    /// Serve every clone on one port, picked by database name; off makes `url` the direct URL.
31    pub router: bool,
32    pub router_port: u16,
33    /// The address the router binds; `0.0.0.0` makes it reachable from the network.
34    pub router_listen: IpAddr,
35    /// A clone with no connections for this long is put to sleep; `None` (off) never.
36    pub idle_sleep: Option<Duration>,
37    /// An unnamed, unprotected clone idle for this long is destroyed; `None` (off) never.
38    pub idle_reap: Option<Duration>,
39    /// Which clone back end to use; `auto` detects it from the file system. Chosen once at start.
40    pub backend: BackendKind,
41    /// The ZFS dataset Mitosis owns (for example `tank/mitosis`); `None` when not set.
42    pub zfs_dataset: Option<String>,
43}
44
45impl Default for Config {
46    fn default() -> Self {
47        Config {
48            max_clones: 8,
49            min_free_bytes: 2_000_000_000,
50            ram_budget: None,
51            disk_budget: None,
52            queue_wait: Duration::from_secs(60),
53            clone_disk_warn: 5_000_000_000,
54            port_range: 54320..=54999,
55            default_max_age_secs: 604_800,
56            ready_timeout: Duration::from_secs(10),
57            stop_timeout: Duration::from_secs(10),
58            scheduler_tick: Duration::from_secs(60),
59            router: true,
60            router_port: 54300,
61            router_listen: IpAddr::V4(Ipv4Addr::LOCALHOST),
62            idle_sleep: Some(Duration::from_secs(600)),
63            idle_reap: None,
64            backend: BackendKind::Auto,
65            zfs_dataset: None,
66        }
67    }
68}
69
70/// Where a setting's value came from. In JSON: `"source": "default" | "file" | "env"`, plus
71/// `"env_var"` for `env`, so `source` is always a string.
72#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
73#[serde(tag = "source", content = "env_var", rename_all = "snake_case")]
74pub enum ConfigSource {
75    Default,
76    File,
77    /// The name of the environment variable.
78    Env(String),
79}
80
81impl ConfigSource {
82    /// `default`, `config.toml` or `env MITOSIS_X`.
83    pub fn label(&self) -> String {
84        match self {
85            ConfigSource::Default => "default".into(),
86            ConfigSource::File => "config.toml".into(),
87            ConfigSource::Env(v) => format!("env {v}"),
88        }
89    }
90}
91
92#[derive(Clone, Debug, Serialize)]
93pub struct ConfigEntry {
94    pub key: String,
95    pub value: String,
96    #[serde(flatten)]
97    pub source: ConfigSource,
98}
99
100struct KeySpec {
101    key: &'static str,
102    env: &'static str,
103    /// Completes "<where> must be ...".
104    want: &'static str,
105    example: &'static str,
106}
107
108const KEYS: [KeySpec; 13] = [
109    KeySpec {
110        key: "max_clones",
111        env: "MITOSIS_MAX_CLONES",
112        want: "a whole number of at least 1",
113        example: "8",
114    },
115    KeySpec {
116        key: "min_free_disk",
117        env: "MITOSIS_MIN_FREE_DISK",
118        want: "a size like 2GB, or a number of bytes",
119        example: "2GB",
120    },
121    KeySpec {
122        key: "ram_budget",
123        env: "MITOSIS_RAM_BUDGET",
124        want: "a size like 4GB (more than 0), or off",
125        example: "4GB",
126    },
127    KeySpec {
128        key: "disk_budget",
129        env: "MITOSIS_DISK_BUDGET",
130        want: "a size like 50GB (more than 0), or off",
131        example: "50GB",
132    },
133    KeySpec {
134        key: "queue_wait",
135        env: "MITOSIS_QUEUE_WAIT",
136        want: "a duration like 60s or 2m, or 0 for no wait",
137        example: "60s",
138    },
139    KeySpec {
140        key: "clone_disk_warn",
141        env: "MITOSIS_CLONE_DISK_WARN",
142        want: "a size like 5GB, or a number of bytes",
143        example: "5GB",
144    },
145    KeySpec {
146        key: "router",
147        env: "MITOSIS_ROUTER",
148        want: "on or off",
149        example: "on",
150    },
151    KeySpec {
152        key: "router_port",
153        env: "MITOSIS_ROUTER_PORT",
154        want: "a port number from 1 to 65535, outside the clone ports 54320-54999",
155        example: "54300",
156    },
157    KeySpec {
158        key: "router_listen",
159        env: "MITOSIS_ROUTER_LISTEN",
160        want: "127.0.0.1, 0.0.0.0 or ::",
161        example: "127.0.0.1",
162    },
163    KeySpec {
164        key: "idle_sleep",
165        env: "MITOSIS_IDLE_SLEEP",
166        want: "a duration like 10m or 1h, or off",
167        example: "10m",
168    },
169    KeySpec {
170        key: "idle_reap",
171        env: "MITOSIS_IDLE_REAP",
172        want: "a duration like 2h or 1d, or off",
173        example: "1d",
174    },
175    KeySpec {
176        key: "backend",
177        env: "MITOSIS_BACKEND",
178        want: "auto, apfs, reflink, zfs or copy",
179        example: "auto",
180    },
181    KeySpec {
182        key: "zfs_dataset",
183        env: "MITOSIS_ZFS_DATASET",
184        want: "a ZFS dataset name like tank/mitosis, or empty for none",
185        example: "tank/mitosis",
186    },
187];
188
189fn invalid(msg: impl Into<String>) -> MitosisError {
190    MitosisError::new(ErrorCode::InvalidRequest, msg)
191}
192
193/// Every setting as `(config key, environment variable)`, in `mitosis config` order.
194pub fn keys() -> impl Iterator<Item = (&'static str, &'static str)> {
195    KEYS.iter().map(|k| (k.key, k.env))
196}
197
198fn key_list() -> String {
199    KEYS.iter().map(|k| k.key).collect::<Vec<_>>().join(", ")
200}
201
202fn spec(key: &str) -> Result<&'static KeySpec> {
203    KEYS.iter().find(|k| k.key == key).ok_or_else(|| {
204        invalid(format!("unknown setting {key:?}"))
205            .with_hint(format!("settings are: {}", key_list()))
206    })
207}
208
209/// Plain bytes, or a whole number with a unit: `KB|MB|GB|TB` (powers of 1000) or
210/// `KiB|MiB|GiB|TiB` (powers of 1024), any case, no space between number and unit.
211pub fn parse_size(s: &str) -> Result<u64> {
212    let bad = || {
213        invalid(format!("invalid size {s:?}"))
214            .with_hint("use a whole number of bytes or a number plus a unit, like 500MB or 4GiB")
215    };
216    let split = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
217    let (digits, unit) = s.split_at(split);
218    if digits.is_empty() {
219        return Err(bad());
220    }
221    let mult: u64 = match unit.to_ascii_lowercase().as_str() {
222        "" => 1,
223        "kb" => 1_000,
224        "mb" => 1_000_000,
225        "gb" => 1_000_000_000,
226        "tb" => 1_000_000_000_000,
227        "kib" => 1 << 10,
228        "mib" => 1 << 20,
229        "gib" => 1 << 30,
230        "tib" => 1 << 40,
231        _ => return Err(bad()),
232    };
233    digits
234        .parse::<u64>()
235        .ok()
236        .and_then(|n| n.checked_mul(mult))
237        .ok_or_else(bad)
238}
239
240/// The largest decimal unit that divides `n` exactly, so `parse_size(format_size(n)) == n`.
241fn format_size(n: u64) -> String {
242    for (unit, name) in [
243        (1_000_000_000_000, "TB"),
244        (1_000_000_000, "GB"),
245        (1_000_000, "MB"),
246        (1_000, "KB"),
247    ] {
248        if n != 0 && n.is_multiple_of(unit) {
249            return format!("{}{name}", n / unit);
250        }
251    }
252    n.to_string()
253}
254
255/// One unit, the largest that divides the duration exactly (`90s`, not `1m30s`), so that
256/// `parse_wait(format_setting_duration(d)) == d` and the shown value can be pasted into
257/// `config set`. `0s` for zero.
258fn format_setting_duration(d: Duration) -> String {
259    let secs = d.as_secs();
260    for (unit, size) in [('d', 86_400), ('h', 3600), ('m', 60)] {
261        if secs != 0 && secs.is_multiple_of(size) {
262            return format!("{}{unit}", secs / size);
263        }
264    }
265    format!("{secs}s")
266}
267
268fn parse_budget(v: &str) -> Option<Option<u64>> {
269    if v.eq_ignore_ascii_case("off") {
270        Some(None)
271    } else {
272        // A zero budget would refuse every clone; `off` is the way to say no limit.
273        parse_size(v).ok().filter(|n| *n > 0).map(Some)
274    }
275}
276
277fn parse_switch(v: &str) -> Option<bool> {
278    match v.to_ascii_lowercase().as_str() {
279        "on" | "true" => Some(true),
280        "off" | "false" => Some(false),
281        _ => None,
282    }
283}
284
285fn parse_idle(v: &str) -> Option<Option<Duration>> {
286    if v.eq_ignore_ascii_case("off") {
287        Some(None)
288    } else {
289        parse_duration(v).ok().map(Some)
290    }
291}
292
293/// `127.0.0.1` or every interface only: `url` always names `127.0.0.1`, which any other single
294/// address (including `::1`) would not answer on. On macOS `::` is dual-stack, so it answers too.
295fn parse_listen(v: &str) -> Option<IpAddr> {
296    let ip: IpAddr = v.parse().ok()?;
297    let ok = match ip {
298        IpAddr::V4(a) => a == Ipv4Addr::LOCALHOST || a.is_unspecified(),
299        IpAddr::V6(a) => a.is_unspecified(),
300    };
301    ok.then_some(ip)
302}
303
304fn parse_wait(v: &str) -> Option<Duration> {
305    crate::names::parse_wait(v).ok()
306}
307
308impl Config {
309    /// The defaults, then `<home>/config.toml`, then the `MITOSIS_*` environment variables.
310    pub fn load(home: &Home) -> Result<Config> {
311        Config::default()
312            .layered(home, &|k| std::env::var(k).ok())
313            .map(|(c, _)| c)
314    }
315
316    /// Like `load`, but keeps the settings nobody can set from file or environment.
317    pub fn reloaded(&self, home: &Home) -> Result<Config> {
318        let base = Config {
319            port_range: self.port_range.clone(),
320            default_max_age_secs: self.default_max_age_secs,
321            ready_timeout: self.ready_timeout,
322            stop_timeout: self.stop_timeout,
323            scheduler_tick: self.scheduler_tick,
324            ..Config::default()
325        };
326        // The router binds once at start, so its settings change on a daemon restart only.
327        base.layered(home, &|k| std::env::var(k).ok())
328            .map(|(c, _)| Config {
329                router: self.router,
330                router_port: self.router_port,
331                router_listen: self.router_listen,
332                // The back end is chosen once, when the daemon opens its context.
333                backend: self.backend,
334                zfs_dataset: self.zfs_dataset.clone(),
335                ..c
336            })
337    }
338
339    /// One entry per setting with its current value and where it came from.
340    pub fn describe(home: &Home) -> Result<Vec<ConfigEntry>> {
341        let (cfg, sources) = Config::default().layered(home, &|k| std::env::var(k).ok())?;
342        Ok(KEYS
343            .iter()
344            .zip(sources)
345            .map(|(k, source)| ConfigEntry {
346                key: k.key.into(),
347                value: cfg.show(k.key),
348                source,
349            })
350            .collect())
351    }
352
353    /// The environment variable that overrides `key` and the value in effect, if one is set.
354    pub fn env_override(home: &Home, key: &str) -> Result<Option<(String, String)>> {
355        Config::env_override_with(home, key, &|k| std::env::var(k).ok())
356    }
357
358    fn env_override_with(
359        home: &Home,
360        key: &str,
361        var: &dyn Fn(&str) -> Option<String>,
362    ) -> Result<Option<(String, String)>> {
363        let (cfg, sources) = Config::default().layered(home, var)?;
364        Ok(KEYS
365            .iter()
366            .zip(sources)
367            .find(|(k, _)| k.key == key)
368            .and_then(|(k, source)| match source {
369                ConfigSource::Env(v) => Some((v, cfg.show(k.key))),
370                _ => None,
371            }))
372    }
373
374    /// Validates `value`, then stores it in `<home>/config.toml`, keeping the other keys.
375    pub fn set_in_file(home: &Home, key: &str, value: &str) -> Result<()> {
376        let value = value.trim();
377        let s = spec(key)?;
378        Config::default()
379            .set_field(key, value)
380            .ok_or_else(|| bad_value(&format!("setting {key}"), s, value, false))?;
381        home.ensure_dirs()?;
382        let mut table = read_table(home)?;
383        let stored = match key {
384            "max_clones" | "router_port" => value.parse::<i64>().map(toml::Value::Integer).ok(),
385            _ => None,
386        };
387        table.insert(
388            key.into(),
389            stored.unwrap_or_else(|| toml::Value::String(value.into())),
390        );
391        let text = toml::to_string(&table)
392            .map_err(|e| MitosisError::new(ErrorCode::Internal, format!("encode config: {e}")))?;
393        // A temp file and a rename, so a reader never sees a half-written file.
394        // Named by pid, so two processes writing at once never share a half-written file.
395        let tmp = home
396            .root
397            .join(format!("config.toml.tmp-{}", std::process::id()));
398        let mut f = std::fs::OpenOptions::new()
399            .write(true)
400            .create(true)
401            .truncate(true)
402            .mode(0o600)
403            .open(&tmp)?;
404        f.write_all(text.as_bytes())?;
405        f.sync_all()?;
406        if let Err(e) = std::fs::rename(&tmp, home.config_file()) {
407            let _ = std::fs::remove_file(&tmp);
408            return Err(e.into());
409        }
410        Ok(())
411    }
412
413    /// Applies one setting from its text form; `None` when the value is not valid.
414    fn set_field(&mut self, key: &str, v: &str) -> Option<()> {
415        match key {
416            "max_clones" => self.max_clones = v.parse::<usize>().ok().filter(|n| *n >= 1)?,
417            "min_free_disk" => self.min_free_bytes = parse_size(v).ok()?,
418            "ram_budget" => self.ram_budget = parse_budget(v)?,
419            "disk_budget" => self.disk_budget = parse_budget(v)?,
420            "queue_wait" => self.queue_wait = parse_wait(v)?,
421            "clone_disk_warn" => self.clone_disk_warn = parse_size(v).ok()?,
422            "router" => self.router = parse_switch(v)?,
423            "router_port" => {
424                // Inside `port_range` a clone could take it while the router waits to bind.
425                self.router_port = v
426                    .parse::<u16>()
427                    .ok()
428                    .filter(|p| *p >= 1 && !self.port_range.contains(p))?
429            }
430            "router_listen" => self.router_listen = parse_listen(v)?,
431            "idle_sleep" => self.idle_sleep = parse_idle(v)?,
432            "idle_reap" => self.idle_reap = parse_idle(v)?,
433            "backend" => self.backend = v.parse().ok()?,
434            "zfs_dataset" => {
435                if v.contains(char::is_whitespace) {
436                    return None;
437                }
438                self.zfs_dataset = (!v.is_empty()).then(|| v.to_string());
439            }
440            _ => return None,
441        }
442        Some(())
443    }
444
445    fn show(&self, key: &str) -> String {
446        let budget = |b: Option<u64>| b.map_or("off".to_string(), format_size);
447        match key {
448            "max_clones" => self.max_clones.to_string(),
449            "min_free_disk" => format_size(self.min_free_bytes),
450            "ram_budget" => budget(self.ram_budget),
451            "disk_budget" => budget(self.disk_budget),
452            "queue_wait" => format_setting_duration(self.queue_wait),
453            "clone_disk_warn" => format_size(self.clone_disk_warn),
454            "router" => if self.router { "on" } else { "off" }.to_string(),
455            "router_port" => self.router_port.to_string(),
456            "router_listen" => self.router_listen.to_string(),
457            "idle_sleep" => self
458                .idle_sleep
459                .map_or("off".into(), format_setting_duration),
460            "idle_reap" => self.idle_reap.map_or("off".into(), format_setting_duration),
461            "backend" => self.backend.as_str().to_string(),
462            "zfs_dataset" => self.zfs_dataset.clone().unwrap_or_default(),
463            _ => String::new(),
464        }
465    }
466
467    /// `self`, then the file, then the environment; also says which layer set each key.
468    fn layered(
469        mut self,
470        home: &Home,
471        var: &dyn Fn(&str) -> Option<String>,
472    ) -> Result<(Config, Vec<ConfigSource>)> {
473        let mut sources = vec![ConfigSource::Default; KEYS.len()];
474        for (key, value) in &read_table(home)? {
475            let Some(i) = KEYS.iter().position(|k| k.key == key) else {
476                return Err(invalid(format!("config.toml has an unknown key {key:?}"))
477                    .with_hint(format!("settings are: {}", key_list())));
478            };
479            let origin = format!("config.toml key {key}");
480            let text = match value {
481                toml::Value::Integer(n) => n.to_string(),
482                toml::Value::String(s) => s.clone(),
483                toml::Value::Boolean(b) => b.to_string(),
484                other => {
485                    return Err(invalid(format!(
486                        "{origin} must be {}, got a {}",
487                        KEYS[i].want,
488                        other.type_str()
489                    ))
490                    .with_hint(file_hint(&KEYS[i])))
491                }
492            };
493            self.set_field(key, text.trim())
494                .ok_or_else(|| bad_value(&origin, &KEYS[i], &text, true))?;
495            sources[i] = ConfigSource::File;
496        }
497        for (i, k) in KEYS.iter().enumerate() {
498            if let Some(v) = var(k.env) {
499                self.set_field(k.key, v.trim())
500                    .ok_or_else(|| bad_value(k.env, k, &v, false))?;
501                sources[i] = ConfigSource::Env(k.env.into());
502            }
503        }
504        Ok((self, sources))
505    }
506}
507
508fn file_hint(s: &KeySpec) -> String {
509    format!("fix it with: mitosis config set {} {}", s.key, s.example)
510}
511
512fn bad_value(origin: &str, s: &KeySpec, value: &str, from_file: bool) -> MitosisError {
513    let hint = if from_file {
514        file_hint(s)
515    } else if origin == s.env {
516        format!("fix or unset {origin}, e.g. {}={}", s.env, s.example)
517    } else {
518        format!("for example: mitosis config set {} {}", s.key, s.example)
519    };
520    invalid(format!("{origin} must be {}, got {value:?}", s.want)).with_hint(hint)
521}
522
523/// The parsed file; empty when there is none.
524fn read_table(home: &Home) -> Result<toml::Table> {
525    let path = home.config_file();
526    let text = match std::fs::read_to_string(&path) {
527        Ok(t) => t,
528        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(toml::Table::new()),
529        Err(e) => {
530            return Err(MitosisError::new(
531                ErrorCode::Internal,
532                format!("read {}: {e}", path.display()),
533            ))
534        }
535    };
536    toml::from_str(&text).map_err(|e| {
537        invalid(format!("{} is not valid TOML: {e}", path.display()))
538            .with_hint("fix the file, or delete it to go back to the defaults")
539    })
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    fn home() -> (tempfile::TempDir, Home) {
547        let tmp = tempfile::tempdir().unwrap();
548        let home = Home::at(tmp.path().to_path_buf());
549        (tmp, home)
550    }
551
552    fn no_env(_: &str) -> Option<String> {
553        None
554    }
555
556    fn entry(
557        home: &Home,
558        env: &dyn Fn(&str) -> Option<String>,
559        key: &str,
560    ) -> (String, ConfigSource) {
561        let (cfg, sources) = Config::default().layered(home, env).unwrap();
562        let i = KEYS.iter().position(|k| k.key == key).unwrap();
563        (cfg.show(key), sources[i].clone())
564    }
565
566    #[test]
567    fn defaults() {
568        let c = Config::default();
569        assert_eq!(c.max_clones, 8);
570        assert_eq!(c.min_free_bytes, 2_000_000_000);
571        assert_eq!((c.ram_budget, c.disk_budget), (None, None));
572        assert_eq!(c.queue_wait, Duration::from_secs(60));
573        assert_eq!(c.clone_disk_warn, 5_000_000_000);
574        assert_eq!(c.port_range, 54320..=54999);
575        assert_eq!(c.default_max_age_secs, 604_800);
576        assert_eq!(c.ready_timeout, Duration::from_secs(10));
577        assert_eq!(c.stop_timeout, Duration::from_secs(10));
578        assert_eq!(c.scheduler_tick, Duration::from_secs(60));
579        assert!(c.router);
580        assert_eq!(c.router_port, 54300);
581        assert_eq!(c.router_listen.to_string(), "127.0.0.1");
582        assert_eq!(c.idle_sleep, Some(Duration::from_secs(600)));
583        assert_eq!(c.idle_reap, None);
584    }
585
586    #[test]
587    fn backend_key_parses_all_values_and_rejects_others() {
588        let (_tmp, home) = home();
589        for v in ["auto", "apfs", "reflink", "zfs", "copy"] {
590            let env = |k: &str| (k == "MITOSIS_BACKEND").then(|| v.to_string());
591            assert_eq!(entry(&home, &env, "backend").0, v);
592        }
593        let env = |k: &str| (k == "MITOSIS_BACKEND").then(|| "btrfs".to_string());
594        let e = Config::default().layered(&home, &env).unwrap_err();
595        assert_eq!(e.code, ErrorCode::InvalidRequest);
596        assert!(e.message.starts_with("MITOSIS_BACKEND"), "{e}");
597        assert!(
598            e.message.contains("auto, apfs, reflink, zfs or copy"),
599            "{e}"
600        );
601        assert!(Config::set_in_file(&home, "backend", "btrfs").is_err());
602        Config::set_in_file(&home, "backend", "copy").unwrap();
603        let (c, _) = Config::default().layered(&home, &no_env).unwrap();
604        assert_eq!(c.backend, BackendKind::Copy);
605    }
606
607    #[test]
608    fn zfs_dataset_defaults_empty() {
609        let (_tmp, home) = home();
610        assert_eq!(Config::default().zfs_dataset, None);
611        assert_eq!(entry(&home, &no_env, "zfs_dataset").0, "");
612        Config::set_in_file(&home, "zfs_dataset", "tank/mitosis").unwrap();
613        let (c, _) = Config::default().layered(&home, &no_env).unwrap();
614        assert_eq!(c.zfs_dataset.as_deref(), Some("tank/mitosis"));
615        assert!(Config::set_in_file(&home, "zfs_dataset", "tank mitosis").is_err());
616        Config::set_in_file(&home, "zfs_dataset", "").unwrap();
617        let (c, _) = Config::default().layered(&home, &no_env).unwrap();
618        assert_eq!(c.zfs_dataset, None);
619    }
620
621    #[test]
622    fn idle_keys() {
623        let (_tmp, home) = home();
624        std::fs::write(
625            home.config_file(),
626            "idle_sleep = \"off\"\nidle_reap = \"2h\"\n",
627        )
628        .unwrap();
629        let (c, _) = Config::default().layered(&home, &no_env).unwrap();
630        assert_eq!(
631            (c.idle_sleep, c.idle_reap),
632            (None, Some(Duration::from_secs(7200)))
633        );
634        assert_eq!(entry(&home, &no_env, "idle_sleep").0, "off");
635        assert_eq!(entry(&home, &no_env, "idle_reap").0, "2h");
636        let env = |k: &str| (k == "MITOSIS_IDLE_SLEEP").then(|| "0s".to_string());
637        assert!(Config::default().layered(&home, &env).is_err());
638        assert!(Config::set_in_file(&home, "idle_sleep", "soon").is_err());
639        Config::set_in_file(&home, "idle_sleep", "5m").unwrap();
640        let (c, _) = Config::default().layered(&home, &no_env).unwrap();
641        assert_eq!(c.idle_sleep, Some(Duration::from_secs(300)));
642    }
643
644    #[test]
645    fn router_keys() {
646        let (_tmp, home) = home();
647        std::fs::write(
648            home.config_file(),
649            "router = false\nrouter_port = 6000\nrouter_listen = \"0.0.0.0\"\n",
650        )
651        .unwrap();
652        let (c, _) = Config::default().layered(&home, &no_env).unwrap();
653        assert_eq!((c.router, c.router_port), (false, 6000));
654        assert_eq!(c.router_listen.to_string(), "0.0.0.0");
655        let env = |k: &str| (k == "MITOSIS_ROUTER").then(|| "on".to_string());
656        assert_eq!(entry(&home, &env, "router").0, "on");
657        for (k, v) in [
658            ("MITOSIS_ROUTER", "maybe"),
659            ("MITOSIS_ROUTER_PORT", "0"),
660            ("MITOSIS_ROUTER_PORT", "70000"),
661            ("MITOSIS_ROUTER_LISTEN", "localhost"),
662            ("MITOSIS_ROUTER_LISTEN", "192.168.1.5"),
663            ("MITOSIS_ROUTER_LISTEN", "127.0.0.2"),
664            ("MITOSIS_ROUTER_LISTEN", "fe80::1"),
665            ("MITOSIS_ROUTER_LISTEN", "::1"),
666            ("MITOSIS_ROUTER_PORT", "54320"),
667            ("MITOSIS_ROUTER_PORT", "54999"),
668        ] {
669            let env = |n: &str| (n == k).then(|| v.to_string());
670            let e = Config::default().layered(&home, &env).unwrap_err();
671            assert!(e.message.starts_with(k), "{e}");
672        }
673        for ok in ["127.0.0.1", "0.0.0.0", "::"] {
674            let env = |n: &str| (n == "MITOSIS_ROUTER_LISTEN").then(|| ok.to_string());
675            let (c, _) = Config::default().layered(&home, &env).unwrap();
676            assert_eq!(c.router_listen.to_string(), ok);
677        }
678        assert!(Config::set_in_file(&home, "router_port", "54400").is_err());
679        assert!(Config::set_in_file(&home, "router_listen", "10.0.0.1").is_err());
680        Config::set_in_file(&home, "router_port", "6001").unwrap();
681        Config::set_in_file(&home, "router", "off").unwrap();
682        let (c, _) = Config::default().layered(&home, &no_env).unwrap();
683        assert_eq!((c.router, c.router_port), (false, 6001));
684    }
685
686    #[test]
687    fn parse_size_units() {
688        assert_eq!(parse_size("500MB").unwrap(), 500_000_000);
689        assert_eq!(parse_size("4GiB").unwrap(), 4 * 1024 * 1024 * 1024);
690        assert_eq!(parse_size("123").unwrap(), 123);
691        assert_eq!(parse_size("2gb").unwrap(), 2_000_000_000);
692        assert_eq!(parse_size("1TiB").unwrap(), 1 << 40);
693        for bad in [
694            "4 gb",
695            "-1",
696            "GB",
697            "",
698            "1.5GB",
699            "5XB",
700            "99999999999999999999",
701        ] {
702            assert!(parse_size(bad).is_err(), "{bad:?}");
703        }
704        assert_eq!(
705            parse_size(&format_size(2_000_000_000)).unwrap(),
706            2_000_000_000
707        );
708    }
709
710    #[test]
711    fn config_file_then_env_precedence() {
712        let (_tmp, home) = home();
713        std::fs::write(home.config_file(), "max_clones = 3\n").unwrap();
714        let env = |k: &str| (k == "MITOSIS_MAX_CLONES").then(|| "5".to_string());
715        assert_eq!(
716            entry(&home, &env, "max_clones"),
717            ("5".into(), ConfigSource::Env("MITOSIS_MAX_CLONES".into()))
718        );
719        assert_eq!(
720            entry(&home, &no_env, "max_clones"),
721            ("3".into(), ConfigSource::File)
722        );
723        assert_eq!(
724            entry(&home, &no_env, "ram_budget"),
725            ("off".into(), ConfigSource::Default)
726        );
727    }
728
729    #[test]
730    fn config_file_errors_name_the_key() {
731        let (_tmp, home) = home();
732        let err = |text: &str| {
733            std::fs::write(home.config_file(), text).unwrap();
734            Config::default().layered(&home, &no_env).unwrap_err()
735        };
736        let e = err("ram_budget = \"lots\"\n");
737        assert_eq!(e.code, ErrorCode::InvalidRequest);
738        assert!(
739            e.message.contains("ram_budget") && e.message.contains("config.toml"),
740            "{e}"
741        );
742        assert!(e.hint.as_deref().unwrap().contains("4GB"), "{e}");
743        let e = err("colour = 1\n");
744        assert!(
745            e.message.contains("colour") && e.message.contains("config.toml"),
746            "{e}"
747        );
748        let e = err("max_clones = [\n");
749        assert!(e.message.contains("config.toml"), "{e}");
750        let e = err("max_clones = true\n");
751        assert!(e.message.contains("max_clones"), "{e}");
752        let e = err("max_clones = 0\n");
753        assert!(e.message.contains("max_clones"), "{e}");
754    }
755
756    #[test]
757    fn env_errors_name_the_variable() {
758        let (_tmp, home) = home();
759        for (k, v) in [
760            ("MITOSIS_MAX_CLONES", "0"),
761            ("MITOSIS_MAX_CLONES", "lots"),
762            ("MITOSIS_MIN_FREE_DISK", ""),
763            ("MITOSIS_RAM_BUDGET", "4 GB"),
764            ("MITOSIS_QUEUE_WAIT", "soon"),
765        ] {
766            let env = |n: &str| (n == k).then(|| v.to_string());
767            let e = Config::default().layered(&home, &env).unwrap_err();
768            assert_eq!(e.code, ErrorCode::InvalidRequest);
769            assert!(e.message.starts_with(k), "{e}");
770        }
771    }
772
773    #[test]
774    fn budgets_and_wait() {
775        let (_tmp, home) = home();
776        std::fs::write(
777            home.config_file(),
778            "ram_budget = \"2GB\"\ndisk_budget = \"off\"\nqueue_wait = \"0s\"\nmin_free_disk = 500000000\n",
779        )
780        .unwrap();
781        let (c, _) = Config::default().layered(&home, &no_env).unwrap();
782        assert_eq!(c.ram_budget, Some(2_000_000_000));
783        assert_eq!(c.disk_budget, None);
784        assert_eq!(c.queue_wait, Duration::ZERO);
785        assert_eq!(c.min_free_bytes, 500_000_000);
786        let env = |k: &str| (k == "MITOSIS_QUEUE_WAIT").then(|| "2m".to_string());
787        let (c, _) = Config::default().layered(&home, &env).unwrap();
788        assert_eq!(c.queue_wait, Duration::from_secs(120));
789    }
790
791    #[test]
792    fn zero_budgets_are_rejected() {
793        let (_tmp, home) = home();
794        for key in ["ram_budget", "disk_budget"] {
795            std::fs::write(home.config_file(), format!("{key} = \"0\"\n")).unwrap();
796            let e = Config::default().layered(&home, &no_env).unwrap_err();
797            assert_eq!(e.code, ErrorCode::InvalidRequest);
798            assert!(e.message.contains(key), "{}", e.message);
799            assert!(Config::set_in_file(&home, key, "0").is_err());
800            let env =
801                |k: &str| (k == format!("MITOSIS_{}", key.to_uppercase())).then(|| "0".to_string());
802            std::fs::remove_file(home.config_file()).unwrap();
803            assert!(Config::default().layered(&home, &env).is_err());
804        }
805    }
806
807    #[test]
808    fn set_in_file_keeps_other_keys() {
809        let (_tmp, home) = home();
810        Config::set_in_file(&home, "max_clones", "4").unwrap();
811        Config::set_in_file(&home, "ram_budget", "2GB").unwrap();
812        let table: toml::Table =
813            toml::from_str(&std::fs::read_to_string(home.config_file()).unwrap()).unwrap();
814        assert_eq!(table["max_clones"].as_integer(), Some(4));
815        assert_eq!(table["ram_budget"].as_str(), Some("2GB"));
816        let (c, _) = Config::default().layered(&home, &no_env).unwrap();
817        assert_eq!((c.max_clones, c.ram_budget), (4, Some(2_000_000_000)));
818        // Invalid values and unknown keys leave the file alone.
819        assert!(Config::set_in_file(&home, "ram_budget", "lots").is_err());
820        assert!(Config::set_in_file(&home, "colour", "red").is_err());
821        let again: toml::Table =
822            toml::from_str(&std::fs::read_to_string(home.config_file()).unwrap()).unwrap();
823        assert_eq!(table, again);
824    }
825
826    #[test]
827    fn reload_keeps_fixed_settings() {
828        let (_tmp, home) = home();
829        let base = Config {
830            port_range: 60000..=60019,
831            scheduler_tick: Duration::from_millis(50),
832            max_clones: 1,
833            ..Config::default()
834        };
835        // Env may be set in the developer's shell; only the fixed settings are asserted on.
836        if let Ok(c) = base.reloaded(&home) {
837            assert_eq!(c.port_range, 60000..=60019);
838            assert_eq!(c.scheduler_tick, Duration::from_millis(50));
839        }
840    }
841
842    #[test]
843    fn shown_durations_parse_back() {
844        for secs in [0, 1, 45, 60, 90, 3600, 5400, 7200, 86_400, 90_000] {
845            let d = Duration::from_secs(secs);
846            let shown = format_setting_duration(d);
847            assert_eq!(parse_wait(&shown), Some(d), "{secs}s shown as {shown}");
848        }
849        assert_eq!(format_setting_duration(Duration::from_secs(90)), "90s");
850        assert_eq!(format_setting_duration(Duration::from_secs(7200)), "2h");
851    }
852
853    #[test]
854    fn set_in_file_leaves_no_temp_file() {
855        let (_tmp, home) = home();
856        Config::set_in_file(&home, "max_clones", "4").unwrap();
857        Config::set_in_file(&home, "router", "off").unwrap();
858        let names: Vec<String> = std::fs::read_dir(&home.root)
859            .unwrap()
860            .flatten()
861            .map(|e| e.file_name().to_string_lossy().into_owned())
862            .collect();
863        assert!(names.iter().all(|n| !n.contains(".tmp")), "{names:?}");
864        assert!(names.contains(&"config.toml".to_string()));
865    }
866
867    #[test]
868    fn env_override_names_the_variable_in_effect() {
869        let (_tmp, home) = home();
870        Config::set_in_file(&home, "max_clones", "4").unwrap();
871        assert_eq!(
872            Config::env_override_with(&home, "max_clones", &no_env).unwrap(),
873            None
874        );
875        let env = |k: &str| (k == "MITOSIS_MAX_CLONES").then(|| "9".to_string());
876        assert_eq!(
877            Config::env_override_with(&home, "max_clones", &env).unwrap(),
878            Some(("MITOSIS_MAX_CLONES".to_string(), "9".to_string()))
879        );
880        assert_eq!(
881            Config::env_override_with(&home, "router", &env).unwrap(),
882            None
883        );
884    }
885}