diff --git a/CHANGELOG.md b/CHANGELOG.md index a33a9e3..cdb33bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 content. `--cat` renders a dim one-line summary (`[metadata · key=value, …]`); TUI shows the same line and can expand it to an inline key/value box with the new `m` key. A blank row follows the - summary for visual separation. Opt out entirely via `[metadata] show = false` + summary for visual separation. Opt out entirely via `metadata = false` in `~/.config/termdown/config.toml`. See `docs/adr/0001-metadata-block-handling.md`. ### Changed @@ -24,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the old settings aren't dropped silently — move the file to the new location to clear it. A documented `config.example.toml` with every default ships in the repo root. +- **Config parsing is now strict.** An unknown key (e.g. a typo like `bel` for + `bell`) or an invalid `theme` value is reported as a one-line warning and the + config falls back to defaults, instead of being silently ignored. `--theme` + likewise warns on an unrecognized value rather than quietly auto-detecting. ## [0.5.1] - 2026-05-26 diff --git a/CONTEXT.md b/CONTEXT.md index 4255325..21a430c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -35,12 +35,13 @@ The two display states for a metadata block in TUI mode: Cat mode has no "expanded" state — only the one-line summary or nothing. -### `[metadata] show` -The single config knob (in `~/.config/termdown/config.toml`) controlling whether -frontmatter is visible at all. `show = true` (default) renders the [[metadata -one-line summary]] / expanded box; `show = false` hides the metadata block in -**both** cat and TUI. The pulldown-cmark metadata extensions are always -enabled internally regardless — `show` only gates rendering, never parsing. +### `metadata` +The single top-level config knob (in `~/.config/termdown/config.toml`) +controlling whether frontmatter is visible at all. `metadata = true` (the +default, and the behavior when the key is absent) renders the [[metadata +one-line summary]] / expanded box; `metadata = false` hides the metadata block +in **both** cat and TUI. The pulldown-cmark metadata extensions are always +enabled internally regardless — it only gates rendering, never parsing. See [[adr-0001-metadata-block-handling]]. ### Heuristic parser diff --git a/config.example.toml b/config.example.toml index 858cf7f..effc011 100644 --- a/config.example.toml +++ b/config.example.toml @@ -18,12 +18,11 @@ theme = "auto" # CLI override: --no-bell. bell = true -[metadata] # Render YAML (`---`) / TOML (`+++`) frontmatter metadata blocks. When true # (default), --cat and the TUI show a dim one-line summary, and the TUI `m` # key expands it to a key/value box. When false, metadata is hidden entirely # (it is still parsed, so it never leaks into body content). -show = true +metadata = true [font.heading] # Fonts for image-rendered headings (H1–H3). When unset, termdown uses diff --git a/docs/MARKDOWN_FEATURE_COVERAGE.md b/docs/MARKDOWN_FEATURE_COVERAGE.md index cc69197..42ffa27 100644 --- a/docs/MARKDOWN_FEATURE_COVERAGE.md +++ b/docs/MARKDOWN_FEATURE_COVERAGE.md @@ -18,7 +18,7 @@ Audit of `src/markdown.rs` against pulldown-cmark 0.13 and common Markdown exten | Horizontal rule | ✓ | | | HTML blocks | ✓ | Rendered verbatim as a dim preformatted block; HTML comments dropped | | Inline HTML | ⚠ | Format tags (`b`/`strong`, `i`/`em`, `u`, `s`/`del`/`strike`, `code`/`kbd`) map to ANSI; `
` / `
` handled; comments dropped; unknown tags stripped but their content is preserved. Attributes (e.g. `style="color:red"`, `href`) are not interpreted. | -| YAML / TOML frontmatter | ✓ | Parsed via pulldown-cmark's metadata-block extensions. Rendered as a dim one-line summary (`[metadata · key=value, …]`) in `--cat`; foldable inline box in TUI (toggle with `m`). Heuristic key/value extraction. See `docs/adr/0001-metadata-block-handling.md`. Opt out via `[metadata] show = false` in `~/.config/termdown/config.toml`. | +| YAML / TOML frontmatter | ✓ | Parsed via pulldown-cmark's metadata-block extensions. Rendered as a dim one-line summary (`[metadata · key=value, …]`) in `--cat`; foldable inline box in TUI (toggle with `m`). Heuristic key/value extraction. See `docs/adr/0001-metadata-block-handling.md`. Opt out via `metadata = false` in `~/.config/termdown/config.toml`. | ## Enabled GFM extensions diff --git a/docs/adr/0001-metadata-block-handling.md b/docs/adr/0001-metadata-block-handling.md index 66c2d80..a901d69 100644 --- a/docs/adr/0001-metadata-block-handling.md +++ b/docs/adr/0001-metadata-block-handling.md @@ -133,3 +133,16 @@ as body content and not as completely invisible noise: and the `## Configuration` section of the README. - "Use `title` field in TUI title bar" — sketched as a future enhancement, not committed. + +## Amendment — 2026-05-29 + +The config gate (decision #6) was originally implemented as a nested table +`[metadata] show = true`. Before the feature shipped in a release, it was +flattened to a top-level `metadata = true` boolean. Rationale: a single on/off +toggle does not earn its own `[section]`, and the nested form was inconsistent +with the sibling top-level switches (`theme`, `bell`) — the ADR itself rejected +extra metadata knobs as YAGNI, which undercut the only justification for the +table. Implemented as `Option` exactly like `bell`: `None` (key absent) +and `Some(true)` render, `Some(false)` hides. If functional frontmatter +features (e.g. piping `title` to the title bar) ever land, the knob can be +re-promoted to a `[metadata]` table at that point. diff --git a/src/cat.rs b/src/cat.rs index 282b298..97b48e2 100644 --- a/src/cat.rs +++ b/src/cat.rs @@ -16,7 +16,7 @@ pub fn print(doc: &RenderedDoc, term_width: usize, colors: &Colors, config: &Con let stdout = std::io::stdout(); let mut out = BufWriter::new(stdout.lock()); - if config.metadata.show { + if config.metadata.unwrap_or(true) { if let Some(meta) = &doc.metadata { write_metadata_oneline(&mut out, meta, term_width); let _ = writeln!(&mut out); diff --git a/src/config.rs b/src/config.rs index cd6f9f1..743ddd6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,12 +3,14 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; #[derive(Deserialize, Default, Clone)] +#[serde(deny_unknown_fields)] pub struct Config { #[serde(default)] pub font: FontSection, - /// Theme override: "dark", "light", or "auto" (default). - pub theme: Option, + /// Color theme. `None` (key absent) means `Auto`. An unrecognized value is + /// a hard parse error surfaced by `load`, not a silent fallback to auto. + pub theme: Option, /// Vim-style edge bell: emit a terminal BEL when the user tries to scroll /// past the top or bottom of the document. The terminal emulator decides @@ -17,40 +19,49 @@ pub struct Config { /// `--no-bell` overrides to `Some(false)`. pub bell: Option, - #[serde(default)] - pub metadata: MetadataSection, -} - -#[derive(Deserialize, Clone)] -pub struct MetadataSection { /// Whether to render frontmatter (YAML `---` / TOML `+++` metadata blocks). - /// `true` (default) shows the one-line summary in cat / TUI-folded, and - /// allows the TUI `m` key to expand. `false` hides metadata entirely; - /// parsing still runs so the block never leaks into body content. + /// Mirrors `bell`: `None` (key absent) and `Some(true)` both render — the + /// one-line summary in cat / TUI-folded, with the TUI `m` key to expand. + /// `Some(false)` hides metadata entirely; parsing still runs so the block + /// never leaks into body content. /// See `docs/adr/0001-metadata-block-handling.md`. - #[serde(default = "default_metadata_show")] - pub show: bool, + pub metadata: Option, } -fn default_metadata_show() -> bool { - true +/// Color theme selection. `Auto` (the default when the key is absent) detects +/// the terminal background via OSC 11; `Dark` / `Light` force a palette. +#[derive(Deserialize, Clone, Copy, PartialEq, Eq, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ThemeChoice { + Auto, + Dark, + Light, } -impl Default for MetadataSection { - fn default() -> Self { - Self { - show: default_metadata_show(), +impl std::str::FromStr for ThemeChoice { + type Err = (); + + /// Parse a CLI `--theme` value. Mirrors the serde `rename_all = "lowercase"` + /// mapping so the flag and the config file accept exactly the same names. + fn from_str(s: &str) -> Result { + match s { + "auto" => Ok(Self::Auto), + "dark" => Ok(Self::Dark), + "light" => Ok(Self::Light), + _ => Err(()), } } } #[derive(Deserialize, Default, Clone)] +#[serde(deny_unknown_fields)] pub struct FontSection { #[serde(default)] pub heading: HeadingFontConfig, } #[derive(Deserialize, Default, Clone)] +#[serde(deny_unknown_fields)] pub struct HeadingFontConfig { /// Font for Latin / ASCII text (sans-serif recommended, e.g. "Inter"). pub latin: Option, @@ -125,14 +136,48 @@ mod tests { toml::from_str(example).expect("config.example.toml must remain valid TOML"); // The example spells out the *effective* defaults explicitly. - assert_eq!(parsed.theme.as_deref(), Some("auto")); + assert_eq!(parsed.theme, Some(ThemeChoice::Auto)); assert_eq!(parsed.bell, Some(true)); - // `metadata.show` is a real struct default — assert the example tracks it. - assert_eq!(parsed.metadata.show, Config::default().metadata.show); - assert!(parsed.metadata.show); + // `metadata` mirrors `bell`: the example spells out the effective + // default explicitly as `Some(true)` (a missing key parses as `None`, + // which is also treated as "show"). + assert_eq!(parsed.metadata, Some(true)); // Font overrides are commented out, so they must parse as unset. assert!(parsed.font.heading.latin.is_none()); assert!(parsed.font.heading.cjk.is_none()); assert!(parsed.font.heading.emoji.is_none()); } + + /// A valid `theme` deserializes to the matching enum; the field is optional. + #[test] + fn theme_parses_known_values_and_defaults_to_none() { + let parsed: Config = toml::from_str("theme = \"dark\"").unwrap(); + assert_eq!(parsed.theme, Some(ThemeChoice::Dark)); + assert_eq!(Config::default().theme, None); + } + + /// An unrecognized `theme` is a hard error, not a silent fallback to auto — + /// `load` turns this into a one-line warning instead of dropping it. + #[test] + fn invalid_theme_value_is_rejected() { + assert!(toml::from_str::("theme = \"drak\"").is_err()); + } + + /// `deny_unknown_fields` catches typo'd keys (e.g. `bel` for `bell`) rather + /// than silently ignoring them. + #[test] + fn unknown_top_level_key_is_rejected() { + assert!(toml::from_str::("bel = false").is_err()); + } + + /// The CLI `--theme` parser accepts exactly the config file's value set. + #[test] + fn theme_choice_from_str_matches_serde_names() { + use std::str::FromStr; + assert_eq!(ThemeChoice::from_str("auto"), Ok(ThemeChoice::Auto)); + assert_eq!(ThemeChoice::from_str("dark"), Ok(ThemeChoice::Dark)); + assert_eq!(ThemeChoice::from_str("light"), Ok(ThemeChoice::Light)); + assert!(ThemeChoice::from_str("Dark").is_err()); + assert!(ThemeChoice::from_str("blue").is_err()); + } } diff --git a/src/layout.rs b/src/layout.rs index fd3c908..6f75710 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -13,7 +13,7 @@ pub struct RenderedDoc { pub images: Vec, /// Parsed frontmatter, if the document opens with a YAML (`---`) or TOML /// (`+++`) metadata block. The block content never appears in `lines` - /// regardless of [`config.metadata.show`]; renderers consult this field + /// regardless of [`config.metadata`]; renderers consult this field /// directly and decide how (or whether) to display it. pub metadata: Option, } @@ -101,7 +101,7 @@ pub fn build(md: &str, config: &Config, theme: Theme) -> RenderedDoc { opts.insert(Options::ENABLE_TASKLISTS); // Both metadata block flavors are always enabled — they only ever match // at line 1 column 1, so they can't false-positive in regular content. - // The `[metadata] show` config knob gates *display*, not parsing. + // The `metadata` config knob gates *display*, not parsing. opts.insert(Options::ENABLE_YAML_STYLE_METADATA_BLOCKS); opts.insert(Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS); let parser = Parser::new_ext(md, opts); diff --git a/src/main.rs b/src/main.rs index 9c7ce68..0d4371e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,8 @@ mod tui; use std::fs; use std::io::{self, Read}; +use crate::config::ThemeChoice; + use crossterm::tty::IsTty; use terminal_size::{terminal_size, Width}; @@ -56,14 +58,24 @@ fn main() { config.bell = Some(false); } - // Parse --theme flag (takes precedence over config). - let cli_theme = args - .windows(2) - .find(|w| w[0] == "--theme") - .map(|w| w[1].clone()); + // Parse --theme flag (takes precedence over config). An unrecognized value + // warns and falls through to config/auto instead of being silently + // swallowed, matching how the config file reports an invalid theme. + let cli_theme = args.windows(2).find(|w| w[0] == "--theme").and_then(|w| { + match w[1].parse::() { + Ok(choice) => Some(choice), + Err(()) => { + eprintln!( + "Warning: ignoring invalid --theme value {:?}; expected auto, dark, or light", + w[1] + ); + None + } + } + }); // Resolve theme: CLI flag > config file > auto-detect. - let theme = resolve_theme(cli_theme.as_deref(), config.theme.as_deref()); + let theme = resolve_theme(cli_theme, config.theme); // Collect file arg, skipping --theme and its value. let file_arg = { @@ -187,11 +199,10 @@ fn restore_termios(saved: &libc::termios) { } } -fn resolve_theme(cli: Option<&str>, config: Option<&str>) -> theme::Theme { - let value = cli.or(config).unwrap_or("auto"); - match value { - "dark" => theme::Theme::Dark, - "light" => theme::Theme::Light, - _ => theme::detect(), +fn resolve_theme(cli: Option, config: Option) -> theme::Theme { + match cli.or(config).unwrap_or(ThemeChoice::Auto) { + ThemeChoice::Dark => theme::Theme::Dark, + ThemeChoice::Light => theme::Theme::Light, + ThemeChoice::Auto => theme::detect(), } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 6ba5fd0..084ec26 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -52,7 +52,7 @@ struct DocEntry { toc_open: bool, /// Whether the frontmatter metadata block (if any) is shown expanded as an /// inline box. Default `false` = folded one-line summary. Toggled by the - /// `m` key. Has no effect when `config.metadata.show` is false. + /// `m` key. Has no effect when `config.metadata` is `Some(false)`. metadata_expanded: bool, } @@ -295,7 +295,7 @@ fn event_loop(terminal: &mut Terminal, app: &mut App) -> io::Resu size.width }; app.term_size = (size.width, body_height); - let show_metadata = app.config.metadata.show; + let show_metadata = app.config.metadata.unwrap_or(true); { let active = app.active_mut(); if active.viewport.width != body_width || active.viewport.height != body_height { @@ -469,7 +469,7 @@ fn handle_normal_key(app: &mut App, ev: &Event) -> io::Result<()> { } // No-op when metadata display is disabled — toggling would only // churn the wrap cache and force a redraw with no visible change. - input::Action::ToggleMetadata if app.config.metadata.show => { + input::Action::ToggleMetadata if app.config.metadata.unwrap_or(true) => { let active = app.active_mut(); if active.doc.metadata.is_some() { active.metadata_expanded = !active.metadata_expanded; diff --git a/src/tui/viewport.rs b/src/tui/viewport.rs index 5410f17..b7c030a 100644 --- a/src/tui/viewport.rs +++ b/src/tui/viewport.rs @@ -65,7 +65,7 @@ impl Viewport { /// (Re)compute the wrap cache if width, metadata visibility, or fold state /// have changed since the last call. `show_metadata` and `expanded` come - /// from `Config.metadata.show` and `DocEntry.metadata_expanded` respectively. + /// from `Config.metadata` and `DocEntry.metadata_expanded` respectively. pub fn ensure_wrap(&mut self, doc: &RenderedDoc, show_metadata: bool, expanded: bool) { if self.cache_width == self.width && self.cache_metadata_expanded == expanded diff --git a/tests/cli.rs b/tests/cli.rs index f7d08eb..86096cc 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -347,9 +347,9 @@ fn xdg_config_home_is_honored_for_config_loading() { ); // A `config.toml` placed at `$XDG_CONFIG_HOME/termdown/` must take effect: - // `show = false` hides the summary the baseline rendered. + // `metadata = false` hides the summary the baseline rendered. let cfg = TempDir::new(); - cfg.write("termdown/config.toml", "[metadata]\nshow = false\n"); + cfg.write("termdown/config.toml", "metadata = false\n"); let configured = run_termdown( &["--cat", path], None,