Krolyxon
Commit a44bdf7

Branches

Tags

Refs →

remagesrcconfigmod.rs

1048 lines · 33.3 KB · Rust

1//! Configuration: schema, compiled-in defaults, includes, validation and live reload.
2//!
3//! The compiled-in default is `default.toml` itself (`--dump-config` prints it), so every option
4//! has a default by construction. Layers merge in order — default, includes, the user file —
5//! with tables merged key by key. Each file is first deserialised on its own, which is what
6//! gives errors their `file:line:col`.
7
8pub mod theme;
9pub mod watch;
10
11use std::collections::BTreeMap;
12use std::fmt;
13use std::path::{Path, PathBuf};
14
15use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor};
16use serde::{Deserialize, Serialize};
17
18use crate::action::{Action, CATALOG};
19use crate::input::keymap::{Bindings, KeyTables};
20use crate::input::keys::KeySeq;
21use crate::state::AlphaBg;
22use crate::state::queue::SortKey;
23use crate::state::view::FitMode;
24use theme::{Theme, ThemeConfig};
25
26pub const DEFAULT: &str = include_str!("default.toml");
27
28/// Includes nest at most this deep.
29const MAX_INCLUDE_DEPTH: usize = 5;
30
31#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
32#[serde(default, deny_unknown_fields)]
33pub struct Config {
34 pub include: Vec<String>,
35 pub general: General,
36 pub window: WindowConfig,
37 pub image: ImageConfig,
38 pub grid: GridConfig,
39 pub ui: UiConfig,
40 pub animation: AnimationConfig,
41 pub theme: ThemeConfig,
42 pub keys: Keys,
43 pub commands: BTreeMap<String, Command>,
44 pub decoders: Vec<Decoder>,
45}
46
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
48#[serde(rename_all = "lowercase")]
49pub enum StartMode {
50 #[default]
51 Image,
52 Grid,
53}
54
55#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
56#[serde(default, deny_unknown_fields)]
57pub struct General {
58 pub open_siblings: bool,
59 pub recursive: bool,
60 pub sort: SortKey,
61 pub reverse: bool,
62 pub start_mode: StartMode,
63 pub wrap: bool,
64 pub watch_dir: bool,
65}
66
67#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
68#[serde(default, deny_unknown_fields)]
69pub struct WindowConfig {
70 pub decorations: bool,
71 pub title: String,
72 pub class: String,
73 pub fullscreen: bool,
74}
75
76#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
77#[serde(rename_all = "lowercase")]
78pub enum FilterMode {
79 #[default]
80 Auto,
81 Linear,
82 Nearest,
83}
84
85impl FilterMode {
86 /// `None` = automatic, otherwise whether to force nearest.
87 pub fn nearest(self) -> Option<bool> {
88 match self {
89 FilterMode::Auto => None,
90 FilterMode::Linear => Some(false),
91 FilterMode::Nearest => Some(true),
92 }
93 }
94}
95
96#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
97#[serde(default, deny_unknown_fields)]
98pub struct ImageConfig {
99 pub fit: FitMode,
100 pub zoom_step: f32,
101 pub filter: FilterMode,
102 pub alpha_background: AlphaBg,
103 pub auto_orient: bool,
104 pub color_management: bool,
105 pub preload: usize,
106 pub cache_mb: usize,
107 pub slideshow_delay: f32,
108}
109
110#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
111#[serde(default, deny_unknown_fields)]
112pub struct GridConfig {
113 pub sizes: Vec<u32>,
114 pub size: u32,
115 pub gap: u32,
116 pub thumbnail_cache: bool,
117 pub names: bool,
118}
119
120#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
121#[serde(default, deny_unknown_fields)]
122pub struct UiConfig {
123 pub statusbar: bool,
124 pub which_key_delay_ms: u64,
125 pub scale: f32,
126 pub statusbar_left: String,
127 pub statusbar_right: String,
128 pub info_command: String,
129 pub font_ui: String,
130 pub font_mono: String,
131 pub font_display: String,
132}
133
134#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
135#[serde(rename_all = "lowercase")]
136pub enum Transition {
137 #[default]
138 Fade,
139 None,
140}
141
142#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
143#[serde(default, deny_unknown_fields)]
144pub struct AnimationConfig {
145 pub enabled: bool,
146 pub duration_ms: u64,
147 pub transition: Transition,
148}
149
150#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
151#[serde(default, deny_unknown_fields)]
152pub struct Keys {
153 pub global: BindingTable,
154 pub image: BindingTable,
155 pub grid: BindingTable,
156 pub visual: BindingTable,
157}
158
159impl Keys {
160 fn tables(&self) -> KeyTables {
161 KeyTables {
162 global: self.global.bindings.clone(),
163 image: self.image.bindings.clone(),
164 grid: self.grid.bindings.clone(),
165 visual: self.visual.bindings.clone(),
166 }
167 }
168
169 fn first_error(&self) -> Option<&(std::ops::Range<usize>, String)> {
170 [&self.global, &self.image, &self.grid, &self.visual]
171 .into_iter()
172 .flat_map(|t| t.errors.first())
173 .min_by_key(|(span, _)| span.start)
174 }
175}
176
177/// A `[keys.<mode>]` table in file order.
178#[derive(Clone, Debug, Default, PartialEq)]
179pub struct BindingTable {
180 pub bindings: Bindings,
181 /// Bad key notation and duplicates, with the key's byte span.
182 pub errors: Vec<(std::ops::Range<usize>, String)>,
183}
184
185impl<'de> Deserialize<'de> for BindingTable {
186 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
187 struct V;
188 impl<'de> Visitor<'de> for V {
189 type Value = BindingTable;
190 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
191 f.write_str("a table of key = action")
192 }
193 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<BindingTable, A::Error> {
194 let mut out = BindingTable::default();
195 while let Some(k) = map.next_key::<toml::Spanned<String>>()? {
196 let actions = map.next_value::<Binding>()?.0;
197 // serde errors can't point at a key, so key errors are kept with their
198 // span and reported by `Layer::parse`.
199 let seq: KeySeq = match k.get_ref().parse() {
200 Ok(seq) => seq,
201 Err(e) => {
202 out.errors.push((k.span(), e));
203 continue;
204 }
205 };
206 if let Some((prev, _)) = out.bindings.iter().find(|(s, _)| *s == seq) {
207 let msg = format!(
208 "`{}` is bound twice in this table (also as `{prev}`)",
209 k.get_ref()
210 );
211 out.errors.push((k.span(), msg));
212 continue;
213 }
214 out.bindings.push((seq, actions));
215 }
216 Ok(out)
217 }
218 }
219 d.deserialize_map(V)
220 }
221}
222
223/// One binding value: an action, an array of actions, or `"none"`.
224#[derive(Clone, Debug, PartialEq)]
225pub struct Binding(pub Option<Vec<Action>>);
226
227impl<'de> Deserialize<'de> for Binding {
228 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
229 let list = ActionList::deserialize(d)?;
230 Ok(Binding(list.0))
231 }
232}
233
234/// An action or an array of them; `"none"` (or `[]`) is `None`.
235#[derive(Clone, Debug, PartialEq)]
236pub struct ActionList(pub Option<Vec<Action>>);
237
238impl<'de> Deserialize<'de> for ActionList {
239 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
240 struct V;
241 fn parse<E: de::Error>(s: &str) -> Result<Action, E> {
242 s.parse().map_err(|e| E::custom(e))
243 }
244 impl<'de> Visitor<'de> for V {
245 type Value = ActionList;
246 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
247 f.write_str("an action, an array of actions, or \"none\"")
248 }
249 fn visit_str<E: de::Error>(self, s: &str) -> Result<ActionList, E> {
250 if s.trim() == "none" {
251 return Ok(ActionList(None));
252 }
253 Ok(ActionList(Some(vec![parse(s)?])))
254 }
255 fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<ActionList, A::Error> {
256 let mut out = Vec::new();
257 while let Some(s) = seq.next_element::<String>()? {
258 out.push(parse(&s)?);
259 }
260 Ok(ActionList((!out.is_empty()).then_some(out)))
261 }
262 }
263 d.deserialize_any(V)
264 }
265}
266
267impl Default for ActionList {
268 fn default() -> Self {
269 ActionList(Some(vec![Action::Refresh]))
270 }
271}
272
273/// A command line: a string runs through `sh -c`, an array is exec'd directly.
274#[derive(Clone, Debug, PartialEq, Deserialize)]
275#[serde(untagged)]
276pub enum CommandLine {
277 Shell(String),
278 Exec(Vec<String>),
279}
280
281impl Default for CommandLine {
282 fn default() -> Self {
283 CommandLine::Exec(Vec::new())
284 }
285}
286
287#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
288#[serde(rename_all = "lowercase")]
289pub enum Targets {
290 #[default]
291 Auto,
292 Current,
293 Marked,
294 All,
295}
296
297#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
298#[serde(rename_all = "lowercase")]
299pub enum Stdin {
300 #[default]
301 None,
302 Files,
303 Files0,
304}
305
306#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
307#[serde(rename_all = "lowercase")]
308pub enum Output {
309 #[default]
310 Status,
311 Actions,
312 Ignore,
313}
314
315/// A `[commands.<name>]` entry. Execution arrives with M4.
316#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
317#[serde(default, deny_unknown_fields)]
318pub struct Command {
319 pub desc: String,
320 pub run: CommandLine,
321 pub targets: Targets,
322 pub stdin: Stdin,
323 pub confirm: Option<String>,
324 pub output: Output,
325 pub after: ActionList,
326}
327
328/// A `[[decoders]]` entry. Used from M5.
329#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
330#[serde(default, deny_unknown_fields)]
331pub struct Decoder {
332 #[serde(rename = "match")]
333 pub patterns: Vec<String>,
334 pub run: Vec<String>,
335}
336
337/// A successfully loaded configuration.
338#[derive(Clone, Debug)]
339pub struct Loaded {
340 pub config: Config,
341 /// Key tables per layer, lowest priority first.
342 pub keys: Vec<KeyTables>,
343 pub theme: Theme,
344 pub warnings: Vec<String>,
345 /// Files whose changes should trigger a reload.
346 pub watch: Vec<PathBuf>,
347}
348
349#[derive(Clone, Debug)]
350pub struct LoadError {
351 pub message: String,
352 pub watch: Vec<PathBuf>,
353}
354
355impl fmt::Display for LoadError {
356 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
357 f.write_str(&self.message)
358 }
359}
360
361/// `$XDG_CONFIG_HOME/remage/config.toml`, falling back to `~/.config/remage/`.
362pub fn default_path() -> Option<PathBuf> {
363 dirs::config_dir().map(|d| d.join("remage").join("config.toml"))
364}
365
366/// The closest candidate within a small edit distance, for "did you mean" hints.
367pub fn suggest<'a>(word: &str, candidates: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
368 let word = word.to_lowercase();
369 candidates
370 .into_iter()
371 .map(|c| (levenshtein(&word, &c.to_lowercase()), c))
372 .filter(|(d, _)| *d > 0 && (*d <= 2 || *d * 3 <= word.chars().count()))
373 .min_by_key(|(d, _)| *d)
374 .map(|(_, c)| c)
375}
376
377fn levenshtein(a: &str, b: &str) -> usize {
378 let b: Vec<char> = b.chars().collect();
379 let mut row: Vec<usize> = (0..=b.len()).collect();
380 for (i, ca) in a.chars().enumerate() {
381 let mut prev = row[0];
382 row[0] = i + 1;
383 for (j, cb) in b.iter().enumerate() {
384 let cur = row[j + 1];
385 row[j + 1] = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(cur + 1);
386 prev = cur;
387 }
388 }
389 row[b.len()]
390}
391
392/// Expand `~` and `$VAR` / `${VAR}`; relative paths resolve against `base`.
393pub fn expand_path(s: &str, base: &Path) -> PathBuf {
394 let mut out = String::new();
395 let mut rest = s;
396 if let Some(r) = rest.strip_prefix('~')
397 && (r.is_empty() || r.starts_with('/'))
398 && let Some(home) = dirs::home_dir()
399 {
400 out.push_str(&home.to_string_lossy());
401 rest = r;
402 }
403 while let Some(i) = rest.find('$') {
404 out.push_str(&rest[..i]);
405 let after = &rest[i + 1..];
406 let (name, used) = if let Some(b) = after.strip_prefix('{') {
407 match b.find('}') {
408 Some(end) => (&b[..end], end + 2),
409 None => ("", 0),
410 }
411 } else {
412 let end = after
413 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
414 .unwrap_or(after.len());
415 (&after[..end], end)
416 };
417 if name.is_empty() {
418 out.push('$');
419 rest = after;
420 } else {
421 out.push_str(&std::env::var(name).unwrap_or_default());
422 rest = &after[used..];
423 }
424 }
425 out.push_str(rest);
426 let p = PathBuf::from(out);
427 if p.is_relative() { base.join(p) } else { p }
428}
429
430fn line_col(src: &str, offset: usize) -> (usize, usize) {
431 let offset = offset.min(src.len());
432 let before = &src[..offset];
433 let line = before.matches('\n').count() + 1;
434 let col = before
435 .rfind('\n')
436 .map_or(before, |i| &before[i + 1..])
437 .chars()
438 .count()
439 + 1;
440 (line, col)
441}
442
443/// Add a "did you mean" to serde's unknown field / variant messages.
444fn with_hint(msg: &str) -> String {
445 let Some(rest) = msg
446 .strip_prefix("unknown field `")
447 .or_else(|| msg.strip_prefix("unknown variant `"))
448 else {
449 return msg.to_string();
450 };
451 let Some((word, expected)) = rest.split_once('`') else {
452 return msg.to_string();
453 };
454 let options: Vec<&str> = expected.split('`').skip(1).step_by(2).collect();
455 match suggest(word, options) {
456 Some(s) => format!("{msg}; did you mean `{s}`?"),
457 None => msg.to_string(),
458 }
459}
460
461fn located(file: &Path, src: &str, span: Option<std::ops::Range<usize>>, msg: &str) -> String {
462 let msg = with_hint(msg.trim_end());
463 match span {
464 Some(s) => {
465 let (l, c) = line_col(src, s.start);
466 format!("{}:{l}:{c}: {msg}", file.display())
467 }
468 None => format!("{}: {msg}", file.display()),
469 }
470}
471
472/// One parsed config file.
473struct Layer {
474 file: PathBuf,
475 src: String,
476 table: toml::Table,
477 keys: KeyTables,
478}
479
480impl Layer {
481 fn parse(file: PathBuf, src: String) -> Result<(Layer, Config), String> {
482 let typed: Config =
483 toml::from_str(&src).map_err(|e| located(&file, &src, e.span(), e.message()))?;
484 if let Some((span, msg)) = typed.keys.first_error() {
485 return Err(located(&file, &src, Some(span.clone()), msg));
486 }
487 let table: toml::Table =
488 toml::from_str(&src).map_err(|e| located(&file, &src, e.span(), e.message()))?;
489 let keys = typed.keys.tables();
490 Ok((
491 Layer {
492 file,
493 src,
494 table,
495 keys,
496 },
497 typed,
498 ))
499 }
500
501 /// `file:line:col` of a dotted path in this file, if it sets it.
502 fn locate(&self, path: &[&str], msg: &str) -> Option<String> {
503 let span = span_of(&self.src, path, |_| true)?;
504 Some(located(&self.file, &self.src, Some(span), msg))
505 }
506}
507
508type DeTable<'i> = toml::de::DeTable<'i>;
509type DeValue<'i> = toml::de::DeValue<'i>;
510
511fn entry<'a, 'i>(t: &'a DeTable<'i>, key: &str) -> Option<&'a toml::Spanned<DeValue<'i>>> {
512 t.iter().find(|(k, _)| k.get_ref() == key).map(|(_, v)| v)
513}
514
515/// The span of the value at a dotted path; with `pick`, of the first entry in the table at
516/// `path` whose value it accepts.
517fn span_of(
518 src: &str,
519 path: &[&str],
520 pick: impl Fn(&DeValue) -> bool,
521) -> Option<std::ops::Range<usize>> {
522 let doc = DeTable::parse(src).ok()?;
523 let (last, init) = path.split_last()?;
524 let mut table = doc.get_ref();
525 for p in init {
526 table = entry(table, p)?.get_ref().as_table()?;
527 }
528 let v = entry(table, last)?;
529 match v.get_ref().as_table() {
530 Some(t) if path.len() > 1 && path[0] == "keys" => t
531 .iter()
532 .find(|(_, v)| pick(v.get_ref()))
533 .map(|(_, v)| v.span()),
534 _ => Some(v.span()),
535 }
536}
537
538struct Loader {
539 layers: Vec<Layer>,
540 warnings: Vec<String>,
541 watch: Vec<PathBuf>,
542 stack: Vec<PathBuf>,
543}
544
545impl Loader {
546 fn watch(&mut self, path: &Path) {
547 for p in [Some(path.to_path_buf()), std::fs::canonicalize(path).ok()]
548 .into_iter()
549 .flatten()
550 {
551 if !self.watch.contains(&p) {
552 self.watch.push(p);
553 }
554 }
555 }
556
557 fn file(&mut self, path: &Path) -> Result<(), String> {
558 self.watch(path);
559 let src = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
560 let (layer, typed) = Layer::parse(path.to_path_buf(), src)?;
561 let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
562 self.stack.push(canon);
563 let base = path.parent().unwrap_or(Path::new("/")).to_path_buf();
564 for inc in &typed.include {
565 let p = expand_path(inc, &base);
566 let canon = std::fs::canonicalize(&p).unwrap_or_else(|_| p.clone());
567 if self.stack.contains(&canon) {
568 let msg = format!("include cycle through {}", p.display());
569 return Err(layer.locate(&["include"], &msg).unwrap_or(msg));
570 }
571 if self.stack.len() > MAX_INCLUDE_DEPTH {
572 let msg = format!("includes nest deeper than {MAX_INCLUDE_DEPTH}");
573 return Err(layer.locate(&["include"], &msg).unwrap_or(msg));
574 }
575 if p.is_file() {
576 self.file(&p)?;
577 } else {
578 // Watched anyway: matugen or pywal may create it later.
579 self.watch(&p);
580 self.warnings.push(format!(
581 "{}: include not found: {}",
582 path.display(),
583 p.display()
584 ));
585 }
586 }
587 self.stack.pop();
588 self.layers.push(layer);
589 Ok(())
590 }
591
592 /// The last layer that sets `path`, formatted as an error there.
593 fn error_at(&self, path: &[&str], msg: &str) -> String {
594 self.layers
595 .iter()
596 .rev()
597 .find_map(|l| l.locate(path, msg))
598 .unwrap_or_else(|| format!("{}: {msg}", path.join(".")))
599 }
600}
601
602/// Merge `over` into `base`, table by table. Commands replace whole, not field by field.
603fn merge(base: &mut toml::Table, over: toml::Table, depth: usize, key: &str) {
604 for (k, v) in over {
605 match (base.get_mut(&k), v) {
606 (Some(toml::Value::Table(b)), toml::Value::Table(o))
607 if !(depth == 1 && key == "commands") =>
608 {
609 merge(b, o, depth + 1, &k);
610 }
611 (_, v) => {
612 base.insert(k, v);
613 }
614 }
615 }
616}
617
618fn default_layer() -> (Layer, Config) {
619 match Layer::parse(PathBuf::from("<default>"), DEFAULT.to_string()) {
620 Ok(l) => l,
621 // The default is tested; this only guards against a broken build.
622 Err(e) => panic!("built-in default config: {e}"),
623 }
624}
625
626/// The compiled-in defaults alone.
627pub fn defaults() -> Loaded {
628 let (layer, config) = default_layer();
629 Loaded {
630 theme: theme::resolve(&config.theme, None),
631 keys: vec![layer.keys],
632 config,
633 warnings: Vec::new(),
634 watch: Vec::new(),
635 }
636}
637
638/// Load the config at `path` (or the default location) over the defaults. An explicit path
639/// must exist; a missing default file just means defaults.
640pub fn load(path: Option<&Path>) -> Result<Loaded, LoadError> {
641 let explicit = path.is_some();
642 let path = path.map(Path::to_path_buf).or_else(default_path);
643 let mut loader = Loader {
644 layers: vec![default_layer().0],
645 warnings: Vec::new(),
646 watch: Vec::new(),
647 stack: Vec::new(),
648 };
649 let result = (|| {
650 if let Some(path) = &path {
651 if path.is_file() || explicit {
652 loader.file(path)?;
653 } else {
654 loader.watch(path);
655 }
656 }
657 finish(&mut loader)
658 })();
659 result.map_err(|message| LoadError {
660 message,
661 watch: loader.watch.clone(),
662 })
663}
664
665fn finish(loader: &mut Loader) -> Result<Loaded, String> {
666 let mut table = toml::Table::new();
667 for layer in &loader.layers {
668 let mut t = layer.table.clone();
669 t.remove("keys");
670 t.remove("include");
671 merge(&mut table, t, 0, "");
672 }
673 let config: Config = toml::Value::Table(table)
674 .try_into()
675 .map_err(|e: toml::de::Error| format!("merged config: {}", e.message()))?;
676 validate(&config, loader)?;
677
678 let pywal = match &config.theme.pywal {
679 Some(p) => {
680 let base = loader.layers.last().and_then(|l| l.file.parent());
681 let path = expand_path(p, base.unwrap_or(Path::new("/")));
682 loader.watch(&path);
683 match theme::read_pywal(&path) {
684 Ok(colors) => Some(colors),
685 Err(e) => {
686 loader.warnings.push(format!("pywal: {e}"));
687 None
688 }
689 }
690 }
691 None => None,
692 };
693 Ok(Loaded {
694 theme: theme::resolve(&config.theme, pywal),
695 keys: loader.layers.iter().map(|l| l.keys.clone()).collect(),
696 config,
697 warnings: loader.warnings.clone(),
698 watch: loader.watch.clone(),
699 })
700}
701
702/// Range checks and cross-references that the types alone can't express.
703fn validate(c: &Config, loader: &Loader) -> Result<(), String> {
704 let check = |ok: bool, path: &[&str], msg: &str| -> Result<(), String> {
705 if ok {
706 Ok(())
707 } else {
708 Err(loader.error_at(path, msg))
709 }
710 };
711 let i = &c.image;
712 check(
713 i.zoom_step > 1.0 && i.zoom_step <= 10.0,
714 &["image", "zoom_step"],
715 "zoom_step must be above 1.0 and at most 10",
716 )?;
717 check(
718 i.preload <= 16,
719 &["image", "preload"],
720 "preload must be at most 16",
721 )?;
722 check(
723 i.cache_mb >= 16,
724 &["image", "cache_mb"],
725 "cache_mb must be at least 16",
726 )?;
727 check(
728 i.slideshow_delay.is_finite() && i.slideshow_delay > 0.0,
729 &["image", "slideshow_delay"],
730 "slideshow_delay must be positive",
731 )?;
732 let g = &c.grid;
733 check(
734 !g.sizes.is_empty()
735 && g.sizes.windows(2).all(|w| w[0] < w[1])
736 && g.sizes.iter().all(|s| (16..=1024).contains(s)),
737 &["grid", "sizes"],
738 "sizes must be ascending, each between 16 and 1024",
739 )?;
740 check(
741 (16..=1024).contains(&g.size),
742 &["grid", "size"],
743 "size must be between 16 and 1024",
744 )?;
745 check(g.gap <= 256, &["grid", "gap"], "gap must be at most 256")?;
746 check(
747 c.ui.scale.is_finite() && (0.25..=4.0).contains(&c.ui.scale),
748 &["ui", "scale"],
749 "scale must be between 0.25 and 4",
750 )?;
751 check(
752 c.animation.duration_ms <= 2000,
753 &["animation", "duration_ms"],
754 "duration_ms must be at most 2000",
755 )?;
756 check(
757 !c.window.class.is_empty(),
758 &["window", "class"],
759 "class must not be empty",
760 )?;
761 for (name, cmd) in &c.commands {
762 let empty = match &cmd.run {
763 CommandLine::Shell(s) => s.trim().is_empty(),
764 CommandLine::Exec(v) => v.is_empty(),
765 };
766 check(
767 !empty,
768 &["commands", name],
769 &format!("command `{name}` needs a `run`"),
770 )?;
771 }
772 // `run <name>` must name a command.
773 for layer in &loader.layers {
774 for (mode, table) in [
775 ("global", &layer.keys.global),
776 ("image", &layer.keys.image),
777 ("grid", &layer.keys.grid),
778 ("visual", &layer.keys.visual),
779 ] {
780 for (_, actions) in table {
781 for a in actions.iter().flatten() {
782 if let Action::Run(name) = a
783 && !c.commands.contains_key(name)
784 {
785 let hint = suggest(name, c.commands.keys().map(String::as_str))
786 .map(|s| format!(", did you mean `{s}`?"))
787 .unwrap_or_default();
788 let msg = format!("no command named `{name}`{hint}");
789 let loc = span_of(&layer.src, &["keys", mode], |v| mentions(v, name))
790 .map(|span| located(&layer.file, &layer.src, Some(span), &msg));
791 return Err(loc.unwrap_or(msg));
792 }
793 }
794 }
795 }
796 }
797 Ok(())
798}
799
800/// Whether a binding value contains `run <name>`.
801fn mentions(v: &DeValue, name: &str) -> bool {
802 let is_run = |s: &str| matches!(s.parse(), Ok(Action::Run(n)) if n == name);
803 match v {
804 DeValue::String(s) => is_run(s),
805 DeValue::Array(a) => a.iter().any(|e| e.get_ref().as_str().is_some_and(is_run)),
806 _ => false,
807 }
808}
809
810/// The full commented default, plus a reference of every action.
811pub fn dump() -> String {
812 let mut out = DEFAULT.to_string();
813 out.push_str("\n# Actions, for key bindings, the : palette, IPC and command output:\n#\n");
814 for e in CATALOG {
815 let usage = if e.args.is_empty() {
816 e.name.to_string()
817 } else {
818 format!("{} {}", e.name, e.args)
819 };
820 out.push_str(&format!("# {usage:<46} {}\n", e.desc));
821 }
822 out
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828 use crate::input::keymap::Keymap;
829 use crate::state::Mode;
830
831 fn tmp(name: &str) -> PathBuf {
832 let dir = std::env::temp_dir().join(format!("remage-test-{}-{name}", std::process::id()));
833 let _ = std::fs::remove_dir_all(&dir);
834 std::fs::create_dir_all(&dir).unwrap();
835 dir
836 }
837
838 fn write(dir: &Path, name: &str, src: &str) -> PathBuf {
839 let p = dir.join(name);
840 std::fs::write(&p, src).unwrap();
841 p
842 }
843
844 fn err(src: &str) -> String {
845 static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
846 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
847 let dir = tmp(&format!("err{n}"));
848 let p = write(&dir, "config.toml", src);
849 load(Some(&p))
850 .unwrap_err()
851 .message
852 .replace(&dir.display().to_string(), "")
853 }
854
855 #[test]
856 fn default_parses_and_is_complete() {
857 let d = defaults();
858 assert_eq!(d.config.image.fit, FitMode::Down);
859 assert_eq!(d.config.grid.sizes, [96, 128, 160, 224, 320]);
860 assert!(d.config.commands.is_empty());
861 // Every field of the plain sections is spelled out in default.toml, so none of the
862 // placeholder serde defaults can leak through.
863 let doc: toml::Table = toml::from_str(DEFAULT).unwrap();
864 let sections: [(&str, toml::Value); 6] = [
865 ("general", toml::Value::try_from(&d.config.general).unwrap()),
866 ("window", toml::Value::try_from(&d.config.window).unwrap()),
867 ("image", toml::Value::try_from(&d.config.image).unwrap()),
868 ("grid", toml::Value::try_from(&d.config.grid).unwrap()),
869 ("ui", toml::Value::try_from(&d.config.ui).unwrap()),
870 (
871 "animation",
872 toml::Value::try_from(&d.config.animation).unwrap(),
873 ),
874 ];
875 for (name, v) in sections {
876 let written = doc[name].as_table().unwrap();
877 for key in v.as_table().unwrap().keys() {
878 assert!(written.contains_key(key), "default.toml lacks {name}.{key}");
879 }
880 }
881 assert!(doc["theme"].as_table().unwrap().contains_key("preset"));
882 }
883
884 #[test]
885 fn default_validates() {
886 let mut loader = Loader {
887 layers: vec![default_layer().0],
888 warnings: Vec::new(),
889 watch: Vec::new(),
890 stack: Vec::new(),
891 };
892 finish(&mut loader).unwrap();
893 }
894
895 #[test]
896 fn partial_file_merges_over_defaults() {
897 let dir = tmp("partial");
898 let p = write(&dir, "config.toml", "[image]\nfit = \"width\"\n");
899 let l = load(Some(&p)).unwrap();
900 assert_eq!(l.config.image.fit, FitMode::Width);
901 assert_eq!(l.config.image.zoom_step, 1.25);
902 assert_eq!(l.config.window.class, "remage");
903 assert!(l.watch.contains(&p));
904 }
905
906 #[test]
907 fn errors_carry_file_line_col_and_hints() {
908 assert_eq!(
909 err("[image]\nfitt = \"down\"\n"),
910 "/config.toml:2:1: unknown field `fitt`, expected one of `fit`, `zoom_step`, \
911 `filter`, `alpha_background`, `auto_orient`, `color_management`, `preload`, \
912 `cache_mb`, `slideshow_delay`; did you mean `fit`?"
913 );
914 let e = err("[image]\nfit = \"wdth\"\n");
915 assert!(
916 e.starts_with("/config.toml:2:7: unknown variant `wdth`"),
917 "{e}"
918 );
919 assert!(e.ends_with("did you mean `width`?"), "{e}");
920 let e = err("[keys.global]\nx = \"nxt\"\n");
921 assert!(
922 e.starts_with("/config.toml:2:5: unknown action `nxt`"),
923 "{e}"
924 );
925 let e = err("[keys.global]\n\"<C-Bogus>\" = \"next\"\n");
926 assert!(e.contains("/config.toml:2:"), "{e}");
927 assert!(e.contains("unknown key `<C-Bogus>`"), "{e}");
928 let e = err("[image]\nzoom_step = 0.5\n");
929 assert!(e.starts_with("/config.toml:2:13: zoom_step"), "{e}");
930 let e = err("[keys.image]\n\"<Space>w\" = \"run wallpaper\"\n");
931 assert!(
932 e.starts_with("/config.toml:2:14: no command named `wallpaper`"),
933 "{e}"
934 );
935 let e = err("[theme]\naccent = \"#12345\"\n");
936 assert!(e.starts_with("/config.toml:2:10:"), "{e}");
937 let e = err("[keys.global]\n\"G\" = \"last\"\n\"<S-g>\" = \"first\"\n");
938 assert!(e.contains("bound twice"), "{e}");
939 let e = err("[image\n");
940 assert!(e.starts_with("/config.toml:1:"), "{e}");
941 }
942
943 #[test]
944 fn includes_load_in_order_and_the_includer_wins() {
945 let dir = tmp("include");
946 write(
947 &dir,
948 "a.toml",
949 "[theme]\nbackground = \"#101010\"\naccent = \"#00ff00\"\n[image]\nfit = \"fill\"\n",
950 );
951 write(&dir, "b.toml", "[theme]\naccent = \"#0000ff\"\n");
952 let p = write(
953 &dir,
954 "config.toml",
955 "include = [\"a.toml\", \"b.toml\", \"missing.toml\"]\n[image]\nfit = \"width\"\n\
956 [commands.wallpaper]\nrun = [\"true\"]\n\
957 [keys.global]\n\"<Space>w\" = \"run wallpaper\"\n",
958 );
959 let l = load(Some(&p)).unwrap();
960 assert_eq!(l.config.image.fit, FitMode::Width);
961 assert_eq!(l.theme.background, theme::Rgba::rgb(0x101010));
962 assert_eq!(l.theme.accent, theme::Rgba::rgb(0x0000ff));
963 assert_eq!(l.warnings.len(), 1);
964 assert!(l.warnings[0].contains("missing.toml"));
965 assert!(l.watch.contains(&dir.join("missing.toml")));
966 assert!(l.watch.contains(&dir.join("a.toml")));
967 let refs: Vec<&KeyTables> = l.keys.iter().collect();
968 let km = Keymap::new(&refs);
969 let w: KeySeq = "<Space>w".parse().unwrap();
970 assert_eq!(
971 km.lookup(Mode::Image, &w.0),
972 Some(&[Action::Run("wallpaper".into())][..])
973 );
974 }
975
976 #[test]
977 fn include_cycles_are_errors() {
978 let dir = tmp("cycle");
979 write(&dir, "a.toml", "include = [\"config.toml\"]\n");
980 let p = write(&dir, "config.toml", "include = [\"a.toml\"]\n");
981 assert!(load(Some(&p)).unwrap_err().message.contains("cycle"));
982 }
983
984 #[test]
985 fn commands_replace_whole() {
986 let dir = tmp("commands");
987 write(
988 &dir,
989 "a.toml",
990 "[commands.rm]\nrun = [\"rm\", \"{files}\"]\nconfirm = \"Sure?\"\n",
991 );
992 let p = write(
993 &dir,
994 "config.toml",
995 "include = [\"a.toml\"]\n[commands.rm]\nrun = \"trash {files}\"\n",
996 );
997 let l = load(Some(&p)).unwrap();
998 let rm = &l.config.commands["rm"];
999 assert_eq!(rm.confirm, None);
1000 assert_eq!(rm.run, CommandLine::Shell("trash {files}".into()));
1001 assert_eq!(rm.after, ActionList(Some(vec![Action::Refresh])));
1002 }
1003
1004 #[test]
1005 fn missing_explicit_file_is_an_error() {
1006 let dir = tmp("missing");
1007 assert!(load(Some(&dir.join("nope.toml"))).is_err());
1008 }
1009
1010 #[test]
1011 fn path_expansion() {
1012 let base = Path::new("/etc/remage");
1013 // SAFETY: tests in this module don't read REMAGE_TEST_DIR concurrently.
1014 unsafe { std::env::set_var("REMAGE_TEST_DIR", "/opt/x") };
1015 assert_eq!(
1016 expand_path("a.toml", base),
1017 PathBuf::from("/etc/remage/a.toml")
1018 );
1019 assert_eq!(
1020 expand_path("$REMAGE_TEST_DIR/a", base),
1021 PathBuf::from("/opt/x/a")
1022 );
1023 assert_eq!(
1024 expand_path("${REMAGE_TEST_DIR}b", base),
1025 PathBuf::from("/opt/xb")
1026 );
1027 let home = dirs::home_dir().unwrap();
1028 assert_eq!(expand_path("~/c", base), home.join("c"));
1029 assert_eq!(expand_path("/abs", base), PathBuf::from("/abs"));
1030 }
1031
1032 #[test]
1033 fn suggestions() {
1034 assert_eq!(suggest("nxt", ["next", "prev"]), Some("next"));
1035 assert_eq!(suggest("zzzzzz", ["next", "prev"]), None);
1036 assert_eq!(suggest("next", ["next"]), None);
1037 }
1038
1039 #[test]
1040 fn dump_lists_every_action() {
1041 let d = dump();
1042 for e in CATALOG {
1043 assert!(d.contains(&format!("# {}", e.name)), "{}", e.name);
1044 }
1045 // The dump is itself a valid config.
1046 toml::from_str::<Config>(&d).unwrap();
1047 }
1048}