1use serde::Deserialize;
7use std::path::{Path, PathBuf};
8
9#[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#[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 pub apply: Option<bool>,
60 pub format: Option<String>,
62}
63
64#[derive(Debug, Default, Clone, Deserialize)]
66#[serde(default, deny_unknown_fields)]
67pub struct FormatConfig {
68 pub auto: Option<bool>,
70 pub command: Option<String>,
72 #[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
97pub 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 if dir.join(".git").exists() {
122 return None;
123 }
124 if !dir.pop() {
125 return None;
126 }
127 }
128}
129
130pub fn apply_config(global: &mut crate::cli::global::GlobalFlags, config: &ProjectConfig) {
136 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") => {} 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 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 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 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 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 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#[derive(Debug)]
248pub struct CachedConfig {
249 config: ProjectConfig,
250 config_dir: Option<PathBuf>,
251}
252
253impl CachedConfig {
254 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 pub fn project_config(&self) -> &ProjectConfig {
272 &self.config
273 }
274
275 pub fn config_dir(&self) -> Option<&Path> {
277 self.config_dir.as_deref()
278 }
279}
280
281const _: () = {
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 std::fs::write(
358 dir.path().join(".patchloom.toml"),
359 "[write_policy]\nensure_final_newline = true\n",
360 )
361 .unwrap();
362
363 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 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 assert!(global.ensure_final_newline);
438 assert_eq!(global.exclude, vec!["config_glob"]);
440 assert_eq!(global.glob, vec!["user_glob"]);
442 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 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 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()), ..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 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()), },
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 #[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, ..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, ..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 #[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 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 assert_eq!(global.format.as_deref(), Some("cargo fmt --all"));
862 }
863
864 #[test]
867 fn write_policy_config_rejects_unknown_fields() {
868 let toml = r#"ensur_final_newline = true"#; 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 #[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}