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