ccsync_core/config/
merge.rs1use std::fs;
19use std::path::Path;
20
21use anyhow::Context;
22
23use super::discovery::ConfigFiles;
24use super::types::Config;
25use crate::error::Result;
26
27pub struct ConfigMerger;
29
30impl Default for ConfigMerger {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36impl ConfigMerger {
37 #[must_use]
39 pub const fn new() -> Self {
40 Self
41 }
42
43 pub fn merge(files: &ConfigFiles) -> Result<Config> {
55 let mut merged = Config::default();
56
57 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 fn merge_into(base: &mut Config, path: &Path) -> Result<()> {
79 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 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 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 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 assert_eq!(config.follow_symlinks, Some(true));
224 }
225}