edamame 0.1.1

A fast TUI Markdown editor and viewer
Documentation
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
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
//! TOML readers for the three config files.
//!
//! Each reader is fail-soft: missing files become defaults, parse errors
//! and unknown keys are collected into a [`ConfigWarning`] vector that the
//! App surfaces in a startup modal.  See [`read_and_warn`] for the shared
//! read → deserialize-with-unknown-keys → warn loop.

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

use super::config::Config;
use super::keymap::{parse_key, Action, KeyBindingOverrides};
use super::sections::{
    AUTOSAVE_IDLE_MS_DEFAULT, AUTOSAVE_IDLE_MS_MAX_EXCLUSIVE, AUTOSAVE_IDLE_MS_MIN_EXCLUSIVE,
};
use super::theme::Theme;
use super::theme_file::ThemeFile;
use super::warnings::{ConfigWarning, WarningKind};

/// Discover user-droppable export stylesheets in `<config_dir>/export/`.
///
/// Returns every `.css` file in that folder, sorted by path.  The
/// `default.css.example` reference scaffolded on first run is deliberately
/// excluded by the `.css` extension filter — it's a fork-able template, not
/// a selectable stylesheet.  A missing or unreadable directory yields an
/// empty vector — callers fall back to the compiled-in `Builtin` stylesheet.
///
/// A `--no-config` run always yields the empty vector: the export folder
/// is part of the config directory this run has taken out of play, and
/// the compiled-in stylesheet is exactly the built-in default that flag
/// asks for.  See [`crate::config::persistence`].
pub fn list_export_stylesheets(config_dir: &Path) -> Vec<PathBuf> {
    if !super::persistence::config_reads_allowed() {
        return Vec::new();
    }
    let export_dir = config_dir.join("export");
    let Ok(entries) = std::fs::read_dir(&export_dir) else {
        return Vec::new();
    };
    let mut files: Vec<PathBuf> = entries
        .filter_map(std::result::Result::ok)
        .map(|e| e.path())
        .filter(|p| {
            p.is_file()
                && p.extension()
                    .and_then(|e| e.to_str())
                    .is_some_and(|e| e.eq_ignore_ascii_case("css"))
        })
        .collect();
    files.sort();
    files
}

/// Parse a TOML payload into `T`, recording unknown keys via
/// `serde_ignored`.  Returns the parsed struct and the list of
/// dotted-path keys that no field on `T` consumed.  Unlike
/// `toml::from_str`, success here doesn't imply a clean file — the
/// caller checks the returned `Vec` and pushes a warning if non-empty.
fn deserialize_with_unknown_keys<'de, T>(
    raw: &'de str,
) -> std::result::Result<(T, Vec<String>), toml::de::Error>
where
    T: serde::de::Deserialize<'de>,
{
    let mut unknown: Vec<String> = Vec::new();
    // toml 1.x parses eagerly in `Deserializer::parse`, returning a
    // `Result`; a malformed document surfaces here rather than during
    // the `serde_ignored::deserialize` walk below.
    let de = toml::Deserializer::parse(raw)?;
    let value = serde_ignored::deserialize(de, |path| unknown.push(path.to_string()))?;
    Ok((value, unknown))
}

/// Read `path`, deserialize into `T`, and emit any warnings into
/// `warnings`.  Missing → `on_missing()` (no warning); IO failure →
/// `on_parse_failure()` + `ParseError` warning; toml parse error →
/// `on_parse_failure()` + `ParseError` warning; unknown keys →
/// parsed value + `UnknownKeys` warning.  The two fallbacks are
/// separate so callers like [`read_theme_named`] can attach a
/// `tracing::warn!` to the missing-file path only.
fn read_and_warn<T, M, F>(
    path: &Path,
    warnings: &mut Vec<ConfigWarning>,
    on_missing: M,
    on_parse_failure: F,
) -> T
where
    T: serde::de::DeserializeOwned,
    M: FnOnce() -> T,
    F: FnOnce() -> T,
{
    let raw = match std::fs::read_to_string(path) {
        Ok(raw) => raw,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return on_missing(),
        Err(e) => {
            warnings.push(ConfigWarning {
                path: path.to_path_buf(),
                kind: WarningKind::ParseError(format!("Failed to read file: {e}")),
            });
            return on_parse_failure();
        }
    };
    match deserialize_with_unknown_keys::<T>(&raw) {
        Ok((value, unknown)) => {
            if !unknown.is_empty() {
                warnings.push(ConfigWarning {
                    path: path.to_path_buf(),
                    kind: WarningKind::UnknownKeys(unknown),
                });
            }
            value
        }
        Err(e) => {
            warnings.push(ConfigWarning {
                path: path.to_path_buf(),
                kind: WarningKind::ParseError(e.to_string()),
            });
            on_parse_failure()
        }
    }
}

