shoin 0.2.1

A no-clutter terminal Markdown editor with modal editing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
//! Config discovery, parsing, and hot reload. SPEC.md §8.
//!
//! A config location is either a single `.conf` file or a DIRECTORY of `.conf`
//! files (conf.d style) — the standard `~/.config/<app>/*.conf` layout. When a
//! directory is used, its `*.conf` files are read in sorted order and deep-merged
//! (later keys win), so settings can be split by category (`theme.conf`,
//! `keys.conf`, …). Missing keys fall back to defaults, so any subset is valid.

pub mod init;
pub mod keys;
pub mod schema;

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

pub use schema::Config;

/// The config files to merge, and the path to watch for hot reload.
struct Located {
    files: Vec<PathBuf>,
    watch: PathBuf,
}

/// The `.conf` file(s) a candidate path resolves to: itself if a file, its
/// sorted `*.conf` children if a directory, nothing otherwise.
fn resolve(path: &Path) -> Vec<PathBuf> {
    if path.is_file() {
        return vec![path.to_path_buf()];
    }
    if path.is_dir() {
        let mut files: Vec<PathBuf> = std::fs::read_dir(path)
            .into_iter()
            .flatten()
            .flatten()
            .map(|e| e.path())
            .filter(|p| p.extension().is_some_and(|e| e == "conf"))
            .collect();
        files.sort();
        return files;
    }
    Vec::new()
}

/// Candidate locations, first that resolves to any file wins:
///   1. `--config <path>`            (file or directory; used alone if given)
///   2. `$SHOIN_CONFIG`       (file or directory)
///   3. `./shoin.conf`        (project-local single file)
///   4. `./shoin/`            (project-local conf.d directory)
///   5. `$XDG_CONFIG_HOME/shoin/`
///   6. `~/.config/shoin/`
fn candidates(explicit: Option<&Path>) -> Vec<PathBuf> {
    if let Some(p) = explicit {
        return vec![p.to_path_buf()];
    }
    let mut c = Vec::new();
    if let Ok(p) = std::env::var("SHOIN_CONFIG") {
        c.push(PathBuf::from(p));
    }
    c.push(PathBuf::from("shoin.conf"));
    c.push(PathBuf::from("shoin"));
    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
        c.push(PathBuf::from(xdg).join("shoin"));
    }
    if let Ok(home) = std::env::var("HOME") {
        c.push(PathBuf::from(home).join(".config").join("shoin"));
    }
    c
}

fn located(explicit: Option<&Path>) -> Option<Located> {
    for cand in candidates(explicit) {
        let files = resolve(&cand);
        if !files.is_empty() {
            return Some(Located { files, watch: cand });
        }
    }
    None
}

/// The resolved config files (empty = built-in defaults).
pub fn discover(explicit: Option<&Path>) -> Vec<PathBuf> {
    located(explicit).map(|l| l.files).unwrap_or_default()
}

/// Resolve and merge the config, falling back to defaults for anything absent.
pub fn load(explicit: Option<&Path>) -> Result<Config> {
    merge_files(&discover(explicit))
}

/// Parse one TOML string over the defaults. Every real config path merges a
/// directory, so this exists for tests that want a config in one literal.
#[cfg(test)]
pub fn parse(text: &str) -> Result<Config> {
    let mut merged = shipped_keys();
    merge_tables(&mut merged, toml::from_str(text)?);
    from_table(merged)
}

/// The shipped `keys.conf`, as the table every config merges over.
///
/// `Config::default()` has empty `[keys.*]`, so without this base every
/// `<leader>` binding did nothing until `--init-config` had been run. Layering
/// the shipped file UNDER the user's is what makes a fresh install work, and it
/// keeps working for a partial config: a lone `theme.conf` still gets
/// `<leader>fe`. A shipped binding is removed by binding it to `"none"`.
///
/// Only `keys.conf`. Layering the other shipped files would mark every glyph
/// as user-set, and `nerd_fonts = false` would then fall back on nothing. Their
/// values are the serde defaults already.
fn shipped_keys() -> toml::Table {
    let (_, body) = init::SHIPPED
        .iter()
        .find(|(name, _)| *name == "keys.conf")
        .expect("keys.conf is shipped");
    toml::from_str(body).expect("the shipped keys.conf parses")
}

