edamame 0.1.2

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
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EditorConfig {
    /// When true, code block lines that exceed the terminal width are wrapped.
    /// Default: false (long lines extend beyond the visible area without wrapping).
    pub code_block_wrap: bool,
    /// When true, long lines in the document wrap at the terminal width.
    /// Default: true.
    pub line_wrap: bool,
    /// When true, multiple consecutive blank lines in the source are rendered
    /// as multiple blank lines in the output.  Standard Markdown collapses them
    /// to a single blank line; this option preserves the author's intent.
    /// Default: true.
    pub preserve_blank_lines: bool,
    /// When true (default), pressing Up/Down in rendered/hybrid mode moves the
    /// cursor by **visual** lines (accounting for word-wrap), so the cursor
    /// stays at the same horizontal column on the screen.  When false, movement
    /// is by **logical** buffer lines (one `\n`-terminated line per step).
    pub visual_line_nav: bool,
    /// Fingerprints of terminals (TERM_PROGRAM + TERM + capability tuple)
    /// the user has already seen the capabilities notice for.  The notice
    /// fires once per new fingerprint and is silenced on dismiss; subsequent
    /// launches in the same terminal stay quiet, while launches in a
    /// previously-unseen terminal re-fire the notice.  Built by
    /// [`crate::terminal::Capabilities::fingerprint`].
    pub seen_terminal_fingerprints: Vec<String>,
    /// When true, the first-run welcome modal is shown at startup.  Defaults
    /// to `true` so a fresh install sees the welcome on first launch; the
    /// modal's "Show again next time" toggle (default off) writes `false`
    /// here when the user saves.  Also gates the four legacy startup prompts
    /// (images-enabled, remote-image, diagrams, capabilities notice) — they
    /// are suppressed while the welcome is still pending so the user is never
    /// double-prompted.
    pub show_welcome: bool,
    /// When true (the default), edamame checks GitHub for a newer
    /// release at startup — at most once per 24 h, and silently unless
    /// there is genuinely newer news the user hasn't been shown yet.
    /// Turning it off suppresses only the *automatic* check: the About
    /// page's "Check for updates" button and the command-palette action
    /// always check on request.  See `docs/security.md` for the network
    /// posture, and `app::update_check` for the mechanics.
    pub check_for_updates: bool,
    /// Unix epoch seconds of the last automatic release check, stamped
    /// when the check is *spawned* rather than when it resolves — a
    /// worker that hangs, or a process killed before the result lands,
    /// must not re-check on every launch.  `0` means never checked, so
    /// a fresh install checks on first run.  Bookkeeping written by
    /// edamame, not a knob to hand-edit.
    pub last_update_check: u64,
    /// The release tag the startup notice has already been shown for,
    /// so a user who has seen (or explicitly looked up) a version isn't
    /// told about it again on every launch.  Empty until the first
    /// notice.  Bookkeeping written by edamame, not a knob to
    /// hand-edit.
    pub update_notified_for: String,
    /// The version that last ran, used to show the release notes once
    /// after an upgrade (`app::post_upgrade`).  Read from the bundled
    /// `CHANGELOG.md`, so this is unrelated to the release *check*
    /// above and involves no network.
    ///
    /// Empty means no version has been recorded yet, which a fresh
    /// install and an upgrade from a build predating this field share;
    /// `show_welcome` is what tells them apart, since only a returning
    /// user could have turned it off.  Bookkeeping written by edamame,
    /// not a knob to hand-edit; the About page's `[ Release notes ]`
    /// button is how the notes are read again, and it touches neither
    /// this field nor the notice.
    pub last_version_seen: String,
    /// When true, line numbers are displayed in a left gutter in all three
    /// modes (Preview, Rendered, Raw).  Numbers are right-aligned and styled
    /// with the theme's `line_number` style (derived from `text_muted`).
    /// Default: false.
    pub show_line_numbers: bool,
    /// Lines advanced per mouse-wheel tick.  Default 1 — users can bump this
    /// to 2 or 3 for a coarser, faster feel at the cost of fine-grained
    /// control.  The keyboard `ScrollUp` / `ScrollDown` actions always step
    /// by exactly one line and are not affected by this setting.
    pub mouse_scroll_lines: usize,
    /// Duration (milliseconds) that a non-sticky transient message
    /// overlays the hint line before auto-expiring.  Errors ignore this
    /// and remain visible until the user dismisses them with Escape.
    pub transient_ms: u64,
    /// When true, the editor content area is capped to `max_width_cols`
    /// columns and centred horizontally inside the terminal, with the
    /// surrounding gutters painted in `theme.normal`.  Off by default —
    /// the editor uses the full terminal width.  When the terminal is
    /// narrower than the cap the cap has no effect; the full terminal
    /// width is used.  The bottom status / hint region always spans the
    /// full terminal width regardless of this setting.
    pub max_width_enabled: bool,
    /// Maximum content width in columns when `max_width_enabled` is
    /// true.  Clamped to a floor of 20 at use sites to prevent
    /// pathological narrow values that would break layout.  Default: 100.
    pub max_width_cols: usize,
    /// When true, H1 headings render as 4-row "big text" via the
    /// `tui-big-text` widget (Quadrant pixel size — uses ▀▄▌▐ block
    /// glyphs).  Falls back to the regular one-line styled rendering
    /// when the title would exceed the viewport width or contains
    /// non-ASCII characters (font8x8 only covers ASCII).  Default: false.
    pub big_h1: bool,
    /// When true (the default), fenced code blocks are syntax
    /// highlighted using the language named in the opening fence
    /// (```` ```rust ````).  There is no auto-detection: a fence with
    /// no language, or one naming a grammar we do not ship, renders as
    /// plain code exactly as it did before this setting existed.
    /// Default: true.
    pub syntax_highlighting: bool,
    /// When true, the buffer is silently written to disk after
    /// `autosave_idle_ms` of typing inactivity.  Only fires for buffers
    /// with an associated file path; an unnamed buffer never autosaves.
    /// Default: false.
    pub autosave_enabled: bool,
    /// Idle window (ms) the user must stop editing for before the
    /// pending dirty buffer is autosaved.  Every keystroke resets the
    /// timer (debounce, not throttle), so a typing burst produces at
    /// most one autosave at the end.  Default: 5000.
    pub autosave_idle_ms: u64,
    /// When true (default), an external write detected while the
    /// buffer is **clean** opens diff-review mode so the change is
    /// surfaced hunk by hunk before it replaces what is on screen.
    /// When false, a clean buffer is silently reloaded from disk
    /// instead.  A **dirty** buffer always prompts the conflict modal
    /// regardless of this setting (whose `[Merge]` button still enters
    /// diff review on demand) — unsaved edits are never discarded
    /// silently.
    pub diff_on_change: bool,
    /// When true (default), the explanatory modal shown on entering
    /// diff-review mode is displayed.  The modal's "Don't show this
    /// again" checkbox flips this off so subsequent reviews open
    /// straight into the diff view.
    pub show_diff_intro: bool,
    /// When true (default), the editor cursor blinks on a fixed
    /// cadence (`cursor_blink_ms`); when false the cursor is drawn
    /// solid and never hidden.  Exposed as an on/off toggle in the
    /// settings overlay; the cadence itself stays file-only.
    pub cursor_blink: bool,
    /// Cursor blink half-period in milliseconds — the cursor toggles
    /// between visible and hidden every `cursor_blink_ms`.  Only
    /// consulted when `cursor_blink` is true.  Default: 530 (the
    /// classic terminal cadence).  File-only; the overlay exposes the
    /// on/off toggle but not this value.
    pub cursor_blink_ms: u64,
}

