Skip to main content

mdwright_config/
config.rs

1//! Project configuration loaded from `mdwright.toml`.
2//!
3//! The boundary [`Config::load_explicit`] / [`Config::discover`] hides
4//! the discovery surfaces (explicit `--config` path; an ancestor walk
5//! over `.mdwright.toml`, `mdwright.toml`, and `pyproject.toml`'s
6//! `[tool.mdwright]` table, stopping at the first `.git/` boundary),
7//! TOML parsing, schema validation, and the mapping from raw TOML
8//! shapes into resolved values. Callers see opaque types with getters;
9//! nothing outside this module imports `toml` or `serde`.
10//!
11//! ## Why two layers internally
12//!
13//! Misspellings in the TOML must produce immediate errors, so the
14//! private [`Schema`] family deserialises with
15//! `#[serde(deny_unknown_fields)]` and tracks per-key presence via
16//! `Option<…>`. The public types ([`Config`], [`FmtOptions`], the
17//! style enums) carry already-resolved values — no `Option`s leak —
18//! and stay stable even if the on-disk format gains alternate
19//! representations (e.g. CLI overrides for individual keys later on).
20
21use std::collections::{HashMap, HashSet};
22use std::error::Error as StdError;
23use std::fmt;
24use std::fs;
25use std::io;
26use std::path::{Path, PathBuf};
27
28use mdwright_document::{
29    ExtensionOptions, GfmAutolinkPolicy, GfmOptions, MathDelimiterSet, MathParseOptions, MystOptions, PandocOptions,
30    ParseOptions, RenderOptions, RenderProfile,
31};
32use mdwright_format::{
33    EndOfLine, FmtOptions, HeadingAttrsStyle, ItalicStyle, LinkDefStyle, ListContinuationIndent, ListMarkerStyle,
34    MathOptions, MathRender, OrderedListStyle, Placement, StrongStyle, TableStyle, ThematicStyle, TrailingNewline,
35    Wrap, WrapStrategy,
36};
37use mdwright_lint::RuleSet;
38use mdwright_mathrender::Renderer;
39use serde::de::{Error as DeError, Visitor};
40use serde::{Deserialize, Deserializer};
41
42// ============================================================
43// Public surface
44// ============================================================
45
46/// Resolved project configuration. Construct with
47/// [`Config::load_explicit`] (for `--config PATH`) or
48/// [`Config::discover`] (for the ancestor walk from CWD).
49#[derive(Debug, Clone)]
50pub struct Config {
51    lint_rule_selection: LintRuleSelection,
52    exclude_globs: Vec<String>,
53    extra_info_strings: Vec<String>,
54    render_lint_options: LintRenderOptions,
55    fmt_options: FmtOptions,
56    parse_options: ParseOptions,
57    render_options: RenderOptions,
58    /// Path of the file this config was loaded from, if any. `None`
59    /// for the defaults instance.
60    source: Option<PathBuf>,
61}
62
63impl Config {
64    /// Load configuration from exactly `path`. Used for `--config PATH`.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`ConfigError`] if the file is missing, unreadable,
69    /// malformed TOML, or fails schema validation (an unknown key or a
70    /// malformed value is an error, not a silent default).
71    pub fn load_explicit(path: &Path) -> Result<Self, ConfigError> {
72        read_mdwright_toml(path)
73    }
74
75    /// Discover the nearest applicable config by walking upward from
76    /// `cwd`. At each directory, candidates are tried in precedence
77    /// order: `.mdwright.toml`, then `mdwright.toml`, then
78    /// `pyproject.toml`'s `[tool.mdwright]` table (a `pyproject.toml`
79    /// *without* that table does not stop the walk). The walk stops
80    /// at the filesystem root or the first directory containing a
81    /// `.git/` entry (the workspace boundary).
82    ///
83    /// Returns the all-defaults instance if no candidate is found.
84    /// Absence of a config file is *not* an error.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`ConfigError`] if a candidate file is found but cannot
89    /// be read, parsed as TOML, or matched against the schema.
90    pub fn discover(cwd: &Path) -> Result<Self, ConfigError> {
91        match discover_walk(cwd)? {
92            Some(cfg) => Ok(cfg),
93            None => Ok(Self::from_schema(Schema::default(), None)),
94        }
95    }
96
97    /// Path of the configuration file this `Config` was loaded from,
98    /// or `None` if no file was used (the defaults instance).
99    #[must_use]
100    pub fn source(&self) -> Option<&Path> {
101        self.source.as_deref()
102    }
103
104    /// Directory containing the configuration file, useful as the
105    /// base for resolving relative paths inside the config (e.g.
106    /// `[lint] exclude` globs). `None` when no file was loaded; in
107    /// that case callers typically use `$PWD` as the base.
108    #[must_use]
109    pub fn source_dir(&self) -> Option<&Path> {
110        self.source.as_deref().and_then(Path::parent)
111    }
112
113    /// Resolved lint rule selection from `[lint]`.
114    #[must_use]
115    pub fn lint_rule_selection(&self) -> &LintRuleSelection {
116        &self.lint_rule_selection
117    }
118
119    /// Gitignore-style patterns from `[lint] exclude`. Files matching
120    /// any pattern are dropped from lint runs.
121    #[must_use]
122    pub fn exclude_globs(&self) -> &[String] {
123        &self.exclude_globs
124    }
125
126    /// Project-specific allowlist extension for `info-string-typo`.
127    /// The stdlib default still applies; these are *additions*.
128    #[must_use]
129    pub fn extra_info_strings(&self) -> &[String] {
130        &self.extra_info_strings
131    }
132
133    /// Resolved `[lint.render]` configuration for the `math/render-compat`
134    /// lint family. Names the renderer (`MathJax` v3 / `KaTeX`), its loaded
135    /// packages, and known macros. The CLI translates this into a
136    /// `mdwright_mathrender::RenderProfile`.
137    #[must_use]
138    pub fn render_lint_options(&self) -> &LintRenderOptions {
139        &self.render_lint_options
140    }
141
142    /// Resolved formatter knobs from `[fmt]`. Formatter sessions are
143    /// the first consumers; the lint side ignores these.
144    #[must_use]
145    pub fn fmt_options(&self) -> &FmtOptions {
146        &self.fmt_options
147    }
148
149    /// Resolved Markdown recognition policy.
150    #[must_use]
151    pub fn parse_options(&self) -> ParseOptions {
152        self.parse_options
153    }
154
155    /// Resolved HTML rendering policy.
156    #[must_use]
157    pub fn render_options(&self) -> RenderOptions {
158        self.render_options
159    }
160
161    /// The all-defaults [`Config`] — what [`Self::discover`] returns
162    /// when no `.mdwright.toml` / `mdwright.toml` / `pyproject.toml`
163    /// is found on the upward walk. Exposed for long-lived processes
164    /// (the LSP server) that need a synchronous fallback when
165    /// `discover` encounters an unreadable config file mid-walk.
166    #[must_use]
167    pub fn defaults() -> Self {
168        Self::from_schema(Schema::default(), None)
169    }
170
171    fn from_schema(schema: Schema, source: Option<PathBuf>) -> Self {
172        let Schema {
173            lint,
174            fmt,
175            parse,
176            render,
177        } = schema;
178        Self {
179            lint_rule_selection: LintRuleSelection {
180                preset: LintRulePreset::from(lint.preset),
181                select: lint.select,
182                extend_select: lint.extend_select,
183                ignore: lint.ignore,
184            },
185            exclude_globs: lint.exclude,
186            extra_info_strings: lint.info_strings.extra,
187            render_lint_options: lint.render.into(),
188            fmt_options: fmt_options_from_schema(fmt),
189            parse_options: parse_options_from_schema(parse),
190            render_options: render_options_from_schema(render),
191            source,
192        }
193    }
194}
195
196/// Resolved `[lint.render]` knobs. Carried alongside the rule selection so
197/// the CLI can configure the `math/render-compat` rule's profile.
198#[derive(Clone, Debug, PartialEq, Eq)]
199pub struct LintRenderOptions {
200    renderer: Renderer,
201    packages: Vec<String>,
202    macros: HashMap<String, u8>,
203}
204
205impl Default for LintRenderOptions {
206    fn default() -> Self {
207        Self {
208            renderer: Renderer::MathJaxV3,
209            packages: Vec::new(),
210            macros: HashMap::new(),
211        }
212    }
213}
214
215impl LintRenderOptions {
216    /// Renderer the `math/render-compat` rule should check against.
217    #[must_use]
218    pub const fn renderer(&self) -> Renderer {
219        self.renderer
220    }
221
222    /// Renderer packages / extensions to load on top of the renderer's
223    /// default autoload set (e.g. `["mhchem", "physics"]`).
224    #[must_use]
225    pub fn packages(&self) -> &[String] {
226        &self.packages
227    }
228
229    /// User-declared macros known to be in scope, keyed by command name
230    /// (without the leading backslash). The value is the macro's arity;
231    /// the checker treats the name as defined and ignores arity at
232    /// check time.
233    #[must_use]
234    pub fn macros(&self) -> &HashMap<String, u8> {
235        &self.macros
236    }
237}
238
239/// Named baseline for lint rule selection.
240#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
241pub enum LintRulePreset {
242    /// The curated default-on rule set.
243    #[default]
244    Default,
245    /// Every registered rule.
246    All,
247    /// No baseline rules; use `select` for an exact explicit set.
248    None,
249}
250
251/// Resolved `[lint]` rule-selection policy.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct LintRuleSelection {
254    preset: LintRulePreset,
255    select: Vec<String>,
256    extend_select: Vec<String>,
257    ignore: Vec<String>,
258}
259
260impl LintRuleSelection {
261    #[must_use]
262    pub fn preset(&self) -> LintRulePreset {
263        self.preset
264    }
265
266    #[must_use]
267    pub fn select(&self) -> &[String] {
268        &self.select
269    }
270
271    #[must_use]
272    pub fn extend_select(&self) -> &[String] {
273        &self.extend_select
274    }
275
276    #[must_use]
277    pub fn ignore(&self) -> &[String] {
278        &self.ignore
279    }
280
281    /// Partition the available rule pool according to this config.
282    ///
283    /// The config schema owns selector policy, but callers provide the
284    /// available pool so downstream binaries can include custom rules
285    /// without teaching `mdwright-config` where they came from.
286    ///
287    /// # Errors
288    ///
289    /// Returns [`RuleSelectionError`] when a selected rule name is not
290    /// present in `available`, or when a manually constructed selection
291    /// violates the TOML schema invariants.
292    pub fn resolve(&self, available: RuleSet) -> Result<RuleSet, RuleSelectionError> {
293        if self.preset != LintRulePreset::None && !self.select.is_empty() {
294            return Err(RuleSelectionError::new(
295                "`lint.select` can only be used with `lint.preset = \"none\"`; use `extend-select` to add rules to a preset",
296            ));
297        }
298
299        let inventory: Vec<(String, bool)> = available
300            .iter()
301            .map(|r| (r.name().to_owned(), r.is_default()))
302            .collect();
303        let all_names: HashSet<&str> = inventory.iter().map(|(name, _)| name.as_str()).collect();
304        let default_names: HashSet<&str> = inventory
305            .iter()
306            .filter_map(|(name, is_default)| is_default.then_some(name.as_str()))
307            .collect();
308
309        let mut selected: HashSet<String> = match self.preset {
310            LintRulePreset::Default => default_names.iter().map(|name| (*name).to_owned()).collect(),
311            LintRulePreset::All => all_names.iter().map(|name| (*name).to_owned()).collect(),
312            LintRulePreset::None => HashSet::new(),
313        };
314
315        for name in &self.select {
316            ensure_known_rule(name, &all_names)?;
317            selected.insert(name.clone());
318        }
319        for name in &self.extend_select {
320            ensure_known_rule(name, &all_names)?;
321            selected.insert(name.clone());
322        }
323        for name in &self.ignore {
324            ensure_known_rule(name, &all_names)?;
325            selected.remove(name);
326        }
327
328        let mut result = RuleSet::new();
329        for rule in available {
330            if selected.contains(rule.name()) {
331                result
332                    .add(rule)
333                    .map_err(|err| RuleSelectionError::new(err.to_string()))?;
334            }
335        }
336        Ok(result)
337    }
338}
339
340fn ensure_known_rule(name: &str, known: &HashSet<&str>) -> Result<(), RuleSelectionError> {
341    if known.contains(name) {
342        Ok(())
343    } else {
344        Err(RuleSelectionError::new(format!(
345            "unknown lint rule `{name}` (run `mdwright list-rules` to see what's registered)"
346        )))
347    }
348}
349
350/// Failure to resolve configured lint rule selection against the
351/// available rule pool.
352#[derive(Debug, Clone, PartialEq, Eq)]
353pub struct RuleSelectionError {
354    message: String,
355}
356
357impl RuleSelectionError {
358    fn new(message: impl Into<String>) -> Self {
359        Self {
360            message: message.into(),
361        }
362    }
363}
364
365impl fmt::Display for RuleSelectionError {
366    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367        f.write_str(&self.message)
368    }
369}
370
371impl StdError for RuleSelectionError {}
372
373/// Failure to load configuration: I/O, TOML syntax, or schema
374/// validation. The `Display` impl renders the path and underlying
375/// cause.
376#[derive(Debug)]
377pub struct ConfigError {
378    message: String,
379}
380
381impl ConfigError {
382    fn io(path: &Path, err: &io::Error) -> Self {
383        Self {
384            message: format!("read {}: {err}", path.display()),
385        }
386    }
387
388    fn parse(path: &Path, err: &toml::de::Error) -> Self {
389        Self {
390            message: format!("parse {}: {err}", path.display()),
391        }
392    }
393}
394
395impl fmt::Display for ConfigError {
396    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397        f.write_str(&self.message)
398    }
399}
400
401impl StdError for ConfigError {}
402
403// ============================================================
404// Internal schema (deserialisation target)
405// ============================================================
406
407#[derive(Debug, Default, Deserialize)]
408#[serde(deny_unknown_fields)]
409struct Schema {
410    #[serde(default)]
411    lint: LintSchema,
412    #[serde(default)]
413    fmt: FmtSchema,
414    #[serde(default)]
415    parse: ParseSchema,
416    #[serde(default)]
417    render: RenderSchema,
418}
419
420#[derive(Debug)]
421struct LintSchema {
422    preset: LintPresetSchema,
423    select: Vec<String>,
424    extend_select: Vec<String>,
425    ignore: Vec<String>,
426    exclude: Vec<String>,
427    info_strings: InfoStringsSchema,
428    render: RenderLintSchema,
429}
430
431impl Default for LintSchema {
432    fn default() -> Self {
433        Self {
434            preset: LintPresetSchema::Default,
435            select: Vec::new(),
436            extend_select: Vec::new(),
437            ignore: Vec::new(),
438            exclude: Vec::new(),
439            info_strings: InfoStringsSchema::default(),
440            render: RenderLintSchema::default(),
441        }
442    }
443}
444
445impl<'de> Deserialize<'de> for LintSchema {
446    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
447    where
448        D: Deserializer<'de>,
449    {
450        #[derive(Deserialize)]
451        #[serde(deny_unknown_fields)]
452        struct RawLintSchema {
453            #[serde(default, deserialize_with = "reject_legacy_rules")]
454            rules: (),
455            #[serde(default)]
456            preset: LintPresetSchema,
457            #[serde(default)]
458            select: Vec<String>,
459            #[serde(default, rename = "extend-select")]
460            extend_select: Vec<String>,
461            #[serde(default)]
462            ignore: Vec<String>,
463            #[serde(default)]
464            exclude: Vec<String>,
465            #[serde(default, rename = "info-strings")]
466            info_strings: InfoStringsSchema,
467            #[serde(default)]
468            render: RenderLintSchema,
469        }
470
471        let RawLintSchema {
472            rules: _rules,
473            preset,
474            select,
475            extend_select,
476            ignore,
477            exclude,
478            info_strings,
479            render,
480        } = RawLintSchema::deserialize(deserializer)?;
481
482        for (key, names) in [
483            ("select", select.as_slice()),
484            ("extend-select", extend_select.as_slice()),
485            ("ignore", ignore.as_slice()),
486        ] {
487            for name in names {
488                if matches!(name.as_str(), "default" | "all" | "none") {
489                    return Err(D::Error::custom(format!(
490                        "`lint.{key}` accepts rule names only; `{name}` is a preset, so use `lint.preset = \"{name}\"`"
491                    )));
492                }
493            }
494        }
495
496        if preset != LintPresetSchema::None && !select.is_empty() {
497            return Err(D::Error::custom(
498                "`lint.select` can only be used with `lint.preset = \"none\"`; use `extend-select` to add rules to a preset",
499            ));
500        }
501
502        Ok(Self {
503            preset,
504            select,
505            extend_select,
506            ignore,
507            exclude,
508            info_strings,
509            render,
510        })
511    }
512}
513
514#[derive(Debug, Default, Deserialize)]
515#[serde(deny_unknown_fields)]
516struct RenderLintSchema {
517    #[serde(default)]
518    renderer: RendererSchema,
519    #[serde(default)]
520    packages: Vec<String>,
521    #[serde(default)]
522    macros: HashMap<String, RenderMacroSchema>,
523}
524
525#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Eq)]
526#[serde(rename_all = "kebab-case")]
527enum RendererSchema {
528    #[default]
529    #[serde(alias = "mathjax-v3")]
530    MathjaxV3,
531    Katex,
532}
533
534impl From<RendererSchema> for Renderer {
535    fn from(s: RendererSchema) -> Self {
536        match s {
537            RendererSchema::MathjaxV3 => Self::MathJaxV3,
538            RendererSchema::Katex => Self::Katex,
539        }
540    }
541}
542
543#[derive(Debug, Deserialize)]
544#[serde(untagged)]
545enum RenderMacroSchema {
546    /// `RR = 0` form (arity only).
547    Arity(u8),
548    /// `RR = { arity = 0 }` form (room for future fields without
549    /// breaking the wire format).
550    Table(RenderMacroTable),
551}
552
553#[derive(Debug, Deserialize)]
554#[serde(deny_unknown_fields)]
555struct RenderMacroTable {
556    #[serde(default)]
557    arity: u8,
558}
559
560impl From<RenderMacroSchema> for u8 {
561    fn from(schema: RenderMacroSchema) -> Self {
562        match schema {
563            RenderMacroSchema::Arity(arity) => arity,
564            RenderMacroSchema::Table(table) => table.arity,
565        }
566    }
567}
568
569impl From<RenderLintSchema> for LintRenderOptions {
570    fn from(schema: RenderLintSchema) -> Self {
571        Self {
572            renderer: schema.renderer.into(),
573            packages: schema.packages,
574            macros: schema.macros.into_iter().map(|(name, m)| (name, m.into())).collect(),
575        }
576    }
577}
578
579fn reject_legacy_rules<'de, D>(deserializer: D) -> Result<(), D::Error>
580where
581    D: Deserializer<'de>,
582{
583    let _ignored = toml::Value::deserialize(deserializer)?;
584    Err(D::Error::custom(
585        "`lint.rules` has been replaced by `lint.preset`, `lint.select`, `lint.extend-select`, and `lint.ignore`",
586    ))
587}
588
589#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Eq)]
590#[serde(rename_all = "kebab-case")]
591enum LintPresetSchema {
592    #[default]
593    Default,
594    All,
595    None,
596}
597
598impl From<LintPresetSchema> for LintRulePreset {
599    fn from(s: LintPresetSchema) -> Self {
600        match s {
601            LintPresetSchema::Default => Self::Default,
602            LintPresetSchema::All => Self::All,
603            LintPresetSchema::None => Self::None,
604        }
605    }
606}
607
608#[derive(Debug, Default, Deserialize)]
609#[serde(deny_unknown_fields)]
610struct InfoStringsSchema {
611    #[serde(default)]
612    extra: Vec<String>,
613}
614
615#[derive(Debug, Default, Deserialize)]
616#[serde(deny_unknown_fields)]
617struct FmtSchema {
618    #[serde(default)]
619    profile: Option<FmtProfileSchema>,
620    #[serde(default)]
621    wrap: Option<WrapSchema>,
622    #[serde(default, rename = "wrap-strategy")]
623    wrap_strategy: Option<WrapStrategySchema>,
624    #[serde(default)]
625    italic: Option<ItalicSchema>,
626    #[serde(default)]
627    strong: Option<StrongSchema>,
628    #[serde(default, rename = "list-marker")]
629    list_marker: Option<ListMarkerSchema>,
630    #[serde(default, rename = "ordered-list")]
631    ordered_list: Option<OrderedListSchema>,
632    #[serde(default, rename = "thematic-break")]
633    thematic_break: Option<ThematicSchema>,
634    #[serde(default, rename = "trailing-newline")]
635    trailing_newline: Option<TrailingNewlineSchema>,
636    #[serde(default, rename = "end-of-line")]
637    end_of_line: Option<EndOfLineSchema>,
638    #[serde(default)]
639    exclude: Vec<String>,
640    #[serde(default)]
641    refs: Option<RefsSchema>,
642    #[serde(default)]
643    footnotes: Option<FootnotesSchema>,
644    #[serde(default)]
645    tables: Option<TablesSchema>,
646    #[serde(default)]
647    lists: Option<ListsSchema>,
648    #[serde(default)]
649    frontmatter: Option<FrontmatterSchema>,
650    #[serde(default)]
651    math: Option<MathSchema>,
652    #[serde(default, rename = "heading-attrs")]
653    heading_attrs: Option<HeadingAttrsSchema>,
654}
655
656fn fmt_options_from_schema(schema: FmtSchema) -> FmtOptions {
657    let refs = schema.refs.unwrap_or_default();
658    let footnotes = schema.footnotes.unwrap_or_default();
659    let tables = schema.tables.unwrap_or_default();
660    let lists = schema.lists.unwrap_or_default();
661    let frontmatter = schema.frontmatter.unwrap_or_default();
662    let default = match schema.profile.unwrap_or(FmtProfileSchema::Preserve) {
663        FmtProfileSchema::Preserve => FmtOptions::default(),
664        FmtProfileSchema::Mdformat => FmtOptions::mdformat(),
665    };
666    let mut opts = default
667        .clone()
668        .with_exclude_globs(schema.exclude)
669        .with_link_def_placement(
670            refs.placement
671                .map_or_else(|| default.link_def_placement(), Placement::from),
672        )
673        .with_link_def_style(refs.style.map_or_else(|| default.link_def_style(), LinkDefStyle::from))
674        .with_footnote_placement(
675            footnotes
676                .placement
677                .map_or_else(|| default.footnote_placement(), Placement::from),
678        );
679    opts = opts.with_preserve_frontmatter(frontmatter.preserve.unwrap_or_else(|| default.preserve_frontmatter()));
680    opts = opts.with_table(tables.style.map_or_else(|| default.table(), TableStyle::from));
681    opts = opts.with_list_continuation_indent(
682        lists
683            .continuation_indent
684            .map_or_else(|| default.list_continuation_indent(), ListContinuationIndent::from),
685    );
686    if let Some(wrap) = schema.wrap {
687        opts = opts.with_wrap(Wrap::from(wrap));
688    }
689    if let Some(strategy) = schema.wrap_strategy {
690        opts = opts.with_wrap_strategy(WrapStrategy::from(strategy));
691    }
692    if let Some(italic) = schema.italic {
693        opts = opts.with_italic(ItalicStyle::from(italic));
694    }
695    if let Some(strong) = schema.strong {
696        opts = opts.with_strong(StrongStyle::from(strong));
697    }
698    if let Some(list_marker) = schema.list_marker {
699        opts = opts.with_list_marker(ListMarkerStyle::from(list_marker));
700    }
701    if let Some(ordered_list) = schema.ordered_list {
702        opts = opts.with_ordered_list(OrderedListStyle::from(ordered_list));
703    }
704    if let Some(thematic_break) = schema.thematic_break {
705        opts = opts.with_thematic_break(ThematicStyle::from(thematic_break));
706    }
707    if let Some(trailing_newline) = schema.trailing_newline {
708        opts = opts.with_trailing_newline(TrailingNewline::from(trailing_newline));
709    }
710    if let Some(end_of_line) = schema.end_of_line {
711        opts = opts.with_end_of_line(EndOfLine::from(end_of_line));
712    }
713    if let Some(math) = schema.math {
714        opts = opts.with_math(MathOptions::from(math));
715    }
716    if let Some(heading_attrs) = schema.heading_attrs {
717        opts = opts.with_heading_attrs(HeadingAttrsStyle::from(heading_attrs));
718    }
719    opts
720}
721
722#[derive(Debug, Default, Deserialize)]
723#[serde(deny_unknown_fields)]
724struct ParseSchema {
725    #[serde(default)]
726    extensions: Option<ExtensionsSchema>,
727    #[serde(default)]
728    math: Option<ParseMathSchema>,
729}
730
731fn parse_options_from_schema(schema: ParseSchema) -> ParseOptions {
732    let mut opts = ParseOptions::default();
733    if let Some(extensions) = schema.extensions {
734        opts = opts.with_extensions(ExtensionOptions::from(extensions));
735    }
736    if let Some(math) = schema.math {
737        opts = opts.with_math(MathParseOptions::from(math));
738    }
739    opts
740}
741
742#[derive(Debug, Default, Deserialize)]
743#[serde(deny_unknown_fields)]
744struct RenderSchema {
745    #[serde(default)]
746    profile: Option<RenderProfileSchema>,
747}
748
749fn render_options_from_schema(schema: RenderSchema) -> RenderOptions {
750    let default = RenderOptions::default();
751    RenderOptions::default().with_profile(schema.profile.map_or_else(|| default.profile(), RenderProfile::from))
752}
753
754#[derive(Debug, Deserialize)]
755#[serde(rename_all = "kebab-case")]
756enum RenderProfileSchema {
757    Pulldown,
758    CmarkGfm,
759}
760
761impl From<RenderProfileSchema> for RenderProfile {
762    fn from(s: RenderProfileSchema) -> Self {
763        match s {
764            RenderProfileSchema::Pulldown => Self::Pulldown,
765            RenderProfileSchema::CmarkGfm => Self::CmarkGfm,
766        }
767    }
768}
769
770#[derive(Debug, Deserialize)]
771#[serde(rename_all = "kebab-case")]
772enum HeadingAttrsSchema {
773    Preserve,
774    Canonicalise,
775}
776
777impl From<HeadingAttrsSchema> for HeadingAttrsStyle {
778    fn from(s: HeadingAttrsSchema) -> Self {
779        match s {
780            HeadingAttrsSchema::Preserve => Self::Preserve,
781            HeadingAttrsSchema::Canonicalise => Self::Canonicalise,
782        }
783    }
784}
785
786#[derive(Debug, Default, Deserialize)]
787#[serde(deny_unknown_fields)]
788#[allow(
789    clippy::struct_field_names,
790    clippy::struct_excessive_bools,
791    reason = "shape mirrors `ExtensionOptions`; the `_lists` postfix matches the TOML key convention"
792)]
793struct ExtensionsSchema {
794    #[serde(default)]
795    gfm: Option<GfmSchema>,
796    #[serde(default, rename = "definition-lists")]
797    definition_lists: Option<bool>,
798    #[serde(default, rename = "abbreviation-lists")]
799    abbreviation_lists: Option<bool>,
800    #[serde(default, rename = "heading-attribute-lists")]
801    heading_attribute_lists: Option<bool>,
802    #[serde(default, rename = "block-attribute-lists")]
803    block_attribute_lists: Option<bool>,
804    #[serde(default)]
805    myst: Option<MystSchema>,
806    #[serde(default)]
807    pandoc: Option<PandocSchema>,
808}
809
810impl From<ExtensionsSchema> for ExtensionOptions {
811    fn from(s: ExtensionsSchema) -> Self {
812        let default = Self::default();
813        Self {
814            gfm: s.gfm.map_or(default.gfm, GfmOptions::from),
815            definition_lists: s.definition_lists.unwrap_or(default.definition_lists),
816            abbreviation_lists: s.abbreviation_lists.unwrap_or(default.abbreviation_lists),
817            heading_attribute_lists: s.heading_attribute_lists.unwrap_or(default.heading_attribute_lists),
818            block_attribute_lists: s.block_attribute_lists.unwrap_or(default.block_attribute_lists),
819            myst: s.myst.map_or(default.myst, MystOptions::from),
820            pandoc: s.pandoc.map_or(default.pandoc, PandocOptions::from),
821        }
822    }
823}
824
825#[derive(Debug, Default, Deserialize)]
826#[serde(deny_unknown_fields)]
827struct GfmSchema {
828    #[serde(default)]
829    autolinks: Option<GfmAutolinkPolicySchema>,
830    #[serde(default)]
831    tagfilter: Option<bool>,
832}
833
834impl From<GfmSchema> for GfmOptions {
835    fn from(s: GfmSchema) -> Self {
836        let default = Self::default();
837        Self {
838            autolinks: s.autolinks.map_or(default.autolinks, GfmAutolinkPolicy::from),
839            tagfilter: s.tagfilter.unwrap_or(default.tagfilter),
840        }
841    }
842}
843
844#[derive(Copy, Clone, Debug, Deserialize)]
845#[serde(rename_all = "kebab-case")]
846enum GfmAutolinkPolicySchema {
847    Disabled,
848    Urls,
849    UrlsAndEmails,
850}
851
852impl From<GfmAutolinkPolicySchema> for GfmAutolinkPolicy {
853    fn from(s: GfmAutolinkPolicySchema) -> Self {
854        match s {
855            GfmAutolinkPolicySchema::Disabled => Self::Disabled,
856            GfmAutolinkPolicySchema::Urls => Self::Urls,
857            GfmAutolinkPolicySchema::UrlsAndEmails => Self::UrlsAndEmails,
858        }
859    }
860}
861
862#[derive(Debug, Default, Deserialize)]
863#[serde(deny_unknown_fields)]
864struct ParseMathSchema {
865    #[serde(default)]
866    delimiters: Option<MathDelimiterSetSchema>,
867}
868
869impl From<ParseMathSchema> for MathParseOptions {
870    fn from(s: ParseMathSchema) -> Self {
871        let default = Self::default();
872        Self {
873            delimiters: s.delimiters.map_or(default.delimiters, MathDelimiterSet::from),
874        }
875    }
876}
877
878#[derive(Copy, Clone, Debug, Deserialize)]
879#[serde(rename_all = "kebab-case")]
880enum MathDelimiterSetSchema {
881    Tex,
882    Github,
883}
884
885impl From<MathDelimiterSetSchema> for MathDelimiterSet {
886    fn from(s: MathDelimiterSetSchema) -> Self {
887        match s {
888            MathDelimiterSetSchema::Tex => Self::Tex,
889            MathDelimiterSetSchema::Github => Self::Github,
890        }
891    }
892}
893
894#[derive(Debug, Default, Deserialize)]
895#[serde(deny_unknown_fields)]
896#[allow(clippy::struct_excessive_bools, reason = "shape mirrors `MystOptions`")]
897struct MystSchema {
898    #[serde(default, rename = "directive-containers")]
899    directive_containers: Option<bool>,
900    #[serde(default, rename = "inline-roles")]
901    inline_roles: Option<bool>,
902    #[serde(default, rename = "substitution-references")]
903    substitution_references: Option<bool>,
904    #[serde(default)]
905    comments: Option<bool>,
906}
907
908impl From<MystSchema> for MystOptions {
909    fn from(s: MystSchema) -> Self {
910        let default = Self::default();
911        Self {
912            directive_containers: s.directive_containers.unwrap_or(default.directive_containers),
913            inline_roles: s.inline_roles.unwrap_or(default.inline_roles),
914            substitution_references: s.substitution_references.unwrap_or(default.substitution_references),
915            comments: s.comments.unwrap_or(default.comments),
916        }
917    }
918}
919
920#[derive(Debug, Default, Deserialize)]
921#[serde(deny_unknown_fields)]
922struct PandocSchema {
923    #[serde(default, rename = "fenced-divs")]
924    fenced_divs: Option<bool>,
925    #[serde(default, rename = "short-form-divs")]
926    short_form_divs: Option<bool>,
927    #[serde(default, rename = "inline-attribute-spans")]
928    inline_attribute_spans: Option<bool>,
929}
930
931impl From<PandocSchema> for PandocOptions {
932    fn from(s: PandocSchema) -> Self {
933        let default = Self::default();
934        Self {
935            fenced_divs: s.fenced_divs.unwrap_or(default.fenced_divs),
936            short_form_divs: s.short_form_divs.unwrap_or(default.short_form_divs),
937            inline_attribute_spans: s.inline_attribute_spans.unwrap_or(default.inline_attribute_spans),
938        }
939    }
940}
941
942#[derive(Debug, Default, Deserialize)]
943#[serde(deny_unknown_fields)]
944struct MathSchema {
945    #[serde(default)]
946    normalise: Option<bool>,
947    #[serde(default)]
948    render: Option<MathRenderSchema>,
949}
950
951#[derive(Debug, Deserialize)]
952#[serde(rename_all = "kebab-case")]
953enum MathRenderSchema {
954    None,
955    CommonmarkKatex,
956    Dollar,
957}
958
959impl From<MathRenderSchema> for MathRender {
960    fn from(s: MathRenderSchema) -> Self {
961        match s {
962            MathRenderSchema::None => Self::None,
963            MathRenderSchema::CommonmarkKatex => Self::CommonmarkKatex,
964            MathRenderSchema::Dollar => Self::Dollar,
965        }
966    }
967}
968
969impl From<MathSchema> for MathOptions {
970    fn from(s: MathSchema) -> Self {
971        let default = Self::default();
972        Self {
973            normalise: s.normalise.unwrap_or(default.normalise),
974            render: s.render.map_or(default.render, MathRender::from),
975        }
976    }
977}
978
979#[derive(Debug, Default, Deserialize)]
980#[serde(deny_unknown_fields)]
981struct FrontmatterSchema {
982    #[serde(default)]
983    preserve: Option<bool>,
984}
985
986#[derive(Debug, Default, Deserialize)]
987#[serde(deny_unknown_fields)]
988struct RefsSchema {
989    #[serde(default)]
990    placement: Option<PlacementSchema>,
991    #[serde(default)]
992    style: Option<LinkDefStyleSchema>,
993}
994
995#[derive(Debug, Default, Deserialize)]
996#[serde(deny_unknown_fields)]
997struct FootnotesSchema {
998    #[serde(default)]
999    placement: Option<PlacementSchema>,
1000}
1001
1002#[derive(Debug, Default, Deserialize)]
1003#[serde(deny_unknown_fields)]
1004struct TablesSchema {
1005    #[serde(default)]
1006    style: Option<TableStyleSchema>,
1007}
1008
1009#[derive(Debug, Default, Deserialize)]
1010#[serde(deny_unknown_fields)]
1011struct ListsSchema {
1012    #[serde(default, rename = "continuation-indent")]
1013    continuation_indent: Option<ListContinuationIndentSchema>,
1014}
1015
1016#[derive(Debug, Deserialize)]
1017#[serde(rename_all = "kebab-case")]
1018enum ListContinuationIndentSchema {
1019    MarkerWidth,
1020    FourSpace,
1021}
1022
1023impl From<ListContinuationIndentSchema> for ListContinuationIndent {
1024    fn from(s: ListContinuationIndentSchema) -> Self {
1025        match s {
1026            ListContinuationIndentSchema::MarkerWidth => Self::MarkerWidth,
1027            ListContinuationIndentSchema::FourSpace => Self::FourSpace,
1028        }
1029    }
1030}
1031
1032#[derive(Debug, Deserialize)]
1033#[serde(rename_all = "lowercase")]
1034enum PlacementSchema {
1035    End,
1036    Preserve,
1037}
1038
1039#[derive(Debug, Deserialize)]
1040#[serde(rename_all = "lowercase")]
1041enum LinkDefStyleSchema {
1042    Bare,
1043    Angle,
1044    Preserve,
1045}
1046
1047#[derive(Debug)]
1048enum WrapSchema {
1049    Mode(WrapMode),
1050    Columns(u32),
1051}
1052
1053impl<'de> Deserialize<'de> for WrapSchema {
1054    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1055    where
1056        D: Deserializer<'de>,
1057    {
1058        struct WrapVisitor;
1059
1060        impl Visitor<'_> for WrapVisitor {
1061            type Value = WrapSchema;
1062
1063            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1064                formatter.write_str(r#""keep", "no", or an integer column width"#)
1065            }
1066
1067            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
1068            where
1069                E: DeError,
1070            {
1071                match value {
1072                    "keep" => Ok(WrapSchema::Mode(WrapMode::Keep)),
1073                    "no" => Ok(WrapSchema::Mode(WrapMode::No)),
1074                    _ => Err(E::custom(format!(
1075                        r#"invalid wrap value {value:?}; expected "keep", "no", or an integer column width"#
1076                    ))),
1077                }
1078            }
1079
1080            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
1081            where
1082                E: DeError,
1083            {
1084                let columns = u32::try_from(value).map_err(|_| {
1085                    E::custom(format!(
1086                        "wrap column width {value} is too large; expected an integer from 0 to {}",
1087                        u32::MAX
1088                    ))
1089                })?;
1090                Ok(WrapSchema::Columns(columns))
1091            }
1092
1093            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
1094            where
1095                E: DeError,
1096            {
1097                let columns = u32::try_from(value).map_err(|_| {
1098                    E::custom(format!(
1099                        r#"invalid wrap value {value}; expected "keep", "no", or a non-negative integer column width"#
1100                    ))
1101                })?;
1102                Ok(WrapSchema::Columns(columns))
1103            }
1104        }
1105
1106        deserializer.deserialize_any(WrapVisitor)
1107    }
1108}
1109
1110#[derive(Debug, Deserialize)]
1111#[serde(rename_all = "lowercase")]
1112enum WrapMode {
1113    Keep,
1114    No,
1115}
1116
1117#[derive(Debug, Deserialize)]
1118#[serde(rename_all = "kebab-case")]
1119enum WrapStrategySchema {
1120    Stable,
1121    Balanced,
1122}
1123
1124impl From<WrapStrategySchema> for WrapStrategy {
1125    fn from(s: WrapStrategySchema) -> Self {
1126        match s {
1127            WrapStrategySchema::Stable => Self::Stable,
1128            WrapStrategySchema::Balanced => Self::Balanced,
1129        }
1130    }
1131}
1132
1133#[derive(Debug, Deserialize)]
1134#[serde(rename_all = "lowercase")]
1135enum ItalicSchema {
1136    Asterisk,
1137    Underscore,
1138    Preserve,
1139}
1140
1141#[derive(Debug, Deserialize)]
1142#[serde(rename_all = "lowercase")]
1143enum StrongSchema {
1144    Asterisk,
1145    Underscore,
1146    Preserve,
1147}
1148
1149#[derive(Debug, Deserialize)]
1150#[serde(rename_all = "kebab-case")]
1151enum FmtProfileSchema {
1152    Preserve,
1153    Mdformat,
1154}
1155
1156#[derive(Debug, Deserialize)]
1157#[serde(rename_all = "lowercase")]
1158enum ListMarkerSchema {
1159    Dash,
1160    Asterisk,
1161    Plus,
1162    Preserve,
1163}
1164
1165#[derive(Debug, Deserialize)]
1166#[serde(rename_all = "lowercase")]
1167enum OrderedListSchema {
1168    One,
1169    Consistent,
1170    Preserve,
1171}
1172
1173#[derive(Debug, Deserialize)]
1174#[serde(rename_all = "kebab-case")]
1175enum ThematicSchema {
1176    Dash,
1177    Asterisk,
1178    Underscore,
1179    #[serde(rename = "underscore-70")]
1180    Underscore70,
1181    Preserve,
1182}
1183
1184#[derive(Debug, Deserialize)]
1185#[serde(rename_all = "lowercase")]
1186enum TableStyleSchema {
1187    Compact,
1188    Align,
1189    Preserve,
1190}
1191
1192#[derive(Debug, Deserialize)]
1193#[serde(untagged)]
1194enum TrailingNewlineSchema {
1195    Named(TrailingNewlineNamed),
1196    /// `trailing-newline = true` ⇒ `Ensure`; `false` ⇒ `Strip`. Kept
1197    /// for backward compatibility with config files written against
1198    /// the pre-Preserve schema.
1199    Bool(bool),
1200}
1201
1202#[derive(Debug, Deserialize)]
1203#[serde(rename_all = "lowercase")]
1204enum TrailingNewlineNamed {
1205    Preserve,
1206    Strip,
1207    Ensure,
1208}
1209
1210impl From<TrailingNewlineSchema> for TrailingNewline {
1211    fn from(s: TrailingNewlineSchema) -> Self {
1212        match s {
1213            TrailingNewlineSchema::Named(TrailingNewlineNamed::Preserve) => Self::Preserve,
1214            TrailingNewlineSchema::Named(TrailingNewlineNamed::Strip) => Self::Strip,
1215            TrailingNewlineSchema::Named(TrailingNewlineNamed::Ensure) => Self::Ensure,
1216            TrailingNewlineSchema::Bool(true) => Self::Ensure,
1217            TrailingNewlineSchema::Bool(false) => Self::Strip,
1218        }
1219    }
1220}
1221
1222#[derive(Debug, Deserialize)]
1223#[serde(rename_all = "lowercase")]
1224enum EndOfLineSchema {
1225    Lf,
1226    Crlf,
1227    Keep,
1228}
1229
1230impl From<WrapSchema> for Wrap {
1231    fn from(s: WrapSchema) -> Self {
1232        match s {
1233            WrapSchema::Mode(WrapMode::Keep) => Self::Keep,
1234            WrapSchema::Mode(WrapMode::No) => Self::No,
1235            WrapSchema::Columns(n) => Self::At(n),
1236        }
1237    }
1238}
1239
1240impl From<ItalicSchema> for ItalicStyle {
1241    fn from(s: ItalicSchema) -> Self {
1242        match s {
1243            ItalicSchema::Asterisk => Self::Asterisk,
1244            ItalicSchema::Underscore => Self::Underscore,
1245            ItalicSchema::Preserve => Self::Preserve,
1246        }
1247    }
1248}
1249
1250impl From<StrongSchema> for StrongStyle {
1251    fn from(s: StrongSchema) -> Self {
1252        match s {
1253            StrongSchema::Asterisk => Self::Asterisk,
1254            StrongSchema::Underscore => Self::Underscore,
1255            StrongSchema::Preserve => Self::Preserve,
1256        }
1257    }
1258}
1259
1260impl From<ThematicSchema> for ThematicStyle {
1261    fn from(s: ThematicSchema) -> Self {
1262        match s {
1263            ThematicSchema::Dash => Self::Dash,
1264            ThematicSchema::Asterisk => Self::Asterisk,
1265            ThematicSchema::Underscore => Self::Underscore,
1266            ThematicSchema::Underscore70 => Self::Underscore70,
1267            ThematicSchema::Preserve => Self::Preserve,
1268        }
1269    }
1270}
1271
1272impl From<TableStyleSchema> for TableStyle {
1273    fn from(s: TableStyleSchema) -> Self {
1274        match s {
1275            TableStyleSchema::Compact => Self::Compact,
1276            TableStyleSchema::Align => Self::Align,
1277            TableStyleSchema::Preserve => Self::Preserve,
1278        }
1279    }
1280}
1281
1282impl From<ListMarkerSchema> for ListMarkerStyle {
1283    fn from(s: ListMarkerSchema) -> Self {
1284        match s {
1285            ListMarkerSchema::Dash => Self::Dash,
1286            ListMarkerSchema::Asterisk => Self::Asterisk,
1287            ListMarkerSchema::Plus => Self::Plus,
1288            ListMarkerSchema::Preserve => Self::Preserve,
1289        }
1290    }
1291}
1292
1293impl From<OrderedListSchema> for OrderedListStyle {
1294    fn from(s: OrderedListSchema) -> Self {
1295        match s {
1296            OrderedListSchema::One => Self::One,
1297            OrderedListSchema::Consistent => Self::Consistent,
1298            OrderedListSchema::Preserve => Self::Preserve,
1299        }
1300    }
1301}
1302
1303impl From<PlacementSchema> for Placement {
1304    fn from(s: PlacementSchema) -> Self {
1305        match s {
1306            PlacementSchema::End => Self::End,
1307            PlacementSchema::Preserve => Self::Preserve,
1308        }
1309    }
1310}
1311
1312impl From<LinkDefStyleSchema> for LinkDefStyle {
1313    fn from(s: LinkDefStyleSchema) -> Self {
1314        match s {
1315            LinkDefStyleSchema::Bare => Self::Bare,
1316            LinkDefStyleSchema::Angle => Self::Angle,
1317            LinkDefStyleSchema::Preserve => Self::Preserve,
1318        }
1319    }
1320}
1321
1322impl From<EndOfLineSchema> for EndOfLine {
1323    fn from(s: EndOfLineSchema) -> Self {
1324        match s {
1325            EndOfLineSchema::Lf => Self::Lf,
1326            EndOfLineSchema::Crlf => Self::Crlf,
1327            EndOfLineSchema::Keep => Self::Keep,
1328        }
1329    }
1330}
1331
1332// ============================================================
1333// File readers
1334// ============================================================
1335
1336fn read_mdwright_toml(path: &Path) -> Result<Config, ConfigError> {
1337    let text = fs::read_to_string(path).map_err(|e| ConfigError::io(path, &e))?;
1338    let schema: Schema = toml::from_str(&text).map_err(|e| ConfigError::parse(path, &e))?;
1339    Ok(Config::from_schema(schema, Some(path.to_owned())))
1340}
1341
1342/// Walk upward from `start`, returning the first config that matches.
1343/// Stops at the filesystem root or at the first directory containing a
1344/// `.git/` entry (the workspace boundary).
1345fn discover_walk(start: &Path) -> Result<Option<Config>, ConfigError> {
1346    for dir in start.ancestors() {
1347        if let Some(cfg) = try_load_dir(dir)? {
1348            return Ok(Some(cfg));
1349        }
1350        if dir.join(".git").exists() {
1351            return Ok(None);
1352        }
1353    }
1354    Ok(None)
1355}
1356
1357/// Try the discovery candidates in one directory in precedence order:
1358/// `.mdwright.toml` > `mdwright.toml` > `pyproject.toml [tool.mdwright]`.
1359/// A `pyproject.toml` without the table returns `Ok(None)` so the
1360/// caller continues the ancestor walk.
1361fn try_load_dir(dir: &Path) -> Result<Option<Config>, ConfigError> {
1362    for name in [".mdwright.toml", "mdwright.toml"] {
1363        let candidate = dir.join(name);
1364        if candidate.is_file() {
1365            return Ok(Some(read_mdwright_toml(&candidate)?));
1366        }
1367    }
1368    let pyproject = dir.join("pyproject.toml");
1369    if pyproject.is_file() {
1370        return read_pyproject(&pyproject);
1371    }
1372    Ok(None)
1373}
1374
1375fn read_pyproject(path: &Path) -> Result<Option<Config>, ConfigError> {
1376    let text = fs::read_to_string(path).map_err(|e| ConfigError::io(path, &e))?;
1377    let value: toml::Value = toml::from_str(&text).map_err(|e| ConfigError::parse(path, &e))?;
1378    let Some(table) = value.as_table() else {
1379        return Ok(None);
1380    };
1381    let Some(tool) = table.get("tool").and_then(toml::Value::as_table) else {
1382        return Ok(None);
1383    };
1384    let Some(mdw) = tool.get("mdwright") else {
1385        return Ok(None);
1386    };
1387    let schema: Schema = mdw
1388        .clone()
1389        .try_into()
1390        .map_err(|e: toml::de::Error| ConfigError::parse(path, &e))?;
1391    Ok(Some(Config::from_schema(schema, Some(path.to_owned()))))
1392}
1393
1394#[cfg(test)]
1395mod tests {
1396    use anyhow::{Result, anyhow};
1397
1398    use mdwright_lint::RuleSet;
1399
1400    use crate::documentation;
1401
1402    use super::{
1403        Config, EndOfLine, FmtOptions, GfmAutolinkPolicy, ItalicStyle, LintRulePreset, ListContinuationIndent,
1404        ListMarkerStyle, MathDelimiterSet, MathRender, OrderedListStyle, RenderProfile, Schema, StrongStyle,
1405        TableStyle, ThematicStyle, TrailingNewline, Wrap, WrapStrategy,
1406    };
1407
1408    fn schema_from_str(src: &str) -> Result<Schema> {
1409        toml::from_str::<Schema>(src).map_err(|e| anyhow!("parse: {e}"))
1410    }
1411
1412    fn config_from_str(src: &str) -> Result<Config> {
1413        Ok(Config::from_schema(schema_from_str(src)?, None))
1414    }
1415
1416    #[test]
1417    fn parses_complete_toml() -> Result<()> {
1418        let src = r#"
1419[lint]
1420preset = "default"
1421extend-select = ["escaped-emphasis"]
1422ignore = ["bare-url"]
1423exclude = ["docs/vendored/**"]
1424[lint.info-strings]
1425extra = ["promql"]
1426
1427[fmt]
1428wrap = 70
1429italic = "asterisk"
1430strong = "underscore"
1431list-marker = "dash"
1432ordered-list = "consistent"
1433thematic-break = "asterisk"
1434trailing-newline = true
1435end-of-line = "lf"
1436exclude = ["docs/generated/**"]
1437
1438[fmt.tables]
1439style = "align"
1440"#;
1441        let cfg = config_from_str(src)?;
1442        let lint = cfg.lint_rule_selection();
1443        assert_eq!(lint.preset(), LintRulePreset::Default);
1444        assert!(lint.select().is_empty());
1445        assert_eq!(lint.extend_select(), &["escaped-emphasis".to_owned()]);
1446        assert_eq!(lint.ignore(), &["bare-url".to_owned()]);
1447        assert_eq!(cfg.exclude_globs(), &["docs/vendored/**".to_owned()]);
1448        assert_eq!(cfg.extra_info_strings(), &["promql".to_owned()]);
1449        let fmt = cfg.fmt_options();
1450        assert_eq!(fmt.wrap(), Wrap::At(70));
1451        assert_eq!(fmt.wrap_strategy(), WrapStrategy::Stable);
1452        assert_eq!(fmt.italic(), ItalicStyle::Asterisk);
1453        assert_eq!(fmt.strong(), StrongStyle::Underscore);
1454        assert_eq!(fmt.list_marker(), ListMarkerStyle::Dash);
1455        assert_eq!(fmt.ordered_list(), OrderedListStyle::Consistent);
1456        assert_eq!(fmt.thematic_break_style(), ThematicStyle::Asterisk);
1457        assert_eq!(fmt.table(), TableStyle::Align);
1458        assert_eq!(fmt.trailing_newline(), TrailingNewline::Ensure);
1459        assert_eq!(fmt.end_of_line(), EndOfLine::Lf);
1460        assert_eq!(fmt.exclude_globs(), &["docs/generated/**".to_owned()]);
1461        Ok(())
1462    }
1463
1464    #[test]
1465    fn default_lint_selection_resolves_defaults() -> Result<()> {
1466        let cfg = config_from_str("")?;
1467        let rules = cfg
1468            .lint_rule_selection()
1469            .resolve(RuleSet::stdlib_all())
1470            .map_err(|err| anyhow!("{err}"))?;
1471        assert!(!rules.is_empty());
1472        assert!(rules.contains("bare-url"));
1473        assert!(!rules.contains("latex-command"));
1474        Ok(())
1475    }
1476
1477    #[test]
1478    fn lint_selection_supports_all_preset() -> Result<()> {
1479        let cfg = config_from_str("[lint]\npreset = \"all\"\n")?;
1480        let rules = cfg
1481            .lint_rule_selection()
1482            .resolve(RuleSet::stdlib_all())
1483            .map_err(|err| anyhow!("{err}"))?;
1484        assert!(rules.contains("latex-command"));
1485        assert!(rules.contains("bare-url"));
1486        Ok(())
1487    }
1488
1489    #[test]
1490    fn lint_selection_supports_explicit_select_with_none_preset() -> Result<()> {
1491        let cfg = config_from_str("[lint]\npreset = \"none\"\nselect = [\"heading-punctuation\", \"bare-url\"]\n")?;
1492        let rules = cfg
1493            .lint_rule_selection()
1494            .resolve(RuleSet::stdlib_all())
1495            .map_err(|err| anyhow!("{err}"))?;
1496        assert!(rules.contains("heading-punctuation"));
1497        assert!(rules.contains("bare-url"));
1498        assert_eq!(rules.len(), 2);
1499        Ok(())
1500    }
1501
1502    #[test]
1503    fn lint_selection_supports_extend_select_and_ignore() -> Result<()> {
1504        let cfg = config_from_str(
1505            "[lint]\npreset = \"default\"\nextend-select = [\"latex-command\"]\nignore = [\"bare-url\"]\n",
1506        )?;
1507        let rules = cfg
1508            .lint_rule_selection()
1509            .resolve(RuleSet::stdlib_all())
1510            .map_err(|err| anyhow!("{err}"))?;
1511        assert!(rules.contains("latex-command"));
1512        assert!(!rules.contains("bare-url"));
1513        Ok(())
1514    }
1515
1516    #[test]
1517    fn rejects_legacy_rules_key_with_migration_hint() -> Result<()> {
1518        let err = toml::from_str::<Schema>("[lint]\nrules = \"default,+latex-command\"\n")
1519            .err()
1520            .ok_or_else(|| anyhow!("expected error"))?;
1521        let rendered = err.to_string();
1522        assert!(
1523            rendered.contains("lint.rules"),
1524            "error should name legacy key: {rendered}"
1525        );
1526        assert!(
1527            rendered.contains("extend-select"),
1528            "error should suggest new keys: {rendered}"
1529        );
1530        Ok(())
1531    }
1532
1533    #[test]
1534    fn rejects_presets_in_rule_name_lists() -> Result<()> {
1535        let err = toml::from_str::<Schema>("[lint]\npreset = \"none\"\nselect = [\"default\"]\n")
1536            .err()
1537            .ok_or_else(|| anyhow!("expected error"))?;
1538        let rendered = err.to_string();
1539        assert!(
1540            rendered.contains("preset") && rendered.contains("select"),
1541            "error should explain preset/rule split: {rendered}"
1542        );
1543        Ok(())
1544    }
1545
1546    #[test]
1547    fn rejects_select_with_non_none_preset() -> Result<()> {
1548        let err = toml::from_str::<Schema>("[lint]\npreset = \"default\"\nselect = [\"bare-url\"]\n")
1549            .err()
1550            .ok_or_else(|| anyhow!("expected error"))?;
1551        let rendered = err.to_string();
1552        assert!(
1553            rendered.contains("extend-select") && rendered.contains("preset"),
1554            "error should explain valid shape: {rendered}"
1555        );
1556        Ok(())
1557    }
1558
1559    #[test]
1560    fn resolve_rejects_unknown_rule_names() -> Result<()> {
1561        let cfg = config_from_str("[lint]\nextend-select = [\"no-such-rule\"]\n")?;
1562        let err = cfg
1563            .lint_rule_selection()
1564            .resolve(RuleSet::stdlib_all())
1565            .err()
1566            .ok_or_else(|| anyhow!("expected error"))?;
1567        assert!(err.to_string().contains("no-such-rule"));
1568        Ok(())
1569    }
1570
1571    #[test]
1572    fn parses_render_packages_and_macros() -> Result<()> {
1573        let src = r#"
1574[lint.render]
1575renderer = "mathjax-v3"
1576packages = ["mhchem", "physics"]
1577[lint.render.macros]
1578RR = 0
1579NN = { arity = 0 }
1580proj = { arity = 1 }
1581"#;
1582        let cfg = config_from_str(src)?;
1583        let options = cfg.render_lint_options();
1584        assert_eq!(options.renderer(), mdwright_mathrender::Renderer::MathJaxV3);
1585        assert_eq!(options.packages(), &["mhchem".to_owned(), "physics".to_owned()]);
1586        assert_eq!(options.macros().get("RR"), Some(&0));
1587        assert_eq!(options.macros().get("NN"), Some(&0));
1588        assert_eq!(options.macros().get("proj"), Some(&1));
1589        Ok(())
1590    }
1591
1592    #[test]
1593    fn parses_katex_renderer_choice() -> Result<()> {
1594        let src = "[lint.render]\nrenderer = \"katex\"\n";
1595        let cfg = config_from_str(src)?;
1596        assert_eq!(
1597            cfg.render_lint_options().renderer(),
1598            mdwright_mathrender::Renderer::Katex
1599        );
1600        Ok(())
1601    }
1602
1603    #[test]
1604    fn rejects_unknown_render_key() {
1605        let err = toml::from_str::<Schema>("[lint.render]\nfoo = []\n");
1606        assert!(err.is_err(), "unknown key should be rejected");
1607    }
1608
1609    #[test]
1610    fn generated_default_toml_parses_as_defaults() -> Result<()> {
1611        let generated = documentation::render_default_toml();
1612        let cfg = config_from_str(&generated)?;
1613        let default = Config::defaults();
1614
1615        assert_eq!(cfg.lint_rule_selection(), default.lint_rule_selection());
1616        assert_eq!(cfg.exclude_globs(), default.exclude_globs());
1617        assert_eq!(cfg.extra_info_strings(), default.extra_info_strings());
1618        assert_eq!(cfg.parse_options(), default.parse_options());
1619        assert_eq!(cfg.render_options(), default.render_options());
1620
1621        let fmt = cfg.fmt_options();
1622        let default_fmt = default.fmt_options();
1623        assert_eq!(fmt.wrap(), default_fmt.wrap());
1624        assert_eq!(fmt.wrap_strategy(), default_fmt.wrap_strategy());
1625        assert_eq!(fmt.italic(), default_fmt.italic());
1626        assert_eq!(fmt.strong(), default_fmt.strong());
1627        assert_eq!(fmt.list_marker(), default_fmt.list_marker());
1628        assert_eq!(fmt.ordered_list(), default_fmt.ordered_list());
1629        assert_eq!(fmt.thematic_break_style(), default_fmt.thematic_break_style());
1630        assert_eq!(fmt.trailing_newline(), default_fmt.trailing_newline());
1631        assert_eq!(fmt.end_of_line(), default_fmt.end_of_line());
1632        assert_eq!(fmt.exclude_globs(), default_fmt.exclude_globs());
1633        assert_eq!(fmt.link_def_placement(), default_fmt.link_def_placement());
1634        assert_eq!(fmt.link_def_style(), default_fmt.link_def_style());
1635        assert_eq!(fmt.footnote_placement(), default_fmt.footnote_placement());
1636        assert_eq!(fmt.table(), default_fmt.table());
1637        assert_eq!(fmt.list_continuation_indent(), default_fmt.list_continuation_indent());
1638        assert_eq!(fmt.preserve_frontmatter(), default_fmt.preserve_frontmatter());
1639        assert_eq!(fmt.heading_attrs(), default_fmt.heading_attrs());
1640        assert!(!fmt.math().normalise);
1641        assert_eq!(fmt.math().render, MathRender::None);
1642
1643        assert!(generated.contains("[lint.info-strings]"));
1644        assert!(generated.contains("extra = []"));
1645        assert!(generated.contains("[fmt.math]"));
1646        assert!(generated.contains("render = \"none\""));
1647        assert!(generated.contains("[parse.math]"));
1648        assert!(generated.contains("delimiters = \"tex\""));
1649        assert!(generated.contains("[parse.extensions.gfm]"));
1650        assert!(generated.contains("autolinks = \"urls-and-emails\""));
1651        Ok(())
1652    }
1653
1654    #[test]
1655    fn parse_math_delimiters_default_to_tex() -> Result<()> {
1656        let cfg = config_from_str("")?;
1657        assert_eq!(cfg.parse_options().math().delimiters, MathDelimiterSet::Tex);
1658        Ok(())
1659    }
1660
1661    #[test]
1662    fn parse_math_delimiters_accept_github() -> Result<()> {
1663        let cfg = config_from_str("[parse.math]\ndelimiters = \"github\"\n")?;
1664        assert_eq!(cfg.parse_options().math().delimiters, MathDelimiterSet::Github);
1665        Ok(())
1666    }
1667
1668    #[test]
1669    fn rejects_unknown_top_level_key() -> Result<()> {
1670        let src = "[lnt]\nrules = \"default\"\n";
1671        let err = toml::from_str::<Schema>(src)
1672            .err()
1673            .ok_or_else(|| anyhow!("expected error"))?;
1674        let rendered = err.to_string();
1675        assert!(rendered.contains("lnt"), "error should name 'lnt': {rendered}");
1676        Ok(())
1677    }
1678
1679    #[test]
1680    fn rejects_unknown_inner_key() -> Result<()> {
1681        let src = "[lint]\nrulez = \"default\"\n";
1682        let err = toml::from_str::<Schema>(src)
1683            .err()
1684            .ok_or_else(|| anyhow!("expected error"))?;
1685        let rendered = err.to_string();
1686        assert!(rendered.contains("rulez"), "error should name 'rulez': {rendered}");
1687        Ok(())
1688    }
1689
1690    #[test]
1691    fn wrap_schema_accepts_string_or_int() -> Result<()> {
1692        let keep = config_from_str("[fmt]\nwrap = \"keep\"\n")?;
1693        assert_eq!(keep.fmt_options().wrap(), Wrap::Keep);
1694        assert_eq!(keep.fmt_options().wrap().columns(), u32::MAX);
1695        let no = config_from_str("[fmt]\nwrap = \"no\"\n")?;
1696        assert_eq!(no.fmt_options().wrap(), Wrap::No);
1697        assert_eq!(no.fmt_options().wrap().columns(), u32::MAX);
1698        let columns = config_from_str("[fmt]\nwrap = 70\n")?;
1699        assert_eq!(columns.fmt_options().wrap(), Wrap::At(70));
1700        assert_eq!(columns.fmt_options().wrap().columns(), 70);
1701        Ok(())
1702    }
1703
1704    #[test]
1705    fn parse_extensions_are_parse_policy() -> Result<()> {
1706        let cfg = config_from_str(
1707            r#"
1708[parse.extensions]
1709definition-lists = false
1710heading-attribute-lists = false
1711
1712[parse.extensions.gfm]
1713autolinks = "disabled"
1714tagfilter = false
1715
1716[parse.extensions.myst]
1717comments = false
1718
1719[parse.extensions.pandoc]
1720inline-attribute-spans = false
1721"#,
1722        )?;
1723        let extensions = cfg.parse_options().extensions();
1724        assert_eq!(extensions.gfm.autolinks, GfmAutolinkPolicy::Disabled);
1725        assert!(!extensions.gfm.tagfilter);
1726        assert!(!extensions.definition_lists);
1727        assert!(!extensions.heading_attribute_lists);
1728        assert!(!extensions.myst.comments);
1729        assert!(!extensions.pandoc.inline_attribute_spans);
1730        Ok(())
1731    }
1732
1733    #[test]
1734    fn render_profile_is_render_policy() -> Result<()> {
1735        let default = config_from_str("")?;
1736        assert_eq!(default.render_options().profile(), RenderProfile::Pulldown);
1737
1738        let cfg = config_from_str("[render]\nprofile = \"cmark-gfm\"\n")?;
1739        assert_eq!(cfg.render_options().profile(), RenderProfile::CmarkGfm);
1740        Ok(())
1741    }
1742
1743    #[test]
1744    fn rejects_unknown_render_profile() -> Result<()> {
1745        let err = config_from_str("[render]\nprofile = \"github\"\n")
1746            .err()
1747            .ok_or_else(|| anyhow!("expected error"))?;
1748        assert!(
1749            err.to_string().contains("profile"),
1750            "error should name rejected render profile: {err}"
1751        );
1752        Ok(())
1753    }
1754
1755    #[test]
1756    fn fmt_profile_mdformat_sets_compatible_defaults() -> Result<()> {
1757        let cfg = config_from_str("[fmt]\nprofile = \"mdformat\"\n")?;
1758        let fmt = cfg.fmt_options();
1759        assert_eq!(fmt.wrap(), Wrap::Keep);
1760        assert_eq!(fmt.wrap_strategy(), WrapStrategy::Stable);
1761        assert_eq!(fmt.italic(), ItalicStyle::Preserve);
1762        assert_eq!(fmt.strong(), StrongStyle::Preserve);
1763        assert_eq!(fmt.list_marker(), ListMarkerStyle::Dash);
1764        assert_eq!(fmt.list_continuation_indent(), ListContinuationIndent::FourSpace);
1765        assert_eq!(fmt.ordered_list(), OrderedListStyle::One);
1766        assert_eq!(fmt.thematic_break_style(), ThematicStyle::Underscore70);
1767        assert_eq!(fmt.table(), TableStyle::Align);
1768        assert!(fmt.preserve_frontmatter());
1769        Ok(())
1770    }
1771
1772    #[test]
1773    fn explicit_fmt_keys_override_mdformat_profile() -> Result<()> {
1774        let cfg = config_from_str(
1775            r#"
1776[fmt]
1777profile = "mdformat"
1778wrap = 120
1779wrap-strategy = "balanced"
1780list-marker = "plus"
1781ordered-list = "consistent"
1782thematic-break = "dash"
1783
1784[fmt.lists]
1785continuation-indent = "marker-width"
1786
1787[fmt.tables]
1788style = "preserve"
1789"#,
1790        )?;
1791        let fmt = cfg.fmt_options();
1792        assert_eq!(fmt.wrap(), Wrap::At(120));
1793        assert_eq!(fmt.wrap_strategy(), WrapStrategy::Balanced);
1794        assert_eq!(fmt.list_marker(), ListMarkerStyle::Plus);
1795        assert_eq!(fmt.ordered_list(), OrderedListStyle::Consistent);
1796        assert_eq!(fmt.list_continuation_indent(), ListContinuationIndent::MarkerWidth);
1797        assert_eq!(fmt.thematic_break_style(), ThematicStyle::Dash);
1798        assert_eq!(fmt.table(), TableStyle::Preserve);
1799        Ok(())
1800    }
1801
1802    #[test]
1803    fn fmt_wrap_strategy_accepts_supported_styles() -> Result<()> {
1804        let stable = config_from_str("[fmt]\nwrap-strategy = \"stable\"\n")?;
1805        assert_eq!(stable.fmt_options().wrap_strategy(), WrapStrategy::Stable);
1806
1807        let balanced = config_from_str("[fmt]\nwrap-strategy = \"balanced\"\n")?;
1808        assert_eq!(balanced.fmt_options().wrap_strategy(), WrapStrategy::Balanced);
1809
1810        let err = config_from_str("[fmt]\nwrap-strategy = \"pretty\"\n")
1811            .err()
1812            .ok_or_else(|| anyhow!("expected wrap-strategy error"))?;
1813        assert!(
1814            err.to_string().contains("wrap-strategy"),
1815            "error should name wrap-strategy: {err}"
1816        );
1817        Ok(())
1818    }
1819
1820    #[test]
1821    fn fmt_lists_continuation_indent_accepts_supported_styles() -> Result<()> {
1822        let marker_width = config_from_str("[fmt.lists]\ncontinuation-indent = \"marker-width\"\n")?;
1823        assert_eq!(
1824            marker_width.fmt_options().list_continuation_indent(),
1825            ListContinuationIndent::MarkerWidth
1826        );
1827
1828        let four_space = config_from_str("[fmt.lists]\ncontinuation-indent = \"four-space\"\n")?;
1829        assert_eq!(
1830            four_space.fmt_options().list_continuation_indent(),
1831            ListContinuationIndent::FourSpace
1832        );
1833
1834        let err = config_from_str("[fmt.lists]\ncontinuation-indent = \"tab\"\n")
1835            .err()
1836            .ok_or_else(|| anyhow!("expected continuation-indent error"))?;
1837        assert!(
1838            err.to_string().contains("continuation-indent"),
1839            "error should name rejected continuation-indent: {err}"
1840        );
1841        Ok(())
1842    }
1843
1844    #[test]
1845    fn fmt_tables_style_accepts_supported_styles() -> Result<()> {
1846        let compact = config_from_str("[fmt.tables]\nstyle = \"compact\"\n")?;
1847        assert_eq!(compact.fmt_options().table(), TableStyle::Compact);
1848
1849        let align = config_from_str("[fmt.tables]\nstyle = \"align\"\n")?;
1850        assert_eq!(align.fmt_options().table(), TableStyle::Align);
1851
1852        let preserve = config_from_str("[fmt.tables]\nstyle = \"preserve\"\n")?;
1853        assert_eq!(preserve.fmt_options().table(), TableStyle::Preserve);
1854
1855        let pad = config_from_str("[fmt.tables]\nstyle = \"pad\"\n")
1856            .err()
1857            .ok_or_else(|| anyhow!("expected table style error"))?;
1858        assert!(
1859            pad.to_string().contains("style"),
1860            "error should name rejected table style: {pad}"
1861        );
1862        Ok(())
1863    }
1864
1865    #[test]
1866    fn rejects_unknown_fmt_profile_and_table_style() -> Result<()> {
1867        let profile = config_from_str("[fmt]\nprofile = \"aggressive\"\n")
1868            .err()
1869            .ok_or_else(|| anyhow!("expected profile error"))?;
1870        assert!(
1871            profile.to_string().contains("profile"),
1872            "error should name profile: {profile}"
1873        );
1874
1875        let table = config_from_str("[fmt.tables]\nstyle = \"wide\"\n")
1876            .err()
1877            .ok_or_else(|| anyhow!("expected table style error"))?;
1878        assert!(
1879            table.to_string().contains("style"),
1880            "error should name table style: {table}"
1881        );
1882        Ok(())
1883    }
1884
1885    #[test]
1886    fn formatter_extension_table_is_not_a_schema_key() -> Result<()> {
1887        let src = concat!("[fmt", ".extensions]\ndefinition-lists = false\n");
1888        let err = toml::from_str::<Schema>(src)
1889            .err()
1890            .ok_or_else(|| anyhow!("expected error"))?;
1891        let rendered = err.to_string();
1892        assert!(
1893            rendered.contains("extensions"),
1894            "error should name rejected formatter extension table: {rendered}"
1895        );
1896        Ok(())
1897    }
1898
1899    #[test]
1900    fn resolvers_honour_style() -> Result<()> {
1901        let preserve = config_from_str("[fmt]\nitalic = \"preserve\"\nlist-marker = \"preserve\"\n")?;
1902        let fmt = preserve.fmt_options();
1903        assert_eq!(fmt.resolve_italic(b'_'), b'_');
1904        assert_eq!(fmt.resolve_italic(b'*'), b'*');
1905        assert_eq!(fmt.resolve_list_marker(b'+'), b'+');
1906
1907        let pin = config_from_str("[fmt]\nitalic = \"asterisk\"\nlist-marker = \"dash\"\n")?;
1908        let fmt = pin.fmt_options();
1909        assert_eq!(fmt.resolve_italic(b'_'), b'*');
1910        assert_eq!(fmt.resolve_list_marker(b'*'), b'-');
1911
1912        // Default config (no [fmt] table): delimiter and marker style
1913        // knobs are Preserve, so resolvers pass source bytes through
1914        // unchanged. Tables have their own default normal form.
1915        let defaults = FmtOptions::default();
1916        assert_eq!(defaults.resolve_italic(b'_'), b'_');
1917        assert_eq!(defaults.resolve_italic(b'*'), b'*');
1918        assert_eq!(defaults.resolve_list_marker(b'+'), b'+');
1919        assert_eq!(defaults.resolve_list_marker(b'-'), b'-');
1920        Ok(())
1921    }
1922
1923    #[test]
1924    fn style_enums_round_trip() -> Result<()> {
1925        for (lit, expected) in [
1926            ("\"asterisk\"", ItalicStyle::Asterisk),
1927            ("\"underscore\"", ItalicStyle::Underscore),
1928            ("\"preserve\"", ItalicStyle::Preserve),
1929        ] {
1930            let cfg = config_from_str(&format!("[fmt]\nitalic = {lit}\n"))?;
1931            assert_eq!(cfg.fmt_options().italic(), expected);
1932        }
1933        for (lit, expected) in [
1934            ("\"asterisk\"", StrongStyle::Asterisk),
1935            ("\"underscore\"", StrongStyle::Underscore),
1936            ("\"preserve\"", StrongStyle::Preserve),
1937        ] {
1938            let cfg = config_from_str(&format!("[fmt]\nstrong = {lit}\n"))?;
1939            assert_eq!(cfg.fmt_options().strong(), expected);
1940        }
1941        for (lit, expected) in [
1942            ("\"dash\"", ThematicStyle::Dash),
1943            ("\"asterisk\"", ThematicStyle::Asterisk),
1944            ("\"underscore\"", ThematicStyle::Underscore),
1945            ("\"underscore-70\"", ThematicStyle::Underscore70),
1946            ("\"preserve\"", ThematicStyle::Preserve),
1947        ] {
1948            let cfg = config_from_str(&format!("[fmt]\nthematic-break = {lit}\n"))?;
1949            assert_eq!(cfg.fmt_options().thematic_break_style(), expected);
1950        }
1951        for (lit, expected) in [
1952            ("\"dash\"", ListMarkerStyle::Dash),
1953            ("\"asterisk\"", ListMarkerStyle::Asterisk),
1954            ("\"plus\"", ListMarkerStyle::Plus),
1955            ("\"preserve\"", ListMarkerStyle::Preserve),
1956        ] {
1957            let cfg = config_from_str(&format!("[fmt]\nlist-marker = {lit}\n"))?;
1958            assert_eq!(cfg.fmt_options().list_marker(), expected);
1959        }
1960        for (lit, expected) in [
1961            ("\"one\"", OrderedListStyle::One),
1962            ("\"consistent\"", OrderedListStyle::Consistent),
1963            ("\"preserve\"", OrderedListStyle::Preserve),
1964        ] {
1965            let cfg = config_from_str(&format!("[fmt]\nordered-list = {lit}\n"))?;
1966            assert_eq!(cfg.fmt_options().ordered_list(), expected);
1967        }
1968        for (lit, expected) in [
1969            ("\"lf\"", EndOfLine::Lf),
1970            ("\"crlf\"", EndOfLine::Crlf),
1971            ("\"keep\"", EndOfLine::Keep),
1972        ] {
1973            let cfg = config_from_str(&format!("[fmt]\nend-of-line = {lit}\n"))?;
1974            assert_eq!(cfg.fmt_options().end_of_line(), expected);
1975        }
1976        Ok(())
1977    }
1978}