/// Read `config.toml`.  Missing → defaults; parse error → defaults +
/// `ParseError` warning; unknown keys → parsed value + `UnknownKeys`
/// warning.  IO errors other than NotFound also produce a `ParseError`
/// warning so the user always sees the failure path.
pub(super) fn read_main_config(path: &Path, warnings: &mut Vec<ConfigWarning>) -> Config {
    let mut config: Config = read_and_warn(path, warnings, Config::default, Config::default);
    validate_main_config(path, &mut config, warnings);
    config
}

/// Post-deserialization sanity checks for `config.toml`.  Each rule
/// resets the offending field to its default and pushes a
/// [`WarningKind::InvalidValue`] so the user sees that their requested
/// value didn't take effect.  Keep this list short — runtime-clamp at
/// the use site (see e.g. `MAX_WIDTH_COLS_MIN`) is preferred for fields
/// where any value still produces a sensible UI; this path is for
/// fields where an out-of-range value would be actively confusing
/// (autosave firing on every keystroke at `idle_ms = 0`, etc.).
fn validate_main_config(path: &Path, config: &mut Config, warnings: &mut Vec<ConfigWarning>) {
    let idle = config.editor.autosave_idle_ms;
    if idle <= AUTOSAVE_IDLE_MS_MIN_EXCLUSIVE || idle >= AUTOSAVE_IDLE_MS_MAX_EXCLUSIVE {
        config.editor.autosave_idle_ms = AUTOSAVE_IDLE_MS_DEFAULT;
        warnings.push(ConfigWarning {
            path: path.to_path_buf(),
            kind: WarningKind::InvalidValue {
                key: "editor.autosave_idle_ms".to_string(),
                message: format!(
                    "value {idle} is outside the supported range ({} < N < {}); \
                     using the default ({AUTOSAVE_IDLE_MS_DEFAULT}) instead",
                    AUTOSAVE_IDLE_MS_MIN_EXCLUSIVE, AUTOSAVE_IDLE_MS_MAX_EXCLUSIVE,
                ),
            },
        });
    }
}

/// Read `keybindings.toml`.  In addition to the usual parse-error /
/// unknown-key paths, every entry is validated against the `Action`
/// enum and `parse_key`; bad entries are stripped and reported under a
/// single `InvalidKeybindings` warning so the live keymap only contains
/// usable bindings.
pub(super) fn read_keybindings(
    path: &Path,
    warnings: &mut Vec<ConfigWarning>,
) -> KeyBindingOverrides {
    let mut overrides: KeyBindingOverrides = read_and_warn(
        path,
        warnings,
        KeyBindingOverrides::default,
        KeyBindingOverrides::default,
    );
    let mut errors = Vec::new();
    overrides.0.retain(|action_str, key_str| {
        if let Err(e) = Action::from_str(action_str) {
            errors.push(format!("{action_str} = \"{key_str}\": {e}"));
            return false;
        }
        if let Err(e) = parse_key(key_str) {
            errors.push(format!("{action_str} = \"{key_str}\": {e}"));
            return false;
        }
        true
    });
    if !errors.is_empty() {
        warnings.push(ConfigWarning {
            path: path.to_path_buf(),
            kind: WarningKind::InvalidKeybindings(errors),
        });
    }
    overrides
}

/// Built-in theme name substituted when the active theme is missing
/// and `truecolor` is `true`.  Picked because Edamame is the project's
/// canonical truecolor palette.
pub const TRUECOLOR_FALLBACK_THEME: &str = "Edamame";
/// Built-in theme name substituted when the active theme is missing
/// and `truecolor` is `false`.  Indexed-color built-in that renders
/// faithfully on 256-color and even 16-color terminals.
pub const INDEXED_FALLBACK_THEME: &str = "256 Dark";

