patchloom 0.5.0

Structured file editing library and CLI for AI agents: parser-backed JSON/YAML/TOML edits, AST-aware code operations via tree-sitter, multi-file batching, markdown operations, and MCP server
Documentation
use crate::backup;
use crate::cli::global::GlobalFlags;
use crate::exit;
use clap::Args;
use serde::Serialize;

#[derive(Debug, Args)]
#[command(after_help = "\
EXAMPLES:
  patchloom undo --list
  patchloom undo --apply
  patchloom undo --session 20240101_120000 --apply")]
pub struct UndoArgs {
    /// List available backup sessions instead of restoring.
    #[arg(long)]
    pub list: bool,

    /// Restore a specific backup session by timestamp.
    #[arg(long)]
    pub session: Option<String>,

    /// Actually restore files (default: dry-run showing what would change).
    #[arg(long)]
    pub apply: bool,
}

#[derive(Debug, Serialize)]
struct UndoPreviewEntry {
    path: String,
    action: String,
}

#[derive(Debug, Serialize)]
struct UndoPreviewOutput {
    ok: bool,
    status: &'static str,
    session: String,
    file_count: usize,
    entries: Vec<UndoPreviewEntry>,
}

pub fn run(args: UndoArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
    let cwd = global.resolve_cwd()?;

    if args.list {
        let sessions = backup::list_sessions(&cwd)?;
        if sessions.is_empty() {
            if global.json {
                println!("[]");
            } else if global.show_status() {
                eprintln!("no backup sessions found");
            }
            return Ok(exit::NO_MATCHES);
        }

        if global.json {
            println!("{}", serde_json::to_string_pretty(&sessions)?);
        } else if global.jsonl {
            for session in &sessions {
                println!("{}", serde_json::to_string(session)?);
            }
        } else if !global.quiet {
            for s in &sessions {
                let file_count = s.entries.len();
                let actions: Vec<String> = s
                    .entries
                    .iter()
                    .map(|e| format!("  {} ({})", e.path, action_label(&e.action)))
                    .collect();
                println!("{} ({file_count} file(s))", s.timestamp);
                for a in &actions {
                    println!("{a}");
                }
                println!();
            }
        }
        return Ok(exit::SUCCESS);
    }

    // Determine which session to restore.
    let timestamp = if let Some(ref ts) = args.session {
        ts.clone()
    } else {
        // Use the most recent session.
        let sessions = backup::list_sessions(&cwd)?;
        if sessions.is_empty() {
            if global.show_status() {
                eprintln!("no backup sessions found");
            }
            return Ok(exit::NO_MATCHES);
        }
        sessions[0].timestamp.clone()
    };

    if !args.apply {
        // Dry-run: show what would be restored.
        let sessions = backup::list_sessions(&cwd)?;
        let session = sessions
            .iter()
            .find(|s| s.timestamp == timestamp)
            .ok_or_else(|| anyhow::anyhow!("no backup session found for {timestamp} (use `patchloom undo --list` to see available sessions)"))?;

        let entries: Vec<UndoPreviewEntry> = session
            .entries
            .iter()
            .map(|entry| UndoPreviewEntry {
                path: entry.path.clone(),
                action: match entry.action {
                    backup::FileAction::Modified => "restore original",
                    backup::FileAction::Created => "delete (was created by apply)",
                    backup::FileAction::Deleted => "recreate (was deleted by apply)",
                }
                .to_string(),
            })
            .collect();

        if global.json || global.jsonl {
            let output = UndoPreviewOutput {
                ok: true,
                status: "changes_detected",
                session: timestamp.clone(),
                file_count: entries.len(),
                entries,
            };
            global.emit_json(&output)?;
        } else if !global.quiet {
            println!(
                "Would restore session {} ({} file(s)):",
                timestamp,
                session.entries.len()
            );
            for entry in &entries {
                println!("  {} -> {}", entry.path, entry.action);
            }
        }
        if global.show_status() {
            eprintln!("\nhint: use --apply to actually restore these files");
        }
        return Ok(exit::CHANGES_DETECTED);
    }

    // Apply restore.
    let restored = backup::restore_session(&cwd, &timestamp)?;
    if global.show_status() {
        eprintln!("restored {restored} file(s) from session {timestamp}");
    }

    Ok(exit::SUCCESS)
}

