atomwrite 0.1.0

Atomic file operations CLI for LLM agents — read, write, edit, search, replace with NDJSON output
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0

//! File restoration from a previous timestamped backup.

use std::fs;
use std::io::Write;
use std::time::Instant;

use anyhow::{Context, Result};

use crate::atomic::{AtomicWriteOptions, atomic_write};
use crate::checksum;
use crate::cli::{GlobalArgs, RollbackArgs};
use crate::error::AtomwriteError;
use crate::ndjson_types::RollbackResult;
use crate::output::NdjsonWriter;

/// Restore a file from a previous backup.
///
/// # Errors
///
/// Returns `AtomwriteError::NotFound` if no matching backup is found.
/// Returns `AtomwriteError::WorkspaceJail` if the path escapes the workspace.
/// Returns an I/O error if restoration fails.
pub fn cmd_rollback(
    args: &RollbackArgs,
    global: &GlobalArgs,
    writer: &mut NdjsonWriter<impl Write>,
) -> Result<()> {
    let start = Instant::now();
    let workspace = global.resolve_workspace()?;
    let target = crate::path_safety::validate_path(&args.path, &workspace)?;

    let parent = target.parent().unwrap_or(std::path::Path::new("."));
    let filename = target
        .file_name()
        .ok_or_else(|| AtomwriteError::InvalidInput {
            reason: "path has no filename".into(),
        })?
        .to_string_lossy();
    let prefix = format!("{filename}.bak.");

    let mut backups: Vec<std::path::PathBuf> = fs::read_dir(parent)
        .with_context(|| format!("cannot list directory {}", parent.display()))?
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| {
            p.file_name()
                .and_then(|n| n.to_str())
                .is_some_and(|n| n.starts_with(&prefix))
        })
        .collect();

    if backups.is_empty() {
        return Err(AtomwriteError::NotFound {
            path: target.clone(),
        }
        .into());
    }

    backups.sort();

    let backup = if let Some(ref ts) = args.timestamp {
        let needle = format!("{prefix}{ts}");
        backups
            .iter()
            .find(|p| {
                p.file_name()
                    .and_then(|n| n.to_str())
                    .is_some_and(|n| n == needle)
            })
            .cloned()
            .ok_or_else(|| AtomwriteError::NotFound {
                path: target.clone(),
            })?
    } else {
        // SAFETY: backups is guaranteed non-empty by the is_empty() check above.
        backups
            .last()
            .cloned()
            .expect("BUG: backups verified non-empty")
    };

    if args.dry_run {
        writer.write_event(&serde_json::json!({
            "type": "plan",
            "operation": "rollback",
            "path": target.display().to_string(),
            "restore_from": backup.display().to_string(),
        }))?;
        return Ok(());
    }

    let checksum_before = if target.exists() {
        Some(checksum::hash_file(&target)?)
    } else {
        None
    };

    let content = crate::file_io::read_file_bytes(&backup)?;
    let opts = AtomicWriteOptions {
        backup: false,
        ..Default::default()
    };
    atomic_write(&target, &content, &opts, &workspace)?;

    let checksum_after = checksum::hash_file(&target)?;

    let verified = if args.verify {
        let backup_hash = checksum::hash_bytes(&content);
        Some(checksum_after == backup_hash)
    } else {
        None
    };

    writer.write_event(&RollbackResult {
        r#type: "rollback",
        path: target.display().to_string(),
        restored_from: backup.display().to_string(),
        checksum_before,
        checksum_after,
        verified,
        elapsed_ms: start.elapsed().as_millis() as u64,
    })?;

    Ok(())
}