Skip to main content

xianyun_core/config/
migration.rs

1//! Port of `internal/config/migration.go`: structural upgrade of a config
2//! file to the latest schema, in memory (`PlanMigration`) or committed to
3//! disk (`MigrationPlan::write`).
4
5use super::errors::ConfigError;
6use super::registry::{LATEST_VERSION, load_and_upgrade};
7use super::version::{ConfigVersion, detect_version_from_bytes};
8
9#[derive(Debug, Clone)]
10pub struct MigrationResult {
11    pub original_file: String,
12    pub backup_file: String,
13    pub from_version: ConfigVersion,
14    pub to_version: ConfigVersion,
15}
16
17#[derive(Debug, Clone)]
18pub struct MigrationOptions {
19    pub backup_suffix: String,
20    pub skip_backup: bool,
21}
22
23impl Default for MigrationOptions {
24    fn default() -> Self {
25        Self {
26            backup_suffix: ".bak".to_string(),
27            skip_backup: false,
28        }
29    }
30}
31
32/// A computed, in-memory upgrade of a config file to the latest format,
33/// produced by [`plan_migration`] without touching disk.
34#[derive(Debug, Clone)]
35pub struct MigrationPlan {
36    pub path: String,
37    pub from_version: ConfigVersion,
38    pub content: Vec<u8>,
39}
40
41impl MigrationPlan {
42    /// Commits the planned upgrade to disk in place (with a backup unless
43    /// disabled) and returns the outcome.
44    pub fn write(&self, opts: Option<MigrationOptions>) -> Result<MigrationResult, ConfigError> {
45        let opts = opts.unwrap_or_default();
46
47        let mut result = MigrationResult {
48            original_file: self.path.clone(),
49            backup_file: format!("{}{}", self.path, opts.backup_suffix),
50            from_version: self.from_version,
51            to_version: LATEST_VERSION,
52        };
53
54        if !opts.skip_backup {
55            result.backup_file = create_backup(&self.path, &result.backup_file)?;
56        }
57
58        std::fs::write(&self.path, &self.content)
59            .map_err(|err| ConfigError::InvalidYAML(format!("writing migrated config: {err}")))?;
60
61        Ok(result)
62    }
63}
64
65/// Reads the config at `path` and computes its upgrade to the latest format
66/// without writing anything. Returns [`ConfigError::AlreadyMigrated`] if the
67/// file is already at the latest version.
68pub fn plan_migration(path: &str) -> Result<MigrationPlan, ConfigError> {
69    let (content, from) = upgrade_file_to_latest(path)?;
70    Ok(MigrationPlan {
71        path: path.to_string(),
72        from_version: from,
73        content,
74    })
75}
76
77/// Reads the config at `path`, structurally upgrades it through the version
78/// chain to the latest schema, and returns the marshaled YAML along with the
79/// file's detected source version. It operates on the raw file (`extends`
80/// and variables are not resolved), so the output is a faithful
81/// re-expression of the input in the latest format.
82fn upgrade_file_to_latest(path: &str) -> Result<(Vec<u8>, ConfigVersion), ConfigError> {
83    let data =
84        std::fs::read(path).map_err(|_| ConfigError::ConfigFileNotFound(path.to_string()))?;
85
86    let version = detect_version_from_bytes(&data)?;
87
88    if version == LATEST_VERSION {
89        return Err(ConfigError::AlreadyMigrated);
90    }
91
92    // `apply_latest_defaults` is false: migrate performs a structural
93    // upgrade and must not inject latest defaults into the on-disk file. (A
94    // non-latest declared version is still defaulted, since its conversion
95    // depends on it — moot today, as only the latest version is registered.)
96    let upgraded = load_and_upgrade(version, &data, false)?;
97
98    let out = serde_yaml::to_string(&upgraded)
99        .map_err(|err| ConfigError::InvalidYAML(format!("marshaling upgraded config: {err}")))?;
100
101    Ok((out.into_bytes(), version))
102}
103
104/// Creates a backup of the original file, returning the actual path used
105/// (which may differ from `backup_path` if a file already exists there).
106fn create_backup(original_path: &str, backup_path: &str) -> Result<String, ConfigError> {
107    let data = std::fs::read(original_path)
108        .map_err(|err| ConfigError::InvalidYAML(format!("reading original file: {err}")))?;
109
110    let actual_backup_path = if std::path::Path::new(backup_path).exists() {
111        let timestamp = chrono_like_timestamp();
112        format!("{backup_path}.{timestamp}")
113    } else {
114        backup_path.to_string()
115    };
116
117    std::fs::write(&actual_backup_path, data)
118        .map_err(|err| ConfigError::InvalidYAML(format!("writing backup file: {err}")))?;
119
120    Ok(actual_backup_path)
121}
122
123/// A `YYYYMMDD-HHMMSS`-shaped timestamp for disambiguating backup filenames,
124/// mirroring the Go original's `time.Now().Format("20060102-150405")`. No
125/// date/time crate is added for this single call site: the calendar
126/// arithmetic is done by hand against `SystemTime`, in UTC (the Go original
127/// uses the local timezone, but the timestamp's only purpose is uniqueness
128/// within a backup-suffix collision, not a user-facing wall-clock value).
129fn chrono_like_timestamp() -> String {
130    let now = std::time::SystemTime::now()
131        .duration_since(std::time::UNIX_EPOCH)
132        .unwrap_or_default()
133        .as_secs();
134
135    let days = now / 86400;
136    let secs_of_day = now % 86400;
137    let (hour, minute, second) = (
138        secs_of_day / 3600,
139        (secs_of_day % 3600) / 60,
140        secs_of_day % 60,
141    );
142
143    let (year, month, day) = civil_from_days(days as i64);
144
145    format!("{year:04}{month:02}{day:02}-{hour:02}{minute:02}{second:02}")
146}
147
148/// Converts a day count since the Unix epoch to a (year, month, day) civil
149/// date, using Howard Hinnant's well-known `civil_from_days` algorithm
150/// (proleptic Gregorian calendar, valid for the full range of `i64` days).
151fn civil_from_days(z: i64) -> (i64, u32, u32) {
152    let z = z + 719_468;
153    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
154    let doe = (z - era * 146_097) as u64;
155    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
156    let y = yoe as i64 + era * 400;
157    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
158    let mp = (5 * doy + 2) / 153;
159    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
160    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
161
162    (if m <= 2 { y + 1 } else { y }, m, d)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use std::fs;
169
170    fn write_latest_config() -> (tempfile::TempDir, String) {
171        let dir = tempfile::tempdir().unwrap();
172        let path = dir.path().join("caproni.yaml");
173        fs::write(&path, "version: 2\ncluster:\n  driver: mock\n").unwrap();
174        (dir, path.to_string_lossy().into_owned())
175    }
176
177    // Port of TestPlanMigration_AlreadyLatest.
178    #[test]
179    fn plan_migration_already_latest() {
180        let (_dir, path) = write_latest_config();
181        let err = plan_migration(&path).unwrap_err();
182        assert!(matches!(err, ConfigError::AlreadyMigrated));
183    }
184
185    // Port of TestPlanMigration_UnsupportedVersion.
186    #[test]
187    fn plan_migration_unsupported_version() {
188        let dir = tempfile::tempdir().unwrap();
189        let path = dir.path().join("caproni.yaml");
190        fs::write(&path, "version: 99\ncluster:\n  driver: mock\n").unwrap();
191
192        let err = plan_migration(&path.to_string_lossy()).unwrap_err();
193        assert!(matches!(err, ConfigError::UnsupportedVersion));
194    }
195
196    // Port of TestPlanMigration_FileNotFound.
197    #[test]
198    fn plan_migration_file_not_found() {
199        let dir = tempfile::tempdir().unwrap();
200        let path = dir.path().join("does-not-exist.yaml");
201
202        let err = plan_migration(&path.to_string_lossy()).unwrap_err();
203        assert!(matches!(err, ConfigError::ConfigFileNotFound(_)));
204    }
205
206    // Port of TestPlanMigration_UpgradesDeprecatedVersion and
207    // TestPlanMigration_DoesNotWrite (migration_upgrade_test.go): both mutate
208    // the process-global Go version registry to register a synthetic
209    // deprecated version. The Rust `registry` module (see
210    // `super::registry`) has no such global-mutation seam yet, so these two
211    // are deferred until that design lands; see PORT_NOTES.md.
212
213    // Port of TestMigrationPlan_Write.
214    #[test]
215    fn migration_plan_write_writes_content_and_backs_up_the_original() {
216        let dir = tempfile::tempdir().unwrap();
217        let path = dir.path().join("caproni.yaml");
218        let original = b"version: 1\ncluster:\n  driver: mock\n";
219        fs::write(&path, original).unwrap();
220
221        let plan = MigrationPlan {
222            path: path.to_string_lossy().into_owned(),
223            from_version: ConfigVersion::Unknown,
224            content: b"version: 2\n".to_vec(),
225        };
226
227        let result = plan
228            .write(Some(MigrationOptions {
229                backup_suffix: ".bak".into(),
230                skip_backup: false,
231            }))
232            .expect("write should succeed");
233        assert_eq!(result.to_version, super::super::registry::LATEST_VERSION);
234
235        let written = fs::read(&path).unwrap();
236        assert_eq!(written, b"version: 2\n");
237
238        let backup = fs::read(&result.backup_file).unwrap();
239        assert_eq!(backup, original);
240    }
241
242    #[test]
243    fn migration_plan_write_skips_the_backup_when_requested() {
244        let dir = tempfile::tempdir().unwrap();
245        let path = dir.path().join("caproni.yaml");
246        fs::write(&path, "version: 1\n").unwrap();
247
248        let plan = MigrationPlan {
249            path: path.to_string_lossy().into_owned(),
250            from_version: ConfigVersion::Unknown,
251            content: b"version: 2\n".to_vec(),
252        };
253
254        plan.write(Some(MigrationOptions {
255            backup_suffix: ".bak".into(),
256            skip_backup: true,
257        }))
258        .expect("write should succeed");
259
260        let bak_files: Vec<_> = fs::read_dir(dir.path())
261            .unwrap()
262            .filter_map(|e| e.ok())
263            .filter(|e| e.file_name().to_string_lossy().contains(".bak"))
264            .collect();
265        assert!(
266            bak_files.is_empty(),
267            "no backup should be created when skipped"
268        );
269    }
270}