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