patchloom 0.24.0

Structured file editing library and CLI for AI agents: parser-backed JSON/YAML/TOML edits, AST-aware code operations, multi-file batching, markdown operations, and MCP server
Documentation
//! Patch (unified diff) apply operations for the library API.
//!
//! Single-file `apply_patch` delegates to the tx engine via `execute_as_edit_result`.
//! Multi-file `apply_patch_file` retains a direct implementation.

use std::path::Path;

use crate::containment::PathGuard;
use crate::plan::Operation;

use super::{ApplyMode, EditResult};

/// Apply a unified diff patch to a file.
///
/// Returns an `EditResult` with the patched content.
pub fn apply_patch(
    path: &Path,
    patch_text: &str,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
) -> anyhow::Result<EditResult> {
    let op = Operation::PatchApply {
        diff: patch_text.into(),
        on_stale: Default::default(),
        allow_conflicts: false,
    };
    // Resolve cwd so multi-component relative paths (and git-style patch
    // paths that match the caller path) join correctly.
    let abs = super::absolute_for_engine(path).map_err(|e| {
        crate::fallback::EditError::new(
            crate::fallback::EditErrorKind::OperationFailed,
            format!("failed to resolve path {}: {e}", path.display()),
        )
    })?;
    let cwd_owned: std::path::PathBuf;
    let cwd = if path.is_absolute() {
        abs.parent().unwrap_or_else(|| Path::new("."))
    } else {
        // path "src/lib.rs" → strip components so cwd is project root and
        // patch path "src/lib.rs" resolves once.
        cwd_owned = abs
            .ancestors()
            .nth(path.components().count())
            .unwrap_or_else(|| Path::new("."))
            .to_path_buf();
        cwd_owned.as_path()
    };
    let display = path.to_string_lossy();
    patch_write(op, cwd, mode, guard, Some(display.as_ref()))
}

#[cfg(any(feature = "cli", feature = "files"))]
fn patch_write(
    op: Operation,
    cwd: &Path,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
    display_path: Option<&str>,
) -> anyhow::Result<EditResult> {
    super::execute_as_edit_result_with_path(op, mode, cwd, guard, "patch", None, display_path)
}

#[cfg(not(any(feature = "cli", feature = "files")))]
fn patch_write(
    _op: Operation,
    cwd: &Path,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
    _display_path: Option<&str>,
) -> anyhow::Result<EditResult> {
    use crate::ops;

    if let Operation::PatchApply { diff, .. } = _op {
        let patch_files = ops::patch::parse_patch(&diff).map_err(|e| {
            anyhow::Error::new(crate::exit::ParseErrorError {
                msg: format!("patch parse error: {e}"),
            })
        })?;

        if patch_files.is_empty() {
            return Err(anyhow::Error::new(crate::exit::ParseErrorError {
                msg: "no files in patch".into(),
            }));
        }

        // Apply to the first file in the patch.
        let pf = &patch_files[0];
        let file_path = cwd.join(&pf.path);
        // Strict sole-path (#1894): binary / invalid UTF-8 → Binary / InvalidEncoding.
        let original = crate::files::load_text_strict(&file_path, &pf.path)?;

        let new_content = ops::patch::apply_hunks(&original, &pf.hunks).map_err(|e| {
            if e.contains("stale context") {
                anyhow::Error::new(crate::exit::AmbiguousError {
                    msg: format!("patch apply error: {e}"),
                })
            } else {
                anyhow::Error::new(crate::exit::InvalidInputError {
                    msg: format!("patch apply error: {e}"),
                })
            }
        })?;

        let policy = crate::write::WritePolicy::default();
        let (applied, backup_session) =
            super::write_if_apply(&file_path, &new_content, mode, &policy, guard)?;
        {
            let mut __e =
                super::build_edit_result(&pf.path, original, new_content, applied, "patch", None);
            __e.backup_session = backup_session;
            Ok(__e)
        }
    } else {
        anyhow::bail!("expected PatchApply operation")
    }
}

/// Apply a multi-file patch. Returns one `EditResult` per affected file.
///
/// Retains direct implementation since it produces multiple `EditResult`s
/// (one per file), which the single-op `execute_as_edit_result` adapter
/// doesn't support.
///
/// **Atomic Apply:** all files are load+hunk preflighted first; on Apply a
/// single backup session covers every path. Any write failure restores the
/// whole batch (no half-applied multi-file patch).
pub fn apply_patch_file(
    patch_text: &str,
    cwd: &Path,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
) -> anyhow::Result<Vec<EditResult>> {
    let patch_files = crate::ops::patch::parse_patch(patch_text).map_err(|e| {
        anyhow::Error::new(crate::exit::ParseErrorError {
            msg: format!("patch parse error: {e}"),
        })
    })?;

    // Phase 1: preflight load + hunk apply for every file (no disk writes).
    let mut staged: Vec<(std::path::PathBuf, String, String, String)> = Vec::new();
    for pf in &patch_files {
        let file_path = cwd.join(&pf.path);
        // Strict sole-path (#1894).
        let original = crate::files::load_text_strict(&file_path, &pf.path)?;

        let new_content = crate::ops::patch::apply_hunks(&original, &pf.hunks).map_err(|e| {
            if e.contains("stale context") {
                anyhow::Error::new(crate::exit::AmbiguousError {
                    msg: format!("patch apply error for {}: {e}", pf.path),
                })
            } else {
                anyhow::Error::new(crate::exit::InvalidInputError {
                    msg: format!("patch apply error for {}: {e}", pf.path),
                })
            }
        })?;
        staged.push((file_path, pf.path.clone(), original, new_content));
    }

    // Phase 2: one backup session + all-or-nothing write on Apply.
    let policy = crate::write::WritePolicy::default();
    let write_pairs: Vec<(&std::path::Path, &str)> = staged
        .iter()
        .map(|(p, _, _, n)| (p.as_path(), n.as_str()))
        .collect();
    let (applied, backup_session) =
        super::write_if_apply_many(&write_pairs, mode, &policy, guard, cwd)?;

    let mut results = Vec::with_capacity(staged.len());
    for (_abs, display, original, new_content) in staged {
        let mut edit =
            super::build_edit_result(&display, original, new_content, applied, "patch", None);
        // Same session id on every file result so hosts can undo once.
        edit.backup_session = backup_session.clone();
        results.push(edit);
    }
    Ok(results)
}