/// Deserialize the merged table, then run the one post-merge pass — settings
/// whose value depends on ANOTHER setting, which serde cannot resolve while it
/// fills in defaults. Every `Config` in the program comes through here, hot
/// reload included.
///
/// The raw table is what makes that possible: it still knows which keys the
/// user actually wrote, a distinction the filled-in struct has lost.
fn from_table(table: toml::Table) -> Result<Config> {
    let set: std::collections::HashSet<String> = table
        .get("glyphs")
        .and_then(|v| v.as_table())
        .map(|t| t.keys().cloned().collect())
        .unwrap_or_default();
    let mut cfg: Config = toml::Value::Table(table)
        .try_into()
        .context("resolving merged config")?;
    cfg.glyphs.apply_ascii_fallback(|key| set.contains(key));
    Ok(cfg)
}

/// Settings whose value is one word out of a fixed set.
///
/// Every one of these parsers is LENIENT — it has to be, or one stale value in
/// one file would refuse the whole config, which is the leniency `[keys.*]`
/// already gets. The cost of leniency is silence: `cursor.normal = "blok"` is a
/// block cursor and no explanation. So the fallback stays and this makes it
/// visible, through the same startup/reload flash a bad keybinding uses.
///
/// This exists because `editor.final_newline` was documented, shipped, and read
/// by nothing at all — the kind of rot that is only ever found by asking every
/// setting, in one place, whether anyone understood it.
pub fn validate(cfg: &Config) -> Vec<String> {
    let mut out = Vec::new();
    let mut check = |name: &str, value: &str, ok: bool, legal: &str| {
        if !ok {
            out.push(format!("{name}: {legal}, not {value:?}"));
        }
    };

    check(
        "editor.final_newline",
        &cfg.editor.final_newline,
        crate::fs::save::FinalNewline::parse(&cfg.editor.final_newline).is_some(),
        "preserve · always · never",
    );
    check(
        "editor.table_align",
        &cfg.editor.table_align,
        crate::text::table::TableAlign::parse(&cfg.editor.table_align).is_some(),
        "auto · manual",
    );
    {
        // A number, so the value shown is the number that was written — the
        // point of the message is that 9 is not one of five, not that "9" is
        // an unknown word.
        let n = cfg.editor.autosave_interval;
        check(
            "editor.autosave_interval",
            &n.to_string(),
            crate::fs::save::AutosaveInterval::parse(n).is_some(),
            "1 to 5 minutes",
        );
    }
    check(
        "layout.focus",
        &cfg.layout.focus,
        crate::render::focus::FocusMode::parse(&cfg.layout.focus).is_some(),
        "off · paragraph · sentence",
    );
    check(
        "layout.numbers",
        &cfg.layout.numbers,
        crate::render::numbers::NumberMode::parse(&cfg.layout.numbers).is_some(),
        "off · relative · absolute",
    );
    check(
        "layout.align",
        &cfg.layout.align,
        matches!(cfg.layout.align.trim(), "left" | "center" | "centre"),
        "left · center",
    );
    check(
        "layout.text_align",
        &cfg.layout.text_align,
        crate::render::align::TextAlign::parse(&cfg.layout.text_align).is_some(),
        "left · center · right · justified",
    );
    check(
        "transclude.embed",
        &cfg.transclude.embed,
        crate::transclude::Mode::parse(&cfg.transclude.embed).is_some(),
        "none · short · rec · full",
    );
    for (name, value) in [
        ("cursor.normal", &cfg.cursor.normal),
        ("cursor.insert", &cfg.cursor.insert),
        ("cursor.visual", &cfg.cursor.visual),
    ] {
        check(
            name,
            value,
            crate::app::CursorShape::parse(value).is_some(),
            "block · bar · underline",
        );
    }
    out
}

