Skip to main content

maw/
config.rs

1//! Manifold repository configuration (`config.toml`).
2//!
3//! Defines the typed configuration for `.manifold/config.toml`, including
4//! workspace backend selection, merge validation, and merge drivers.
5
6use std::fmt;
7use std::path::Path;
8
9use serde::Deserialize;
10
11// ---------------------------------------------------------------------------
12// Top-level config
13// ---------------------------------------------------------------------------
14
15/// Top-level Manifold repository configuration.
16///
17/// Parsed from `.manifold/config.toml`. Missing fields use sensible defaults.
18/// Missing file → all defaults (no error).
19#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
20#[serde(deny_unknown_fields)]
21#[derive(Default)]
22pub struct ManifoldConfig {
23    /// Repository-level settings.
24    #[serde(default)]
25    pub repo: RepoConfig,
26
27    /// Workspace backend settings.
28    #[serde(default)]
29    pub workspace: WorkspaceConfig,
30
31    /// Merge settings.
32    #[serde(default)]
33    pub merge: MergeConfig,
34}
35
36// ---------------------------------------------------------------------------
37// RepoConfig
38// ---------------------------------------------------------------------------
39
40/// Repository-level settings.
41#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct RepoConfig {
44    /// The main branch name (default: `"main"`).
45    #[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// ---------------------------------------------------------------------------
62// WorkspaceConfig
63// ---------------------------------------------------------------------------
64
65/// Workspace backend selection.
66#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct WorkspaceConfig {
69    /// Which backend to use for workspace isolation.
70    #[serde(default)]
71    pub backend: BackendKind,
72
73    /// Enable Level 1 git compatibility refs (`refs/manifold/ws/<name>`).
74    ///
75    /// When enabled, workspace state can be inspected with standard git tools,
76    /// e.g. `git diff refs/manifold/ws/alice..main`.
77    #[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/// The workspace isolation backend.
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Deserialize)]
96#[serde(rename_all = "kebab-case")]
97pub enum BackendKind {
98    /// Auto-detect the best available backend.
99    #[default]
100    Auto,
101    /// Git worktree backend (Phase 1).
102    GitWorktree,
103    /// Reflink/CoW backend (Btrfs/XFS/APFS).
104    Reflink,
105    /// `OverlayFS` backend (Linux only).
106    Overlay,
107    /// Plain copy backend (universal fallback).
108    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// ---------------------------------------------------------------------------
124// MergeConfig
125// ---------------------------------------------------------------------------
126
127/// Merge behaviour settings.
128#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
129#[serde(deny_unknown_fields)]
130#[derive(Default)]
131pub struct MergeConfig {
132    /// Post-merge validation settings.
133    #[serde(default)]
134    pub validation: ValidationConfig,
135
136    /// Custom merge drivers.
137    #[serde(default)]
138    pub drivers: Vec<MergeDriver>,
139
140    /// AST-aware merge settings (opt-in per language via tree-sitter).
141    #[serde(default)]
142    pub ast: AstConfig,
143}
144
145// ---------------------------------------------------------------------------
146// AstConfig — AST-aware merge settings
147// ---------------------------------------------------------------------------
148
149/// Configuration for AST-aware merge via tree-sitter (§6.2).
150///
151/// Controls which languages use AST-level merge as a fallback when diff3 fails.
152/// Enabled by default for all built-in language packs.
153///
154/// ```toml
155/// [merge.ast]
156/// languages = ["rust", "python", "typescript", "javascript", "go"]
157/// packs = ["core", "web", "backend"]
158/// semantic_false_positive_budget_pct = 5
159/// semantic_min_confidence = 70
160/// ```
161#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct AstConfig {
164    /// Languages for which AST merge is enabled.
165    ///
166    /// Supported values: `"rust"`, `"python"`, `"typescript"`, `"javascript"`, `"go"`.
167    /// Empty by default; language packs control baseline enablement.
168    #[serde(default)]
169    pub languages: Vec<AstConfigLanguage>,
170
171    /// Optional language packs that expand to multiple languages.
172    ///
173    /// Packs are additive with `languages` and deduplicated by the merge layer.
174    #[serde(default = "default_ast_packs")]
175    pub packs: Vec<AstLanguagePack>,
176
177    /// Maximum allowed semantic false-positive rate percentage (0-100).
178    ///
179    /// Semantic rules with confidence below `min_confidence` are downgraded to
180    /// generic AST-node conflict reasons to keep diagnostics conservative.
181    #[serde(default = "default_semantic_false_positive_budget_pct")]
182    pub semantic_false_positive_budget_pct: u8,
183
184    /// Minimum confidence required for semantic rule-specific diagnostics.
185    #[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/// A language supported by the AST merge layer.
217#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize)]
218#[serde(rename_all = "lowercase")]
219pub enum AstConfigLanguage {
220    /// Rust (.rs files).
221    Rust,
222    /// Python (.py files).
223    Python,
224    /// TypeScript (.ts, .tsx files).
225    #[serde(alias = "ts")]
226    TypeScript,
227    /// JavaScript (.js, .jsx, .mjs, .cjs files).
228    JavaScript,
229    /// Go (.go files).
230    Go,
231}
232
233/// A predefined pack of AST grammars that can be enabled together.
234#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize)]
235#[serde(rename_all = "lowercase")]
236pub enum AstLanguagePack {
237    /// Existing stable languages (Rust/Python/TypeScript).
238    Core,
239    /// Front-end language family (TypeScript/JavaScript).
240    Web,
241    /// Backend language family (Rust/Go/Python).
242    Backend,
243}
244
245impl MergeConfig {
246    /// Return the effective merge drivers.
247    ///
248    /// If `[[merge.drivers]]` is omitted, built-in deterministic drivers for
249    /// common lockfiles are used.
250    #[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// ---------------------------------------------------------------------------
276// ValidationConfig
277// ---------------------------------------------------------------------------
278
279// ---------------------------------------------------------------------------
280// LanguagePreset
281// ---------------------------------------------------------------------------
282
283/// Built-in per-language validation preset.
284///
285/// Each preset provides a curated sequence of validation commands for a
286/// specific language/ecosystem. Presets activate when no explicit
287/// `command` or `commands` are configured.
288///
289/// Use `"auto"` to let Manifold detect the project type from filesystem
290/// markers (e.g. `Cargo.toml` → Rust, `pyproject.toml` → Python,
291/// `tsconfig.json` → TypeScript).
292#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize)]
293#[serde(rename_all = "lowercase")]
294pub enum LanguagePreset {
295    /// Auto-detect project type from filesystem markers.
296    ///
297    /// Detection order:
298    /// 1. `Cargo.toml` → Rust
299    /// 2. `pyproject.toml` / `setup.py` / `setup.cfg` → Python
300    /// 3. `tsconfig.json` → TypeScript
301    Auto,
302    /// Rust preset: `["cargo check", "cargo test --no-run"]`.
303    Rust,
304    /// Python preset: `["python -m py_compile", "pytest -q --co"]`.
305    Python,
306    /// TypeScript preset: `["tsc --noEmit"]`.
307    TypeScript,
308}
309
310impl LanguagePreset {
311    /// Returns the validation commands for this preset.
312    ///
313    /// Returns an empty slice for `Auto` — auto-detection must be performed
314    /// externally against the actual project directory.
315    #[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// ---------------------------------------------------------------------------
338// ValidationConfig
339// ---------------------------------------------------------------------------
340
341/// Post-merge validation command settings.
342///
343/// Supports both a single `command` string and a `commands` array. When both
344/// are specified, `command` runs first, then all entries from `commands`.
345/// When neither is set, validation is skipped — unless a `preset` is
346/// configured, in which case the preset's commands are used.
347#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
348#[serde(deny_unknown_fields)]
349pub struct ValidationConfig {
350    /// Shell command to run for post-merge validation (e.g. `"cargo test"`).
351    /// `None` means no validation (unless `commands` or `preset` is set).
352    pub command: Option<String>,
353
354    /// Multiple shell commands to run in sequence. Each runs via `sh -c`.
355    /// Execution stops on first failure.
356    #[serde(default)]
357    pub commands: Vec<String>,
358
359    /// Per-language preset. When set and no explicit `command`/`commands`
360    /// are configured, the preset's commands are used instead.
361    ///
362    /// Use `"auto"` to detect the project type from filesystem markers.
363    #[serde(default)]
364    pub preset: Option<LanguagePreset>,
365
366    /// Timeout in seconds for each validation command.
367    #[serde(default = "default_validation_timeout")]
368    pub timeout_seconds: u32,
369
370    /// What to do when validation fails.
371    #[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    /// Returns the explicit commands from `command` and `commands` fields.
389    ///
390    /// Empty commands are filtered out. If `command` is set, it becomes the
391    /// first entry. All entries from `commands` follow.
392    ///
393    /// **Note:** This does **not** include preset commands. Use
394    /// [`effective_commands`] or the validate phase's preset resolution for
395    /// the full command list (explicit + preset fallback).
396    #[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    /// Returns `true` if at least one explicit validation command is
413    /// configured (ignoring presets).
414    #[must_use]
415    pub fn has_commands(&self) -> bool {
416        !self.effective_commands().is_empty()
417    }
418
419    /// Returns `true` if validation is configured — either through explicit
420    /// commands or a preset.
421    #[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/// Action to take when post-merge validation fails.
433#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Deserialize)]
434#[serde(rename_all = "kebab-case")]
435pub enum OnFailure {
436    /// Log a warning but allow the merge.
437    Warn,
438    /// Block the merge — do not advance the epoch.
439    Block,
440    /// Create a quarantine workspace with the failed merge result.
441    Quarantine,
442    /// Block the merge AND create a quarantine workspace.
443    #[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// ---------------------------------------------------------------------------
459// MergeDriver
460// ---------------------------------------------------------------------------
461
462/// A custom merge driver for specific file patterns.
463#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
464#[serde(deny_unknown_fields)]
465pub struct MergeDriver {
466    /// Glob pattern for matching file paths (e.g. `"*.lock"`, `"schema/*.sql"`).
467    #[serde(rename = "match")]
468    pub match_glob: String,
469
470    /// The driver kind.
471    pub kind: MergeDriverKind,
472
473    /// External command for `regenerate` drivers. Ignored for `ours/theirs`.
474    pub command: Option<String>,
475}
476
477/// Built-in merge driver kinds.
478#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize)]
479#[serde(rename_all = "kebab-case")]
480pub enum MergeDriverKind {
481    /// Re-generate the file deterministically from merged sources.
482    ///
483    /// Requires `command` in `[[merge.drivers]]`.
484    Regenerate,
485    /// Deterministically keep the epoch/main version (base side).
486    Ours,
487    /// Deterministically keep the workspace side.
488    ///
489    /// Only valid when exactly one workspace touched the path.
490    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// ---------------------------------------------------------------------------
504// Loading
505// ---------------------------------------------------------------------------
506
507/// Error loading a Manifold configuration file.
508#[derive(Debug)]
509pub struct ConfigError {
510    /// The path that was being loaded (if available).
511    pub path: Option<std::path::PathBuf>,
512    /// Human-readable message with line-level detail when possible.
513    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    /// Load configuration from a TOML file.
530    ///
531    /// - If the file does not exist, returns all defaults (not an error).
532    /// - If the file exists but contains invalid TOML or unknown fields,
533    ///   returns a [`ConfigError`] with line-level detail.
534    ///
535    /// # Errors
536    /// Returns `ConfigError` on I/O errors (other than not-found) or parse errors.
537    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    /// Parse configuration from a TOML string.
557    ///
558    /// # Errors
559    /// Returns `ConfigError` on invalid TOML or unknown fields.
560    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                // Calculate line number from byte offset.
565                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// ---------------------------------------------------------------------------
581// Tests
582// ---------------------------------------------------------------------------
583
584#[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        // Everything else is default.
720        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    // -- BackendKind Display --
826
827    #[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    // -- OnFailure Display --
837
838    #[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    // -- MergeDriverKind Display --
850
851    #[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    // -- All BackendKind variants parse --
859
860    #[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    // -- All OnFailure variants parse --
876
877    #[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    // -- ConfigError Display --
895
896    #[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    // -- LanguagePreset --
919
920    #[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        // Auto is resolved externally via detect_language_preset.
949        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        // No explicit commands set
981        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        // Explicit commands take precedence over preset in resolve_commands,
1000        // but both can be specified in TOML.
1001        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        // effective_commands only returns explicit (not preset)
1014        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    // -----------------------------------------------------------------------
1033    // AST merge config tests
1034    // -----------------------------------------------------------------------
1035
1036    #[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}