Skip to main content

xianyun_core/config/
registry.rs

1//! Port of `internal/config/versions.go`: the version registry, the upgrade
2//! walker, and `loadAndUpgrade`.
3
4use super::errors::ConfigError;
5use super::latest::CaproniConfig;
6use super::version::ConfigVersion;
7use super::versions::Upgrader;
8
9/// The current schema version — the version the upgrade walker converges to.
10pub const LATEST_VERSION: ConfigVersion = ConfigVersion::V2;
11
12/// The number of registered config versions. The Go original derives this
13/// from `len(registry)`, a package-level `map[ConfigVersion]versionEntry`;
14/// this port has no mutable global registry (see `PORT_NOTES.md`), so the
15/// single currently-registered version (`Version2`) is hardcoded here
16/// instead. Update this alongside `supported_versions`/`load_and_upgrade`
17/// if a second version is ever registered.
18const REGISTERED_VERSION_COUNT: usize = 1;
19
20/// Returns the registered config versions in ascending order, formatted for
21/// error messages (e.g. "1, 2").
22pub fn supported_versions() -> String {
23    "2".to_string()
24}
25
26/// Upgrades `cfg` to the latest version by repeated single-step upgrades. It
27/// terminates when a step reports [`Upgrader::is_terminal`] (the Rust
28/// replacement for the Go original's pointer-identity check — see
29/// `versions::Upgrader::is_terminal`'s doc comment). The bound guards
30/// against a mis-implemented, non-progressing `upgrade`.
31pub fn walk(cfg: Box<dyn Upgrader>) -> Result<Box<dyn Upgrader>, ConfigError> {
32    let mut current = cfg;
33
34    for _ in 0..=REGISTERED_VERSION_COUNT {
35        if current.is_terminal() {
36            return Ok(current);
37        }
38
39        current = current
40            .upgrade()
41            .map_err(|err| ConfigError::SemanticValidation(format!("upgrade step: {err}")))?;
42    }
43
44    Err(ConfigError::UpgradeLoop)
45}
46
47/// Constructs the declared version's struct, unmarshals data into it, applies
48/// defaults, and walks the upgrade chain to the latest config.
49///
50/// `apply_latest_defaults` controls the load-vs-seed defaults asymmetry: the
51/// load path passes `true` (a native-latest config is defaulted); the seed
52/// path passes `false` (a native-latest config is NOT defaulted). A
53/// non-latest declared version is always defaulted, because its conversion
54/// depends on its own version's defaults having run — moot today since only
55/// `Version2` (the latest) is registered, but kept for fidelity with the Go
56/// original's `declared != LatestVersion || applyLatestDefaults` condition.
57pub fn load_and_upgrade(
58    declared: ConfigVersion,
59    data: &[u8],
60    apply_latest_defaults: bool,
61) -> Result<CaproniConfig, ConfigError> {
62    if declared != LATEST_VERSION {
63        return Err(ConfigError::UnsupportedVersion);
64    }
65
66    let mut cfg: CaproniConfig =
67        serde_yaml::from_slice(data).map_err(|err| ConfigError::InvalidYAML(err.to_string()))?;
68
69    if declared != LATEST_VERSION || apply_latest_defaults {
70        cfg.apply_defaults()
71            .map_err(|err| ConfigError::SemanticValidation(format!("applying defaults: {err}")))?;
72    }
73
74    let upgraded = walk(Box::new(cfg)).map_err(|err| {
75        ConfigError::SemanticValidation(format!(
76            "upgrading config declared as version {declared}: {err}"
77        ))
78    })?;
79
80    upgraded
81        .as_any()
82        .downcast_ref::<CaproniConfig>()
83        .cloned()
84        .ok_or(ConfigError::UpgradeLoop)
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    /// Mimics the latest version: `is_terminal` reports `true`, so `walk`
92    /// stops without calling `upgrade` at all — the port's replacement for
93    /// the Go original's `Upgrade` returning the receiver's own pointer.
94    struct TerminalUpgrader;
95
96    impl Upgrader for TerminalUpgrader {
97        fn upgrade(&self) -> Result<Box<dyn Upgrader>, super::super::versions::UpgraderError> {
98            unreachable!("walk must not call upgrade on a terminal value")
99        }
100
101        fn is_terminal(&self) -> bool {
102            true
103        }
104
105        fn as_any(&self) -> &dyn std::any::Any {
106            self
107        }
108    }
109
110    /// Upgrades exactly once, to a fixed terminal upgrader.
111    struct OneStepUpgrader;
112
113    impl Upgrader for OneStepUpgrader {
114        fn upgrade(&self) -> Result<Box<dyn Upgrader>, super::super::versions::UpgraderError> {
115            Ok(Box::new(TerminalUpgrader))
116        }
117
118        fn as_any(&self) -> &dyn std::any::Any {
119            self
120        }
121    }
122
123    /// Never converges: every `upgrade` returns a fresh, distinct value, so
124    /// `walk`'s bound is what must stop it.
125    struct RunawayUpgrader {
126        step: u32,
127    }
128
129    impl Upgrader for RunawayUpgrader {
130        fn upgrade(&self) -> Result<Box<dyn Upgrader>, super::super::versions::UpgraderError> {
131            Ok(Box::new(RunawayUpgrader {
132                step: self.step + 1,
133            }))
134        }
135
136        fn as_any(&self) -> &dyn std::any::Any {
137            self
138        }
139    }
140
141    // Port of TestWalkNativeLatestIsNoOp.
142    #[test]
143    fn walk_native_latest_is_noop() {
144        let data = b"version: 2\ncluster:\n  driver: mock\n";
145        let cfg = load_and_upgrade(ConfigVersion::V2, data, true).expect("load should succeed");
146        assert_eq!(cfg.version, 2);
147        assert!(
148            cfg.cluster.name.is_some(),
149            "latest defaults applied for a native-latest config"
150        );
151    }
152
153    // Port of TestLoadAndUpgradeUnsupportedVersion.
154    #[test]
155    fn load_and_upgrade_unsupported_version() {
156        let err = load_and_upgrade(ConfigVersion::Unknown, b"version: 99\n", true).unwrap_err();
157        assert!(matches!(err, ConfigError::UnsupportedVersion));
158    }
159
160    // Port of TestSeedSkipsLatestDefaults.
161    #[test]
162    fn seed_skips_latest_defaults() {
163        let data = b"version: 2\ncluster:\n  driver: mock\n";
164        let cfg = load_and_upgrade(ConfigVersion::V2, data, false).expect("load should succeed");
165        assert!(cfg.cluster.name.is_none());
166    }
167
168    // Port of TestWalkConvergesAfterUpgradeStep.
169    #[test]
170    fn walk_converges_after_upgrade_step() {
171        let start: Box<dyn Upgrader> = Box::new(OneStepUpgrader);
172        let got = walk(start).expect("walk should succeed");
173        let _ = got; // pointer-identity assertion is not portable; see PORT_NOTES.md
174    }
175
176    // Port of TestWalkStopsRunawayUpgradeWithBound.
177    #[test]
178    fn walk_stops_runaway_upgrade_with_bound() {
179        let start: Box<dyn Upgrader> = Box::new(RunawayUpgrader { step: 0 });
180        match walk(start) {
181            Err(err) => assert!(matches!(err, ConfigError::UpgradeLoop)),
182            Ok(_) => panic!("expected ErrUpgradeLoop"),
183        }
184    }
185}