1use crate::error::{Result, ScopeError};
18use std::io::Write as _;
19use std::path::{Path, PathBuf};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Item {
24 pub from: PathBuf,
25 pub to: PathBuf,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Outcome {
31 Copied,
32 KeptExisting,
33 NoSource,
34 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#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Report {
52 pub copied: usize,
53 pub from: Vec<PathBuf>,
55}
56
57const 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
65pub 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
76pub 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 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
133pub 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 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
196const COMPLETE: &str = "complete";
198
199fn 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
209fn 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 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
269pub 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 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 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 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 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 assert_eq!(report.from, [f.xdg.join("venues")]);
405 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 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 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 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 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 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 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 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 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 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}