Skip to main content

hongdown/
config.rs

1// SPDX-FileCopyrightText: 2025 Hong Minhee <https://hongminhee.org/>
2// SPDX-License-Identifier: GPL-3.0-or-later
3//! Configuration file support for Hongdown.
4//!
5//! This module provides functionality for loading and parsing configuration
6//! files (`.hongdown.toml`) that control the formatter's behavior.
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use serde::Deserialize;
12
13/// The default configuration file name.
14pub const CONFIG_FILE_NAME: &str = ".hongdown.toml";
15
16/// Default value for `git_aware` (true).
17fn default_git_aware() -> bool {
18    true
19}
20
21/// Configuration for the Hongdown formatter.
22#[derive(Debug, Clone, Deserialize, PartialEq)]
23#[serde(default)]
24pub struct Config {
25    /// Skip inheriting from parent configurations (default: false).
26    /// When true, only this config file and Hongdown's defaults are used.
27    #[serde(default)]
28    pub no_inherit: bool,
29
30    /// Maximum line width for wrapping (default: 80).
31    pub line_width: LineWidth,
32
33    /// Glob patterns for files to include (default: empty, meaning all files
34    /// must be specified on command line).
35    pub include: Vec<String>,
36
37    /// Glob patterns for files to exclude (default: empty).
38    pub exclude: Vec<String>,
39
40    /// Respect `.gitignore` files and skip `.git` directory (default: true).
41    #[serde(default = "default_git_aware")]
42    pub git_aware: bool,
43
44    /// Heading formatting options.
45    pub heading: HeadingConfig,
46
47    /// Unordered list formatting options.
48    pub unordered_list: UnorderedListConfig,
49
50    /// Ordered list formatting options.
51    pub ordered_list: OrderedListConfig,
52
53    /// Code block formatting options.
54    pub code_block: CodeBlockConfig,
55
56    /// Thematic break (horizontal rule) formatting options.
57    pub thematic_break: ThematicBreakConfig,
58
59    /// Punctuation transformation options (SmartyPants-style).
60    pub punctuation: PunctuationConfig,
61}
62
63impl Default for Config {
64    fn default() -> Self {
65        Self {
66            no_inherit: false,
67            line_width: LineWidth::default(),
68            include: Vec::new(),
69            exclude: Vec::new(),
70            git_aware: true,
71            heading: HeadingConfig::default(),
72            unordered_list: UnorderedListConfig::default(),
73            ordered_list: OrderedListConfig::default(),
74            code_block: CodeBlockConfig::default(),
75            thematic_break: ThematicBreakConfig::default(),
76            punctuation: PunctuationConfig::default(),
77        }
78    }
79}
80
81/// A layer of configuration that may have some fields unset.
82///
83/// Used for merging multiple configuration sources with field-level overriding.
84/// This allows cascading configurations where later configs only override
85/// specific fields they define, preserving other fields from parent configs.
86#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
87#[serde(default)]
88pub struct ConfigLayer {
89    /// Skip inheriting from parent configurations (default: false).
90    #[serde(default)]
91    pub no_inherit: bool,
92
93    /// Maximum line width for wrapping.
94    pub line_width: Option<LineWidth>,
95
96    /// Glob patterns for files to include.
97    pub include: Option<Vec<String>>,
98
99    /// Glob patterns for files to exclude.
100    pub exclude: Option<Vec<String>>,
101
102    /// Respect `.gitignore` files and skip `.git` directory.
103    pub git_aware: Option<bool>,
104
105    /// Heading formatting options.
106    pub heading: Option<HeadingConfig>,
107
108    /// Unordered list formatting options.
109    pub unordered_list: Option<UnorderedListConfig>,
110
111    /// Ordered list formatting options.
112    pub ordered_list: Option<OrderedListConfig>,
113
114    /// Code block formatting options.
115    pub code_block: Option<CodeBlockConfig>,
116
117    /// Thematic break (horizontal rule) formatting options.
118    pub thematic_break: Option<ThematicBreakConfig>,
119
120    /// Punctuation transformation options (SmartyPants-style).
121    pub punctuation: Option<PunctuationConfig>,
122}
123
124impl ConfigLayer {
125    /// Load a ConfigLayer from a TOML file.
126    ///
127    /// Returns an error if the file cannot be read or the TOML is invalid.
128    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
129        let content =
130            std::fs::read_to_string(path).map_err(|e| ConfigError::Io(path.to_path_buf(), e))?;
131        toml::from_str(&content).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))
132    }
133
134    /// Merge this layer on top of a base Config.
135    ///
136    /// Fields set in this layer (Some(value)) override the corresponding
137    /// fields in the base config. Fields that are None preserve the base
138    /// config's values.
139    pub fn merge_over(self, mut base: Config) -> Config {
140        // Always update no_inherit from the layer
141        base.no_inherit = self.no_inherit;
142
143        if let Some(line_width) = self.line_width {
144            base.line_width = line_width;
145        }
146        if let Some(include) = self.include {
147            base.include = include;
148        }
149        if let Some(exclude) = self.exclude {
150            base.exclude = exclude;
151        }
152        if let Some(git_aware) = self.git_aware {
153            base.git_aware = git_aware;
154        }
155        if let Some(heading) = self.heading {
156            base.heading = heading;
157        }
158        if let Some(unordered_list) = self.unordered_list {
159            base.unordered_list = unordered_list;
160        }
161        if let Some(ordered_list) = self.ordered_list {
162            base.ordered_list = ordered_list;
163        }
164        if let Some(code_block) = self.code_block {
165            base.code_block = code_block;
166        }
167        if let Some(thematic_break) = self.thematic_break {
168            base.thematic_break = thematic_break;
169        }
170        if let Some(punctuation) = self.punctuation {
171            base.punctuation = punctuation;
172        }
173        base
174    }
175}
176
177/// Heading formatting options.
178#[derive(Debug, Clone, Deserialize, PartialEq)]
179#[serde(default)]
180pub struct HeadingConfig {
181    /// Use `===` underline for h1 (default: true).
182    pub setext_h1: bool,
183
184    /// Use `---` underline for h2 (default: true).
185    pub setext_h2: bool,
186
187    /// Convert headings to sentence case (default: false).
188    pub sentence_case: bool,
189
190    /// Additional proper nouns to preserve (case-sensitive).
191    /// These are merged with built-in proper nouns.
192    pub proper_nouns: Vec<String>,
193
194    /// Words to treat as common nouns (case-sensitive).
195    /// These are excluded from built-in proper nouns.
196    /// Useful for words like "Go" which can be either a programming language
197    /// or a common verb depending on context.
198    pub common_nouns: Vec<String>,
199}
200
201impl Default for HeadingConfig {
202    fn default() -> Self {
203        Self {
204            setext_h1: true,
205            setext_h2: true,
206            sentence_case: false,
207            proper_nouns: Vec::new(),
208            common_nouns: Vec::new(),
209        }
210    }
211}
212
213/// Marker character for unordered lists.
214#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
215pub enum UnorderedMarker {
216    /// Hyphen marker (`-`)
217    #[default]
218    #[serde(rename = "-")]
219    Hyphen,
220    /// Asterisk marker (`*`)
221    #[serde(rename = "*")]
222    Asterisk,
223    /// Plus marker (`+`)
224    #[serde(rename = "+")]
225    Plus,
226}
227
228impl UnorderedMarker {
229    /// Get the character representation of this marker.
230    pub fn as_char(self) -> char {
231        match self {
232            Self::Hyphen => '-',
233            Self::Asterisk => '*',
234            Self::Plus => '+',
235        }
236    }
237}
238
239/// Leading spaces before a list marker or thematic break (0-3).
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub struct LeadingSpaces(usize);
242
243impl LeadingSpaces {
244    /// Maximum allowed leading spaces (CommonMark requirement).
245    pub const MAX: usize = 3;
246
247    /// Create a new LeadingSpaces.
248    ///
249    /// Returns an error if the value is greater than 3.
250    pub fn new(value: usize) -> Result<Self, String> {
251        if value > Self::MAX {
252            Err(format!(
253                "leading_spaces must be at most {}, got {}.",
254                Self::MAX,
255                value
256            ))
257        } else {
258            Ok(Self(value))
259        }
260    }
261
262    /// Get the inner value.
263    pub fn get(self) -> usize {
264        self.0
265    }
266}
267
268impl Default for LeadingSpaces {
269    fn default() -> Self {
270        Self(1)
271    }
272}
273
274impl<'de> serde::Deserialize<'de> for LeadingSpaces {
275    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
276    where
277        D: serde::Deserializer<'de>,
278    {
279        let value = usize::deserialize(deserializer)?;
280        Self::new(value).map_err(serde::de::Error::custom)
281    }
282}
283
284/// Trailing spaces after a list marker (0-3).
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286pub struct TrailingSpaces(usize);
287
288impl TrailingSpaces {
289    /// Maximum allowed trailing spaces (CommonMark requirement).
290    pub const MAX: usize = 3;
291
292    /// Create a new TrailingSpaces.
293    ///
294    /// Returns an error if the value is greater than 3.
295    pub fn new(value: usize) -> Result<Self, String> {
296        if value > Self::MAX {
297            Err(format!(
298                "trailing_spaces must be at most {}, got {}.",
299                Self::MAX,
300                value
301            ))
302        } else {
303            Ok(Self(value))
304        }
305    }
306
307    /// Get the inner value.
308    pub fn get(self) -> usize {
309        self.0
310    }
311}
312
313impl Default for TrailingSpaces {
314    fn default() -> Self {
315        Self(2)
316    }
317}
318
319impl<'de> serde::Deserialize<'de> for TrailingSpaces {
320    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
321    where
322        D: serde::Deserializer<'de>,
323    {
324        let value = usize::deserialize(deserializer)?;
325        Self::new(value).map_err(serde::de::Error::custom)
326    }
327}
328
329/// Indentation width for nested list items (must be at least 1).
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
331pub struct IndentWidth(usize);
332
333impl IndentWidth {
334    /// Minimum allowed indent width.
335    pub const MIN: usize = 1;
336
337    /// Create a new IndentWidth.
338    ///
339    /// Returns an error if the value is less than 1.
340    pub fn new(value: usize) -> Result<Self, String> {
341        if value < Self::MIN {
342            Err(format!(
343                "indent_width must be at least {}, got {}.",
344                Self::MIN,
345                value
346            ))
347        } else {
348            Ok(Self(value))
349        }
350    }
351
352    /// Get the inner value.
353    pub fn get(self) -> usize {
354        self.0
355    }
356}
357
358impl Default for IndentWidth {
359    fn default() -> Self {
360        Self(4)
361    }
362}
363
364impl<'de> serde::Deserialize<'de> for IndentWidth {
365    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
366    where
367        D: serde::Deserializer<'de>,
368    {
369        let value = usize::deserialize(deserializer)?;
370        Self::new(value).map_err(serde::de::Error::custom)
371    }
372}
373
374/// Maximum line width for text wrapping (must be at least 8).
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub struct LineWidth(usize);
377
378impl LineWidth {
379    /// Minimum allowed line width.
380    pub const MIN: usize = 8;
381
382    /// Recommended minimum line width.
383    pub const RECOMMENDED_MIN: usize = 40;
384
385    /// Create a new LineWidth.
386    ///
387    /// Returns an error if the value is less than 8.
388    pub fn new(value: usize) -> Result<Self, String> {
389        if value < Self::MIN {
390            Err(format!(
391                "line_width must be at least {}, got {}.",
392                Self::MIN,
393                value
394            ))
395        } else {
396            Ok(Self(value))
397        }
398    }
399
400    /// Get the inner value.
401    pub fn get(self) -> usize {
402        self.0
403    }
404
405    /// Check if the line width is below the recommended minimum.
406    pub fn is_below_recommended(self) -> bool {
407        self.0 < Self::RECOMMENDED_MIN
408    }
409}
410
411impl Default for LineWidth {
412    fn default() -> Self {
413        Self(80)
414    }
415}
416
417impl<'de> serde::Deserialize<'de> for LineWidth {
418    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
419    where
420        D: serde::Deserializer<'de>,
421    {
422        let value = usize::deserialize(deserializer)?;
423        Self::new(value).map_err(serde::de::Error::custom)
424    }
425}
426
427/// Unordered list formatting options.
428#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
429#[serde(default)]
430pub struct UnorderedListConfig {
431    /// Marker character: `-`, `*`, or `+` (default: `-`).
432    pub unordered_marker: UnorderedMarker,
433
434    /// Spaces before the marker (default: 1).
435    pub leading_spaces: LeadingSpaces,
436
437    /// Spaces after the marker (default: 2).
438    pub trailing_spaces: TrailingSpaces,
439
440    /// Indentation width for nested items (default: 4).
441    pub indent_width: IndentWidth,
442}
443
444/// Marker character for ordered lists.
445#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
446pub enum OrderedMarker {
447    /// Period marker (`.`) - `1.`, `2.`, etc.
448    #[default]
449    #[serde(rename = ".")]
450    Period,
451    /// Parenthesis marker (`)`) - `1)`, `2)`, etc.
452    #[serde(rename = ")")]
453    Parenthesis,
454}
455
456impl OrderedMarker {
457    /// Get the character representation of this marker.
458    pub fn as_char(self) -> char {
459        match self {
460            Self::Period => '.',
461            Self::Parenthesis => ')',
462        }
463    }
464}
465
466/// Padding style for ordered list numbers.
467#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Default)]
468#[serde(rename_all = "lowercase")]
469pub enum OrderedListPad {
470    /// Pad before the number (default): `  1.`, `  2.`, ..., ` 10.`
471    #[default]
472    Start,
473    /// Pad after the number: `1. `, `2. `, ..., `10.`
474    End,
475}
476
477/// Ordered list formatting options.
478#[derive(Debug, Clone, Deserialize, PartialEq)]
479#[serde(default)]
480pub struct OrderedListConfig {
481    /// Marker style at odd nesting levels: `.` for `1.` (default: `.`).
482    pub odd_level_marker: OrderedMarker,
483
484    /// Marker style at even nesting levels: `)` for `1)` (default: `)`).
485    pub even_level_marker: OrderedMarker,
486
487    /// Padding style for aligning numbers of different widths (default: `start`).
488    pub pad: OrderedListPad,
489
490    /// Indentation width for nested ordered list items (default: 4).
491    pub indent_width: IndentWidth,
492}
493
494impl Default for OrderedListConfig {
495    fn default() -> Self {
496        Self {
497            odd_level_marker: OrderedMarker::default(),
498            even_level_marker: OrderedMarker::Parenthesis,
499            pad: OrderedListPad::Start,
500            indent_width: IndentWidth::default(),
501        }
502    }
503}
504
505/// Default timeout for external formatters in seconds.
506fn default_formatter_timeout() -> u64 {
507    5
508}
509
510/// External formatter configuration for a single language.
511///
512/// Can be specified in two formats:
513/// - Simple: `["command", "arg1", "arg2"]`
514/// - Full: `{ command = ["command", "arg1"], timeout = 10 }`
515#[derive(Debug, Clone, Deserialize, PartialEq)]
516#[serde(untagged)]
517pub enum FormatterConfig {
518    /// Simple array format: `["deno", "fmt", "-"]`
519    Simple(Vec<String>),
520    /// Full format with command and options.
521    Full {
522        /// Command and arguments as a vector.
523        command: Vec<String>,
524        /// Timeout in seconds (default: 5).
525        #[serde(default = "default_formatter_timeout")]
526        timeout: u64,
527    },
528}
529
530impl FormatterConfig {
531    /// Get the command as a slice.
532    pub fn command(&self) -> &[String] {
533        match self {
534            FormatterConfig::Simple(cmd) => cmd,
535            FormatterConfig::Full { command, .. } => command,
536        }
537    }
538
539    /// Get the timeout in seconds.
540    pub fn timeout(&self) -> u64 {
541        match self {
542            FormatterConfig::Simple(_) => default_formatter_timeout(),
543            FormatterConfig::Full { timeout, .. } => *timeout,
544        }
545    }
546
547    /// Validate the configuration.
548    ///
549    /// Returns an error message if the configuration is invalid.
550    pub fn validate(&self) -> Result<(), String> {
551        if self.command().is_empty() {
552            return Err("formatter command cannot be empty".to_string());
553        }
554        Ok(())
555    }
556}
557
558/// Fence character for code blocks.
559#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
560pub enum FenceChar {
561    /// Tilde fence (`~`)
562    #[default]
563    #[serde(rename = "~")]
564    Tilde,
565    /// Backtick fence (`` ` ``)
566    #[serde(rename = "`")]
567    Backtick,
568}
569
570impl FenceChar {
571    /// Get the character representation of this fence character.
572    pub fn as_char(self) -> char {
573        match self {
574            Self::Tilde => '~',
575            Self::Backtick => '`',
576        }
577    }
578}
579
580/// Minimum fence length for code blocks (must be at least 3).
581#[derive(Debug, Clone, Copy, PartialEq, Eq)]
582pub struct MinFenceLength(usize);
583
584impl MinFenceLength {
585    /// Minimum allowed fence length (CommonMark requirement).
586    pub const MIN: usize = 3;
587
588    /// Create a new MinFenceLength.
589    ///
590    /// Returns an error if the value is less than 3.
591    pub fn new(value: usize) -> Result<Self, String> {
592        if value < Self::MIN {
593            Err(format!(
594                "min_fence_length must be at least {}, got {}.",
595                Self::MIN,
596                value
597            ))
598        } else {
599            Ok(Self(value))
600        }
601    }
602
603    /// Get the inner value.
604    pub fn get(self) -> usize {
605        self.0
606    }
607}
608
609impl Default for MinFenceLength {
610    fn default() -> Self {
611        Self(4)
612    }
613}
614
615impl<'de> serde::Deserialize<'de> for MinFenceLength {
616    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
617    where
618        D: serde::Deserializer<'de>,
619    {
620        let value = usize::deserialize(deserializer)?;
621        Self::new(value).map_err(serde::de::Error::custom)
622    }
623}
624
625/// Code block formatting options.
626#[derive(Debug, Clone, Deserialize, PartialEq)]
627#[serde(default)]
628pub struct CodeBlockConfig {
629    /// Fence character: `~` or `` ` `` (default: `~`).
630    pub fence_char: FenceChar,
631
632    /// Minimum fence length (default: 4).
633    pub min_fence_length: MinFenceLength,
634
635    /// Add space between fence and language identifier (default: true).
636    pub space_after_fence: bool,
637
638    /// Default language identifier for code blocks without one (default: empty).
639    /// When empty, code blocks without a language identifier remain without one.
640    /// Set to e.g. "text" to add a default language identifier.
641    pub default_language: String,
642
643    /// External formatters for code blocks by language.
644    ///
645    /// Key: language identifier (exact match only).
646    /// Value: formatter configuration.
647    pub formatters: HashMap<String, FormatterConfig>,
648}
649
650impl Default for CodeBlockConfig {
651    fn default() -> Self {
652        Self {
653            fence_char: FenceChar::default(),
654            min_fence_length: MinFenceLength::default(),
655            space_after_fence: true,
656            default_language: String::new(),
657            formatters: HashMap::new(),
658        }
659    }
660}
661
662/// Thematic break style string (must be a valid CommonMark thematic break pattern).
663///
664/// A valid thematic break consists of:
665/// - At least 3 of the same character: `*`, `-`, or `_`
666/// - Optional spaces between the characters
667#[derive(Debug, Clone, PartialEq, Eq)]
668pub struct ThematicBreakStyle(String);
669
670impl ThematicBreakStyle {
671    /// Minimum number of marker characters required.
672    pub const MIN_MARKERS: usize = 3;
673
674    /// Create a new ThematicBreakStyle.
675    ///
676    /// Returns an error if the style is not a valid CommonMark thematic break pattern.
677    pub fn new(style: String) -> Result<Self, String> {
678        // Validate the thematic break pattern according to CommonMark spec:
679        // - Must contain at least 3 of the same character (*, -, or _)
680        // - Can have spaces between characters
681        // - No other characters allowed (except spaces)
682
683        let trimmed = style.trim();
684        if trimmed.is_empty() {
685            return Err("thematic_break style cannot be empty.".to_string());
686        }
687
688        // Count each marker type
689        let mut asterisk_count = 0;
690        let mut hyphen_count = 0;
691        let mut underscore_count = 0;
692        let mut has_other = false;
693
694        for ch in trimmed.chars() {
695            match ch {
696                '*' => asterisk_count += 1,
697                '-' => hyphen_count += 1,
698                '_' => underscore_count += 1,
699                ' ' | '\t' => {} // Whitespace is allowed
700                _ => {
701                    has_other = true;
702                    break;
703                }
704            }
705        }
706
707        if has_other {
708            return Err(format!(
709                "thematic_break style must only contain *, -, or _ (with optional spaces), got {:?}.",
710                style
711            ));
712        }
713
714        // Must have at least 3 of exactly one marker type
715        let marker_counts = [
716            (asterisk_count, '*'),
717            (hyphen_count, '-'),
718            (underscore_count, '_'),
719        ];
720
721        let valid_markers: Vec<_> = marker_counts
722            .iter()
723            .filter(|(count, _)| *count >= Self::MIN_MARKERS)
724            .collect();
725
726        if valid_markers.is_empty() {
727            return Err(format!(
728                "thematic_break style must contain at least {} of the same marker (*, -, or _), got {:?}.",
729                Self::MIN_MARKERS,
730                style
731            ));
732        }
733
734        if valid_markers.len() > 1 {
735            return Err(format!(
736                "thematic_break style must use only one type of marker (*, -, or _), got {:?}.",
737                style
738            ));
739        }
740
741        Ok(Self(style))
742    }
743
744    /// Get the inner value.
745    pub fn as_str(&self) -> &str {
746        &self.0
747    }
748}
749
750impl Default for ThematicBreakStyle {
751    fn default() -> Self {
752        Self(
753            "- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -".to_string(),
754        )
755    }
756}
757
758impl<'de> serde::Deserialize<'de> for ThematicBreakStyle {
759    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
760    where
761        D: serde::Deserializer<'de>,
762    {
763        let value = String::deserialize(deserializer)?;
764        Self::new(value).map_err(serde::de::Error::custom)
765    }
766}
767
768/// Thematic break (horizontal rule) formatting options.
769#[derive(Debug, Clone, Deserialize, PartialEq)]
770#[serde(default)]
771pub struct ThematicBreakConfig {
772    /// The style string for thematic breaks (default: `*  *  *`).
773    pub style: ThematicBreakStyle,
774
775    /// Number of leading spaces before the thematic break (0-3, default: 3).
776    /// CommonMark allows 0-3 leading spaces for thematic breaks.
777    pub leading_spaces: LeadingSpaces,
778}
779
780impl Default for ThematicBreakConfig {
781    fn default() -> Self {
782        Self {
783            style: ThematicBreakStyle::default(),
784            leading_spaces: LeadingSpaces::new(3).unwrap(),
785        }
786    }
787}
788
789/// Dash pattern for en-dash or em-dash transformation.
790/// Must be a non-empty string of valid characters.
791#[derive(Debug, Clone, PartialEq, Eq)]
792pub struct DashPattern(String);
793
794impl DashPattern {
795    /// Create a new DashPattern.
796    ///
797    /// Returns an error if the pattern is empty or contains invalid characters.
798    pub fn new(pattern: String) -> Result<Self, String> {
799        if pattern.is_empty() {
800            return Err("dash pattern cannot be empty.".to_string());
801        }
802
803        // Check for non-printable or whitespace characters
804        if pattern.chars().any(|c| !c.is_ascii_graphic()) {
805            return Err(format!(
806                "dash pattern must only contain printable ASCII characters (no whitespace), got {:?}.",
807                pattern
808            ));
809        }
810
811        Ok(Self(pattern))
812    }
813
814    /// Get the inner value.
815    pub fn as_str(&self) -> &str {
816        &self.0
817    }
818}
819
820impl<'de> serde::Deserialize<'de> for DashPattern {
821    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
822    where
823        D: serde::Deserializer<'de>,
824    {
825        let value = String::deserialize(deserializer)?;
826        Self::new(value).map_err(serde::de::Error::custom)
827    }
828}
829
830/// Dash transformation setting.
831/// Can be `false` (disabled) or a string pattern to match.
832#[derive(Debug, Clone, PartialEq, Default)]
833pub enum DashSetting {
834    /// Dash transformation is disabled.
835    #[default]
836    Disabled,
837    /// Transform the given pattern to a dash character.
838    Pattern(DashPattern),
839}
840
841impl<'de> Deserialize<'de> for DashSetting {
842    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
843    where
844        D: serde::Deserializer<'de>,
845    {
846        use serde::de::{self, Visitor};
847
848        struct DashSettingVisitor;
849
850        impl<'de> Visitor<'de> for DashSettingVisitor {
851            type Value = DashSetting;
852
853            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
854                formatter.write_str("false or a string pattern")
855            }
856
857            fn visit_bool<E>(self, value: bool) -> Result<DashSetting, E>
858            where
859                E: de::Error,
860            {
861                if value {
862                    Err(de::Error::custom(
863                        "expected false or a string pattern, got true",
864                    ))
865                } else {
866                    Ok(DashSetting::Disabled)
867                }
868            }
869
870            fn visit_str<E>(self, value: &str) -> Result<DashSetting, E>
871            where
872                E: de::Error,
873            {
874                DashPattern::new(value.to_string())
875                    .map(DashSetting::Pattern)
876                    .map_err(de::Error::custom)
877            }
878
879            fn visit_string<E>(self, value: String) -> Result<DashSetting, E>
880            where
881                E: de::Error,
882            {
883                DashPattern::new(value)
884                    .map(DashSetting::Pattern)
885                    .map_err(de::Error::custom)
886            }
887        }
888
889        deserializer.deserialize_any(DashSettingVisitor)
890    }
891}
892
893/// Punctuation transformation options (SmartyPants-style).
894#[derive(Debug, Clone, Deserialize, PartialEq)]
895#[serde(default)]
896pub struct PunctuationConfig {
897    /// Convert straight double quotes to curly quotes (default: true).
898    /// `"text"` becomes `"text"` (U+201C and U+201D).
899    pub curly_double_quotes: bool,
900
901    /// Convert straight single quotes to curly quotes (default: true).
902    /// `'text'` becomes `'text'` (U+2018 and U+2019).
903    pub curly_single_quotes: bool,
904
905    /// Convert straight apostrophes to curly apostrophes (default: false).
906    /// `it's` becomes `it's` (U+2019).
907    pub curly_apostrophes: bool,
908
909    /// Convert three dots to ellipsis character (default: true).
910    /// `...` becomes `…` (U+2026).
911    pub ellipsis: bool,
912
913    /// Convert a pattern to en-dash (default: disabled).
914    /// Set to a string like `"--"` to enable.
915    /// The pattern is replaced with `–` (U+2013).
916    pub en_dash: DashSetting,
917
918    /// Convert a pattern to em-dash (default: `"--"`).
919    /// Set to `false` to disable, or a string like `"---"` for a different pattern.
920    /// The pattern is replaced with `—` (U+2014).
921    pub em_dash: DashSetting,
922}
923
924impl Default for PunctuationConfig {
925    fn default() -> Self {
926        Self {
927            curly_double_quotes: true,
928            curly_single_quotes: true,
929            curly_apostrophes: false,
930            ellipsis: true,
931            en_dash: DashSetting::Disabled,
932            em_dash: DashSetting::Pattern(DashPattern::new("--".to_string()).unwrap()),
933        }
934    }
935}
936
937impl Config {
938    /// Parse a configuration from a TOML string.
939    pub fn from_toml(toml_str: &str) -> Result<Self, toml::de::Error> {
940        toml::from_str(toml_str)
941    }
942
943    /// Load configuration from a file.
944    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
945        let content =
946            std::fs::read_to_string(path).map_err(|e| ConfigError::Io(path.to_path_buf(), e))?;
947        Self::from_toml(&content).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))
948    }
949
950    /// Discover and load configuration by searching up the directory tree.
951    ///
952    /// Starting from `start_dir`, searches for `.hongdown.toml` in each parent
953    /// directory until the filesystem root is reached. Returns `None` if no
954    /// configuration file is found.
955    pub fn discover(start_dir: &Path) -> Result<Option<(PathBuf, Self)>, ConfigError> {
956        let mut current = start_dir.to_path_buf();
957        loop {
958            let config_path = current.join(CONFIG_FILE_NAME);
959            if config_path.exists() {
960                let config = Self::from_file(&config_path)?;
961                return Ok(Some((config_path, config)));
962            }
963            if !current.pop() {
964                break;
965            }
966        }
967        Ok(None)
968    }
969
970    /// Load cascading configuration from all sources.
971    ///
972    /// Returns the merged configuration and the path of the project config
973    /// (if found). Configurations are loaded and merged in this order:
974    /// 1. System config (`/etc/hongdown/config.toml`)
975    /// 2. User legacy config (`~/.hongdown.toml`)
976    /// 3. User XDG config (`$XDG_CONFIG_HOME/hongdown/config.toml`)
977    /// 4. Project config (`.hongdown.toml` in `start_dir` or parent directories)
978    ///
979    /// If the project config has `no_inherit = true`, all parent configs are
980    /// ignored.
981    pub fn load_cascading(start_dir: &Path) -> Result<(Self, Option<PathBuf>), ConfigError> {
982        let mut layers = Vec::new();
983        let mut project_config_path = None;
984
985        // 1. Try system-wide config: /etc/hongdown/config.toml
986        if let Some(layer) = Self::load_system_config()? {
987            layers.push(layer);
988        }
989
990        // 2. Try user legacy config: ~/.hongdown.toml
991        if let Some(layer) = Self::load_user_legacy_config()? {
992            layers.push(layer);
993        }
994
995        // 3. Try XDG user config: $XDG_CONFIG_HOME/hongdown/config.toml
996        if let Some(layer) = Self::load_user_xdg_config()? {
997            layers.push(layer);
998        }
999
1000        // 4. Try project config: search upward from start_dir
1001        if let Some((path, layer)) = Self::discover_project_config(start_dir)? {
1002            project_config_path = Some(path);
1003
1004            // If no_inherit is true, skip all parent layers
1005            if layer.no_inherit {
1006                layers.clear();
1007            }
1008
1009            layers.push(layer);
1010        }
1011
1012        // Merge all layers, starting from Config::default()
1013        let mut config = Self::default();
1014        for layer in layers {
1015            config = layer.merge_over(config);
1016        }
1017
1018        Ok((config, project_config_path))
1019    }
1020
1021    /// Load system-wide config from /etc/hongdown/config.toml.
1022    fn load_system_config() -> Result<Option<ConfigLayer>, ConfigError> {
1023        Self::try_load_layer(Path::new("/etc/hongdown/config.toml"))
1024    }
1025
1026    /// Load user legacy config from ~/.hongdown.toml.
1027    fn load_user_legacy_config() -> Result<Option<ConfigLayer>, ConfigError> {
1028        if let Some(home) = dirs::home_dir() {
1029            Self::try_load_layer(&home.join(".hongdown.toml"))
1030        } else {
1031            Ok(None)
1032        }
1033    }
1034
1035    /// Load user XDG config from $XDG_CONFIG_HOME/hongdown/config.toml.
1036    fn load_user_xdg_config() -> Result<Option<ConfigLayer>, ConfigError> {
1037        if let Some(config_dir) = dirs::config_dir() {
1038            Self::try_load_layer(&config_dir.join("hongdown/config.toml"))
1039        } else {
1040            Ok(None)
1041        }
1042    }
1043
1044    /// Discover project config by searching upward from start_dir.
1045    fn discover_project_config(
1046        start_dir: &Path,
1047    ) -> Result<Option<(PathBuf, ConfigLayer)>, ConfigError> {
1048        let mut current = start_dir.to_path_buf();
1049        loop {
1050            let config_path = current.join(CONFIG_FILE_NAME);
1051            if config_path.exists() {
1052                let layer = ConfigLayer::from_file(&config_path)?;
1053                return Ok(Some((config_path, layer)));
1054            }
1055            if !current.pop() {
1056                break;
1057            }
1058        }
1059        Ok(None)
1060    }
1061
1062    /// Try to load a config layer from a path. Returns None if file doesn't exist.
1063    fn try_load_layer(path: &Path) -> Result<Option<ConfigLayer>, ConfigError> {
1064        if !path.exists() {
1065            return Ok(None);
1066        }
1067        ConfigLayer::from_file(path).map(Some)
1068    }
1069
1070    /// Collect files matching the include patterns, excluding those matching
1071    /// exclude patterns.
1072    ///
1073    /// The `base_dir` is used as the starting point for glob pattern matching.
1074    /// Returns an empty list if no include patterns are configured.
1075    pub fn collect_files(&self, base_dir: &Path) -> Result<Vec<PathBuf>, ConfigError> {
1076        use ignore::WalkBuilder;
1077        use ignore::overrides::OverrideBuilder;
1078
1079        if self.include.is_empty() {
1080            return Ok(Vec::new());
1081        }
1082
1083        // Build override patterns: include patterns are whitelist,
1084        // exclude patterns are prefixed with ! to negate them
1085        let mut override_builder = OverrideBuilder::new(base_dir);
1086
1087        // Add include patterns
1088        for pattern in &self.include {
1089            override_builder.add(pattern).map_err(ConfigError::Ignore)?;
1090        }
1091
1092        // Add exclude patterns with ! prefix (negation)
1093        for pattern in &self.exclude {
1094            override_builder
1095                .add(&format!("!{}", pattern))
1096                .map_err(ConfigError::Ignore)?;
1097        }
1098
1099        let overrides = override_builder.build().map_err(ConfigError::Ignore)?;
1100
1101        // Build walker with overrides
1102        // This efficiently skips directories that don't match patterns
1103        let walker = WalkBuilder::new(base_dir)
1104            .hidden(false) // Don't automatically ignore hidden files
1105            .git_ignore(self.git_aware) // Respect .gitignore if git_aware is enabled
1106            .git_global(false) // Don't use global gitignore (for consistency)
1107            .git_exclude(self.git_aware) // Respect .git/info/exclude if git_aware is enabled
1108            .overrides(overrides)
1109            .build();
1110
1111        // Collect matching files
1112        let mut files = Vec::new();
1113        for entry in walker {
1114            let entry = entry.map_err(ConfigError::Ignore)?;
1115            let path = entry.path();
1116            if path.is_file() {
1117                files.push(path.to_path_buf());
1118            }
1119        }
1120
1121        // Sort for consistent ordering
1122        files.sort();
1123
1124        Ok(files)
1125    }
1126}
1127
1128/// Errors that can occur when loading configuration.
1129#[derive(Debug)]
1130pub enum ConfigError {
1131    /// I/O error reading the configuration file.
1132    Io(PathBuf, std::io::Error),
1133    /// Error parsing the TOML configuration.
1134    Parse(PathBuf, toml::de::Error),
1135    /// Error parsing a glob pattern.
1136    Glob(String, glob::PatternError),
1137    /// I/O error during glob iteration.
1138    GlobIo(glob::GlobError),
1139    /// Error from ignore crate (file traversal).
1140    Ignore(ignore::Error),
1141}
1142
1143impl std::fmt::Display for ConfigError {
1144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1145        match self {
1146            ConfigError::Io(path, err) => {
1147                write!(f, "failed to read {}: {}", path.display(), err)
1148            }
1149            ConfigError::Parse(path, err) => {
1150                write!(f, "failed to parse {}: {}", path.display(), err)
1151            }
1152            ConfigError::Glob(pattern, err) => {
1153                write!(f, "invalid glob pattern '{}': {}", pattern, err)
1154            }
1155            ConfigError::GlobIo(err) => {
1156                write!(f, "error reading file: {}", err)
1157            }
1158            ConfigError::Ignore(err) => {
1159                write!(f, "error during file traversal: {}", err)
1160            }
1161        }
1162    }
1163}
1164
1165impl std::error::Error for ConfigError {
1166    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1167        match self {
1168            ConfigError::Io(_, err) => Some(err),
1169            ConfigError::Parse(_, err) => Some(err),
1170            ConfigError::Glob(_, err) => Some(err),
1171            ConfigError::GlobIo(err) => Some(err),
1172            ConfigError::Ignore(err) => Some(err),
1173        }
1174    }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179    use super::*;
1180
1181    #[test]
1182    fn test_default_config() {
1183        let config = Config::default();
1184        assert_eq!(config.line_width.get(), 80);
1185        assert!(config.git_aware);
1186        assert!(config.heading.setext_h1);
1187        assert!(config.heading.setext_h2);
1188        assert_eq!(
1189            config.unordered_list.unordered_marker,
1190            UnorderedMarker::Hyphen
1191        );
1192        assert_eq!(config.unordered_list.leading_spaces.get(), 1);
1193        assert_eq!(config.unordered_list.trailing_spaces.get(), 2);
1194        assert_eq!(config.unordered_list.indent_width.get(), 4);
1195        assert_eq!(config.ordered_list.odd_level_marker, OrderedMarker::Period);
1196        assert_eq!(
1197            config.ordered_list.even_level_marker,
1198            OrderedMarker::Parenthesis
1199        );
1200        assert_eq!(config.ordered_list.pad, OrderedListPad::Start);
1201        assert_eq!(config.ordered_list.indent_width.get(), 4);
1202        assert_eq!(config.code_block.fence_char, FenceChar::Tilde);
1203        assert_eq!(config.code_block.min_fence_length.get(), 4);
1204        assert!(config.code_block.space_after_fence);
1205        assert_eq!(config.code_block.default_language, "");
1206        assert_eq!(
1207            config.thematic_break.style.as_str(),
1208            "- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -"
1209        );
1210        assert_eq!(config.thematic_break.leading_spaces.get(), 3);
1211    }
1212
1213    #[test]
1214    fn test_parse_empty_toml() {
1215        let config = Config::from_toml("").unwrap();
1216        assert_eq!(config, Config::default());
1217    }
1218
1219    #[test]
1220    fn test_parse_line_width() {
1221        let config = Config::from_toml("line_width = 100").unwrap();
1222        assert_eq!(config.line_width.get(), 100);
1223    }
1224
1225    #[test]
1226    fn test_parse_heading_config() {
1227        let config = Config::from_toml(
1228            r#"
1229[heading]
1230setext_h1 = false
1231setext_h2 = false
1232"#,
1233        )
1234        .unwrap();
1235        assert!(!config.heading.setext_h1);
1236        assert!(!config.heading.setext_h2);
1237    }
1238
1239    #[test]
1240    fn test_parse_heading_sentence_case() {
1241        let config = Config::from_toml(
1242            r#"
1243[heading]
1244sentence_case = true
1245"#,
1246        )
1247        .unwrap();
1248        assert!(config.heading.sentence_case);
1249    }
1250
1251    #[test]
1252    fn test_parse_heading_proper_nouns() {
1253        let config = Config::from_toml(
1254            r#"
1255[heading]
1256proper_nouns = ["Hongdown", "MyCompany", "MyProduct"]
1257"#,
1258        )
1259        .unwrap();
1260        assert_eq!(
1261            config.heading.proper_nouns,
1262            vec!["Hongdown", "MyCompany", "MyProduct"]
1263        );
1264    }
1265
1266    #[test]
1267    fn test_parse_heading_sentence_case_with_proper_nouns() {
1268        let config = Config::from_toml(
1269            r#"
1270[heading]
1271sentence_case = true
1272proper_nouns = ["Hongdown", "MyAPI"]
1273"#,
1274        )
1275        .unwrap();
1276        assert!(config.heading.sentence_case);
1277        assert_eq!(config.heading.proper_nouns, vec!["Hongdown", "MyAPI"]);
1278    }
1279
1280    #[test]
1281    fn test_parse_heading_common_nouns() {
1282        let config = Config::from_toml(
1283            r#"
1284[heading]
1285common_nouns = ["Go", "Swift"]
1286"#,
1287        )
1288        .unwrap();
1289        assert_eq!(config.heading.common_nouns, vec!["Go", "Swift"]);
1290    }
1291
1292    #[test]
1293    fn test_parse_heading_with_proper_and_common_nouns() {
1294        let config = Config::from_toml(
1295            r#"
1296[heading]
1297sentence_case = true
1298proper_nouns = ["MyAPI"]
1299common_nouns = ["Go"]
1300"#,
1301        )
1302        .unwrap();
1303        assert!(config.heading.sentence_case);
1304        assert_eq!(config.heading.proper_nouns, vec!["MyAPI"]);
1305        assert_eq!(config.heading.common_nouns, vec!["Go"]);
1306    }
1307
1308    #[test]
1309    fn test_parse_unordered_list_config() {
1310        let config = Config::from_toml(
1311            r#"
1312[unordered_list]
1313unordered_marker = "*"
1314leading_spaces = 0
1315trailing_spaces = 1
1316indent_width = 2
1317"#,
1318        )
1319        .unwrap();
1320        assert_eq!(
1321            config.unordered_list.unordered_marker,
1322            UnorderedMarker::Asterisk
1323        );
1324        assert_eq!(config.unordered_list.leading_spaces.get(), 0);
1325        assert_eq!(config.unordered_list.trailing_spaces.get(), 1);
1326        assert_eq!(config.unordered_list.indent_width.get(), 2);
1327    }
1328
1329    #[test]
1330    fn test_parse_ordered_list_config() {
1331        let config = Config::from_toml(
1332            r#"
1333[ordered_list]
1334odd_level_marker = ")"
1335even_level_marker = "."
1336"#,
1337        )
1338        .unwrap();
1339        assert_eq!(
1340            config.ordered_list.odd_level_marker,
1341            OrderedMarker::Parenthesis
1342        );
1343        assert_eq!(config.ordered_list.even_level_marker, OrderedMarker::Period);
1344        assert_eq!(config.ordered_list.pad, OrderedListPad::Start); // default
1345    }
1346
1347    #[test]
1348    fn test_parse_ordered_list_pad_end() {
1349        let config = Config::from_toml(
1350            r#"
1351[ordered_list]
1352pad = "end"
1353"#,
1354        )
1355        .unwrap();
1356        assert_eq!(config.ordered_list.pad, OrderedListPad::End);
1357    }
1358
1359    #[test]
1360    fn test_parse_ordered_list_pad_start() {
1361        let config = Config::from_toml(
1362            r#"
1363[ordered_list]
1364pad = "start"
1365"#,
1366        )
1367        .unwrap();
1368        assert_eq!(config.ordered_list.pad, OrderedListPad::Start);
1369    }
1370
1371    #[test]
1372    fn test_parse_code_block_config() {
1373        let config = Config::from_toml(
1374            r#"
1375[code_block]
1376fence_char = "`"
1377min_fence_length = 3
1378space_after_fence = false
1379"#,
1380        )
1381        .unwrap();
1382        assert_eq!(config.code_block.fence_char, FenceChar::Backtick);
1383        assert_eq!(config.code_block.min_fence_length.get(), 3);
1384        assert!(!config.code_block.space_after_fence);
1385        assert_eq!(config.code_block.default_language, ""); // Default is empty
1386    }
1387
1388    #[test]
1389    fn test_parse_code_block_default_language() {
1390        let config = Config::from_toml(
1391            r#"
1392[code_block]
1393default_language = "text"
1394"#,
1395        )
1396        .unwrap();
1397        assert_eq!(config.code_block.default_language, "text");
1398    }
1399
1400    #[test]
1401    fn test_parse_full_config() {
1402        let config = Config::from_toml(
1403            r#"
1404line_width = 80
1405
1406[heading]
1407setext_h1 = true
1408setext_h2 = true
1409
1410[unordered_list]
1411unordered_marker = "-"
1412leading_spaces = 1
1413trailing_spaces = 2
1414indent_width = 4
1415
1416[ordered_list]
1417odd_level_marker = "."
1418even_level_marker = ")"
1419
1420[code_block]
1421fence_char = "~"
1422min_fence_length = 4
1423space_after_fence = true
1424
1425[thematic_break]
1426style = "- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -"
1427leading_spaces = 3
1428"#,
1429        )
1430        .unwrap();
1431        assert_eq!(config, Config::default());
1432    }
1433
1434    #[test]
1435    fn test_parse_thematic_break_config() {
1436        let config = Config::from_toml(
1437            r#"
1438[thematic_break]
1439style = "---"
1440"#,
1441        )
1442        .unwrap();
1443        assert_eq!(config.thematic_break.style.as_str(), "---");
1444    }
1445
1446    #[test]
1447    fn test_parse_invalid_toml() {
1448        let result = Config::from_toml("line_width = \"not a number\"");
1449        assert!(result.is_err());
1450    }
1451
1452    #[test]
1453    fn test_discover_config_in_parent_dir() {
1454        let temp_dir = std::env::temp_dir().join("hongdown_test_parent");
1455        let _ = std::fs::remove_dir_all(&temp_dir);
1456        let sub_dir = temp_dir.join("subdir").join("nested");
1457        std::fs::create_dir_all(&sub_dir).unwrap();
1458        let config_path = temp_dir.join(CONFIG_FILE_NAME);
1459        std::fs::write(&config_path, "line_width = 90").unwrap();
1460
1461        let result = Config::discover(&sub_dir).unwrap();
1462        assert!(result.is_some());
1463        let (path, config) = result.unwrap();
1464        assert_eq!(path, config_path);
1465        assert_eq!(config.line_width.get(), 90);
1466
1467        let _ = std::fs::remove_dir_all(&temp_dir);
1468    }
1469
1470    #[test]
1471    fn test_default_include_exclude() {
1472        let config = Config::default();
1473        assert!(config.include.is_empty());
1474        assert!(config.exclude.is_empty());
1475    }
1476
1477    #[test]
1478    fn test_parse_include_patterns() {
1479        let config = Config::from_toml(
1480            r#"
1481include = ["*.md", "docs/**/*.md"]
1482"#,
1483        )
1484        .unwrap();
1485        assert_eq!(config.include, vec!["*.md", "docs/**/*.md"]);
1486    }
1487
1488    #[test]
1489    fn test_parse_exclude_patterns() {
1490        let config = Config::from_toml(
1491            r#"
1492exclude = ["node_modules/**", "target/**"]
1493"#,
1494        )
1495        .unwrap();
1496        assert_eq!(config.exclude, vec!["node_modules/**", "target/**"]);
1497    }
1498
1499    #[test]
1500    fn test_parse_include_and_exclude() {
1501        let config = Config::from_toml(
1502            r#"
1503include = ["**/*.md"]
1504exclude = ["vendor/**"]
1505"#,
1506        )
1507        .unwrap();
1508        assert_eq!(config.include, vec!["**/*.md"]);
1509        assert_eq!(config.exclude, vec!["vendor/**"]);
1510    }
1511
1512    #[test]
1513    fn test_collect_files_with_include() {
1514        let temp_dir = std::env::temp_dir().join("hongdown_test_collect");
1515        let _ = std::fs::remove_dir_all(&temp_dir);
1516        std::fs::create_dir_all(&temp_dir).unwrap();
1517        std::fs::write(temp_dir.join("README.md"), "# Test").unwrap();
1518        std::fs::write(temp_dir.join("CHANGELOG.md"), "# Changes").unwrap();
1519        std::fs::write(temp_dir.join("main.rs"), "fn main() {}").unwrap();
1520
1521        let config = Config::from_toml(r#"include = ["*.md"]"#).unwrap();
1522        let files = config.collect_files(&temp_dir).unwrap();
1523
1524        assert_eq!(files.len(), 2);
1525        assert!(files.iter().any(|p| p.ends_with("README.md")));
1526        assert!(files.iter().any(|p| p.ends_with("CHANGELOG.md")));
1527
1528        let _ = std::fs::remove_dir_all(&temp_dir);
1529    }
1530
1531    #[test]
1532    fn test_collect_files_with_exclude() {
1533        let temp_dir = std::env::temp_dir().join("hongdown_test_exclude");
1534        let _ = std::fs::remove_dir_all(&temp_dir);
1535        std::fs::create_dir_all(&temp_dir).unwrap();
1536        std::fs::create_dir_all(temp_dir.join("vendor")).unwrap();
1537        std::fs::write(temp_dir.join("README.md"), "# Test").unwrap();
1538        std::fs::write(temp_dir.join("vendor").join("lib.md"), "# Lib").unwrap();
1539
1540        let config = Config::from_toml(
1541            r#"
1542include = ["**/*.md"]
1543exclude = ["vendor/**"]
1544"#,
1545        )
1546        .unwrap();
1547        let files = config.collect_files(&temp_dir).unwrap();
1548
1549        assert_eq!(files.len(), 1);
1550        assert!(files[0].ends_with("README.md"));
1551
1552        let _ = std::fs::remove_dir_all(&temp_dir);
1553    }
1554
1555    #[test]
1556    fn test_collect_files_empty_include() {
1557        let temp_dir = std::env::temp_dir().join("hongdown_test_empty");
1558        let _ = std::fs::remove_dir_all(&temp_dir);
1559        std::fs::create_dir_all(&temp_dir).unwrap();
1560        std::fs::write(temp_dir.join("README.md"), "# Test").unwrap();
1561
1562        let config = Config::default();
1563        let files = config.collect_files(&temp_dir).unwrap();
1564
1565        assert!(files.is_empty());
1566
1567        let _ = std::fs::remove_dir_all(&temp_dir);
1568    }
1569
1570    #[test]
1571    fn test_default_punctuation_config() {
1572        let config = PunctuationConfig::default();
1573        assert!(config.curly_double_quotes);
1574        assert!(config.curly_single_quotes);
1575        assert!(!config.curly_apostrophes);
1576        assert!(config.ellipsis);
1577        assert_eq!(config.en_dash, DashSetting::Disabled);
1578        assert_eq!(
1579            config.em_dash,
1580            DashSetting::Pattern(DashPattern::new("--".to_string()).unwrap())
1581        );
1582    }
1583
1584    #[test]
1585    fn test_parse_punctuation_config_all_options() {
1586        let config = Config::from_toml(
1587            r#"
1588[punctuation]
1589curly_double_quotes = false
1590curly_single_quotes = false
1591curly_apostrophes = true
1592ellipsis = false
1593en_dash = "--"
1594em_dash = "---"
1595"#,
1596        )
1597        .unwrap();
1598        assert!(!config.punctuation.curly_double_quotes);
1599        assert!(!config.punctuation.curly_single_quotes);
1600        assert!(config.punctuation.curly_apostrophes);
1601        assert!(!config.punctuation.ellipsis);
1602        assert_eq!(
1603            config.punctuation.en_dash,
1604            DashSetting::Pattern(DashPattern::new("--".to_string()).unwrap())
1605        );
1606        assert_eq!(
1607            config.punctuation.em_dash,
1608            DashSetting::Pattern(DashPattern::new("---".to_string()).unwrap())
1609        );
1610    }
1611
1612    #[test]
1613    fn test_parse_dash_setting_disabled() {
1614        let config = Config::from_toml(
1615            r#"
1616[punctuation]
1617em_dash = false
1618"#,
1619        )
1620        .unwrap();
1621        assert_eq!(config.punctuation.em_dash, DashSetting::Disabled);
1622    }
1623
1624    #[test]
1625    fn test_parse_dash_setting_pattern() {
1626        let config = Config::from_toml(
1627            r#"
1628[punctuation]
1629en_dash = "---"
1630"#,
1631        )
1632        .unwrap();
1633        assert_eq!(
1634            config.punctuation.en_dash,
1635            DashSetting::Pattern(DashPattern::new("---".to_string()).unwrap())
1636        );
1637    }
1638
1639    #[test]
1640    fn test_punctuation_config_in_full_config() {
1641        let config = Config::from_toml(
1642            r#"
1643line_width = 100
1644
1645[punctuation]
1646curly_double_quotes = true
1647em_dash = "--"
1648"#,
1649        )
1650        .unwrap();
1651        assert_eq!(config.line_width.get(), 100);
1652        assert!(config.punctuation.curly_double_quotes);
1653        assert_eq!(
1654            config.punctuation.em_dash,
1655            DashSetting::Pattern(DashPattern::new("--".to_string()).unwrap())
1656        );
1657    }
1658
1659    #[test]
1660    fn test_default_code_block_formatters() {
1661        let config = Config::default();
1662        assert!(config.code_block.formatters.is_empty());
1663    }
1664
1665    #[test]
1666    fn test_parse_formatter_simple() {
1667        let config = Config::from_toml(
1668            r#"
1669[code_block.formatters]
1670javascript = ["deno", "fmt", "-"]
1671"#,
1672        )
1673        .unwrap();
1674        let formatter = config.code_block.formatters.get("javascript").unwrap();
1675        assert_eq!(formatter.command(), &["deno", "fmt", "-"]);
1676        assert_eq!(formatter.timeout(), 5);
1677    }
1678
1679    #[test]
1680    fn test_parse_formatter_full() {
1681        let config = Config::from_toml(
1682            r#"
1683[code_block.formatters.python]
1684command = ["black", "-"]
1685timeout = 10
1686"#,
1687        )
1688        .unwrap();
1689        let formatter = config.code_block.formatters.get("python").unwrap();
1690        assert_eq!(formatter.command(), &["black", "-"]);
1691        assert_eq!(formatter.timeout(), 10);
1692    }
1693
1694    #[test]
1695    fn test_parse_formatter_full_default_timeout() {
1696        let config = Config::from_toml(
1697            r#"
1698[code_block.formatters.rust]
1699command = ["rustfmt"]
1700"#,
1701        )
1702        .unwrap();
1703        let formatter = config.code_block.formatters.get("rust").unwrap();
1704        assert_eq!(formatter.command(), &["rustfmt"]);
1705        assert_eq!(formatter.timeout(), 5);
1706    }
1707
1708    #[test]
1709    fn test_parse_multiple_formatters() {
1710        let config = Config::from_toml(
1711            r#"
1712[code_block.formatters]
1713javascript = ["deno", "fmt", "-"]
1714typescript = ["deno", "fmt", "-"]
1715
1716[code_block.formatters.python]
1717command = ["black", "-"]
1718timeout = 10
1719"#,
1720        )
1721        .unwrap();
1722        assert_eq!(config.code_block.formatters.len(), 3);
1723        assert!(config.code_block.formatters.contains_key("javascript"));
1724        assert!(config.code_block.formatters.contains_key("typescript"));
1725        assert!(config.code_block.formatters.contains_key("python"));
1726    }
1727
1728    #[test]
1729    fn test_formatter_empty_command_validation() {
1730        let config = Config::from_toml(
1731            r#"
1732[code_block.formatters]
1733javascript = []
1734"#,
1735        )
1736        .unwrap();
1737        assert!(
1738            config
1739                .code_block
1740                .formatters
1741                .get("javascript")
1742                .unwrap()
1743                .validate()
1744                .is_err()
1745        );
1746    }
1747
1748    #[test]
1749    fn test_formatter_valid_command_validation() {
1750        let config = Config::from_toml(
1751            r#"
1752[code_block.formatters]
1753javascript = ["deno", "fmt", "-"]
1754"#,
1755        )
1756        .unwrap();
1757        assert!(
1758            config
1759                .code_block
1760                .formatters
1761                .get("javascript")
1762                .unwrap()
1763                .validate()
1764                .is_ok()
1765        );
1766    }
1767}
1768
1769#[cfg(test)]
1770mod unordered_marker_tests {
1771    use super::*;
1772
1773    #[test]
1774    fn test_unordered_marker_default() {
1775        let marker = UnorderedMarker::default();
1776        assert_eq!(marker, UnorderedMarker::Hyphen);
1777        assert_eq!(marker.as_char(), '-');
1778    }
1779
1780    #[test]
1781    fn test_unordered_marker_hyphen() {
1782        let config = Config::from_toml(
1783            r#"
1784[unordered_list]
1785unordered_marker = "-"
1786"#,
1787        )
1788        .unwrap();
1789        assert_eq!(
1790            config.unordered_list.unordered_marker,
1791            UnorderedMarker::Hyphen
1792        );
1793        assert_eq!(config.unordered_list.unordered_marker.as_char(), '-');
1794    }
1795
1796    #[test]
1797    fn test_unordered_marker_asterisk() {
1798        let config = Config::from_toml(
1799            r#"
1800[unordered_list]
1801unordered_marker = "*"
1802"#,
1803        )
1804        .unwrap();
1805        assert_eq!(
1806            config.unordered_list.unordered_marker,
1807            UnorderedMarker::Asterisk
1808        );
1809        assert_eq!(config.unordered_list.unordered_marker.as_char(), '*');
1810    }
1811
1812    #[test]
1813    fn test_unordered_marker_plus() {
1814        let config = Config::from_toml(
1815            r#"
1816[unordered_list]
1817unordered_marker = "+"
1818"#,
1819        )
1820        .unwrap();
1821        assert_eq!(
1822            config.unordered_list.unordered_marker,
1823            UnorderedMarker::Plus
1824        );
1825        assert_eq!(config.unordered_list.unordered_marker.as_char(), '+');
1826    }
1827
1828    #[test]
1829    fn test_unordered_marker_invalid_period() {
1830        let result = Config::from_toml(
1831            r#"
1832[unordered_list]
1833unordered_marker = "."
1834"#,
1835        );
1836        assert!(result.is_err());
1837        let err_msg = result.unwrap_err().to_string();
1838        assert!(err_msg.contains("unordered_marker"));
1839    }
1840
1841    #[test]
1842    fn test_unordered_marker_invalid_letter() {
1843        let result = Config::from_toml(
1844            r#"
1845[unordered_list]
1846unordered_marker = "x"
1847"#,
1848        );
1849        assert!(result.is_err());
1850    }
1851
1852    #[test]
1853    fn test_unordered_marker_invalid_number() {
1854        let result = Config::from_toml(
1855            r#"
1856[unordered_list]
1857unordered_marker = "1"
1858"#,
1859        );
1860        assert!(result.is_err());
1861    }
1862
1863    #[test]
1864    fn test_unordered_marker_invalid_empty() {
1865        let result = Config::from_toml(
1866            r#"
1867[unordered_list]
1868unordered_marker = ""
1869"#,
1870        );
1871        assert!(result.is_err());
1872    }
1873}
1874
1875#[cfg(test)]
1876mod ordered_marker_tests {
1877    use super::*;
1878
1879    #[test]
1880    fn test_ordered_marker_default() {
1881        let marker = OrderedMarker::default();
1882        assert_eq!(marker, OrderedMarker::Period);
1883        assert_eq!(marker.as_char(), '.');
1884    }
1885
1886    #[test]
1887    fn test_ordered_marker_period() {
1888        let config = Config::from_toml(
1889            r#"
1890[ordered_list]
1891odd_level_marker = "."
1892"#,
1893        )
1894        .unwrap();
1895        assert_eq!(config.ordered_list.odd_level_marker, OrderedMarker::Period);
1896        assert_eq!(config.ordered_list.odd_level_marker.as_char(), '.');
1897    }
1898
1899    #[test]
1900    fn test_ordered_marker_parenthesis() {
1901        let config = Config::from_toml(
1902            r#"
1903[ordered_list]
1904even_level_marker = ")"
1905"#,
1906        )
1907        .unwrap();
1908        assert_eq!(
1909            config.ordered_list.even_level_marker,
1910            OrderedMarker::Parenthesis
1911        );
1912        assert_eq!(config.ordered_list.even_level_marker.as_char(), ')');
1913    }
1914
1915    #[test]
1916    fn test_ordered_marker_invalid_hyphen() {
1917        let result = Config::from_toml(
1918            r#"
1919[ordered_list]
1920odd_level_marker = "-"
1921"#,
1922        );
1923        assert!(result.is_err());
1924        let err_msg = result.unwrap_err().to_string();
1925        assert!(err_msg.contains("odd_level_marker"));
1926    }
1927
1928    #[test]
1929    fn test_ordered_marker_invalid_asterisk() {
1930        let result = Config::from_toml(
1931            r#"
1932[ordered_list]
1933even_level_marker = "*"
1934"#,
1935        );
1936        assert!(result.is_err());
1937    }
1938
1939    #[test]
1940    fn test_ordered_marker_invalid_letter() {
1941        let result = Config::from_toml(
1942            r#"
1943[ordered_list]
1944odd_level_marker = "a"
1945"#,
1946        );
1947        assert!(result.is_err());
1948    }
1949
1950    #[test]
1951    fn test_ordered_marker_invalid_empty() {
1952        let result = Config::from_toml(
1953            r#"
1954[ordered_list]
1955odd_level_marker = ""
1956"#,
1957        );
1958        assert!(result.is_err());
1959    }
1960}
1961
1962#[cfg(test)]
1963mod fence_char_tests {
1964    use super::*;
1965
1966    #[test]
1967    fn test_fence_char_default_is_tilde() {
1968        assert_eq!(FenceChar::default(), FenceChar::Tilde);
1969    }
1970
1971    #[test]
1972    fn test_fence_char_as_char() {
1973        assert_eq!(FenceChar::Tilde.as_char(), '~');
1974        assert_eq!(FenceChar::Backtick.as_char(), '`');
1975    }
1976
1977    #[test]
1978    fn test_fence_char_parse_tilde() {
1979        let config = Config::from_toml(
1980            r#"
1981[code_block]
1982fence_char = "~"
1983"#,
1984        )
1985        .unwrap();
1986        assert_eq!(config.code_block.fence_char, FenceChar::Tilde);
1987    }
1988
1989    #[test]
1990    fn test_fence_char_parse_backtick() {
1991        let config = Config::from_toml(
1992            r#"
1993[code_block]
1994fence_char = "`"
1995"#,
1996        )
1997        .unwrap();
1998        assert_eq!(config.code_block.fence_char, FenceChar::Backtick);
1999    }
2000
2001    #[test]
2002    fn test_fence_char_invalid_char() {
2003        let result = Config::from_toml(
2004            r##"
2005[code_block]
2006fence_char = "#"
2007"##,
2008        );
2009        assert!(result.is_err());
2010    }
2011
2012    #[test]
2013    fn test_fence_char_invalid_empty() {
2014        let result = Config::from_toml(
2015            r#"
2016[code_block]
2017fence_char = ""
2018"#,
2019        );
2020        assert!(result.is_err());
2021    }
2022}
2023
2024#[cfg(test)]
2025mod min_fence_length_tests {
2026    use super::*;
2027
2028    #[test]
2029    fn test_min_fence_length_default() {
2030        assert_eq!(MinFenceLength::default().get(), 4);
2031    }
2032
2033    #[test]
2034    fn test_min_fence_length_valid() {
2035        assert_eq!(MinFenceLength::new(3).unwrap().get(), 3);
2036        assert_eq!(MinFenceLength::new(4).unwrap().get(), 4);
2037        assert_eq!(MinFenceLength::new(10).unwrap().get(), 10);
2038    }
2039
2040    #[test]
2041    fn test_min_fence_length_too_small() {
2042        assert!(MinFenceLength::new(0).is_err());
2043        assert!(MinFenceLength::new(1).is_err());
2044        assert!(MinFenceLength::new(2).is_err());
2045    }
2046
2047    #[test]
2048    fn test_min_fence_length_parse_valid() {
2049        let config = Config::from_toml(
2050            r#"
2051[code_block]
2052min_fence_length = 3
2053"#,
2054        )
2055        .unwrap();
2056        assert_eq!(config.code_block.min_fence_length.get(), 3);
2057    }
2058
2059    #[test]
2060    fn test_min_fence_length_parse_invalid() {
2061        let result = Config::from_toml(
2062            r#"
2063[code_block]
2064min_fence_length = 2
2065"#,
2066        );
2067        assert!(result.is_err());
2068    }
2069
2070    #[test]
2071    fn test_min_fence_length_parse_zero() {
2072        let result = Config::from_toml(
2073            r#"
2074[code_block]
2075min_fence_length = 0
2076"#,
2077        );
2078        assert!(result.is_err());
2079    }
2080}
2081
2082#[cfg(test)]
2083mod line_width_tests {
2084    use super::*;
2085
2086    #[test]
2087    fn test_line_width_default() {
2088        assert_eq!(LineWidth::default().get(), 80);
2089    }
2090
2091    #[test]
2092    fn test_line_width_valid() {
2093        assert_eq!(LineWidth::new(8).unwrap().get(), 8);
2094        assert_eq!(LineWidth::new(40).unwrap().get(), 40);
2095        assert_eq!(LineWidth::new(80).unwrap().get(), 80);
2096        assert_eq!(LineWidth::new(120).unwrap().get(), 120);
2097    }
2098
2099    #[test]
2100    fn test_line_width_below_recommended() {
2101        assert!(LineWidth::new(8).unwrap().is_below_recommended());
2102        assert!(LineWidth::new(39).unwrap().is_below_recommended());
2103        assert!(!LineWidth::new(40).unwrap().is_below_recommended());
2104        assert!(!LineWidth::new(80).unwrap().is_below_recommended());
2105    }
2106
2107    #[test]
2108    fn test_line_width_invalid() {
2109        assert!(LineWidth::new(0).is_err());
2110        assert!(LineWidth::new(7).is_err());
2111        assert_eq!(
2112            LineWidth::new(5).unwrap_err(),
2113            "line_width must be at least 8, got 5."
2114        );
2115    }
2116
2117    #[test]
2118    fn test_line_width_parse_valid() {
2119        let config = Config::from_toml("line_width = 100").unwrap();
2120        assert_eq!(config.line_width.get(), 100);
2121    }
2122
2123    #[test]
2124    fn test_line_width_parse_invalid() {
2125        let result = Config::from_toml("line_width = 5");
2126        assert!(result.is_err());
2127        let err = result.unwrap_err().to_string();
2128        assert!(err.contains("line_width must be at least 8"));
2129    }
2130}
2131
2132#[cfg(test)]
2133mod thematic_break_style_tests {
2134    use super::*;
2135
2136    #[test]
2137    fn test_thematic_break_style_default() {
2138        let style = ThematicBreakStyle::default();
2139        assert_eq!(
2140            style.as_str(),
2141            "- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -"
2142        );
2143    }
2144
2145    #[test]
2146    fn test_thematic_break_style_valid_hyphens() {
2147        assert!(ThematicBreakStyle::new("---".to_string()).is_ok());
2148        assert!(ThematicBreakStyle::new("- - -".to_string()).is_ok());
2149        assert!(ThematicBreakStyle::new("-----".to_string()).is_ok());
2150        assert_eq!(
2151            ThematicBreakStyle::new("---".to_string()).unwrap().as_str(),
2152            "---"
2153        );
2154    }
2155
2156    #[test]
2157    fn test_thematic_break_style_valid_asterisks() {
2158        assert!(ThematicBreakStyle::new("***".to_string()).is_ok());
2159        assert!(ThematicBreakStyle::new("* * *".to_string()).is_ok());
2160        assert!(ThematicBreakStyle::new("*****".to_string()).is_ok());
2161    }
2162
2163    #[test]
2164    fn test_thematic_break_style_valid_underscores() {
2165        assert!(ThematicBreakStyle::new("___".to_string()).is_ok());
2166        assert!(ThematicBreakStyle::new("_ _ _".to_string()).is_ok());
2167        assert!(ThematicBreakStyle::new("_____".to_string()).is_ok());
2168    }
2169
2170    #[test]
2171    fn test_thematic_break_style_empty() {
2172        let result = ThematicBreakStyle::new("".to_string());
2173        assert!(result.is_err());
2174        assert_eq!(result.unwrap_err(), "thematic_break style cannot be empty.");
2175    }
2176
2177    #[test]
2178    fn test_thematic_break_style_too_few_markers() {
2179        assert!(ThematicBreakStyle::new("--".to_string()).is_err());
2180        assert!(ThematicBreakStyle::new("**".to_string()).is_err());
2181        assert!(ThematicBreakStyle::new("__".to_string()).is_err());
2182        let result = ThematicBreakStyle::new("--".to_string());
2183        assert!(
2184            result
2185                .unwrap_err()
2186                .contains("must contain at least 3 of the same marker")
2187        );
2188    }
2189
2190    #[test]
2191    fn test_thematic_break_style_mixed_markers() {
2192        let result = ThematicBreakStyle::new("---***".to_string());
2193        assert!(result.is_err());
2194        assert!(result.unwrap_err().contains("only one type of marker"));
2195    }
2196
2197    #[test]
2198    fn test_thematic_break_style_invalid_characters() {
2199        let result = ThematicBreakStyle::new("---abc".to_string());
2200        assert!(result.is_err());
2201        assert!(result.unwrap_err().contains("must only contain *, -, or _"));
2202    }
2203
2204    #[test]
2205    fn test_thematic_break_style_parse_valid() {
2206        let config = Config::from_toml(
2207            r#"
2208[thematic_break]
2209style = "***"
2210"#,
2211        )
2212        .unwrap();
2213        assert_eq!(config.thematic_break.style.as_str(), "***");
2214    }
2215
2216    #[test]
2217    fn test_thematic_break_style_parse_invalid() {
2218        let result = Config::from_toml(
2219            r#"
2220[thematic_break]
2221style = "--"
2222"#,
2223        );
2224        assert!(result.is_err());
2225        let err = result.unwrap_err().to_string();
2226        assert!(err.contains("must contain at least 3"));
2227    }
2228}
2229
2230#[cfg(test)]
2231mod dash_pattern_tests {
2232    use super::*;
2233
2234    #[test]
2235    fn test_dash_pattern_valid() {
2236        assert!(DashPattern::new("--".to_string()).is_ok());
2237        assert!(DashPattern::new("---".to_string()).is_ok());
2238        assert!(DashPattern::new("-".to_string()).is_ok());
2239        assert_eq!(DashPattern::new("--".to_string()).unwrap().as_str(), "--");
2240    }
2241
2242    #[test]
2243    fn test_dash_pattern_empty() {
2244        let result = DashPattern::new("".to_string());
2245        assert!(result.is_err());
2246        assert_eq!(result.unwrap_err(), "dash pattern cannot be empty.");
2247    }
2248
2249    #[test]
2250    fn test_dash_pattern_with_whitespace() {
2251        // Patterns cannot contain whitespace
2252        assert!(DashPattern::new("- -".to_string()).is_err());
2253        assert!(DashPattern::new(" --".to_string()).is_err());
2254        assert!(DashPattern::new("-- ".to_string()).is_err());
2255        let result = DashPattern::new("- -".to_string());
2256        assert!(
2257            result
2258                .unwrap_err()
2259                .contains("must only contain printable ASCII characters")
2260        );
2261    }
2262
2263    #[test]
2264    fn test_dash_pattern_parse_valid() {
2265        let config = Config::from_toml(
2266            r#"
2267[punctuation]
2268em_dash = "--"
2269"#,
2270        )
2271        .unwrap();
2272        if let DashSetting::Pattern(p) = &config.punctuation.em_dash {
2273            assert_eq!(p.as_str(), "--");
2274        } else {
2275            panic!("Expected Pattern");
2276        }
2277    }
2278
2279    #[test]
2280    fn test_dash_pattern_parse_invalid() {
2281        let result = Config::from_toml(
2282            r#"
2283[punctuation]
2284em_dash = ""
2285"#,
2286        );
2287        assert!(result.is_err());
2288        let err = result.unwrap_err().to_string();
2289        assert!(err.contains("dash pattern cannot be empty"));
2290    }
2291}
2292
2293#[cfg(test)]
2294mod leading_spaces_tests {
2295    use super::*;
2296
2297    #[test]
2298    fn test_leading_spaces_default() {
2299        assert_eq!(LeadingSpaces::default().get(), 1);
2300    }
2301
2302    #[test]
2303    fn test_leading_spaces_valid() {
2304        assert_eq!(LeadingSpaces::new(0).unwrap().get(), 0);
2305        assert_eq!(LeadingSpaces::new(1).unwrap().get(), 1);
2306        assert_eq!(LeadingSpaces::new(2).unwrap().get(), 2);
2307        assert_eq!(LeadingSpaces::new(3).unwrap().get(), 3);
2308    }
2309
2310    #[test]
2311    fn test_leading_spaces_invalid() {
2312        assert!(LeadingSpaces::new(4).is_err());
2313        assert!(LeadingSpaces::new(5).is_err());
2314        assert_eq!(
2315            LeadingSpaces::new(4).unwrap_err(),
2316            "leading_spaces must be at most 3, got 4."
2317        );
2318    }
2319
2320    #[test]
2321    fn test_leading_spaces_parse_valid() {
2322        let config = Config::from_toml(
2323            r#"
2324[unordered_list]
2325leading_spaces = 2
2326"#,
2327        )
2328        .unwrap();
2329        assert_eq!(config.unordered_list.leading_spaces.get(), 2);
2330    }
2331
2332    #[test]
2333    fn test_leading_spaces_parse_invalid() {
2334        let result = Config::from_toml(
2335            r#"
2336[unordered_list]
2337leading_spaces = 5
2338"#,
2339        );
2340        assert!(result.is_err());
2341        let err = result.unwrap_err().to_string();
2342        assert!(err.contains("leading_spaces must be at most 3"));
2343    }
2344}
2345
2346#[cfg(test)]
2347mod trailing_spaces_tests {
2348    use super::*;
2349
2350    #[test]
2351    fn test_trailing_spaces_default() {
2352        assert_eq!(TrailingSpaces::default().get(), 2);
2353    }
2354
2355    #[test]
2356    fn test_trailing_spaces_valid() {
2357        assert_eq!(TrailingSpaces::new(0).unwrap().get(), 0);
2358        assert_eq!(TrailingSpaces::new(1).unwrap().get(), 1);
2359        assert_eq!(TrailingSpaces::new(2).unwrap().get(), 2);
2360        assert_eq!(TrailingSpaces::new(3).unwrap().get(), 3);
2361    }
2362
2363    #[test]
2364    fn test_trailing_spaces_invalid() {
2365        assert!(TrailingSpaces::new(4).is_err());
2366        assert!(TrailingSpaces::new(5).is_err());
2367        assert_eq!(
2368            TrailingSpaces::new(4).unwrap_err(),
2369            "trailing_spaces must be at most 3, got 4."
2370        );
2371    }
2372
2373    #[test]
2374    fn test_trailing_spaces_parse_valid() {
2375        let config = Config::from_toml(
2376            r#"
2377[unordered_list]
2378trailing_spaces = 1
2379"#,
2380        )
2381        .unwrap();
2382        assert_eq!(config.unordered_list.trailing_spaces.get(), 1);
2383    }
2384
2385    #[test]
2386    fn test_trailing_spaces_parse_invalid() {
2387        let result = Config::from_toml(
2388            r#"
2389[unordered_list]
2390trailing_spaces = 4
2391"#,
2392        );
2393        assert!(result.is_err());
2394        let err = result.unwrap_err().to_string();
2395        assert!(err.contains("trailing_spaces must be at most 3"));
2396    }
2397}
2398
2399#[cfg(test)]
2400mod indent_width_tests {
2401    use super::*;
2402
2403    #[test]
2404    fn test_indent_width_default() {
2405        assert_eq!(IndentWidth::default().get(), 4);
2406    }
2407
2408    #[test]
2409    fn test_indent_width_valid() {
2410        assert_eq!(IndentWidth::new(1).unwrap().get(), 1);
2411        assert_eq!(IndentWidth::new(2).unwrap().get(), 2);
2412        assert_eq!(IndentWidth::new(4).unwrap().get(), 4);
2413        assert_eq!(IndentWidth::new(8).unwrap().get(), 8);
2414    }
2415
2416    #[test]
2417    fn test_indent_width_invalid() {
2418        assert!(IndentWidth::new(0).is_err());
2419        assert_eq!(
2420            IndentWidth::new(0).unwrap_err(),
2421            "indent_width must be at least 1, got 0."
2422        );
2423    }
2424
2425    #[test]
2426    fn test_indent_width_parse_valid() {
2427        let config = Config::from_toml(
2428            r#"
2429[unordered_list]
2430indent_width = 2
2431"#,
2432        )
2433        .unwrap();
2434        assert_eq!(config.unordered_list.indent_width.get(), 2);
2435    }
2436
2437    #[test]
2438    fn test_indent_width_parse_invalid() {
2439        let result = Config::from_toml(
2440            r#"
2441[unordered_list]
2442indent_width = 0
2443"#,
2444        );
2445        assert!(result.is_err());
2446        let err = result.unwrap_err().to_string();
2447        assert!(err.contains("indent_width must be at least 1"));
2448    }
2449}
2450
2451#[cfg(test)]
2452mod config_layer_tests {
2453    use super::*;
2454    use tempfile::TempDir;
2455
2456    #[test]
2457    fn test_config_layer_from_file_empty() {
2458        let temp_dir = TempDir::new().unwrap();
2459        let config_path = temp_dir.path().join(".hongdown.toml");
2460        std::fs::write(&config_path, "").unwrap();
2461
2462        let layer = ConfigLayer::from_file(&config_path).unwrap();
2463        assert_eq!(layer, ConfigLayer::default());
2464    }
2465
2466    #[test]
2467    fn test_config_layer_from_file_partial() {
2468        let temp_dir = TempDir::new().unwrap();
2469        let config_path = temp_dir.path().join(".hongdown.toml");
2470        std::fs::write(
2471            &config_path,
2472            r#"
2473line_width = 100
2474git_aware = false
2475"#,
2476        )
2477        .unwrap();
2478
2479        let layer = ConfigLayer::from_file(&config_path).unwrap();
2480        assert_eq!(layer.line_width, Some(LineWidth::new(100).unwrap()));
2481        assert_eq!(layer.git_aware, Some(false));
2482        assert_eq!(layer.include, None);
2483        assert_eq!(layer.heading, None);
2484    }
2485
2486    #[test]
2487    fn test_config_layer_from_file_no_inherit() {
2488        let temp_dir = TempDir::new().unwrap();
2489        let config_path = temp_dir.path().join(".hongdown.toml");
2490        std::fs::write(
2491            &config_path,
2492            r#"
2493no_inherit = true
2494line_width = 100
2495"#,
2496        )
2497        .unwrap();
2498
2499        let layer = ConfigLayer::from_file(&config_path).unwrap();
2500        assert_eq!(layer.no_inherit, true);
2501        assert_eq!(layer.line_width, Some(LineWidth::new(100).unwrap()));
2502    }
2503
2504    #[test]
2505    fn test_config_layer_from_file_not_found() {
2506        let result = ConfigLayer::from_file(Path::new("/nonexistent/.hongdown.toml"));
2507        assert!(result.is_err());
2508    }
2509
2510    #[test]
2511    fn test_config_layer_merge_simple_fields() {
2512        let base = Config {
2513            line_width: LineWidth::new(80).unwrap(),
2514            git_aware: true,
2515            ..Config::default()
2516        };
2517
2518        let layer = ConfigLayer {
2519            line_width: Some(LineWidth::new(100).unwrap()),
2520            git_aware: Some(false),
2521            ..ConfigLayer::default()
2522        };
2523
2524        let merged = layer.merge_over(base);
2525        assert_eq!(merged.line_width.get(), 100);
2526        assert_eq!(merged.git_aware, false);
2527    }
2528
2529    #[test]
2530    fn test_config_layer_merge_preserves_unset() {
2531        let base = Config {
2532            line_width: LineWidth::new(120).unwrap(),
2533            git_aware: false,
2534            include: vec!["*.md".to_string()],
2535            ..Config::default()
2536        };
2537
2538        let layer = ConfigLayer {
2539            line_width: Some(LineWidth::new(100).unwrap()),
2540            // git_aware and include are None, should preserve base
2541            ..ConfigLayer::default()
2542        };
2543
2544        let merged = layer.merge_over(base);
2545        assert_eq!(merged.line_width.get(), 100);
2546        assert_eq!(merged.git_aware, false); // Preserved
2547        assert_eq!(merged.include, vec!["*.md".to_string()]); // Preserved
2548    }
2549
2550    #[test]
2551    fn test_config_layer_merge_nested_structs() {
2552        let base = Config {
2553            heading: HeadingConfig {
2554                setext_h1: true,
2555                setext_h2: true,
2556                sentence_case: false,
2557                proper_nouns: vec!["Rust".to_string()],
2558                common_nouns: Vec::new(),
2559            },
2560            ..Config::default()
2561        };
2562
2563        let layer = ConfigLayer {
2564            heading: Some(HeadingConfig {
2565                setext_h1: false,
2566                setext_h2: false,
2567                sentence_case: true,
2568                proper_nouns: vec!["Python".to_string()],
2569                common_nouns: Vec::new(),
2570            }),
2571            ..ConfigLayer::default()
2572        };
2573
2574        let merged = layer.merge_over(base);
2575        assert_eq!(merged.heading.setext_h1, false);
2576        assert_eq!(merged.heading.setext_h2, false);
2577        assert_eq!(merged.heading.sentence_case, true);
2578        assert_eq!(merged.heading.proper_nouns, vec!["Python".to_string()]);
2579    }
2580
2581    #[test]
2582    fn test_config_layer_merge_vec_replacement() {
2583        let base = Config {
2584            include: vec!["*.md".to_string(), "*.txt".to_string()],
2585            exclude: vec!["test/**".to_string()],
2586            ..Config::default()
2587        };
2588
2589        let layer = ConfigLayer {
2590            include: Some(vec!["docs/*.md".to_string()]),
2591            // exclude is None, should preserve base
2592            ..ConfigLayer::default()
2593        };
2594
2595        let merged = layer.merge_over(base);
2596        assert_eq!(merged.include, vec!["docs/*.md".to_string()]);
2597        assert_eq!(merged.exclude, vec!["test/**".to_string()]); // Preserved
2598    }
2599}
2600
2601#[cfg(test)]
2602mod cascading_config_tests {
2603    use super::*;
2604    use tempfile::TempDir;
2605
2606    #[test]
2607    fn test_try_load_layer_file_exists() {
2608        let temp_dir = TempDir::new().unwrap();
2609        let config_path = temp_dir.path().join(".hongdown.toml");
2610        std::fs::write(
2611            &config_path,
2612            r#"
2613line_width = 100
2614"#,
2615        )
2616        .unwrap();
2617
2618        let layer = Config::try_load_layer(&config_path).unwrap();
2619        assert!(layer.is_some());
2620        assert_eq!(
2621            layer.unwrap().line_width,
2622            Some(LineWidth::new(100).unwrap())
2623        );
2624    }
2625
2626    #[test]
2627    fn test_try_load_layer_file_not_exists() {
2628        let result = Config::try_load_layer(Path::new("/nonexistent/.hongdown.toml")).unwrap();
2629        assert!(result.is_none());
2630    }
2631
2632    #[test]
2633    fn test_discover_project_config() {
2634        let temp_dir = TempDir::new().unwrap();
2635        let parent = temp_dir.path();
2636        let child = parent.join("project");
2637        std::fs::create_dir(&child).unwrap();
2638
2639        let config_path = parent.join(".hongdown.toml");
2640        std::fs::write(&config_path, "line_width = 100").unwrap();
2641
2642        let result = Config::discover_project_config(&child).unwrap();
2643        assert!(result.is_some());
2644        let (path, layer) = result.unwrap();
2645        assert_eq!(path, config_path);
2646        assert_eq!(layer.line_width, Some(LineWidth::new(100).unwrap()));
2647    }
2648
2649    #[test]
2650    fn test_discover_project_config_not_found() {
2651        let temp_dir = TempDir::new().unwrap();
2652        let result = Config::discover_project_config(temp_dir.path()).unwrap();
2653        assert!(result.is_none());
2654    }
2655
2656    #[test]
2657    fn test_load_cascading_project_only() {
2658        let temp_dir = TempDir::new().unwrap();
2659        let config_path = temp_dir.path().join(".hongdown.toml");
2660        std::fs::write(&config_path, "line_width = 100").unwrap();
2661
2662        let (config, path) = Config::load_cascading(temp_dir.path()).unwrap();
2663        assert_eq!(path, Some(config_path));
2664        assert_eq!(config.line_width.get(), 100);
2665    }
2666
2667    #[test]
2668    fn test_load_cascading_no_config() {
2669        let temp_dir = TempDir::new().unwrap();
2670        let (config, path) = Config::load_cascading(temp_dir.path()).unwrap();
2671        assert_eq!(path, None);
2672        assert_eq!(config, Config::default());
2673    }
2674
2675    #[test]
2676    fn test_load_cascading_with_no_inherit() {
2677        let temp_dir = TempDir::new().unwrap();
2678        let config_path = temp_dir.path().join(".hongdown.toml");
2679
2680        // Config with no_inherit = true, line_width = 100
2681        std::fs::write(
2682            &config_path,
2683            r#"
2684no_inherit = true
2685line_width = 100
2686"#,
2687        )
2688        .unwrap();
2689
2690        let (config, _) = Config::load_cascading(temp_dir.path()).unwrap();
2691
2692        // Should use config's values, ignoring any system/user configs
2693        assert_eq!(config.line_width.get(), 100);
2694        assert_eq!(config.no_inherit, true);
2695    }
2696
2697    #[test]
2698    fn test_load_cascading_nearest_project_config() {
2699        let temp_dir = TempDir::new().unwrap();
2700        let parent = temp_dir.path();
2701        let child = parent.join("project");
2702        std::fs::create_dir(&child).unwrap();
2703
2704        // Parent config: line_width = 120
2705        std::fs::write(parent.join(".hongdown.toml"), "line_width = 120").unwrap();
2706
2707        // Child config: line_width = 100
2708        std::fs::write(child.join(".hongdown.toml"), "line_width = 100").unwrap();
2709
2710        let (config, path) = Config::load_cascading(&child).unwrap();
2711
2712        // Should use nearest (child) config, parent config is ignored
2713        assert_eq!(config.line_width.get(), 100);
2714        assert_eq!(path, Some(child.join(".hongdown.toml")));
2715    }
2716
2717    #[test]
2718    fn test_load_cascading_searches_parent_dirs() {
2719        let temp_dir = TempDir::new().unwrap();
2720        let parent = temp_dir.path();
2721        let child = parent.join("project");
2722        std::fs::create_dir(&child).unwrap();
2723
2724        // Only parent has config
2725        std::fs::write(
2726            parent.join(".hongdown.toml"),
2727            r#"
2728line_width = 120
2729git_aware = false
2730"#,
2731        )
2732        .unwrap();
2733
2734        let (config, path) = Config::load_cascading(&child).unwrap();
2735
2736        // Should find parent's config when searching from child
2737        assert_eq!(config.line_width.get(), 120);
2738        assert_eq!(config.git_aware, false);
2739        assert_eq!(path, Some(parent.join(".hongdown.toml")));
2740    }
2741}