/// Deep-merge a set of `.conf` files (in order) over [`shipped_keys`] into a
/// single `Config`. No files at all is the shipped bindings over the defaults.
fn merge_files(files: &[PathBuf]) -> Result<Config> {
    let mut merged = shipped_keys();
    for f in files {
        let text = std::fs::read_to_string(f)
            .with_context(|| format!("reading config {}", f.display()))?;
        let table: toml::Table =
            toml::from_str(&text).with_context(|| format!("parsing config {}", f.display()))?;
        merge_tables(&mut merged, table);
    }
    from_table(merged)
}

/// Recursively merge `over` into `base`; a nested table merges key-by-key, any
/// other value replaces.
fn merge_tables(base: &mut toml::Table, over: toml::Table) {
    for (key, value) in over {
        match (base.get_mut(&key), value) {
            (Some(toml::Value::Table(bt)), toml::Value::Table(ot)) => merge_tables(bt, ot),
            (_, value) => {
                base.insert(key, value);
            }
        }
    }
}

/// Watches the resolved config location with `notify`. Watching a directory
/// catches any `.conf` file being edited, added, or removed.
///
/// On change the caller re-runs [`reload`], which re-discovers and merges; a
/// candidate is swapped in only if it parses. A broken config never takes down a
/// running editor holding unsaved text.
pub struct ConfigWatcher {
    explicit: Option<PathBuf>,
    rx: std::sync::mpsc::Receiver<()>,
    _watcher: notify::RecommendedWatcher,
}

