1use std::fmt;
7use std::path::Path;
8
9use serde::Deserialize;
10
11#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
20#[serde(deny_unknown_fields)]
21#[derive(Default)]
22pub struct ManifoldConfig {
23 #[serde(default)]
25 pub repo: RepoConfig,
26
27 #[serde(default)]
29 pub workspace: WorkspaceConfig,
30
31 #[serde(default)]
33 pub merge: MergeConfig,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct RepoConfig {
44 #[serde(default = "default_branch")]
46 pub branch: String,
47}
48
49impl Default for RepoConfig {
50 fn default() -> Self {
51 Self {
52 branch: default_branch(),
53 }
54 }
55}
56
57fn default_branch() -> String {
58 "main".to_owned()
59}
60
61#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct WorkspaceConfig {
69 #[serde(default)]
71 pub backend: BackendKind,
72
73 #[serde(default = "default_git_compat_refs")]
78 pub git_compat_refs: bool,
79}
80
81impl Default for WorkspaceConfig {
82 fn default() -> Self {
83 Self {
84 backend: BackendKind::default(),
85 git_compat_refs: default_git_compat_refs(),
86 }
87 }
88}
89
90const fn default_git_compat_refs() -> bool {
91 true
92}
93
94#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Deserialize)]
96#[serde(rename_all = "kebab-case")]
97pub enum BackendKind {
98 #[default]
100 Auto,
101 GitWorktree,
103 Reflink,
105 Overlay,
107 Copy,
109}
110
111impl fmt::Display for BackendKind {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 match self {
114 Self::Auto => write!(f, "auto"),
115 Self::GitWorktree => write!(f, "git-worktree"),
116 Self::Reflink => write!(f, "reflink"),
117 Self::Overlay => write!(f, "overlay"),
118 Self::Copy => write!(f, "copy"),
119 }
120 }
121}
122
123#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
129#[serde(deny_unknown_fields)]
130#[derive(Default)]
131pub struct MergeConfig {
132 #[serde(default)]
134 pub validation: ValidationConfig,
135
136 #[serde(default)]
138 pub drivers: Vec<MergeDriver>,
139
140 #[serde(default)]
142 pub ast: AstConfig,
143}
144
145#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct AstConfig {
164 #[serde(default)]
169 pub languages: Vec<AstConfigLanguage>,
170
171 #[serde(default = "default_ast_packs")]
175 pub packs: Vec<AstLanguagePack>,
176
177 #[serde(default = "default_semantic_false_positive_budget_pct")]
182 pub semantic_false_positive_budget_pct: u8,
183
184 #[serde(default = "default_semantic_min_confidence")]
186 pub semantic_min_confidence: u8,
187}
188
189impl Default for AstConfig {
190 fn default() -> Self {
191 Self {
192 languages: Vec::new(),
193 packs: default_ast_packs(),
194 semantic_false_positive_budget_pct: default_semantic_false_positive_budget_pct(),
195 semantic_min_confidence: default_semantic_min_confidence(),
196 }
197 }
198}
199
200fn default_ast_packs() -> Vec<AstLanguagePack> {
201 vec![
202 AstLanguagePack::Core,
203 AstLanguagePack::Web,
204 AstLanguagePack::Backend,
205 ]
206}
207
208const fn default_semantic_false_positive_budget_pct() -> u8 {
209 5
210}
211
212const fn default_semantic_min_confidence() -> u8 {
213 70
214}
215
216#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize)]
218#[serde(rename_all = "lowercase")]
219pub enum AstConfigLanguage {
220 Rust,
222 Python,
224 #[serde(alias = "ts")]
226 TypeScript,
227 JavaScript,
229 Go,
231}
232
233#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize)]
235#[serde(rename_all = "lowercase")]
236pub enum AstLanguagePack {
237 Core,
239 Web,
241 Backend,
243}
244
245impl MergeConfig {
246 #[must_use]
251 pub fn effective_drivers(&self) -> Vec<MergeDriver> {
252 if self.drivers.is_empty() {
253 default_merge_drivers()
254 } else {
255 self.drivers.clone()
256 }
257 }
258}
259
260fn default_merge_drivers() -> Vec<MergeDriver> {
261 vec![
262 MergeDriver {
263 match_glob: "Cargo.lock".to_owned(),
264 kind: MergeDriverKind::Regenerate,
265 command: Some("cargo generate-lockfile".to_owned()),
266 },
267 MergeDriver {
268 match_glob: "package-lock.json".to_owned(),
269 kind: MergeDriverKind::Regenerate,
270 command: Some("npm install --package-lock-only".to_owned()),
271 },
272 ]
273}
274
275#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize)]
293#[serde(rename_all = "lowercase")]
294pub enum LanguagePreset {
295 Auto,
302 Rust,
304 Python,
306 TypeScript,
308}
309
310impl LanguagePreset {
311 #[must_use]
316 pub const fn commands(&self) -> &'static [&'static str] {
317 match self {
318 Self::Rust => &["cargo check", "cargo test --no-run"],
319 Self::Python => &["python -m py_compile", "pytest -q --co"],
320 Self::TypeScript => &["tsc --noEmit"],
321 Self::Auto => &[],
322 }
323 }
324}
325
326impl fmt::Display for LanguagePreset {
327 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
328 match self {
329 Self::Auto => write!(f, "auto"),
330 Self::Rust => write!(f, "rust"),
331 Self::Python => write!(f, "python"),
332 Self::TypeScript => write!(f, "typescript"),
333 }
334 }
335}
336
337#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
348#[serde(deny_unknown_fields)]
349pub struct ValidationConfig {
350 pub command: Option<String>,
353
354 #[serde(default)]
357 pub commands: Vec<String>,
358
359 #[serde(default)]
364 pub preset: Option<LanguagePreset>,
365
366 #[serde(default = "default_validation_timeout")]
368 pub timeout_seconds: u32,
369
370 #[serde(default)]
372 pub on_failure: OnFailure,
373}
374
375impl Default for ValidationConfig {
376 fn default() -> Self {
377 Self {
378 command: None,
379 commands: Vec::new(),
380 preset: None,
381 timeout_seconds: default_validation_timeout(),
382 on_failure: OnFailure::default(),
383 }
384 }
385}
386
387impl ValidationConfig {
388 #[must_use]
397 pub fn effective_commands(&self) -> Vec<&str> {
398 let mut result = Vec::new();
399 if let Some(cmd) = &self.command
400 && !cmd.is_empty()
401 {
402 result.push(cmd.as_str());
403 }
404 for cmd in &self.commands {
405 if !cmd.is_empty() {
406 result.push(cmd.as_str());
407 }
408 }
409 result
410 }
411
412 #[must_use]
415 pub fn has_commands(&self) -> bool {
416 !self.effective_commands().is_empty()
417 }
418
419 #[must_use]
422 #[allow(dead_code)]
423 pub fn has_any_validation(&self) -> bool {
424 self.has_commands() || self.preset.is_some()
425 }
426}
427
428const fn default_validation_timeout() -> u32 {
429 60
430}
431
432#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Deserialize)]
434#[serde(rename_all = "kebab-case")]
435pub enum OnFailure {
436 Warn,
438 Block,
440 Quarantine,
442 #[default]
444 BlockQuarantine,
445}
446
447impl fmt::Display for OnFailure {
448 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
449 match self {
450 Self::Warn => write!(f, "warn"),
451 Self::Block => write!(f, "block"),
452 Self::Quarantine => write!(f, "quarantine"),
453 Self::BlockQuarantine => write!(f, "block+quarantine"),
454 }
455 }
456}
457
458#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
464#[serde(deny_unknown_fields)]
465pub struct MergeDriver {
466 #[serde(rename = "match")]
468 pub match_glob: String,
469
470 pub kind: MergeDriverKind,
472
473 pub command: Option<String>,
475}
476
477#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize)]
479#[serde(rename_all = "kebab-case")]
480pub enum MergeDriverKind {
481 Regenerate,
485 Ours,
487 Theirs,
491}
492
493impl fmt::Display for MergeDriverKind {
494 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
495 match self {
496 Self::Regenerate => write!(f, "regenerate"),
497 Self::Ours => write!(f, "ours"),
498 Self::Theirs => write!(f, "theirs"),
499 }
500 }
501}
502
503#[derive(Debug)]
509pub struct ConfigError {
510 pub path: Option<std::path::PathBuf>,
512 pub message: String,
514}
515
516impl fmt::Display for ConfigError {
517 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
518 if let Some(p) = &self.path {
519 write!(f, "{}: {}", p.display(), self.message)
520 } else {
521 write!(f, "config error: {}", self.message)
522 }
523 }
524}
525
526impl std::error::Error for ConfigError {}
527
528impl ManifoldConfig {
529 pub fn load(path: &Path) -> Result<Self, ConfigError> {
538 let contents = match std::fs::read_to_string(path) {
539 Ok(c) => c,
540 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
541 return Ok(Self::default());
542 }
543 Err(e) => {
544 return Err(ConfigError {
545 path: Some(path.to_owned()),
546 message: format!("could not read file: {e}"),
547 });
548 }
549 };
550 Self::parse(&contents).map_err(|mut e| {
551 e.path = Some(path.to_owned());
552 e
553 })
554 }
555
556 pub fn parse(toml_str: &str) -> Result<Self, ConfigError> {
561 toml::from_str(toml_str).map_err(|e| {
562 let mut message = e.message().to_owned();
563 if let Some(span) = e.span() {
564 let line = toml_str[..span.start]
566 .chars()
567 .filter(|&c| c == '\n')
568 .count()
569 + 1;
570 message = format!("line {line}: {message}");
571 }
572 ConfigError {
573 path: None,
574 message,
575 }
576 })
577 }
578}
579
580#[cfg(test)]
585mod tests {
586 use super::*;
587
588 #[test]
589 fn defaults_all_fields() {
590 let cfg = ManifoldConfig::default();
591 assert_eq!(cfg.repo.branch, "main");
592 assert_eq!(cfg.workspace.backend, BackendKind::Auto);
593 assert!(cfg.workspace.git_compat_refs);
594 assert_eq!(cfg.merge.validation.command, None);
595 assert!(cfg.merge.validation.commands.is_empty());
596 assert_eq!(cfg.merge.validation.timeout_seconds, 60);
597 assert_eq!(cfg.merge.validation.on_failure, OnFailure::BlockQuarantine);
598 assert!(!cfg.merge.validation.has_commands());
599 assert!(cfg.merge.drivers.is_empty());
600
601 let defaults = cfg.merge.effective_drivers();
602 assert!(
603 defaults
604 .iter()
605 .any(|d| d.match_glob == "Cargo.lock" && d.kind == MergeDriverKind::Regenerate)
606 );
607 assert!(
608 defaults
609 .iter()
610 .any(|d| d.match_glob == "package-lock.json"
611 && d.kind == MergeDriverKind::Regenerate)
612 );
613 }
614
615 #[test]
616 fn parse_empty_string() {
617 let cfg = ManifoldConfig::parse("").unwrap();
618 assert_eq!(cfg, ManifoldConfig::default());
619 }
620
621 #[test]
622 fn parse_full_config() {
623 let toml = r#"
624[repo]
625branch = "develop"
626
627[workspace]
628backend = "git-worktree"
629
630[merge.validation]
631command = "cargo test"
632timeout_seconds = 120
633on_failure = "block"
634
635[[merge.drivers]]
636match = "Cargo.lock"
637kind = "regenerate"
638command = "cargo generate-lockfile"
639
640[[merge.drivers]]
641match = "generated/**"
642kind = "theirs"
643"#;
644 let cfg = ManifoldConfig::parse(toml).unwrap();
645 assert_eq!(cfg.repo.branch, "develop");
646 assert_eq!(cfg.workspace.backend, BackendKind::GitWorktree);
647 assert!(cfg.workspace.git_compat_refs);
648 assert_eq!(cfg.merge.validation.command.as_deref(), Some("cargo test"));
649 assert_eq!(cfg.merge.validation.timeout_seconds, 120);
650 assert_eq!(cfg.merge.validation.on_failure, OnFailure::Block);
651 assert_eq!(cfg.merge.drivers.len(), 2);
652 assert_eq!(cfg.merge.drivers[0].match_glob, "Cargo.lock");
653 assert_eq!(cfg.merge.drivers[0].kind, MergeDriverKind::Regenerate);
654 assert_eq!(
655 cfg.merge.drivers[0].command.as_deref(),
656 Some("cargo generate-lockfile")
657 );
658 assert_eq!(cfg.merge.drivers[1].match_glob, "generated/**");
659 assert_eq!(cfg.merge.drivers[1].kind, MergeDriverKind::Theirs);
660 assert!(cfg.merge.drivers[1].command.is_none());
661 }
662
663 #[test]
664 fn parse_workspace_git_compat_refs_false() {
665 let toml = r#"
666[workspace]
667backend = "git-worktree"
668git_compat_refs = false
669"#;
670 let cfg = ManifoldConfig::parse(toml).unwrap();
671 assert_eq!(cfg.workspace.backend, BackendKind::GitWorktree);
672 assert!(!cfg.workspace.git_compat_refs);
673 }
674
675 #[test]
676 fn parse_commands_array() {
677 let toml = r#"
678[merge.validation]
679commands = ["cargo check", "cargo test"]
680timeout_seconds = 120
681on_failure = "block"
682"#;
683 let cfg = ManifoldConfig::parse(toml).unwrap();
684 assert_eq!(cfg.merge.validation.command, None);
685 assert_eq!(
686 cfg.merge.validation.commands,
687 vec!["cargo check", "cargo test"]
688 );
689 assert_eq!(
690 cfg.merge.validation.effective_commands(),
691 vec!["cargo check", "cargo test"]
692 );
693 assert!(cfg.merge.validation.has_commands());
694 }
695
696 #[test]
697 fn parse_command_and_commands_together() {
698 let toml = r#"
699[merge.validation]
700command = "cargo fmt --check"
701commands = ["cargo check", "cargo test"]
702on_failure = "block-quarantine"
703"#;
704 let cfg = ManifoldConfig::parse(toml).unwrap();
705 assert_eq!(
706 cfg.merge.validation.effective_commands(),
707 vec!["cargo fmt --check", "cargo check", "cargo test"]
708 );
709 }
710
711 #[test]
712 fn parse_partial_config_uses_defaults() {
713 let toml = r#"
714[repo]
715branch = "trunk"
716"#;
717 let cfg = ManifoldConfig::parse(toml).unwrap();
718 assert_eq!(cfg.repo.branch, "trunk");
719 assert_eq!(cfg.workspace.backend, BackendKind::Auto);
721 assert!(cfg.workspace.git_compat_refs);
722 assert_eq!(cfg.merge.validation.timeout_seconds, 60);
723 assert!(cfg.merge.validation.commands.is_empty());
724 }
725
726 #[test]
727 fn parse_rejects_unknown_top_level_field() {
728 let toml = r"
729unknown_field = true
730";
731 let err = ManifoldConfig::parse(toml).unwrap_err();
732 assert!(
733 err.message.contains("unknown field"),
734 "error should mention unknown field: {}",
735 err.message
736 );
737 }
738
739 #[test]
740 fn parse_rejects_unknown_nested_field() {
741 let toml = r#"
742[repo]
743branch = "main"
744extra = "oops"
745"#;
746 let err = ManifoldConfig::parse(toml).unwrap_err();
747 assert!(
748 err.message.contains("unknown field"),
749 "error should mention unknown field: {}",
750 err.message
751 );
752 }
753
754 #[test]
755 fn parse_rejects_invalid_backend() {
756 let toml = r#"
757[workspace]
758backend = "quantum-teleport"
759"#;
760 let err = ManifoldConfig::parse(toml).unwrap_err();
761 assert!(
762 err.message.contains("unknown variant"),
763 "error should mention unknown variant: {}",
764 err.message
765 );
766 }
767
768 #[test]
769 fn parse_rejects_invalid_on_failure() {
770 let toml = r#"
771[merge.validation]
772on_failure = "explode"
773"#;
774 let err = ManifoldConfig::parse(toml).unwrap_err();
775 assert!(
776 err.message.contains("unknown variant"),
777 "error should mention unknown variant: {}",
778 err.message
779 );
780 }
781
782 #[test]
783 fn parse_includes_line_number_on_error() {
784 let toml = "good = 1\n[repo]\nbranch = 42\n";
785 let err = ManifoldConfig::parse(toml).unwrap_err();
786 assert!(
787 err.message.contains("line"),
788 "error should include line number: {}",
789 err.message
790 );
791 }
792
793 #[test]
794 fn load_missing_file_returns_defaults() {
795 let cfg = ManifoldConfig::load(Path::new("/nonexistent/config.toml")).unwrap();
796 assert_eq!(cfg, ManifoldConfig::default());
797 }
798
799 #[test]
800 fn load_existing_file() {
801 let dir = tempfile::tempdir().unwrap();
802 let path = dir.path().join("config.toml");
803 std::fs::write(
804 &path,
805 r#"
806[repo]
807branch = "release"
808"#,
809 )
810 .unwrap();
811 let cfg = ManifoldConfig::load(&path).unwrap();
812 assert_eq!(cfg.repo.branch, "release");
813 }
814
815 #[test]
816 fn load_invalid_file_shows_path() {
817 let dir = tempfile::tempdir().unwrap();
818 let path = dir.path().join("bad.toml");
819 std::fs::write(&path, "not valid [[[toml").unwrap();
820 let err = ManifoldConfig::load(&path).unwrap_err();
821 assert_eq!(err.path.as_deref(), Some(path.as_path()));
822 assert!(!err.message.is_empty());
823 }
824
825 #[test]
828 fn backend_kind_display() {
829 assert_eq!(format!("{}", BackendKind::Auto), "auto");
830 assert_eq!(format!("{}", BackendKind::GitWorktree), "git-worktree");
831 assert_eq!(format!("{}", BackendKind::Reflink), "reflink");
832 assert_eq!(format!("{}", BackendKind::Overlay), "overlay");
833 assert_eq!(format!("{}", BackendKind::Copy), "copy");
834 }
835
836 #[test]
839 fn on_failure_display() {
840 assert_eq!(format!("{}", OnFailure::Warn), "warn");
841 assert_eq!(format!("{}", OnFailure::Block), "block");
842 assert_eq!(format!("{}", OnFailure::Quarantine), "quarantine");
843 assert_eq!(
844 format!("{}", OnFailure::BlockQuarantine),
845 "block+quarantine"
846 );
847 }
848
849 #[test]
852 fn merge_driver_kind_display() {
853 assert_eq!(format!("{}", MergeDriverKind::Regenerate), "regenerate");
854 assert_eq!(format!("{}", MergeDriverKind::Ours), "ours");
855 assert_eq!(format!("{}", MergeDriverKind::Theirs), "theirs");
856 }
857
858 #[test]
861 fn all_backend_kinds_parse() {
862 for (input, expected) in [
863 ("auto", BackendKind::Auto),
864 ("git-worktree", BackendKind::GitWorktree),
865 ("reflink", BackendKind::Reflink),
866 ("overlay", BackendKind::Overlay),
867 ("copy", BackendKind::Copy),
868 ] {
869 let toml = format!("[workspace]\nbackend = \"{input}\"");
870 let cfg = ManifoldConfig::parse(&toml).unwrap();
871 assert_eq!(cfg.workspace.backend, expected, "variant: {input}");
872 }
873 }
874
875 #[test]
878 fn all_on_failure_variants_parse() {
879 for (input, expected) in [
880 ("warn", OnFailure::Warn),
881 ("block", OnFailure::Block),
882 ("quarantine", OnFailure::Quarantine),
883 ("block-quarantine", OnFailure::BlockQuarantine),
884 ] {
885 let toml = format!("[merge.validation]\non_failure = \"{input}\"");
886 let cfg = ManifoldConfig::parse(&toml).unwrap();
887 assert_eq!(
888 cfg.merge.validation.on_failure, expected,
889 "variant: {input}"
890 );
891 }
892 }
893
894 #[test]
897 fn config_error_display_with_path() {
898 let err = ConfigError {
899 path: Some(std::path::PathBuf::from("/repo/.manifold/config.toml")),
900 message: "bad field".to_owned(),
901 };
902 let msg = format!("{err}");
903 assert!(msg.contains("/repo/.manifold/config.toml"));
904 assert!(msg.contains("bad field"));
905 }
906
907 #[test]
908 fn config_error_display_without_path() {
909 let err = ConfigError {
910 path: None,
911 message: "parse error".to_owned(),
912 };
913 let msg = format!("{err}");
914 assert!(msg.contains("config error"));
915 assert!(msg.contains("parse error"));
916 }
917
918 #[test]
921 fn language_preset_display() {
922 assert_eq!(format!("{}", LanguagePreset::Auto), "auto");
923 assert_eq!(format!("{}", LanguagePreset::Rust), "rust");
924 assert_eq!(format!("{}", LanguagePreset::Python), "python");
925 assert_eq!(format!("{}", LanguagePreset::TypeScript), "typescript");
926 }
927
928 #[test]
929 fn language_preset_commands_rust() {
930 let cmds = LanguagePreset::Rust.commands();
931 assert_eq!(cmds, &["cargo check", "cargo test --no-run"]);
932 }
933
934 #[test]
935 fn language_preset_commands_python() {
936 let cmds = LanguagePreset::Python.commands();
937 assert_eq!(cmds, &["python -m py_compile", "pytest -q --co"]);
938 }
939
940 #[test]
941 fn language_preset_commands_typescript() {
942 let cmds = LanguagePreset::TypeScript.commands();
943 assert_eq!(cmds, &["tsc --noEmit"]);
944 }
945
946 #[test]
947 fn language_preset_auto_has_no_commands() {
948 assert!(LanguagePreset::Auto.commands().is_empty());
950 }
951
952 #[test]
953 fn all_language_presets_parse() {
954 for (input, expected) in [
955 ("auto", LanguagePreset::Auto),
956 ("rust", LanguagePreset::Rust),
957 ("python", LanguagePreset::Python),
958 ("typescript", LanguagePreset::TypeScript),
959 ] {
960 let toml = format!("[merge.validation]\npreset = \"{input}\"");
961 let cfg = ManifoldConfig::parse(&toml).unwrap();
962 assert_eq!(
963 cfg.merge.validation.preset.as_ref().unwrap(),
964 &expected,
965 "variant: {input}"
966 );
967 }
968 }
969
970 #[test]
971 fn validation_config_preset_defaults_to_none() {
972 let cfg = ManifoldConfig::default();
973 assert!(cfg.merge.validation.preset.is_none());
974 }
975
976 #[test]
977 fn validation_config_has_any_validation_with_preset() {
978 let cfg = ManifoldConfig::parse("[merge.validation]\npreset = \"rust\"").unwrap();
979 assert!(cfg.merge.validation.has_any_validation());
980 assert!(!cfg.merge.validation.has_commands());
982 }
983
984 #[test]
985 fn validation_config_has_any_validation_with_command() {
986 let cfg = ManifoldConfig::parse("[merge.validation]\ncommand = \"cargo test\"").unwrap();
987 assert!(cfg.merge.validation.has_any_validation());
988 assert!(cfg.merge.validation.has_commands());
989 }
990
991 #[test]
992 fn validation_config_has_no_validation_by_default() {
993 let cfg = ManifoldConfig::default();
994 assert!(!cfg.merge.validation.has_any_validation());
995 }
996
997 #[test]
998 fn parse_preset_with_explicit_commands_coexist() {
999 let toml = r#"
1002[merge.validation]
1003command = "cargo fmt --check"
1004preset = "rust"
1005on_failure = "block"
1006"#;
1007 let cfg = ManifoldConfig::parse(toml).unwrap();
1008 assert_eq!(
1009 cfg.merge.validation.command.as_deref(),
1010 Some("cargo fmt --check")
1011 );
1012 assert_eq!(cfg.merge.validation.preset, Some(LanguagePreset::Rust));
1013 assert_eq!(
1015 cfg.merge.validation.effective_commands(),
1016 vec!["cargo fmt --check"]
1017 );
1018 assert!(cfg.merge.validation.has_any_validation());
1019 }
1020
1021 #[test]
1022 fn parse_rejects_invalid_language_preset() {
1023 let toml = "[merge.validation]\npreset = \"cobol\"";
1024 let err = ManifoldConfig::parse(toml).unwrap_err();
1025 assert!(
1026 err.message.contains("unknown variant"),
1027 "expected 'unknown variant' but got: {}",
1028 err.message
1029 );
1030 }
1031
1032 #[test]
1037 fn ast_config_defaults_to_all_packs() {
1038 let cfg = ManifoldConfig::default();
1039 assert!(
1040 cfg.merge.ast.languages.is_empty(),
1041 "explicit language list should default to empty"
1042 );
1043 assert!(
1044 cfg.merge.ast.packs.contains(&AstLanguagePack::Core),
1045 "AST core pack should be enabled by default"
1046 );
1047 assert!(
1048 cfg.merge.ast.packs.contains(&AstLanguagePack::Web),
1049 "AST web pack should be enabled by default"
1050 );
1051 assert!(
1052 cfg.merge.ast.packs.contains(&AstLanguagePack::Backend),
1053 "AST backend pack should be enabled by default"
1054 );
1055 assert_eq!(cfg.merge.ast.semantic_false_positive_budget_pct, 5);
1056 assert_eq!(cfg.merge.ast.semantic_min_confidence, 70);
1057 }
1058
1059 #[test]
1060 fn parse_ast_config_all_languages() {
1061 let toml = r#"
1062[merge.ast]
1063languages = ["rust", "python", "typescript"]
1064"#;
1065 let cfg = ManifoldConfig::parse(toml).unwrap();
1066 assert_eq!(cfg.merge.ast.languages.len(), 3);
1067 assert!(cfg.merge.ast.languages.contains(&AstConfigLanguage::Rust));
1068 assert!(cfg.merge.ast.languages.contains(&AstConfigLanguage::Python));
1069 assert!(
1070 cfg.merge
1071 .ast
1072 .languages
1073 .contains(&AstConfigLanguage::TypeScript)
1074 );
1075 }
1076
1077 #[test]
1078 fn parse_ast_config_single_language() {
1079 let toml = r#"
1080[merge.ast]
1081languages = ["rust"]
1082"#;
1083 let cfg = ManifoldConfig::parse(toml).unwrap();
1084 assert_eq!(cfg.merge.ast.languages.len(), 1);
1085 assert_eq!(cfg.merge.ast.languages[0], AstConfigLanguage::Rust);
1086 }
1087
1088 #[test]
1089 fn parse_ast_config_ts_alias() {
1090 let toml = r#"
1091[merge.ast]
1092languages = ["ts"]
1093"#;
1094 let cfg = ManifoldConfig::parse(toml).unwrap();
1095 assert_eq!(cfg.merge.ast.languages.len(), 1);
1096 assert_eq!(cfg.merge.ast.languages[0], AstConfigLanguage::TypeScript);
1097 }
1098
1099 #[test]
1100 fn parse_ast_config_javascript_and_go() {
1101 let toml = r#"
1102[merge.ast]
1103languages = ["javascript", "go"]
1104"#;
1105 let cfg = ManifoldConfig::parse(toml).unwrap();
1106 assert_eq!(cfg.merge.ast.languages.len(), 2);
1107 assert!(
1108 cfg.merge
1109 .ast
1110 .languages
1111 .contains(&AstConfigLanguage::JavaScript)
1112 );
1113 assert!(cfg.merge.ast.languages.contains(&AstConfigLanguage::Go));
1114 }
1115
1116 #[test]
1117 fn parse_ast_config_packs_and_semantic_thresholds() {
1118 let toml = r#"
1119[merge.ast]
1120packs = ["core", "web"]
1121semantic_false_positive_budget_pct = 3
1122semantic_min_confidence = 80
1123"#;
1124 let cfg = ManifoldConfig::parse(toml).unwrap();
1125 assert_eq!(cfg.merge.ast.packs.len(), 2);
1126 assert!(cfg.merge.ast.packs.contains(&AstLanguagePack::Core));
1127 assert!(cfg.merge.ast.packs.contains(&AstLanguagePack::Web));
1128 assert_eq!(cfg.merge.ast.semantic_false_positive_budget_pct, 3);
1129 assert_eq!(cfg.merge.ast.semantic_min_confidence, 80);
1130 }
1131
1132 #[test]
1133 fn parse_ast_config_empty_languages() {
1134 let toml = r"
1135[merge.ast]
1136languages = []
1137";
1138 let cfg = ManifoldConfig::parse(toml).unwrap();
1139 assert!(cfg.merge.ast.languages.is_empty());
1140 }
1141
1142 #[test]
1143 fn parse_ast_config_rejects_unknown_language() {
1144 let toml = r#"
1145[merge.ast]
1146languages = ["cobol"]
1147"#;
1148 let err = ManifoldConfig::parse(toml).unwrap_err();
1149 assert!(
1150 err.message.contains("unknown variant"),
1151 "expected 'unknown variant' but got: {}",
1152 err.message
1153 );
1154 }
1155}