Skip to main content

scope/paths/
migration.rs

1//! Copy the user's files from the old locations into the scope home, once.
2//!
3//! Old versions kept the config, venues and address book in
4//! `~/.config/scope` (or the platform config folder), and tokens and TUI
5//! state in the platform data folder. A start copies every old file whose
6//! new copy does not exist. Nothing old is deleted or changed, and no new
7//! file is written over. Each copy, and each failed copy, gets one line in
8//! `state/migration.jsonl` (the audit record).
9//!
10//! It happens once: scope has one home. When a run that copied files
11//! finishes, the log gets a `complete` line, and no later start reads the
12//! old folders again, so a copy the user deleted stays deleted and a file
13//! added to an old folder is ignored. A log from before that line existed
14//! still stops a file it shows as copied from being copied back. A start
15//! with nothing to copy writes nothing, so a read-only home works.
16
17use crate::error::{Result, ScopeError};
18use std::io::Write as _;
19use std::path::{Path, PathBuf};
20
21/// One old file and its new place.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Item {
24    pub from: PathBuf,
25    pub to: PathBuf,
26}
27
28/// What happened to one item.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Outcome {
31    Copied,
32    KeptExisting,
33    NoSource,
34    /// Copied by an earlier start: the user may have deleted the copy.
35    CopiedBefore,
36}
37
38impl Outcome {
39    fn as_str(self) -> &'static str {
40        match self {
41            Outcome::Copied => "copied",
42            Outcome::KeptExisting => "kept-existing",
43            Outcome::NoSource => "no-source",
44            Outcome::CopiedBefore => "copied-before",
45        }
46    }
47}
48
49/// The result of a migration run.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Report {
52    pub copied: usize,
53    /// The old folders the copied files came from, each once, in order.
54    pub from: Vec<PathBuf>,
55}
56
57/// Files in the old platform data folder (`<data>/scope/`) and where they go.
58const DATA_FILES: [(&str, &str); 4] = [
59    ("tokens.yaml", "data/tokens.yaml"),
60    ("session.yaml", "state/session.yaml"),
61    ("history.txt", "state/history.txt"),
62    ("tui-events.jsonl", "state/tui-events.jsonl"),
63];
64
65/// The real old-to-new list for this machine.
66pub fn legacy_items() -> Vec<Item> {
67    let xdg = super::user_home().map(|h| h.join(".config").join("scope"));
68    items_for(
69        xdg.as_deref(),
70        super::legacy_config_dir().as_deref(),
71        super::legacy_data_dir().as_deref(),
72        &super::home(),
73    )
74}
75
76/// The old-to-new list for the given old roots and new home.
77/// `xdg_root` is `~/.config/scope`; the platform roots are the folders
78/// above their `scope` subfolder.
79pub fn items_for(
80    xdg_root: Option<&Path>,
81    platform_config: Option<&Path>,
82    platform_data: Option<&Path>,
83    home: &Path,
84) -> Vec<Item> {
85    let mut items = Vec::new();
86    // The config: ~/.config/scope first, then the platform folder, as old
87    // versions read it.
88    let config_from = [
89        xdg_root.map(|r| r.join("config.yaml")),
90        platform_config.map(|r| r.join("scope").join("config.yaml")),
91    ]
92    .into_iter()
93    .flatten()
94    .find(|p| p.exists())
95    .or_else(|| xdg_root.map(|r| r.join("config.yaml")));
96    if let Some(from) = config_from {
97        items.push(Item {
98            from,
99            to: home.join("config.yaml"),
100        });
101    }
102    if let Some(xdg) = xdg_root {
103        items.push(Item {
104            from: xdg.join("data").join("address_book.yaml"),
105            to: home.join("data").join("address_book.yaml"),
106        });
107        if let Ok(entries) = std::fs::read_dir(xdg.join("venues")) {
108            let mut venues: Vec<PathBuf> = entries
109                .flatten()
110                .map(|e| e.path())
111                .filter(|p| p.extension().is_some_and(|e| e == "yaml" || e == "yml"))
112                .collect();
113            venues.sort();
114            for from in venues {
115                let to = home
116                    .join("venues")
117                    .join(from.file_name().unwrap_or_default());
118                items.push(Item { from, to });
119            }
120        }
121    }
122    if let Some(data) = platform_data {
123        for (old, new) in DATA_FILES {
124            items.push(Item {
125                from: data.join("scope").join(old),
126                to: home.join(new),
127            });
128        }
129    }
130    items
131}
132
133/// Copies each item whose source exists, whose destination does not, and
134/// that `log` does not show as copied already, logging each copy and each
135/// failure to `log`. Stops at the first failed copy with an error that
136/// names the file; the files copied before it stay.
137pub fn run(items: &[Item], log: &Path) -> Result<Report> {
138    let entries = log_entries(log);
139    if entries.iter().any(|v| v["result"] == COMPLETE) {
140        return Ok(Report {
141            copied: 0,
142            from: Vec::new(),
143        });
144    }
145    let done = copied_before(&entries);
146    let mut copied = 0;
147    let mut from: Vec<PathBuf> = Vec::new();
148    for item in items {
149        let result = if done.contains(&(item.from.clone(), item.to.clone())) {
150            Ok(Outcome::CopiedBefore)
151        } else {
152            copy_one(item)
153        };
154        match &result {
155            Ok(Outcome::Copied) => append_log(log, item, Outcome::Copied.as_str())?,
156            Ok(_) => {}
157            Err(e) => append_log(log, item, &format!("failed: {e}"))?,
158        }
159        match result {
160            Ok(Outcome::Copied) => {
161                copied += 1;
162                let dir = item
163                    .from
164                    .parent()
165                    .map(Path::to_path_buf)
166                    .unwrap_or_default();
167                if !from.contains(&dir) {
168                    from.push(dir);
169                }
170            }
171            Ok(_) => {}
172            Err(e) => {
173                return Err(ScopeError::io_context(
174                    e,
175                    format!(
176                        "cannot copy {} to {}",
177                        item.from.display(),
178                        item.to.display()
179                    ),
180                ));
181            }
182        }
183    }
184    // Done once anything was ever copied: the old folders are not read
185    // again. With nothing copied, nothing is written (a read-only home).
186    if copied > 0 || !done.is_empty() {
187        let end = serde_json::json!({
188            "at": chrono::Utc::now().to_rfc3339(),
189            "result": COMPLETE,
190        });
191        append_line(log, &end)?;
192    }
193    Ok(Report { copied, from })
194}
195
196/// The result of the log line that ends a migration.
197const COMPLETE: &str = "complete";
198
199/// The log's entries; an unreadable log or line has none.
200fn log_entries(log: &Path) -> Vec<serde_json::Value> {
201    let Ok(text) = std::fs::read_to_string(log) else {
202        return Vec::new();
203    };
204    text.lines()
205        .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
206        .collect()
207}
208
209/// The (from, to) pairs `entries` record as copied. The worst case of a
210/// lost log is the old behaviour, a copy made again.
211fn copied_before(entries: &[serde_json::Value]) -> std::collections::HashSet<(PathBuf, PathBuf)> {
212    entries
213        .iter()
214        .filter(|v| v["result"] == Outcome::Copied.as_str())
215        .filter_map(|v| {
216            Some((
217                PathBuf::from(v["from"].as_str()?),
218                PathBuf::from(v["to"].as_str()?),
219            ))
220        })
221        .collect()
222}
223
224fn copy_one(item: &Item) -> std::io::Result<Outcome> {
225    if !item.from.exists() {
226        return Ok(Outcome::NoSource);
227    }
228    if item.to.exists() {
229        return Ok(Outcome::KeptExisting);
230    }
231    if let Some(parent) = item.to.parent() {
232        std::fs::create_dir_all(parent)?;
233    }
234    // Copy to a temporary file and rename it: a copy that stops partway
235    // (disk full, a kill) must not become the new file, which later starts
236    // would keep. A temporary file left by such a copy is replaced.
237    let mut name = item.to.file_name().unwrap_or_default().to_os_string();
238    name.push(".migrating");
239    let temp = item.to.with_file_name(name);
240    std::fs::copy(&item.from, &temp)?;
241    std::fs::rename(&temp, &item.to)?;
242    Ok(Outcome::Copied)
243}
244
245fn append_log(log: &Path, item: &Item, result: &str) -> Result<()> {
246    let line = serde_json::json!({
247        "at": chrono::Utc::now().to_rfc3339(),
248        "from": item.from.display().to_string(),
249        "to": item.to.display().to_string(),
250        "result": result,
251    });
252    append_line(log, &line)
253}
254
255fn append_line(log: &Path, line: &serde_json::Value) -> Result<()> {
256    let context = || format!("cannot write the migration log {}", log.display());
257    if let Some(parent) = log.parent() {
258        std::fs::create_dir_all(parent).map_err(|e| ScopeError::io_context(e, context()))?;
259    }
260    let mut file = std::fs::OpenOptions::new()
261        .create(true)
262        .append(true)
263        .open(log)
264        .map_err(|e| ScopeError::io_context(e, context()))?;
265    writeln!(file, "{line}").map_err(|e| ScopeError::io_context(e, context()))?;
266    Ok(())
267}
268
269/// Migrates this machine's old files into the scope home.
270pub fn migrate() -> Result<Report> {
271    run(&legacy_items(), &super::state_dir().join("migration.jsonl"))
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use std::fs;
278
279    /// Old folders and a new home under one temp folder.
280    struct Fixture {
281        _tmp: tempfile::TempDir,
282        xdg: PathBuf,
283        platform_config: PathBuf,
284        platform_data: PathBuf,
285        home: PathBuf,
286    }
287
288    fn fixture() -> Fixture {
289        let tmp = tempfile::tempdir().unwrap();
290        let p = tmp.path().to_path_buf();
291        Fixture {
292            xdg: p.join("xdg/scope"),
293            platform_config: p.join("platform-config"),
294            platform_data: p.join("platform-data"),
295            home: p.join("home/.scope-bca"),
296            _tmp: tmp,
297        }
298    }
299
300    fn put(path: &Path, body: &str) {
301        fs::create_dir_all(path.parent().unwrap()).unwrap();
302        fs::write(path, body).unwrap();
303    }
304
305    fn items(f: &Fixture) -> Vec<Item> {
306        items_for(
307            Some(&f.xdg),
308            Some(&f.platform_config),
309            Some(&f.platform_data),
310            &f.home,
311        )
312    }
313
314    fn log_lines(f: &Fixture) -> Vec<serde_json::Value> {
315        fs::read_to_string(f.home.join("state/migration.jsonl"))
316            .unwrap_or_default()
317            .lines()
318            .map(|l| serde_json::from_str(l).unwrap())
319            .collect()
320    }
321
322    fn run_fixture(f: &Fixture) -> crate::error::Result<Report> {
323        run(&items(f), &f.home.join("state/migration.jsonl"))
324    }
325
326    #[test]
327    fn test_copies_every_old_file_to_its_new_place_and_leaves_the_old() {
328        let f = fixture();
329        put(&f.xdg.join("config.yaml"), "chains: {}\n");
330        put(&f.xdg.join("venues/weex.yaml"), "id: weex\n");
331        put(&f.xdg.join("data/address_book.yaml"), "addresses: []\n");
332        put(&f.platform_data.join("scope/tokens.yaml"), "aliases: {}\n");
333        put(&f.platform_data.join("scope/session.yaml"), "chain: auto\n");
334        put(&f.platform_data.join("scope/history.txt"), "address 0x1\n");
335        put(&f.platform_data.join("scope/tui-events.jsonl"), "{}\n");
336
337        let report = run_fixture(&f).unwrap();
338
339        assert_eq!(report.copied, 7);
340        for (new, body) in [
341            ("config.yaml", "chains: {}\n"),
342            ("venues/weex.yaml", "id: weex\n"),
343            ("data/address_book.yaml", "addresses: []\n"),
344            ("data/tokens.yaml", "aliases: {}\n"),
345            ("state/session.yaml", "chain: auto\n"),
346            ("state/history.txt", "address 0x1\n"),
347            ("state/tui-events.jsonl", "{}\n"),
348        ] {
349            assert_eq!(fs::read_to_string(f.home.join(new)).unwrap(), body, "{new}");
350        }
351        // The old files are unchanged.
352        assert_eq!(
353            fs::read_to_string(f.xdg.join("config.yaml")).unwrap(),
354            "chains: {}\n"
355        );
356        assert!(f.platform_data.join("scope/tokens.yaml").exists());
357    }
358
359    #[test]
360    fn test_config_comes_from_the_platform_folder_when_xdg_has_none() {
361        // Review focus 1: old versions read the platform config folder too.
362        let f = fixture();
363        put(&f.platform_config.join("scope/config.yaml"), "output: {}\n");
364        run_fixture(&f).unwrap();
365        assert_eq!(
366            fs::read_to_string(f.home.join("config.yaml")).unwrap(),
367            "output: {}\n"
368        );
369    }
370
371    #[test]
372    fn test_an_existing_new_file_is_never_overwritten() {
373        // Review focus 2.
374        let f = fixture();
375        put(&f.xdg.join("config.yaml"), "old\n");
376        put(&f.home.join("config.yaml"), "new\n");
377        let report = run_fixture(&f).unwrap();
378        assert_eq!(report.copied, 0);
379        assert_eq!(
380            fs::read_to_string(f.home.join("config.yaml")).unwrap(),
381            "new\n"
382        );
383    }
384
385    #[test]
386    fn test_a_second_run_copies_nothing() {
387        let f = fixture();
388        put(&f.xdg.join("config.yaml"), "chains: {}\n");
389        assert_eq!(run_fixture(&f).unwrap().copied, 1);
390        assert_eq!(run_fixture(&f).unwrap().copied, 0);
391    }
392
393    #[test]
394    fn test_it_happens_once_so_the_old_folders_never_compete() {
395        let f = fixture();
396        put(
397            &f.xdg.join("venues/weex.yaml"),
398            "id: weex
399",
400        );
401        let report = run_fixture(&f).unwrap();
402        assert_eq!(report.copied, 1);
403        // The report names where the copies came from.
404        assert_eq!(report.from, [f.xdg.join("venues")]);
405        // A deleted copy stays deleted, and a file added to the old
406        // folder later is not read.
407        fs::remove_file(f.home.join("venues/weex.yaml")).unwrap();
408        put(
409            &f.xdg.join("venues/kucoin.yaml"),
410            "id: kucoin
411",
412        );
413        assert_eq!(run_fixture(&f).unwrap().copied, 0);
414        assert!(!f.home.join("venues/weex.yaml").exists());
415        assert!(!f.home.join("venues/kucoin.yaml").exists());
416    }
417
418    #[test]
419    fn test_a_log_from_before_complete_stops_copies_coming_back_and_ends() {
420        let f = fixture();
421        put(
422            &f.xdg.join("venues/weex.yaml"),
423            "id: weex
424",
425        );
426        let item = Item {
427            from: f.xdg.join("venues/weex.yaml"),
428            to: f.home.join("venues/weex.yaml"),
429        };
430        // 0.8.4 logged the copy but no end; the user then deleted it.
431        append_log(&f.home.join("state/migration.jsonl"), &item, "copied").unwrap();
432        assert_eq!(run_fixture(&f).unwrap().copied, 0);
433        assert!(!item.to.exists());
434        let last = log_lines(&f).pop().unwrap();
435        assert_eq!(last["result"], COMPLETE);
436    }
437
438    #[test]
439    fn test_the_log_records_each_copy_and_nothing_else() {
440        // The audit record: one line per copied (or failed) file.
441        let f = fixture();
442        put(&f.xdg.join("config.yaml"), "chains: {}\n");
443        put(&f.platform_data.join("scope/tokens.yaml"), "aliases: {}\n");
444        run_fixture(&f).unwrap();
445        let mut lines = log_lines(&f);
446        assert_eq!(lines.len(), 3, "{lines:?}");
447        // The last line ends the migration.
448        assert_eq!(lines.pop().unwrap()["result"], COMPLETE);
449        for line in &lines {
450            assert!(line["at"].is_string() && line["from"].is_string() && line["to"].is_string());
451            assert_eq!(line["result"], "copied");
452        }
453        run_fixture(&f).unwrap();
454        assert_eq!(log_lines(&f).len(), 3, "a later start adds no line");
455    }
456
457    #[cfg(unix)]
458    #[test]
459    fn test_nothing_to_copy_writes_nothing_so_a_read_only_home_works() {
460        // Review C1/I2: with nothing to copy, a start must not write at
461        // all (a read-only home ran every command before this change) and
462        // must not grow the log.
463        use std::os::unix::fs::PermissionsExt;
464        let f = fixture();
465        put(&f.home.join("config.yaml"), "chains: {}\n");
466        fs::set_permissions(&f.home, fs::Permissions::from_mode(0o555)).unwrap();
467        let result = run_fixture(&f);
468        fs::set_permissions(&f.home, fs::Permissions::from_mode(0o755)).unwrap();
469        assert_eq!(result.unwrap().copied, 0);
470        assert!(
471            !f.home.join("state").exists(),
472            "no log when nothing happened"
473        );
474    }
475
476    #[test]
477    fn test_a_copy_left_by_a_crash_is_replaced_not_kept() {
478        // Review I1: a copy that stopped partway must not become the new
479        // file. Copies go through a temporary file and a rename, and a
480        // temporary file left by a crash is replaced.
481        let f = fixture();
482        put(&f.xdg.join("config.yaml"), "chains: {}\n");
483        let leftover = f.home.join("config.yaml.migrating");
484        put(&leftover, "chai");
485        assert_eq!(run_fixture(&f).unwrap().copied, 1);
486        assert_eq!(
487            fs::read_to_string(f.home.join("config.yaml")).unwrap(),
488            "chains: {}\n"
489        );
490        assert!(!leftover.exists(), "the temporary file is gone");
491    }
492
493    #[test]
494    fn test_a_failed_copy_stops_names_the_file_and_a_rerun_finishes() {
495        // Review focus 3: a source that cannot be read. A folder in its
496        // place cannot be copied, also as root, where mode 000 does not
497        // stop a read (#106), and on Windows.
498        let f = fixture();
499        put(&f.xdg.join("config.yaml"), "chains: {}\n");
500        let tokens = f.platform_data.join("scope/tokens.yaml");
501        fs::create_dir_all(&tokens).unwrap();
502
503        let err = run_fixture(&f).unwrap_err().to_string();
504        assert!(err.contains("tokens.yaml"), "{err}");
505        // The file copied before the failure stays.
506        assert!(f.home.join("config.yaml").exists());
507        assert!(
508            log_lines(&f)
509                .iter()
510                .any(|l| l["result"].as_str().unwrap().starts_with("failed:"))
511        );
512
513        fs::remove_dir(&tokens).unwrap();
514        put(&tokens, "aliases: {}\n");
515        let report = run_fixture(&f).unwrap();
516        assert_eq!(report.copied, 1, "the rerun copies only what is left");
517        assert!(f.home.join("data/tokens.yaml").exists());
518    }
519
520    #[test]
521    fn test_only_yaml_venue_files_are_copied() {
522        // Review focus 5.
523        let f = fixture();
524        put(&f.xdg.join("venues/weex.yaml"), "id: weex\n");
525        put(&f.xdg.join("venues/kraken.yml"), "id: kraken\n");
526        put(&f.xdg.join("venues/README.txt"), "notes\n");
527        put(&f.xdg.join("venues/weex.yaml.bak"), "old\n");
528        run_fixture(&f).unwrap();
529        assert!(f.home.join("venues/weex.yaml").exists());
530        assert!(f.home.join("venues/kraken.yml").exists());
531        assert!(!f.home.join("venues/README.txt").exists());
532        assert!(!f.home.join("venues/weex.yaml.bak").exists());
533    }
534
535    #[test]
536    fn test_portfolio_and_daemon_files_are_not_migrated() {
537        let f = fixture();
538        for name in ["portfolio.yaml", "scope-web.pid", "scope-web.log"] {
539            put(&f.platform_data.join("scope").join(name), "x\n");
540        }
541        assert_eq!(run_fixture(&f).unwrap().copied, 0);
542    }
543
544    #[test]
545    fn test_real_items_point_into_the_sandbox_in_tests() {
546        // legacy_items() must never name a real user file in a test build.
547        let temp = std::env::temp_dir();
548        for item in legacy_items() {
549            assert!(item.from.starts_with(&temp), "{}", item.from.display());
550            assert!(item.to.starts_with(&temp), "{}", item.to.display());
551        }
552    }
553}