xianyun_core/config/
migration.rs1use 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#[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 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
65pub 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
77fn 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 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
104fn 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
123fn 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
148fn 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 #[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 #[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 #[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 #[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}