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