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::{ARKDEC_DECOMPILER, BUILT_IN_DECOMPILER, BUILT_IN_DECOMPILERS, 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_harmony_default: Name = Name(String::from(ARKDEC_DECOMPILER)), "decompile.harmony_default", "ASC_HARMONY_DECOMPILER", None,
510        "backend of asc decompile on a HarmonyOS program unless --decompiler names one; arkdec runs when ASC_ARKDEC_WORKER, [decompilers.arkdec] command or a worker next to asc / on PATH gives it";
511    decompile_harmony_fallback: Names = Names::default(), "decompile.harmony_fallback", "ASC_HARMONY_DECOMPILE_FALLBACK", None,
512        "backends tried in order when the HarmonyOS one fails on a whole record; none: the disassembly right away";
513    decompile_mapping: MaybePath = MaybePath::default(), "decompile.mapping", "ASC_MAPPING", None,
514        "R8 / ProGuard mapping.txt whose names decompiled source shows (--mapping); none: the DEX's own names";
515    decompile_auto_install: bool = true, "decompile.auto_install", "ASC_AUTO_INSTALL", None,
516        "download asc-dexdec-worker speaking this asc's protocol version from decompile.release_url when no worker is found";
517    decompile_release_url: Name = Name(String::from(DEFAULT_RELEASE_URL)), "decompile.release_url", "ASC_RELEASE_URL", None,
518        "where asc install and the auto-install fetch the worker: URL/asc-dexdec-worker-pPROTOCOL-TARGET.tar.gz";
519}
520
521/// The downloads of the latest asc-dexdec-worker release, the default
522/// `decompile.release_url`.
523pub const DEFAULT_RELEASE_URL: &str = "https://github.com/DroidASC/asc-dexdec/releases/latest/download";
524
525/// Settings whose file value may be an array of strings, read as the names
526/// joined with `,`.
527const LIST_KEYS: &[&str] = &["decompile.fallback", "decompile.harmony_fallback"];
528
529/// `[section] key = v` as `("section.key", v)`.
530fn flatten<'t>(prefix: &str, table: &'t toml::Table, out: &mut Vec<(String, &'t toml::Value)>) {
531    for (k, v) in table {
532        let name = if prefix.is_empty() { k.clone() } else { format!("{prefix}.{k}") };
533        match v {
534            toml::Value::Table(t) => flatten(&name, t, out),
535            v => out.push((name, v)),
536        }
537    }
538}
539
540fn parse_value<T: Value>(key: &str, text: &str, origin: &str) -> Result<T, ConfigError> {
541    T::parse(text).ok_or_else(|| ConfigError::Invalid { origin: origin.into(), key: key.into(), text: text.into(), expected: T::EXPECTED })
542}
543
544impl Config {
545    fn known_keys() -> String {
546        Config::KEYS.iter().map(|k| k.name).collect::<Vec<_>>().join(", ")
547    }
548
549    /// The config file: `$ASC_CONFIG` (empty: none), else
550    /// `$XDG_CONFIG_HOME/asc/config.toml`, else on Windows
551    /// `%APPDATA%\\asc\\config.toml`, else `~/.config/asc/config.toml`.
552    pub fn file_path(env: impl Fn(&str) -> Option<String>) -> Option<PathBuf> {
553        if let Some(p) = env(CONFIG_ENV) {
554            return (!p.is_empty()).then(|| expand_home(&p));
555        }
556        let set = |name: &str| env(name).filter(|d| !d.is_empty()).map(PathBuf::from);
557        let base = set("XDG_CONFIG_HOME")
558            .or_else(|| if cfg!(windows) { set("APPDATA") } else { None })
559            .or_else(|| set("HOME").map(|h| h.join(".config")))?;
560        Some(base.join("asc").join("config.toml"))
561    }
562
563    /// Read the process's environment and config file, then apply `flags`
564    /// (`(key, value)` pairs from the command line).
565    ///
566    /// # Errors
567    ///
568    /// [`ConfigError::File`] when the config file exists but cannot be read,
569    /// and the errors of [`Config::resolve`].
570    pub fn load(flags: &[(String, String)]) -> Result<Config, ConfigError> {
571        let env = |name: &str| std::env::var(name).ok();
572        let file = match Config::file_path(env) {
573            Some(path) => match std::fs::read_to_string(&path) {
574                Ok(text) => Some((path, text)),
575                Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
576                Err(e) => return Err(ConfigError::File { path: path.display().to_string(), message: e.to_string() }),
577            },
578            None => None,
579        };
580        Config::resolve(file.as_ref().map(|(p, t)| (p.as_path(), t.as_str())), env, flags)
581    }
582
583    /// Defaults, then the file `(path, text)`, then `env`, then `flags`.
584    ///
585    /// # Errors
586    ///
587    /// [`ConfigError::File`] when the file is not valid TOML,
588    /// [`ConfigError::UnknownKey`] for a name not in [`Config::KEYS`], and
589    /// [`ConfigError::Invalid`] for a value that does not parse.
590    pub fn resolve(
591        file: Option<(&Path, &str)>,
592        env: impl Fn(&str) -> Option<String>,
593        flags: &[(String, String)],
594    ) -> Result<Config, ConfigError> {
595        let mut config = Config::defaults();
596        if let Some((path, text)) = file {
597            config.apply_file(path, text)?;
598            config.file = Some(path.to_path_buf());
599        }
600        for key in Config::KEYS {
601            let found =
602                [Some(key.env), key.legacy_env].into_iter().flatten().find_map(|var| Some((var, env(var).filter(|v| !v.is_empty())?)));
603            if let Some((var, text)) = found {
604                config.set(key.name, &text, Source::Env, &format!("${var}"))?;
605            }
606        }
607        for (name, text) in flags {
608            config.set(name, text, Source::Flag, "command line")?;
609        }
610        Ok(config)
611    }
612
613    fn apply_file(&mut self, path: &Path, text: &str) -> Result<(), ConfigError> {
614        let origin = path.display().to_string();
615        let mut table: toml::Table =
616            text.parse().map_err(|e: toml::de::Error| ConfigError::File { path: origin.clone(), message: e.message().to_string() })?;
617        // Tables of their own, not settings: taken out before the rest is flattened.
618        if let Some(decompilers) = table.remove("decompilers") {
619            self.decompilers = Decompiler::parse_all(&decompilers, &origin)?;
620        }
621        let mut pairs = Vec::new();
622        flatten("", &table, &mut pairs);
623        for (name, value) in pairs {
624            let text = match value {
625                toml::Value::String(s) => s.clone(),
626                toml::Value::Integer(i) => i.to_string(),
627                toml::Value::Boolean(b) => b.to_string(),
628                toml::Value::Array(items) if LIST_KEYS.contains(&name.as_str()) => {
629                    let names: Option<Vec<&str>> = items.iter().map(toml::Value::as_str).collect();
630                    match names {
631                        Some(names) if names.is_empty() => "none".to_string(),
632                        Some(names) => names.join(","),
633                        None => {
634                            return Err(ConfigError::Invalid {
635                                origin,
636                                key: name,
637                                text: "<array>".into(),
638                                expected: "an array of strings",
639                            });
640                        }
641                    }
642                }
643                other => {
644                    return Err(ConfigError::Invalid {
645                        origin,
646                        key: name,
647                        text: format!("<{}>", other.type_str()),
648                        expected: "a string, number or boolean",
649                    });
650                }
651            };
652            self.set(&name, &text, Source::File, &origin)?;
653        }
654        Ok(())
655    }
656
657    /// The `(key, value)` pairs of the settings given on the command line,
658    /// to hand on to a process started for this one (a daemon).
659    pub fn flag_pairs(&self) -> Vec<(String, String)> {
660        self.entries()
661            .into_iter()
662            .filter(|(_, _, source, _)| *source == Source::Flag)
663            .map(|(key, text, _, _)| (key.name.to_string(), text))
664            .collect()
665    }
666
667    /// The `threads` setting as the engine takes it.
668    pub fn threads(&self) -> Threads {
669        match self.threads.value {
670            Count::Auto => Threads::Auto,
671            Count::Fixed(n) => Threads::Fixed(n),
672        }
673    }
674
675    /// [`Config::threads`] as an explicit count, `None` for auto.
676    pub fn fixed_threads(&self) -> Option<usize> {
677        match self.threads.value {
678            Count::Auto => None,
679            Count::Fixed(n) => Some(n),
680        }
681    }
682
683    /// How an automatic memory cache follows `device`; `None` for a fixed size.
684    pub fn memory_policy(&self, device: &Device) -> Option<MemoryPolicy> {
685        let percent = match self.memory_max.value {
686            Amount::Bytes(_) => return None,
687            Amount::Auto => MemoryPolicy::PERCENT,
688            Amount::Percent(p) => p,
689        };
690        Some(MemoryPolicy { percent, ceiling: self.memory_ceiling(device), split: self.memory_split.value })
691    }
692
693    fn memory_ceiling(&self, device: &Device) -> usize {
694        match self.memory_ceiling.value {
695            Amount::Auto => MemoryPolicy::auto_ceiling(device.total_memory),
696            Amount::Bytes(b) => usize::try_from(b).unwrap_or(usize::MAX),
697            Amount::Percent(p) => device
698                .total_memory
699                .map_or(MemoryPolicy::CEILING_MIN, |t| usize::try_from(u128::from(t) * u128::from(p) / 100).unwrap_or(usize::MAX)),
700        }
701    }
702
703    /// The memory cache's starting size on `device`.
704    pub fn memory_bytes(&self, device: &Device) -> usize {
705        match self.memory_policy(device) {
706            Some(policy) => policy.alone(device.available_memory),
707            None => match self.memory_max.value {
708                Amount::Bytes(b) => usize::try_from(b).unwrap_or(usize::MAX),
709                Amount::Auto | Amount::Percent(_) => MemoryPolicy::CEILING_MIN,
710            },
711        }
712    }
713
714    /// The cache directory in use (`None`: the cache is off, or no default
715    /// directory could be determined).
716    pub fn cache_dir(&self) -> Option<PathBuf> {
717        if !self.cache_enabled.value {
718            return None;
719        }
720        match &self.cache_dir.value {
721            CacheDir::Default => DiskCache::default_dir(),
722            CacheDir::Path(p) => Some(p.clone()),
723        }
724    }
725
726    /// The disk cache budget from `cache.max` and `cache.ceiling`.
727    pub fn disk_capacity(&self) -> DiskCapacity {
728        let ceiling = self.cache_ceiling.value.0;
729        match self.cache_max.value {
730            Amount::Auto => DiskCapacity::Auto(AutoDisk::new(AutoDisk::PERCENT, ceiling)),
731            Amount::Percent(percent) => DiskCapacity::Auto(AutoDisk::new(percent, ceiling)),
732            Amount::Bytes(b) => DiskCapacity::Fixed(b),
733        }
734    }
735
736    /// The disk cache, unless it is off.
737    pub fn disk_cache(&self) -> Option<DiskCache> {
738        let tiers: Vec<Tier> = Tier::ALL.into_iter().filter(|t| self.cache_tiers.value.0[*t as usize]).collect();
739        Some(
740            DiskCache::open(self.cache_dir()?)
741                .with_capacity_mode(self.disk_capacity())
742                .with_tiers(&tiers)
743                .with_min_dex_bytes(self.cache_min_dex.value.0),
744        )
745    }
746
747    fn writers(&self) -> Option<usize> {
748        match self.cache_writers.value {
749            Count::Auto => None,
750            Count::Fixed(n) => Some(n),
751        }
752    }
753
754    /// The cache of a one-shot command: the disk cache only.
755    pub fn one_shot_cache(&self) -> CacheConfig {
756        CacheConfig {
757            disk: self.disk_cache(),
758            writers: self.writers(),
759            write_queue_bytes: self.write_queue_bytes(),
760            ..CacheConfig::default()
761        }
762    }
763
764    /// The cache of a resident host (`mcp`, the daemon) on `device`.
765    pub fn resident_cache(&self, device: &Device) -> CacheConfig {
766        CacheConfig {
767            memory_bytes: self.memory_bytes(device),
768            disk: self.disk_cache(),
769            memory_auto: self.memory_policy(device),
770            write_queue_bytes: self.write_queue_bytes(),
771            writers: self.writers(),
772        }
773    }
774
775    fn write_queue_bytes(&self) -> usize {
776        usize::try_from(self.cache_write_queue.value.0).unwrap_or(usize::MAX)
777    }
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783    use std::collections::HashMap;
784
785    const GIB: u64 = 1 << 30;
786
787    fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
788        let vars: HashMap<String, String> = vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
789        move |k| vars.get(k).cloned()
790    }
791
792    fn flags(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
793        pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
794    }
795
796    #[test]
797    fn values_parse_and_show_the_same_text() {
798        assert_eq!(Amount::parse("512"), Some(Amount::Bytes(512 << 20)));
799        assert_eq!(Amount::parse(" 4G "), Some(Amount::Bytes(4 * GIB)));
800        assert_eq!(Amount::parse("4GiB"), Some(Amount::Bytes(4 * GIB)));
801        assert_eq!(Amount::parse("1024k"), Some(Amount::Bytes(1 << 20)));
802        assert_eq!(Amount::parse("25%"), Some(Amount::Percent(25)));
803        assert_eq!(Amount::parse("0%"), None);
804        assert_eq!(Amount::parse("lots"), None);
805        assert_eq!(Amount::Bytes(6 * GIB).to_string(), "6G");
806        assert_eq!(Amount::Bytes(1536 << 20).to_string(), "1536M");
807        assert_eq!(Bytes(0).to_string(), "0B");
808        assert_eq!(Count::parse("-1"), None);
809        assert_eq!(Count::parse("8"), Some(Count::Fixed(8)));
810        assert_eq!(Tiers::parse("dex, rendered"), Some(Tiers([true, false, true])));
811        assert_eq!(Tiers::parse("l1"), None);
812        assert_eq!(Tiers([false; 3]).to_string(), "none");
813        assert_eq!(Seconds::parse("15m").map(|s| s.0), Some(Duration::from_secs(900)));
814        assert_eq!(Seconds::parse("2").map(|s| s.to_string()).as_deref(), Some("2s"));
815        assert_eq!(Millis::parse("300").map(|s| s.to_string()).as_deref(), Some("300ms"));
816        assert_eq!(bool::parse("OFF"), Some(false));
817        for (key, text, _, _) in Config::defaults().entries() {
818            assert!(Config::defaults().set(key.name, &text, Source::Flag, "test").is_ok(), "{} = {text} does not read back", key.name);
819        }
820    }
821
822    #[test]
823    fn flags_beat_env_beat_file_beat_defaults() {
824        let file = "threads = 3\n[memory]\nmax = \"30%\"\nsplit = false\n[cache]\nmax = \"8G\"\ntiers = \"dex\"\n";
825        let path = Path::new("/etc/asc.toml");
826        let c =
827            Config::resolve(Some((path, file)), env(&[("ASC_THREADS", "6"), ("ASC_CACHE_MAX_MB", "1024")]), &flags(&[("threads", "9")]))
828                .unwrap();
829        assert_eq!(c.threads, Setting { value: Count::Fixed(9), source: Source::Flag });
830        assert_eq!(c.cache_max, Setting { value: Amount::Bytes(GIB), source: Source::Env });
831        assert_eq!(c.memory_max, Setting { value: Amount::Percent(30), source: Source::File });
832        assert!(!c.memory_split.value);
833        assert_eq!(c.cache_tiers.value, Tiers([true, false, false]));
834        assert_eq!(c.cache_dir.source, Source::Default);
835        assert_eq!(c.file.as_deref(), Some(path));
836        assert_eq!(c.flag_pairs(), flags(&[("threads", "9")]));
837        // The current name wins over the legacy one; empty variables are unset.
838        let c = Config::resolve(None, env(&[("ASC_CACHE_MAX", "20%"), ("ASC_CACHE_MAX_MB", "1"), ("ASC_THREADS", "")]), &[]).unwrap();
839        assert_eq!(c.cache_max.value, Amount::Percent(20));
840        assert_eq!(c.threads.source, Source::Default);
841    }
842
843    #[test]
844    fn errors_say_where_and_what_is_expected() {
845        let e = Config::resolve(None, env(&[("ASC_MEMORY", "lots")]), &[]).unwrap_err().to_string();
846        assert!(e.contains("$ASC_MEMORY: memory.max = \"lots\": expected auto, a size"), "{e}");
847        let e = Config::resolve(Some((Path::new("c.toml"), "[memory]\nmaxx = 1\n")), env(&[]), &[]).unwrap_err().to_string();
848        assert!(e.starts_with("c.toml: unknown setting \"memory.maxx\"; known: threads, memory.max"), "{e}");
849        let e = Config::resolve(Some((Path::new("c.toml"), "threads = [1]\n")), env(&[]), &[]).unwrap_err().to_string();
850        assert!(e.contains("expected a string, number or boolean"), "{e}");
851        let e = Config::resolve(Some((Path::new("c.toml"), "threads = \n")), env(&[]), &[]).unwrap_err().to_string();
852        assert!(e.starts_with("c.toml: "), "{e}");
853        let e = Config::resolve(None, env(&[]), &flags(&[("cache.tiers", "l1")])).unwrap_err().to_string();
854        assert!(e.contains("command line: cache.tiers = \"l1\": expected a list of dex, derived, rendered"), "{e}");
855    }
856
857    #[test]
858    fn the_file_is_found_like_other_xdg_configs() {
859        assert_eq!(Config::file_path(env(&[("HOME", "/h")])), Some(PathBuf::from("/h/.config/asc/config.toml")));
860        assert_eq!(Config::file_path(env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "/x")])), Some(PathBuf::from("/x/asc/config.toml")));
861        assert_eq!(Config::file_path(env(&[("HOME", "/h"), (CONFIG_ENV, "/c.toml")])), Some(PathBuf::from("/c.toml")));
862        assert_eq!(Config::file_path(env(&[("HOME", "/h"), (CONFIG_ENV, "")])), None);
863    }
864
865    #[test]
866    fn auto_values_follow_the_device() {
867        let laptop = Device { total_memory: Some(12 * GIB), available_memory: Some(6 * GIB), cpus: 16 };
868        let big = Device { total_memory: Some(96 * GIB), available_memory: Some(60 * GIB), cpus: 32 };
869        let c = Config::defaults();
870        assert_eq!(c.memory_bytes(&laptop), (6 * GIB / 4) as usize);
871        assert_eq!(c.memory_bytes(&big), 6 << 30);
872        assert_eq!(c.memory_policy(&big), Some(MemoryPolicy { percent: 25, ceiling: 6 << 30, split: true }));
873        let c = Config::resolve(None, env(&[]), &flags(&[("memory.max", "10%"), ("memory.ceiling", "50%")])).unwrap();
874        assert_eq!(c.memory_bytes(&big), (60 * GIB / 10) as usize);
875        assert_eq!(c.memory_policy(&big).unwrap().ceiling, (48 * GIB) as usize);
876        let c = Config::resolve(None, env(&[]), &flags(&[("memory.max", "1G")])).unwrap();
877        assert_eq!((c.memory_bytes(&big), c.memory_policy(&big)), (GIB as usize, None));
878        let c = Config::resolve(None, env(&[]), &flags(&[("cache.max", "10%"), ("cache.ceiling", "5G")])).unwrap();
879        assert_eq!(c.disk_capacity(), DiskCapacity::Auto(AutoDisk::new(10, 5 * GIB)));
880        let off = Config::resolve(None, env(&[("ASC_CACHE", "off")]), &[]).unwrap();
881        assert!(off.disk_cache().is_none() && off.cache_dir().is_none());
882        let fixed = Config::resolve(None, env(&[]), &flags(&[("threads", "4"), ("cache.writers", "2"), ("cache.dir", "/tmp/c")])).unwrap();
883        assert_eq!((fixed.threads(), fixed.fixed_threads()), (Threads::Fixed(4), Some(4)));
884        let cache = fixed.resident_cache(&laptop);
885        assert_eq!((cache.writers, cache.write_queue_bytes), (Some(2), CacheConfig::WRITE_QUEUE));
886        assert_eq!(cache.disk.unwrap().dir(), Path::new("/tmp/c"));
887    }
888}