/// Floor applied to `EditorConfig::max_width_cols` at every use site so a
/// stray `0` or single-digit value can't break layout.
pub const MAX_WIDTH_COLS_MIN: usize = 20;

/// Exclusive lower bound for `EditorConfig::autosave_idle_ms`.  Values
/// at or below this are rejected at load time with a warning so an
/// accidental small / zero value can't autosave on every keystroke.
pub const AUTOSAVE_IDLE_MS_MIN_EXCLUSIVE: u64 = 1000;
/// Exclusive upper bound for `EditorConfig::autosave_idle_ms` (10
/// minutes).  Past this the feature is effectively off and the user
/// almost certainly wants to disable autosave outright instead.
pub const AUTOSAVE_IDLE_MS_MAX_EXCLUSIVE: u64 = 600_000;
/// Default debounce window used by `EditorConfig::default` and by the
/// loader's out-of-range fallback in `validate_main_config`.  Kept
/// alongside the bounds so all three numbers move together.
pub const AUTOSAVE_IDLE_MS_DEFAULT: u64 = 5000;

/// User-selected appearance mode.  Independent of `Config::theme`: the
/// mode filters which themes appear in the picker and which counterpart
/// is previewed when the user toggles modes, but does not directly
/// dictate the active theme name.  The picker / settings overlay are
/// responsible for keeping `theme` consistent with `appearance` when
/// the user changes either.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AppearanceMode {
    #[default]
    Dark,
    Light,
}

