Skip to content

Commit 0e9b8bc

Browse files
committed
feat(migrate): add apply and undo with migration log
1 parent ab1dec9 commit 0e9b8bc

1 file changed

Lines changed: 174 additions & 0 deletions

File tree

src/migrate.rs

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,109 @@ pub fn format_preview_markdown(preview: &MigrationPreview) -> String {
452452
out
453453
}
454454

455+
// ── Apply / Undo / Persistence ────────────────────────────────
456+
457+
/// Execute a migration preview: move each file to its suggested path.
458+
///
459+
/// Skips files with no suggested path or that are already in the correct
460+
/// location. Logs each successful move to the store's migration log so it
461+
/// can be undone later.
462+
pub fn apply_preview(
463+
preview: &MigrationPreview,
464+
store: &Store,
465+
vault_path: &Path,
466+
) -> Result<MigrationResult> {
467+
let mut moved = 0;
468+
let mut errors = Vec::new();
469+
470+
for fc in &preview.files {
471+
let target = match &fc.classification.suggested_path {
472+
Some(p) => p,
473+
None => continue,
474+
};
475+
// Skip if already in correct location
476+
if fc.path == *target {
477+
continue;
478+
}
479+
480+
// Extract target folder from the suggested path
481+
let folder = std::path::Path::new(target)
482+
.parent()
483+
.and_then(|p| p.to_str())
484+
.unwrap_or("");
485+
486+
match crate::writer::move_note(&fc.path, folder, store, vault_path) {
487+
Ok(_) => {
488+
store.log_migration(
489+
&preview.migration_id,
490+
&fc.path,
491+
target,
492+
&format!("{:?}", fc.classification.category),
493+
fc.classification.confidence,
494+
)?;
495+
moved += 1;
496+
}
497+
Err(e) => errors.push(format!("{}: {e:#}", fc.path)),
498+
}
499+
}
500+
501+
Ok(MigrationResult {
502+
migration_id: preview.migration_id.clone(),
503+
moved,
504+
skipped: errors.len(),
505+
errors,
506+
})
507+
}
508+
509+
/// Rollback the most recent migration by moving files back to their
510+
/// original locations and deleting the migration log entries.
511+
pub fn undo_last(store: &Store, vault_path: &Path) -> Result<UndoResult> {
512+
let migration_id = store
513+
.get_last_migration_id()?
514+
.ok_or_else(|| anyhow::anyhow!("No migration to undo"))?;
515+
let entries = store.get_migration(&migration_id)?;
516+
517+
let mut restored = 0;
518+
let mut errors = Vec::new();
519+
520+
// Reverse order to undo correctly
521+
for entry in entries.iter().rev() {
522+
let old_folder = std::path::Path::new(&entry.old_path)
523+
.parent()
524+
.and_then(|p| p.to_str())
525+
.filter(|s| !s.is_empty())
526+
.unwrap_or(".");
527+
match crate::writer::move_note(&entry.new_path, old_folder, store, vault_path) {
528+
Ok(_) => restored += 1,
529+
Err(e) => errors.push(format!("{}: {e:#}", entry.new_path)),
530+
}
531+
}
532+
533+
store.delete_migration(&migration_id)?;
534+
535+
Ok(UndoResult {
536+
migration_id,
537+
restored,
538+
errors,
539+
})
540+
}
541+
542+
/// Write a migration preview to disk as both JSON and markdown files.
543+
pub fn save_preview(preview: &MigrationPreview, data_dir: &Path) -> Result<()> {
544+
let json = serde_json::to_string_pretty(preview)?;
545+
std::fs::write(data_dir.join("migration-preview.json"), json)?;
546+
let md = format_preview_markdown(preview);
547+
std::fs::write(data_dir.join("migration-preview.md"), md)?;
548+
Ok(())
549+
}
550+
551+
/// Load a previously saved migration preview from disk.
552+
pub fn load_preview(data_dir: &Path) -> Result<MigrationPreview> {
553+
let json = std::fs::read_to_string(data_dir.join("migration-preview.json"))?;
554+
let preview: MigrationPreview = serde_json::from_str(&json)?;
555+
Ok(preview)
556+
}
557+
455558
// ── Tests ──────────────────────────────────────────────────────
456559

457560
#[cfg(test)]
@@ -748,4 +851,75 @@ mod tests {
748851
assert!(!md.contains("## Project"));
749852
assert!(!md.contains("## Uncertain"));
750853
}
854+
855+
// ── apply / undo / save+load tests ────────────────────────
856+
857+
#[test]
858+
fn test_apply_and_undo_roundtrip() {
859+
let tmp = tempfile::tempdir().unwrap();
860+
let root = tmp.path().to_path_buf();
861+
let store = crate::store::Store::open_memory().unwrap();
862+
863+
// Create directory structure
864+
std::fs::create_dir_all(root.join("01-Projects")).unwrap();
865+
866+
// Create a file at root level
867+
std::fs::write(root.join("todo.md"), "# Todo\n- [ ] task\n").unwrap();
868+
store
869+
.insert_file("todo.md", "hash1", 100, &[], "tod123", None, None)
870+
.unwrap();
871+
872+
// Build a preview manually
873+
let preview = MigrationPreview {
874+
migration_id: "test-mig-001".into(),
875+
files: vec![FileClassification {
876+
path: "todo.md".into(),
877+
classification: Classification {
878+
category: Category::Project,
879+
confidence: 0.8,
880+
signal: "has tasks".into(),
881+
suggested_path: Some("01-Projects/todo.md".into()),
882+
},
883+
}],
884+
uncertain: vec![],
885+
skipped: 0,
886+
};
887+
888+
// Apply
889+
let result = apply_preview(&preview, &store, &root).unwrap();
890+
assert_eq!(result.moved, 1);
891+
assert!(result.errors.is_empty());
892+
assert!(!root.join("todo.md").exists());
893+
assert!(root.join("01-Projects/todo.md").exists());
894+
895+
// Undo
896+
let undo = undo_last(&store, &root).unwrap();
897+
assert_eq!(undo.restored, 1);
898+
assert!(undo.errors.is_empty());
899+
assert!(root.join("todo.md").exists());
900+
assert!(!root.join("01-Projects/todo.md").exists());
901+
}
902+
903+
#[test]
904+
fn test_undo_no_migration() {
905+
let store = crate::store::Store::open_memory().unwrap();
906+
let tmp = tempfile::tempdir().unwrap();
907+
let result = undo_last(&store, tmp.path());
908+
assert!(result.is_err());
909+
}
910+
911+
#[test]
912+
fn test_save_and_load_preview() {
913+
let tmp = tempfile::tempdir().unwrap();
914+
let preview = MigrationPreview {
915+
migration_id: "test-001".into(),
916+
files: vec![],
917+
uncertain: vec![],
918+
skipped: 5,
919+
};
920+
save_preview(&preview, tmp.path()).unwrap();
921+
let loaded = load_preview(tmp.path()).unwrap();
922+
assert_eq!(loaded.migration_id, "test-001");
923+
assert_eq!(loaded.skipped, 5);
924+
}
751925
}

0 commit comments

Comments
 (0)