Skip to main content

panache_parser/
config.rs

1use std::collections::HashMap;
2use std::env;
3use std::fs;
4use std::io;
5use std::path::{Path, PathBuf};
6
7use globset::GlobBuilder;
8use serde::{Deserialize, Deserializer, Serialize};
9
10mod formatter_presets;
11pub use formatter_presets::FormatterPresetMetadata;
12
13/// The flavor of Markdown to parse and format.
14/// Each flavor has a different set of default extensions enabled.
15#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
16#[serde(rename_all = "kebab-case")]
17pub enum Flavor {
18    /// Standard Pandoc Markdown (default extensions enabled)
19    #[default]
20    Pandoc,
21    /// Quarto (Pandoc + Quarto-specific extensions)
22    Quarto,
23    /// R Markdown (Pandoc + R-specific extensions)
24    #[serde(rename = "rmarkdown")]
25    RMarkdown,
26    /// GitHub Flavored Markdown
27    Gfm,
28    /// CommonMark
29    CommonMark,
30    /// MultiMarkdown
31    #[serde(rename = "multimarkdown")]
32    MultiMarkdown,
33}
34
35/// Pandoc/Markdown extensions configuration.
36/// Each field represents a specific Pandoc extension.
37/// Extensions marked with a comment indicate implementation status.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39#[serde(default)]
40#[serde(rename_all = "kebab-case")]
41pub struct Extensions {
42    // ===== Block-level extensions =====
43
44    // Headings
45    /// Require blank line before headers (default: enabled)
46    #[serde(alias = "blank_before_header")]
47    pub blank_before_header: bool,
48    /// Full attribute syntax on headers {#id .class key=value}
49    #[serde(alias = "header_attributes")]
50    pub header_attributes: bool,
51    /// Auto-generate identifiers from headings
52    pub auto_identifiers: bool,
53    /// Use GitHub's algorithm for auto-generated heading identifiers
54    pub gfm_auto_identifiers: bool,
55    /// Implicit header references ([Heading] links to header)
56    pub implicit_header_references: bool,
57
58    // Block quotes
59    /// Require blank line before blockquotes (default: enabled)
60    #[serde(alias = "blank_before_blockquote")]
61    pub blank_before_blockquote: bool,
62
63    // Lists
64    /// Fancy list markers (roman numerals, letters, etc.)
65    #[serde(alias = "fancy_lists")]
66    pub fancy_lists: bool,
67    /// Start ordered lists at arbitrary numbers
68    pub startnum: bool,
69    /// Example lists with (@) markers
70    #[serde(alias = "example_lists")]
71    pub example_lists: bool,
72    /// GitHub-style task lists - [ ] and - [x]
73    #[serde(alias = "task_lists")]
74    pub task_lists: bool,
75    /// Term/definition syntax
76    #[serde(alias = "definition_lists")]
77    pub definition_lists: bool,
78
79    // Code blocks
80    /// Fenced code blocks with backticks
81    #[serde(alias = "backtick_code_blocks")]
82    pub backtick_code_blocks: bool,
83    /// Fenced code blocks with tildes
84    #[serde(alias = "fenced_code_blocks")]
85    pub fenced_code_blocks: bool,
86    /// Attributes on fenced code blocks {.language #id}
87    #[serde(alias = "fenced_code_attributes")]
88    pub fenced_code_attributes: bool,
89    /// Executable code syntax (currently fenced chunks like ```{r} / ```{python})
90    pub executable_code: bool,
91    /// R Markdown inline executable code (`...`r ...)
92    pub rmarkdown_inline_code: bool,
93    /// Quarto inline executable code (`...`{r} ...)
94    pub quarto_inline_code: bool,
95    /// Attributes on inline code
96    #[serde(alias = "inline_code_attributes")]
97    pub inline_code_attributes: bool,
98
99    // Tables
100    /// Simple table syntax
101    #[serde(alias = "simple_tables")]
102    pub simple_tables: bool,
103    /// Multiline cell content in tables
104    #[serde(alias = "multiline_tables")]
105    pub multiline_tables: bool,
106    /// Grid-style tables
107    #[serde(alias = "grid_tables")]
108    pub grid_tables: bool,
109    /// Pipe tables (GitHub/PHP Markdown style)
110    #[serde(alias = "pipe_tables")]
111    pub pipe_tables: bool,
112    /// Table captions
113    #[serde(alias = "table_captions")]
114    pub table_captions: bool,
115
116    // Divs
117    /// Fenced divs ::: {.class}
118    #[serde(alias = "fenced_divs")]
119    pub fenced_divs: bool,
120    /// HTML <div> elements
121    #[serde(alias = "native_divs")]
122    pub native_divs: bool,
123
124    // Other block elements
125    /// Line blocks for poetry | prefix
126    #[serde(alias = "line_blocks")]
127    pub line_blocks: bool,
128
129    // ===== Inline elements =====
130
131    // Emphasis
132    /// Underscores don't trigger emphasis in snake_case
133    #[serde(alias = "intraword_underscores")]
134    pub intraword_underscores: bool,
135    /// Strikethrough ~~text~~
136    pub strikeout: bool,
137    /// Superscript and subscript ^super^ ~sub~
138    pub superscript: bool,
139    pub subscript: bool,
140
141    // Links
142    /// Inline links [text](url)
143    #[serde(alias = "inline_links")]
144    pub inline_links: bool,
145    /// Reference links [text][ref]
146    #[serde(alias = "reference_links")]
147    pub reference_links: bool,
148    /// Shortcut reference links [ref] without second []
149    #[serde(alias = "shortcut_reference_links")]
150    pub shortcut_reference_links: bool,
151    /// Attributes on links [text](url){.class}
152    #[serde(alias = "link_attributes")]
153    pub link_attributes: bool,
154    /// Automatic links <http://example.com>
155    pub autolinks: bool,
156
157    // Images
158    /// Inline images ![alt](url)
159    #[serde(alias = "inline_images")]
160    pub inline_images: bool,
161    /// Paragraph with just image becomes figure
162    #[serde(alias = "implicit_figures")]
163    pub implicit_figures: bool,
164
165    // Math
166    /// Dollar-delimited math $x$ and $$equation$$
167    #[serde(alias = "tex_math_dollars")]
168    pub tex_math_dollars: bool,
169    /// [NON-DEFAULT] GFM math: inline $`...`$ and fenced ``` math blocks
170    #[serde(alias = "tex_math_gfm")]
171    pub tex_math_gfm: bool,
172    /// [NON-DEFAULT] Single backslash math \(...\) and \[...\] (RMarkdown default)
173    #[serde(alias = "tex_math_single_backslash")]
174    pub tex_math_single_backslash: bool,
175    /// [NON-DEFAULT] Double backslash math \\(...\\) and \\[...\\]
176    #[serde(alias = "tex_math_double_backslash")]
177    pub tex_math_double_backslash: bool,
178
179    // Footnotes
180    /// Inline footnotes ^[text]
181    #[serde(alias = "inline_footnotes")]
182    pub inline_footnotes: bool,
183    /// Reference footnotes `[^1]` (requires footnote parsing)
184    pub footnotes: bool,
185
186    // Citations
187    /// Citation syntax [@cite]
188    pub citations: bool,
189
190    // Spans
191    /// Bracketed spans [text]{.class}
192    #[serde(alias = "bracketed_spans")]
193    pub bracketed_spans: bool,
194    /// HTML <span> elements
195    #[serde(alias = "native_spans")]
196    pub native_spans: bool,
197
198    // ===== Metadata =====
199    /// YAML metadata block
200    #[serde(alias = "yaml_metadata_block")]
201    pub yaml_metadata_block: bool,
202    /// Pandoc title block (Title/Author/Date)
203    #[serde(alias = "pandoc_title_block")]
204    pub pandoc_title_block: bool,
205    /// [NON-DEFAULT] MultiMarkdown metadata/title block (Key: Value ...)
206    pub mmd_title_block: bool,
207
208    // ===== Raw content =====
209    /// Raw HTML blocks and inline
210    #[serde(alias = "raw_html")]
211    pub raw_html: bool,
212    /// Markdown inside HTML blocks
213    #[serde(alias = "markdown_in_html_blocks")]
214    pub markdown_in_html_blocks: bool,
215    /// LaTeX commands and environments
216    #[serde(alias = "raw_tex")]
217    pub raw_tex: bool,
218    /// Generic raw blocks with {=format} syntax
219    #[serde(alias = "raw_attribute")]
220    pub raw_attribute: bool,
221
222    // ===== Escapes and special characters =====
223    /// Backslash escapes any symbol
224    #[serde(alias = "all_symbols_escapable")]
225    pub all_symbols_escapable: bool,
226    /// Backslash at line end = hard line break
227    #[serde(alias = "escaped_line_breaks")]
228    pub escaped_line_breaks: bool,
229
230    // ===== NON-DEFAULT EXTENSIONS =====
231    // These are disabled by default in Pandoc
232    /// [NON-DEFAULT] Bare URLs become links
233    #[serde(alias = "autolink_bare_uris")]
234    pub autolink_bare_uris: bool,
235    /// [NON-DEFAULT] Newline = <br>
236    #[serde(alias = "hard_line_breaks")]
237    pub hard_line_breaks: bool,
238    /// [NON-DEFAULT] MultiMarkdown style heading identifiers [my-id]
239    pub mmd_header_identifiers: bool,
240    /// [NON-DEFAULT] MultiMarkdown key=value attributes on reference defs
241    pub mmd_link_attributes: bool,
242    /// [NON-DEFAULT] GitHub/CommonMark alerts in blockquotes (`> [!NOTE]`)
243    pub alerts: bool,
244    /// [NON-DEFAULT] :emoji: syntax
245    pub emoji: bool,
246    /// [NON-DEFAULT] Highlighted ==text==
247    pub mark: bool,
248
249    // ===== Quarto-specific extensions =====
250    /// Quarto callout blocks (.callout-note, etc.)
251    #[serde(alias = "quarto_callouts")]
252    pub quarto_callouts: bool,
253    /// Quarto cross-references @fig-id, @tbl-id
254    #[serde(alias = "quarto_crossrefs")]
255    pub quarto_crossrefs: bool,
256    /// Quarto shortcodes {{< name args >}}
257    #[serde(alias = "quarto_shortcodes")]
258    pub quarto_shortcodes: bool,
259    /// Bookdown references \@ref(label) and (\#label)
260    pub bookdown_references: bool,
261    /// Bookdown equation references in LaTeX math blocks (\#eq:label)
262    pub bookdown_equation_references: bool,
263}
264
265impl Default for Extensions {
266    fn default() -> Self {
267        Self::for_flavor(Flavor::default())
268    }
269}
270
271impl Extensions {
272    fn none_defaults() -> Self {
273        Self {
274            alerts: false,
275            all_symbols_escapable: false,
276            auto_identifiers: false,
277            autolink_bare_uris: false,
278            autolinks: false,
279            backtick_code_blocks: false,
280            blank_before_blockquote: false,
281            blank_before_header: false,
282            bookdown_references: false,
283            bookdown_equation_references: false,
284            bracketed_spans: false,
285            citations: false,
286            definition_lists: false,
287            emoji: false,
288            escaped_line_breaks: false,
289            example_lists: false,
290            executable_code: false,
291            rmarkdown_inline_code: false,
292            quarto_inline_code: false,
293            fancy_lists: false,
294            fenced_code_attributes: false,
295            fenced_code_blocks: false,
296            fenced_divs: false,
297            footnotes: false,
298            gfm_auto_identifiers: false,
299            grid_tables: false,
300            hard_line_breaks: false,
301            header_attributes: false,
302            implicit_figures: false,
303            implicit_header_references: false,
304            inline_code_attributes: false,
305            inline_footnotes: false,
306            inline_images: false,
307            inline_links: false,
308            intraword_underscores: false,
309            line_blocks: false,
310            link_attributes: false,
311            mark: false,
312            markdown_in_html_blocks: false,
313            mmd_header_identifiers: false,
314            mmd_link_attributes: false,
315            mmd_title_block: false,
316            multiline_tables: false,
317            native_divs: false,
318            native_spans: false,
319            pandoc_title_block: false,
320            pipe_tables: false,
321            quarto_callouts: false,
322            quarto_crossrefs: false,
323            quarto_shortcodes: false,
324            raw_attribute: false,
325            raw_html: false,
326            raw_tex: false,
327            reference_links: false,
328            shortcut_reference_links: false,
329            simple_tables: false,
330            startnum: false,
331            strikeout: false,
332            subscript: false,
333            superscript: false,
334            table_captions: false,
335            task_lists: false,
336            tex_math_dollars: false,
337            tex_math_double_backslash: false,
338            tex_math_gfm: false,
339            tex_math_single_backslash: false,
340            yaml_metadata_block: false,
341        }
342    }
343
344    /// Get the default extension set for a given flavor.
345    pub fn for_flavor(flavor: Flavor) -> Self {
346        match flavor {
347            Flavor::Pandoc => Self::pandoc_defaults(),
348            Flavor::Quarto => Self::quarto_defaults(),
349            Flavor::RMarkdown => Self::rmarkdown_defaults(),
350            Flavor::Gfm => Self::gfm_defaults(),
351            Flavor::CommonMark => Self::commonmark_defaults(),
352            Flavor::MultiMarkdown => Self::multimarkdown_defaults(),
353        }
354    }
355
356    fn pandoc_defaults() -> Self {
357        Self {
358            // Block-level - enabled by default in Pandoc
359            auto_identifiers: true,
360            blank_before_blockquote: true,
361            blank_before_header: true,
362            gfm_auto_identifiers: false,
363            header_attributes: true,
364            implicit_header_references: true,
365
366            // Lists
367            definition_lists: true,
368            example_lists: true,
369            fancy_lists: true,
370            startnum: true,
371            task_lists: true,
372
373            // Code
374            backtick_code_blocks: true,
375            executable_code: false,
376            rmarkdown_inline_code: false,
377            quarto_inline_code: false,
378            fenced_code_attributes: true,
379            fenced_code_blocks: true,
380            inline_code_attributes: true,
381
382            // Tables
383            grid_tables: true,
384            multiline_tables: true,
385            pipe_tables: true,
386            simple_tables: true,
387            table_captions: true,
388
389            // Divs
390            fenced_divs: true,
391            native_divs: true,
392
393            // Other blocks
394            line_blocks: true,
395
396            // Inline
397            intraword_underscores: true,
398            strikeout: true,
399            subscript: true,
400            superscript: true,
401
402            // Links
403            autolinks: true,
404            inline_links: true,
405            link_attributes: true,
406            reference_links: true,
407            shortcut_reference_links: true,
408
409            // Images
410            implicit_figures: true,
411            inline_images: true,
412
413            // Math
414            tex_math_dollars: true,
415            tex_math_double_backslash: false,
416            tex_math_gfm: false,
417            tex_math_single_backslash: false,
418
419            // Footnotes
420            footnotes: true,
421            inline_footnotes: true,
422
423            // Citations
424            citations: true,
425
426            // Spans
427            bracketed_spans: true,
428            native_spans: true,
429
430            // Metadata
431            mmd_title_block: false,
432            pandoc_title_block: true,
433            yaml_metadata_block: true,
434
435            // Raw
436            markdown_in_html_blocks: false,
437            raw_attribute: true,
438            raw_html: true,
439            raw_tex: true,
440
441            // Escapes
442            all_symbols_escapable: true,
443            escaped_line_breaks: true,
444
445            // Non-default
446            alerts: false,
447            autolink_bare_uris: false,
448            emoji: false,
449            hard_line_breaks: false,
450            mark: false,
451            mmd_header_identifiers: false,
452            mmd_link_attributes: false,
453
454            // Quarto/Bookdown-specific
455            bookdown_references: false,
456            bookdown_equation_references: false,
457            quarto_callouts: false,
458            quarto_crossrefs: false,
459            quarto_shortcodes: false,
460        }
461    }
462
463    fn quarto_defaults() -> Self {
464        let mut ext = Self::pandoc_defaults();
465
466        ext.executable_code = true;
467        ext.rmarkdown_inline_code = true;
468        ext.quarto_inline_code = true;
469        ext.quarto_callouts = true;
470        ext.quarto_crossrefs = true;
471        ext.quarto_shortcodes = true;
472
473        ext
474    }
475
476    fn rmarkdown_defaults() -> Self {
477        let mut ext = Self::pandoc_defaults();
478
479        ext.bookdown_references = true;
480        ext.bookdown_equation_references = true;
481        ext.executable_code = true;
482        ext.rmarkdown_inline_code = true;
483        ext.quarto_inline_code = false;
484        ext.tex_math_dollars = true;
485        ext.tex_math_single_backslash = true;
486
487        ext
488    }
489
490    fn gfm_defaults() -> Self {
491        let mut ext = Self::none_defaults();
492
493        ext.alerts = true;
494        ext.auto_identifiers = true;
495        ext.autolink_bare_uris = true;
496        ext.backtick_code_blocks = true;
497        ext.emoji = true;
498        ext.fenced_code_blocks = true;
499        ext.footnotes = true;
500        ext.gfm_auto_identifiers = true;
501        ext.pipe_tables = true;
502        ext.raw_html = true;
503        ext.strikeout = true;
504        ext.task_lists = true;
505        ext.tex_math_dollars = true;
506        ext.tex_math_gfm = true;
507        ext.yaml_metadata_block = true;
508
509        ext
510    }
511
512    fn commonmark_defaults() -> Self {
513        let mut ext = Self::none_defaults();
514        ext.raw_html = true;
515        ext
516    }
517
518    fn multimarkdown_defaults() -> Self {
519        let mut ext = Self::none_defaults();
520
521        ext.all_symbols_escapable = true;
522        ext.auto_identifiers = true;
523        ext.backtick_code_blocks = true;
524        ext.definition_lists = true;
525        ext.footnotes = true;
526        ext.implicit_figures = true;
527        ext.implicit_header_references = true;
528        ext.intraword_underscores = true;
529        ext.mmd_header_identifiers = true;
530        ext.mmd_link_attributes = true;
531        ext.mmd_title_block = true;
532        ext.pipe_tables = true;
533        ext.raw_attribute = true;
534        ext.raw_html = true;
535        ext.reference_links = true;
536        ext.shortcut_reference_links = true;
537        ext.subscript = true;
538        ext.superscript = true;
539        ext.tex_math_dollars = true;
540        ext.tex_math_double_backslash = true;
541
542        ext
543    }
544
545    /// Merge user-specified extension overrides with flavor defaults.
546    ///
547    /// This is used to support partial extension overrides in config files.
548    /// For example, if a user specifies `flavor = "quarto"` and then sets
549    /// `[extensions] quarto-crossrefs = false`, we want all other extensions
550    /// to use Quarto defaults, not Pandoc defaults.
551    ///
552    /// # Arguments
553    /// * `user_overrides` - Map of extension names to their user-specified values
554    /// * `flavor` - The flavor to use for default values
555    ///
556    /// # Returns
557    /// A new Extensions struct with flavor defaults merged with user overrides
558    pub fn merge_with_flavor(user_overrides: HashMap<String, bool>, flavor: Flavor) -> Self {
559        let defaults = Self::for_flavor(flavor);
560        Self::merge_overrides(defaults, user_overrides)
561    }
562
563    fn merge_overrides(base: Extensions, user_overrides: HashMap<String, bool>) -> Self {
564        use serde_json::{Map, Value};
565
566        let defaults_value =
567            serde_json::to_value(&base).expect("Failed to serialize extension defaults");
568
569        let mut merged = if let Value::Object(obj) = defaults_value {
570            obj
571        } else {
572            Map::new()
573        };
574
575        // Apply user overrides (normalize snake_case to kebab-case for consistency)
576        for (key, value) in user_overrides {
577            // Normalize: convert snake_case to kebab-case
578            let normalized_key = key.replace('_', "-");
579            merged.insert(normalized_key, Value::Bool(value));
580        }
581
582        // Deserialize back to Extensions
583        serde_json::from_value(Value::Object(merged))
584            .expect("Failed to deserialize merged extensions")
585    }
586}
587
588/// Configuration for an external code formatter.
589#[derive(Debug, Clone, PartialEq)]
590pub struct FormatterConfig {
591    /// Command to execute (e.g., "black", "air", "rustfmt")
592    pub cmd: String,
593    /// Arguments to pass to the command (e.g., ["-", "--line-length=80"])
594    pub args: Vec<String>,
595    /// Whether this formatter is enabled (deprecated, kept for backwards compatibility)
596    pub enabled: bool,
597    /// Whether the formatter reads from stdin (true) or requires a file path (false)
598    pub stdin: bool,
599}
600
601/// NEW: Language → Formatter mapping value (single formatter or chain)
602#[derive(Debug, Clone, Deserialize, PartialEq)]
603#[serde(untagged)]
604pub enum FormatterValue {
605    /// Single formatter: r = "air"
606    Single(String),
607    /// Multiple formatters (sequential): python = ["isort", "black"]
608    Multiple(Vec<String>),
609}
610
611/// NEW: Named formatter definition (formatters.NAME sections in new format)
612/// OLD: Language-specific formatter config (formatters.LANG sections in old format)
613///
614/// In new format, if the definition name matches a built-in preset, unspecified fields
615/// will inherit from that preset. This allows partial overrides like:
616///
617/// ```toml
618/// [formatters.air]
619/// args = ["format", "--custom"]  # Overrides args, inherits cmd/stdin from built-in "air"
620/// ```
621///
622/// Additionally, you can modify arguments incrementally using `prepend-args` and `append-args`:
623///
624/// ```toml
625/// [formatters.air]
626/// append-args = ["-i", "2"]  # Adds args to end: ["format", "{}", "-i", "2"]
627/// ```
628#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
629#[serde(default)]
630#[serde(rename_all = "kebab-case")]
631pub struct FormatterDefinition {
632    /// Reference to a built-in preset (e.g., "air", "black") - OLD FORMAT ONLY
633    /// In new format, presets are referenced directly in [formatters] mapping
634    pub preset: Option<String>,
635    /// Custom command to execute (None = inherit from preset if name matches)
636    pub cmd: Option<String>,
637    /// Arguments to pass (None = inherit from preset if name matches)
638    pub args: Option<Vec<String>>,
639    /// Arguments to prepend to base args (from preset or explicit args)
640    #[serde(alias = "prepend_args")]
641    pub prepend_args: Option<Vec<String>>,
642    /// Arguments to append to base args (from preset or explicit args)
643    #[serde(alias = "append_args")]
644    pub append_args: Option<Vec<String>>,
645    /// Whether the formatter reads from stdin (None = inherit from preset if name matches)
646    pub stdin: Option<bool>,
647    /// DEPRECATED: Whether formatter is enabled (old format only)
648    pub enabled: Option<bool>,
649}
650
651/// Internal struct for deserializing FormatterConfig with preset support.
652#[derive(Debug, Deserialize)]
653#[serde(default)]
654struct RawFormatterConfig {
655    /// Preset name (e.g., "air", "ruff") - mutually exclusive with cmd
656    preset: Option<String>,
657    /// Command to execute
658    cmd: Option<String>,
659    /// Arguments to pass to the command
660    args: Option<Vec<String>>,
661    /// Whether this formatter is enabled
662    enabled: bool,
663    /// Whether the formatter reads from stdin
664    stdin: bool,
665}
666
667impl Default for RawFormatterConfig {
668    fn default() -> Self {
669        Self {
670            preset: None,
671            cmd: None,
672            args: None,
673            enabled: true,
674            stdin: true,
675        }
676    }
677}
678
679impl<'de> Deserialize<'de> for FormatterConfig {
680    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
681    where
682        D: Deserializer<'de>,
683    {
684        let raw = RawFormatterConfig::deserialize(deserializer)?;
685
686        // Check mutual exclusivity of preset and cmd
687        if raw.preset.is_some() && raw.cmd.is_some() {
688            return Err(serde::de::Error::custom(
689                "FormatterConfig: 'preset' and 'cmd' are mutually exclusive - use one or the other",
690            ));
691        }
692
693        // If preset is specified, resolve it
694        if let Some(preset_name) = raw.preset {
695            let preset = get_formatter_preset(&preset_name).ok_or_else(|| {
696                let available = formatter_preset_names().join(", ");
697                serde::de::Error::custom(format!(
698                    "Unknown formatter preset: '{}'. Available presets: {}",
699                    preset_name, available
700                ))
701            })?;
702
703            // Return the preset, but respect enabled field if explicitly set
704            Ok(FormatterConfig {
705                cmd: preset.cmd,
706                args: preset.args,
707                enabled: raw.enabled,
708                stdin: preset.stdin,
709            })
710        } else if let Some(cmd) = raw.cmd {
711            // Custom configuration
712            Ok(FormatterConfig {
713                cmd,
714                args: raw.args.unwrap_or_default(),
715                enabled: raw.enabled,
716                stdin: raw.stdin,
717            })
718        } else {
719            // No preset and no cmd - return empty config
720            // This can happen with Default::default()
721            Ok(FormatterConfig {
722                cmd: String::new(),
723                args: raw.args.unwrap_or_default(),
724                enabled: raw.enabled,
725                stdin: raw.stdin,
726            })
727        }
728    }
729}
730
731impl Default for FormatterConfig {
732    fn default() -> Self {
733        Self {
734            cmd: String::new(),
735            args: Vec::new(),
736            enabled: true,
737            stdin: true,
738        }
739    }
740}
741
742/// Get a built-in formatter preset by name.
743/// Returns None if the preset doesn't exist.
744pub fn get_formatter_preset(name: &str) -> Option<FormatterConfig> {
745    formatter_presets::get_formatter_preset(name)
746}
747
748/// Canonical built-in formatter preset names used for docs and diagnostics.
749pub fn formatter_preset_names() -> &'static [&'static str] {
750    formatter_presets::formatter_preset_names()
751}
752
753pub fn formatter_preset_supported_languages(name: &str) -> Option<&'static [&'static str]> {
754    formatter_presets::formatter_preset_supported_languages(name)
755}
756
757pub fn formatter_preset_metadata(name: &str) -> Option<&'static FormatterPresetMetadata> {
758    formatter_presets::formatter_preset_metadata(name)
759}
760
761pub fn all_formatter_preset_metadata() -> &'static [FormatterPresetMetadata] {
762    formatter_presets::all_formatter_preset_metadata()
763}
764
765pub fn formatter_presets_for_language(language: &str) -> Vec<&'static FormatterPresetMetadata> {
766    formatter_presets::formatter_presets_for_language(language)
767}
768
769fn normalize_formatter_language(language: &str) -> String {
770    language.trim().to_ascii_lowercase().replace('_', "-")
771}
772
773fn validate_formatter_language_for_preset(lang: &str, formatter_name: &str) -> Result<(), String> {
774    let Some(supported) = formatter_preset_supported_languages(formatter_name) else {
775        return Ok(()); // custom formatter or unknown preset handled elsewhere
776    };
777
778    let normalized_lang = normalize_formatter_language(lang);
779    let matches = supported
780        .iter()
781        .any(|supported_lang| *supported_lang == normalized_lang);
782
783    if matches {
784        return Ok(());
785    }
786
787    Err(format!(
788        "Language '{}': formatter '{}' does not support this language. Supported languages: {}",
789        lang,
790        formatter_name,
791        supported.join(", ")
792    ))
793}
794
795/// Get the default formatters HashMap with built-in presets.
796/// Currently includes R (air) and Python (ruff).
797pub fn default_formatters() -> HashMap<String, FormatterConfig> {
798    let mut map = HashMap::new();
799    map.insert("r".to_string(), get_formatter_preset("air").unwrap());
800    map.insert("python".to_string(), get_formatter_preset("ruff").unwrap());
801    map
802}
803
804/// Style for formatting math delimiters
805#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
806#[serde(rename_all = "kebab-case")]
807pub enum MathDelimiterStyle {
808    /// Preserve original delimiter style (\(...\) stays \(...\), $...$ stays $...$)
809    #[default]
810    Preserve,
811    /// Normalize all to dollar syntax ($...$ and $$...$$)
812    Dollars,
813    /// Normalize all to backslash syntax (\(...\) and \[...\])
814    Backslash,
815}
816
817/// Tab stop handling for formatter output.
818#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
819#[serde(rename_all = "kebab-case")]
820pub enum TabStopMode {
821    /// Normalize tabs to spaces (4-column tab stop).
822    #[default]
823    Normalize,
824    /// Preserve tabs in literal code spans/blocks.
825    Preserve,
826}
827
828/// Formatting style configuration.
829/// Groups all style-related settings together.
830#[derive(Debug, Clone, Deserialize, PartialEq)]
831#[serde(default)]
832#[serde(rename_all = "kebab-case")]
833pub struct StyleConfig {
834    /// Text wrapping mode
835    pub wrap: Option<WrapMode>,
836    /// Blank line handling between blocks
837    pub blank_lines: BlankLines,
838    /// Math delimiter style preference
839    pub math_delimiter_style: MathDelimiterStyle,
840    /// Math indentation (spaces)
841    pub math_indent: usize,
842    /// Tab stop handling (normalize or preserve)
843    pub tab_stops: TabStopMode,
844    /// Tab width for expanding tabs when normalizing
845    pub tab_width: usize,
846    /// Use panache-native greedy wrapping instead of textwrap.
847    pub built_in_greedy_wrap: bool,
848}
849
850impl Default for StyleConfig {
851    fn default() -> Self {
852        Self {
853            wrap: Some(WrapMode::Reflow),
854            blank_lines: BlankLines::Collapse,
855            math_delimiter_style: MathDelimiterStyle::default(),
856            math_indent: 0,
857            tab_stops: TabStopMode::Normalize,
858            tab_width: 4,
859            built_in_greedy_wrap: true,
860        }
861    }
862}
863
864impl StyleConfig {
865    // No flavor-specific defaults needed - just use field defaults
866}
867
868#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
869pub enum PandocCompat {
870    /// Alias for Panache's pinned newest supported Pandoc-compat behavior.
871    ///
872    /// This is intentionally NOT "floating upstream latest". It resolves to
873    /// a concrete version that Panache has verified, and is bumped manually.
874    #[serde(rename = "latest")]
875    Latest,
876    /// Match Pandoc 3.7 behavior for ambiguous syntax edge cases.
877    #[serde(rename = "3.7", alias = "3-7", alias = "v3.7", alias = "v3-7")]
878    V3_7,
879    /// Match Pandoc 3.9 behavior for ambiguous syntax edge cases.
880    #[default]
881    #[serde(rename = "3.9", alias = "3-9", alias = "v3.9", alias = "v3-9")]
882    V3_9,
883}
884
885impl PandocCompat {
886    /// Pinned target for `latest`.
887    pub const PINNED_LATEST: Self = Self::V3_9;
888
889    pub fn effective(self) -> Self {
890        match self {
891            Self::Latest => Self::PINNED_LATEST,
892            other => other,
893        }
894    }
895}
896
897/// Parser configuration.
898#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
899#[serde(default, rename_all = "kebab-case")]
900pub struct ParserConfig {
901    /// Compatibility target for ambiguous Pandoc behavior.
902    pub pandoc_compat: PandocCompat,
903}
904
905impl ParserConfig {
906    pub fn effective_pandoc_compat(&self) -> PandocCompat {
907        self.pandoc_compat.effective()
908    }
909}
910
911/// Linter configuration.
912/// Preferred shape is `[lint.rules] rule-name = true/false`.
913/// Legacy `[lint] rule-name = true/false` is still supported (deprecated).
914#[derive(Debug, Clone, Serialize, PartialEq, Default)]
915pub struct LintConfig {
916    pub rules: HashMap<String, bool>,
917}
918
919impl LintConfig {
920    fn normalize_rule_name(name: &str) -> String {
921        name.trim().to_lowercase().replace('_', "-")
922    }
923
924    fn normalize(mut self) -> Self {
925        self.rules = self
926            .rules
927            .into_iter()
928            .map(|(name, enabled)| (Self::normalize_rule_name(&name), enabled))
929            .collect();
930        self
931    }
932
933    pub fn is_rule_enabled(&self, rule_name: &str) -> bool {
934        let normalized = Self::normalize_rule_name(rule_name);
935        self.rules.get(&normalized).copied().unwrap_or(true)
936    }
937}
938
939impl<'de> Deserialize<'de> for LintConfig {
940    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
941    where
942        D: Deserializer<'de>,
943    {
944        let value = toml::Value::deserialize(deserializer)?;
945        let mut rules = HashMap::new();
946        let mut used_legacy_shape = false;
947
948        let mut table = value
949            .as_table()
950            .cloned()
951            .ok_or_else(|| serde::de::Error::custom("expected [lint] table"))?;
952
953        // New shape: [lint.rules]
954        if let Some(rules_value) = table.remove("rules") {
955            let rules_table = rules_value
956                .as_table()
957                .ok_or_else(|| serde::de::Error::custom("[lint.rules] must be a table"))?;
958            for (name, enabled) in rules_table {
959                let enabled = enabled.as_bool().ok_or_else(|| {
960                    serde::de::Error::custom(format!(
961                        "[lint.rules] entry '{}' must be true or false",
962                        name
963                    ))
964                })?;
965                rules.insert(name.clone(), enabled);
966            }
967        }
968
969        // Legacy shape: [lint] rule-name = true/false
970        for (name, enabled) in table {
971            let enabled = enabled.as_bool().ok_or_else(|| {
972                serde::de::Error::custom(format!(
973                    "Unsupported [lint] key '{}'; use [lint.rules] for rule toggles",
974                    name
975                ))
976            })?;
977            used_legacy_shape = true;
978            rules.insert(name, enabled);
979        }
980
981        if used_legacy_shape {
982            eprintln!(
983                "Warning: [lint] rule = true/false is deprecated; use [lint.rules] rule = true/false."
984            );
985        }
986
987        Ok(Self { rules }.normalize())
988    }
989}
990
991/// Internal deserialization struct that allows for optional fields
992#[derive(Debug, Clone, Deserialize)]
993#[serde(rename_all = "kebab-case")]
994struct RawConfig {
995    #[serde(default)]
996    flavor: Flavor,
997    #[serde(default)]
998    extensions: Option<toml::Value>,
999    #[serde(default)]
1000    line_ending: Option<LineEnding>,
1001    #[serde(default = "default_line_width")]
1002    line_width: usize,
1003    #[serde(default)]
1004    pandoc_compat: Option<PandocCompat>,
1005
1006    // New preferred formatting section
1007    #[serde(default)]
1008    #[serde(rename = "format")]
1009    format_section: Option<StyleConfig>,
1010
1011    // DEPRECATED: [style] section (kept for backwards compatibility)
1012    #[serde(default)]
1013    style: Option<StyleConfig>,
1014
1015    // DEPRECATED: Old top-level style fields (kept for backwards compatibility)
1016    #[serde(default)]
1017    math_indent: usize,
1018    #[serde(default)]
1019    math_delimiter_style: MathDelimiterStyle,
1020    #[serde(default)]
1021    wrap: Option<WrapMode>,
1022    #[serde(default = "default_blank_lines")]
1023    blank_lines: BlankLines,
1024    #[serde(default)]
1025    tab_stops: TabStopMode,
1026    #[serde(default = "default_tab_width")]
1027    tab_width: usize,
1028    // Parser configuration (deprecated home for pandoc-compat)
1029    #[serde(default)]
1030    parser: Option<ParserConfig>,
1031
1032    // NEW: Language → Formatter(s) mapping
1033    // This will be a raw Value that we'll parse manually to handle both formats
1034    #[serde(default)]
1035    formatters: Option<toml::Value>,
1036
1037    /// Max parallel external tool invocations (formatters/linters) per document.
1038    #[serde(default)]
1039    external_max_parallel: Option<usize>,
1040
1041    #[serde(default)]
1042    linters: HashMap<String, String>,
1043    #[serde(default)]
1044    lint: Option<LintConfig>,
1045    #[serde(default)]
1046    cache_dir: Option<String>,
1047    #[serde(default)]
1048    exclude: Option<Vec<String>>,
1049    #[serde(default)]
1050    extend_exclude: Vec<String>,
1051    #[serde(default)]
1052    include: Option<Vec<String>>,
1053    #[serde(default)]
1054    extend_include: Vec<String>,
1055    #[serde(default)]
1056    flavor_overrides: HashMap<String, Flavor>,
1057}
1058
1059fn default_line_width() -> usize {
1060    80
1061}
1062
1063fn default_external_max_parallel() -> usize {
1064    // Conservative cap: documents may have hundreds of code blocks.
1065    std::thread::available_parallelism()
1066        .map(|n| n.get())
1067        .unwrap_or(1)
1068        .clamp(1, 8)
1069}
1070
1071fn default_blank_lines() -> BlankLines {
1072    BlankLines::Collapse
1073}
1074
1075fn default_tab_width() -> usize {
1076    4
1077}
1078
1079pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
1080    ".Rproj.user/",
1081    ".bzr/",
1082    ".cache/",
1083    ".devevn/",
1084    ".direnv/",
1085    ".git/",
1086    ".hg/",
1087    ".julia/",
1088    ".mypy_cache/",
1089    ".nox/",
1090    ".pytest_cache/",
1091    ".ruff_cache/",
1092    ".svn/",
1093    ".tmp/",
1094    ".tox/",
1095    ".venv/",
1096    ".vscode/",
1097    "_book/",
1098    "_build/",
1099    "_freeze/",
1100    "_site/",
1101    "build/",
1102    "dist/",
1103    "node_modules/",
1104    "renv/",
1105    "target/",
1106    "tests/testthat/_snaps",
1107    "**/LICENSE.md",
1108];
1109
1110pub const DEFAULT_INCLUDE_PATTERNS: &[&str] =
1111    &["*.md", "*.qmd", "*.Rmd", "*.markdown", "*.mdown", "*.mkd"];
1112const MARKDOWN_FAMILY_EXTENSIONS: &[&str] = &["md", "markdown", "mdown", "mkd"];
1113
1114/// Resolve a single formatter name to a FormatterConfig.
1115///
1116/// Resolve a formatter name to a FormatterConfig.
1117///
1118/// Resolution order:
1119/// 1. Check if it's a named definition in formatter_definitions
1120///    - If name matches a built-in preset, inherit unspecified fields from preset
1121///    - If name doesn't match preset, require full cmd specification
1122/// 2. Fall back to built-in preset (no custom definition)
1123/// 3. Error if neither found
1124///
1125/// # Examples
1126///
1127/// ```toml
1128/// # Partial override - inherits cmd/stdin from built-in "air"
1129/// [formatters.air]
1130/// args = ["format", "--custom"]
1131///
1132/// # Append args to preset - final: ["format", "{}", "-i", "2"]
1133/// [formatters.air]
1134/// append_args = ["-i", "2"]
1135///
1136/// # Full custom - no preset match, requires cmd
1137/// [formatters.custom-fmt]
1138/// cmd = "my-formatter"
1139/// args = ["--flag"]
1140/// ```
1141fn resolve_formatter_name(
1142    name: &str,
1143    formatter_definitions: &HashMap<String, FormatterDefinition>,
1144) -> Result<FormatterConfig, String> {
1145    // Check for named definition first
1146    if let Some(definition) = formatter_definitions.get(name) {
1147        // Named definition exists - resolve it
1148
1149        // NEW FORMAT: preset field not allowed in named definitions
1150        // (Use direct preset reference in [formatters] mapping instead)
1151        if definition.preset.is_some() {
1152            return Err(format!(
1153                "Formatter '{}': 'preset' field not allowed in named definitions. Use [formatters] mapping instead (e.g., `lang = \"{}\"`).",
1154                name, name
1155            ));
1156        }
1157
1158        // Try to load built-in preset as base (if name matches)
1159        let preset = get_formatter_preset(name);
1160
1161        // Build config by applying overrides to preset (or requiring cmd if no preset)
1162        match (preset, &definition.cmd) {
1163            // Case 1: Preset exists - use as base and apply overrides
1164            (Some(mut base_config), _) => {
1165                // Override cmd if specified
1166                if let Some(cmd) = &definition.cmd {
1167                    base_config.cmd = cmd.clone();
1168                }
1169                // Override args if specified
1170                if let Some(args) = &definition.args {
1171                    base_config.args = args.clone();
1172                }
1173                // Override stdin if specified
1174                if let Some(stdin) = definition.stdin {
1175                    base_config.stdin = stdin;
1176                }
1177
1178                // Apply prepend_args and append_args modifiers
1179                apply_arg_modifiers(&mut base_config.args, definition);
1180
1181                Ok(base_config)
1182            }
1183            // Case 2: No preset, but cmd specified - full custom formatter
1184            (None, Some(cmd)) => {
1185                let mut args = definition.args.clone().unwrap_or_default();
1186
1187                // Apply prepend_args and append_args modifiers
1188                apply_arg_modifiers(&mut args, definition);
1189
1190                Ok(FormatterConfig {
1191                    cmd: cmd.clone(),
1192                    args,
1193                    enabled: true,
1194                    stdin: definition.stdin.unwrap_or(true),
1195                })
1196            }
1197            // Case 3: No preset, no cmd - error
1198            (None, None) => Err(format!(
1199                "Formatter '{}': must specify 'cmd' field (not a known preset)",
1200                name
1201            )),
1202        }
1203    } else {
1204        // Not a named definition - check built-in presets
1205        get_formatter_preset(name).ok_or_else(|| {
1206            format!(
1207                "Unknown formatter '{}': not a named definition or built-in preset. \
1208                 Define it in [formatters.{}] section or use a known preset.",
1209                name, name
1210            )
1211        })
1212    }
1213}
1214
1215/// Apply prepend_args and append_args modifiers to an argument list.
1216///
1217/// Modifiers are applied in order: prepend_args + base_args + append_args
1218/// If no base args exist, they're treated as empty (user responsibility).
1219fn apply_arg_modifiers(args: &mut Vec<String>, definition: &FormatterDefinition) {
1220    // Prepend args if specified
1221    if let Some(prepend) = &definition.prepend_args {
1222        let mut new_args = prepend.clone();
1223        new_args.append(args);
1224        *args = new_args;
1225    }
1226
1227    // Append args if specified
1228    if let Some(append) = &definition.append_args {
1229        args.extend_from_slice(append);
1230    }
1231}
1232
1233/// Resolve a language's formatter value (single or multiple) to a list of FormatterConfigs.
1234fn resolve_language_formatters(
1235    lang: &str,
1236    value: &FormatterValue,
1237    formatter_definitions: &HashMap<String, FormatterDefinition>,
1238) -> Result<Vec<FormatterConfig>, String> {
1239    let formatter_names = match value {
1240        FormatterValue::Single(name) => vec![name.as_str()],
1241        FormatterValue::Multiple(names) => names.iter().map(|s| s.as_str()).collect(),
1242    };
1243
1244    // Resolve each formatter name
1245    formatter_names
1246        .into_iter()
1247        .map(|name| {
1248            // Language guards apply only to built-in presets. Named formatter
1249            // definitions are user-owned and may be intentionally reused across
1250            // languages (e.g. a custom "prettier" definition).
1251            if !formatter_definitions.contains_key(name) {
1252                validate_formatter_language_for_preset(lang, name)?;
1253            }
1254            resolve_formatter_name(name, formatter_definitions)
1255                .map_err(|e| format!("Language '{}': {}", lang, e))
1256        })
1257        .collect()
1258}
1259
1260impl RawConfig {
1261    /// Finalize into Config, applying flavor-based defaults where needed
1262    fn finalize(self) -> Config {
1263        let parser_from_section = self.parser.unwrap_or_default();
1264        let parser_pandoc_compat = parser_from_section.pandoc_compat;
1265
1266        let resolved_pandoc_compat = if let Some(pandoc_compat) = self.pandoc_compat {
1267            if parser_pandoc_compat != PandocCompat::default()
1268                && parser_pandoc_compat != pandoc_compat
1269            {
1270                eprintln!(
1271                    "Warning: Both top-level 'pandoc-compat' and [parser].pandoc-compat are set. Using top-level 'pandoc-compat'."
1272                );
1273            }
1274            pandoc_compat
1275        } else {
1276            if parser_pandoc_compat != PandocCompat::default() {
1277                eprintln!(
1278                    "Warning: [parser].pandoc-compat is deprecated. Please use top-level 'pandoc-compat'."
1279                );
1280            }
1281            parser_pandoc_compat
1282        };
1283
1284        // Check for deprecated top-level style fields
1285        let has_deprecated_fields = self.wrap.is_some()
1286            || self.math_indent != 0
1287            || self.math_delimiter_style != MathDelimiterStyle::default()
1288            || self.blank_lines != default_blank_lines()
1289            || self.tab_stops != TabStopMode::Normalize
1290            || self.tab_width != default_tab_width();
1291
1292        if has_deprecated_fields && self.format_section.is_none() && self.style.is_none() {
1293            eprintln!(
1294                "Warning: top-level style fields (wrap, math-indent, etc.) \
1295                 are deprecated. Please move them under [format] section. \
1296                 See documentation for the new format."
1297            );
1298        }
1299
1300        // Merge formatting config: prefer [format], then deprecated [style], then old top-level fields.
1301        let style = if let Some(format_config) = self.format_section {
1302            if self.style.is_some() {
1303                eprintln!(
1304                    "Warning: Both [format] and deprecated [style] sections found. \
1305                     Using [format] section."
1306                );
1307            }
1308            if has_deprecated_fields {
1309                eprintln!(
1310                    "Warning: Both [format] section and top-level style fields found. \
1311                     Using [format] section and ignoring top-level fields."
1312                );
1313            }
1314
1315            format_config
1316        } else if let Some(style_config) = self.style {
1317            eprintln!("Warning: [style] section is deprecated. Please use [format] instead.");
1318            if has_deprecated_fields {
1319                eprintln!(
1320                    "Warning: Both deprecated [style] section and top-level style fields found. \
1321                     Using [style] section and ignoring top-level fields."
1322                );
1323            }
1324            style_config
1325        } else {
1326            // Old format - construct StyleConfig from top-level fields
1327            StyleConfig {
1328                wrap: self.wrap.or(Some(WrapMode::Reflow)),
1329                blank_lines: self.blank_lines,
1330                math_delimiter_style: self.math_delimiter_style,
1331                math_indent: self.math_indent,
1332                tab_stops: self.tab_stops,
1333                tab_width: self.tab_width,
1334                built_in_greedy_wrap: true,
1335            }
1336        };
1337
1338        Config {
1339            extensions: resolve_extensions_for_flavor(self.extensions.as_ref(), self.flavor),
1340            line_ending: self.line_ending.or(Some(LineEnding::Auto)),
1341            flavor: self.flavor,
1342            line_width: self.line_width,
1343            wrap: style.wrap,
1344            blank_lines: style.blank_lines,
1345            math_delimiter_style: style.math_delimiter_style,
1346            math_indent: style.math_indent,
1347            tab_stops: style.tab_stops,
1348            tab_width: style.tab_width,
1349            formatters: resolve_formatters(self.formatters),
1350            linters: self.linters,
1351            lint: self.lint.unwrap_or_default().normalize(),
1352            cache_dir: self.cache_dir,
1353            external_max_parallel: self
1354                .external_max_parallel
1355                .unwrap_or_else(default_external_max_parallel),
1356            parser: ParserConfig {
1357                pandoc_compat: resolved_pandoc_compat,
1358            },
1359            built_in_greedy_wrap: style.built_in_greedy_wrap,
1360            exclude: self.exclude,
1361            extend_exclude: self.extend_exclude,
1362            include: self.include,
1363            extend_include: self.extend_include,
1364            flavor_overrides: self.flavor_overrides,
1365        }
1366    }
1367}
1368
1369fn parse_flavor_key(s: &str) -> Option<Flavor> {
1370    match s.replace('_', "-").to_lowercase().as_str() {
1371        "pandoc" => Some(Flavor::Pandoc),
1372        "quarto" => Some(Flavor::Quarto),
1373        "rmarkdown" | "r-markdown" => Some(Flavor::RMarkdown),
1374        "gfm" => Some(Flavor::Gfm),
1375        "common-mark" | "commonmark" => Some(Flavor::CommonMark),
1376        "multimarkdown" | "multi-markdown" => Some(Flavor::MultiMarkdown),
1377        _ => None,
1378    }
1379}
1380
1381fn resolve_extensions_for_flavor(
1382    extensions_value: Option<&toml::Value>,
1383    flavor: Flavor,
1384) -> Extensions {
1385    let Some(value) = extensions_value else {
1386        return Extensions::for_flavor(flavor);
1387    };
1388
1389    let Some(table) = value.as_table() else {
1390        eprintln!("Warning: [extensions] must be a table; using flavor defaults.");
1391        return Extensions::for_flavor(flavor);
1392    };
1393
1394    let mut global_overrides = HashMap::new();
1395    let mut flavor_overrides = HashMap::new();
1396
1397    for (key, val) in table {
1398        if let Some(enabled) = val.as_bool() {
1399            global_overrides.insert(key.clone(), enabled);
1400            continue;
1401        }
1402
1403        let Some(flavor_table) = val.as_table() else {
1404            eprintln!(
1405                "Warning: [extensions] entry '{}' must be a boolean or table; ignoring.",
1406                key
1407            );
1408            continue;
1409        };
1410
1411        let Some(target_flavor) = parse_flavor_key(key) else {
1412            eprintln!(
1413                "Warning: [extensions.{}] is not a known flavor table; ignoring.",
1414                key
1415            );
1416            continue;
1417        };
1418
1419        if target_flavor != flavor {
1420            continue;
1421        }
1422
1423        for (sub_key, sub_val) in flavor_table {
1424            let Some(enabled) = sub_val.as_bool() else {
1425                eprintln!(
1426                    "Warning: [extensions.{}] entry '{}' must be true or false; ignoring.",
1427                    key, sub_key
1428                );
1429                continue;
1430            };
1431            flavor_overrides.insert(sub_key.clone(), enabled);
1432        }
1433    }
1434
1435    let base = Extensions::merge_with_flavor(global_overrides, flavor);
1436    Extensions::merge_overrides(base, flavor_overrides)
1437}
1438
1439/// Resolve formatter configuration from both old and new formats.
1440/// Returns HashMap<String, Vec<FormatterConfig>> for language → formatter(s) mapping.
1441fn resolve_formatters(
1442    raw_formatters: Option<toml::Value>,
1443) -> HashMap<String, Vec<FormatterConfig>> {
1444    let Some(value) = raw_formatters else {
1445        return HashMap::new();
1446    };
1447
1448    // Try to determine which format this is
1449    let toml::Value::Table(table) = value else {
1450        eprintln!("Warning: Invalid formatters configuration - expected table");
1451        return HashMap::new();
1452    };
1453
1454    // Strategy: Detect old format vs new format
1455    // Old format: ALL entries are tables with preset/cmd/args (language-specific configs)
1456    // New format: Mix of strings/arrays (language mappings) and optionally tables (named definitions)
1457
1458    let has_string_or_array = table
1459        .values()
1460        .any(|v| matches!(v, toml::Value::String(_) | toml::Value::Array(_)));
1461
1462    if has_string_or_array {
1463        // New format detected (has language mappings as strings/arrays)
1464        resolve_new_format_formatters(table)
1465    } else {
1466        // Old format (all entries are tables)
1467        resolve_old_format_formatters(table)
1468    }
1469}
1470
1471/// Resolve new format: [formatters] = { r = "air", python = ["isort", "black"] }
1472/// Plus optional [formatters.air] and [formatters.isort] definitions.
1473fn resolve_new_format_formatters(
1474    table: toml::map::Map<String, toml::Value>,
1475) -> HashMap<String, Vec<FormatterConfig>> {
1476    let mut mappings = HashMap::new();
1477    let mut definitions = HashMap::new();
1478
1479    // First pass: separate mappings from definitions
1480    for (key, value) in table {
1481        match &value {
1482            toml::Value::String(_) | toml::Value::Array(_) => {
1483                // This is a language mapping
1484                let formatter_value: Result<FormatterValue, _> = value.try_into();
1485                match formatter_value {
1486                    Ok(fv) => {
1487                        mappings.insert(key, fv);
1488                    }
1489                    Err(e) => {
1490                        eprintln!("Error parsing formatter value for '{}': {}", key, e);
1491                    }
1492                }
1493            }
1494            toml::Value::Table(_) => {
1495                // This is a named formatter definition
1496                let definition: Result<FormatterDefinition, _> = value.try_into();
1497                match definition {
1498                    Ok(def) => {
1499                        definitions.insert(key, def);
1500                    }
1501                    Err(e) => {
1502                        eprintln!("Error parsing formatter definition '{}': {}", key, e);
1503                    }
1504                }
1505            }
1506            _ => {
1507                eprintln!(
1508                    "Warning: Invalid formatter entry '{}' - must be string, array, or table",
1509                    key
1510                );
1511            }
1512        }
1513    }
1514
1515    // Second pass: resolve mappings using definitions
1516    let mut resolved = HashMap::new();
1517    for (lang, value) in mappings {
1518        match resolve_language_formatters(&lang, &value, &definitions) {
1519            Ok(configs) if !configs.is_empty() => {
1520                resolved.insert(lang, configs);
1521            }
1522            Ok(_) => {} // Empty list
1523            Err(e) => {
1524                eprintln!("Error resolving formatters for language '{}': {}", lang, e);
1525                eprintln!("Skipping formatter for '{}'", lang);
1526            }
1527        }
1528    }
1529
1530    resolved
1531}
1532
1533/// Resolve old format: [formatters.r] with preset/cmd fields directly.
1534fn resolve_old_format_formatters(
1535    table: toml::map::Map<String, toml::Value>,
1536) -> HashMap<String, Vec<FormatterConfig>> {
1537    eprintln!(
1538        "Warning: Old formatter configuration format detected. \
1539         Please migrate to the new format with [formatters] section. \
1540         See documentation for the new format."
1541    );
1542
1543    let mut resolved = HashMap::new();
1544    for (lang, value) in table {
1545        let definition: Result<FormatterDefinition, _> = value.try_into();
1546        match definition {
1547            Ok(def) => {
1548                // Skip if disabled (old format only)
1549                // enabled is Option<bool> now, so check for Some(false)
1550                if def.enabled == Some(false) {
1551                    continue;
1552                }
1553
1554                match resolve_old_format_definition(&lang, &def) {
1555                    Ok(config) => {
1556                        resolved.insert(lang, vec![config]);
1557                    }
1558                    Err(e) => {
1559                        eprintln!("Error in old formatter config for '{}': {}", lang, e);
1560                        eprintln!("Skipping formatter for '{}'", lang);
1561                    }
1562                }
1563            }
1564            Err(e) => {
1565                eprintln!("Error parsing old formatter config for '{}': {}", lang, e);
1566            }
1567        }
1568    }
1569
1570    resolved
1571}
1572
1573/// Resolve old-format formatter definition (inline preset/cmd in formatters.LANG).
1574fn resolve_old_format_definition(
1575    _lang: &str,
1576    definition: &FormatterDefinition,
1577) -> Result<FormatterConfig, String> {
1578    // Check for conflicts
1579    if definition.preset.is_some() && definition.cmd.is_some() {
1580        return Err("'preset' and 'cmd' are mutually exclusive".to_string());
1581    }
1582
1583    if let Some(preset_name) = &definition.preset {
1584        // Resolve preset
1585        let preset = get_formatter_preset(preset_name).ok_or_else(|| {
1586            let available = formatter_preset_names().join(", ");
1587            format!(
1588                "Unknown formatter preset '{}'. Available presets: {}",
1589                preset_name, available
1590            )
1591        })?;
1592
1593        let mut args = definition.args.clone().unwrap_or(preset.args);
1594
1595        // Apply prepend/append modifiers
1596        apply_arg_modifiers(&mut args, definition);
1597
1598        Ok(FormatterConfig {
1599            cmd: preset.cmd,
1600            args,
1601            enabled: true, // enabled field checked by caller
1602            stdin: preset.stdin,
1603        })
1604    } else if let Some(cmd) = &definition.cmd {
1605        // Custom command
1606        let mut args = definition.args.clone().unwrap_or_default();
1607
1608        // Apply prepend/append modifiers
1609        apply_arg_modifiers(&mut args, definition);
1610
1611        Ok(FormatterConfig {
1612            cmd: cmd.clone(),
1613            args,
1614            enabled: true,
1615            stdin: definition.stdin.unwrap_or(true),
1616        })
1617    } else {
1618        Err("must specify either 'preset' or 'cmd'".to_string())
1619    }
1620}
1621
1622#[derive(Debug, Clone)]
1623pub struct Config {
1624    pub flavor: Flavor,
1625    pub extensions: Extensions,
1626    pub line_ending: Option<LineEnding>,
1627    pub line_width: usize,
1628    pub math_indent: usize,
1629    pub math_delimiter_style: MathDelimiterStyle,
1630    pub tab_stops: TabStopMode,
1631    pub tab_width: usize,
1632    pub wrap: Option<WrapMode>,
1633    pub blank_lines: BlankLines,
1634    /// Language → Formatter(s) mapping (supports multiple formatters per language)
1635    pub formatters: HashMap<String, Vec<FormatterConfig>>,
1636    pub linters: HashMap<String, String>,
1637    /// Max parallel external tool invocations (formatters/linters) per document.
1638    pub external_max_parallel: usize,
1639    /// Parser configuration (experimental)
1640    pub parser: ParserConfig,
1641    /// Linter rule toggles.
1642    pub lint: LintConfig,
1643    /// Optional CLI cache directory override.
1644    pub cache_dir: Option<String>,
1645    pub built_in_greedy_wrap: bool,
1646    pub exclude: Option<Vec<String>>,
1647    pub extend_exclude: Vec<String>,
1648    pub include: Option<Vec<String>>,
1649    pub extend_include: Vec<String>,
1650    pub flavor_overrides: HashMap<String, Flavor>,
1651}
1652
1653impl<'de> Deserialize<'de> for Config {
1654    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1655    where
1656        D: Deserializer<'de>,
1657    {
1658        RawConfig::deserialize(deserializer).map(|raw| raw.finalize())
1659    }
1660}
1661
1662impl Default for Config {
1663    fn default() -> Self {
1664        let flavor = Flavor::default();
1665        Self {
1666            flavor,
1667            extensions: Extensions::for_flavor(flavor),
1668            line_ending: Some(LineEnding::Auto),
1669            line_width: 80,
1670            math_indent: 0,
1671            math_delimiter_style: MathDelimiterStyle::default(),
1672            tab_stops: TabStopMode::Normalize,
1673            tab_width: 4,
1674            wrap: Some(WrapMode::Reflow),
1675            blank_lines: BlankLines::Collapse,
1676            formatters: HashMap::new(), // Opt-in: empty by default
1677            linters: HashMap::new(),    // Opt-in: empty by default
1678            external_max_parallel: default_external_max_parallel(),
1679            parser: ParserConfig::default(),
1680            lint: LintConfig::default(),
1681            cache_dir: None,
1682            built_in_greedy_wrap: true,
1683            exclude: None,
1684            extend_exclude: Vec::new(),
1685            include: None,
1686            extend_include: Vec::new(),
1687            flavor_overrides: HashMap::new(),
1688        }
1689    }
1690}
1691
1692#[derive(Default, Clone)]
1693pub struct ConfigBuilder {
1694    config: Config,
1695}
1696
1697impl ConfigBuilder {
1698    pub fn math_indent(mut self, indent: usize) -> Self {
1699        self.config.math_indent = indent;
1700        self
1701    }
1702
1703    pub fn tab_stops(mut self, mode: TabStopMode) -> Self {
1704        self.config.tab_stops = mode;
1705        self
1706    }
1707
1708    pub fn tab_width(mut self, width: usize) -> Self {
1709        self.config.tab_width = width;
1710        self
1711    }
1712
1713    pub fn line_width(mut self, width: usize) -> Self {
1714        self.config.line_width = width;
1715        self
1716    }
1717
1718    pub fn line_ending(mut self, ending: LineEnding) -> Self {
1719        self.config.line_ending = Some(ending);
1720        self
1721    }
1722
1723    pub fn blank_lines(mut self, mode: BlankLines) -> Self {
1724        self.config.blank_lines = mode;
1725        self
1726    }
1727
1728    pub fn build(self) -> Config {
1729        self.config
1730    }
1731}
1732
1733#[derive(Debug, Clone, Deserialize, PartialEq)]
1734#[serde(rename_all = "kebab-case")]
1735pub enum WrapMode {
1736    Preserve,
1737    Reflow,
1738    Sentence,
1739}
1740
1741#[derive(Debug, Clone, Deserialize, PartialEq)]
1742#[serde(rename_all = "kebab-case")]
1743pub enum LineEnding {
1744    Auto,
1745    Lf,
1746    Crlf,
1747}
1748
1749#[derive(Debug, Clone, Deserialize, PartialEq)]
1750#[serde(rename_all = "kebab-case")]
1751pub enum BlankLines {
1752    /// Preserve original blank lines (any number)
1753    Preserve,
1754    /// Collapse multiple consecutive blank lines to a single blank line
1755    Collapse,
1756}
1757
1758const CANDIDATE_NAMES: &[&str] = &[".panache.toml", "panache.toml"];
1759
1760/// Check for deprecated snake_case extension names and warn users.
1761fn check_deprecated_extension_names(s: &str, path: &Path) {
1762    // Parse as generic TOML to inspect raw keys
1763    let Ok(toml_value) = toml::from_str::<toml::Value>(s) else {
1764        return; // If TOML is invalid, let the main parser handle the error
1765    };
1766
1767    let Some(extensions_table) = toml_value
1768        .as_table()
1769        .and_then(|t| t.get("extensions"))
1770        .and_then(|v| v.as_table())
1771    else {
1772        return; // No [extensions] section
1773    };
1774
1775    // List of all extension field names that should be kebab-case
1776    let deprecated_names: Vec<&str> = extensions_table
1777        .keys()
1778        .filter(|k| k.contains('_'))
1779        .map(|k| k.as_str())
1780        .collect();
1781
1782    if !deprecated_names.is_empty() {
1783        eprintln!(
1784            "Warning: Deprecated snake_case extension names found in {}:",
1785            path.display()
1786        );
1787        eprintln!("  The following extensions use deprecated snake_case naming:");
1788        for name in &deprecated_names {
1789            let kebab = name.replace('_', "-");
1790            eprintln!("    {} -> {} (use kebab-case)", name, kebab);
1791        }
1792        eprintln!("  Snake_case extension names are deprecated and will be removed in v1.0.0.");
1793        eprintln!(
1794            "  Please update your config to use kebab-case (e.g., quarto-crossrefs instead of quarto_crossrefs)."
1795        );
1796    }
1797}
1798
1799/// Check for deprecated snake_case formatter field names and warn users.
1800fn check_deprecated_formatter_names(s: &str, path: &Path) {
1801    // Parse as generic TOML to inspect raw keys
1802    let Ok(toml_value) = toml::from_str::<toml::Value>(s) else {
1803        return;
1804    };
1805
1806    let Some(formatters_table) = toml_value
1807        .as_table()
1808        .and_then(|t| t.get("formatters"))
1809        .and_then(|v| v.as_table())
1810    else {
1811        return; // No [formatters] section
1812    };
1813
1814    // Check each formatter definition for deprecated field names
1815    let mut found_deprecated = false;
1816    for (formatter_name, formatter_value) in formatters_table {
1817        if let Some(formatter_def) = formatter_value.as_table() {
1818            let deprecated_fields: Vec<&str> = formatter_def
1819                .keys()
1820                .filter(|k| matches!(k.as_str(), "prepend_args" | "append_args"))
1821                .map(|k| k.as_str())
1822                .collect();
1823
1824            if !deprecated_fields.is_empty() {
1825                if !found_deprecated {
1826                    eprintln!(
1827                        "Warning: Deprecated snake_case formatter field names found in {}:",
1828                        path.display()
1829                    );
1830                    found_deprecated = true;
1831                }
1832                eprintln!("  In [formatters.{}]:", formatter_name);
1833                for field in deprecated_fields {
1834                    let kebab = field.replace('_', "-");
1835                    eprintln!("    {} -> {}", field, kebab);
1836                }
1837            }
1838        }
1839    }
1840
1841    if found_deprecated {
1842        eprintln!(
1843            "  Snake_case formatter field names are deprecated and will be removed in v1.0.0."
1844        );
1845        eprintln!(
1846            "  Please update your config to use kebab-case (e.g., prepend-args instead of prepend_args)."
1847        );
1848    }
1849}
1850
1851/// Check for deprecated code-block style configuration and warn users.
1852fn check_deprecated_code_block_style_options(s: &str, path: &Path) {
1853    let Ok(toml_value) = toml::from_str::<toml::Value>(s) else {
1854        return;
1855    };
1856    let Some(root) = toml_value.as_table() else {
1857        return;
1858    };
1859
1860    let top_level = root.contains_key("code-blocks");
1861    let format_nested = root
1862        .get("format")
1863        .and_then(|v| v.as_table())
1864        .is_some_and(|format| format.contains_key("code-blocks"));
1865    let style_nested = root
1866        .get("style")
1867        .and_then(|v| v.as_table())
1868        .is_some_and(|style| style.contains_key("code-blocks"));
1869
1870    if top_level || format_nested || style_nested {
1871        eprintln!(
1872            "Warning: Deprecated code block style options found in {}:",
1873            path.display()
1874        );
1875        if format_nested {
1876            eprintln!("  - [format.code-blocks]");
1877        }
1878        if top_level {
1879            eprintln!("  - [code-blocks]");
1880        }
1881        if style_nested {
1882            eprintln!("  - [style.code-blocks]");
1883        }
1884        eprintln!("  These options are now no-ops and will be removed in a future release.");
1885    }
1886}
1887
1888fn parse_config_str(s: &str, path: &Path) -> io::Result<Config> {
1889    // Check for deprecated names before parsing
1890    check_deprecated_extension_names(s, path);
1891    check_deprecated_formatter_names(s, path);
1892    check_deprecated_code_block_style_options(s, path);
1893
1894    let config: Config = toml::from_str(s).map_err(|e| {
1895        io::Error::new(
1896            io::ErrorKind::InvalidData,
1897            format!("invalid config {}: {e}", path.display()),
1898        )
1899    })?;
1900
1901    Ok(config)
1902}
1903
1904fn read_config(path: &Path) -> io::Result<Config> {
1905    log::debug!("Reading config from: {}", path.display());
1906    let s = fs::read_to_string(path)?;
1907    let config = parse_config_str(&s, path)?;
1908    log::debug!("Loaded config from: {}", path.display());
1909    Ok(config)
1910}
1911
1912fn find_in_tree(start_dir: &Path) -> Option<PathBuf> {
1913    for dir in start_dir.ancestors() {
1914        for name in CANDIDATE_NAMES {
1915            let p = dir.join(name);
1916            if p.is_file() {
1917                return Some(p);
1918            }
1919        }
1920    }
1921    None
1922}
1923
1924fn xdg_config_path() -> Option<PathBuf> {
1925    if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
1926        let p = Path::new(&xdg).join("panache").join("config.toml");
1927        if p.is_file() {
1928            return Some(p);
1929        }
1930    }
1931    if let Ok(home) = env::var("HOME") {
1932        let p = Path::new(&home)
1933            .join(".config")
1934            .join("panache")
1935            .join("config.toml");
1936        if p.is_file() {
1937            return Some(p);
1938        }
1939    }
1940    None
1941}
1942
1943/// Load configuration with precedence:
1944/// 1) explicit path (error if unreadable/invalid)
1945/// 2) walk up from start_dir: .panache.toml, panache.toml
1946/// 3) XDG: $XDG_CONFIG_HOME/panache/config.toml or ~/.config/panache/config.toml
1947/// 4) default config
1948///
1949/// Flavor detection logic (when input_file is provided):
1950/// - .qmd files: Always use Quarto flavor
1951/// - .Rmd files: Always use RMarkdown flavor
1952/// - Markdown-family files (.md/.markdown/.mdown/.mkd): Use most-specific
1953///   `flavor-overrides` match when provided, else use `flavor` from config
1954/// - Other extensions: Use `flavor` from config
1955///
1956/// The `flavor` config field determines the default flavor for stdin and files
1957/// without a matching extension override.
1958pub fn load(
1959    explicit: Option<&Path>,
1960    start_dir: &Path,
1961    input_file: Option<&Path>,
1962) -> io::Result<(Config, Option<PathBuf>)> {
1963    let (mut cfg, cfg_path) = if let Some(path) = explicit {
1964        let cfg = read_config(path)?;
1965        (cfg, Some(path.to_path_buf()))
1966    } else if let Some(p) = find_in_tree(start_dir)
1967        && let Ok(cfg) = read_config(&p)
1968    {
1969        (cfg, Some(p))
1970    } else if let Some(p) = xdg_config_path()
1971        && let Ok(cfg) = read_config(&p)
1972    {
1973        (cfg, Some(p))
1974    } else {
1975        log::debug!("No config file found, using defaults");
1976        (Config::default(), None)
1977    };
1978
1979    if let Some(flavor) = detect_flavor(input_file, cfg_path.as_deref(), &cfg) {
1980        cfg.flavor = flavor;
1981        cfg.extensions = if let Some(path) = cfg_path.as_deref() {
1982            fs::read_to_string(path)
1983                .ok()
1984                .and_then(|s| toml::from_str::<toml::Value>(&s).ok())
1985                .map(|root| resolve_extensions_for_flavor(root.get("extensions"), flavor))
1986                .unwrap_or_else(|| Extensions::for_flavor(flavor))
1987        } else {
1988            Extensions::for_flavor(flavor)
1989        };
1990    }
1991
1992    Ok((cfg, cfg_path))
1993}
1994
1995fn detect_flavor(
1996    input_file: Option<&Path>,
1997    cfg_path: Option<&Path>,
1998    cfg: &Config,
1999) -> Option<Flavor> {
2000    let input_path = input_file?;
2001    let ext = input_path.extension().and_then(|e| e.to_str())?;
2002    let ext_lower = ext.to_lowercase();
2003
2004    match ext_lower.as_str() {
2005        "qmd" => {
2006            log::debug!("Using Quarto flavor for .qmd file");
2007            Some(Flavor::Quarto)
2008        }
2009        "rmd" => {
2010            log::debug!("Using RMarkdown flavor for .Rmd file");
2011            Some(Flavor::RMarkdown)
2012        }
2013        _ if MARKDOWN_FAMILY_EXTENSIONS.contains(&ext_lower.as_str()) => {
2014            let base_dir = cfg_path.and_then(Path::parent);
2015            let override_flavor =
2016                detect_flavor_override(input_path, base_dir, &cfg.flavor_overrides);
2017            let final_flavor = override_flavor.unwrap_or(cfg.flavor);
2018            if let Some(flavor) = override_flavor {
2019                log::debug!(
2020                    "Using {:?} flavor for {} (matched flavor-overrides)",
2021                    flavor,
2022                    input_path.display()
2023                );
2024            } else {
2025                log::debug!(
2026                    "Using {:?} flavor for {} (from config)",
2027                    final_flavor,
2028                    input_path.display()
2029                );
2030            }
2031            Some(final_flavor)
2032        }
2033        _ => None,
2034    }
2035}
2036
2037fn detect_flavor_override(
2038    input_path: &Path,
2039    base_dir: Option<&Path>,
2040    overrides: &HashMap<String, Flavor>,
2041) -> Option<Flavor> {
2042    if overrides.is_empty() {
2043        return None;
2044    }
2045
2046    let full_path = normalize_path_for_matching(input_path);
2047    let rel_path = base_dir
2048        .and_then(|base| input_path.strip_prefix(base).ok())
2049        .map(normalize_path_for_matching);
2050    let file_name = input_path
2051        .file_name()
2052        .and_then(|name| name.to_str())
2053        .map(|name| name.to_string());
2054
2055    let mut best: Option<((usize, usize, usize), Flavor)> = None;
2056    for (pattern, flavor) in overrides {
2057        let matched = glob_matches_path(pattern, &full_path)
2058            || rel_path
2059                .as_deref()
2060                .is_some_and(|relative| glob_matches_path(pattern, relative))
2061            || file_name
2062                .as_deref()
2063                .is_some_and(|name| glob_matches_path(pattern, name));
2064        if !matched {
2065            continue;
2066        }
2067
2068        let score = pattern_specificity(pattern);
2069        if best.is_none_or(|(best_score, _)| score > best_score) {
2070            best = Some((score, *flavor));
2071        }
2072    }
2073
2074    best.map(|(_, flavor)| flavor)
2075}
2076
2077fn normalize_path_for_matching(path: &Path) -> String {
2078    path.to_string_lossy().replace('\\', "/")
2079}
2080
2081fn pattern_specificity(pattern: &str) -> (usize, usize, usize) {
2082    let literal_chars = pattern.chars().filter(|c| *c != '*' && *c != '?').count();
2083    let segment_count = pattern
2084        .split('/')
2085        .filter(|segment| !segment.is_empty())
2086        .count();
2087    let wildcard_count = pattern.chars().filter(|c| *c == '*' || *c == '?').count();
2088    (literal_chars, segment_count, usize::MAX - wildcard_count)
2089}
2090
2091fn glob_matches_path(pattern: &str, path: &str) -> bool {
2092    let normalized_pattern = pattern.replace('\\', "/");
2093    GlobBuilder::new(&normalized_pattern)
2094        .literal_separator(true)
2095        .build()
2096        .map(|glob| glob.compile_matcher().is_match(path))
2097        .unwrap_or(false)
2098}
2099
2100#[cfg(test)]
2101mod tests {
2102    use super::*;
2103
2104    #[test]
2105    fn missing_fields_uses_defaults() {
2106        let toml_str = r#"
2107            wrap = "reflow"
2108        "#;
2109        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2110        assert_eq!(cfg.line_width, 80);
2111        // Formatters are opt-in, so empty by default
2112        assert!(cfg.formatters.is_empty());
2113        assert!(cfg.cache_dir.is_none());
2114    }
2115
2116    #[test]
2117    fn formatter_config_basic() {
2118        let toml_str = r#"
2119            [formatters.python]
2120            cmd = "black"
2121            args = ["-"]
2122        "#;
2123        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2124
2125        let python_fmt = &cfg.formatters.get("python").unwrap()[0];
2126        assert_eq!(python_fmt.cmd, "black");
2127        assert_eq!(python_fmt.args, vec!["-"]);
2128        assert!(python_fmt.enabled);
2129    }
2130
2131    #[test]
2132    fn formatter_config_multiple_languages() {
2133        let toml_str = r#"
2134            [formatters.r]
2135            cmd = "air"
2136            args = ["--preset=tidyverse"]
2137            
2138            [formatters.python]
2139            cmd = "black"
2140            args = ["-", "--line-length=88"]
2141            
2142            [formatters.rust]
2143            cmd = "rustfmt"
2144            enabled = false
2145        "#;
2146        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2147
2148        // Old format detected - should have 2 formatters (rust disabled)
2149        assert_eq!(cfg.formatters.len(), 2);
2150
2151        let r_fmt = &cfg.formatters.get("r").unwrap()[0];
2152        assert_eq!(r_fmt.cmd, "air");
2153        assert_eq!(r_fmt.args, vec!["--preset=tidyverse"]);
2154        assert!(r_fmt.enabled);
2155
2156        let py_fmt = &cfg.formatters.get("python").unwrap()[0];
2157        assert_eq!(py_fmt.cmd, "black");
2158        assert_eq!(py_fmt.args.len(), 2);
2159
2160        // rust is disabled in old format, so it shouldn't be in the map
2161        assert!(!cfg.formatters.contains_key("rust"));
2162    }
2163
2164    #[test]
2165    fn cache_dir_parsing() {
2166        let toml_str = r#"
2167            cache-dir = ".panache/local-cache"
2168        "#;
2169        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2170        assert_eq!(cfg.cache_dir.as_deref(), Some(".panache/local-cache"));
2171    }
2172
2173    #[test]
2174    fn formatter_config_no_args() {
2175        let toml_str = r#"
2176            [formatters.rustfmt]
2177            cmd = "rustfmt"
2178        "#;
2179        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2180
2181        let fmt = &cfg.formatters.get("rustfmt").unwrap()[0];
2182        assert_eq!(fmt.cmd, "rustfmt");
2183        assert!(fmt.args.is_empty());
2184        assert!(fmt.enabled);
2185    }
2186
2187    #[test]
2188    fn formatter_empty_cmd_is_valid() {
2189        // Empty cmd is technically valid in deserialization
2190        // Validation happens at runtime
2191        let toml_str = r#"
2192            [formatters.test]
2193            cmd = ""
2194        "#;
2195        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2196        let fmt = &cfg.formatters.get("test").unwrap()[0];
2197        assert_eq!(fmt.cmd, "");
2198    }
2199
2200    #[test]
2201    fn preset_resolution_air() {
2202        let toml_str = r#"
2203            [formatters.r]
2204            preset = "air"
2205        "#;
2206        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2207        let r_fmt = &cfg.formatters.get("r").unwrap()[0];
2208        assert_eq!(r_fmt.cmd, "air");
2209        assert_eq!(r_fmt.args, vec!["format", "{}"]);
2210        assert!(!r_fmt.stdin);
2211        assert!(r_fmt.enabled);
2212    }
2213
2214    #[test]
2215    fn preset_resolution_ruff() {
2216        let toml_str = r#"
2217            [formatters.python]
2218            preset = "ruff"
2219        "#;
2220        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2221        let py_fmt = &cfg.formatters.get("python").unwrap()[0];
2222        assert_eq!(py_fmt.cmd, "ruff");
2223        assert_eq!(
2224            py_fmt.args,
2225            vec!["format", "--stdin-filename", "stdin.py", "-"]
2226        );
2227        assert!(py_fmt.stdin);
2228        assert!(py_fmt.enabled);
2229    }
2230
2231    #[test]
2232    fn preset_resolution_black() {
2233        let toml_str = r#"
2234            [formatters.python]
2235            preset = "black"
2236        "#;
2237        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2238        let py_fmt = &cfg.formatters.get("python").unwrap()[0];
2239        assert_eq!(py_fmt.cmd, "black");
2240        assert_eq!(py_fmt.args, vec!["-"]);
2241        assert!(py_fmt.stdin);
2242    }
2243
2244    #[test]
2245    fn preset_resolution_sqlfmt() {
2246        let toml_str = r#"
2247            [formatters.sql]
2248            preset = "sqlfmt"
2249        "#;
2250        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2251        let fmt = &cfg.formatters.get("sql").unwrap()[0];
2252        assert_eq!(fmt.cmd, "sqlfmt");
2253        assert_eq!(fmt.args, vec!["-"]);
2254        assert!(fmt.stdin);
2255    }
2256
2257    #[test]
2258    fn preset_resolution_alejandra() {
2259        let toml_str = r#"
2260            [formatters.nix]
2261            preset = "alejandra"
2262        "#;
2263        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2264        let fmt = &cfg.formatters.get("nix").unwrap()[0];
2265        assert_eq!(fmt.cmd, "alejandra");
2266        assert!(fmt.args.is_empty());
2267        assert!(fmt.stdin);
2268    }
2269
2270    #[test]
2271    fn preset_resolution_terraform_fmt() {
2272        let toml_str = r#"
2273            [formatters.hcl]
2274            preset = "terraform-fmt"
2275        "#;
2276        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2277        let fmt = &cfg.formatters.get("hcl").unwrap()[0];
2278        assert_eq!(fmt.cmd, "terraform");
2279        assert_eq!(fmt.args, vec!["fmt", "-no-color", "-"]);
2280        assert!(fmt.stdin);
2281    }
2282
2283    #[test]
2284    fn preset_resolution_yamlfix() {
2285        let toml_str = r#"
2286            [formatters.yaml]
2287            preset = "yamlfix"
2288        "#;
2289        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2290        let fmt = &cfg.formatters.get("yaml").unwrap()[0];
2291        assert_eq!(fmt.cmd, "yamlfix");
2292        assert_eq!(fmt.args, vec!["-"]);
2293        assert!(fmt.stdin);
2294    }
2295
2296    #[test]
2297    fn preset_resolution_gofmt() {
2298        let toml_str = r#"
2299            [formatters.go]
2300            preset = "gofmt"
2301        "#;
2302        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2303        let fmt = &cfg.formatters.get("go").unwrap()[0];
2304        assert_eq!(fmt.cmd, "gofmt");
2305        assert!(fmt.args.is_empty());
2306        assert!(fmt.stdin);
2307    }
2308
2309    #[test]
2310    fn preset_resolution_gofumpt() {
2311        let toml_str = r#"
2312            [formatters.go]
2313            preset = "gofumpt"
2314        "#;
2315        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2316        let fmt = &cfg.formatters.get("go").unwrap()[0];
2317        assert_eq!(fmt.cmd, "gofumpt");
2318        assert!(fmt.args.is_empty());
2319        assert!(fmt.stdin);
2320    }
2321
2322    #[test]
2323    fn preset_resolution_nixfmt() {
2324        let toml_str = r#"
2325            [formatters.nix]
2326            preset = "nixfmt"
2327        "#;
2328        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2329        let fmt = &cfg.formatters.get("nix").unwrap()[0];
2330        assert_eq!(fmt.cmd, "nixfmt");
2331        assert!(fmt.args.is_empty());
2332        assert!(fmt.stdin);
2333    }
2334
2335    #[test]
2336    fn preset_resolution_gleam() {
2337        let toml_str = r#"
2338            [formatters.gleam]
2339            preset = "gleam"
2340        "#;
2341        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2342        let fmt = &cfg.formatters.get("gleam").unwrap()[0];
2343        assert_eq!(fmt.cmd, "gleam");
2344        assert_eq!(fmt.args, vec!["format", "--stdin"]);
2345        assert!(fmt.stdin);
2346    }
2347
2348    #[test]
2349    fn preset_resolution_yq() {
2350        let toml_str = r#"
2351            [formatters.yaml]
2352            preset = "yq"
2353        "#;
2354        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2355        let fmt = &cfg.formatters.get("yaml").unwrap()[0];
2356        assert_eq!(fmt.cmd, "yq");
2357        assert_eq!(fmt.args, vec!["-P", "-"]);
2358        assert!(fmt.stdin);
2359    }
2360
2361    #[test]
2362    fn preset_resolution_asmfmt() {
2363        let toml_str = r#"
2364            [formatters.asm]
2365            preset = "asmfmt"
2366        "#;
2367        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2368        let fmt = &cfg.formatters.get("asm").unwrap()[0];
2369        assert_eq!(fmt.cmd, "asmfmt");
2370        assert!(fmt.args.is_empty());
2371        assert!(fmt.stdin);
2372    }
2373
2374    #[test]
2375    fn preset_resolution_astyle() {
2376        let toml_str = r#"
2377            [formatters.cpp]
2378            preset = "astyle"
2379        "#;
2380        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2381        let fmt = &cfg.formatters.get("cpp").unwrap()[0];
2382        assert_eq!(fmt.cmd, "astyle");
2383        assert_eq!(fmt.args, vec!["--quiet"]);
2384        assert!(fmt.stdin);
2385    }
2386
2387    #[test]
2388    fn preset_resolution_autocorrect() {
2389        let toml_str = r#"
2390            [formatters.text]
2391            preset = "autocorrect"
2392        "#;
2393        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2394        let fmt = &cfg.formatters.get("text").unwrap()[0];
2395        assert_eq!(fmt.cmd, "autocorrect");
2396        assert_eq!(fmt.args, vec!["--stdin"]);
2397        assert!(fmt.stdin);
2398    }
2399
2400    #[test]
2401    fn preset_resolution_cmake_format() {
2402        let toml_str = r#"
2403            [formatters.cmake]
2404            preset = "cmake-format"
2405        "#;
2406        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2407        let fmt = &cfg.formatters.get("cmake").unwrap()[0];
2408        assert_eq!(fmt.cmd, "cmake-format");
2409        assert_eq!(fmt.args, vec!["-"]);
2410        assert!(fmt.stdin);
2411    }
2412
2413    #[test]
2414    fn preset_resolution_cue_fmt() {
2415        let toml_str = r#"
2416            [formatters.cue]
2417            preset = "cue-fmt"
2418        "#;
2419        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2420        let fmt = &cfg.formatters.get("cue").unwrap()[0];
2421        assert_eq!(fmt.cmd, "cue");
2422        assert_eq!(fmt.args, vec!["fmt", "-"]);
2423        assert!(fmt.stdin);
2424    }
2425
2426    #[test]
2427    fn preset_resolution_jsonnetfmt() {
2428        let toml_str = r#"
2429            [formatters.jsonnet]
2430            preset = "jsonnetfmt"
2431        "#;
2432        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2433        let fmt = &cfg.formatters.get("jsonnet").unwrap()[0];
2434        assert_eq!(fmt.cmd, "jsonnetfmt");
2435        assert_eq!(fmt.args, vec!["-"]);
2436        assert!(fmt.stdin);
2437    }
2438
2439    #[test]
2440    fn preset_resolution_dfmt() {
2441        let toml_str = r#"
2442            [formatters.d]
2443            preset = "dfmt"
2444        "#;
2445        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2446        let fmt = &cfg.formatters.get("d").unwrap()[0];
2447        assert_eq!(fmt.cmd, "dfmt");
2448        assert!(fmt.args.is_empty());
2449        assert!(fmt.stdin);
2450    }
2451
2452    #[test]
2453    fn preset_resolution_efmt() {
2454        let toml_str = r#"
2455            [formatters.erl]
2456            preset = "efmt"
2457        "#;
2458        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2459        let fmt = &cfg.formatters.get("erl").unwrap()[0];
2460        assert_eq!(fmt.cmd, "efmt");
2461        assert_eq!(fmt.args, vec!["-"]);
2462        assert!(fmt.stdin);
2463    }
2464
2465    #[test]
2466    fn preset_resolution_nginxfmt() {
2467        let toml_str = r#"
2468            [formatters.nginx]
2469            preset = "nginxfmt"
2470        "#;
2471        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2472        let fmt = &cfg.formatters.get("nginx").unwrap()[0];
2473        assert_eq!(fmt.cmd, "nginxfmt");
2474        assert_eq!(fmt.args, vec!["-"]);
2475        assert!(fmt.stdin);
2476    }
2477
2478    #[test]
2479    fn preset_resolution_tclfmt() {
2480        let toml_str = r#"
2481            [formatters.tcl]
2482            preset = "tclfmt"
2483        "#;
2484        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2485        let fmt = &cfg.formatters.get("tcl").unwrap()[0];
2486        assert_eq!(fmt.cmd, "tclfmt");
2487        assert_eq!(fmt.args, vec!["-"]);
2488        assert!(fmt.stdin);
2489    }
2490
2491    #[test]
2492    fn preset_resolution_tex_fmt() {
2493        let toml_str = r#"
2494            [formatters.tex]
2495            preset = "tex-fmt"
2496        "#;
2497        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2498        let fmt = &cfg.formatters.get("tex").unwrap()[0];
2499        assert_eq!(fmt.cmd, "tex-fmt");
2500        assert_eq!(fmt.args, vec!["-s"]);
2501        assert!(fmt.stdin);
2502    }
2503
2504    #[test]
2505    fn preset_resolution_typstyle() {
2506        let toml_str = r#"
2507            [formatters.typst]
2508            preset = "typstyle"
2509        "#;
2510        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2511        let fmt = &cfg.formatters.get("typst").unwrap()[0];
2512        assert_eq!(fmt.cmd, "typstyle");
2513        assert!(fmt.args.is_empty());
2514        assert!(fmt.stdin);
2515    }
2516
2517    #[test]
2518    fn preset_resolution_gdformat() {
2519        let toml_str = r#"
2520            [formatters.gdscript]
2521            preset = "gdformat"
2522        "#;
2523        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2524        let fmt = &cfg.formatters.get("gdscript").unwrap()[0];
2525        assert_eq!(fmt.cmd, "gdformat");
2526        assert_eq!(fmt.args, vec!["-"]);
2527        assert!(fmt.stdin);
2528    }
2529
2530    #[test]
2531    fn preset_resolution_hurlfmt() {
2532        let toml_str = r#"
2533            [formatters.hurl]
2534            preset = "hurlfmt"
2535        "#;
2536        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2537        let fmt = &cfg.formatters.get("hurl").unwrap()[0];
2538        assert_eq!(fmt.cmd, "hurlfmt");
2539        assert!(fmt.args.is_empty());
2540        assert!(fmt.stdin);
2541    }
2542
2543    #[test]
2544    fn preset_resolution_ktfmt() {
2545        let toml_str = r#"
2546            [formatters.kotlin]
2547            preset = "ktfmt"
2548        "#;
2549        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2550        let fmt = &cfg.formatters.get("kotlin").unwrap()[0];
2551        assert_eq!(fmt.cmd, "ktfmt");
2552        assert_eq!(fmt.args, vec!["-"]);
2553        assert!(fmt.stdin);
2554    }
2555
2556    #[test]
2557    fn preset_resolution_leptosfmt() {
2558        let toml_str = r#"
2559            [formatters.rust]
2560            preset = "leptosfmt"
2561        "#;
2562        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2563        let fmt = &cfg.formatters.get("rust").unwrap()[0];
2564        assert_eq!(fmt.cmd, "leptosfmt");
2565        assert_eq!(fmt.args, vec!["--stdin"]);
2566        assert!(fmt.stdin);
2567    }
2568
2569    #[test]
2570    fn preset_resolution_pycln() {
2571        let toml_str = r#"
2572            [formatters.python]
2573            preset = "pycln"
2574        "#;
2575        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2576        let fmt = &cfg.formatters.get("python").unwrap()[0];
2577        assert_eq!(fmt.cmd, "pycln");
2578        assert_eq!(fmt.args, vec!["--silence", "-"]);
2579        assert!(fmt.stdin);
2580    }
2581
2582    #[test]
2583    fn preset_resolution_pyproject_fmt() {
2584        let toml_str = r#"
2585            [formatters.toml]
2586            preset = "pyproject-fmt"
2587        "#;
2588        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2589        let fmt = &cfg.formatters.get("toml").unwrap()[0];
2590        assert_eq!(fmt.cmd, "pyproject-fmt");
2591        assert_eq!(fmt.args, vec!["-"]);
2592        assert!(fmt.stdin);
2593    }
2594
2595    #[test]
2596    fn preset_resolution_google_java_format() {
2597        let toml_str = r#"
2598            [formatters.java]
2599            preset = "google-java-format"
2600        "#;
2601        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2602        let fmt = &cfg.formatters.get("java").unwrap()[0];
2603        assert_eq!(fmt.cmd, "google-java-format");
2604        assert_eq!(fmt.args, vec!["-"]);
2605        assert!(fmt.stdin);
2606    }
2607
2608    #[test]
2609    fn preset_resolution_racketfmt() {
2610        let toml_str = r#"
2611            [formatters.racket]
2612            preset = "racketfmt"
2613        "#;
2614        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2615        let fmt = &cfg.formatters.get("racket").unwrap()[0];
2616        assert_eq!(fmt.cmd, "raco");
2617        assert_eq!(fmt.args, vec!["fmt"]);
2618        assert!(fmt.stdin);
2619    }
2620
2621    #[test]
2622    fn preset_resolution_rubyfmt() {
2623        let toml_str = r#"
2624            [formatters.ruby]
2625            preset = "rubyfmt"
2626        "#;
2627        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2628        let fmt = &cfg.formatters.get("ruby").unwrap()[0];
2629        assert_eq!(fmt.cmd, "rubyfmt");
2630        assert!(fmt.args.is_empty());
2631        assert!(fmt.stdin);
2632    }
2633
2634    #[test]
2635    fn preset_resolution_rufo() {
2636        let toml_str = r#"
2637            [formatters.ruby]
2638            preset = "rufo"
2639        "#;
2640        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2641        let fmt = &cfg.formatters.get("ruby").unwrap()[0];
2642        assert_eq!(fmt.cmd, "rufo");
2643        assert!(fmt.args.is_empty());
2644        assert!(fmt.stdin);
2645    }
2646
2647    #[test]
2648    fn preset_resolution_bean_format() {
2649        let toml_str = r#"
2650            [formatters.beancount]
2651            preset = "bean-format"
2652        "#;
2653        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2654        let fmt = &cfg.formatters.get("beancount").unwrap()[0];
2655        assert_eq!(fmt.cmd, "bean-format");
2656        assert_eq!(fmt.args, vec!["-"]);
2657        assert!(fmt.stdin);
2658    }
2659
2660    #[test]
2661    fn preset_resolution_beautysh() {
2662        let toml_str = r#"
2663            [formatters.bash]
2664            preset = "beautysh"
2665        "#;
2666        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2667        let fmt = &cfg.formatters.get("bash").unwrap()[0];
2668        assert_eq!(fmt.cmd, "beautysh");
2669        assert_eq!(fmt.args, vec!["-"]);
2670        assert!(fmt.stdin);
2671    }
2672
2673    #[test]
2674    fn preset_resolution_cljfmt() {
2675        let toml_str = r#"
2676            [formatters.clojure]
2677            preset = "cljfmt"
2678        "#;
2679        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2680        let fmt = &cfg.formatters.get("clojure").unwrap()[0];
2681        assert_eq!(fmt.cmd, "cljfmt");
2682        assert_eq!(fmt.args, vec!["fix", "-"]);
2683        assert!(fmt.stdin);
2684    }
2685
2686    #[test]
2687    fn preset_resolution_fish_indent() {
2688        let toml_str = r#"
2689            [formatters.fish]
2690            preset = "fish_indent"
2691        "#;
2692        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2693        let fmt = &cfg.formatters.get("fish").unwrap()[0];
2694        assert_eq!(fmt.cmd, "fish_indent");
2695        assert!(fmt.args.is_empty());
2696        assert!(fmt.stdin);
2697    }
2698
2699    #[test]
2700    fn preset_resolution_fixjson() {
2701        let toml_str = r#"
2702            [formatters.json]
2703            preset = "fixjson"
2704        "#;
2705        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2706        let fmt = &cfg.formatters.get("json").unwrap()[0];
2707        assert_eq!(fmt.cmd, "fixjson");
2708        assert!(fmt.args.is_empty());
2709        assert!(fmt.stdin);
2710    }
2711
2712    #[test]
2713    fn preset_resolution_bibtex_tidy() {
2714        let toml_str = r#"
2715            [formatters.bibtex]
2716            preset = "bibtex-tidy"
2717        "#;
2718        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2719        let fmt = &cfg.formatters.get("bibtex").unwrap()[0];
2720        assert_eq!(fmt.cmd, "bibtex-tidy");
2721        assert_eq!(fmt.args, vec!["--quiet"]);
2722        assert!(fmt.stdin);
2723    }
2724
2725    #[test]
2726    fn preset_resolution_bpfmt() {
2727        let toml_str = r#"
2728            [formatters.bp]
2729            preset = "bpfmt"
2730        "#;
2731        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2732        let fmt = &cfg.formatters.get("bp").unwrap()[0];
2733        assert_eq!(fmt.cmd, "bpfmt");
2734        assert_eq!(fmt.args, vec!["-w", "{}"]);
2735        assert!(!fmt.stdin);
2736    }
2737
2738    #[test]
2739    fn preset_resolution_bsfmt() {
2740        let toml_str = r#"
2741            [formatters.brs]
2742            preset = "bsfmt"
2743        "#;
2744        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2745        let fmt = &cfg.formatters.get("brs").unwrap()[0];
2746        assert_eq!(fmt.cmd, "bsfmt");
2747        assert_eq!(fmt.args, vec!["{}", "--write"]);
2748        assert!(!fmt.stdin);
2749    }
2750
2751    #[test]
2752    fn preset_resolution_buf() {
2753        let toml_str = r#"
2754            [formatters.proto]
2755            preset = "buf"
2756        "#;
2757        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2758        let fmt = &cfg.formatters.get("proto").unwrap()[0];
2759        assert_eq!(fmt.cmd, "buf");
2760        assert_eq!(fmt.args, vec!["format", "-w", "{}"]);
2761        assert!(!fmt.stdin);
2762    }
2763
2764    #[test]
2765    fn preset_resolution_buildifier() {
2766        let toml_str = r#"
2767            [formatters.bazel]
2768            preset = "buildifier"
2769        "#;
2770        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2771        let fmt = &cfg.formatters.get("bazel").unwrap()[0];
2772        assert_eq!(fmt.cmd, "buildifier");
2773        assert_eq!(fmt.args, vec!["-path", "{}", "-"]);
2774        assert!(fmt.stdin);
2775    }
2776
2777    #[test]
2778    fn preset_resolution_cabal_fmt() {
2779        let toml_str = r#"
2780            [formatters.cabal]
2781            preset = "cabal-fmt"
2782        "#;
2783        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2784        let fmt = &cfg.formatters.get("cabal").unwrap()[0];
2785        assert_eq!(fmt.cmd, "cabal-fmt");
2786        assert_eq!(fmt.args, vec!["--inplace", "{}"]);
2787        assert!(!fmt.stdin);
2788    }
2789
2790    #[test]
2791    fn preset_resolution_prettier_typescript() {
2792        let toml_str = r#"
2793            [formatters.typescript]
2794            preset = "prettier"
2795        "#;
2796        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2797        let fmt = &cfg.formatters.get("typescript").unwrap()[0];
2798        assert_eq!(fmt.cmd, "prettier");
2799        assert_eq!(fmt.args, vec!["--stdin-filepath", "{}"]);
2800        assert!(fmt.stdin);
2801    }
2802
2803    #[test]
2804    fn preset_and_cmd_mutually_exclusive() {
2805        let toml_str = r#"
2806            [formatters.r]
2807            preset = "air"
2808            cmd = "styler"
2809        "#;
2810        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2811        // The formatter should be skipped (error logged), so r shouldn't be in the map
2812        assert!(!cfg.formatters.contains_key("r"));
2813    }
2814
2815    #[test]
2816    fn unknown_preset_fails() {
2817        let toml_str = r#"
2818            [formatters.r]
2819            preset = "nonexistent"
2820        "#;
2821        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2822        // The formatter should be skipped (error logged), so r shouldn't be in the map
2823        assert!(!cfg.formatters.contains_key("r"));
2824    }
2825
2826    #[test]
2827    fn preset_language_mismatch_is_rejected() {
2828        let toml_str = r#"
2829            [formatters]
2830            python = "gofmt"
2831        "#;
2832        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2833        assert!(!cfg.formatters.contains_key("python"));
2834    }
2835
2836    #[test]
2837    fn preset_language_alias_is_accepted() {
2838        let toml_str = r#"
2839            [formatters]
2840            yml = "yamlfmt"
2841        "#;
2842        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2843        let fmts = cfg.formatters.get("yml").unwrap();
2844        assert_eq!(fmts.len(), 1);
2845        assert_eq!(fmts[0].cmd, "yamlfmt");
2846    }
2847
2848    #[test]
2849    fn named_definition_skips_builtin_language_guard() {
2850        let toml_str = r#"
2851            [formatters]
2852            javascript = "prettier"
2853
2854            [formatters.prettier]
2855            cmd = "prettier"
2856            args = ["--print-width=100"]
2857        "#;
2858        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2859        let fmts = cfg.formatters.get("javascript").unwrap();
2860        assert_eq!(fmts.len(), 1);
2861        assert_eq!(fmts[0].cmd, "prettier");
2862        assert_eq!(fmts[0].args, vec!["--print-width=100"]);
2863    }
2864
2865    #[test]
2866    fn preset_metadata_lookup_contains_url() {
2867        let meta = formatter_preset_metadata("gofmt").unwrap();
2868        assert_eq!(meta.name, "gofmt");
2869        assert_eq!(meta.cmd, "gofmt");
2870        assert!(meta.url.contains("pkg.go.dev"));
2871    }
2872
2873    #[test]
2874    fn preset_metadata_language_lookup_works() {
2875        let names: Vec<&str> = formatter_presets_for_language("yaml")
2876            .iter()
2877            .map(|meta| meta.name)
2878            .collect();
2879        assert!(names.contains(&"yamlfmt"));
2880        assert!(names.contains(&"yamlfix"));
2881        assert!(names.contains(&"yq"));
2882    }
2883
2884    #[test]
2885    fn preset_metadata_language_lookup_includes_prettier_for_typescript() {
2886        let names: Vec<&str> = formatter_presets_for_language("typescript")
2887            .iter()
2888            .map(|meta| meta.name)
2889            .collect();
2890        assert!(names.contains(&"prettier"));
2891    }
2892
2893    #[test]
2894    fn builtin_defaults_when_no_config() {
2895        let cfg = Config::default();
2896        // Formatters are opt-in, so empty by default
2897        assert!(cfg.formatters.is_empty());
2898        assert!(cfg.lint.is_rule_enabled("heading-hierarchy"));
2899        assert!(cfg.lint.is_rule_enabled("undefined-references"));
2900    }
2901
2902    #[test]
2903    fn lint_config_allows_rule_toggles() {
2904        let toml_str = r#"
2905            [lint.rules]
2906            heading-hierarchy = false
2907            undefined-references = false
2908        "#;
2909        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2910        assert!(!cfg.lint.is_rule_enabled("heading-hierarchy"));
2911        assert!(!cfg.lint.is_rule_enabled("undefined-references"));
2912        assert!(cfg.lint.is_rule_enabled("duplicate-reference-labels"));
2913    }
2914
2915    #[test]
2916    fn lint_config_normalizes_snake_case_rule_names() {
2917        let toml_str = r#"
2918            [lint.rules]
2919            undefined_references = false
2920        "#;
2921        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2922        assert!(!cfg.lint.is_rule_enabled("undefined-references"));
2923    }
2924
2925    #[test]
2926    fn lint_config_legacy_top_level_rules_still_supported() {
2927        let toml_str = r#"
2928            [lint]
2929            undefined-references = false
2930        "#;
2931        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2932        assert!(!cfg.lint.is_rule_enabled("undefined-references"));
2933    }
2934
2935    #[test]
2936    fn path_selector_fields_parse() {
2937        let toml_str = r#"
2938            exclude = ["tests/", "build/"]
2939            extend-exclude = ["snapshots/"]
2940            include = ["*.qmd"]
2941            extend-include = ["*.md"]
2942        "#;
2943        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2944        assert_eq!(
2945            cfg.exclude,
2946            Some(vec!["tests/".to_string(), "build/".to_string()])
2947        );
2948        assert_eq!(cfg.extend_exclude, vec!["snapshots/".to_string()]);
2949        assert_eq!(cfg.include, Some(vec!["*.qmd".to_string()]));
2950        assert_eq!(cfg.extend_include, vec!["*.md".to_string()]);
2951    }
2952
2953    #[test]
2954    fn path_selector_fields_default_to_unset_or_empty() {
2955        let cfg = toml::from_str::<Config>("line-width = 100").unwrap();
2956        assert!(cfg.exclude.is_none());
2957        assert!(cfg.extend_exclude.is_empty());
2958        assert!(cfg.include.is_none());
2959        assert!(cfg.extend_include.is_empty());
2960    }
2961
2962    #[test]
2963    fn default_exclude_patterns_include_license_md() {
2964        assert!(DEFAULT_EXCLUDE_PATTERNS.contains(&"**/LICENSE.md"));
2965    }
2966
2967    #[test]
2968    fn flavor_overrides_parse() {
2969        let toml_str = r#"
2970            [flavor-overrides]
2971            "README.md" = "gfm"
2972            "docs/**/*.md" = "quarto"
2973        "#;
2974        let cfg = toml::from_str::<Config>(toml_str).unwrap();
2975        assert_eq!(cfg.flavor_overrides.get("README.md"), Some(&Flavor::Gfm));
2976        assert_eq!(
2977            cfg.flavor_overrides.get("docs/**/*.md"),
2978            Some(&Flavor::Quarto)
2979        );
2980    }
2981
2982    #[test]
2983    fn flavor_override_uses_most_specific_match() {
2984        let mut overrides = HashMap::new();
2985        overrides.insert("docs/**/*.md".to_string(), Flavor::Pandoc);
2986        overrides.insert("docs/README.md".to_string(), Flavor::Gfm);
2987        let input = Path::new("/project/docs/README.md");
2988
2989        let flavor = detect_flavor_override(input, Some(Path::new("/project")), &overrides);
2990        assert_eq!(flavor, Some(Flavor::Gfm));
2991    }
2992
2993    #[test]
2994    fn detect_flavor_uses_override_for_markdown_family() {
2995        let mut cfg = Config {
2996            flavor: Flavor::Pandoc,
2997            ..Config::default()
2998        };
2999        cfg.flavor_overrides
3000            .insert("README.md".to_string(), Flavor::Gfm);
3001
3002        let flavor = detect_flavor(
3003            Some(Path::new("/project/README.md")),
3004            Some(Path::new("/project/panache.toml")),
3005            &cfg,
3006        );
3007        assert_eq!(flavor, Some(Flavor::Gfm));
3008    }
3009
3010    #[test]
3011    fn detect_flavor_keeps_qmd_rmd_extension_defaults() {
3012        let mut cfg = Config {
3013            flavor: Flavor::Gfm,
3014            ..Config::default()
3015        };
3016        cfg.flavor_overrides
3017            .insert("docs/**/*.qmd".to_string(), Flavor::Pandoc);
3018        cfg.flavor_overrides
3019            .insert("docs/**/*.Rmd".to_string(), Flavor::Quarto);
3020
3021        let qmd_flavor = detect_flavor(
3022            Some(Path::new("/project/docs/chapter.qmd")),
3023            Some(Path::new("/project/panache.toml")),
3024            &cfg,
3025        );
3026        let rmd_flavor = detect_flavor(
3027            Some(Path::new("/project/docs/chapter.Rmd")),
3028            Some(Path::new("/project/panache.toml")),
3029            &cfg,
3030        );
3031
3032        assert_eq!(qmd_flavor, Some(Flavor::Quarto));
3033        assert_eq!(rmd_flavor, Some(Flavor::RMarkdown));
3034    }
3035
3036    #[test]
3037    fn user_config_adds_formatters() {
3038        let toml_str = r#"
3039            [formatters.r]
3040            cmd = "custom"
3041            args = ["--flag"]
3042        "#;
3043        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3044
3045        // Only R should be configured
3046        assert_eq!(cfg.formatters.len(), 1);
3047        let r_fmt = &cfg.formatters.get("r").unwrap()[0];
3048        assert_eq!(r_fmt.cmd, "custom");
3049        assert_eq!(r_fmt.args, vec!["--flag"]);
3050    }
3051
3052    #[test]
3053    fn empty_formatters_section_stays_empty() {
3054        let toml_str = r#"
3055            line_width = 100
3056        "#;
3057        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3058
3059        // Formatters are opt-in, should be empty
3060        assert!(cfg.formatters.is_empty());
3061    }
3062
3063    #[test]
3064    fn preset_with_enabled_false() {
3065        let toml_str = r#"
3066            [formatters.r]
3067            preset = "air"
3068            enabled = false
3069        "#;
3070        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3071        // Old format with enabled=false should not include the formatter
3072        assert!(!cfg.formatters.contains_key("r"));
3073    }
3074
3075    #[test]
3076    fn default_flavor_is_pandoc() {
3077        let default_cfg = Config::default();
3078        assert_eq!(default_cfg.flavor, Flavor::Pandoc);
3079    }
3080
3081    #[test]
3082    fn extensions_merge_with_flavor_quarto() {
3083        // Test that extension overrides properly merge with Quarto flavor defaults
3084        let toml_str = r#"
3085            flavor = "quarto"
3086            
3087            [extensions]
3088            quarto-crossrefs = false
3089        "#;
3090        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3091
3092        // The overridden extension should be false
3093        assert!(!cfg.extensions.quarto_crossrefs);
3094
3095        // Other Quarto-specific extensions should still use Quarto defaults (true)
3096        assert!(cfg.extensions.quarto_callouts);
3097        assert!(cfg.extensions.quarto_shortcodes);
3098
3099        // General Pandoc extensions should also use Quarto defaults
3100        assert!(cfg.extensions.citations);
3101        assert!(cfg.extensions.yaml_metadata_block);
3102        assert!(cfg.extensions.fenced_divs);
3103    }
3104
3105    #[test]
3106    fn extensions_merge_with_flavor_pandoc() {
3107        // Test that extension overrides work with Pandoc flavor
3108        let toml_str = r#"
3109            flavor = "pandoc"
3110            
3111            [extensions]
3112            citations = false
3113        "#;
3114        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3115
3116        // The overridden extension should be false
3117        assert!(!cfg.extensions.citations);
3118
3119        // Other Pandoc extensions should still use Pandoc defaults (true)
3120        assert!(cfg.extensions.yaml_metadata_block);
3121        assert!(cfg.extensions.fenced_divs);
3122
3123        // Quarto extensions should be false in Pandoc flavor
3124        assert!(!cfg.extensions.quarto_crossrefs);
3125        assert!(!cfg.extensions.quarto_callouts);
3126    }
3127
3128    #[test]
3129    fn extensions_no_override_uses_flavor_defaults() {
3130        // Test that omitting [extensions] uses flavor defaults
3131        let toml_str = r#"
3132            flavor = "quarto"
3133        "#;
3134        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3135
3136        // Should use Quarto defaults
3137        assert!(cfg.extensions.quarto_crossrefs);
3138        assert!(cfg.extensions.quarto_callouts);
3139        assert!(cfg.extensions.quarto_shortcodes);
3140    }
3141
3142    #[test]
3143    fn extensions_empty_section_uses_flavor_defaults() {
3144        // Test that empty [extensions] section still uses flavor defaults
3145        let toml_str = r#"
3146            flavor = "quarto"
3147            
3148            [extensions]
3149        "#;
3150        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3151
3152        // Should use Quarto defaults
3153        assert!(cfg.extensions.quarto_crossrefs);
3154        assert!(cfg.extensions.quarto_callouts);
3155        assert!(cfg.extensions.quarto_shortcodes);
3156    }
3157
3158    #[test]
3159    fn extensions_multiple_overrides() {
3160        // Test multiple extension overrides
3161        let toml_str = r#"
3162            flavor = "quarto"
3163            
3164            [extensions]
3165            quarto-crossrefs = false
3166            citations = false
3167            emoji = true
3168        "#;
3169        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3170
3171        // Overridden extensions
3172        assert!(!cfg.extensions.quarto_crossrefs);
3173        assert!(!cfg.extensions.citations);
3174        assert!(cfg.extensions.emoji);
3175
3176        // Other Quarto defaults should remain
3177        assert!(cfg.extensions.quarto_callouts);
3178        assert!(cfg.extensions.quarto_shortcodes);
3179    }
3180
3181    #[test]
3182    fn extensions_per_flavor_override_wins_over_global() {
3183        let toml_str = r#"
3184            flavor = "gfm"
3185
3186            [extensions]
3187            task-lists = false
3188            citations = true
3189
3190            [extensions.gfm]
3191            task-lists = true
3192        "#;
3193        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3194
3195        assert!(cfg.extensions.task_lists);
3196        assert!(cfg.extensions.citations);
3197    }
3198
3199    #[test]
3200    fn extensions_per_flavor_table_is_ignored_for_other_flavors() {
3201        let toml_str = r#"
3202            flavor = "pandoc"
3203
3204            [extensions]
3205            citations = false
3206
3207            [extensions.gfm]
3208            citations = true
3209        "#;
3210        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3211
3212        assert!(!cfg.extensions.citations);
3213    }
3214
3215    #[test]
3216    fn extensions_per_flavor_commonmark_alias_works() {
3217        let toml_str = r#"
3218            flavor = "common-mark"
3219
3220            [extensions]
3221            citations = true
3222
3223            [extensions.commonmark]
3224            citations = false
3225        "#;
3226        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3227
3228        assert!(!cfg.extensions.citations);
3229    }
3230
3231    #[test]
3232    fn multimarkdown_flavor_enables_mmd_header_identifiers_by_default() {
3233        let cfg = toml::from_str::<Config>("flavor = \"multimarkdown\"").unwrap();
3234
3235        assert_eq!(cfg.flavor, Flavor::MultiMarkdown);
3236        assert!(cfg.extensions.mmd_header_identifiers);
3237        assert!(cfg.extensions.mmd_title_block);
3238        assert!(cfg.extensions.mmd_link_attributes);
3239        assert!(!cfg.extensions.pandoc_title_block);
3240        assert!(cfg.extensions.tex_math_double_backslash);
3241        assert!(cfg.extensions.definition_lists);
3242        assert!(cfg.extensions.raw_attribute);
3243    }
3244
3245    #[test]
3246    fn extensions_per_flavor_multimarkdown_table_works() {
3247        let toml_str = r#"
3248            flavor = "multimarkdown"
3249
3250            [extensions]
3251            mmd-header-identifiers = false
3252
3253            [extensions.multimarkdown]
3254            mmd-header-identifiers = true
3255        "#;
3256        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3257
3258        assert!(cfg.extensions.mmd_header_identifiers);
3259    }
3260
3261    #[test]
3262    fn extensions_per_flavor_multimarkdown_title_block_override_works() {
3263        let toml_str = r#"
3264            flavor = "multimarkdown"
3265
3266            [extensions]
3267            mmd-title-block = false
3268
3269            [extensions.multimarkdown]
3270            mmd-title-block = true
3271        "#;
3272        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3273
3274        assert!(cfg.extensions.mmd_title_block);
3275    }
3276
3277    #[test]
3278    fn extensions_per_flavor_multimarkdown_link_attributes_override_works() {
3279        let toml_str = r#"
3280            flavor = "multimarkdown"
3281
3282            [extensions]
3283            mmd-link-attributes = false
3284
3285            [extensions.multimarkdown]
3286            mmd-link-attributes = true
3287        "#;
3288        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3289
3290        assert!(cfg.extensions.mmd_link_attributes);
3291    }
3292
3293    #[test]
3294    fn alerts_enabled_by_default_for_gfm() {
3295        let cfg = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3296        assert!(cfg.extensions.alerts);
3297    }
3298
3299    #[test]
3300    fn auto_identifiers_enabled_by_default_for_pandoc_and_gfm() {
3301        let pandoc = toml::from_str::<Config>("flavor = \"pandoc\"").unwrap();
3302        let gfm = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3303        assert!(pandoc.extensions.auto_identifiers);
3304        assert!(gfm.extensions.auto_identifiers);
3305    }
3306
3307    #[test]
3308    fn gfm_auto_identifiers_enabled_by_default_only_for_gfm() {
3309        let pandoc = toml::from_str::<Config>("flavor = \"pandoc\"").unwrap();
3310        let gfm = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3311        let commonmark = toml::from_str::<Config>("flavor = \"common-mark\"").unwrap();
3312
3313        assert!(!pandoc.extensions.gfm_auto_identifiers);
3314        assert!(gfm.extensions.gfm_auto_identifiers);
3315        assert!(!commonmark.extensions.gfm_auto_identifiers);
3316    }
3317
3318    #[test]
3319    fn footnotes_enabled_by_default_for_gfm() {
3320        let cfg = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3321        assert!(cfg.extensions.footnotes);
3322    }
3323
3324    #[test]
3325    fn fenced_code_blocks_enabled_by_default_for_gfm() {
3326        let cfg = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3327        assert!(cfg.extensions.backtick_code_blocks);
3328        assert!(cfg.extensions.fenced_code_blocks);
3329    }
3330
3331    #[test]
3332    fn tex_math_gfm_enabled_by_default_for_gfm() {
3333        let cfg = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3334        assert!(cfg.extensions.tex_math_gfm);
3335    }
3336
3337    #[test]
3338    fn executable_code_enabled_by_default_for_quarto_and_rmarkdown() {
3339        let quarto = toml::from_str::<Config>("flavor = \"quarto\"").unwrap();
3340        let rmarkdown = toml::from_str::<Config>("flavor = \"rmarkdown\"").unwrap();
3341
3342        assert!(quarto.extensions.executable_code);
3343        assert!(rmarkdown.extensions.executable_code);
3344        assert!(quarto.extensions.quarto_inline_code);
3345        assert!(quarto.extensions.rmarkdown_inline_code);
3346        assert!(rmarkdown.extensions.rmarkdown_inline_code);
3347        assert!(!rmarkdown.extensions.quarto_inline_code);
3348    }
3349
3350    #[test]
3351    fn bookdown_equation_references_enabled_by_default_only_for_rmarkdown() {
3352        let pandoc = toml::from_str::<Config>("flavor = \"pandoc\"").unwrap();
3353        let quarto = toml::from_str::<Config>("flavor = \"quarto\"").unwrap();
3354        let rmarkdown = toml::from_str::<Config>("flavor = \"rmarkdown\"").unwrap();
3355
3356        assert!(!pandoc.extensions.bookdown_equation_references);
3357        assert!(!quarto.extensions.bookdown_equation_references);
3358        assert!(rmarkdown.extensions.bookdown_equation_references);
3359    }
3360
3361    #[test]
3362    fn executable_code_disabled_by_default_for_pandoc_gfm_and_commonmark() {
3363        let pandoc = toml::from_str::<Config>("flavor = \"pandoc\"").unwrap();
3364        let gfm = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3365        let commonmark = toml::from_str::<Config>("flavor = \"common-mark\"").unwrap();
3366
3367        assert!(!pandoc.extensions.executable_code);
3368        assert!(!gfm.extensions.executable_code);
3369        assert!(!commonmark.extensions.executable_code);
3370        assert!(!pandoc.extensions.quarto_inline_code);
3371        assert!(!pandoc.extensions.rmarkdown_inline_code);
3372        assert!(!gfm.extensions.quarto_inline_code);
3373        assert!(!gfm.extensions.rmarkdown_inline_code);
3374        assert!(!commonmark.extensions.quarto_inline_code);
3375        assert!(!commonmark.extensions.rmarkdown_inline_code);
3376    }
3377
3378    #[test]
3379    fn gfm_disables_non_gfm_pandoc_extensions() {
3380        let cfg = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3381        assert!(!cfg.extensions.citations);
3382        assert!(!cfg.extensions.definition_lists);
3383        assert!(!cfg.extensions.fenced_divs);
3384        assert!(!cfg.extensions.raw_tex);
3385    }
3386
3387    #[test]
3388    fn commonmark_defaults_match_minimal_set() {
3389        let cfg = toml::from_str::<Config>("flavor = \"common-mark\"").unwrap();
3390        assert!(cfg.extensions.raw_html);
3391        assert!(!cfg.extensions.auto_identifiers);
3392        assert!(!cfg.extensions.autolinks);
3393        assert!(!cfg.extensions.inline_links);
3394        assert!(!cfg.extensions.reference_links);
3395    }
3396
3397    #[test]
3398    fn alerts_disabled_by_default_for_pandoc() {
3399        let cfg = toml::from_str::<Config>("flavor = \"pandoc\"").unwrap();
3400        assert!(!cfg.extensions.alerts);
3401    }
3402
3403    #[test]
3404    fn format_section_new_format() {
3405        let toml_str = r#"
3406            flavor = "quarto"
3407            
3408            [format]
3409            wrap = "sentence"
3410            built-in-greedy-wrap = true
3411            blank-lines = "collapse"
3412            math-delimiter-style = "dollars"
3413            math-indent = 2
3414            tab-stops = "preserve"
3415            tab-width = 4
3416        "#;
3417        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3418
3419        assert_eq!(cfg.wrap, Some(WrapMode::Sentence));
3420        assert_eq!(cfg.blank_lines, BlankLines::Collapse);
3421        assert_eq!(cfg.math_delimiter_style, MathDelimiterStyle::Dollars);
3422        assert_eq!(cfg.math_indent, 2);
3423        assert_eq!(cfg.tab_stops, TabStopMode::Preserve);
3424        assert_eq!(cfg.tab_width, 4);
3425        assert!(cfg.built_in_greedy_wrap);
3426    }
3427
3428    #[test]
3429    fn format_section_with_deprecated_code_blocks_is_accepted() {
3430        let toml_str = r#"
3431            flavor = "pandoc"
3432            
3433            [format]
3434            wrap = "preserve"
3435            
3436            [format.code-blocks]
3437            fence-style = "tilde"
3438            attribute-style = "explicit"
3439        "#;
3440        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3441
3442        assert_eq!(cfg.wrap, Some(WrapMode::Preserve));
3443        assert!(cfg.built_in_greedy_wrap);
3444    }
3445
3446    #[test]
3447    fn backwards_compat_old_format_still_works() {
3448        let toml_str = r#"
3449            flavor = "quarto"
3450            wrap = "reflow"
3451            math-indent = 4
3452            
3453            [code-blocks]
3454            fence-style = "backtick"
3455        "#;
3456        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3457
3458        // Old format should still work
3459        assert_eq!(cfg.wrap, Some(WrapMode::Reflow));
3460        assert_eq!(cfg.math_indent, 4);
3461    }
3462
3463    #[test]
3464    fn format_section_takes_precedence() {
3465        let toml_str = r#"
3466            flavor = "quarto"
3467            
3468            # Old format (should be ignored)
3469            wrap = "preserve"
3470            math-indent = 10
3471            
3472            # New format (should take precedence)
3473            [format]
3474            wrap = "sentence"
3475            math-indent = 2
3476        "#;
3477        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3478
3479        // New [format] section should win
3480        assert_eq!(cfg.wrap, Some(WrapMode::Sentence));
3481        assert_eq!(cfg.math_indent, 2);
3482    }
3483
3484    #[test]
3485    fn deprecated_style_section_still_supported() {
3486        let toml_str = r#"
3487            [style]
3488            wrap = "preserve"
3489        "#;
3490        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3491        assert_eq!(cfg.wrap, Some(WrapMode::Preserve));
3492    }
3493}
3494
3495#[cfg(test)]
3496mod line_ending_test {
3497    use super::*;
3498
3499    #[test]
3500    fn test_deserialize_line_ending_in_config() {
3501        #[derive(Deserialize)]
3502        struct TestConfig {
3503            line_ending: LineEnding,
3504        }
3505
3506        let cfg: TestConfig = toml::from_str(r#"line_ending = "lf""#).unwrap();
3507        assert_eq!(cfg.line_ending, LineEnding::Lf);
3508
3509        let cfg2: TestConfig = toml::from_str(r#"line_ending = "auto""#).unwrap();
3510        assert_eq!(cfg2.line_ending, LineEnding::Auto);
3511
3512        let cfg3: TestConfig = toml::from_str(r#"line_ending = "crlf""#).unwrap();
3513        assert_eq!(cfg3.line_ending, LineEnding::Crlf);
3514    }
3515}
3516
3517#[cfg(test)]
3518mod raw_config_test {
3519    use super::*;
3520
3521    #[test]
3522    fn test_raw_config_line_ending() {
3523        // Must use hyphen (line-ending) not underscore due to #[serde(rename_all = "kebab-case")]
3524        let cfg: Config = toml::from_str(r#"line-ending = "lf""#).unwrap();
3525        assert_eq!(cfg.line_ending, Some(LineEnding::Lf));
3526
3527        // Test that it goes through RawConfig properly
3528        let content = r#"
3529        line-ending = "crlf"
3530        line-width = 100
3531        "#;
3532        let cfg2: Config = toml::from_str(content).unwrap();
3533        assert_eq!(cfg2.line_ending, Some(LineEnding::Crlf));
3534        assert_eq!(cfg2.line_width, 100);
3535    }
3536}
3537
3538#[cfg(test)]
3539mod field_name_test {
3540    use super::*;
3541
3542    #[test]
3543    fn test_line_ending_field_name() {
3544        // The RawConfig uses #[serde(rename_all = "kebab-case")] so field names use hyphens
3545        let cfg: Config = toml::from_str(r#"line-ending = "lf""#).unwrap();
3546        assert_eq!(cfg.line_ending, Some(LineEnding::Lf));
3547
3548        // Test all three values
3549        let cfg_auto: Config = toml::from_str(r#"line-ending = "auto""#).unwrap();
3550        assert_eq!(cfg_auto.line_ending, Some(LineEnding::Auto));
3551
3552        let cfg_crlf: Config = toml::from_str(r#"line-ending = "crlf""#).unwrap();
3553        assert_eq!(cfg_crlf.line_ending, Some(LineEnding::Crlf));
3554    }
3555}
3556
3557#[cfg(test)]
3558mod code_blocks_config_test {
3559    use super::*;
3560
3561    #[test]
3562    fn deprecated_top_level_code_blocks_is_accepted_as_noop() {
3563        let toml_str = r#"
3564            flavor = "pandoc"
3565            
3566            [code-blocks]
3567            attribute-style = "explicit"
3568        "#;
3569        let cfg: Config = toml::from_str(toml_str).unwrap();
3570        assert_eq!(cfg.flavor, Flavor::Pandoc);
3571    }
3572
3573    #[test]
3574    fn deprecated_format_code_blocks_is_accepted_as_noop() {
3575        let toml_str = r#"
3576            flavor = "quarto"
3577            [format]
3578            wrap = "reflow"
3579            
3580            [format.code-blocks]
3581            attribute-style = "explicit"
3582        "#;
3583        let cfg: Config = toml::from_str(toml_str).unwrap();
3584        assert_eq!(cfg.wrap, Some(WrapMode::Reflow));
3585    }
3586
3587    #[test]
3588    fn no_code_blocks_config_still_parses() {
3589        let toml_str = r#"
3590            flavor = "quarto"
3591        "#;
3592        let cfg: Config = toml::from_str(toml_str).unwrap();
3593        assert_eq!(cfg.flavor, Flavor::Quarto);
3594    }
3595
3596    // ===== New Formatter Format Tests =====
3597
3598    #[test]
3599    fn new_format_single_formatter() {
3600        let toml_str = r#"
3601            [formatters]
3602            r = "air"
3603            python = "black"
3604        "#;
3605        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3606
3607        assert_eq!(cfg.formatters.len(), 2);
3608
3609        // Check R formatter (resolved from built-in preset)
3610        let r_fmts = cfg.formatters.get("r").unwrap();
3611        assert_eq!(r_fmts.len(), 1);
3612        assert_eq!(r_fmts[0].cmd, "air");
3613        assert_eq!(r_fmts[0].args, vec!["format", "{}"]);
3614
3615        // Check Python formatter (resolved from built-in preset)
3616        let py_fmts = cfg.formatters.get("python").unwrap();
3617        assert_eq!(py_fmts.len(), 1);
3618        assert_eq!(py_fmts[0].cmd, "black");
3619    }
3620
3621    #[test]
3622    fn new_format_multiple_formatters() {
3623        let toml_str = r#"
3624            [formatters]
3625            python = ["ruff", "black"]
3626        "#;
3627        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3628
3629        let py_fmts = cfg.formatters.get("python").unwrap();
3630        assert_eq!(py_fmts.len(), 2);
3631        assert_eq!(py_fmts[0].cmd, "ruff");
3632        assert_eq!(py_fmts[1].cmd, "black");
3633    }
3634
3635    #[test]
3636    fn new_format_with_custom_definition() {
3637        let toml_str = r#"
3638            [formatters]
3639            r = "custom-air"
3640            
3641            [formatters.custom-air]
3642            cmd = "air"
3643            args = ["format", "--custom-flag"]
3644        "#;
3645        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3646
3647        let r_fmts = cfg.formatters.get("r").unwrap();
3648        assert_eq!(r_fmts.len(), 1);
3649        assert_eq!(r_fmts[0].cmd, "air");
3650        assert_eq!(r_fmts[0].args, vec!["format", "--custom-flag"]);
3651    }
3652
3653    #[test]
3654    fn new_format_empty_array() {
3655        let toml_str = r#"
3656            [formatters]
3657            r = []
3658        "#;
3659        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3660
3661        // Empty array means no formatting for this language
3662        assert!(!cfg.formatters.contains_key("r"));
3663    }
3664
3665    #[test]
3666    fn new_format_reusable_definition() {
3667        let toml_str = r#"
3668            [formatters]
3669            javascript = "prettier"
3670            typescript = "prettier"
3671            json = "prettier"
3672            
3673            [formatters.prettier]
3674            cmd = "prettier"
3675            args = ["--print-width=100"]
3676        "#;
3677        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3678
3679        assert_eq!(cfg.formatters.len(), 3);
3680
3681        // All should use the same prettier config
3682        for lang in ["javascript", "typescript", "json"] {
3683            let fmts = cfg.formatters.get(lang).unwrap();
3684            assert_eq!(fmts.len(), 1);
3685            assert_eq!(fmts[0].cmd, "prettier");
3686            assert_eq!(fmts[0].args, vec!["--print-width=100"]);
3687        }
3688    }
3689
3690    // ===== Preset inheritance tests =====
3691
3692    #[test]
3693    fn preset_inheritance_override_only_args() {
3694        let toml_str = r#"
3695            [formatters]
3696            r = "air"
3697            
3698            [formatters.air]
3699            args = ["format", "--custom-flag", "{}"]
3700        "#;
3701        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3702
3703        let r_fmts = cfg.formatters.get("r").unwrap();
3704        assert_eq!(r_fmts.len(), 1);
3705        // cmd and stdin inherited from built-in "air" preset
3706        assert_eq!(r_fmts[0].cmd, "air");
3707        assert!(!r_fmts[0].stdin);
3708        // args overridden
3709        assert_eq!(r_fmts[0].args, vec!["format", "--custom-flag", "{}"]);
3710    }
3711
3712    #[test]
3713    fn preset_inheritance_override_only_cmd() {
3714        let toml_str = r#"
3715            [formatters]
3716            r = "air"
3717            
3718            [formatters.air]
3719            cmd = "custom-air"
3720        "#;
3721        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3722
3723        let r_fmts = cfg.formatters.get("r").unwrap();
3724        assert_eq!(r_fmts.len(), 1);
3725        // cmd overridden
3726        assert_eq!(r_fmts[0].cmd, "custom-air");
3727        // args and stdin inherited from built-in "air" preset
3728        assert_eq!(r_fmts[0].args, vec!["format", "{}"]);
3729        assert!(!r_fmts[0].stdin);
3730    }
3731
3732    #[test]
3733    fn preset_inheritance_override_only_stdin() {
3734        let toml_str = r#"
3735            [formatters]
3736            r = "air"
3737            
3738            [formatters.air]
3739            stdin = true
3740        "#;
3741        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3742
3743        let r_fmts = cfg.formatters.get("r").unwrap();
3744        assert_eq!(r_fmts.len(), 1);
3745        // cmd and args inherited from built-in "air" preset
3746        assert_eq!(r_fmts[0].cmd, "air");
3747        assert_eq!(r_fmts[0].args, vec!["format", "{}"]);
3748        // stdin overridden
3749        assert!(r_fmts[0].stdin);
3750    }
3751
3752    #[test]
3753    fn preset_inheritance_override_multiple_fields() {
3754        let toml_str = r#"
3755            [formatters]
3756            python = "black"
3757            
3758            [formatters.black]
3759            args = ["--line-length=100"]
3760            stdin = false
3761        "#;
3762        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3763
3764        let py_fmts = cfg.formatters.get("python").unwrap();
3765        assert_eq!(py_fmts.len(), 1);
3766        // cmd inherited
3767        assert_eq!(py_fmts[0].cmd, "black");
3768        // args and stdin overridden
3769        assert_eq!(py_fmts[0].args, vec!["--line-length=100"]);
3770        assert!(!py_fmts[0].stdin);
3771    }
3772
3773    #[test]
3774    fn preset_inheritance_override_all_fields() {
3775        let toml_str = r#"
3776            [formatters]
3777            r = "air"
3778            
3779            [formatters.air]
3780            cmd = "totally-different"
3781            args = ["custom"]
3782            stdin = true
3783        "#;
3784        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3785
3786        let r_fmts = cfg.formatters.get("r").unwrap();
3787        assert_eq!(r_fmts.len(), 1);
3788        // All fields overridden (complete replacement)
3789        assert_eq!(r_fmts[0].cmd, "totally-different");
3790        assert_eq!(r_fmts[0].args, vec!["custom"]);
3791        assert!(r_fmts[0].stdin);
3792    }
3793
3794    #[test]
3795    fn preset_inheritance_empty_definition_uses_preset() {
3796        let toml_str = r#"
3797            [formatters]
3798            r = "air"
3799            
3800            [formatters.air]
3801            # Empty definition - should use preset as-is
3802        "#;
3803        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3804
3805        let r_fmts = cfg.formatters.get("r").unwrap();
3806        assert_eq!(r_fmts.len(), 1);
3807        // All fields from built-in preset
3808        assert_eq!(r_fmts[0].cmd, "air");
3809        assert_eq!(r_fmts[0].args, vec!["format", "{}"]);
3810        assert!(!r_fmts[0].stdin);
3811    }
3812
3813    #[test]
3814    fn preset_inheritance_unknown_name_without_cmd_errors() {
3815        let toml_str = r#"
3816            [formatters]
3817            r = "unknown-formatter"
3818            
3819            [formatters.unknown-formatter]
3820            args = ["--flag"]
3821        "#;
3822        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3823
3824        // Should fail to resolve - unknown preset and no cmd
3825        // Error logged, formatter not included in map
3826        assert!(!cfg.formatters.contains_key("r"));
3827    }
3828
3829    #[test]
3830    fn preset_inheritance_unknown_name_with_cmd_works() {
3831        let toml_str = r#"
3832            [formatters]
3833            r = "unknown-formatter"
3834            
3835            [formatters.unknown-formatter]
3836            cmd = "my-custom-formatter"
3837            args = ["--flag"]
3838        "#;
3839        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3840
3841        let r_fmts = cfg.formatters.get("r").unwrap();
3842        assert_eq!(r_fmts.len(), 1);
3843        // Should work - has cmd even though name doesn't match preset
3844        assert_eq!(r_fmts[0].cmd, "my-custom-formatter");
3845        assert_eq!(r_fmts[0].args, vec!["--flag"]);
3846        assert!(r_fmts[0].stdin); // default
3847    }
3848
3849    // ===== Tests for append_args and prepend_args =====
3850
3851    #[test]
3852    fn append_args_with_preset_inheritance() {
3853        let toml_str = r#"
3854            [formatters]
3855            r = "air"
3856            
3857            [formatters.air]
3858            append-args = ["-i", "2"]
3859        "#;
3860        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3861
3862        let r_fmts = cfg.formatters.get("r").unwrap();
3863        assert_eq!(r_fmts.len(), 1);
3864        // Preset args: ["format", "{}"]
3865        // After append: ["format", "{}", "-i", "2"]
3866        assert_eq!(r_fmts[0].cmd, "air");
3867        assert_eq!(r_fmts[0].args, vec!["format", "{}", "-i", "2"]);
3868        assert!(!r_fmts[0].stdin);
3869    }
3870
3871    #[test]
3872    fn prepend_args_with_preset_inheritance() {
3873        let toml_str = r#"
3874            [formatters]
3875            r = "air"
3876            
3877            [formatters.air]
3878            prepend-args = ["--verbose"]
3879        "#;
3880        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3881
3882        let r_fmts = cfg.formatters.get("r").unwrap();
3883        assert_eq!(r_fmts.len(), 1);
3884        // Preset args: ["format", "{}"]
3885        // After prepend: ["--verbose", "format", "{}"]
3886        assert_eq!(r_fmts[0].cmd, "air");
3887        assert_eq!(r_fmts[0].args, vec!["--verbose", "format", "{}"]);
3888        assert!(!r_fmts[0].stdin);
3889    }
3890
3891    #[test]
3892    fn both_prepend_and_append_args() {
3893        let toml_str = r#"
3894            [formatters]
3895            r = "air"
3896            
3897            [formatters.air]
3898            prepend_args = ["--verbose"]
3899            append_args = ["-i", "2"]
3900        "#;
3901        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3902
3903        let r_fmts = cfg.formatters.get("r").unwrap();
3904        assert_eq!(r_fmts.len(), 1);
3905        // Preset args: ["format", "{}"]
3906        // After prepend + append: ["--verbose", "format", "{}", "-i", "2"]
3907        assert_eq!(r_fmts[0].args, vec!["--verbose", "format", "{}", "-i", "2"]);
3908    }
3909
3910    #[test]
3911    fn append_args_with_explicit_args() {
3912        let toml_str = r#"
3913            [formatters]
3914            r = "custom"
3915            
3916            [formatters.custom]
3917            cmd = "shfmt"
3918            args = ["-filename", "$FILENAME"]
3919            append_args = ["-i", "2"]
3920        "#;
3921        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3922
3923        let r_fmts = cfg.formatters.get("r").unwrap();
3924        assert_eq!(r_fmts.len(), 1);
3925        // Explicit args with append: ["-filename", "$FILENAME", "-i", "2"]
3926        assert_eq!(r_fmts[0].cmd, "shfmt");
3927        assert_eq!(r_fmts[0].args, vec!["-filename", "$FILENAME", "-i", "2"]);
3928    }
3929
3930    #[test]
3931    fn prepend_args_with_explicit_args() {
3932        let toml_str = r#"
3933            [formatters]
3934            r = "custom"
3935            
3936            [formatters.custom]
3937            cmd = "formatter"
3938            args = ["input.txt"]
3939            prepend_args = ["--config", "cfg.toml"]
3940        "#;
3941        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3942
3943        let r_fmts = cfg.formatters.get("r").unwrap();
3944        assert_eq!(r_fmts.len(), 1);
3945        // Explicit args with prepend: ["--config", "cfg.toml", "input.txt"]
3946        assert_eq!(r_fmts[0].args, vec!["--config", "cfg.toml", "input.txt"]);
3947    }
3948
3949    #[test]
3950    fn args_override_with_append_still_applies() {
3951        let toml_str = r#"
3952            [formatters]
3953            r = "air"
3954            
3955            [formatters.air]
3956            args = ["custom", "override"]
3957            append_args = ["--extra"]
3958        "#;
3959        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3960
3961        let r_fmts = cfg.formatters.get("r").unwrap();
3962        assert_eq!(r_fmts.len(), 1);
3963        // Overridden args + append: ["custom", "override", "--extra"]
3964        assert_eq!(r_fmts[0].args, vec!["custom", "override", "--extra"]);
3965    }
3966
3967    #[test]
3968    fn empty_append_prepend_arrays() {
3969        let toml_str = r#"
3970            [formatters]
3971            r = "air"
3972            
3973            [formatters.air]
3974            prepend_args = []
3975            append_args = []
3976        "#;
3977        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3978
3979        let r_fmts = cfg.formatters.get("r").unwrap();
3980        assert_eq!(r_fmts.len(), 1);
3981        // Empty modifiers = no-op, preset args unchanged
3982        assert_eq!(r_fmts[0].args, vec!["format", "{}"]);
3983    }
3984
3985    #[test]
3986    fn modifiers_without_base_args() {
3987        let toml_str = r#"
3988            [formatters]
3989            r = "custom"
3990            
3991            [formatters.custom]
3992            cmd = "formatter"
3993            prepend_args = ["--flag"]
3994            append_args = ["--other"]
3995        "#;
3996        let cfg = toml::from_str::<Config>(toml_str).unwrap();
3997
3998        let r_fmts = cfg.formatters.get("r").unwrap();
3999        assert_eq!(r_fmts.len(), 1);
4000        // No base args (no preset, no explicit args), modifiers create args from scratch
4001        // Result: ["--flag", "--other"]
4002        assert_eq!(r_fmts[0].args, vec!["--flag", "--other"]);
4003    }
4004}
4005
4006#[cfg(test)]
4007mod parser_config_test {
4008    use super::*;
4009
4010    #[test]
4011    fn test_parser_config_default() {
4012        let cfg = Config::default();
4013        assert_eq!(cfg.parser.pandoc_compat, PandocCompat::V3_9);
4014    }
4015
4016    #[test]
4017    fn test_parser_config_empty() {
4018        // Verify empty parser config deserializes correctly
4019        let toml_str = r#"
4020            [parser]
4021        "#;
4022        let cfg: Config = toml::from_str(toml_str).unwrap();
4023        assert_eq!(cfg.parser.pandoc_compat, PandocCompat::V3_9);
4024    }
4025
4026    #[test]
4027    fn test_parser_config_pandoc_compat_latest() {
4028        let toml_str = r#"
4029            pandoc-compat = "latest"
4030        "#;
4031        let cfg: Config = toml::from_str(toml_str).unwrap();
4032        assert_eq!(cfg.parser.pandoc_compat, PandocCompat::Latest);
4033        assert_eq!(cfg.parser.effective_pandoc_compat(), PandocCompat::V3_9);
4034    }
4035
4036    #[test]
4037    fn test_parser_config_pandoc_compat_accepts_version_aliases() {
4038        for value in ["3.7", "3-7", "v3.7", "v3-7"] {
4039            let toml_str = format!("pandoc-compat = \"{}\"\n", value);
4040            let cfg: Config = toml::from_str(&toml_str).unwrap();
4041            assert_eq!(cfg.parser.pandoc_compat, PandocCompat::V3_7);
4042        }
4043
4044        for value in ["3.9", "3-9", "v3.9", "v3-9"] {
4045            let toml_str = format!("pandoc-compat = \"{}\"\n", value);
4046            let cfg: Config = toml::from_str(&toml_str).unwrap();
4047            assert_eq!(cfg.parser.pandoc_compat, PandocCompat::V3_9);
4048        }
4049    }
4050
4051    #[test]
4052    fn test_parser_config_pandoc_compat_parser_section_backwards_compat() {
4053        let toml_str = r#"
4054            [parser]
4055            pandoc-compat = "latest"
4056        "#;
4057        let cfg: Config = toml::from_str(toml_str).unwrap();
4058        assert_eq!(cfg.parser.pandoc_compat, PandocCompat::Latest);
4059    }
4060
4061    #[test]
4062    fn test_parser_config_pandoc_compat_top_level_takes_precedence() {
4063        let toml_str = r#"
4064            pandoc-compat = "3.7"
4065
4066            [parser]
4067            pandoc-compat = "latest"
4068        "#;
4069        let cfg: Config = toml::from_str(toml_str).unwrap();
4070        assert_eq!(cfg.parser.pandoc_compat, PandocCompat::V3_7);
4071    }
4072}
4073
4074#[test]
4075fn test_snake_case_alias_backwards_compat() {
4076    // Verify that snake_case (old format) still works via aliases
4077    let toml_str = r#"
4078            flavor = "quarto"
4079            
4080            [extensions]
4081            quarto_crossrefs = false
4082            tex_math_dollars = true
4083            tex_math_gfm = true
4084        "#;
4085    let cfg = toml::from_str::<Config>(toml_str).unwrap();
4086
4087    assert!(!cfg.extensions.quarto_crossrefs);
4088    assert!(cfg.extensions.tex_math_dollars);
4089    assert!(cfg.extensions.tex_math_gfm);
4090}
4091
4092#[test]
4093fn test_kebab_case_new_format() {
4094    // Verify that kebab-case (new format) works
4095    let toml_str = r#"
4096            flavor = "quarto"
4097            
4098            [extensions]
4099            quarto-crossrefs = false
4100            tex-math-dollars = true
4101            tex-math-gfm = true
4102        "#;
4103    let cfg = toml::from_str::<Config>(toml_str).unwrap();
4104
4105    assert!(!cfg.extensions.quarto_crossrefs);
4106    assert!(cfg.extensions.tex_math_dollars);
4107    assert!(cfg.extensions.tex_math_gfm);
4108}
4109
4110#[test]
4111fn test_formatter_prepend_append_args_snake_case() {
4112    // Old format with snake_case
4113    let toml_str = r#"
4114            [formatters.test]
4115            cmd = "test"
4116            args = ["--middle"]
4117            prepend_args = ["--before"]
4118            append_args = ["--after"]
4119        "#;
4120    let cfg = toml::from_str::<Config>(toml_str).unwrap();
4121    let fmt = &cfg.formatters.get("test").unwrap()[0];
4122
4123    assert_eq!(fmt.cmd, "test");
4124    assert_eq!(fmt.args, vec!["--before", "--middle", "--after"]);
4125}
4126
4127#[test]
4128fn test_formatter_prepend_append_args_kebab_case() {
4129    // New format with kebab-case
4130    let toml_str = r#"
4131            [formatters.test]
4132            cmd = "test"
4133            args = ["--middle"]
4134            prepend-args = ["--before"]
4135            append-args = ["--after"]
4136        "#;
4137    let cfg = toml::from_str::<Config>(toml_str).unwrap();
4138    let fmt = &cfg.formatters.get("test").unwrap()[0];
4139
4140    assert_eq!(fmt.cmd, "test");
4141    assert_eq!(fmt.args, vec!["--before", "--middle", "--after"]);
4142}