/// Read the active theme file.
///
/// Returns the parsed [`ThemeFile`] alongside an optional fallback
/// name: `Some(name)` means the requested theme was missing on disk
/// (and wasn't a built-in), so `name` was substituted in its place.
/// The caller is responsible for persisting the rename back to
/// `config.toml` so the substitution doesn't recur on next launch.
///
/// Semantics by case:
///   - name resolves to a built-in: return that built-in, `None`.
///   - file present, parses cleanly: return parsed file, `None`.
///   - file present, parse error: compiled default, `None`
///     (existing `ParseError` warning still fires).
///   - file present, blank: empty file, `None`
///     (user opt-out of styling).
///   - file absent: built-in fallback, `Some(name)`
///     + [`WarningKind::MissingTheme`].
///
/// `truecolor` selects between [`TRUECOLOR_FALLBACK_THEME`] and
/// [`INDEXED_FALLBACK_THEME`] for the missing-file case.
pub(super) fn read_theme_named(
    config_dir: &Path,
    name: &str,
    truecolor: bool,
    warnings: &mut Vec<ConfigWarning>,
) -> (ThemeFile, Option<String>) {
    // Built-in themes always win on name collision: a user file
    // `themes/default.toml` is ignored if `default` is a built-in.
    // Custom user themes go through the disk path below.
    if let Some(theme) = Theme::builtin(name) {
        return ((&theme).into(), None);
    }

    // Past this point every branch reads `themes/<name>.toml`, which a
    // `--no-config` run must not do.  Defense in depth: `list_theme_names`
    // offers built-ins only under the same gate, so a custom name should
    // never reach here — but if one does (a stale `config.theme` from a
    // caller that didn't come through the picker), fall back to the
    // capability-appropriate built-in rather than reading the file.
    //
    // `None`, not `Some(fallback)`: the second element asks the caller to
    // persist the rename, and this run neither wrote nor read that file.
    // No `MissingTheme` warning either — nothing is missing, it is
    // excluded, and a modal about it would be noise on every launch.
    if !super::persistence::config_reads_allowed() {
        let fallback = if truecolor {
            TRUECOLOR_FALLBACK_THEME
        } else {
            INDEXED_FALLBACK_THEME
        };
        tracing::debug!(
            theme = name,
            fallback,
            "--no-config: not reading a user theme file; using the built-in fallback"
        );
        let theme = Theme::builtin(fallback).expect("built-in fallback name is valid");
        return ((&theme).into(), None);
    }

    let path = config_dir.join("themes").join(format!("{name}.toml"));
    // Detect "missing" up-front so we can apply a capability-aware
    // built-in fallback and surface a `MissingTheme` warning, rather
    // than silently degrading to the compiled `Theme::default()`.
    // The blank-file and parse-error cases still flow through
    // `read_and_warn` below — those are deliberate user states, not
    // missing-file states.
    if !path.exists() {
        let fallback = if truecolor {
            TRUECOLOR_FALLBACK_THEME
        } else {
            INDEXED_FALLBACK_THEME
        };
        tracing::warn!(
            theme = name,
            path = %path.display(),
            fallback,
            "theme file not found; substituting built-in fallback"
        );
        warnings.push(ConfigWarning {
            path: path.clone(),
            kind: WarningKind::MissingTheme {
                requested: name.to_string(),
                fallback: fallback.to_string(),
            },
        });
        let theme = Theme::builtin(fallback).expect("built-in fallback name is valid");
        return ((&theme).into(), Some(fallback.to_string()));
    }

    // File exists — read it directly.  We bypass `read_and_warn`
    // here because its `on_missing` branch is unreachable after the
    // existence check above, and unread + reparse on parse failure
    // both fall back to the compiled `Theme::default()` (a blank
    // file is a valid opt-out of styling and still parses cleanly
    // into an empty `ThemeFile`).
    let theme_default = || ((&Theme::default()).into(), None);
    let raw = match std::fs::read_to_string(&path) {
        Ok(raw) => raw,
        Err(e) => {
            warnings.push(ConfigWarning {
                path: path.clone(),
                kind: WarningKind::ParseError(format!("Failed to read file: {e}")),
            });
            return theme_default();
        }
    };
    match deserialize_with_unknown_keys::<ThemeFile>(&raw) {
        Ok((value, unknown)) => {
            if !unknown.is_empty() {
                warnings.push(ConfigWarning {
                    path: path.clone(),
                    kind: WarningKind::UnknownKeys(unknown),
                });
            }
            (value, None)
        }
        Err(e) => {
            warnings.push(ConfigWarning {
                path,
                kind: WarningKind::ParseError(e.to_string()),
            });
            theme_default()
        }
    }
}

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

    /// The shipped `config/config.toml` is copied verbatim into every new
    /// user's config directory, so a typo in it greets a first-time user
    /// with a warning modal.  It must deserialize cleanly *and* leave no
    /// unknown keys — the same two checks `read_and_warn` performs at
    /// startup.
    #[test]
    fn shipped_reference_config_loads_without_warnings() {
        let raw = super::super::init::REFERENCE_CONFIG_TOML;
        let (_config, unknown): (Config, Vec<String>) = deserialize_with_unknown_keys(raw)
            .expect("the shipped config/config.toml must parse as a Config");
        assert!(
            unknown.is_empty(),
            "config/config.toml documents keys that no longer exist: {unknown:?}"
        );
    }

    /// Most of `config/config.toml` is *commented* examples, and the test
    /// above can't see any of them — it parses the file as shipped, where
    /// a key renamed out from under its `# key = value` line is just a
    /// comment.  The user who uncomments it is the one who finds out.
    ///
    /// So uncomment each example in turn and put it through the same
    /// deserialize-and-report-unknown-keys check, scoped to whichever
    /// table it sits under (tracking commented `# [section]` headers as
    /// well as live ones).  This is the config-side counterpart to
    /// `shipped_reference_keybindings_are_all_uncommentable`.
    #[test]
    fn shipped_reference_config_examples_are_all_uncommentable() {
        let checked = check_commented_examples(super::super::init::REFERENCE_CONFIG_TOML)
            .unwrap_or_else(|e| panic!("{e}"));
        assert!(
            checked > 20,
            "expected the reference config to carry commented examples; only found \
             {checked} — has the format changed?"
        );
    }

    /// The scanner above is only worth having if it fails on a stale
    /// example, so pin that directly: a key that no longer exists must be
    /// reported, and the live/commented section tracking must attribute it
    /// to the right table.
    #[test]
    fn commented_example_scanner_rejects_a_key_that_no_longer_exists() {
        let good = "[editor]\n# line_wrap = true\n# [dev]\n# logging = false\n";
        assert_eq!(check_commented_examples(good), Ok(2));

        let stale = "[editor]\n# line_wrap_renamed = true\n";
        let err = check_commented_examples(stale).unwrap_err();
        assert!(
            err.contains("line_wrap_renamed") && err.contains("[editor]"),
            "unhelpful failure message: {err}"
        );

        // A real key, but filed under the wrong table — the same failure.
        let misplaced = "# [dev]\n# line_wrap = true\n";
        assert!(check_commented_examples(misplaced).is_err());

        // Prose and trailing-comment continuations must not be mistaken
        // for examples.
        let prose = "[editor]\n# Wrap long lines. Default: true.\n#     # a .css file.\n";
        assert_eq!(check_commented_examples(prose), Ok(0));

        // Bracketed *prose* must not be adopted as a section header — doing
        // so would check every example after it against a table that does
        // not exist.  `# [ ] a task` looks like one to a naive scan.
        let bracket_prose = "[editor]\n# [ ] a task\n# line_wrap = true\n";
        assert_eq!(check_commented_examples(bracket_prose), Ok(1));

        // A real commented-out header still is one, in both spellings.
        assert_eq!(
            table_header("[export.html]").as_deref(),
            Some("[export.html]")
        );
        assert_eq!(
            table_header("[[export.custom]]").as_deref(),
            Some("[[export.custom]]")
        );
        assert_eq!(table_header("[see the note above]"), None);
        assert_eq!(table_header("[]"), None);
        assert_eq!(table_header("[editor"), None);
    }

    /// `[table]` / `[[array.of.tables]]` if `line` is exactly a TOML table
    /// header, else `None`.
    ///
    /// The bracket content must be a dotted run of bare-key characters,
    /// which is what separates a header from bracketed *prose* — a comment
    /// line like `# [ ] a task` or `# [see the note above]` would otherwise
    /// be adopted as the current section and every example after it checked
    /// against a table that does not exist.
    fn table_header(line: &str) -> Option<String> {
        let inner = line
            .strip_prefix("[[")
            .and_then(|l| l.strip_suffix("]]"))
            .or_else(|| line.strip_prefix('[').and_then(|l| l.strip_suffix(']')))?;
        let bare = !inner.is_empty()
            && inner.split('.').all(|seg| {
                !seg.is_empty()
                    && seg
                        .chars()
                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
            });
        bare.then(|| line.to_owned())
    }

    /// Uncomment every `# key = value` example in a reference config and
    /// check it against the live `Config` schema, scoped to whichever
    /// table it sits under.  Returns how many examples were checked, or
    /// the first failure.  See the caller for why this exists.
    ///
    /// Section tracking is a line scan, not a TOML parse, so it rests on
    /// the reference file's layout: a commented-out `# [section]` header
    /// claims every commented example below it until the next header of
    /// either kind.  A commented header dropped into the middle of a live
    /// table would therefore misattribute the examples that follow — but
    /// that misattribution *fails the test* rather than skipping a check,
    /// so the failure mode is a false alarm the author sees immediately,
    /// never a stale example slipping through.
    fn check_commented_examples(raw: &str) -> Result<usize, String> {
        let mut section = String::new();
        let mut checked = 0;

        for (lineno, line) in raw.lines().enumerate() {
            let trimmed = line.trim();
            // A live table header, or a commented-out one (`# [dev]`, which
            // is how the reference file presents a whole optional section).
            let candidate = trimmed.strip_prefix('#').map_or(trimmed, str::trim);
            if let Some(header) = table_header(candidate) {
                section = header;
                continue;
            }
            // Only commented lines are of interest; live ones are already
            // covered by the whole-file parse above.
            let Some(body) = trimmed.strip_prefix('#').map(str::trim) else {
                continue;
            };
            // Prose, not an example: `# ... some sentence ...`, or a
            // continuation of a previous line's trailing comment.
            let Some((key, _)) = body.split_once('=') else {
                continue;
            };
            let key = key.trim();
            if key.is_empty()
                || !key
                    .chars()
                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
            {
                continue;
            }

            let doc = format!("{section}\n{body}\n");
            let (_config, unknown): (Config, Vec<String>) = deserialize_with_unknown_keys(&doc)
                .map_err(|e| {
                    format!(
                        "config.toml line {}: uncommenting `{body}` under `{section}` \
                         does not parse: {e}",
                        lineno + 1
                    )
                })?;
            if !unknown.is_empty() {
                return Err(format!(
                    "config.toml line {}: `{key}` under `{section}` is no longer a real \
                     setting (reported unknown: {unknown:?})",
                    lineno + 1
                ));
            }
            checked += 1;
        }

        Ok(checked)
    }

    /// Every keybinding the shipped `config/keybindings.toml` shows as a
    /// commented example must be one the user can actually uncomment.
    ///
    /// This is the file's whole purpose, and it has been wrong before: it
    /// used to present `Action = ""` as the way to leave something
    /// unbound, which is a parse error that silently drops the entry.
    /// Uncomment every `# Name = "chord"` line and put it through the same
    /// action-name and `parse_key` validation the loader uses.
    #[test]
    fn shipped_reference_keybindings_are_all_uncommentable() {
        let raw = include_str!("../../config/keybindings.toml");
        let mut checked = 0;
        for line in raw.lines() {
            let Some(body) = line.trim_start().strip_prefix('#') else {
                continue;
            };
            let body = body.trim();
            // Only consider lines shaped like a binding: `Name = "chord"`.
            let Some((name, value)) = body.split_once('=') else {
                continue;
            };
            let (name, value) = (name.trim(), value.trim());
            if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric()) {
                continue;
            }
            let Some(chord) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) else {
                continue;
            };
            Action::from_str(name)
                .unwrap_or_else(|_| panic!("keybindings.toml names unknown action `{name}`"));
            parse_key(chord).unwrap_or_else(|_| {
                panic!("keybindings.toml shows `{name} = \"{chord}\"`, which does not parse")
            });
            checked += 1;
        }
        assert!(
            checked > 10,
            "expected the reference keybindings file to carry example bindings; \
             only found {checked} — has the format changed?"
        );
    }

    /// The export folder lives inside the config directory, so a
    /// `--no-config` run must not enumerate it — the HTML-export modal
    /// falls back to the compiled-in stylesheet, which is what the flag
    /// asks for.  The second half proves the empty result came from the
    /// gate and not from an empty folder.
    #[test]
    fn export_stylesheets_are_not_listed_while_the_config_dir_is_disabled() {
        let _lock = crate::test_env::env_lock();
        let dir = tempfile::tempdir().unwrap();
        let export = dir.path().join("export");
        std::fs::create_dir_all(&export).unwrap();
        std::fs::write(export.join("mine.css"), "").unwrap();

        {
            let _disabled = crate::config::persistence::SuppressGuard::new();
            assert!(list_export_stylesheets(dir.path()).is_empty());
        }
        assert_eq!(list_export_stylesheets(dir.path()).len(), 1);
    }

    /// Defense in depth behind `list_theme_names`: even handed a custom
    /// theme name directly, a disabled run reads no file and substitutes
    /// the capability-appropriate built-in — with no `MissingTheme`
    /// warning, because nothing is missing.
    #[test]
    fn a_user_theme_is_not_read_while_the_config_dir_is_disabled() {
        let _lock = crate::test_env::env_lock();
        let dir = tempfile::tempdir().unwrap();
        let themes = dir.path().join("themes");
        std::fs::create_dir_all(&themes).unwrap();
        std::fs::write(themes.join("mine.toml"), "[h1]\nfg = \"red\"\n").unwrap();

        // `ThemeFile` has no `PartialEq`; its TOML rendering is a faithful
        // stand-in and reports a readable diff on failure.
        let render = |f: &ThemeFile| toml::to_string(f).expect("theme file serialises");
        let builtin = render(&(&Theme::builtin(TRUECOLOR_FALLBACK_THEME).unwrap()).into());

        let mut warnings = Vec::new();
        {
            let _disabled = crate::config::persistence::SuppressGuard::new();
            let (file, fallback) = read_theme_named(dir.path(), "mine", true, &mut warnings);
            assert_eq!(fallback, None, "nothing to persist — the file went unread");
            assert!(warnings.is_empty(), "excluded is not missing: {warnings:?}");
            assert_eq!(render(&file), builtin);
        }

        // Ungated, the same call reads the file — so the assertions above
        // are about the gate, not about a misdirected path.
        let (file, fallback) = read_theme_named(dir.path(), "mine", true, &mut warnings);
        assert_eq!(fallback, None);
        assert_ne!(
            render(&file),
            builtin,
            "the user theme should have been read"
        );
    }

    #[test]
    fn list_export_stylesheets_finds_css_sorted_and_ignores_others() {
        let _lock = crate::test_env::env_lock();
        let dir = tempfile::tempdir().unwrap();
        let export = dir.path().join("export");
        std::fs::create_dir_all(&export).unwrap();
        std::fs::write(export.join("zebra.css"), "").unwrap();
        std::fs::write(export.join("default.css"), "").unwrap();
        std::fs::write(export.join("notes.txt"), "").unwrap();
        std::fs::write(export.join("UPPER.CSS"), "").unwrap();
        // The scaffolded fork-able reference is not a `.css` and is excluded.
        std::fs::write(export.join("default.css.example"), "").unwrap();

        let found = list_export_stylesheets(dir.path());
        let names: Vec<String> = found
            .iter()
            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
            .collect();
        assert_eq!(names, ["UPPER.CSS", "default.css", "zebra.css"]);
    }

    #[test]
    fn list_export_stylesheets_missing_dir_is_empty() {
        let _lock = crate::test_env::env_lock();
        let dir = tempfile::tempdir().unwrap();
        assert!(list_export_stylesheets(dir.path()).is_empty());
    }
}