Skip to main content

patchloom/
config.rs

1//! Project configuration file support (.patchloom.toml).
2//!
3//! Searches from the working directory upward for a `.patchloom.toml` file and
4//! parses it into [`ProjectConfig`]. CLI flags override config file values.
5
6use serde::Deserialize;
7use std::path::{Path, PathBuf};
8
9// Note: crate::write::WritePolicyOverride is used for plan serialization (forward-compatible).
10// WritePolicyConfig (below) is used for .patchloom.toml parsing (typo-catching).
11
12/// Config-specific write policy with strict field validation.
13///
14/// This type is used exclusively by `.patchloom.toml` parsing. It has
15/// `deny_unknown_fields` to catch typos like `ensur_final_newline`.
16///
17/// For plan serialization (tx plans, MCP), use [`WritePolicyOverride`] which
18/// omits `deny_unknown_fields` for forward compatibility.
19///
20/// [`WritePolicyOverride`]: crate::write::WritePolicyOverride
21#[derive(Debug, Default, Deserialize)]
22#[serde(default, deny_unknown_fields)]
23pub struct WritePolicyConfig {
24    pub ensure_final_newline: Option<bool>,
25    pub normalize_eol: Option<String>,
26    pub trim_trailing_whitespace: Option<bool>,
27    pub collapse_blanks: Option<bool>,
28    pub respect_editorconfig: Option<bool>,
29}
30
31impl From<WritePolicyConfig> for crate::write::WritePolicyOverride {
32    fn from(c: WritePolicyConfig) -> Self {
33        Self {
34            ensure_final_newline: c.ensure_final_newline,
35            normalize_eol: c.normalize_eol,
36            trim_trailing_whitespace: c.trim_trailing_whitespace,
37            collapse_blanks: c.collapse_blanks,
38            respect_editorconfig: c.respect_editorconfig,
39        }
40    }
41}
42
43/// Project-level configuration loaded from `.patchloom.toml`.
44#[derive(Debug, Default, Deserialize)]
45#[serde(default, deny_unknown_fields)]
46pub struct ProjectConfig {
47    pub write_policy: WritePolicyConfig,
48    pub exclude: Exclude,
49    pub output: Output,
50    pub tx: TxConfig,
51    pub defaults: Defaults,
52    pub format: FormatConfig,
53}
54
55#[derive(Debug, Default, Deserialize)]
56#[serde(default, deny_unknown_fields)]
57pub struct Defaults {
58    /// Default to --apply mode. When true, write commands apply changes without needing --apply flag.
59    pub apply: Option<bool>,
60    /// Default format command (e.g., "cargo fmt"). Applied after --apply unless overridden by CLI --format.
61    pub format: Option<String>,
62}
63
64/// Format configuration: per-extension formatters and auto-format toggle.
65#[derive(Debug, Default, Clone, Deserialize)]
66#[serde(default, deny_unknown_fields)]
67pub struct FormatConfig {
68    /// When true, run formatters automatically after --apply without needing --format.
69    pub auto: Option<bool>,
70    /// Single catch-all formatter command (runs on the entire cwd).
71    pub command: Option<String>,
72    /// Per-extension formatter commands. Keys are file extensions (without dot),
73    /// values are shell commands. The file path is appended as the last argument.
74    #[serde(default)]
75    pub by_extension: std::collections::HashMap<String, String>,
76}
77
78#[derive(Debug, Default, Deserialize)]
79#[serde(default, deny_unknown_fields)]
80pub struct TxConfig {
81    pub strict: Option<bool>,
82}
83
84#[derive(Debug, Default, Deserialize)]
85#[serde(default, deny_unknown_fields)]
86pub struct Exclude {
87    #[serde(default)]
88    pub globs: Vec<String>,
89}
90
91#[derive(Debug, Default, Deserialize)]
92#[serde(default, deny_unknown_fields)]
93pub struct Output {
94    pub color: Option<String>,
95}
96
97/// Search for `.patchloom.toml` starting from `start` and walking up to the
98/// filesystem root. Returns the parsed config and its directory, or `None` if
99/// no config file is found.
100pub fn find_and_load(start: &Path) -> Option<(ProjectConfig, PathBuf)> {
101    let mut dir = start.to_path_buf();
102    loop {
103        let candidate = dir.join(".patchloom.toml");
104        if candidate.is_file() {
105            let content = match std::fs::read_to_string(&candidate) {
106                Ok(c) => c,
107                Err(e) => {
108                    eprintln!("warning: could not read {}: {}", candidate.display(), e);
109                    return None;
110                }
111            };
112            match toml_edit::de::from_str::<ProjectConfig>(&content) {
113                Ok(config) => return Some((config, dir)),
114                Err(e) => {
115                    eprintln!("warning: malformed {}: {}", candidate.display(), e);
116                    return None;
117                }
118            }
119        }
120        // Stop at repo root to avoid loading configs from parent directories.
121        if dir.join(".git").exists() {
122            return None;
123        }
124        if !dir.pop() {
125            return None;
126        }
127    }
128}
129
130/// Merge a [`ProjectConfig`] into [`GlobalFlags`](crate::cli::global::GlobalFlags), applying config values only
131/// where the CLI did not explicitly set them.
132///
133/// Apply config values as defaults into GlobalFlags (CLI flags still win where set).
134/// Available for library tx execution as well as CLI.
135pub fn apply_config(global: &mut crate::cli::global::GlobalFlags, config: &ProjectConfig) {
136    // Write policy: config provides defaults, CLI flags win.
137    if !global.ensure_final_newline && config.write_policy.ensure_final_newline == Some(true) {
138        global.ensure_final_newline = true;
139    }
140    if global.normalize_eol.is_none() {
141        match config.write_policy.normalize_eol.as_deref() {
142            Some("lf") => global.normalize_eol = Some(crate::cli::global::EolMode::Lf),
143            Some("crlf") => global.normalize_eol = Some(crate::cli::global::EolMode::Crlf),
144            Some("cr") => global.normalize_eol = Some(crate::cli::global::EolMode::Cr),
145            Some("keep") => {} // explicit keep = default behavior, no-op
146            Some(invalid) => {
147                eprintln!("{}", invalid_normalize_eol_warning(invalid));
148            }
149            None => {}
150        }
151    }
152    if !global.trim_trailing_whitespace
153        && config.write_policy.trim_trailing_whitespace == Some(true)
154    {
155        global.trim_trailing_whitespace = true;
156    }
157    if !global.collapse_blanks && config.write_policy.collapse_blanks == Some(true) {
158        global.collapse_blanks = true;
159    }
160    if !global.respect_editorconfig && config.write_policy.respect_editorconfig == Some(true) {
161        global.respect_editorconfig = true;
162    }
163
164    // Defaults: config provides defaults, CLI flags win.
165    // Only apply config's defaults.apply if no explicit mode flag was set.
166    // If the user passed --check or --diff, they explicitly want a non-apply mode
167    // and the config should not override that.
168    if !global.apply
169        && !global.check
170        && !global.diff
171        && !global.confirm
172        && config.defaults.apply == Some(true)
173    {
174        global.apply = true;
175    }
176    if global.format.is_none() {
177        // CLI --format wins, then defaults.format, then format.command (with auto=true)
178        if let Some(ref fmt) = config.defaults.format {
179            global.format = Some(fmt.clone());
180        } else if config.format.auto == Some(true)
181            && let Some(ref cmd) = config.format.command
182        {
183            global.format = Some(cmd.clone());
184        }
185    }
186
187    // Store per-extension format config so run_format_command can use it.
188    if config.format.auto == Some(true)
189        && (!config.format.by_extension.is_empty() || config.format.command.is_some())
190    {
191        global.format_config = Some(config.format.clone());
192    }
193
194    // Exclude globs: prepend config globs before user excludes.
195    if !config.exclude.globs.is_empty() {
196        let mut merged = config.exclude.globs.clone();
197        merged.append(&mut global.exclude);
198        global.exclude = merged;
199    }
200
201    // Color: config provides default, CLI wins.
202    if matches!(global.color, crate::cli::global::ColorMode::Auto)
203        && let Some(ref color) = config.output.color
204    {
205        global.color = match color.as_str() {
206            "always" => crate::cli::global::ColorMode::Always,
207            "never" => crate::cli::global::ColorMode::Never,
208            "auto" => crate::cli::global::ColorMode::Auto,
209            invalid => {
210                eprintln!("{}", invalid_output_color_warning(invalid));
211                crate::cli::global::ColorMode::Auto
212            }
213        };
214    }
215}
216
217fn invalid_normalize_eol_warning(invalid: &str) -> String {
218    format!(
219        "warning: invalid write_policy.normalize_eol value {invalid:?}; expected \"lf\", \"crlf\", or \"cr\""
220    )
221}
222
223fn invalid_output_color_warning(invalid: &str) -> String {
224    format!(
225        "warning: invalid output.color value {invalid:?}; expected \"always\" or \"never\". Using \"auto\"."
226    )
227}
228
229/// A cached project configuration that can be loaded once and reused across
230/// multiple operations. This avoids re-reading `.patchloom.toml` from disk
231/// on every API call.
232///
233/// # Thread safety
234///
235/// `CachedConfig` is `Send + Sync` and can be shared across threads via
236/// `Arc<CachedConfig>`.
237///
238/// # Example
239///
240/// ```rust,no_run
241/// use patchloom::config::CachedConfig;
242/// use std::path::Path;
243///
244/// let config = CachedConfig::load(Path::new("/my/project"));
245/// // Use config.project_config() in multiple API calls...
246/// ```
247#[derive(Debug)]
248pub struct CachedConfig {
249    config: ProjectConfig,
250    config_dir: Option<PathBuf>,
251}
252
253impl CachedConfig {
254    /// Load configuration from the given directory, searching upward for
255    /// `.patchloom.toml`. Returns a `CachedConfig` with defaults if no
256    /// config file is found.
257    pub fn load(start: &Path) -> Self {
258        match find_and_load(start) {
259            Some((config, dir)) => Self {
260                config,
261                config_dir: Some(dir),
262            },
263            None => Self {
264                config: ProjectConfig::default(),
265                config_dir: None,
266            },
267        }
268    }
269
270    /// Access the loaded project configuration.
271    pub fn project_config(&self) -> &ProjectConfig {
272        &self.config
273    }
274
275    /// The directory containing the `.patchloom.toml` file, if one was found.
276    pub fn config_dir(&self) -> Option<&Path> {
277        self.config_dir.as_deref()
278    }
279}
280
281// Static assertion: CachedConfig must be Send + Sync for cross-thread sharing.
282const _: () = {
283    fn _assert<T: Send + Sync>() {}
284    let _ = _assert::<CachedConfig>;
285};
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use tempfile::TempDir;
291
292    #[test]
293    fn find_and_load_from_dir() {
294        let dir = TempDir::new().unwrap();
295        std::fs::write(
296            dir.path().join(".patchloom.toml"),
297            r#"
298[write_policy]
299ensure_final_newline = true
300normalize_eol = "lf"
301collapse_blanks = true
302
303[exclude]
304globs = ["target/**"]
305
306[output]
307color = "always"
308"#,
309        )
310        .unwrap();
311
312        let (config, found_dir) = find_and_load(dir.path()).unwrap();
313        assert_eq!(found_dir, dir.path());
314        assert_eq!(config.write_policy.ensure_final_newline, Some(true));
315        assert_eq!(config.write_policy.normalize_eol.as_deref(), Some("lf"));
316        assert_eq!(config.write_policy.collapse_blanks, Some(true));
317        assert_eq!(config.exclude.globs, vec!["target/**"]);
318        assert_eq!(config.output.color.as_deref(), Some("always"));
319    }
320
321    #[test]
322    fn find_and_load_walks_up() {
323        let dir = TempDir::new().unwrap();
324        std::fs::write(
325            dir.path().join(".patchloom.toml"),
326            "[write_policy]\nensure_final_newline = true\n",
327        )
328        .unwrap();
329
330        let sub = dir.path().join("sub/deep");
331        std::fs::create_dir_all(&sub).unwrap();
332
333        let (config, found_dir) = find_and_load(&sub).unwrap();
334        assert_eq!(found_dir, dir.path());
335        assert_eq!(config.write_policy.ensure_final_newline, Some(true));
336    }
337
338    #[test]
339    fn find_and_load_tx_strict_override() {
340        let dir = TempDir::new().unwrap();
341        std::fs::write(dir.path().join(".patchloom.toml"), "[tx]\nstrict = false\n").unwrap();
342
343        let (config, _) = find_and_load(dir.path()).unwrap();
344        assert_eq!(config.tx.strict, Some(false));
345    }
346
347    #[test]
348    fn find_and_load_returns_none_when_missing() {
349        let dir = TempDir::new().unwrap();
350        assert!(find_and_load(dir.path()).is_none());
351    }
352
353    #[test]
354    fn find_and_load_stops_at_git_boundary() {
355        let dir = TempDir::new().unwrap();
356        // Place a config in the parent directory.
357        std::fs::write(
358            dir.path().join(".patchloom.toml"),
359            "[write_policy]\nensure_final_newline = true\n",
360        )
361        .unwrap();
362
363        // Create a child directory that is a separate git repo root.
364        let child = dir.path().join("child");
365        std::fs::create_dir_all(&child).unwrap();
366        std::fs::create_dir_all(child.join(".git")).unwrap();
367
368        // Searching from child should NOT find the parent's config.
369        assert!(find_and_load(&child).is_none());
370    }
371
372    #[test]
373    #[cfg(feature = "cli")]
374    fn apply_config_sets_defaults() {
375        let config = ProjectConfig {
376            write_policy: WritePolicyConfig {
377                ensure_final_newline: Some(true),
378                normalize_eol: Some("lf".into()),
379                trim_trailing_whitespace: Some(true),
380                collapse_blanks: Some(true),
381                respect_editorconfig: Some(true),
382            },
383            exclude: Exclude {
384                globs: vec!["target/**".into()],
385            },
386            output: Output {
387                color: Some("always".into()),
388            },
389            tx: TxConfig::default(),
390            defaults: Defaults::default(),
391            format: FormatConfig::default(),
392        };
393        let mut global = crate::cli::global::GlobalFlags::default();
394        apply_config(&mut global, &config);
395
396        assert!(global.ensure_final_newline);
397        assert!(matches!(
398            global.normalize_eol,
399            Some(crate::cli::global::EolMode::Lf)
400        ));
401        assert!(global.trim_trailing_whitespace);
402        assert!(global.collapse_blanks);
403        assert!(global.respect_editorconfig);
404        assert_eq!(global.exclude, vec!["target/**"]);
405        assert!(
406            global.glob.is_empty(),
407            "exclude globs must not leak into include globs"
408        );
409        assert!(matches!(
410            global.color,
411            crate::cli::global::ColorMode::Always
412        ));
413    }
414
415    #[test]
416    #[cfg(feature = "cli")]
417    fn apply_config_cli_flags_win() {
418        let config = ProjectConfig {
419            write_policy: WritePolicyConfig {
420                ensure_final_newline: Some(true),
421                ..WritePolicyConfig::default()
422            },
423            exclude: Exclude {
424                globs: vec!["config_glob".into()],
425            },
426            ..ProjectConfig::default()
427        };
428        let mut global = crate::cli::global::GlobalFlags {
429            ensure_final_newline: true,
430            glob: vec!["user_glob".into()],
431            color: crate::cli::global::ColorMode::Never,
432            ..crate::cli::global::GlobalFlags::default()
433        };
434        apply_config(&mut global, &config);
435
436        // CLI flag already set, config doesn't override
437        assert!(global.ensure_final_newline);
438        // Config exclude globs go to exclude, not include globs
439        assert_eq!(global.exclude, vec!["config_glob"]);
440        // User include globs remain unchanged
441        assert_eq!(global.glob, vec!["user_glob"]);
442        // CLI color wins
443        assert!(matches!(global.color, crate::cli::global::ColorMode::Never));
444    }
445
446    #[test]
447    fn find_and_load_rejects_unknown_keys() {
448        let dir = TempDir::new().unwrap();
449        std::fs::write(
450            dir.path().join(".patchloom.toml"),
451            "[write_policy]\nensur_final_newline = true\n",
452        )
453        .unwrap();
454
455        // Typo in key name is rejected, not silently ignored.
456        assert!(find_and_load(dir.path()).is_none());
457    }
458
459    #[test]
460    fn find_and_load_returns_none_on_malformed_toml() {
461        let dir = TempDir::new().unwrap();
462        std::fs::write(
463            dir.path().join(".patchloom.toml"),
464            "this is not valid { toml [",
465        )
466        .unwrap();
467
468        // Malformed TOML returns None but prints a warning to stderr.
469        assert!(find_and_load(dir.path()).is_none());
470    }
471
472    #[test]
473    #[cfg(feature = "cli")]
474    fn apply_config_unknown_eol_value_ignored() {
475        let config = ProjectConfig {
476            write_policy: WritePolicyConfig {
477                normalize_eol: Some("CRLF".into()), // uppercase: not recognized
478                ..WritePolicyConfig::default()
479            },
480            ..ProjectConfig::default()
481        };
482        let mut global = crate::cli::global::GlobalFlags::default();
483        apply_config(&mut global, &config);
484
485        assert!(global.normalize_eol.is_none());
486        let warning = invalid_normalize_eol_warning("CRLF");
487        assert!(warning.contains("CRLF"));
488        assert!(warning.contains("\"lf\""));
489        assert!(warning.contains("\"crlf\""));
490        assert!(warning.contains("\"cr\""));
491    }
492
493    #[test]
494    #[cfg(feature = "cli")]
495    fn apply_config_crlf_eol() {
496        let config = ProjectConfig {
497            write_policy: WritePolicyConfig {
498                normalize_eol: Some("crlf".into()),
499                ..WritePolicyConfig::default()
500            },
501            ..ProjectConfig::default()
502        };
503        let mut global = crate::cli::global::GlobalFlags::default();
504        apply_config(&mut global, &config);
505
506        assert!(matches!(
507            global.normalize_eol,
508            Some(crate::cli::global::EolMode::Crlf)
509        ));
510    }
511
512    #[test]
513    #[cfg(feature = "cli")]
514    // Regression: color = "auto" in config produced a spurious warning because
515    // the match only handled "always" and "never", falling through to the
516    // invalid-value catch-all for "auto".
517    fn apply_config_color_auto_no_warning() {
518        let config = ProjectConfig {
519            output: Output {
520                color: Some("auto".into()),
521            },
522            ..ProjectConfig::default()
523        };
524        let mut global = crate::cli::global::GlobalFlags::default();
525        apply_config(&mut global, &config);
526        assert!(matches!(global.color, crate::cli::global::ColorMode::Auto));
527    }
528
529    #[test]
530    fn apply_config_unknown_color_value_stays_auto() {
531        let config = ProjectConfig {
532            output: Output {
533                color: Some("yes".into()), // not "always" or "never"
534            },
535            ..ProjectConfig::default()
536        };
537        let mut global = crate::cli::global::GlobalFlags::default();
538        apply_config(&mut global, &config);
539
540        assert!(matches!(global.color, crate::cli::global::ColorMode::Auto));
541        let warning = invalid_output_color_warning("yes");
542        assert!(warning.contains("yes"));
543        assert!(warning.contains("always"));
544        assert!(warning.contains("never"));
545    }
546
547    #[test]
548    fn cached_config_load_with_file() {
549        let dir = TempDir::new().unwrap();
550        std::fs::write(
551            dir.path().join(".patchloom.toml"),
552            "[write_policy]\nensure_final_newline = true\n",
553        )
554        .unwrap();
555
556        let cached = CachedConfig::load(dir.path());
557        assert_eq!(
558            cached.project_config().write_policy.ensure_final_newline,
559            Some(true)
560        );
561        assert_eq!(cached.config_dir(), Some(dir.path()));
562    }
563
564    #[test]
565    fn cached_config_load_defaults_when_missing() {
566        let dir = TempDir::new().unwrap();
567
568        let cached = CachedConfig::load(dir.path());
569        assert_eq!(
570            cached.project_config().write_policy.ensure_final_newline,
571            None
572        );
573        assert!(cached.config_dir().is_none());
574    }
575
576    #[test]
577    #[cfg(feature = "cli")]
578    fn apply_config_respect_editorconfig_from_config() {
579        let config = ProjectConfig {
580            write_policy: WritePolicyConfig {
581                respect_editorconfig: Some(true),
582                ..WritePolicyConfig::default()
583            },
584            ..ProjectConfig::default()
585        };
586        let mut global = crate::cli::global::GlobalFlags::default();
587        assert!(!global.respect_editorconfig);
588        apply_config(&mut global, &config);
589        assert!(global.respect_editorconfig);
590    }
591
592    #[test]
593    #[cfg(feature = "cli")]
594    fn apply_config_apply_default_from_config() {
595        let config = ProjectConfig {
596            defaults: Defaults {
597                apply: Some(true),
598                ..Defaults::default()
599            },
600            ..ProjectConfig::default()
601        };
602        let mut global = crate::cli::global::GlobalFlags::default();
603        assert!(!global.apply);
604        apply_config(&mut global, &config);
605        assert!(global.apply);
606    }
607
608    // Regression: defaults.apply must not override explicit --check or --diff.
609    #[test]
610    fn apply_config_apply_default_respects_check_flag() {
611        let config = ProjectConfig {
612            defaults: Defaults {
613                apply: Some(true),
614                ..Defaults::default()
615            },
616            ..ProjectConfig::default()
617        };
618        let mut global = crate::cli::global::GlobalFlags {
619            check: true, // user explicitly passed --check
620            ..crate::cli::global::GlobalFlags::default()
621        };
622        apply_config(&mut global, &config);
623        assert!(
624            !global.apply,
625            "defaults.apply should not override explicit --check"
626        );
627    }
628
629    #[test]
630    fn apply_config_apply_default_respects_diff_flag() {
631        let config = ProjectConfig {
632            defaults: Defaults {
633                apply: Some(true),
634                ..Defaults::default()
635            },
636            ..ProjectConfig::default()
637        };
638        let mut global = crate::cli::global::GlobalFlags {
639            diff: true, // user explicitly passed --diff
640            ..crate::cli::global::GlobalFlags::default()
641        };
642        apply_config(&mut global, &config);
643        assert!(
644            !global.apply,
645            "defaults.apply should not override explicit --diff"
646        );
647    }
648
649    #[test]
650    #[cfg(feature = "cli")]
651    fn apply_config_format_default_from_config() {
652        let config = ProjectConfig {
653            defaults: Defaults {
654                format: Some("cargo fmt --all".into()),
655                ..Defaults::default()
656            },
657            ..ProjectConfig::default()
658        };
659        let mut global = crate::cli::global::GlobalFlags::default();
660        assert!(global.format.is_none());
661        apply_config(&mut global, &config);
662        assert_eq!(global.format.as_deref(), Some("cargo fmt --all"));
663    }
664
665    #[test]
666    fn find_and_load_with_defaults() {
667        let dir = TempDir::new().unwrap();
668        std::fs::write(
669            dir.path().join(".patchloom.toml"),
670            r#"
671[defaults]
672apply = true
673format = "cargo fmt"
674
675[write_policy]
676respect_editorconfig = true
677"#,
678        )
679        .unwrap();
680
681        let (config, found_dir) = find_and_load(dir.path()).unwrap();
682        assert_eq!(found_dir, dir.path());
683        assert_eq!(config.defaults.apply, Some(true));
684        assert_eq!(config.defaults.format.as_deref(), Some("cargo fmt"));
685        assert_eq!(config.write_policy.respect_editorconfig, Some(true));
686    }
687
688    #[test]
689    fn find_and_load_format_config() {
690        let dir = TempDir::new().unwrap();
691        std::fs::write(
692            dir.path().join(".patchloom.toml"),
693            r#"
694[format]
695auto = true
696command = "treefmt"
697
698[format.by_extension]
699rs = "rustfmt"
700go = "gofmt -w"
701ts = "prettier --write"
702"#,
703        )
704        .unwrap();
705
706        let (config, _) = find_and_load(dir.path()).unwrap();
707        assert_eq!(config.format.auto, Some(true));
708        assert_eq!(config.format.command.as_deref(), Some("treefmt"));
709        assert_eq!(config.format.by_extension.len(), 3);
710        assert_eq!(config.format.by_extension.get("rs").unwrap(), "rustfmt");
711        assert_eq!(config.format.by_extension.get("go").unwrap(), "gofmt -w");
712        assert_eq!(
713            config.format.by_extension.get("ts").unwrap(),
714            "prettier --write"
715        );
716    }
717
718    #[test]
719    fn find_and_load_format_auto_without_command() {
720        let dir = TempDir::new().unwrap();
721        std::fs::write(
722            dir.path().join(".patchloom.toml"),
723            r#"
724[format]
725auto = true
726
727[format.by_extension]
728rs = "rustfmt"
729"#,
730        )
731        .unwrap();
732
733        let (config, _) = find_and_load(dir.path()).unwrap();
734        assert_eq!(config.format.auto, Some(true));
735        assert!(config.format.command.is_none());
736        assert_eq!(config.format.by_extension.get("rs").unwrap(), "rustfmt");
737    }
738
739    #[test]
740    #[cfg(feature = "cli")]
741    fn apply_config_format_auto_command_sets_global_format() {
742        let config = ProjectConfig {
743            format: FormatConfig {
744                auto: Some(true),
745                command: Some("treefmt".into()),
746                ..FormatConfig::default()
747            },
748            ..ProjectConfig::default()
749        };
750        let mut global = crate::cli::global::GlobalFlags::default();
751        assert!(global.format.is_none());
752        apply_config(&mut global, &config);
753        assert_eq!(global.format.as_deref(), Some("treefmt"));
754    }
755
756    #[test]
757    #[cfg(feature = "cli")]
758    fn apply_config_stores_format_config_for_by_extension() {
759        let mut by_ext = std::collections::HashMap::new();
760        by_ext.insert("rs".into(), "rustfmt".into());
761        by_ext.insert("py".into(), "black".into());
762        let config = ProjectConfig {
763            format: FormatConfig {
764                auto: Some(true),
765                command: None,
766                by_extension: by_ext,
767            },
768            ..ProjectConfig::default()
769        };
770        let mut global = crate::cli::global::GlobalFlags::default();
771        assert!(global.format_config.is_none());
772        apply_config(&mut global, &config);
773        let fc = global
774            .format_config
775            .as_ref()
776            .expect("format_config should be set");
777        assert_eq!(fc.auto, Some(true));
778        assert_eq!(fc.by_extension.len(), 2);
779        assert_eq!(fc.by_extension.get("rs").unwrap(), "rustfmt");
780    }
781
782    #[test]
783    #[cfg(feature = "cli")]
784    fn apply_config_skips_format_config_when_auto_false() {
785        let mut by_ext = std::collections::HashMap::new();
786        by_ext.insert("rs".into(), "rustfmt".into());
787        let config = ProjectConfig {
788            format: FormatConfig {
789                auto: None,
790                command: None,
791                by_extension: by_ext,
792            },
793            ..ProjectConfig::default()
794        };
795        let mut global = crate::cli::global::GlobalFlags::default();
796        apply_config(&mut global, &config);
797        assert!(
798            global.format_config.is_none(),
799            "format_config should not be set when auto != true"
800        );
801    }
802
803    /// Regression: normalize_eol = "keep" in config must be accepted (no-op).
804    #[test]
805    #[cfg(feature = "cli")]
806    fn apply_config_keep_eol_is_noop() {
807        let config = ProjectConfig {
808            write_policy: WritePolicyConfig {
809                normalize_eol: Some("keep".into()),
810                ..WritePolicyConfig::default()
811            },
812            ..ProjectConfig::default()
813        };
814        let mut global = crate::cli::global::GlobalFlags::default();
815        apply_config(&mut global, &config);
816
817        // "keep" means "don't normalize" which is the default (None).
818        assert!(
819            global.normalize_eol.is_none(),
820            "keep should not set normalize_eol"
821        );
822    }
823
824    #[test]
825    #[cfg(feature = "cli")]
826    fn apply_config_cr_eol() {
827        let config = ProjectConfig {
828            write_policy: WritePolicyConfig {
829                normalize_eol: Some("cr".into()),
830                ..WritePolicyConfig::default()
831            },
832            ..ProjectConfig::default()
833        };
834        let mut global = crate::cli::global::GlobalFlags::default();
835        apply_config(&mut global, &config);
836
837        assert!(matches!(
838            global.normalize_eol,
839            Some(crate::cli::global::EolMode::Cr)
840        ));
841    }
842
843    #[test]
844    #[cfg(feature = "cli")]
845    fn apply_config_defaults_format_wins_over_format_command() {
846        let config = ProjectConfig {
847            defaults: Defaults {
848                format: Some("cargo fmt --all".into()),
849                ..Defaults::default()
850            },
851            format: FormatConfig {
852                auto: Some(true),
853                command: Some("treefmt".into()),
854                ..FormatConfig::default()
855            },
856            ..ProjectConfig::default()
857        };
858        let mut global = crate::cli::global::GlobalFlags::default();
859        apply_config(&mut global, &config);
860        // defaults.format takes priority over format.command
861        assert_eq!(global.format.as_deref(), Some("cargo fmt --all"));
862    }
863
864    /// WritePolicyConfig (config type) must reject unknown fields to catch typos.
865    /// This is the counterpart to the forward-compat test in write_tests.rs.
866    #[test]
867    fn write_policy_config_rejects_unknown_fields() {
868        let toml = r#"ensur_final_newline = true"#; // typo: missing 'e'
869        let result = toml_edit::de::from_str::<WritePolicyConfig>(toml);
870        assert!(
871            result.is_err(),
872            "WritePolicyConfig should reject unknown fields to catch typos"
873        );
874    }
875
876    /// WritePolicyConfig converts to WritePolicyOverride via From.
877    #[test]
878    fn write_policy_config_converts_to_override() {
879        let config = WritePolicyConfig {
880            ensure_final_newline: Some(true),
881            normalize_eol: Some("lf".into()),
882            trim_trailing_whitespace: Some(true),
883            collapse_blanks: None,
884            respect_editorconfig: Some(false),
885        };
886        let ov: crate::write::WritePolicyOverride = config.into();
887        assert_eq!(ov.ensure_final_newline, Some(true));
888        assert_eq!(ov.normalize_eol.as_deref(), Some("lf"));
889        assert_eq!(ov.trim_trailing_whitespace, Some(true));
890        assert!(ov.collapse_blanks.is_none());
891        assert_eq!(ov.respect_editorconfig, Some(false));
892    }
893}