1use crate::error::{Result, ScopeError};
12use std::io::Write as _;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Item {
18 pub from: PathBuf,
19 pub to: PathBuf,
20}
21
22#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct Report {
43 pub copied: usize,
44}
45
46const 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
54pub 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
65pub 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 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
122pub 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 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
193pub 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 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 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 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 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 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 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 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 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 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 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 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}