1use serde::{Deserialize, Serialize};
31use sha2::{Digest, Sha256};
32use std::io::{Read, Write};
33use std::path::{Path, PathBuf};
34
35pub const JOURNAL_VERSION: u32 = 1;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum MigrationState {
43 NothingToDo,
45 Ready,
47 AlreadyMigrated,
49 Conflict {
52 conflicts: Vec<(PathBuf, PathBuf)>,
54 },
55}
56
57#[derive(Debug, Clone, PartialEq)]
59pub struct MigrationPlan {
60 pub source: PathBuf,
62 pub destination: PathBuf,
64 pub file_count: usize,
66 pub total_bytes: u64,
68 pub state: MigrationState,
70 pub pending: Vec<PathBuf>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct MigrationJournal {
80 pub version: u32,
82 pub source: PathBuf,
84 pub destination: PathBuf,
86 pub status: String,
88 pub started_at: u64,
90}
91
92impl MigrationJournal {
93 pub fn new_in_progress(source: &Path, destination: &Path) -> Self {
95 Self {
96 version: JOURNAL_VERSION,
97 source: source.to_path_buf(),
98 destination: destination.to_path_buf(),
99 status: "in_progress".to_string(),
100 started_at: unix_now(),
101 }
102 }
103
104 pub fn load(path: &Path) -> Option<Self> {
107 let bytes = std::fs::read(path).ok()?;
108 serde_json::from_slice(&bytes).ok()
109 }
110
111 pub fn save(&self, path: &Path) -> std::io::Result<()> {
114 if let Some(parent) = path.parent() {
115 std::fs::create_dir_all(parent)?;
116 }
117 let tmp = path.with_extension("json.part");
118 let bytes =
119 serde_json::to_vec_pretty(self).expect("migration journal is JSON-serializable");
120 {
121 let mut f = std::fs::File::create(&tmp)?;
122 f.write_all(&bytes)?;
123 f.sync_all()?;
124 }
125 std::fs::rename(&tmp, path)?;
126 Ok(())
127 }
128
129 pub fn is_in_progress(&self) -> bool {
131 self.status == "in_progress"
132 }
133}
134
135fn unix_now() -> u64 {
136 std::time::SystemTime::now()
137 .duration_since(std::time::UNIX_EPOCH)
138 .map(|d| d.as_secs())
139 .unwrap_or(0)
140}
141
142#[derive(Debug, thiserror::Error)]
146pub enum HomeMigrationError {
147 #[error(transparent)]
149 Io(#[from] std::io::Error),
150 #[error("journal error: {0}")]
152 Journal(String),
153 #[error("verification failed (rerun `oxicode migrate home` to repair): {0}")]
155 Verify(String),
156}
157
158pub fn walk_files(root: &Path) -> std::io::Result<Vec<PathBuf>> {
163 let mut out = Vec::new();
164 let mut stack = vec![root.to_path_buf()];
165 while let Some(dir) = stack.pop() {
166 let entries = match std::fs::read_dir(&dir) {
167 Ok(entries) => entries,
168 Err(_) if dir != root => continue,
170 Err(e) if dir == root => return Err(e),
171 Err(_) => continue,
172 };
173 for entry in entries {
174 let entry = entry?;
175 let path = entry.path();
176 if path.is_dir() {
177 stack.push(path);
178 } else if path.is_file() {
179 out.push(path.strip_prefix(root).unwrap_or(&path).to_path_buf());
180 }
181 }
182 }
183 out.sort();
184 Ok(out)
185}
186
187fn sha256_file(path: &Path) -> std::io::Result<[u8; 32]> {
188 let mut file = std::fs::File::open(path)?;
189 let mut hasher = Sha256::new();
190 let mut buf = [0u8; 64 * 1024];
191 loop {
192 let n = file.read(&mut buf)?;
193 if n == 0 {
194 break;
195 }
196 hasher.update(&buf[..n]);
197 }
198 Ok(hasher.finalize().into())
199}
200
201fn files_identical(source: &Path, candidate: &Path) -> bool {
203 let Ok(src_meta) = std::fs::metadata(source) else {
204 return false;
205 };
206 let Ok(dst_meta) = std::fs::metadata(candidate) else {
207 return false;
208 };
209 if src_meta.len() != dst_meta.len() {
210 return false;
211 }
212 match (sha256_file(source), sha256_file(candidate)) {
213 (Ok(a), Ok(b)) => a == b,
214 _ => false,
215 }
216}
217
218pub fn preflight(source: &Path, destination: &Path) -> Result<MigrationPlan, HomeMigrationError> {
220 let rel_files = match walk_files(source) {
221 Ok(files) => files,
222 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
224 return Ok(MigrationPlan {
225 source: source.to_path_buf(),
226 destination: destination.to_path_buf(),
227 file_count: 0,
228 total_bytes: 0,
229 state: MigrationState::NothingToDo,
230 pending: Vec::new(),
231 });
232 }
233 Err(e) => return Err(e.into()),
234 };
235 if rel_files.is_empty() {
236 return Ok(MigrationPlan {
237 source: source.to_path_buf(),
238 destination: destination.to_path_buf(),
239 file_count: 0,
240 total_bytes: 0,
241 state: MigrationState::NothingToDo,
242 pending: Vec::new(),
243 });
244 }
245
246 let mut total_bytes = 0u64;
247 let mut pending = Vec::new();
248 let mut conflicts = Vec::new();
249 let mut all_present = true;
250
251 for rel in &rel_files {
252 let src = source.join(rel);
253 let dst = destination.join(rel);
254 total_bytes += std::fs::metadata(&src).map(|m| m.len()).unwrap_or(0);
255
256 if !dst.exists() {
257 all_present = false;
258 pending.push(rel.clone());
259 continue;
260 }
261 if files_identical(&src, &dst) {
262 continue;
263 }
264 conflicts.push((src, dst));
266 }
267
268 let state = if !conflicts.is_empty() {
269 MigrationState::Conflict { conflicts }
270 } else if all_present {
271 MigrationState::AlreadyMigrated
272 } else {
273 MigrationState::Ready
274 };
275
276 Ok(MigrationPlan {
277 source: source.to_path_buf(),
278 destination: destination.to_path_buf(),
279 file_count: rel_files.len(),
280 total_bytes,
281 state,
282 pending,
283 })
284}
285
286pub fn copy_file_idempotent(source: &Path, destination: &Path) -> std::io::Result<bool> {
294 if destination.exists() && files_identical(source, destination) {
295 return Ok(false); }
297
298 if let Some(parent) = destination.parent() {
299 std::fs::create_dir_all(parent)?;
300 }
301 let part = destination.with_file_name(format!(
302 "{}.part-{}",
303 destination
304 .file_name()
305 .map(|n| n.to_string_lossy().to_string())
306 .unwrap_or_default(),
307 std::process::id()
308 ));
309
310 {
311 let mut src = std::fs::File::open(source)?;
312 let mut dst = std::fs::File::create(&part)?;
313 std::io::copy(&mut src, &mut dst)?;
314 dst.sync_all()?;
315 }
316 std::fs::rename(&part, destination)?;
317
318 #[cfg(unix)]
320 if let Some(parent) = destination.parent()
321 && let Ok(dir) = std::fs::File::open(parent)
322 {
323 let _ = dir.sync_all();
324 }
325
326 Ok(true) }
328
329pub fn verify(source: &Path, destination: &Path) -> Result<(), HomeMigrationError> {
333 for rel in walk_files(source)? {
334 let src = source.join(&rel);
335 let dst = destination.join(&rel);
336 if !dst.exists() {
337 return Err(HomeMigrationError::Verify(format!(
338 "missing in destination: {}",
339 dst.display()
340 )));
341 }
342 if !files_identical(&src, &dst) {
343 return Err(HomeMigrationError::Verify(format!(
344 "content mismatch: {} vs {}",
345 src.display(),
346 dst.display()
347 )));
348 }
349 }
350 Ok(())
351}
352
353#[derive(Debug, Clone, PartialEq)]
357pub enum RunOutcome {
358 NothingToDo,
360 Conflict { conflicts: Vec<(PathBuf, PathBuf)> },
362 AlreadyMigrated { completed_journal: bool },
365 DryRun(Box<MigrationPlan>),
367 Copied { copied: usize, skipped: usize },
369}
370
371pub fn run(
377 source: &Path,
378 destination: &Path,
379 journal_path: &Path,
380 dry_run: bool,
381) -> Result<RunOutcome, HomeMigrationError> {
382 let plan = preflight(source, destination)?;
383
384 if dry_run {
385 return Ok(RunOutcome::DryRun(Box::new(plan)));
386 }
387
388 match plan.state {
389 MigrationState::NothingToDo => Ok(RunOutcome::NothingToDo),
390 MigrationState::Conflict { conflicts } => Ok(RunOutcome::Conflict { conflicts }),
391 MigrationState::AlreadyMigrated => {
392 let mut completed_journal = false;
395 if let Some(journal) = MigrationJournal::load(journal_path)
396 && journal.is_in_progress()
397 {
398 let mut done = journal;
399 done.status = "complete".to_string();
400 done.save(journal_path)?;
401 completed_journal = true;
402 }
403 Ok(RunOutcome::AlreadyMigrated { completed_journal })
404 }
405 MigrationState::Ready => {
406 let journal = MigrationJournal::new_in_progress(source, destination);
408 journal.save(journal_path)?;
409
410 let mut copied = 0usize;
411 let mut skipped = 0usize;
412 for rel in &plan.pending {
413 let src = source.join(rel);
414 let dst = destination.join(rel);
415 if copy_file_idempotent(&src, &dst)? {
416 copied += 1;
417 } else {
418 skipped += 1;
419 }
420 }
421
422 verify(source, destination)?;
423
424 let mut done = MigrationJournal::new_in_progress(source, destination);
425 done.status = "complete".to_string();
426 done.save(journal_path)?;
427
428 Ok(RunOutcome::Copied { copied, skipped })
429 }
430 }
431}
432
433#[cfg(test)]
436mod tests {
437 use super::*;
438 use std::fs;
439
440 fn setup_source() -> tempfile::TempDir {
442 let tmp = tempfile::tempdir().unwrap();
443 fs::create_dir_all(tmp.path().join("skills/my-skill")).unwrap();
444 fs::write(tmp.path().join("auth.json"), r#"{"p":"k"}"#).unwrap();
445 fs::write(tmp.path().join("skills/my-skill/SKILL.md"), "# skill").unwrap();
446 fs::write(tmp.path().join("WATCHDOG.md"), "watch").unwrap();
447 tmp
448 }
449
450 #[test]
451 fn walk_files_lists_recursive_relative_paths() {
452 let tmp = setup_source();
453 let files = walk_files(tmp.path()).unwrap();
454 assert_eq!(
455 files,
456 vec![
457 PathBuf::from("WATCHDOG.md"),
458 PathBuf::from("auth.json"),
459 PathBuf::from("skills/my-skill/SKILL.md"),
460 ]
461 );
462 }
463
464 #[test]
465 fn preflight_ready_when_destination_missing() {
466 let src = setup_source();
467 let dst = tempfile::tempdir().unwrap();
468 let plan = preflight(src.path(), dst.path()).unwrap();
469 assert_eq!(plan.state, MigrationState::Ready);
470 assert_eq!(plan.file_count, 3);
471 assert_eq!(plan.total_bytes, 9 + 7 + 5);
472 assert_eq!(plan.pending.len(), 3);
473 }
474
475 #[test]
476 fn preflight_nothing_to_do_when_source_missing_or_empty() {
477 let plan = preflight(Path::new("/nonexistent/legacy-home"), Path::new("/tmp/x")).unwrap();
478 assert_eq!(plan.state, MigrationState::NothingToDo);
479
480 let empty = tempfile::tempdir().unwrap();
481 let dst = tempfile::tempdir().unwrap();
482 let plan = preflight(empty.path(), dst.path()).unwrap();
483 assert_eq!(plan.state, MigrationState::NothingToDo);
484 }
485
486 #[test]
487 fn preflight_already_migrated_when_identical() {
488 let src = setup_source();
489 let dst = tempfile::tempdir().unwrap();
490 run(src.path(), dst.path(), &dst.path().join("j.json"), false).unwrap();
491 let plan = preflight(src.path(), dst.path()).unwrap();
493 assert_eq!(plan.state, MigrationState::AlreadyMigrated);
494 assert!(plan.pending.is_empty());
495 }
496
497 #[test]
498 fn preflight_conflict_on_differing_file() {
499 let src = setup_source();
500 let dst = tempfile::tempdir().unwrap();
501 fs::create_dir_all(dst.path().join("skills")).unwrap();
502 fs::write(dst.path().join("auth.json"), r#"{"different":true}"#).unwrap();
503
504 let plan = preflight(src.path(), dst.path()).unwrap();
505 match plan.state {
506 MigrationState::Conflict { conflicts } => {
507 assert_eq!(conflicts.len(), 1);
508 assert_eq!(conflicts[0].0, src.path().join("auth.json"));
509 assert_eq!(conflicts[0].1, dst.path().join("auth.json"));
510 }
511 other => panic!("expected Conflict, got {other:?}"),
512 }
513 }
514
515 #[test]
516 fn run_copies_and_completes_journal() {
517 let src = setup_source();
518 let dst = tempfile::tempdir().unwrap();
519 let journal_dir = tempfile::tempdir().unwrap();
520 let journal = journal_dir.path().join("journal.json");
521
522 match run(src.path(), dst.path(), &journal, false).unwrap() {
523 RunOutcome::Copied { copied, skipped } => {
524 assert_eq!(copied, 3);
525 assert_eq!(skipped, 0);
526 }
527 other => panic!("expected Copied, got {other:?}"),
528 }
529
530 assert_eq!(
532 fs::read_to_string(dst.path().join("auth.json")).unwrap(),
533 r#"{"p":"k"}"#
534 );
535 assert!(dst.path().join("skills/my-skill/SKILL.md").is_file());
536
537 let j = MigrationJournal::load(&journal).unwrap();
539 assert_eq!(j.status, "complete");
540 assert_eq!(j.version, JOURNAL_VERSION);
541 assert_eq!(j.source, src.path());
542 assert_eq!(j.destination, dst.path());
543
544 assert!(src.path().join("auth.json").is_file());
546 }
547
548 #[test]
549 fn run_is_idempotent_and_resumes_partial_copy() {
550 let src = setup_source();
551 let dst = tempfile::tempdir().unwrap();
552 let journal_dir = tempfile::tempdir().unwrap();
553 let journal = journal_dir.path().join("journal.json");
554
555 run(src.path(), dst.path(), &journal, false).unwrap();
556
557 match run(src.path(), dst.path(), &journal, false).unwrap() {
559 RunOutcome::AlreadyMigrated { completed_journal } => {
560 assert!(!completed_journal);
563 }
564 other => panic!("expected AlreadyMigrated, got {other:?}"),
565 }
566 }
567
568 #[test]
569 fn run_resume_after_partial_copy_completes() {
570 let src = setup_source();
571 let dst = tempfile::tempdir().unwrap();
572 let journal_dir = tempfile::tempdir().unwrap();
573 let journal = journal_dir.path().join("journal.json");
574
575 let journal_entry = MigrationJournal::new_in_progress(src.path(), dst.path());
577 journal_entry.save(&journal).unwrap();
578 fs::create_dir_all(dst.path().join("skills/my-skill")).unwrap();
579 fs::write(dst.path().join("auth.json"), r#"{"p":"k"}"#).unwrap();
580
581 match run(src.path(), dst.path(), &journal, false).unwrap() {
582 RunOutcome::Copied { copied, skipped } => {
583 assert_eq!(copied, 2);
586 assert_eq!(skipped, 0);
587 }
588 other => panic!("expected Copied, got {other:?}"),
589 }
590
591 let j = MigrationJournal::load(&journal).unwrap();
592 assert_eq!(j.status, "complete");
593 }
594
595 #[test]
596 fn verify_fails_on_post_migration_divergence() {
597 let src = setup_source();
598 let dst = tempfile::tempdir().unwrap();
599 let journal_dir = tempfile::tempdir().unwrap();
600 let journal = journal_dir.path().join("journal.json");
601
602 run(src.path(), dst.path(), &journal, false).unwrap();
603 fs::write(dst.path().join("WATCHDOG.md"), "tampered").unwrap();
605
606 let err = verify(src.path(), dst.path()).unwrap_err();
608 assert!(err.to_string().contains("content mismatch"));
609
610 let plan = preflight(src.path(), dst.path()).unwrap();
613 assert!(matches!(plan.state, MigrationState::Conflict { .. }));
614 }
615
616 #[test]
617 fn dry_run_mutates_nothing() {
618 let src = setup_source();
619 let dst = tempfile::tempdir().unwrap();
620 let journal_dir = tempfile::tempdir().unwrap();
621 let journal = journal_dir.path().join("journal.json");
622 let before = walk_files(dst.path()).unwrap();
623
624 match run(src.path(), dst.path(), &journal, true).unwrap() {
625 RunOutcome::DryRun(plan) => {
626 assert_eq!(plan.state, MigrationState::Ready);
627 assert_eq!(plan.file_count, 3);
628 }
629 other => panic!("expected DryRun, got {other:?}"),
630 }
631
632 assert_eq!(walk_files(dst.path()).unwrap(), before);
633 assert!(!journal.exists());
634 }
635
636 #[test]
637 fn stale_in_progress_journal_is_completed_on_already_migrated() {
638 let src = setup_source();
639 let dst = tempfile::tempdir().unwrap();
640 let journal_dir = tempfile::tempdir().unwrap();
641 let journal = journal_dir.path().join("journal.json");
642
643 let opts = copy_tree(src.path(), dst.path());
645 assert_eq!(opts, 3);
646 let entry = MigrationJournal::new_in_progress(src.path(), dst.path());
647 entry.save(&journal).unwrap();
648
649 match run(src.path(), dst.path(), &journal, false).unwrap() {
650 RunOutcome::AlreadyMigrated { completed_journal } => {
651 assert!(completed_journal);
652 }
653 other => panic!("expected AlreadyMigrated, got {other:?}"),
654 }
655 assert_eq!(MigrationJournal::load(&journal).unwrap().status, "complete");
656 }
657
658 fn copy_tree(source: &Path, destination: &Path) -> usize {
659 let mut n = 0;
660 for rel in walk_files(source).unwrap() {
661 let dst = destination.join(&rel);
662 fs::create_dir_all(dst.parent().unwrap()).unwrap();
663 fs::copy(source.join(&rel), &dst).unwrap();
664 n += 1;
665 }
666 n
667 }
668}