@@ -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