patchloom 0.18.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,
    };
    let cwd = path.parent().unwrap_or_else(|| Path::new("."));
    patch_write(op, cwd, mode, guard)
}

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

#[cfg(not(any(feature = "cli", feature = "files")))]
fn patch_write(
    _op: Operation,
    cwd: &Path,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
) -> 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 → InvalidInput.
        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.
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}"),
        })
    })?;

    let mut results = 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),
                })
            }
        })?;

        let policy = crate::write::WritePolicy::default();
        let (applied, backup_session) =
            super::write_if_apply(&file_path, &new_content, mode, &policy, guard)?;
        let mut edit =
            super::build_edit_result(&pf.path, original, new_content, applied, "patch", None);
        edit.backup_session = backup_session;
        results.push(edit);
    }
    Ok(results)
}