Skip to main content

cfgd_core/util/
reconcile.rs

1use crate::config;
2
3/// Fully resolved reconcile settings for a single entity (no Options).
4#[derive(Debug, Clone, serde::Serialize)]
5pub struct EffectiveReconcile {
6    pub interval: String,
7    pub auto_apply: bool,
8    pub drift_policy: config::DriftPolicy,
9}
10
11/// Resolve effective reconcile settings for a module given the profile
12/// inheritance chain and any patches in the global reconcile config.
13///
14/// Precedence (most specific wins):
15///   Named Module patch > Kind-wide Module patch > Named Profile patch >
16///   Kind-wide Profile patch > Global reconcile settings
17///
18/// `profile_chain` is ancestors-first, leaf-last (e.g., `["base", "work"]`).
19/// Within each level, patches apply in list order (last wins for duplicates).
20pub fn resolve_effective_reconcile(
21    module_name: &str,
22    profile_chain: &[&str],
23    reconcile: &config::ReconcileConfig,
24) -> EffectiveReconcile {
25    let mut effective = EffectiveReconcile {
26        interval: reconcile.interval.clone(),
27        auto_apply: reconcile.auto_apply,
28        drift_policy: reconcile.drift_policy.clone(),
29    };
30
31    // 1. Kind-wide Profile patch (no name = applies to all profiles)
32    if let Some(patch) = reconcile
33        .patches
34        .iter()
35        .rev()
36        .find(|p| p.kind == config::ReconcilePatchKind::Profile && p.name.is_none())
37    {
38        overlay_reconcile_patch(&mut effective, patch);
39    }
40
41    // 2. Named Profile patches in inheritance order (leaf last = leaf wins)
42    for profile_name in profile_chain {
43        if let Some(patch) = reconcile.patches.iter().rev().find(|p| {
44            p.kind == config::ReconcilePatchKind::Profile && p.name.as_deref() == Some(profile_name)
45        }) {
46            overlay_reconcile_patch(&mut effective, patch);
47        }
48    }
49
50    // 3. Kind-wide Module patch (no name = applies to all modules)
51    if let Some(patch) = reconcile
52        .patches
53        .iter()
54        .rev()
55        .find(|p| p.kind == config::ReconcilePatchKind::Module && p.name.is_none())
56    {
57        overlay_reconcile_patch(&mut effective, patch);
58    }
59
60    // 4. Named Module patch (highest priority) — last matching entry wins
61    if let Some(patch) = reconcile.patches.iter().rev().find(|p| {
62        p.kind == config::ReconcilePatchKind::Module && p.name.as_deref() == Some(module_name)
63    }) {
64        overlay_reconcile_patch(&mut effective, patch);
65    }
66
67    effective
68}
69
70/// Overlay a patch's `Some` fields onto an effective reconcile struct.
71fn overlay_reconcile_patch(base: &mut EffectiveReconcile, patch: &config::ReconcilePatch) {
72    if let Some(ref interval) = patch.interval {
73        base.interval = interval.clone();
74    }
75    if let Some(auto_apply) = patch.auto_apply {
76        base.auto_apply = auto_apply;
77    }
78    if let Some(ref dp) = patch.drift_policy {
79        base.drift_policy = dp.clone();
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    fn make_reconcile_config(patches: Vec<config::ReconcilePatch>) -> config::ReconcileConfig {
88        config::ReconcileConfig {
89            interval: "5m".into(),
90            on_change: false,
91            auto_apply: false,
92            policy: None,
93            drift_policy: config::DriftPolicy::NotifyOnly,
94            patches,
95        }
96    }
97
98    #[test]
99    fn no_patches_returns_global_defaults() {
100        let rc = make_reconcile_config(vec![]);
101        let eff = resolve_effective_reconcile("docker", &["base"], &rc);
102        assert_eq!(eff.interval, "5m");
103        assert!(!eff.auto_apply);
104        assert_eq!(eff.drift_policy, config::DriftPolicy::NotifyOnly);
105    }
106
107    #[test]
108    fn named_module_patch_overrides_global() {
109        let rc = make_reconcile_config(vec![config::ReconcilePatch {
110            kind: config::ReconcilePatchKind::Module,
111            name: Some("docker".into()),
112            interval: Some("1m".into()),
113            auto_apply: Some(true),
114            drift_policy: Some(config::DriftPolicy::Auto),
115        }]);
116        let eff = resolve_effective_reconcile("docker", &["base"], &rc);
117        assert_eq!(eff.interval, "1m");
118        assert!(eff.auto_apply);
119        assert_eq!(eff.drift_policy, config::DriftPolicy::Auto);
120    }
121
122    #[test]
123    fn named_module_patch_does_not_affect_other_modules() {
124        let rc = make_reconcile_config(vec![config::ReconcilePatch {
125            kind: config::ReconcilePatchKind::Module,
126            name: Some("docker".into()),
127            interval: Some("1m".into()),
128            auto_apply: None,
129            drift_policy: None,
130        }]);
131        let eff = resolve_effective_reconcile("kubernetes", &["base"], &rc);
132        assert_eq!(eff.interval, "5m");
133    }
134
135    #[test]
136    fn kind_wide_module_patch_applies_to_all() {
137        let rc = make_reconcile_config(vec![config::ReconcilePatch {
138            kind: config::ReconcilePatchKind::Module,
139            name: None,
140            interval: Some("2m".into()),
141            auto_apply: None,
142            drift_policy: None,
143        }]);
144        let eff = resolve_effective_reconcile("anything", &["base"], &rc);
145        assert_eq!(eff.interval, "2m");
146    }
147
148    #[test]
149    fn named_profile_patch_applies_when_in_chain() {
150        let rc = make_reconcile_config(vec![config::ReconcilePatch {
151            kind: config::ReconcilePatchKind::Profile,
152            name: Some("work".into()),
153            interval: Some("10m".into()),
154            auto_apply: Some(true),
155            drift_policy: None,
156        }]);
157        let eff = resolve_effective_reconcile("docker", &["base", "work"], &rc);
158        assert_eq!(eff.interval, "10m");
159        assert!(eff.auto_apply);
160    }
161
162    #[test]
163    fn named_module_beats_named_profile() {
164        let rc = make_reconcile_config(vec![
165            config::ReconcilePatch {
166                kind: config::ReconcilePatchKind::Profile,
167                name: Some("work".into()),
168                interval: Some("10m".into()),
169                auto_apply: None,
170                drift_policy: None,
171            },
172            config::ReconcilePatch {
173                kind: config::ReconcilePatchKind::Module,
174                name: Some("docker".into()),
175                interval: Some("30s".into()),
176                auto_apply: None,
177                drift_policy: None,
178            },
179        ]);
180        let eff = resolve_effective_reconcile("docker", &["base", "work"], &rc);
181        assert_eq!(eff.interval, "30s");
182    }
183
184    #[test]
185    fn kind_wide_profile_patch_applies() {
186        let rc = make_reconcile_config(vec![config::ReconcilePatch {
187            kind: config::ReconcilePatchKind::Profile,
188            name: None,
189            interval: None,
190            auto_apply: Some(true),
191            drift_policy: Some(config::DriftPolicy::Auto),
192        }]);
193        let eff = resolve_effective_reconcile("docker", &["base"], &rc);
194        assert!(eff.auto_apply);
195        assert_eq!(eff.drift_policy, config::DriftPolicy::Auto);
196    }
197}