ccsync_core/config/
merge.rs

1//! Configuration merging with precedence rules
2//!
3//! # Merging Semantics
4//!
5//! - **Arrays** (ignore, include, rules): Additive - all values from all configs are combined
6//! - **Booleans**: Override - higher precedence configs override lower precedence
7//!
8//! # Precedence Order
9//!
10//! Configs are loaded from lowest to highest precedence:
11//! 1. Global config (~/.config/ccsync/config.toml)
12//! 2. Project config (.ccsync)
13//! 3. Local config (.ccsync.local)
14//! 4. CLI config (--config flag)
15//!
16//! Higher precedence configs fully override boolean values from lower precedence configs.
17
18use std::fs;
19use std::path::Path;
20
21use anyhow::Context;
22
23use super::discovery::ConfigFiles;
24use super::types::Config;
25use crate::error::Result;
26
27/// Configuration merger
28pub struct ConfigMerger;
29
30impl Default for ConfigMerger {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl ConfigMerger {
37    /// Create a new config merger
38    #[must_use]
39    pub const fn new() -> Self {
40        Self
41    }
42
43    /// Merge multiple config files with precedence rules
44    ///
45    /// Precedence order (highest to lowest):
46    /// 1. CLI config
47    /// 2. .ccsync.local
48    /// 3. .ccsync
49    /// 4. Global config
50    ///
51    /// # Errors
52    ///
53    /// Returns an error if config files cannot be read or parsed.
54    pub fn merge(files: &ConfigFiles) -> Result<Config> {
55        let mut merged = Config::default();
56
57        // Load and merge in reverse precedence order (lowest to highest)
58        if let Some(global) = &files.global {
59            Self::merge_into(&mut merged, global)?;
60        }
61
62        if let Some(project) = &files.project {
63            Self::merge_into(&mut merged, project)?;
64        }
65
66        if let Some(local) = &files.local {
67            Self::merge_into(&mut merged, local)?;
68        }
69
70        if let Some(cli) = &files.cli {
71            Self::merge_into(&mut merged, cli)?;
72        }
73
74        Ok(merged)
75    }
76
77    /// Load and merge a single config file into the existing config
78    fn merge_into(base: &mut Config, path: &Path) -> Result<()> {
79        // Security: Limit config file size to 1MB
80        const MAX_CONFIG_SIZE: u64 = 1024 * 1024;
81        let metadata = fs::metadata(path)
82            .with_context(|| format!("Failed to read metadata for: {}", path.display()))?;
83
84        if metadata.len() > MAX_CONFIG_SIZE {
85            anyhow::bail!(
86                "Config file too large: {} bytes (max: {} bytes)",
87                metadata.len(),
88                MAX_CONFIG_SIZE
89            );
90        }
91
92        let content = fs::read_to_string(path)
93            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
94
95        let config: Config = toml::from_str(&content)
96            .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
97
98        // Merge: additive for arrays (with deduplication), override for Option<bool>
99        base.ignore.extend(config.ignore);
100        base.ignore.sort();
101        base.ignore.dedup();
102
103        base.include.extend(config.include);
104        base.include.sort();
105        base.include.dedup();
106
107        base.rules.extend(config.rules);
108
109        // Override booleans only if explicitly set in higher-precedence config
110        if config.follow_symlinks.is_some() {
111            base.follow_symlinks = config.follow_symlinks;
112        }
113        if config.preserve_symlinks.is_some() {
114            base.preserve_symlinks = config.preserve_symlinks;
115        }
116        if config.dry_run.is_some() {
117            base.dry_run = config.dry_run;
118        }
119        if config.non_interactive.is_some() {
120            base.non_interactive = config.non_interactive;
121        }
122
123        Ok(())
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use std::fs;
131    use tempfile::TempDir;
132
133    #[test]
134    fn test_merge_empty_config() {
135        let files = ConfigFiles {
136            cli: None,
137            local: None,
138            project: None,
139            global: None,
140        };
141
142        let _merger = ConfigMerger::new();
143        let config = ConfigMerger::merge(&files).unwrap();
144
145        assert!(config.ignore.is_empty());
146        assert!(config.include.is_empty());
147    }
148
149    #[test]
150    fn test_merge_single_config() {
151        let tmp = TempDir::new().unwrap();
152        let config_file = tmp.path().join("config.toml");
153        fs::write(
154            &config_file,
155            r#"
156ignore = ["*.tmp", "*.log"]
157follow_symlinks = true
158"#,
159        )
160        .unwrap();
161
162        let files = ConfigFiles {
163            cli: None,
164            local: None,
165            project: Some(config_file),
166            global: None,
167        };
168
169        let _merger = ConfigMerger::new();
170        let config = ConfigMerger::merge(&files).unwrap();
171
172        assert_eq!(config.ignore.len(), 2);
173        assert_eq!(config.follow_symlinks, Some(true));
174    }
175
176    #[test]
177    fn test_merge_precedence() {
178        let tmp = TempDir::new().unwrap();
179
180        let global = tmp.path().join("global.toml");
181        fs::write(&global, r#"ignore = ["*.tmp"]"#).unwrap();
182
183        let project = tmp.path().join("project.toml");
184        fs::write(&project, r#"ignore = ["*.log"]"#).unwrap();
185
186        let files = ConfigFiles {
187            cli: None,
188            local: None,
189            project: Some(project),
190            global: Some(global),
191        };
192
193        let _merger = ConfigMerger::new();
194        let config = ConfigMerger::merge(&files).unwrap();
195
196        // Both patterns should be present (additive merging)
197        assert_eq!(config.ignore.len(), 2);
198        assert!(config.ignore.contains(&"*.tmp".to_string()));
199        assert!(config.ignore.contains(&"*.log".to_string()));
200    }
201
202    #[test]
203    fn test_merge_boolean_override() {
204        let tmp = TempDir::new().unwrap();
205
206        let global = tmp.path().join("global.toml");
207        fs::write(&global, r#"follow_symlinks = false"#).unwrap();
208
209        let project = tmp.path().join("project.toml");
210        fs::write(&project, r#"follow_symlinks = true"#).unwrap();
211
212        let files = ConfigFiles {
213            cli: None,
214            local: None,
215            project: Some(project),
216            global: Some(global),
217        };
218
219        let _merger = ConfigMerger::new();
220        let config = ConfigMerger::merge(&files).unwrap();
221
222        // Project config should override global
223        assert_eq!(config.follow_symlinks, Some(true));
224    }
225}