ralph-coder 0.2.1

An agentic code generation CLI powered by multiple LLM backends
Documentation
/// Revert tests — verifies that post-checkpoint files are deleted on revert.
use ralph::checkpoint::Checkpoint;
use ralph::config::{Config, SessionConfig};
use ralph::session::Session;
use ralph::tools::file_tools;
use tempfile::TempDir;

fn test_config(sessions_dir: &str) -> Config {
    let mut config = Config::default();
    config.session = SessionConfig {
        auto_resume: false,
        sessions_dir: Some(sessions_dir.to_string()),
        clean_after_days: 30,
    };
    config
}

/// Revert should:
/// 1. Restore files that existed at the checkpoint
/// 2. Delete files created after the checkpoint
#[tokio::test]
async fn revert_restores_and_deletes() {
    let dir = TempDir::new().unwrap();
    let workspace = dir.path().join("ws");
    std::fs::create_dir_all(&workspace).unwrap();
    let sessions_dir = dir.path().join("sessions");
    let config = test_config(&sessions_dir.display().to_string());

    // Create initial file and session
    let existing_file = workspace.join("existing.rs");
    file_tools::write_file(&existing_file, "original content").unwrap();

    let mut session =
        Session::create(&workspace, "anthropic", "claude-sonnet-4-6", &config).unwrap();
    session.modified_files.push(existing_file.clone());
    session.flush().unwrap();

    // Checkpoint: existing.rs = "original content"
    let _cp = Checkpoint::create(&session, "baseline", &Default::default())
        .await
        .unwrap();

    // After checkpoint: modify existing file, create a new file
    file_tools::write_file(&existing_file, "modified content").unwrap();
    let new_file = workspace.join("new_file.rs");
    file_tools::write_file(&new_file, "new file content").unwrap();
    session.modified_files.push(new_file.clone());
    session.flush().unwrap();

    // Verify state before revert
    assert_eq!(
        std::fs::read_to_string(&existing_file).unwrap(),
        "modified content"
    );
    assert!(new_file.exists());

    // Revert (files only, skip confirmation by running directly)
    // Since revert requires user confirmation via Printer, we call the file
    // restoration logic directly by simulating what revert_into does.
    // Here we test the outcome via the Checkpoint::find + fields.
    let loaded_cp = Checkpoint::find(&session, "baseline").unwrap();

    // Manually apply the revert logic (what revert_into does internally):
    // 1. Delete post-checkpoint files
    let cp_files: std::collections::HashSet<String> =
        loaded_cp.meta.modified_files.iter().cloned().collect();
    for file_path in &session.modified_files {
        let rel = file_path
            .strip_prefix(&workspace)
            .unwrap_or(file_path)
            .to_string_lossy()
            .to_string();
        if !cp_files.contains(&rel) && file_path.exists() {
            std::fs::remove_file(file_path).unwrap();
        }
    }
    // 2. Restore snapshotted files
    let files_dir = loaded_cp.dir.join("files");
    if files_dir.exists() {
        for entry in walkdir_flat(&files_dir) {
            let rel = entry.strip_prefix(&files_dir).unwrap_or(&entry);
            let dest = workspace.join(rel);
            if let Some(parent) = dest.parent() {
                std::fs::create_dir_all(parent).unwrap();
            }
            std::fs::copy(&entry, &dest).unwrap();
        }
    }

    // Assert: existing file is restored, new file is deleted
    assert_eq!(
        std::fs::read_to_string(&existing_file).unwrap(),
        "original content"
    );
    assert!(
        !new_file.exists(),
        "post-checkpoint file should have been deleted"
    );
}

#[tokio::test]
async fn revert_files_only_leaves_messages() {
    let dir = TempDir::new().unwrap();
    let workspace = dir.path().join("ws");
    std::fs::create_dir_all(&workspace).unwrap();
    let sessions_dir = dir.path().join("sessions");
    let config = test_config(&sessions_dir.display().to_string());

    let mut session =
        Session::create(&workspace, "anthropic", "claude-sonnet-4-6", &config).unwrap();

    // Add some messages
    session
        .messages
        .push(ralph::providers::Message::user("task 1"));
    session
        .messages
        .push(ralph::providers::Message::assistant("done 1"));
    session.flush().unwrap();

    // Create checkpoint with 2 messages
    let cp = Checkpoint::create(&session, "msg-checkpoint", &Default::default())
        .await
        .unwrap();
    assert_eq!(cp.messages.len(), 2);

    // Add more messages
    session
        .messages
        .push(ralph::providers::Message::user("task 2"));
    session.flush().unwrap();
    assert_eq!(session.messages.len(), 3);

    // Load checkpoint and verify it saved 2 messages
    let loaded = Checkpoint::find(&session, "msg-checkpoint").unwrap();
    assert_eq!(loaded.messages.len(), 2);
}

fn walkdir_flat(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
    let mut files = Vec::new();
    if let Ok(entries) = std::fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                files.extend(walkdir_flat(&path));
            } else {
                files.push(path);
            }
        }
    }
    files
}