impl ConfigWatcher {
    /// Begin watching the config location for `explicit` (or the discovered
    /// default). `Ok(None)` when there is nothing to watch (running on defaults).
    pub fn new(explicit: Option<PathBuf>) -> Result<Option<Self>> {
        use notify::Watcher;
        let Some(located) = located(explicit.as_deref()) else {
            return Ok(None);
        };
        // Watch the DIRECTORY, not the file. Editors that save by writing a
        // temp file and renaming it over the original leave the watched inode
        // behind, so a file watch goes deaf after the first such save; the
        // directory sees the rename. Events are filtered back down to the
        // config path, so unrelated files in the directory cost nothing.
        let (watch_dir, only) = match located.watch.is_dir() {
            true => (located.watch.clone(), None),
            false => match located.watch.parent() {
                Some(dir) if !dir.as_os_str().is_empty() => {
                    (dir.to_path_buf(), Some(located.watch.clone()))
                }
                _ => (located.watch.clone(), None),
            },
        };

        // Filter by FILE NAME, not the whole path: the watch is one
        // non-recursive directory, and platform watchers report canonicalized
        // paths (on macOS `/var/...` comes back as `/private/var/...`), which
        // no full-path comparison would ever match.
        let (tx, rx) = std::sync::mpsc::channel();
        let filter = only.as_ref().and_then(|p| p.file_name().map(|n| n.to_owned()));
        let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
            let Ok(event) = res else { return };
            let ours = match &filter {
                Some(name) => event.paths.iter().any(|p| p.file_name() == Some(name.as_os_str())),
                None => true,
            };
            if ours {
                let _ = tx.send(());
            }
        })
        .context("starting config watcher")?;
        watcher
            .watch(&watch_dir, notify::RecursiveMode::NonRecursive)
            .with_context(|| format!("watching {}", watch_dir.display()))?;
        Ok(Some(ConfigWatcher {
            explicit,
            rx,
            _watcher: watcher,
        }))
    }

    /// True if anything changed since the last check (drains coalesced events).
    pub fn changed(&self) -> bool {
        let mut changed = false;
        while self.rx.try_recv().is_ok() {
            changed = true;
        }
        changed
    }

    /// Re-discover and merge the config. `Ok` only if every file parses.
    pub fn reload(&self) -> Result<Config> {
        load(self.explicit.as_deref())
    }

    /// The directory the config was read from, so a setting that names a file
    /// (`[splash] art`) can resolve a relative path against it.
    pub fn config_dir(&self) -> Option<PathBuf> {
        discover(self.explicit.as_deref())
            .first()
            .and_then(|f| f.parent().map(|p| p.to_path_buf()))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::Duration;

    fn tmpdir(tag: &str) -> PathBuf {
        let t = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let dir = std::env::temp_dir().join(format!("shoin-cfg-{tag}-{t}"));
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn merges_conf_files_across_categories() {
        let dir = tmpdir("merge");
        std::fs::write(dir.join("layout.conf"), "[layout]\nmeasure = 100\n").unwrap();
        std::fs::write(
            dir.join("input.conf"),
            "[input]\nmouse = false\n[theme]\ntext = \"#010203\"\n",
        )
        .unwrap();

        let cfg = load(Some(&dir)).unwrap();
        assert_eq!(cfg.layout.measure, 100); // from layout.conf
        assert!(!cfg.input.mouse); // from input.conf
        assert!(cfg.theme.colors.contains_key("text")); // [theme] merged in
        std::fs::remove_dir_all(&dir).ok();
    }

    /// Every `.conf` this repo ships must actually parse.
    ///
    /// It once did not: `transclude.embed` changed from a bool to a mode
    /// string and `shoin/input.conf` still said `false`, which refused the
    /// WHOLE config at startup. Nothing tested the shipped files as files.
    #[test]
    fn the_shipped_config_directory_parses() {
        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("shoin");
        let mut seen = 0;
        for entry in std::fs::read_dir(&dir).expect("shoin/ ships with the repo") {
            let path = entry.unwrap().path();
            if path.extension().and_then(|e| e.to_str()) != Some("conf") {
                continue;
            }
            let text = std::fs::read_to_string(&path).unwrap();
            parse(&text).unwrap_or_else(|e| panic!("{} does not parse: {e}", path.display()));
            seen += 1;
        }
        assert!(seen >= 5, "expected the shipped conf.d, found {seen} files");

        // …and merged together, which is how it is actually loaded.
        let files: Vec<std::path::PathBuf> = std::fs::read_dir(&dir)
            .unwrap()
            .filter_map(|e| e.ok().map(|e| e.path()))
            .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("conf"))
            .collect();
        let merged = merge_files(&files).expect("the shipped directory merges");
        // Parsing is not enough: every enumerated value in the shipped files
        // must also be a word the editor knows, or the demo config warns on
        // startup about itself.
        let warnings = validate(&merged);
        assert!(warnings.is_empty(), "the shipped config warns: {warnings:?}");
    }

    #[test]
    fn later_file_overrides_earlier_within_a_section() {
        let dir = tmpdir("override");
        // Sorted order: a before z, so z wins the shared key.
        std::fs::write(dir.join("a.conf"), "[layout]\nmeasure = 50\nalign = \"left\"\n").unwrap();
        std::fs::write(dir.join("z.conf"), "[layout]\nmeasure = 90\n").unwrap();

        let cfg = load(Some(&dir)).unwrap();
        assert_eq!(cfg.layout.measure, 90); // overridden
        assert_eq!(cfg.layout.align, "left"); // untouched key survives the merge
        std::fs::remove_dir_all(&dir).ok();
    }

    /// `nerd_fonts = false` is a real setting, not decoration: it swaps in the
    /// ASCII glyph set — but never over a glyph the user chose by hand.
    #[test]
    fn nerd_fonts_off_falls_back_to_ascii() {
        let cfg = parse("[glyphs]\nnerd_fonts = false\n").unwrap();
        assert_eq!(cfg.glyphs.task_todo, "[ ]");
        assert_eq!(cfg.glyphs.quote_bar, "|");
        assert_eq!(cfg.glyphs.folder, "", "no Nerd Font, no folder icon");
        assert!(
            !format!("{:?}", cfg.glyphs).chars().any(|c| ('\u{e000}'..='\u{f8ff}').contains(&c)),
            "the ASCII set must contain no Private Use Area glyphs"
        );

        // A hand-set glyph survives the fallback.
        let cfg = parse("[glyphs]\nnerd_fonts = false\nquote_bar = \"▎\"\n").unwrap();
        assert_eq!(cfg.glyphs.quote_bar, "▎");
        assert_eq!(cfg.glyphs.task_todo, "[ ]", "…but the rest still fall back");

        // Left on, nothing changes.
        let cfg = parse("[glyphs]\nnerd_fonts = true\n").unwrap();
        assert_eq!(cfg.glyphs.task_todo, "☐");
    }

    /// The fallback has to survive the conf.d merge and hot reload, not just
    /// `parse` — both paths land in `finish`.
    #[test]
    fn ascii_fallback_survives_the_merge() {
        let dir = tmpdir("ascii");
        std::fs::write(dir.join("glyphs.conf"), "[glyphs]\nnerd_fonts = false\n").unwrap();
        std::fs::write(dir.join("layout.conf"), "[layout]\nmeasure = 70\n").unwrap();

        let cfg = load(Some(&dir)).unwrap();
        assert_eq!(cfg.glyphs.rule, "-");
        assert_eq!(cfg.layout.measure, 70);
        std::fs::remove_dir_all(&dir).ok();
    }

    /// Most editors save by writing a temp file and renaming it into place,
    /// which replaces the inode. Watching the directory is what keeps hot
    /// reload alive across that; watching the file itself goes deaf.
    #[test]
    fn watcher_survives_a_save_by_rename() {
        let dir = tmpdir("rename");
        let conf = dir.join("shoin.conf");
        std::fs::write(&conf, "[layout]\nmeasure = 60\n").unwrap();

        let watcher = ConfigWatcher::new(Some(conf.clone()))
            .unwrap()
            .expect("a watcher for an explicit path");
        // Drain whatever the initial write produced.
        std::thread::sleep(Duration::from_millis(200));
        let _ = watcher.changed();

        let tmp = dir.join("shoin.conf.tmp");
        std::fs::write(&tmp, "[layout]\nmeasure = 42\n").unwrap();
        std::fs::rename(&tmp, &conf).unwrap();

        let deadline = std::time::Instant::now() + Duration::from_secs(5);
        let mut seen = false;
        while std::time::Instant::now() < deadline && !seen {
            seen = watcher.changed();
            std::thread::sleep(Duration::from_millis(50));
        }
        assert!(seen, "a rename over the config should wake the watcher");
        assert_eq!(watcher.reload().unwrap().layout.measure, 42);
        std::fs::remove_dir_all(&dir).ok();
    }

    /// The shipped defaults must all be words the editor understands — this is
    /// what stops a default and its parser drifting apart.
    #[test]
    fn the_defaults_are_all_legal() {
        let warnings = validate(&Config::default());
        assert!(warnings.is_empty(), "{warnings:?}");
    }

    /// An out-of-range `autosave_interval` is a NUMBER, so it gets the same
    /// treatment as a misspelt word: the config still loads, the timer still
    /// runs at the default, and the number is named on startup. Refusing the
    /// whole file over one setting is what the leniency rule above forbids.
    #[test]
    fn an_impossible_autosave_interval_is_named_not_fatal() {
        let cfg = parse("[editor]\nautosave = true\nautosave_interval = 9\n")
            .expect("out of range must not refuse the config");
        assert_eq!(cfg.editor.autosave_interval, 9, "kept as written");
        let w = validate(&cfg);
        assert_eq!(w.len(), 1, "{w:?}");
        assert!(w[0].starts_with("editor.autosave_interval"), "{w:?}");
        assert!(w[0].contains("1 to 5"), "the message names the range: {w:?}");

        // And the timer runs at the default rather than at 9 minutes.
        assert_eq!(
            crate::fs::save::Autosave::from_config(&cfg.editor).interval(),
            Some(std::time::Duration::from_secs(180))
        );

        // 0 is the other end of the same mistake.
        let cfg = parse("[editor]\nautosave_interval = 0\n").unwrap();
        assert_eq!(validate(&cfg).len(), 1);
    }

    /// A misspelt setting is REPORTED. It still falls back, but silently
    /// falling back is how `editor.final_newline` went a whole release unread.
    #[test]
    fn a_misspelt_setting_is_named() {
        let cfg = parse(
            "[editor]\nfinal_newline = \"alway\"\n\n[cursor]\nnormal = \"blok\"\n\n[layout]\nfocus = \"paragrph\"\nalign = \"centre\"\n",
        )
        .expect("a typo must not refuse the whole config");
        let w = validate(&cfg);
        assert_eq!(w.len(), 3, "align = centre is legal: {w:?}");
        assert!(w.iter().any(|s| s.starts_with("editor.final_newline")), "{w:?}");
        assert!(w.iter().any(|s| s.starts_with("cursor.normal")), "{w:?}");
        assert!(w.iter().any(|s| s.starts_with("layout.focus")), "{w:?}");
        assert!(w[0].contains("preserve"), "the message lists the legal words: {w:?}");
    }

    /// Only `keys.conf` is built in; the other shipped files are meant to
    /// spell out the serde defaults. If one drifted, a fresh install and an
    /// `--init-config` install would quietly behave differently.
    #[test]
    fn the_shipped_files_say_what_the_defaults_do() {
        let mut merged = toml::Table::new();
        for (_, body) in init::SHIPPED {
            merge_tables(&mut merged, toml::from_str(body).unwrap());
        }
        let shipped = from_table(merged).unwrap();
        let default = Config::default();

        macro_rules! same {
            ($($section:ident),*) => {$(
                assert_eq!(
                    format!("{:#?}", shipped.$section),
                    format!("{:#?}", default.$section),
                    concat!("shoin/*.conf [", stringify!($section), "] differs from the default"),
                );
            )*};
        }
        same!(layout, status, cursor, editor, input, markdown, glyphs, splash, tree, transclude);

        // `[theme]` is raw strings either way; compare what they resolve to.
        let theme = |c: &Config| crate::render::theme::Theme::from_config(&c.theme).unwrap();
        assert!(theme(&shipped) == theme(&default), "shoin/theme.conf differs from the default theme");
    }

    fn normal(cfg: &Config, key: &str) -> Option<String> {
        cfg.keys.normal.get(key).cloned()
    }

    /// No config anywhere still has the leader bindings. Before, `[keys.*]`
    /// was empty and `<leader>fe` did nothing until `--init-config` ran.
    #[test]
    fn no_config_still_has_the_shipped_bindings() {
        let cfg = merge_files(&[]).unwrap();
        assert_eq!(normal(&cfg, "<leader>fe").as_deref(), Some("file_tree"));
        assert_eq!(normal(&cfg, "<C-s>").as_deref(), Some("save"));

        let (_, warnings) =
            crate::input::keymap::Keymap::from_config(&cfg.keys, &cfg.input.leader);
        assert!(warnings.is_empty(), "the shipped bindings warn: {warnings:?}");
    }

    /// A user config merges OVER the shipped bindings: a partial config keeps
    /// them, a rebind wins, and `"none"` removes one.
    #[test]
    fn a_user_config_layers_over_the_shipped_bindings() {
        let dir = tmpdir("keys");
        std::fs::write(dir.join("theme.conf"), "[layout]\nmeasure = 70\n").unwrap();
        std::fs::write(
            dir.join("keys.conf"),
            "[keys.normal]\n\"<leader>z\" = \"none\"\n\"<leader>w\" = \"quit\"\n\"<leader>x\" = \"save\"\n",
        )
        .unwrap();

        let cfg = load(Some(&dir)).unwrap();
        assert_eq!(normal(&cfg, "<leader>fe").as_deref(), Some("file_tree"), "kept");
        assert_eq!(normal(&cfg, "<leader>w").as_deref(), Some("quit"), "rebound");
        assert_eq!(normal(&cfg, "<leader>x").as_deref(), Some("save"), "added");
        assert_eq!(normal(&cfg, "<leader>z").as_deref(), Some("none"), "unbound");
        assert_eq!(
            crate::input::action::Action::from_config_name("none"),
            Some(crate::input::action::Action::Nop)
        );
        std::fs::remove_dir_all(&dir).ok();
    }

}