cadmus_core/device/
migration.rs1use std::fs;
15use std::path::{Path, PathBuf};
16
17const MIGRATE_DIRS: &[&str] = &["Settings", "logs", "dictionaries"];
21const MIGRATE_FILES: &[&str] = &["Settings.toml"];
22
23crate::migration!(
24 "v1_migrate_data_to_sd_card",
34 async fn migrate_data_to_sd_card(_pool: &sqlx::SqlitePool) {
35 let install_dir = crate::device::CURRENT_DEVICE.install_dir();
36 let data_dir = crate::device::CURRENT_DEVICE.data_dir();
37
38 migrate_data_to_sd(install_dir, data_dir)
39
40 }
41);
42
43fn migrate_data_to_sd(install_dir: PathBuf, data_dir: PathBuf) -> anyhow::Result<()> {
44 if install_dir == data_dir {
45 return Ok(());
46 }
47
48 tracing::info!(
49 from = %install_dir.display(),
50 to = %data_dir.display(),
51 "migrating dynamic data files to sd card"
52 );
53
54 if let Err(e) = fs::create_dir_all(&data_dir) {
55 tracing::warn!(
56 path = %data_dir.display(),
57 error = %e,
58 "failed to create data dir on sd card; skipping migration"
59 );
60 anyhow::bail!("failed to create data dir {}", data_dir.display());
61 }
62
63 let mut all_ok = true;
64
65 for dirname in MIGRATE_DIRS {
66 all_ok &= migrate_dir(&install_dir.join(dirname), &data_dir.join(dirname));
67 }
68
69 for filename in MIGRATE_FILES {
70 all_ok &= migrate_file(&install_dir.join(filename), &data_dir.join(filename));
71 }
72
73 anyhow::ensure!(all_ok, "sd-card data migration completed with copy errors");
74 Ok(())
75}
76
77fn migrate_dir(src: &Path, dst: &Path) -> bool {
78 if !src.exists() {
79 return true;
80 }
81
82 if let Err(e) = fs::create_dir_all(dst) {
83 tracing::warn!(
84 path = %dst.display(),
85 error = %e,
86 "failed to create destination dir; skipping directory migration"
87 );
88 return false;
89 }
90
91 let fully_copied = copy_dir_recursive(src, dst, src);
92
93 if !fully_copied {
94 tracing::warn!(
95 path = %src.display(),
96 "skipping source directory removal; not all files were copied"
97 );
98 return false;
99 }
100
101 if let Err(e) = fs::remove_dir_all(src) {
102 tracing::warn!(
103 path = %src.display(),
104 error = %e,
105 "failed to remove source directory after migration"
106 );
107 }
108
109 true
110}
111
112fn copy_dir_recursive(src_root: &Path, dst_root: &Path, current: &Path) -> bool {
113 let entries = match fs::read_dir(current) {
114 Ok(e) => e,
115 Err(e) => {
116 tracing::warn!(
117 path = %current.display(),
118 error = %e,
119 "failed to read directory during migration"
120 );
121 return false;
122 }
123 };
124
125 let mut all_ok = true;
126
127 for entry in entries {
128 let entry = match entry {
129 Ok(e) => e,
130 Err(e) => {
131 tracing::warn!(error = %e, "failed to read directory entry during migration");
132 all_ok = false;
133 continue;
134 }
135 };
136
137 let file_type = match entry.file_type() {
138 Ok(ft) => ft,
139 Err(e) => {
140 tracing::warn!(error = %e, "failed to get file type during migration");
141 all_ok = false;
142 continue;
143 }
144 };
145
146 let rel = match entry.path().strip_prefix(src_root) {
147 Ok(r) => r.to_path_buf(),
148 Err(e) => {
149 tracing::warn!(error = %e, "failed to strip prefix during migration");
150 all_ok = false;
151 continue;
152 }
153 };
154
155 let dst_path = dst_root.join(&rel);
156
157 if file_type.is_dir() {
158 if let Err(e) = fs::create_dir_all(&dst_path) {
159 tracing::warn!(
160 path = %dst_path.display(),
161 error = %e,
162 "failed to create subdirectory during migration"
163 );
164 all_ok = false;
165 continue;
166 }
167
168 if !copy_dir_recursive(src_root, dst_root, &entry.path()) {
169 all_ok = false;
170 }
171 continue;
172 }
173
174 if dst_path.exists() {
175 continue;
176 }
177
178 if !migrate_file(&entry.path(), &dst_path) {
179 all_ok = false;
180 }
181 }
182
183 all_ok
184}
185
186fn migrate_file(src: &Path, dst: &Path) -> bool {
187 if !src.exists() || dst.exists() {
188 return true;
189 }
190
191 if let Err(e) = fs::copy(src, dst) {
192 tracing::warn!(
193 src = %src.display(),
194 dst = %dst.display(),
195 error = %e,
196 "failed to copy file during migration"
197 );
198 return false;
199 }
200
201 if let Err(e) = fs::remove_file(src) {
202 tracing::warn!(
203 path = %src.display(),
204 error = %e,
205 "failed to remove source file after migration"
206 );
207 }
208
209 tracing::debug!(path = %src.display(), "migrated file to sd card");
210
211 true
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use std::fs;
218 use tempfile::TempDir;
219
220 fn run(install: &TempDir, data: &TempDir) {
221 migrate_data_to_sd(install.path().to_path_buf(), data.path().to_path_buf())
222 .expect("migration failed");
223 }
224
225 #[test]
226 fn test_no_op_when_dirs_equal() {
227 let dir = TempDir::new().unwrap();
228 let settings = dir.path().join("Settings.toml");
229 fs::write(&settings, "key = true").unwrap();
230
231 migrate_data_to_sd(dir.path().to_path_buf(), dir.path().to_path_buf())
232 .expect("migration failed");
233
234 assert!(
235 settings.exists(),
236 "file must be untouched when dirs are equal"
237 );
238 }
239
240 #[test]
241 fn test_migrates_top_level_file() {
242 let install = TempDir::new().unwrap();
243 let data = TempDir::new().unwrap();
244
245 fs::write(install.path().join("Settings.toml"), "key = true").unwrap();
246
247 run(&install, &data);
248
249 assert!(
250 data.path().join("Settings.toml").exists(),
251 "Settings.toml must exist in data dir"
252 );
253 assert!(
254 !install.path().join("Settings.toml").exists(),
255 "Settings.toml must be removed from install dir"
256 );
257 }
258
259 #[test]
260 fn test_migrates_directory_recursively() {
261 let install = TempDir::new().unwrap();
262 let data = TempDir::new().unwrap();
263
264 let src_dir = install.path().join("Settings");
265 fs::create_dir(&src_dir).unwrap();
266 fs::write(src_dir.join("config.toml"), "x = 1").unwrap();
267
268 let nested = src_dir.join("profiles");
269 fs::create_dir(&nested).unwrap();
270 fs::write(nested.join("default.toml"), "y = 2").unwrap();
271
272 run(&install, &data);
273
274 assert!(data.path().join("Settings/config.toml").exists());
275 assert!(data.path().join("Settings/profiles/default.toml").exists());
276 assert!(
277 !install.path().join("Settings").exists(),
278 "source dir must be removed"
279 );
280 }
281
282 #[test]
283 fn test_idempotent_file() {
284 let install = TempDir::new().unwrap();
285 let data = TempDir::new().unwrap();
286
287 fs::write(install.path().join("Settings.toml"), "key = true").unwrap();
288
289 run(&install, &data);
290
291 fs::write(install.path().join("Settings.toml"), "key = false").unwrap();
292
293 run(&install, &data);
294
295 let content = fs::read_to_string(data.path().join("Settings.toml")).unwrap();
296 assert_eq!(
297 content, "key = true",
298 "second run must not overwrite already-migrated file"
299 );
300 }
301
302 #[test]
303 fn test_idempotent_dir() {
304 let install = TempDir::new().unwrap();
305 let data = TempDir::new().unwrap();
306
307 let src_dir = install.path().join("Settings");
308 fs::create_dir(&src_dir).unwrap();
309 fs::write(src_dir.join("a.toml"), "original").unwrap();
310
311 run(&install, &data);
312
313 fs::create_dir(install.path().join("Settings")).unwrap();
314 fs::write(install.path().join("Settings/a.toml"), "overwrite").unwrap();
315
316 run(&install, &data);
317
318 let content = fs::read_to_string(data.path().join("Settings/a.toml")).unwrap();
319 assert_eq!(
320 content, "original",
321 "second run must not overwrite already-migrated file"
322 );
323 }
324
325 #[test]
326 fn test_missing_source_dir_is_skipped() {
327 let install = TempDir::new().unwrap();
328 let data = TempDir::new().unwrap();
329
330 run(&install, &data);
331
332 assert!(
333 data.path().read_dir().unwrap().next().is_none(),
334 "data dir must remain empty"
335 );
336 }
337}