1use crate::config::git_provider::GitConfigProvider;
2use crate::config::source::{ConfigSource, Sourced};
3use crate::config::types::{Component, Config, Settings, UnmatchedPolicy};
4use crate::core::ConfigError;
5use figment::Figment;
6use figment::providers::{Env, Format, Serialized, Toml};
7use figment::value::Tag;
8use figment::value::magic::Tagged;
9use serde::Deserialize;
10use std::path::Path;
11
12#[derive(Debug, Deserialize)]
14struct TaggedSettings {
15 #[serde(default = "default_base_branch")]
16 base_branch: Tagged<String>,
17 #[serde(default = "default_branch_template")]
18 branch_template: Tagged<String>,
19 #[serde(default = "default_unmatched_files")]
20 unmatched_files: Tagged<UnmatchedPolicy>,
21 default_commit_type: Option<Tagged<String>>,
22}
23
24impl Default for TaggedSettings {
25 fn default() -> Self {
26 Self {
27 base_branch: default_base_branch(),
28 branch_template: default_branch_template(),
29 unmatched_files: default_unmatched_files(),
30 default_commit_type: None,
31 }
32 }
33}
34
35fn default_base_branch() -> Tagged<String> {
36 Tagged::from("main".to_string())
37}
38
39fn default_branch_template() -> Tagged<String> {
40 Tagged::from("atomic/{component}".to_string())
41}
42
43fn default_unmatched_files() -> Tagged<UnmatchedPolicy> {
44 Tagged::from(UnmatchedPolicy::Error)
45}
46
47#[derive(Debug, Deserialize)]
49struct TaggedConfig {
50 #[serde(default)]
51 settings: TaggedSettings,
52 #[serde(default)]
53 components: Vec<Component>,
54}
55
56#[derive(Debug)]
58pub struct ResolvedConfig {
59 pub base_branch: Sourced<String>,
60 pub branch_template: Sourced<String>,
61 pub unmatched_files: Sourced<UnmatchedPolicy>,
62 pub default_commit_type: Sourced<Option<String>>,
63 pub components: Vec<Component>,
65}
66
67impl ResolvedConfig {
68 pub fn to_config(&self) -> Config {
70 Config {
71 settings: Settings {
72 base_branch: self.base_branch.value.clone(),
73 branch_template: self.branch_template.value.clone(),
74 unmatched_files: self.unmatched_files.value.clone(),
75 default_commit_type: self.default_commit_type.value.clone(),
76 },
77 components: self.components.clone(),
78 }
79 }
80}
81
82fn source_from_metadata_name(name: &str) -> ConfigSource {
84 if name == "git config" {
85 ConfigSource::GitConfig
86 } else if name.contains(".atomic.toml") || name.starts_with("TOML") {
87 ConfigSource::File
88 } else if name.contains("GIT_ATOMIC") || name.contains("env") {
89 ConfigSource::Env
90 } else {
91 ConfigSource::Default
92 }
93}
94
95fn resolve_source(figment: &Figment, tag: Tag) -> ConfigSource {
97 if tag.is_default() {
98 return ConfigSource::Default;
99 }
100 match figment.get_metadata(tag) {
101 Some(md) => source_from_metadata_name(&md.name),
102 None => ConfigSource::Default,
103 }
104}
105
106pub fn load_layered_config(
114 repo: Option<&gix::Repository>,
115 config_path: &Path,
116) -> Result<ResolvedConfig, ConfigError> {
117 let mut figment = Figment::new()
118 .merge(Serialized::defaults(Settings::default()))
119 .merge(GitConfigProvider::new(repo));
120
121 if config_path.exists() {
122 figment = figment.merge(Toml::file(config_path));
123 }
124
125 figment = figment.merge(Env::prefixed("GIT_ATOMIC_").map(|key| {
126 let k = key.as_str().to_lowercase();
128 format!("settings.{k}").into()
129 }));
130
131 let tagged: TaggedConfig = figment.extract().map_err(|e| ConfigError::Invalid {
132 reason: e.to_string(),
133 })?;
134
135 let mut seen = std::collections::HashSet::new();
137 for component in &tagged.components {
138 if !seen.insert(&component.name) {
139 return Err(ConfigError::Invalid {
140 reason: format!("duplicate component name: {:?}", component.name),
141 });
142 }
143 for pattern in &component.globs {
145 globset::Glob::new(pattern).map_err(|e| ConfigError::InvalidGlob {
146 component: component.name.clone(),
147 pattern: pattern.clone(),
148 reason: e.to_string(),
149 })?;
150 }
151 }
152
153 let base_branch_source = resolve_source(&figment, tagged.settings.base_branch.tag());
155 let branch_template_source = resolve_source(&figment, tagged.settings.branch_template.tag());
156 let unmatched_files_source = resolve_source(&figment, tagged.settings.unmatched_files.tag());
157 let default_commit_type_source = tagged
158 .settings
159 .default_commit_type
160 .as_ref()
161 .map(|t| resolve_source(&figment, t.tag()))
162 .unwrap_or(ConfigSource::Default);
163
164 Ok(ResolvedConfig {
165 base_branch: Sourced::new(tagged.settings.base_branch.into_inner(), base_branch_source),
166 branch_template: Sourced::new(
167 tagged.settings.branch_template.into_inner(),
168 branch_template_source,
169 ),
170 unmatched_files: Sourced::new(
171 tagged.settings.unmatched_files.into_inner(),
172 unmatched_files_source,
173 ),
174 default_commit_type: Sourced::new(
175 tagged.settings.default_commit_type.map(|t| t.into_inner()),
176 default_commit_type_source,
177 ),
178 components: tagged.components,
179 })
180}
181
182#[derive(Debug)]
184pub struct ConfigWarning {
185 pub message: String,
186}
187
188pub fn validate_resolved(config: &ResolvedConfig) -> Vec<ConfigWarning> {
190 let mut warnings = Vec::new();
191
192 if config.components.is_empty() {
193 warnings.push(ConfigWarning {
194 message: "no components defined — create .atomic.toml with [[components]] or run git-atomic init".into(),
195 });
196 }
197
198 if !config.branch_template.value.contains("{component}") {
199 warnings.push(ConfigWarning {
200 message: format!(
201 "branch_template {:?} does not contain {{component}} placeholder",
202 config.branch_template.value
203 ),
204 });
205 }
206
207 if config.base_branch.value.is_empty() {
208 warnings.push(ConfigWarning {
209 message: "base_branch is empty".into(),
210 });
211 }
212
213 warnings
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn defaults_when_no_file() {
222 figment::Jail::expect_with(|_jail| {
223 let resolved = load_layered_config(None, Path::new("nonexistent.toml")).unwrap();
224 assert_eq!(resolved.base_branch.value, "main");
225 assert_eq!(resolved.base_branch.source, ConfigSource::Default);
226 assert!(resolved.components.is_empty());
227 Ok(())
228 });
229 }
230
231 #[test]
232 fn file_overrides_defaults() {
233 figment::Jail::expect_with(|jail| {
234 jail.create_file(
235 ".atomic.toml",
236 r#"
237[settings]
238base_branch = "develop"
239
240[[components]]
241name = "app"
242globs = ["src/**"]
243"#,
244 )?;
245
246 let resolved = load_layered_config(None, Path::new(".atomic.toml")).unwrap();
247 assert_eq!(resolved.base_branch.value, "develop");
248 assert_eq!(resolved.base_branch.source, ConfigSource::File);
249 assert_eq!(resolved.components.len(), 1);
250
251 Ok(())
252 });
253 }
254
255 #[test]
256 fn env_overrides_file() {
257 figment::Jail::expect_with(|jail| {
258 jail.create_file(
259 ".atomic.toml",
260 r#"
261[settings]
262base_branch = "develop"
263
264[[components]]
265name = "app"
266globs = ["src/**"]
267"#,
268 )?;
269
270 jail.set_env("GIT_ATOMIC_BASE_BRANCH", "staging");
271
272 let resolved = load_layered_config(None, Path::new(".atomic.toml")).unwrap();
273 assert_eq!(resolved.base_branch.value, "staging");
274 assert_eq!(resolved.base_branch.source, ConfigSource::Env);
275
276 Ok(())
277 });
278 }
279
280 #[test]
281 fn duplicate_component_names_rejected() {
282 figment::Jail::expect_with(|jail| {
283 jail.create_file(
284 ".atomic.toml",
285 r#"
286[[components]]
287name = "app"
288globs = ["src/**"]
289
290[[components]]
291name = "app"
292globs = ["lib/**"]
293"#,
294 )?;
295
296 let err = load_layered_config(None, Path::new(".atomic.toml")).unwrap_err();
297 assert!(matches!(err, ConfigError::Invalid { .. }));
298 Ok(())
299 });
300 }
301
302 #[test]
303 fn component_order_preserved() {
304 figment::Jail::expect_with(|jail| {
305 jail.create_file(
306 ".atomic.toml",
307 r#"
308[[components]]
309name = "zebra"
310globs = ["z/**"]
311
312[[components]]
313name = "alpha"
314globs = ["a/**"]
315"#,
316 )?;
317
318 let resolved = load_layered_config(None, Path::new(".atomic.toml")).unwrap();
319 assert_eq!(resolved.components[0].name, "zebra");
320 assert_eq!(resolved.components[1].name, "alpha");
321 Ok(())
322 });
323 }
324
325 #[test]
326 fn validate_resolved_warns_on_bad_template() {
327 let resolved = ResolvedConfig {
328 base_branch: Sourced::new("main".into(), ConfigSource::Default),
329 branch_template: Sourced::new("bad-template".into(), ConfigSource::Default),
330 unmatched_files: Sourced::new(UnmatchedPolicy::Error, ConfigSource::Default),
331 default_commit_type: Sourced::new(None, ConfigSource::Default),
332 components: vec![Component {
333 name: "app".into(),
334 globs: vec!["src/**".into()],
335 commit_type: None,
336 branch: None,
337 }],
338 };
339
340 let warnings = validate_resolved(&resolved);
341 assert_eq!(warnings.len(), 1);
342 assert!(warnings[0].message.contains("{component}"));
343 }
344
345 #[test]
346 fn validate_resolved_warns_on_no_components() {
347 let resolved = ResolvedConfig {
348 base_branch: Sourced::new("main".into(), ConfigSource::Default),
349 branch_template: Sourced::new("atomic/{component}".into(), ConfigSource::Default),
350 unmatched_files: Sourced::new(UnmatchedPolicy::Error, ConfigSource::Default),
351 default_commit_type: Sourced::new(None, ConfigSource::Default),
352 components: vec![],
353 };
354
355 let warnings = validate_resolved(&resolved);
356 assert_eq!(warnings.len(), 1);
357 assert!(warnings[0].message.contains("no components defined"));
358 }
359
360 #[test]
361 fn to_config_bridges_correctly() {
362 let resolved = ResolvedConfig {
363 base_branch: Sourced::new("develop".into(), ConfigSource::File),
364 branch_template: Sourced::new("atomic/{component}".into(), ConfigSource::Default),
365 unmatched_files: Sourced::new(UnmatchedPolicy::Warn, ConfigSource::GitConfig),
366 default_commit_type: Sourced::new(Some("feat".into()), ConfigSource::Env),
367 components: vec![Component {
368 name: "app".into(),
369 globs: vec!["src/**".into()],
370 commit_type: None,
371 branch: None,
372 }],
373 };
374
375 let config = resolved.to_config();
376 assert_eq!(config.settings.base_branch, "develop");
377 assert_eq!(config.settings.unmatched_files, UnmatchedPolicy::Warn);
378 assert_eq!(config.components.len(), 1);
379 assert_eq!(config.components[0].name, "app");
380 }
381
382 #[test]
383 fn validate_resolved_warns_on_empty_base_branch() {
384 let resolved = ResolvedConfig {
385 base_branch: Sourced::new("".into(), ConfigSource::File),
386 branch_template: Sourced::new("atomic/{component}".into(), ConfigSource::Default),
387 unmatched_files: Sourced::new(UnmatchedPolicy::Error, ConfigSource::Default),
388 default_commit_type: Sourced::new(None, ConfigSource::Default),
389 components: vec![Component {
390 name: "app".into(),
391 globs: vec!["src/**".into()],
392 commit_type: None,
393 branch: None,
394 }],
395 };
396
397 let warnings = validate_resolved(&resolved);
398 assert!(
399 warnings
400 .iter()
401 .any(|w| w.message.contains("base_branch is empty"))
402 );
403 }
404
405 #[test]
406 fn env_overrides_branch_template() {
407 figment::Jail::expect_with(|jail| {
408 jail.create_file(
409 ".atomic.toml",
410 r#"
411[[components]]
412name = "app"
413globs = ["src/**"]
414"#,
415 )?;
416
417 jail.set_env("GIT_ATOMIC_BRANCH_TEMPLATE", "custom/{component}");
418
419 let resolved = load_layered_config(None, Path::new(".atomic.toml")).unwrap();
420 assert_eq!(resolved.branch_template.value, "custom/{component}");
421 assert_eq!(resolved.branch_template.source, ConfigSource::Env);
422
423 Ok(())
424 });
425 }
426}