Skip to main content

asc_engine/
config.rs

1//! The user's configuration of threads, memory and the disk cache: one
2//! table of settings ([`Config::KEYS`]), each taken from (first wins) a
3//! command-line flag, its environment variable, the config file
4//! ([`Config::file_path`]), or its default. `auto` defaults follow the
5//! machine ([`Device`]) and the user's other busy processes.
6//!
7//! Values are text in every layer: sizes are bytes with an optional
8//! `K`/`M`/`G`/`T` suffix (binary; a bare number is MiB), shares are
9//! percentages (`25%`), switches are `on`/`off`, durations take
10//! `ms`/`s`/`m`/`h` (a bare number is the key's unit).
11
12use crate::{CacheConfig, Device, MemoryPolicy, Threads};
13use asc_cache::{AutoDisk, DEFAULT_MIN_DEX_BYTES, DiskCache, DiskCapacity, Tier};
14use std::fmt;
15use std::path::{Path, PathBuf};
16use std::time::Duration;
17
18/// Environment variable naming the config file; empty: none.
19pub const CONFIG_ENV: &str = "ASC_CONFIG";
20
21/// Where a setting's value came from.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum Source {
24    Default,
25    File,
26    Env,
27    Flag,
28}
29
30impl Source {
31    pub fn name(self) -> &'static str {
32        match self {
33            Source::Default => "default",
34            Source::File => "file",
35            Source::Env => "env",
36            Source::Flag => "flag",
37        }
38    }
39}
40
41/// A count of threads: `auto` or a positive number.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Count {
44    Auto,
45    Fixed(usize),
46}
47
48/// A budget: `auto`, a size, or a percentage of what the key names.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Amount {
51    Auto,
52    Bytes(u64),
53    Percent(u8),
54}
55
56/// A size in bytes.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct Bytes(pub u64);
59
60/// The disk cache directory: the platform default or a path.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum CacheDir {
63    Default,
64    Path(PathBuf),
65}
66
67/// The disk cache tiers in use.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct Tiers(pub [bool; 3]);
70
71/// A duration whose bare numbers are seconds.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct Seconds(pub Duration);
74
75/// A duration whose bare numbers are milliseconds.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct Millis(pub Duration);
78
79/// A setting's value type: parsed from and shown as the same text.
80pub trait Value: Sized + fmt::Display {
81    /// What the text may be, for error messages.
82    const EXPECTED: &'static str;
83    fn parse(text: &str) -> Option<Self>;
84    /// Computed from the machine rather than given.
85    fn is_auto(&self) -> bool {
86        false
87    }
88}
89
90const SIZE_UNITS: [(&str, u32); 5] = [("", 20), ("k", 10), ("m", 20), ("g", 30), ("t", 40)];
91
92/// Bytes from `512`, `512M`, `4G`, `4GiB`, `1024k` (a bare number is MiB).
93fn parse_size(text: &str) -> Option<u64> {
94    let t = text.trim().to_ascii_lowercase();
95    let t = t.strip_suffix("ib").or_else(|| t.strip_suffix('b')).unwrap_or(&t);
96    let split = t.find(|c: char| !c.is_ascii_digit()).unwrap_or(t.len());
97    let (digits, unit) = t.split_at(split);
98    let shift = SIZE_UNITS.iter().find(|(u, _)| *u == unit.trim())?.1;
99    digits.parse::<u64>().ok()?.checked_mul(1 << shift)
100}
101
102fn show_size(bytes: u64) -> String {
103    match SIZE_UNITS[1..].iter().rev().find(|(_, s)| bytes >= 1 << s && bytes.is_multiple_of(1 << s)) {
104        Some((u, s)) => format!("{}{}", bytes >> s, u.to_ascii_uppercase()),
105        None => format!("{bytes}B"),
106    }
107}
108
109fn parse_duration(text: &str, bare: Duration) -> Option<Duration> {
110    let t = text.trim();
111    let split = t.find(|c: char| !c.is_ascii_digit()).unwrap_or(t.len());
112    let (n, unit) = t.split_at(split);
113    let n: u32 = n.parse().ok()?;
114    let unit = match unit.trim() {
115        "" => bare,
116        "ms" => Duration::from_millis(1),
117        "s" => Duration::from_secs(1),
118        "m" | "min" => Duration::from_secs(60),
119        "h" => Duration::from_secs(3600),
120        _ => return None,
121    };
122    unit.checked_mul(n)
123}
124
125fn show_duration(d: Duration) -> String {
126    let ms = d.as_millis();
127    match () {
128        _ if ms > 0 && ms.is_multiple_of(3_600_000) => format!("{}h", ms / 3_600_000),
129        _ if ms > 0 && ms.is_multiple_of(60_000) => format!("{}m", ms / 60_000),
130        _ if ms.is_multiple_of(1000) => format!("{}s", ms / 1000),
131        _ => format!("{ms}ms"),
132    }
133}
134
135impl fmt::Display for Count {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        match self {
138            Count::Auto => f.write_str("auto"),
139            Count::Fixed(n) => write!(f, "{n}"),
140        }
141    }
142}
143
144impl Value for Count {
145    const EXPECTED: &'static str = "auto or a number (0 counts as 1)";
146    fn parse(text: &str) -> Option<Count> {
147        match text.trim() {
148            "auto" => Some(Count::Auto),
149            n => n.parse().ok().map(Count::Fixed),
150        }
151    }
152    fn is_auto(&self) -> bool {
153        *self == Count::Auto
154    }
155}
156
157impl fmt::Display for Amount {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        match self {
160            Amount::Auto => f.write_str("auto"),
161            Amount::Bytes(b) => f.write_str(&show_size(*b)),
162            Amount::Percent(p) => write!(f, "{p}%"),
163        }
164    }
165}
166
167impl Value for Amount {
168    const EXPECTED: &'static str = "auto, a size (512M, 4G; a bare number is MiB) or a percentage (25%)";
169    fn parse(text: &str) -> Option<Amount> {
170        let t = text.trim();
171        if t == "auto" {
172            return Some(Amount::Auto);
173        }
174        match t.strip_suffix('%') {
175            Some(p) => p.trim().parse().ok().filter(|p| (1..=100).contains(p)).map(Amount::Percent),
176            None => parse_size(t).map(Amount::Bytes),
177        }
178    }
179    fn is_auto(&self) -> bool {
180        *self == Amount::Auto
181    }
182}
183
184impl fmt::Display for Bytes {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        f.write_str(&show_size(self.0))
187    }
188}
189
190impl Value for Bytes {
191    const EXPECTED: &'static str = "a size (512M, 4G; a bare number is MiB)";
192    fn parse(text: &str) -> Option<Bytes> {
193        parse_size(text).map(Bytes)
194    }
195}
196
197impl Value for bool {
198    const EXPECTED: &'static str = "on or off";
199    fn parse(text: &str) -> Option<bool> {
200        match text.trim().to_ascii_lowercase().as_str() {
201            "on" | "true" | "yes" | "1" => Some(true),
202            "off" | "false" | "no" | "0" => Some(false),
203            _ => None,
204        }
205    }
206}
207
208impl fmt::Display for CacheDir {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        match self {
211            CacheDir::Default => f.write_str("default"),
212            CacheDir::Path(p) => write!(f, "{}", p.display()),
213        }
214    }
215}
216
217impl Value for CacheDir {
218    const EXPECTED: &'static str = "a directory, or default";
219    fn parse(text: &str) -> Option<CacheDir> {
220        match text.trim() {
221            "" => None,
222            "default" => Some(CacheDir::Default),
223            p => Some(CacheDir::Path(expand_home(p))),
224        }
225    }
226}
227
228/// `~/x` under `$HOME`.
229fn expand_home(path: &str) -> PathBuf {
230    match (path.strip_prefix("~/"), std::env::var_os("HOME")) {
231        (Some(rest), Some(home)) => PathBuf::from(home).join(rest),
232        _ => PathBuf::from(path),
233    }
234}
235
236impl fmt::Display for Tiers {
237    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238        let names: Vec<_> = Tier::ALL.into_iter().filter(|t| self.0[*t as usize]).map(Tier::name).collect();
239        f.write_str(if names.is_empty() { "none".into() } else { names.join(",") }.as_str())
240    }
241}
242
243impl Value for Tiers {
244    const EXPECTED: &'static str = "a list of dex, derived, rendered (or all, none)";
245    fn parse(text: &str) -> Option<Tiers> {
246        match text.trim() {
247            "all" => return Some(Tiers([true; 3])),
248            "none" => return Some(Tiers([false; 3])),
249            _ => {}
250        }
251        let mut on = [false; 3];
252        for name in text.split(',').map(str::trim) {
253            on[*Tier::ALL.iter().find(|t| t.name() == name)? as usize] = true;
254        }
255        Some(Tiers(on))
256    }
257}
258
259impl fmt::Display for Seconds {
260    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261        f.write_str(&show_duration(self.0))
262    }
263}
264
265impl Value for Seconds {
266    const EXPECTED: &'static str = "a duration (900, 15m; a bare number is seconds)";
267    fn parse(text: &str) -> Option<Seconds> {
268        parse_duration(text, Duration::from_secs(1)).map(Seconds)
269    }
270}
271
272impl fmt::Display for Millis {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        f.write_str(&show_duration(self.0))
275    }
276}
277
278impl Value for Millis {
279    const EXPECTED: &'static str = "a duration (300, 2s; a bare number is milliseconds)";
280    fn parse(text: &str) -> Option<Millis> {
281        parse_duration(text, Duration::from_millis(1)).map(Millis)
282    }
283}
284
285/// A value and where it came from.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct Setting<T> {
288    pub value: T,
289    pub source: Source,
290}
291
292/// One row of [`Config::KEYS`].
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub struct Key {
295    /// Name in the config file (`section.name`) and in `--set`.
296    pub name: &'static str,
297    pub env: &'static str,
298    /// An older variable read when `env` is unset.
299    pub legacy_env: Option<&'static str>,
300    pub help: &'static str,
301}
302
303/// Why a configuration could not be read.
304#[derive(Debug, thiserror::Error)]
305pub enum ConfigError {
306    #[error("{origin}: {key} = {text:?}: expected {expected}")]
307    Invalid { origin: String, key: String, text: String, expected: &'static str },
308    #[error("{origin}: unknown setting {key:?}; known: {known}")]
309    UnknownKey { origin: String, key: String, known: String },
310    #[error("{path}: {message}")]
311    File { path: String, message: String },
312}
313
314/// Declares the settings once: the struct, its key table, defaults and
315/// text access all come from this list.
316macro_rules! settings {
317    ($($field:ident: $ty:ty = $default:expr, $name:literal, $env:literal, $legacy:expr, $help:literal;)*) => {
318        /// The resolved settings ([`Config::resolve`]).
319        #[derive(Debug, Clone, PartialEq, Eq)]
320        pub struct Config {
321            $(pub $field: Setting<$ty>,)*
322            /// The config file read, if any.
323            pub file: Option<PathBuf>,
324        }
325
326        impl Config {
327            pub const KEYS: &'static [Key] = &[$(Key { name: $name, env: $env, legacy_env: $legacy, help: $help },)*];
328
329            fn defaults() -> Config {
330                Config { $($field: Setting { value: $default, source: Source::Default },)* file: None }
331            }
332
333            /// Set `name` from `text`; `origin` names the layer in errors.
334            fn set(&mut self, name: &str, text: &str, source: Source, origin: &str) -> Result<(), ConfigError> {
335                match name {
336                    $($name => self.$field = Setting { value: parse_value(name, text, origin)?, source },)*
337                    _ => return Err(ConfigError::UnknownKey { origin: origin.into(), key: name.into(), known: Config::known_keys() }),
338                }
339                Ok(())
340            }
341
342            /// `(key, value text, source, computed from the machine)` of every setting, in [`Config::KEYS`] order.
343            pub fn entries(&self) -> Vec<(&'static Key, String, Source, bool)> {
344                let mut keys = Config::KEYS.iter();
345                vec![$({
346                    let key = keys.next().expect("one key per setting");
347                    (key, self.$field.value.to_string(), self.$field.source, self.$field.value.is_auto())
348                },)*]
349            }
350        }
351    };
352}
353
354settings! {
355    threads: Count = Count::Auto, "threads", "ASC_THREADS", None,
356        "worker threads per process; auto: the CPUs, 4 per CPU split between busy asc processes";
357    memory_max: Amount = Amount::Auto, "memory.max", "ASC_MEMORY", None,
358        "memory cache of mcp and the daemon; auto = 25%: of available memory, split between resident processes";
359    memory_ceiling: Amount = Amount::Auto, "memory.ceiling", "ASC_MEMORY_CEILING", None,
360        "most one process's automatic memory cache takes (a % is of physical memory); auto: 1/16 of it, 2G..8G";
361    memory_split: bool = true, "memory.split", "ASC_MEMORY_SPLIT", None,
362        "split the automatic memory budget between the user's resident processes";
363    cache_enabled: bool = true, "cache.enabled", "ASC_CACHE", None,
364        "read and write the disk cache";
365    cache_dir: CacheDir = CacheDir::Default, "cache.dir", "ASC_CACHE_DIR", None,
366        "disk cache directory; default: $XDG_CACHE_HOME/asc or ~/.cache/asc";
367    cache_max: Amount = Amount::Auto, "cache.max", "ASC_CACHE_MAX", Some("ASC_CACHE_MAX_MB"),
368        "disk cache budget; auto = 20%: of free space plus the cache, at most cache.ceiling, never into a 1 GiB / 5% reserve";
369    cache_ceiling: Bytes = Bytes(AutoDisk::CEILING), "cache.ceiling", "ASC_CACHE_CEILING", None,
370        "most the automatic disk budget takes";
371    cache_tiers: Tiers = Tiers([true; 3]), "cache.tiers", "ASC_CACHE_TIERS", None,
372        "disk cache tiers in use: dex (L1), derived (L2), rendered (L3)";
373    cache_min_dex: Bytes = Bytes(DEFAULT_MIN_DEX_BYTES), "cache.min_dex", "ASC_CACHE_MIN_DEX", Some("ASC_CACHE_MIN_DEX_MB"),
374        "smallest inflated DEX written to L1 (smaller ones are cheaper to inflate again)";
375    cache_write_queue: Bytes = Bytes(CacheConfig::WRITE_QUEUE as u64), "cache.write_queue", "ASC_CACHE_WRITE_QUEUE", None,
376        "heap queued disk writes may hold (at most the memory cache's budget); later ones inflate again when written";
377    cache_writers: Count = Count::Auto, "cache.writers", "ASC_CACHE_WRITERS", None,
378        "disk writer threads per process; auto: 1 per 8 CPUs, 1..4";
379    cache_spill: Bytes = Bytes(64 << 20), "cache.spill", "ASC_CACHE_SPILL", None,
380        "budget of out/ in the cache directory, where cut outputs go; 0 writes none";
381    daemon_idle: Seconds = Seconds(Duration::from_secs(15 * 60)), "daemon.idle", "ASC_DAEMON_IDLE", None,
382        "the daemon exits after this long without a request";
383    daemon_wait: Millis = Millis(Duration::from_millis(300)), "daemon.wait", "ASC_DAEMON_WAIT", Some("ASC_DAEMON_WAIT_MS"),
384        "how long the first command waits for the daemon it starts, then runs itself";
385}
386
387/// `[section] key = v` as `("section.key", v)`.
388fn flatten<'t>(prefix: &str, table: &'t toml::Table, out: &mut Vec<(String, &'t toml::Value)>) {
389    for (k, v) in table {
390        let name = if prefix.is_empty() { k.clone() } else { format!("{prefix}.{k}") };
391        match v {
392            toml::Value::Table(t) => flatten(&name, t, out),
393            v => out.push((name, v)),
394        }
395    }
396}
397
398fn parse_value<T: Value>(key: &str, text: &str, origin: &str) -> Result<T, ConfigError> {
399    T::parse(text).ok_or_else(|| ConfigError::Invalid { origin: origin.into(), key: key.into(), text: text.into(), expected: T::EXPECTED })
400}
401
402impl Config {
403    fn known_keys() -> String {
404        Config::KEYS.iter().map(|k| k.name).collect::<Vec<_>>().join(", ")
405    }
406
407    /// The config file: `$ASC_CONFIG` (empty: none), else
408    /// `$XDG_CONFIG_HOME/asc/config.toml`, else
409    /// `~/.config/asc/config.toml`.
410    pub fn file_path(env: impl Fn(&str) -> Option<String>) -> Option<PathBuf> {
411        if let Some(p) = env(CONFIG_ENV) {
412            return (!p.is_empty()).then(|| expand_home(&p));
413        }
414        let base = env("XDG_CONFIG_HOME")
415            .filter(|d| !d.is_empty())
416            .map(PathBuf::from)
417            .or_else(|| env("HOME").map(|h| Path::new(&h).join(".config")))?;
418        Some(base.join("asc").join("config.toml"))
419    }
420
421    /// Read the process's environment and config file, then apply `flags`
422    /// (`(key, value)` pairs from the command line).
423    pub fn load(flags: &[(String, String)]) -> Result<Config, ConfigError> {
424        let env = |name: &str| std::env::var(name).ok();
425        let file = match Config::file_path(env) {
426            Some(path) => match std::fs::read_to_string(&path) {
427                Ok(text) => Some((path, text)),
428                Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
429                Err(e) => return Err(ConfigError::File { path: path.display().to_string(), message: e.to_string() }),
430            },
431            None => None,
432        };
433        Config::resolve(file.as_ref().map(|(p, t)| (p.as_path(), t.as_str())), env, flags)
434    }
435
436    /// Defaults, then the file `(path, text)`, then `env`, then `flags`.
437    pub fn resolve(
438        file: Option<(&Path, &str)>,
439        env: impl Fn(&str) -> Option<String>,
440        flags: &[(String, String)],
441    ) -> Result<Config, ConfigError> {
442        let mut config = Config::defaults();
443        if let Some((path, text)) = file {
444            config.apply_file(path, text)?;
445            config.file = Some(path.to_path_buf());
446        }
447        for key in Config::KEYS {
448            let found =
449                [Some(key.env), key.legacy_env].into_iter().flatten().find_map(|var| Some((var, env(var).filter(|v| !v.is_empty())?)));
450            if let Some((var, text)) = found {
451                config.set(key.name, &text, Source::Env, &format!("${var}"))?;
452            }
453        }
454        for (name, text) in flags {
455            config.set(name, text, Source::Flag, "command line")?;
456        }
457        Ok(config)
458    }
459
460    fn apply_file(&mut self, path: &Path, text: &str) -> Result<(), ConfigError> {
461        let origin = path.display().to_string();
462        let table: toml::Table =
463            text.parse().map_err(|e: toml::de::Error| ConfigError::File { path: origin.clone(), message: e.message().to_string() })?;
464        let mut pairs = Vec::new();
465        flatten("", &table, &mut pairs);
466        for (name, value) in pairs {
467            let text = match value {
468                toml::Value::String(s) => s.clone(),
469                toml::Value::Integer(i) => i.to_string(),
470                toml::Value::Boolean(b) => b.to_string(),
471                other => {
472                    return Err(ConfigError::Invalid {
473                        origin,
474                        key: name,
475                        text: format!("<{}>", other.type_str()),
476                        expected: "a string, number or boolean",
477                    });
478                }
479            };
480            self.set(&name, &text, Source::File, &origin)?;
481        }
482        Ok(())
483    }
484
485    /// The `(key, value)` pairs of the settings given on the command line,
486    /// to hand on to a process started for this one (a daemon).
487    pub fn flag_pairs(&self) -> Vec<(String, String)> {
488        self.entries()
489            .into_iter()
490            .filter(|(_, _, source, _)| *source == Source::Flag)
491            .map(|(key, text, _, _)| (key.name.to_string(), text))
492            .collect()
493    }
494
495    pub fn threads(&self) -> Threads {
496        match self.threads.value {
497            Count::Auto => Threads::Auto,
498            Count::Fixed(n) => Threads::Fixed(n),
499        }
500    }
501
502    /// [`Config::threads`] as an explicit count, `None` for auto.
503    pub fn fixed_threads(&self) -> Option<usize> {
504        match self.threads.value {
505            Count::Auto => None,
506            Count::Fixed(n) => Some(n),
507        }
508    }
509
510    /// How an automatic memory cache follows `device`; `None` for a fixed size.
511    pub fn memory_policy(&self, device: &Device) -> Option<MemoryPolicy> {
512        let percent = match self.memory_max.value {
513            Amount::Bytes(_) => return None,
514            Amount::Auto => MemoryPolicy::PERCENT,
515            Amount::Percent(p) => p,
516        };
517        Some(MemoryPolicy { percent, ceiling: self.memory_ceiling(device), split: self.memory_split.value })
518    }
519
520    fn memory_ceiling(&self, device: &Device) -> usize {
521        match self.memory_ceiling.value {
522            Amount::Auto => MemoryPolicy::auto_ceiling(device.total_memory),
523            Amount::Bytes(b) => usize::try_from(b).unwrap_or(usize::MAX),
524            Amount::Percent(p) => device
525                .total_memory
526                .map_or(MemoryPolicy::CEILING_MIN, |t| usize::try_from(u128::from(t) * u128::from(p) / 100).unwrap_or(usize::MAX)),
527        }
528    }
529
530    /// The memory cache's starting size on `device`.
531    pub fn memory_bytes(&self, device: &Device) -> usize {
532        match self.memory_policy(device) {
533            Some(policy) => policy.alone(device.available_memory),
534            None => match self.memory_max.value {
535                Amount::Bytes(b) => usize::try_from(b).unwrap_or(usize::MAX),
536                Amount::Auto | Amount::Percent(_) => MemoryPolicy::CEILING_MIN,
537            },
538        }
539    }
540
541    /// The cache directory in use (`None`: the cache is off, or no default
542    /// directory could be determined).
543    pub fn cache_dir(&self) -> Option<PathBuf> {
544        if !self.cache_enabled.value {
545            return None;
546        }
547        match &self.cache_dir.value {
548            CacheDir::Default => DiskCache::default_dir(),
549            CacheDir::Path(p) => Some(p.clone()),
550        }
551    }
552
553    pub fn disk_capacity(&self) -> DiskCapacity {
554        let ceiling = self.cache_ceiling.value.0;
555        match self.cache_max.value {
556            Amount::Auto => DiskCapacity::Auto(AutoDisk { percent: AutoDisk::PERCENT, ceiling }),
557            Amount::Percent(percent) => DiskCapacity::Auto(AutoDisk { percent, ceiling }),
558            Amount::Bytes(b) => DiskCapacity::Fixed(b),
559        }
560    }
561
562    /// The disk cache, unless it is off.
563    pub fn disk_cache(&self) -> Option<DiskCache> {
564        let tiers: Vec<Tier> = Tier::ALL.into_iter().filter(|t| self.cache_tiers.value.0[*t as usize]).collect();
565        Some(
566            DiskCache::open(self.cache_dir()?)
567                .with_capacity_mode(self.disk_capacity())
568                .with_tiers(&tiers)
569                .with_min_dex_bytes(self.cache_min_dex.value.0),
570        )
571    }
572
573    fn writers(&self) -> Option<usize> {
574        match self.cache_writers.value {
575            Count::Auto => None,
576            Count::Fixed(n) => Some(n),
577        }
578    }
579
580    /// The cache of a one-shot command: the disk cache only.
581    pub fn one_shot_cache(&self) -> CacheConfig {
582        CacheConfig {
583            disk: self.disk_cache(),
584            writers: self.writers(),
585            write_queue_bytes: self.write_queue_bytes(),
586            ..CacheConfig::default()
587        }
588    }
589
590    /// The cache of a resident host (`mcp`, the daemon) on `device`.
591    pub fn resident_cache(&self, device: &Device) -> CacheConfig {
592        CacheConfig {
593            memory_bytes: self.memory_bytes(device),
594            disk: self.disk_cache(),
595            memory_auto: self.memory_policy(device),
596            write_queue_bytes: self.write_queue_bytes(),
597            writers: self.writers(),
598        }
599    }
600
601    fn write_queue_bytes(&self) -> usize {
602        usize::try_from(self.cache_write_queue.value.0).unwrap_or(usize::MAX)
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use std::collections::HashMap;
610
611    const GIB: u64 = 1 << 30;
612
613    fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
614        let vars: HashMap<String, String> = vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
615        move |k| vars.get(k).cloned()
616    }
617
618    fn flags(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
619        pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
620    }
621
622    #[test]
623    fn values_parse_and_show_the_same_text() {
624        assert_eq!(Amount::parse("512"), Some(Amount::Bytes(512 << 20)));
625        assert_eq!(Amount::parse(" 4G "), Some(Amount::Bytes(4 * GIB)));
626        assert_eq!(Amount::parse("4GiB"), Some(Amount::Bytes(4 * GIB)));
627        assert_eq!(Amount::parse("1024k"), Some(Amount::Bytes(1 << 20)));
628        assert_eq!(Amount::parse("25%"), Some(Amount::Percent(25)));
629        assert_eq!(Amount::parse("0%"), None);
630        assert_eq!(Amount::parse("lots"), None);
631        assert_eq!(Amount::Bytes(6 * GIB).to_string(), "6G");
632        assert_eq!(Amount::Bytes(1536 << 20).to_string(), "1536M");
633        assert_eq!(Bytes(0).to_string(), "0B");
634        assert_eq!(Count::parse("-1"), None);
635        assert_eq!(Count::parse("8"), Some(Count::Fixed(8)));
636        assert_eq!(Tiers::parse("dex, rendered"), Some(Tiers([true, false, true])));
637        assert_eq!(Tiers::parse("l1"), None);
638        assert_eq!(Tiers([false; 3]).to_string(), "none");
639        assert_eq!(Seconds::parse("15m").map(|s| s.0), Some(Duration::from_secs(900)));
640        assert_eq!(Seconds::parse("2").map(|s| s.to_string()).as_deref(), Some("2s"));
641        assert_eq!(Millis::parse("300").map(|s| s.to_string()).as_deref(), Some("300ms"));
642        assert_eq!(bool::parse("OFF"), Some(false));
643        for (key, text, _, _) in Config::defaults().entries() {
644            assert!(Config::defaults().set(key.name, &text, Source::Flag, "test").is_ok(), "{} = {text} does not read back", key.name);
645        }
646    }
647
648    #[test]
649    fn flags_beat_env_beat_file_beat_defaults() {
650        let file = "threads = 3\n[memory]\nmax = \"30%\"\nsplit = false\n[cache]\nmax = \"8G\"\ntiers = \"dex\"\n";
651        let path = Path::new("/etc/asc.toml");
652        let c =
653            Config::resolve(Some((path, file)), env(&[("ASC_THREADS", "6"), ("ASC_CACHE_MAX_MB", "1024")]), &flags(&[("threads", "9")]))
654                .unwrap();
655        assert_eq!(c.threads, Setting { value: Count::Fixed(9), source: Source::Flag });
656        assert_eq!(c.cache_max, Setting { value: Amount::Bytes(GIB), source: Source::Env });
657        assert_eq!(c.memory_max, Setting { value: Amount::Percent(30), source: Source::File });
658        assert!(!c.memory_split.value);
659        assert_eq!(c.cache_tiers.value, Tiers([true, false, false]));
660        assert_eq!(c.cache_dir.source, Source::Default);
661        assert_eq!(c.file.as_deref(), Some(path));
662        assert_eq!(c.flag_pairs(), flags(&[("threads", "9")]));
663        // The current name wins over the legacy one; empty variables are unset.
664        let c = Config::resolve(None, env(&[("ASC_CACHE_MAX", "20%"), ("ASC_CACHE_MAX_MB", "1"), ("ASC_THREADS", "")]), &[]).unwrap();
665        assert_eq!(c.cache_max.value, Amount::Percent(20));
666        assert_eq!(c.threads.source, Source::Default);
667    }
668
669    #[test]
670    fn errors_say_where_and_what_is_expected() {
671        let e = Config::resolve(None, env(&[("ASC_MEMORY", "lots")]), &[]).unwrap_err().to_string();
672        assert!(e.contains("$ASC_MEMORY: memory.max = \"lots\": expected auto, a size"), "{e}");
673        let e = Config::resolve(Some((Path::new("c.toml"), "[memory]\nmaxx = 1\n")), env(&[]), &[]).unwrap_err().to_string();
674        assert!(e.starts_with("c.toml: unknown setting \"memory.maxx\"; known: threads, memory.max"), "{e}");
675        let e = Config::resolve(Some((Path::new("c.toml"), "threads = [1]\n")), env(&[]), &[]).unwrap_err().to_string();
676        assert!(e.contains("expected a string, number or boolean"), "{e}");
677        let e = Config::resolve(Some((Path::new("c.toml"), "threads = \n")), env(&[]), &[]).unwrap_err().to_string();
678        assert!(e.starts_with("c.toml: "), "{e}");
679        let e = Config::resolve(None, env(&[]), &flags(&[("cache.tiers", "l1")])).unwrap_err().to_string();
680        assert!(e.contains("command line: cache.tiers = \"l1\": expected a list of dex, derived, rendered"), "{e}");
681    }
682
683    #[test]
684    fn the_file_is_found_like_other_xdg_configs() {
685        assert_eq!(Config::file_path(env(&[("HOME", "/h")])), Some(PathBuf::from("/h/.config/asc/config.toml")));
686        assert_eq!(Config::file_path(env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "/x")])), Some(PathBuf::from("/x/asc/config.toml")));
687        assert_eq!(Config::file_path(env(&[("HOME", "/h"), (CONFIG_ENV, "/c.toml")])), Some(PathBuf::from("/c.toml")));
688        assert_eq!(Config::file_path(env(&[("HOME", "/h"), (CONFIG_ENV, "")])), None);
689    }
690
691    #[test]
692    fn auto_values_follow_the_device() {
693        let laptop = Device { total_memory: Some(12 * GIB), available_memory: Some(6 * GIB), cpus: 16 };
694        let big = Device { total_memory: Some(96 * GIB), available_memory: Some(60 * GIB), cpus: 32 };
695        let c = Config::defaults();
696        assert_eq!(c.memory_bytes(&laptop), (6 * GIB / 4) as usize);
697        assert_eq!(c.memory_bytes(&big), 6 << 30);
698        assert_eq!(c.memory_policy(&big), Some(MemoryPolicy { percent: 25, ceiling: 6 << 30, split: true }));
699        let c = Config::resolve(None, env(&[]), &flags(&[("memory.max", "10%"), ("memory.ceiling", "50%")])).unwrap();
700        assert_eq!(c.memory_bytes(&big), (60 * GIB / 10) as usize);
701        assert_eq!(c.memory_policy(&big).unwrap().ceiling, (48 * GIB) as usize);
702        let c = Config::resolve(None, env(&[]), &flags(&[("memory.max", "1G")])).unwrap();
703        assert_eq!((c.memory_bytes(&big), c.memory_policy(&big)), (GIB as usize, None));
704        let c = Config::resolve(None, env(&[]), &flags(&[("cache.max", "10%"), ("cache.ceiling", "5G")])).unwrap();
705        assert_eq!(c.disk_capacity(), DiskCapacity::Auto(AutoDisk { percent: 10, ceiling: 5 * GIB }));
706        let off = Config::resolve(None, env(&[("ASC_CACHE", "off")]), &[]).unwrap();
707        assert!(off.disk_cache().is_none() && off.cache_dir().is_none());
708        let fixed = Config::resolve(None, env(&[]), &flags(&[("threads", "4"), ("cache.writers", "2"), ("cache.dir", "/tmp/c")])).unwrap();
709        assert_eq!((fixed.threads(), fixed.fixed_threads()), (Threads::Fixed(4), Some(4)));
710        let cache = fixed.resident_cache(&laptop);
711        assert_eq!((cache.writers, cache.write_queue_bytes), (Some(2), CacheConfig::WRITE_QUEUE));
712        assert_eq!(cache.disk.unwrap().dir(), Path::new("/tmp/c"));
713    }
714}