fn action_label(action: &backup::FileAction) -> &'static str {
    match action {
        backup::FileAction::Modified => "modified",
        backup::FileAction::Created => "created",
        backup::FileAction::Deleted => "deleted",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;
    use tempfile::TempDir;

    fn create_backup(dir: &Path, filename: &str, content: &str) -> String {
        let file = dir.join(filename);
        std::fs::write(&file, content).unwrap();
        let mut session = backup::BackupSession::new(dir).unwrap();
        session.save_before_write(&file).unwrap();
        session.finalize().unwrap().unwrap()
    }

    #[test]
    fn list_empty_exits_no_matches() {
        let dir = TempDir::new().unwrap();
        let mut global = GlobalFlags::test_default();
        global.quiet = true;
        global.cwd = Some(dir.path().to_string_lossy().to_string());
        let args = UndoArgs {
            list: true,
            session: None,
            apply: false,
        };
        let code = run(args, &global).unwrap();
        assert_eq!(code, exit::NO_MATCHES);
    }

    #[test]
    fn list_with_sessions_exits_success() {
        let dir = TempDir::new().unwrap();
        create_backup(dir.path(), "a.txt", "content");
        let mut global = GlobalFlags::test_default();
        global.quiet = true;
        global.cwd = Some(dir.path().to_string_lossy().to_string());
        let args = UndoArgs {
            list: true,
            session: None,
            apply: false,
        };
        let code = run(args, &global).unwrap();
        assert_eq!(code, exit::SUCCESS);
    }

    #[test]
    fn dry_run_exits_changes_detected() {
        let dir = TempDir::new().unwrap();
        let ts = create_backup(dir.path(), "b.txt", "original");
        std::fs::write(dir.path().join("b.txt"), "modified").unwrap();
        let mut global = GlobalFlags::test_default();
        global.quiet = true;
        global.cwd = Some(dir.path().to_string_lossy().to_string());
        let args = UndoArgs {
            list: false,
            session: Some(ts),
            apply: false,
        };
        let code = run(args, &global).unwrap();
        assert_eq!(code, exit::CHANGES_DETECTED);
    }

    #[test]
    fn apply_restores_and_exits_success() {
        let dir = TempDir::new().unwrap();
        let file = dir.path().join("c.txt");
        let ts = create_backup(dir.path(), "c.txt", "original");
        std::fs::write(&file, "modified").unwrap();
        let mut global = GlobalFlags::test_default();
        global.quiet = true;
        global.cwd = Some(dir.path().to_string_lossy().to_string());
        let args = UndoArgs {
            list: false,
            session: Some(ts),
            apply: true,
        };
        let code = run(args, &global).unwrap();
        assert_eq!(code, exit::SUCCESS);
        assert_eq!(std::fs::read_to_string(&file).unwrap(), "original");
    }

    #[test]
    fn apply_without_session_uses_most_recent() {
        let dir = TempDir::new().unwrap();
        let file = dir.path().join("d.txt");
        create_backup(dir.path(), "d.txt", "v1");
        std::fs::write(&file, "v1-modified").unwrap();
        std::thread::sleep(std::time::Duration::from_millis(10));
        let _ts2 = create_backup(dir.path(), "d.txt", "v2");
        std::fs::write(&file, "v3").unwrap();
        let mut global = GlobalFlags::test_default();
        global.quiet = true;
        global.cwd = Some(dir.path().to_string_lossy().to_string());
        let args = UndoArgs {
            list: false,
            session: None,
            apply: true,
        };
        let code = run(args, &global).unwrap();
        assert_eq!(code, exit::SUCCESS);
        assert_eq!(std::fs::read_to_string(&file).unwrap(), "v2");
    }

    #[test]
    fn nonexistent_session_errors() {
        let dir = TempDir::new().unwrap();
        create_backup(dir.path(), "e.txt", "content");
        let mut global = GlobalFlags::test_default();
        global.quiet = true;
        global.cwd = Some(dir.path().to_string_lossy().to_string());
        let args = UndoArgs {
            list: false,
            session: Some("99999999".to_string()),
            apply: false,
        };
        let result = run(args, &global);
        assert!(result.is_err());
    }

    #[test]
    fn no_sessions_without_apply_exits_no_matches() {
        let dir = TempDir::new().unwrap();
        let mut global = GlobalFlags::test_default();
        global.quiet = true;
        global.cwd = Some(dir.path().to_string_lossy().to_string());
        let args = UndoArgs {
            list: false,
            session: None,
            apply: false,
        };
        let code = run(args, &global).unwrap();
        assert_eq!(code, exit::NO_MATCHES);
    }

    #[test]
    fn action_label_values() {
        assert_eq!(action_label(&backup::FileAction::Modified), "modified");
        assert_eq!(action_label(&backup::FileAction::Created), "created");
        assert_eq!(action_label(&backup::FileAction::Deleted), "deleted");
    }
}