impl AppearanceMode {
    /// The mode on the other side of the toggle.  Used by the theme
    /// picker's Tab / Left / Right / slider-click handlers.  (The
    /// settings overlay has no Appearance row — the picker is the only
    /// surface that changes this.)
    pub fn opposite(self) -> Self {
        match self {
            AppearanceMode::Dark => AppearanceMode::Light,
            AppearanceMode::Light => AppearanceMode::Dark,
        }
    }
}

impl Default for EditorConfig {
    fn default() -> Self {
        Self {
            code_block_wrap: false,
            line_wrap: true,
            preserve_blank_lines: true,
            visual_line_nav: true,
            seen_terminal_fingerprints: Vec::new(),
            show_welcome: true,
            check_for_updates: true,
            last_update_check: 0,
            update_notified_for: String::new(),
            last_version_seen: String::new(),
            show_line_numbers: false,
            mouse_scroll_lines: 1,
            transient_ms: 1500,
            max_width_enabled: false,
            max_width_cols: 100,
            big_h1: false,
            syntax_highlighting: true,
            autosave_enabled: false,
            autosave_idle_ms: AUTOSAVE_IDLE_MS_DEFAULT,
            diff_on_change: true,
            show_diff_intro: true,
            cursor_blink: true,
            cursor_blink_ms: 530,
        }
    }
}

/// Handler name written to `config.modal.handler` for vim modal editing.
pub const VIM_HANDLER: &str = "vim";
/// Handler name for the default (non-modal) editing handler.
pub const DEFAULT_HANDLER: &str = "default";

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ModalConfig {
    /// Which modal handler to use: [`DEFAULT_HANDLER`] or [`VIM_HANDLER`].
    pub handler: String,
}

impl Default for ModalConfig {
    fn default() -> Self {
        Self {
            handler: DEFAULT_HANDLER.into(),
        }
    }
}

/// Table-editing configuration.
///
/// `show_buttons` governs whether the row/column buttons — the `⠿`
/// reorder grips, the `⇔` resize glyph, and the `✕` row/column delete
/// glyphs — are rendered and hit-tested.  Defaults to `true`: the
/// renderer still checks the terminal's detected `Capabilities::mouse`
/// flag before enabling the feature at runtime, so setting this to
/// `true` on a mouseless terminal is a no-op — `App::new` overrides it
/// to `false` when `capabilities.mouse` is absent so persisted config
/// stays faithful to what the user actually sees.
///
/// `row_striping`: when true (the default), alternating data rows are
/// filled with `Theme::table_row_even` / `Theme::table_row_odd` to aid
/// visual scanning on wide tables.
///
/// `warn_on_width_injection`: when true, the first column-border
/// drag on a table without a `<!-- tui-columns: [...] -->` comment opens a
/// modal warning that committing the resize will inject the comment into
/// the Markdown source.  Set false (either via the modal's "Continue and
/// don't ask again" button or directly in `config.toml`) to skip the
/// warning on subsequent drags.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TableConfig {
    pub show_buttons: bool,
    pub row_striping: bool,
    pub warn_on_width_injection: bool,
}

impl Default for TableConfig {
    fn default() -> Self {
        Self {
            show_buttons: true,
            row_striping: true,
            warn_on_width_injection: true,
        }
    }
}

/// Policy for fetching images referenced by `http(s)://` URLs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RemoteImagePolicy {
    /// Prompt the user the first time a document with remote images is opened.
    #[default]
    Ask,
    /// Always fetch remote images without prompting.
    Always,
    /// Never fetch remote images; always fall back to the placeholder.
    Never,
}

/// Master switch for inline image rendering.  `Ask` prompts the user the
/// first time a document with images is opened; `Always` renders without
/// prompting; `Never` keeps the `[Image: alt]` placeholder.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ImagesEnabled {
    /// Prompt the user the first time a document with images is opened.
    #[default]
    Ask,
    /// Always render images inline.
    Always,
    /// Never render images — always fall back to the `[Image: alt]` placeholder.
    Never,
}

