Skip to main content

cadmus_core/device/
migration.rs

1//! One-time migration of dynamic data files from the install directory to the
2//! SD card data directory.
3//!
4//! This migration runs once at startup when a device with removable storage has
5//! an SD card mounted. It moves settings, logs, and dictionaries — but **not**
6//! the SQLite database, which is already open by the time migrations run.
7//!
8//! # Idempotency
9//!
10//! A directory is skipped if the source no longer exists. A file is skipped if
11//! the destination already exists. Re-running the migration after a partial
12//! failure is safe.
13
14use std::fs;
15use std::path::{Path, PathBuf};
16
17/// Directories and individual files to migrate from the install dir to the data
18/// dir. Dictionaries, settings, and logs are included; the SQLite database is
19/// excluded because it is already open when this runs.
20const MIGRATE_DIRS: &[&str] = &["Settings", "logs", "dictionaries"];
21const MIGRATE_FILES: &[&str] = &["Settings.toml"];
22
23crate::migration!(
24    /// Migrates dynamic data files from the install directory to the SD card
25    /// data directory on devices with removable storage.
26    ///
27    /// Settings, logs, and dictionaries are moved. The SQLite database is
28    /// excluded because it is already open when migrations run.
29    ///
30    /// This migration is a no-op when no SD card is present (`data_dir` equals
31    /// `install_dir`). It will be recorded as succeeded so it does not re-run
32    /// on subsequent boots without a card.
33    "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}