Skip to main content

git_atomic/config/
layered.rs

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/// Internal extraction target that uses `Tagged` for provenance tracking.
13#[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/// Internal extraction target matching Config but with tagged settings.
48#[derive(Debug, Deserialize)]
49struct TaggedConfig {
50    #[serde(default)]
51    settings: TaggedSettings,
52    #[serde(default)]
53    components: Vec<Component>,
54}
55
56/// Fully resolved configuration with provenance tracking.
57#[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    /// Components from .atomic.toml. Order preserved by TOML array-of-tables spec.
64    pub components: Vec<Component>,
65}
66
67impl ResolvedConfig {
68    /// Convert back to a plain `Config` for use with `ComponentMatcher` etc.
69    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
82/// Map a figment metadata name to our `ConfigSource` enum.
83fn 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
95/// Resolve the source of a `Tagged<T>` value from the figment.
96fn 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
106/// Load configuration with layered resolution and provenance tracking.
107///
108/// Priority: defaults < git config < .atomic.toml < ENV
109///
110/// `repo` may be `None` if not inside a git repository (e.g. `init` outside a repo).
111/// `config_path` may point to a non-existent file (settings resolve from other sources;
112/// components will be empty).
113pub 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        // GIT_ATOMIC_BASE_BRANCH -> settings.base_branch
127        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    // Validate component name uniqueness
136    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        // Validate globs
144        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    // Build provenance-tracked config
154    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/// Configuration warnings (non-fatal).
183#[derive(Debug)]
184pub struct ConfigWarning {
185    pub message: String,
186}
187
188/// Validate the resolved (merged) configuration for cross-source consistency.
189pub 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}