/// Image-rendering configuration.
///
/// `max_width` / `max_height` are ceilings in terminal cells; each image
/// reserves at most this many rows, and the inline renderer clamps to this
/// width so a single oversized image never takes over the viewport.  Values
/// are applied verbatim by `ratatui_image`'s `Resize::Fit` path.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ImagesConfig {
    /// Master switch — `"ask"` (default) prompts on first document with
    /// images, `"always"` renders without prompting, `"never"` always
    /// falls back to the placeholder.
    pub enabled: ImagesEnabled,
    /// Maximum width (in terminal cells) for a single image.
    pub max_width: usize,
    /// Maximum height (in terminal cells) for a single image.
    pub max_height: usize,
    /// Policy for fetching `http(s)://` images.
    pub remote_policy: RemoteImagePolicy,
}

impl Default for ImagesConfig {
    fn default() -> Self {
        Self {
            enabled: ImagesEnabled::Ask,
            max_width: 100,
            max_height: 24,
            remote_policy: RemoteImagePolicy::Ask,
        }
    }
}

/// Master switch for inline diagram rendering (e.g. mermaid).  `Ask`
/// prompts the user the first time a document with diagrams is opened;
/// `Always` renders without prompting; `Never` keeps the placeholder.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DiagramsEnabled {
    /// Prompt the user the first time a document with diagrams is opened.
    #[default]
    Ask,
    /// Always render diagrams inline.
    Always,
    /// Never render diagrams — always fall back to the placeholder.
    Never,
}

/// Diagram-rendering configuration.  Mirrors [`ImagesConfig::enabled`] —
/// kept separate so a user can opt in to images but not diagrams (or
/// vice-versa).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DiagramsConfig {
    /// Master switch — `"ask"` (default) prompts on first document with
    /// diagrams, `"always"` renders without prompting, `"never"` always
    /// falls back to the placeholder.
    pub enabled: DiagramsEnabled,
}

impl Default for DiagramsConfig {
    fn default() -> Self {
        Self {
            enabled: DiagramsEnabled::Ask,
        }
    }
}

/// Export configuration.
///
/// HTML is the built-in target and also the intermediate format for the
/// user-defined custom commands that produce PDF, DOCX, etc.
/// ([`CustomExportEntry`], [`export::spawn_custom_export`]).
///
/// [`export::spawn_custom_export`]: crate::export::spawn_custom_export
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ExportConfig {
    pub html: HtmlExportConfig,
    /// User-defined extra export entries, in the order they appear in
    /// `config.toml`.  **The palette carries no per-converter row and no
    /// per-converter [`Action`]:** one `Export…` entry
    /// ([`Action::ExportHtml`]) opens the export modal, whose Format list
    /// offers HTML plus each entry here, in this order.
    ///
    /// The modal resolves that list *once, at open time*, cloning each
    /// entry into an `ExportJob` — never storing an index into this
    /// vector.  Returning from the external editor reloads config
    /// wholesale, so an index captured when the modal opened could name a
    /// different converter, or none, by the time `[ Export ]` is pressed.
    ///
    /// Entries that could not produce a working command (no name, no
    /// command, no usable extension) are reported at load time with a
    /// [`WarningKind::InvalidValue`] warning and then *ignored* — they
    /// stay in this vector (so a later save preserves the user's block)
    /// but are left out of the Format list, so every format the modal
    /// offers is runnable.  See [`CustomExportEntry::config_problem`].
    ///
    /// [`Action`]: crate::config::Action
    /// [`Action::ExportHtml`]: crate::config::Action::ExportHtml
    /// [`WarningKind::InvalidValue`]: crate::config::WarningKind::InvalidValue
    pub custom: Vec<CustomExportEntry>,
}

/// HTML export settings.  `stylesheet = "builtin"` (the default) uses the
/// compiled-in CSS bundled with edamame.  Any other value is treated as a
/// filesystem path to a user stylesheet.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HtmlExportConfig {
    /// Either the sentinel `"builtin"` or an absolute / home-relative path
    /// to a user CSS file.  Read at export time; parse errors are surfaced
    /// to the user via the export error message.
    pub stylesheet: String,
    /// When true, local `![alt](relative/path.png)` references are read
    /// from disk at export time and embedded as `data:` URIs so the HTML
    /// is fully self-contained.  Default: false (keeps output compact and
    /// portable alongside the asset directory).
    pub inline_images: bool,
    /// When true (the default), fenced ```mermaid code blocks
    /// are rendered to inline SVG via `mermaid-rs-renderer` and wrapped
    /// in a `<figure class="mermaid-diagram">`.  On render failure the
    /// block falls back to `<pre><code class="language-mermaid">` so the
    /// source is never lost.  Set false to force the code-block form
    /// (e.g. for pipelines that ship their own client-side mermaid.js).
    pub diagrams: bool,
}

