Skip to main content

cmakefmt/config/
file.rs

1// SPDX-FileCopyrightText: Copyright 2026 Puneet Matharu
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Config-file loading and starter template generation.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::config::{
13    CaseStyle, Config, DangleAlign, FractionalTabPolicy, LineEnding, PerCommandConfig,
14};
15use crate::error::{Error, FileParseError, Result};
16
17/// The user-config file structure for `.cmakefmt.yaml`, `.cmakefmt.yml`, and
18/// `.cmakefmt.toml`.
19///
20/// All fields are optional — only specified values override the defaults.
21#[derive(Debug, Clone, Deserialize, Default, schemars::JsonSchema)]
22#[schemars(title = "cmakefmt configuration")]
23#[serde(default)]
24struct FileConfig {
25    /// Formatting options controlling line width, indentation, and layout.
26    format: FormatSection,
27    /// Style options controlling command and keyword casing.
28    style: StyleSection,
29    /// Comment markup processing options.
30    markup: MarkupSection,
31    /// Per-command configuration overrides keyed by lowercase command name.
32    #[serde(rename = "per_command_overrides")]
33    per_command_overrides: HashMap<String, PerCommandConfig>,
34    #[serde(rename = "per_command")]
35    #[schemars(skip)]
36    legacy_per_command: HashMap<String, PerCommandConfig>,
37}
38
39#[derive(Debug, Clone, Deserialize, Default, schemars::JsonSchema)]
40#[serde(default)]
41#[serde(deny_unknown_fields)]
42struct FormatSection {
43    /// Disable formatting entirely and return the source unchanged.
44    disable: Option<bool>,
45    /// Output line-ending style: `unix` (LF), `windows` (CRLF), or `auto` (detect from input).
46    line_ending: Option<LineEnding>,
47    /// Maximum rendered line width before cmakefmt wraps a call. Default: `80`.
48    line_width: Option<usize>,
49    /// Number of spaces per indentation level when `use_tabs` is `false`. Default: `2`.
50    tab_size: Option<usize>,
51    /// Indent with tab characters instead of spaces.
52    use_tabs: Option<bool>,
53    /// How to handle fractional indentation when `use_tabs` is `true`: `use-space` or `round-up`.
54    fractional_tab_policy: Option<FractionalTabPolicy>,
55    /// Maximum number of consecutive blank lines to preserve. Default: `1`.
56    max_empty_lines: Option<usize>,
57    /// Maximum wrapped lines to tolerate before switching to a more vertical layout. Default: `2`.
58    max_hanging_wrap_lines: Option<usize>,
59    /// Maximum positional arguments to keep in a hanging-wrap layout. Default: `6`.
60    max_hanging_wrap_positional_args: Option<usize>,
61    /// Maximum keyword/flag subgroups to keep in a hanging-wrap layout. Default: `2`.
62    max_hanging_wrap_groups: Option<usize>,
63    /// Maximum rows a hanging-wrap positional group may consume before nesting is forced. Default: `2`.
64    max_rows_cmdline: Option<usize>,
65    /// Command names (lowercase) that must always use vertical layout regardless of line width.
66    always_wrap: Option<Vec<String>>,
67    /// Return an error if any formatted output line exceeds `line_width`.
68    require_valid_layout: Option<bool>,
69    /// Place the closing `)` on its own line when a call wraps.
70    dangle_parens: Option<bool>,
71    /// Alignment strategy for a dangling `)`: `prefix`, `open`, or `close`.
72    dangle_align: Option<DangleAlign>,
73    /// Lower heuristic bound used when deciding between compact and wrapped layouts. Default: `4`.
74    min_prefix_length: Option<usize>,
75    /// Upper heuristic bound used when deciding between compact and wrapped layouts. Default: `10`.
76    max_prefix_length: Option<usize>,
77    /// Insert a space before `(` for control-flow commands such as `if`, `foreach`, `while`.
78    space_before_control_paren: Option<bool>,
79    /// Insert a space before `(` for `function()` and `macro()` definitions.
80    space_before_definition_paren: Option<bool>,
81}
82
83#[derive(Debug, Clone, Deserialize, Default, schemars::JsonSchema)]
84#[serde(default)]
85#[serde(deny_unknown_fields)]
86struct StyleSection {
87    /// Output casing for command names: `lower`, `upper`, or `unchanged`.
88    command_case: Option<CaseStyle>,
89    /// Output casing for recognized keywords and flags: `lower`, `upper`, or `unchanged`.
90    keyword_case: Option<CaseStyle>,
91}
92
93#[derive(Debug, Clone, Deserialize, Default, schemars::JsonSchema)]
94#[serde(default)]
95#[serde(deny_unknown_fields)]
96struct MarkupSection {
97    /// Enable markup-aware comment handling.
98    enable_markup: Option<bool>,
99    /// Reflow plain line comments to fit within the configured line width.
100    reflow_comments: Option<bool>,
101    /// Preserve the first comment block in a file literally.
102    first_comment_is_literal: Option<bool>,
103    /// Regex for comments that should never be reflowed.
104    literal_comment_pattern: Option<String>,
105    /// Preferred bullet character when normalizing markup lists. Default: `*`.
106    bullet_char: Option<String>,
107    /// Preferred punctuation for numbered lists when normalizing markup. Default: `.`.
108    enum_char: Option<String>,
109    /// Regex describing fenced literal comment blocks.
110    fence_pattern: Option<String>,
111    /// Regex describing ruler-style comments that should be treated specially.
112    ruler_pattern: Option<String>,
113    /// Minimum ruler length before a hash-only line is treated as a ruler. Default: `10`.
114    hashruler_min_length: Option<usize>,
115    /// Normalize ruler comments when markup handling is enabled.
116    canonicalize_hashrulers: Option<bool>,
117    /// Regex pattern for inline comments explicitly trailing their preceding argument. Default: `#<`.
118    explicit_trailing_pattern: Option<String>,
119}
120
121const CONFIG_FILE_NAME_TOML: &str = ".cmakefmt.toml";
122const CONFIG_FILE_NAME_YAML: &str = ".cmakefmt.yaml";
123const CONFIG_FILE_NAME_YML: &str = ".cmakefmt.yml";
124const CONFIG_FILE_NAMES: &[&str] = &[
125    CONFIG_FILE_NAME_YAML,
126    CONFIG_FILE_NAME_YML,
127    CONFIG_FILE_NAME_TOML,
128];
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub(crate) enum ConfigFileFormat {
132    Toml,
133    Yaml,
134}
135
136impl ConfigFileFormat {
137    pub(crate) fn as_str(self) -> &'static str {
138        match self {
139            Self::Toml => "TOML",
140            Self::Yaml => "YAML",
141        }
142    }
143}
144
145/// Supported `--dump-config` output formats.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
147pub enum DumpConfigFormat {
148    /// Emit YAML.
149    Yaml,
150    /// Emit TOML.
151    Toml,
152}
153
154/// Render a commented starter config in the requested format.
155///
156/// The template is intentionally verbose: every option is introduced by a
157/// short explanatory comment so users can understand the surface without
158/// needing to cross-reference the docs immediately.
159pub fn default_config_template_for(format: DumpConfigFormat) -> String {
160    match format {
161        DumpConfigFormat::Yaml => default_config_template_yaml(),
162        DumpConfigFormat::Toml => default_config_template_toml(),
163    }
164}
165
166/// Render a resolved runtime config back into the user-facing config schema.
167///
168/// This is primarily used by CLI introspection commands such as
169/// `cmakefmt --show-config`.
170pub fn render_effective_config(config: &Config, format: DumpConfigFormat) -> Result<String> {
171    let view = EffectiveConfigFile::from(config);
172    match format {
173        DumpConfigFormat::Yaml => serde_yaml::to_string(&view).map_err(|err| {
174            Error::Formatter(format!("failed to render effective config as YAML: {err}"))
175        }),
176        DumpConfigFormat::Toml => toml::to_string_pretty(&view).map_err(|err| {
177            Error::Formatter(format!("failed to render effective config as TOML: {err}"))
178        }),
179    }
180}
181
182/// Render a commented starter config using the default user-facing dump format.
183pub fn default_config_template() -> String {
184    default_config_template_for(DumpConfigFormat::Yaml)
185}
186
187/// Generate a JSON Schema for the cmakefmt config file format.
188///
189/// The output is a pretty-printed JSON string suitable for publishing to
190/// `cmakefmt.dev/schemas/v{version}/schema.json`.
191pub fn generate_json_schema() -> String {
192    let schema = schemars::schema_for!(FileConfig);
193    serde_json::to_string_pretty(&schema).expect("JSON schema serialization failed")
194}
195
196fn default_config_template_toml() -> String {
197    format!(
198        concat!(
199            "# Default cmakefmt configuration.\n",
200            "# Copy this to .cmakefmt.toml and uncomment the optional settings\n",
201            "# you want to customize.\n\n",
202            "[format]\n",
203            "# Disable formatting entirely (return source unchanged).\n",
204            "# disable = true\n\n",
205            "# Output line-ending style: unix (LF), windows (CRLF), or auto (detect from input).\n",
206            "# line_ending = \"windows\"\n\n",
207            "# Maximum rendered line width before cmakefmt wraps a call.\n",
208            "line_width = {line_width}\n\n",
209            "# Number of spaces per indentation level when use_tabs is false.\n",
210            "tab_size = {tab_size}\n\n",
211            "# Indent with tab characters instead of spaces.\n",
212            "# use_tabs = true\n\n",
213            "# How to handle fractional indentation when use_tabs is true: use-space or round-up.\n",
214            "# fractional_tab_policy = \"round-up\"\n\n",
215            "# Maximum number of consecutive blank lines to preserve.\n",
216            "max_empty_lines = {max_empty_lines}\n\n",
217            "# Maximum wrapped lines to tolerate before switching to a more vertical layout.\n",
218            "max_hanging_wrap_lines = {max_lines_hwrap}\n\n",
219            "# Maximum positional arguments to keep in a hanging-wrap layout.\n",
220            "max_hanging_wrap_positional_args = {max_pargs_hwrap}\n\n",
221            "# Maximum keyword/flag subgroups to keep in a hanging-wrap layout.\n",
222            "max_hanging_wrap_groups = {max_subgroups_hwrap}\n\n",
223            "# Maximum rows a hanging-wrap positional group may consume before nesting is forced.\n",
224            "max_rows_cmdline = {max_rows_cmdline}\n\n",
225            "# Commands that must always use vertical (wrapped) layout.\n",
226            "# always_wrap = [\"target_link_libraries\"]\n\n",
227            "# Return an error if any formatted line exceeds line_width.\n",
228            "# require_valid_layout = true\n\n",
229            "# Put the closing ')' on its own line when a call wraps.\n",
230            "dangle_parens = {dangle_parens}\n\n",
231            "# Alignment strategy for a dangling ')': prefix, open, or close.\n",
232            "dangle_align = \"{dangle_align}\"\n\n",
233            "# Lower heuristic bound used when deciding between compact and wrapped layouts.\n",
234            "min_prefix_length = {min_prefix_chars}\n\n",
235            "# Upper heuristic bound used when deciding between compact and wrapped layouts.\n",
236            "max_prefix_length = {max_prefix_chars}\n\n",
237            "# Insert a space before '(' for control-flow commands like if/foreach.\n",
238            "# space_before_control_paren = true\n\n",
239            "# Insert a space before '(' for function() and macro() definitions.\n",
240            "# space_before_definition_paren = true\n\n",
241            "[style]\n",
242            "# Output casing for command names: lower, upper, or unchanged.\n",
243            "command_case = \"{command_case}\"\n\n",
244            "# Output casing for recognized keywords and flags: lower, upper, or unchanged.\n",
245            "keyword_case = \"{keyword_case}\"\n\n",
246            "[markup]\n",
247            "# Enable markup-aware comment handling.\n",
248            "enable_markup = {enable_markup}\n\n",
249            "# Reflow plain line comments to fit within the configured line width.\n",
250            "# reflow_comments = true\n\n",
251            "# Preserve the first comment block in a file literally.\n",
252            "first_comment_is_literal = {first_comment_is_literal}\n\n",
253            "# Preserve comments matching a custom regex literally.\n",
254            "# literal_comment_pattern = \"^\\\\s*NOTE:\"\n\n",
255            "# Preferred bullet character when normalizing markup lists.\n",
256            "bullet_char = \"{bullet_char}\"\n\n",
257            "# Preferred punctuation for numbered lists when normalizing markup.\n",
258            "enum_char = \"{enum_char}\"\n\n",
259            "# Regex describing fenced literal comment blocks.\n",
260            "fence_pattern = '{fence_pattern}'\n\n",
261            "# Regex describing ruler-style comments that should be treated specially.\n",
262            "ruler_pattern = '{ruler_pattern}'\n\n",
263            "# Minimum ruler length before a hash-only line is treated as a ruler.\n",
264            "hashruler_min_length = {hashruler_min_length}\n\n",
265            "# Normalize ruler comments when markup handling is enabled.\n",
266            "canonicalize_hashrulers = {canonicalize_hashrulers}\n\n",
267            "# Regex pattern for inline comments that are explicitly trailing their preceding\n",
268            "# argument (rendered on the same line). Default: '#<'.\n",
269            "# explicit_trailing_pattern = \"#<\"\n\n",
270            "# Uncomment and edit a block like this to override formatting knobs\n",
271            "# for a specific command. This changes layout behavior for that\n",
272            "# command name only; it does not define new command syntax.\n",
273            "#\n",
274            "# [per_command_overrides.my_custom_command]\n",
275            "# Override the line width just for this command.\n",
276            "# line_width = 120\n\n",
277            "# Override command casing just for this command.\n",
278            "# command_case = \"unchanged\"\n\n",
279            "# Override keyword casing just for this command.\n",
280            "# keyword_case = \"upper\"\n\n",
281            "# Override indentation width just for this command.\n",
282            "# tab_size = 4\n\n",
283            "# Override dangling-paren placement just for this command.\n",
284            "# dangle_parens = false\n\n",
285            "# Override dangling-paren alignment just for this command.\n",
286            "# dangle_align = \"prefix\"\n\n",
287            "# Override the positional-argument hanging-wrap threshold just for this command.\n",
288            "# max_hanging_wrap_positional_args = 8\n\n",
289            "# Override the subgroup hanging-wrap threshold just for this command.\n",
290            "# max_hanging_wrap_groups = 3\n\n",
291            "# TOML custom-command specs live under [commands.<name>]. For\n",
292            "# user config, prefer YAML once these specs grow beyond a couple\n",
293            "# of simple flags/kwargs.\n",
294            "# Command specs tell the formatter which tokens are positional\n",
295            "# arguments, standalone flags, and keyword sections.\n",
296            "#\n",
297            "# Example: one positional argument, one flag, and two keyword\n",
298            "# sections that each take one or more values.\n",
299            "#\n",
300            "# [commands.my_custom_command]\n",
301            "# pargs = 1\n",
302            "# flags = [\"QUIET\"]\n",
303            "# kwargs = {{ SOURCES = {{ nargs = \"+\" }}, LIBRARIES = {{ nargs = \"+\" }} }}\n",
304        ),
305        line_width = Config::default().line_width,
306        tab_size = Config::default().tab_size,
307        max_empty_lines = Config::default().max_empty_lines,
308        max_lines_hwrap = Config::default().max_lines_hwrap,
309        max_pargs_hwrap = Config::default().max_pargs_hwrap,
310        max_subgroups_hwrap = Config::default().max_subgroups_hwrap,
311        max_rows_cmdline = Config::default().max_rows_cmdline,
312        dangle_parens = Config::default().dangle_parens,
313        dangle_align = "prefix",
314        min_prefix_chars = Config::default().min_prefix_chars,
315        max_prefix_chars = Config::default().max_prefix_chars,
316        command_case = "lower",
317        keyword_case = "upper",
318        enable_markup = Config::default().enable_markup,
319        first_comment_is_literal = Config::default().first_comment_is_literal,
320        bullet_char = Config::default().bullet_char,
321        enum_char = Config::default().enum_char,
322        fence_pattern = Config::default().fence_pattern,
323        ruler_pattern = Config::default().ruler_pattern,
324        hashruler_min_length = Config::default().hashruler_min_length,
325        canonicalize_hashrulers = Config::default().canonicalize_hashrulers,
326    )
327}
328
329#[derive(Debug, Clone, Serialize)]
330struct EffectiveConfigFile {
331    format: EffectiveFormatSection,
332    style: EffectiveStyleSection,
333    markup: EffectiveMarkupSection,
334    per_command_overrides: HashMap<String, PerCommandConfig>,
335}
336
337#[derive(Debug, Clone, Serialize)]
338struct EffectiveFormatSection {
339    #[serde(skip_serializing_if = "std::ops::Not::not")]
340    disable: bool,
341    line_ending: LineEnding,
342    line_width: usize,
343    tab_size: usize,
344    use_tabs: bool,
345    fractional_tab_policy: FractionalTabPolicy,
346    max_empty_lines: usize,
347    max_hanging_wrap_lines: usize,
348    max_hanging_wrap_positional_args: usize,
349    max_hanging_wrap_groups: usize,
350    max_rows_cmdline: usize,
351    #[serde(skip_serializing_if = "Vec::is_empty")]
352    always_wrap: Vec<String>,
353    #[serde(skip_serializing_if = "std::ops::Not::not")]
354    require_valid_layout: bool,
355    dangle_parens: bool,
356    dangle_align: DangleAlign,
357    min_prefix_length: usize,
358    max_prefix_length: usize,
359    space_before_control_paren: bool,
360    space_before_definition_paren: bool,
361}
362
363#[derive(Debug, Clone, Serialize)]
364struct EffectiveStyleSection {
365    command_case: CaseStyle,
366    keyword_case: CaseStyle,
367}
368
369#[derive(Debug, Clone, Serialize)]
370struct EffectiveMarkupSection {
371    enable_markup: bool,
372    reflow_comments: bool,
373    first_comment_is_literal: bool,
374    literal_comment_pattern: String,
375    bullet_char: String,
376    enum_char: String,
377    fence_pattern: String,
378    ruler_pattern: String,
379    hashruler_min_length: usize,
380    canonicalize_hashrulers: bool,
381    explicit_trailing_pattern: String,
382}
383
384impl From<&Config> for EffectiveConfigFile {
385    fn from(config: &Config) -> Self {
386        Self {
387            format: EffectiveFormatSection {
388                disable: config.disable,
389                line_ending: config.line_ending,
390                line_width: config.line_width,
391                tab_size: config.tab_size,
392                use_tabs: config.use_tabchars,
393                fractional_tab_policy: config.fractional_tab_policy,
394                max_empty_lines: config.max_empty_lines,
395                max_hanging_wrap_lines: config.max_lines_hwrap,
396                max_hanging_wrap_positional_args: config.max_pargs_hwrap,
397                max_hanging_wrap_groups: config.max_subgroups_hwrap,
398                max_rows_cmdline: config.max_rows_cmdline,
399                always_wrap: config.always_wrap.clone(),
400                require_valid_layout: config.require_valid_layout,
401                dangle_parens: config.dangle_parens,
402                dangle_align: config.dangle_align,
403                min_prefix_length: config.min_prefix_chars,
404                max_prefix_length: config.max_prefix_chars,
405                space_before_control_paren: config.separate_ctrl_name_with_space,
406                space_before_definition_paren: config.separate_fn_name_with_space,
407            },
408            style: EffectiveStyleSection {
409                command_case: config.command_case,
410                keyword_case: config.keyword_case,
411            },
412            markup: EffectiveMarkupSection {
413                enable_markup: config.enable_markup,
414                reflow_comments: config.reflow_comments,
415                first_comment_is_literal: config.first_comment_is_literal,
416                literal_comment_pattern: config.literal_comment_pattern.clone(),
417                bullet_char: config.bullet_char.clone(),
418                enum_char: config.enum_char.clone(),
419                fence_pattern: config.fence_pattern.clone(),
420                ruler_pattern: config.ruler_pattern.clone(),
421                hashruler_min_length: config.hashruler_min_length,
422                canonicalize_hashrulers: config.canonicalize_hashrulers,
423                explicit_trailing_pattern: config.explicit_trailing_pattern.clone(),
424            },
425            per_command_overrides: config.per_command_overrides.clone(),
426        }
427    }
428}
429
430fn default_config_template_yaml() -> String {
431    format!(
432        concat!(
433            "# yaml-language-server: $schema=https://cmakefmt.dev/schemas/latest/schema.json\n",
434            "# Default cmakefmt configuration.\n",
435            "# Copy this to .cmakefmt.yaml and uncomment the optional settings\n",
436            "# you want to customize.\n\n",
437            "format:\n",
438            "  # Disable formatting entirely (return source unchanged).\n",
439            "  # disable: true\n\n",
440            "  # Output line-ending style: unix (LF), windows (CRLF), or auto (detect from input).\n",
441            "  # line_ending: windows\n\n",
442            "  # Maximum rendered line width before cmakefmt wraps a call.\n",
443            "  line_width: {line_width}\n\n",
444            "  # Number of spaces per indentation level when use_tabs is false.\n",
445            "  tab_size: {tab_size}\n\n",
446            "  # Indent with tab characters instead of spaces.\n",
447            "  # use_tabs: true\n\n",
448            "  # How to handle fractional indentation when use_tabs is true: use-space or round-up.\n",
449            "  # fractional_tab_policy: round-up\n\n",
450            "  # Maximum number of consecutive blank lines to preserve.\n",
451            "  max_empty_lines: {max_empty_lines}\n\n",
452            "  # Maximum wrapped lines to tolerate before switching to a more vertical layout.\n",
453            "  max_hanging_wrap_lines: {max_lines_hwrap}\n\n",
454            "  # Maximum positional arguments to keep in a hanging-wrap layout.\n",
455            "  max_hanging_wrap_positional_args: {max_pargs_hwrap}\n\n",
456            "  # Maximum keyword/flag subgroups to keep in a hanging-wrap layout.\n",
457            "  max_hanging_wrap_groups: {max_subgroups_hwrap}\n\n",
458            "  # Maximum rows a hanging-wrap positional group may consume before nesting is forced.\n",
459            "  max_rows_cmdline: {max_rows_cmdline}\n\n",
460            "  # Commands that must always use vertical (wrapped) layout.\n",
461            "  # always_wrap:\n",
462            "  #   - target_link_libraries\n\n",
463            "  # Return an error if any formatted line exceeds line_width.\n",
464            "  # require_valid_layout: true\n\n",
465            "  # Put the closing ')' on its own line when a call wraps.\n",
466            "  dangle_parens: {dangle_parens}\n\n",
467            "  # Alignment strategy for a dangling ')': prefix, open, or close.\n",
468            "  dangle_align: {dangle_align}\n\n",
469            "  # Lower heuristic bound used when deciding between compact and wrapped layouts.\n",
470            "  min_prefix_length: {min_prefix_chars}\n\n",
471            "  # Upper heuristic bound used when deciding between compact and wrapped layouts.\n",
472            "  max_prefix_length: {max_prefix_chars}\n\n",
473            "  # Insert a space before '(' for control-flow commands like if/foreach.\n",
474            "  # space_before_control_paren: true\n\n",
475            "  # Insert a space before '(' for function() and macro() definitions.\n",
476            "  # space_before_definition_paren: true\n\n",
477            "style:\n",
478            "  # Output casing for command names: lower, upper, or unchanged.\n",
479            "  command_case: {command_case}\n\n",
480            "  # Output casing for recognized keywords and flags: lower, upper, or unchanged.\n",
481            "  keyword_case: {keyword_case}\n\n",
482            "markup:\n",
483            "  # Enable markup-aware comment handling.\n",
484            "  enable_markup: {enable_markup}\n\n",
485            "  # Reflow plain line comments to fit within the configured line width.\n",
486            "  # reflow_comments: true\n\n",
487            "  # Preserve the first comment block in a file literally.\n",
488            "  first_comment_is_literal: {first_comment_is_literal}\n\n",
489            "  # Preserve comments matching a custom regex literally.\n",
490            "  # literal_comment_pattern: '^\\s*NOTE:'\n\n",
491            "  # Preferred bullet character when normalizing markup lists.\n",
492            "  bullet_char: '{bullet_char}'\n\n",
493            "  # Preferred punctuation for numbered lists when normalizing markup.\n",
494            "  enum_char: '{enum_char}'\n\n",
495            "  # Regex describing fenced literal comment blocks.\n",
496            "  fence_pattern: '{fence_pattern}'\n\n",
497            "  # Regex describing ruler-style comments that should be treated specially.\n",
498            "  ruler_pattern: '{ruler_pattern}'\n\n",
499            "  # Minimum ruler length before a hash-only line is treated as a ruler.\n",
500            "  hashruler_min_length: {hashruler_min_length}\n\n",
501            "  # Normalize ruler comments when markup handling is enabled.\n",
502            "  canonicalize_hashrulers: {canonicalize_hashrulers}\n\n",
503            "  # Regex pattern for inline comments that are explicitly trailing their preceding\n",
504            "  # argument (rendered on the same line). Default: '#<'.\n",
505            "  # explicit_trailing_pattern: '#<'\n\n",
506            "# Uncomment and edit a block like this to override formatting knobs\n",
507            "# for a specific command. This changes layout behavior for that\n",
508            "# command name only; it does not define new command syntax.\n",
509            "#\n",
510            "# per_command_overrides:\n",
511            "#   my_custom_command:\n",
512            "#     # Override the line width just for this command.\n",
513            "#     line_width: 120\n",
514            "#\n",
515            "#     # Override command casing just for this command.\n",
516            "#     command_case: unchanged\n",
517            "#\n",
518            "#     # Override keyword casing just for this command.\n",
519            "#     keyword_case: upper\n",
520            "#\n",
521            "#     # Override indentation width just for this command.\n",
522            "#     tab_size: 4\n",
523            "#\n",
524            "#     # Override dangling-paren placement just for this command.\n",
525            "#     dangle_parens: false\n",
526            "#\n",
527            "#     # Override dangling-paren alignment just for this command.\n",
528            "#     dangle_align: prefix\n",
529            "#\n",
530            "#     # Override the positional-argument hanging-wrap threshold just for this command.\n",
531            "#     max_hanging_wrap_positional_args: 8\n",
532            "#\n",
533            "#     # Override the subgroup hanging-wrap threshold just for this command.\n",
534            "#     max_hanging_wrap_groups: 3\n\n",
535            "# YAML custom-command specs live under commands:<name>. Command\n",
536            "# specs tell the formatter which tokens are positional arguments,\n",
537            "# standalone flags, and keyword sections.\n",
538            "#\n",
539            "# Example: one positional argument, one flag, and two keyword\n",
540            "# sections that each take one or more values.\n",
541            "#\n",
542            "# commands:\n",
543            "#   my_custom_command:\n",
544            "#     pargs: 1\n",
545            "#     flags:\n",
546            "#       - QUIET\n",
547            "#     kwargs:\n",
548            "#       SOURCES:\n",
549            "#         nargs: \"+\"\n",
550            "#       LIBRARIES:\n",
551            "#         nargs: \"+\"\n",
552        ),
553        line_width = Config::default().line_width,
554        tab_size = Config::default().tab_size,
555        max_empty_lines = Config::default().max_empty_lines,
556        max_lines_hwrap = Config::default().max_lines_hwrap,
557        max_pargs_hwrap = Config::default().max_pargs_hwrap,
558        max_subgroups_hwrap = Config::default().max_subgroups_hwrap,
559        max_rows_cmdline = Config::default().max_rows_cmdline,
560        dangle_parens = Config::default().dangle_parens,
561        dangle_align = "prefix",
562        min_prefix_chars = Config::default().min_prefix_chars,
563        max_prefix_chars = Config::default().max_prefix_chars,
564        command_case = "lower",
565        keyword_case = "upper",
566        enable_markup = Config::default().enable_markup,
567        first_comment_is_literal = Config::default().first_comment_is_literal,
568        bullet_char = Config::default().bullet_char,
569        enum_char = Config::default().enum_char,
570        fence_pattern = Config::default().fence_pattern,
571        ruler_pattern = Config::default().ruler_pattern,
572        hashruler_min_length = Config::default().hashruler_min_length,
573        canonicalize_hashrulers = Config::default().canonicalize_hashrulers,
574    )
575}
576
577impl Config {
578    /// Load configuration for a file at the given path.
579    ///
580    /// Searches for the nearest supported user config (`.cmakefmt.yaml`,
581    /// `.cmakefmt.yml`, then `.cmakefmt.toml`) starting from the file's
582    /// directory and walking up to the repository/filesystem root. If none is
583    /// found, falls back to the same filenames in the home directory.
584    pub fn for_file(file_path: &Path) -> Result<Self> {
585        let config_paths = find_config_files(file_path);
586        Self::from_files(&config_paths)
587    }
588
589    /// Load configuration from a specific supported config file.
590    pub fn from_file(path: &Path) -> Result<Self> {
591        let paths = [path.to_path_buf()];
592        Self::from_files(&paths)
593    }
594
595    /// Load configuration by merging several supported config files in order.
596    ///
597    /// Later files override earlier files.
598    pub fn from_files(paths: &[PathBuf]) -> Result<Self> {
599        let mut config = Config::default();
600        for path in paths {
601            let file_config = load_config_file(path)?;
602            config.apply(file_config);
603        }
604        config.validate_patterns().map_err(Error::Formatter)?;
605        Ok(config)
606    }
607
608    /// Return the config files that would be applied for the given file.
609    ///
610    /// When config discovery is used, this is either the nearest
611    /// supported config file found by walking upward from the file, or a home
612    /// directory config if no nearer config exists.
613    pub fn config_sources_for(file_path: &Path) -> Vec<PathBuf> {
614        find_config_files(file_path)
615    }
616
617    fn apply(&mut self, fc: FileConfig) {
618        // Format section
619        if let Some(v) = fc.format.disable {
620            self.disable = v;
621        }
622        if let Some(v) = fc.format.line_ending {
623            self.line_ending = v;
624        }
625        if let Some(v) = fc.format.line_width {
626            self.line_width = v;
627        }
628        if let Some(v) = fc.format.tab_size {
629            self.tab_size = v;
630        }
631        if let Some(v) = fc.format.use_tabs {
632            self.use_tabchars = v;
633        }
634        if let Some(v) = fc.format.fractional_tab_policy {
635            self.fractional_tab_policy = v;
636        }
637        if let Some(v) = fc.format.max_empty_lines {
638            self.max_empty_lines = v;
639        }
640        if let Some(v) = fc.format.max_hanging_wrap_lines {
641            self.max_lines_hwrap = v;
642        }
643        if let Some(v) = fc.format.max_hanging_wrap_positional_args {
644            self.max_pargs_hwrap = v;
645        }
646        if let Some(v) = fc.format.max_hanging_wrap_groups {
647            self.max_subgroups_hwrap = v;
648        }
649        if let Some(v) = fc.format.max_rows_cmdline {
650            self.max_rows_cmdline = v;
651        }
652        if let Some(v) = fc.format.always_wrap {
653            self.always_wrap = v.into_iter().map(|s| s.to_ascii_lowercase()).collect();
654        }
655        if let Some(v) = fc.format.require_valid_layout {
656            self.require_valid_layout = v;
657        }
658        if let Some(v) = fc.format.dangle_parens {
659            self.dangle_parens = v;
660        }
661        if let Some(v) = fc.format.dangle_align {
662            self.dangle_align = v;
663        }
664        if let Some(v) = fc.format.min_prefix_length {
665            self.min_prefix_chars = v;
666        }
667        if let Some(v) = fc.format.max_prefix_length {
668            self.max_prefix_chars = v;
669        }
670        if let Some(v) = fc.format.space_before_control_paren {
671            self.separate_ctrl_name_with_space = v;
672        }
673        if let Some(v) = fc.format.space_before_definition_paren {
674            self.separate_fn_name_with_space = v;
675        }
676
677        // Style section
678        if let Some(v) = fc.style.command_case {
679            self.command_case = v;
680        }
681        if let Some(v) = fc.style.keyword_case {
682            self.keyword_case = v;
683        }
684
685        // Markup section
686        if let Some(v) = fc.markup.enable_markup {
687            self.enable_markup = v;
688        }
689        if let Some(v) = fc.markup.reflow_comments {
690            self.reflow_comments = v;
691        }
692        if let Some(v) = fc.markup.first_comment_is_literal {
693            self.first_comment_is_literal = v;
694        }
695        if let Some(v) = fc.markup.literal_comment_pattern {
696            self.literal_comment_pattern = v;
697        }
698        if let Some(v) = fc.markup.bullet_char {
699            self.bullet_char = v;
700        }
701        if let Some(v) = fc.markup.enum_char {
702            self.enum_char = v;
703        }
704        if let Some(v) = fc.markup.fence_pattern {
705            self.fence_pattern = v;
706        }
707        if let Some(v) = fc.markup.ruler_pattern {
708            self.ruler_pattern = v;
709        }
710        if let Some(v) = fc.markup.hashruler_min_length {
711            self.hashruler_min_length = v;
712        }
713        if let Some(v) = fc.markup.canonicalize_hashrulers {
714            self.canonicalize_hashrulers = v;
715        }
716        if let Some(v) = fc.markup.explicit_trailing_pattern {
717            self.explicit_trailing_pattern = v;
718        }
719
720        // Per-command overrides (merge, don't replace)
721        for (name, overrides) in fc.per_command_overrides {
722            self.per_command_overrides.insert(name, overrides);
723        }
724    }
725}
726
727fn load_config_file(path: &Path) -> Result<FileConfig> {
728    let contents = std::fs::read_to_string(path).map_err(Error::Io)?;
729    let config: FileConfig = match detect_config_format(path)? {
730        ConfigFileFormat::Toml => toml::from_str(&contents).map_err(|source| {
731            let (line, column) = toml_line_col(&contents, source.span().map(|span| span.start));
732            Error::Config {
733                path: path.to_path_buf(),
734                details: FileParseError {
735                    format: ConfigFileFormat::Toml.as_str(),
736                    message: source.to_string().into_boxed_str(),
737                    line,
738                    column,
739                },
740                source_message: source.to_string().into_boxed_str(),
741            }
742        }),
743        ConfigFileFormat::Yaml => serde_yaml::from_str(&contents).map_err(|source| {
744            let location = source.location();
745            let line = location.as_ref().map(|loc| loc.line());
746            let column = location.as_ref().map(|loc| loc.column());
747            Error::Config {
748                path: path.to_path_buf(),
749                details: FileParseError {
750                    format: ConfigFileFormat::Yaml.as_str(),
751                    message: source.to_string().into_boxed_str(),
752                    line,
753                    column,
754                },
755                source_message: source.to_string().into_boxed_str(),
756            }
757        }),
758    }?;
759
760    if !config.legacy_per_command.is_empty() {
761        return Err(Error::Formatter(format!(
762            "{}: `per_command` has been renamed to `per_command_overrides`",
763            path.display()
764        )));
765    }
766
767    Ok(config)
768}
769
770/// Find the config files that apply to `file_path`.
771///
772/// The nearest supported config discovered while walking upward wins. If
773/// multiple supported config filenames exist in the same directory, YAML is
774/// preferred over TOML. If no project-local config is found, the user home
775/// config is returned when present.
776fn find_config_files(file_path: &Path) -> Vec<PathBuf> {
777    let start_dir = if file_path.is_dir() {
778        file_path.to_path_buf()
779    } else {
780        file_path
781            .parent()
782            .map(Path::to_path_buf)
783            .unwrap_or_else(|| PathBuf::from("."))
784    };
785
786    let mut dir = Some(start_dir.as_path());
787    while let Some(d) = dir {
788        if let Some(candidate) = preferred_config_in_dir(d) {
789            return vec![candidate];
790        }
791
792        if d.join(".git").exists() {
793            break;
794        }
795
796        dir = d.parent();
797    }
798
799    if let Some(home) = home_dir() {
800        if let Some(home_config) = preferred_config_in_dir(&home) {
801            return vec![home_config];
802        }
803    }
804
805    Vec::new()
806}
807
808pub(crate) fn detect_config_format(path: &Path) -> Result<ConfigFileFormat> {
809    let file_name = path
810        .file_name()
811        .and_then(|name| name.to_str())
812        .unwrap_or_default();
813    if file_name == CONFIG_FILE_NAME_TOML
814        || path.extension().and_then(|ext| ext.to_str()) == Some("toml")
815    {
816        return Ok(ConfigFileFormat::Toml);
817    }
818    if matches!(file_name, CONFIG_FILE_NAME_YAML | CONFIG_FILE_NAME_YML)
819        || matches!(
820            path.extension().and_then(|ext| ext.to_str()),
821            Some("yaml" | "yml")
822        )
823    {
824        return Ok(ConfigFileFormat::Yaml);
825    }
826
827    Err(Error::Formatter(format!(
828        "{}: unsupported config format; use .cmakefmt.yaml, .cmakefmt.yml, or .cmakefmt.toml",
829        path.display()
830    )))
831}
832
833fn preferred_config_in_dir(dir: &Path) -> Option<PathBuf> {
834    CONFIG_FILE_NAMES
835        .iter()
836        .map(|name| dir.join(name))
837        .find(|candidate| candidate.is_file())
838}
839
840pub(crate) fn toml_line_col(
841    contents: &str,
842    offset: Option<usize>,
843) -> (Option<usize>, Option<usize>) {
844    let Some(offset) = offset else {
845        return (None, None);
846    };
847    let mut line = 1usize;
848    let mut column = 1usize;
849    for (index, ch) in contents.char_indices() {
850        if index >= offset {
851            break;
852        }
853        if ch == '\n' {
854            line += 1;
855            column = 1;
856        } else {
857            column += 1;
858        }
859    }
860    (Some(line), Some(column))
861}
862
863fn home_dir() -> Option<PathBuf> {
864    std::env::var_os("HOME")
865        .or_else(|| std::env::var_os("USERPROFILE"))
866        .map(PathBuf::from)
867}
868
869#[cfg(test)]
870mod tests {
871    use super::*;
872    use std::fs;
873
874    #[test]
875    fn parse_empty_config() {
876        let config: FileConfig = toml::from_str("").unwrap();
877        assert!(config.format.line_width.is_none());
878    }
879
880    #[test]
881    fn parse_full_config() {
882        let toml_str = r#"
883[format]
884line_width = 120
885tab_size = 4
886use_tabs = true
887max_empty_lines = 2
888dangle_parens = true
889dangle_align = "open"
890space_before_control_paren = true
891space_before_definition_paren = true
892max_hanging_wrap_positional_args = 3
893max_hanging_wrap_groups = 1
894
895[style]
896command_case = "upper"
897keyword_case = "lower"
898
899[markup]
900enable_markup = false
901hashruler_min_length = 20
902
903[per_command_overrides.message]
904dangle_parens = true
905line_width = 100
906"#;
907        let config: FileConfig = toml::from_str(toml_str).unwrap();
908        assert_eq!(config.format.line_width, Some(120));
909        assert_eq!(config.format.tab_size, Some(4));
910        assert_eq!(config.format.use_tabs, Some(true));
911        assert_eq!(config.format.dangle_parens, Some(true));
912        assert_eq!(config.format.dangle_align, Some(DangleAlign::Open));
913        assert_eq!(config.style.command_case, Some(CaseStyle::Upper));
914        assert_eq!(config.style.keyword_case, Some(CaseStyle::Lower));
915        assert_eq!(config.markup.enable_markup, Some(false));
916
917        let msg = config.per_command_overrides.get("message").unwrap();
918        assert_eq!(msg.dangle_parens, Some(true));
919        assert_eq!(msg.line_width, Some(100));
920    }
921
922    #[test]
923    fn old_format_key_aliases_are_rejected() {
924        let toml_str = r#"
925[format]
926use_tabchars = true
927max_lines_hwrap = 4
928max_pargs_hwrap = 3
929max_subgroups_hwrap = 2
930min_prefix_chars = 5
931max_prefix_chars = 11
932separate_ctrl_name_with_space = true
933separate_fn_name_with_space = true
934"#;
935        let err = toml::from_str::<FileConfig>(toml_str)
936            .unwrap_err()
937            .to_string();
938        assert!(err.contains("unknown field"));
939    }
940
941    #[test]
942    fn config_from_file_applies_overrides() {
943        let dir = tempfile::tempdir().unwrap();
944        let config_path = dir.path().join(CONFIG_FILE_NAME_TOML);
945        fs::write(
946            &config_path,
947            r#"
948[format]
949line_width = 100
950tab_size = 4
951
952[style]
953command_case = "upper"
954"#,
955        )
956        .unwrap();
957
958        let config = Config::from_file(&config_path).unwrap();
959        assert_eq!(config.line_width, 100);
960        assert_eq!(config.tab_size, 4);
961        assert_eq!(config.command_case, CaseStyle::Upper);
962        // Unspecified values keep defaults
963        assert!(!config.use_tabchars);
964        assert_eq!(config.max_empty_lines, 1);
965    }
966
967    #[test]
968    fn default_yaml_config_template_parses() {
969        let template = default_config_template();
970        let parsed: FileConfig = serde_yaml::from_str(&template).unwrap();
971        assert_eq!(parsed.format.line_width, Some(Config::default().line_width));
972        assert_eq!(
973            parsed.style.command_case,
974            Some(Config::default().command_case)
975        );
976        assert_eq!(
977            parsed.markup.enable_markup,
978            Some(Config::default().enable_markup)
979        );
980    }
981
982    #[test]
983    fn toml_config_template_parses() {
984        let template = default_config_template_for(DumpConfigFormat::Toml);
985        let parsed: FileConfig = toml::from_str(&template).unwrap();
986        assert_eq!(parsed.format.line_width, Some(Config::default().line_width));
987        assert_eq!(
988            parsed.style.command_case,
989            Some(Config::default().command_case)
990        );
991        assert_eq!(
992            parsed.markup.enable_markup,
993            Some(Config::default().enable_markup)
994        );
995    }
996
997    #[test]
998    fn missing_config_file_uses_defaults() {
999        let dir = tempfile::tempdir().unwrap();
1000        let fake_file = dir.path().join("CMakeLists.txt");
1001        fs::write(&fake_file, "").unwrap();
1002
1003        let config = Config::for_file(&fake_file).unwrap();
1004        assert_eq!(config, Config::default());
1005    }
1006
1007    #[test]
1008    fn config_file_in_parent_is_found() {
1009        let dir = tempfile::tempdir().unwrap();
1010        // Create a .git dir to act as root
1011        fs::create_dir(dir.path().join(".git")).unwrap();
1012        fs::write(
1013            dir.path().join(CONFIG_FILE_NAME_TOML),
1014            "[format]\nline_width = 120\n",
1015        )
1016        .unwrap();
1017
1018        let subdir = dir.path().join("src");
1019        fs::create_dir(&subdir).unwrap();
1020        let file = subdir.join("CMakeLists.txt");
1021        fs::write(&file, "").unwrap();
1022
1023        let config = Config::for_file(&file).unwrap();
1024        assert_eq!(config.line_width, 120);
1025    }
1026
1027    #[test]
1028    fn closer_config_wins() {
1029        let dir = tempfile::tempdir().unwrap();
1030        fs::create_dir(dir.path().join(".git")).unwrap();
1031        fs::write(
1032            dir.path().join(CONFIG_FILE_NAME_TOML),
1033            "[format]\nline_width = 120\ntab_size = 4\n",
1034        )
1035        .unwrap();
1036
1037        let subdir = dir.path().join("src");
1038        fs::create_dir(&subdir).unwrap();
1039        fs::write(
1040            subdir.join(CONFIG_FILE_NAME_TOML),
1041            "[format]\nline_width = 100\n",
1042        )
1043        .unwrap();
1044
1045        let file = subdir.join("CMakeLists.txt");
1046        fs::write(&file, "").unwrap();
1047
1048        let config = Config::for_file(&file).unwrap();
1049        // Only the nearest config is used automatically.
1050        assert_eq!(config.line_width, 100);
1051        assert_eq!(config.tab_size, Config::default().tab_size);
1052    }
1053
1054    #[test]
1055    fn from_files_merges_in_order() {
1056        let dir = tempfile::tempdir().unwrap();
1057        let first = dir.path().join("first.toml");
1058        let second = dir.path().join("second.toml");
1059        fs::write(&first, "[format]\nline_width = 120\ntab_size = 4\n").unwrap();
1060        fs::write(&second, "[format]\nline_width = 100\n").unwrap();
1061
1062        let config = Config::from_files(&[first, second]).unwrap();
1063        assert_eq!(config.line_width, 100);
1064        assert_eq!(config.tab_size, 4);
1065    }
1066
1067    #[test]
1068    fn yaml_config_from_file_applies_overrides() {
1069        let dir = tempfile::tempdir().unwrap();
1070        let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1071        fs::write(
1072            &config_path,
1073            "format:\n  line_width: 100\n  tab_size: 4\nstyle:\n  command_case: upper\n",
1074        )
1075        .unwrap();
1076
1077        let config = Config::from_file(&config_path).unwrap();
1078        assert_eq!(config.line_width, 100);
1079        assert_eq!(config.tab_size, 4);
1080        assert_eq!(config.command_case, CaseStyle::Upper);
1081    }
1082
1083    #[test]
1084    fn yml_config_from_file_applies_overrides() {
1085        let dir = tempfile::tempdir().unwrap();
1086        let config_path = dir.path().join(CONFIG_FILE_NAME_YML);
1087        fs::write(
1088            &config_path,
1089            "style:\n  keyword_case: lower\nformat:\n  line_width: 90\n",
1090        )
1091        .unwrap();
1092
1093        let config = Config::from_file(&config_path).unwrap();
1094        assert_eq!(config.line_width, 90);
1095        assert_eq!(config.keyword_case, CaseStyle::Lower);
1096    }
1097
1098    #[test]
1099    fn yaml_is_preferred_over_toml_during_discovery() {
1100        let dir = tempfile::tempdir().unwrap();
1101        fs::create_dir(dir.path().join(".git")).unwrap();
1102        fs::write(
1103            dir.path().join(CONFIG_FILE_NAME_TOML),
1104            "[format]\nline_width = 120\n",
1105        )
1106        .unwrap();
1107        fs::write(
1108            dir.path().join(CONFIG_FILE_NAME_YAML),
1109            "format:\n  line_width: 90\n",
1110        )
1111        .unwrap();
1112
1113        let file = dir.path().join("CMakeLists.txt");
1114        fs::write(&file, "").unwrap();
1115
1116        let config = Config::for_file(&file).unwrap();
1117        assert_eq!(config.line_width, 90);
1118        assert_eq!(
1119            Config::config_sources_for(&file),
1120            vec![dir.path().join(CONFIG_FILE_NAME_YAML)]
1121        );
1122    }
1123
1124    #[test]
1125    fn invalid_config_returns_error() {
1126        let dir = tempfile::tempdir().unwrap();
1127        let path = dir.path().join(CONFIG_FILE_NAME_TOML);
1128        fs::write(&path, "this is not valid toml {{{").unwrap();
1129
1130        let result = Config::from_file(&path);
1131        assert!(result.is_err());
1132        let err = result.unwrap_err();
1133        assert!(err.to_string().contains("config error"));
1134    }
1135
1136    #[test]
1137    fn config_from_yaml_file_applies_all_sections_and_overrides() {
1138        let dir = tempfile::tempdir().unwrap();
1139        let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1140        fs::write(
1141            &config_path,
1142            r#"
1143format:
1144  line_width: 96
1145  tab_size: 3
1146  use_tabs: true
1147  max_empty_lines: 2
1148  max_hanging_wrap_lines: 4
1149  max_hanging_wrap_positional_args: 7
1150  max_hanging_wrap_groups: 5
1151  dangle_parens: true
1152  dangle_align: open
1153  min_prefix_length: 2
1154  max_prefix_length: 12
1155  space_before_control_paren: true
1156  space_before_definition_paren: true
1157style:
1158  command_case: unchanged
1159  keyword_case: lower
1160markup:
1161  enable_markup: false
1162  reflow_comments: true
1163  first_comment_is_literal: false
1164  literal_comment_pattern: '^\\s*KEEP'
1165  bullet_char: '-'
1166  enum_char: ')'
1167  fence_pattern: '^\\s*(```+).*'
1168  ruler_pattern: '^\\s*={5,}\\s*$'
1169  hashruler_min_length: 42
1170  canonicalize_hashrulers: false
1171per_command_overrides:
1172  my_custom_command:
1173    line_width: 101
1174    tab_size: 5
1175    dangle_parens: false
1176    dangle_align: prefix
1177    max_hanging_wrap_positional_args: 8
1178    max_hanging_wrap_groups: 9
1179"#,
1180        )
1181        .unwrap();
1182
1183        let config = Config::from_file(&config_path).unwrap();
1184        assert_eq!(config.line_width, 96);
1185        assert_eq!(config.tab_size, 3);
1186        assert!(config.use_tabchars);
1187        assert_eq!(config.max_empty_lines, 2);
1188        assert_eq!(config.max_lines_hwrap, 4);
1189        assert_eq!(config.max_pargs_hwrap, 7);
1190        assert_eq!(config.max_subgroups_hwrap, 5);
1191        assert!(config.dangle_parens);
1192        assert_eq!(config.dangle_align, DangleAlign::Open);
1193        assert_eq!(config.min_prefix_chars, 2);
1194        assert_eq!(config.max_prefix_chars, 12);
1195        assert!(config.separate_ctrl_name_with_space);
1196        assert!(config.separate_fn_name_with_space);
1197        assert_eq!(config.command_case, CaseStyle::Unchanged);
1198        assert_eq!(config.keyword_case, CaseStyle::Lower);
1199        assert!(!config.enable_markup);
1200        assert!(config.reflow_comments);
1201        assert!(!config.first_comment_is_literal);
1202        assert_eq!(config.literal_comment_pattern, "^\\\\s*KEEP");
1203        assert_eq!(config.bullet_char, "-");
1204        assert_eq!(config.enum_char, ")");
1205        assert_eq!(config.fence_pattern, "^\\\\s*(```+).*");
1206        assert_eq!(config.ruler_pattern, "^\\\\s*={5,}\\\\s*$");
1207        assert_eq!(config.hashruler_min_length, 42);
1208        assert!(!config.canonicalize_hashrulers);
1209        let per_command = config
1210            .per_command_overrides
1211            .get("my_custom_command")
1212            .unwrap();
1213        assert_eq!(per_command.line_width, Some(101));
1214        assert_eq!(per_command.tab_size, Some(5));
1215        assert_eq!(per_command.dangle_parens, Some(false));
1216        assert_eq!(per_command.dangle_align, Some(DangleAlign::Prefix));
1217        assert_eq!(per_command.max_pargs_hwrap, Some(8));
1218        assert_eq!(per_command.max_subgroups_hwrap, Some(9));
1219    }
1220
1221    #[test]
1222    fn detect_config_format_supports_yaml_and_rejects_unknown() {
1223        assert!(matches!(
1224            detect_config_format(Path::new(".cmakefmt.yml")).unwrap(),
1225            ConfigFileFormat::Yaml
1226        ));
1227        assert!(matches!(
1228            detect_config_format(Path::new("tooling/settings.yaml")).unwrap(),
1229            ConfigFileFormat::Yaml
1230        ));
1231        assert!(matches!(
1232            detect_config_format(Path::new("project.toml")).unwrap(),
1233            ConfigFileFormat::Toml
1234        ));
1235        let err = detect_config_format(Path::new("config.json")).unwrap_err();
1236        assert!(err.to_string().contains("unsupported config format"));
1237    }
1238
1239    #[test]
1240    fn yaml_config_with_legacy_per_command_key_is_rejected() {
1241        let dir = tempfile::tempdir().unwrap();
1242        let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1243        fs::write(
1244            &config_path,
1245            "per_command:\n  message:\n    line_width: 120\n",
1246        )
1247        .unwrap();
1248        let err = Config::from_file(&config_path).unwrap_err();
1249        assert!(err
1250            .to_string()
1251            .contains("`per_command` has been renamed to `per_command_overrides`"));
1252    }
1253
1254    #[test]
1255    fn invalid_yaml_reports_line_and_column() {
1256        let dir = tempfile::tempdir().unwrap();
1257        let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1258        fs::write(&config_path, "format:\n  line_width: [\n").unwrap();
1259
1260        let err = Config::from_file(&config_path).unwrap_err();
1261        match err {
1262            Error::Config { details, .. } => {
1263                assert_eq!(details.format, "YAML");
1264                assert!(details.line.is_some());
1265                assert!(details.column.is_some());
1266            }
1267            other => panic!("expected config parse error, got {other:?}"),
1268        }
1269    }
1270
1271    #[test]
1272    fn toml_line_col_returns_none_when_offset_is_missing() {
1273        assert_eq!(toml_line_col("line = true\n", None), (None, None));
1274    }
1275}