1use 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
18pub const CONFIG_ENV: &str = "ASC_CONFIG";
20
21#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Count {
44 Auto,
45 Fixed(usize),
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Amount {
51 Auto,
52 Bytes(u64),
53 Percent(u8),
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct Bytes(pub u64);
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum CacheDir {
63 Default,
64 Path(PathBuf),
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct Tiers(pub [bool; 3]);
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct Seconds(pub Duration);
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct Millis(pub Duration);
78
79pub trait Value: Sized + fmt::Display {
81 const EXPECTED: &'static str;
83 fn parse(text: &str) -> Option<Self>;
84 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
92fn 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
228fn 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#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct Setting<T> {
288 pub value: T,
289 pub source: Source,
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub struct Key {
295 pub name: &'static str,
297 pub env: &'static str,
298 pub legacy_env: Option<&'static str>,
300 pub help: &'static str,
301}
302
303#[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
314macro_rules! settings {
317 ($($field:ident: $ty:ty = $default:expr, $name:literal, $env:literal, $legacy:expr, $help:literal;)*) => {
318 #[derive(Debug, Clone, PartialEq, Eq)]
320 pub struct Config {
321 $(pub $field: Setting<$ty>,)*
322 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 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 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
387fn 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 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 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 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 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 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 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 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 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 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 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 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 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}