impl Default for HtmlExportConfig {
    fn default() -> Self {
        Self {
            stylesheet: "builtin".into(),
            inline_images: false,
            diagrams: true,
        }
    }
}

/// A single user-configured custom-export entry.
///
/// Each entry appears in the command palette as `Export <name>` and opens
/// the shared export modal, which renders the document to HTML with the
/// options on its form and then runs `command` over the result.
///
/// `command` is run verbatim with two placeholders substituted:
///
/// * `{html}` — path to the just-generated HTML file (temp file owned
///   by the exporter; deleted after the command exits).
/// * `{out}` — path to the final output file (source-stem with the
///   configured `extension` appended).
///
/// [`export::spawn_custom_export`]: crate::export::spawn_custom_export
/// **Every field defaults, and the validator is what enforces them.**  A
/// missing `extension` used to be a hard `toml::de::Error`, and because
/// the loader answers a parse failure by falling back to
/// `Config::default()`, one typo inside an export entry silently reset
/// the user's *entire* config — theme, keybindings, autosave and all —
/// for that launch.  Defaulting here keeps the blast radius to the entry:
/// `readers::validate_custom_exports` reports an unusable one with a
/// warning naming its index, and everything else in `config.toml` still
/// applies.
///
/// A reported entry is **ignored, not deleted** — it stays in this vector
/// so the next `Config::save` writes the user's block back untouched
/// (rather than erasing the very lines the warning asked them to fix).
/// It is merely excluded from the palette; see [`Self::config_problem`].
///
/// It also makes the block in the shipped reference config checkable,
/// since `shipped_reference_config_examples_are_all_uncommentable`
/// uncomments one line at a time.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CustomExportEntry {
    /// Human-readable label, rendered as `Export <name>` in the command
    /// palette.
    pub name: String,
    /// argv-style command.  Element 0 is the executable; remaining
    /// elements are arguments with `{html}` / `{out}` substitution.
    pub command: Vec<String>,
    /// Extension for the output file.  A leading dot and surrounding
    /// whitespace are tolerated in config but not part of the value used
    /// — see [`Self::output_extension`].
    pub extension: String,
}

impl CustomExportEntry {
    /// The extension actually used to name the output file: `extension`
    /// trimmed of surrounding whitespace and a single leading dot.  So
    /// `"pdf"`, `" pdf "` and `".pdf"` all name a `.pdf` output, while
    /// `""`, `"  "` and `"."` name *no* extension (which is why
    /// [`Self::config_problem`] rejects them — otherwise the export would
    /// overwrite the document's own neighbour instead of a sibling file).
    pub fn output_extension(&self) -> &str {
        let trimmed = self.extension.trim();
        trimmed.strip_prefix('.').unwrap_or(trimmed)
    }

    /// The reason this entry cannot produce a working palette command, or
    /// `None` if it is runnable.  The single predicate behind two
    /// decisions: `readers::validate_custom_exports` turns a `Some` into a
    /// startup warning, and the command palette offers only the entries
    /// that answer `None`.  Keeping both on one function is what stops a
    /// warned-about entry from also appearing as a row that fails after
    /// the user fills in the options form.
    ///
    /// The three rules are exactly the ones the runner and the palette
    /// cannot recover from: an empty `name` has nothing to label the row
    /// with; an empty `command` is
    /// [`crate::export::CustomExportError::EmptyCommand`], discovered only
    /// after the export is spawned; and an empty (or path-bearing)
    /// [`Self::output_extension`] would move or unname the output.
    pub fn config_problem(&self) -> Option<&'static str> {
        if self.name.trim().is_empty() {
            Some("`name` is empty; it is what labels the command-palette entry")
        } else if self.command.is_empty() {
            Some("`command` is empty; it needs at least the program to run")
        } else if self.output_extension().is_empty() {
            Some("`extension` is empty; the export would replace the document's own name")
        } else if self.output_extension().contains(['/', '\\']) {
            Some("`extension` contains a path separator; it must name a suffix, not a location")
        } else {
            None
        }
    }
}

/// Developer/diagnostic settings.  Kept separate from `[editor]` because these
/// knobs govern logging and debug tooling, not editing behaviour.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct DevConfig {
    /// When true, `tracing` logs are written to the XDG data dir (e.g.
    /// `~/.local/share/edamame/`).  Off by default so the TUI stays silent.
    pub logging: bool,
}