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