Skip to main content

patchloom/api/
mod.rs

1//! Public library API for embedding patchloom in Rust applications.
2//!
3//! This module provides a clean, CLI-independent interface to patchloom's
4//! editing operations. Functions accept `&Path`/`&str` parameters and return
5//! `Result<EditResult>`, with no dependency on `clap` or process arguments.
6//!
7//! # Quick start
8//!
9//! ```rust,no_run
10//! use patchloom::api::{self, ApplyMode, EditResult};
11//! use std::path::{Path, PathBuf};
12//!
13//! // Replace text in a file (preview only)
14//! let result = api::replace_text(
15//!     Path::new("src/config.rs"),
16//!     "old_value",
17//!     "new_value",
18//!     &api::ReplaceOptions::default(),
19//!     ApplyMode::Preview,
20//!     None,
21//! ).unwrap();
22//! println!("diff:\n{}", result.diff);
23//! ```
24//!
25//! # Apply modes
26//!
27//! All write operations accept an [`ApplyMode`]:
28//! - [`ApplyMode::Preview`] — compute the result without writing to disk.
29//! - [`ApplyMode::Apply`] — write changes to disk with backup.
30//! - [`ApplyMode::Check`] — report whether changes would occur (for CI).
31//!
32//! # Using with PathGuard (containment)
33//!
34//! All write operations accept an optional `guard: Option<&PathGuard>` (added for library users needing relaxed containment).
35//! Pass `None` for no additional checks (default for most internal use).
36//! Pass `Some(&guard)` to enforce the policy (e.g. allow temp dirs via the builder).
37//!
38//! Example:
39//!
40//! ```rust,no_run
41//! use patchloom::api::{self, ApplyMode, ReplaceOptions};
42//! use patchloom::containment::PathGuard;
43//! use std::path::{Path, PathBuf};
44//!
45//! let guard = PathGuard::builder(std::env::current_dir().unwrap())
46//!     .allow_temp_directory()  // includes /tmp + platform temp (macOS symlink safe)
47//!     .build()
48//!     .unwrap();
49//!
50//! let _ = api::replace_text(
51//!     Path::new("src/main.rs"),
52//!     "old",
53//!     "new",
54//!     &ReplaceOptions::default(),
55//!     ApplyMode::Preview,
56//!     Some(&guard),
57//! );
58//! ```
59//!
60//! **Guard semantics:** The guard provides *write-time* enforcement and is only checked for `ApplyMode::Apply` writes (via `ensure_contained` + `write_if_apply`). Reads (e.g. for diff computation, `Preview`/`Check` modes, `doc_get`, search) and pre-write loads may still observe or describe paths outside the guard. This is intentional for trusted library embedding (the host/caller controls visibility). MCP uses a separate strict pre-check layer on all paths. `execute_plan` also accepts a guard and performs upfront validation on declared paths.
61//!
62//! ## Guard & WritePolicy contract
63//!
64//! - Every public write API and plan `Operation` (file.create/delete/rename/append, doc.set/merge/append/..., md.*, patch, replace, tidy writes, etc.) goes through `ensure_contained` (Apply only) + `BackupSession` + `atomic_*` + `WritePolicy`.
65//! - Upfront declared paths checked for `execute_plan` under guard.
66//! - No gaps found on review (greps for ensure/Backup/atomic in api/ + tx.rs + spot in ops).
67//! - Regression: the `write_if_apply` + `ensure_contained` helpers + upfront in execute_plan + existing guard tests under ["files"] matrix.
68//!
69//! # Thread safety
70//!
71//! All types in this module are `Send + Sync`. Functions are safe to call
72//! concurrently from multiple threads when operating on **different files**.
73//! Concurrent edits to the **same file** are the caller's responsibility
74//! to serialize (e.g., via a `Mutex` per file path).
75//!
76//! Backup sessions use unique directory names (nanosecond timestamp +
77//! monotonic counter), so concurrent `ApplyMode::Apply` calls never collide
78//! on backup directories.
79
80use std::path::Path;
81
82use crate::backup::BackupSession;
83use crate::containment::PathGuard;
84use crate::diff::{DiffResult, format_diff_result, unified_diff};
85pub use crate::ops::patch::{Hunk, PatchFile, PatchLine};
86use crate::write::{EolMode, WritePolicy, atomic_write};
87
88#[cfg(any(feature = "cli", feature = "files"))]
89pub use crate::tx::{
90    TxChange, TxDocMutation, TxLintResult, TxOutput as PlanReport, TxReadResult, TxSearchMatch,
91    TxSearchResult,
92};
93
94mod doc;
95pub use self::doc::*;
96
97mod replace;
98pub use self::replace::*;
99
100mod md;
101pub use self::md::*;
102
103mod file;
104pub use self::file::*;
105
106mod patch;
107pub use self::patch::*;
108
109mod tidy;
110pub use self::tidy::*;
111
112mod search;
113pub use self::search::*;
114
115mod read;
116pub use self::read::*;
117
118mod plan;
119pub use self::plan::*;
120
121/// The result of an editing operation.
122#[derive(Debug, Clone)]
123pub struct EditResult {
124    /// Path to the affected file (as provided by the caller).
125    pub path: String,
126    /// The original file content before the edit.
127    pub original_content: String,
128    /// The new content after the edit.
129    pub new_content: String,
130    /// A unified diff between original and new content.
131    pub diff: String,
132    /// Whether the file was actually written to disk.
133    pub applied: bool,
134    /// Whether the content changed.
135    pub changed: bool,
136    /// Action/kind of the edit (e.g. "append", "create", "replace", "rename", "doc.set").
137    /// Helps consumers (like Bline) distinguish cross-file or op type without parsing path.
138    pub action: &'static str,
139    /// For cross-file operations (e.g. `file_rename`, `md_move_section` with `to`),
140    /// the destination path if different from `path`.
141    pub dest_path: Option<String>,
142    /// Number of times the search pattern matched in the original content.
143    ///
144    /// Only meaningful for replace operations; defaults to `0` for other
145    /// operation types (doc, md, file, patch, tidy).
146    pub match_count: usize,
147}
148
149/// Result of an in-memory content edit (no file path, no applied flag).
150///
151/// Returned by [`replace::replace_in_content`] for callers that work on
152/// in-memory buffers rather than files on disk.
153#[derive(Debug, Clone)]
154pub struct ContentEditResult {
155    /// The original content before the edit.
156    pub original: String,
157    /// The content after the edit.
158    pub new_content: String,
159    /// A unified diff between original and new content.
160    pub diff: String,
161    /// Whether the content changed.
162    pub changed: bool,
163    /// Number of times the search pattern matched in the original content.
164    ///
165    /// Populated regardless of whether replacements were applied (e.g. even
166    /// when `if_exists` suppresses the error on zero matches, or when `nth`
167    /// limits which match is replaced). Embedders can use this to enforce
168    /// their own ambiguity policies without pre-scanning the content.
169    pub match_count: usize,
170}
171
172/// Controls whether an operation writes to disk.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum ApplyMode {
175    /// Compute the result without writing. Returns the diff and new content.
176    Preview,
177    /// Write changes to disk with backup support.
178    Apply,
179    /// Report whether changes would occur, without writing.
180    Check,
181}
182
183/// Options for text replacement operations.
184#[derive(Debug, Clone, Default)]
185pub struct ReplaceOptions {
186    /// Use regex mode for the `from` pattern.
187    pub regex: bool,
188    /// Replace only the Nth match (1-based). `None` means replace all.
189    pub nth: Option<usize>,
190    /// Case-insensitive matching.
191    pub case_insensitive: bool,
192    /// Enable multiline matching (dot matches newlines in regex mode).
193    pub multiline: bool,
194    /// Text to insert before each match instead of replacing.
195    /// Mutually exclusive with `to` (the replacement text) and `insert_after`.
196    pub insert_before: Option<String>,
197    /// Text to insert after each match instead of replacing.
198    /// Mutually exclusive with `to` (the replacement text) and `insert_before`.
199    pub insert_after: Option<String>,
200    /// Delete/replace entire lines containing the match rather than just the
201    /// matched text. When `to` is empty, matching lines are removed.
202    pub whole_line: bool,
203    /// Restrict matching to a 1-based inclusive line range `(start, end)`.
204    /// Requires `whole_line` to be `true`.
205    pub range: Option<(usize, Option<usize>)>,
206    /// Return success (no error) even when the pattern matches nothing.
207    pub if_exists: bool,
208    /// When true, match only at word boundaries (`\b` in regex terms).
209    /// Prevents `SetupFile` from matching inside `BenchSetupFile`.
210    /// The pattern is auto-escaped for regex metacharacters before
211    /// wrapping with `\b` anchors.
212    pub word_boundary: bool,
213    /// When true, the operation fails if the pattern matches more than once.
214    ///
215    /// This enforces unambiguous edits: the caller is guaranteed that exactly
216    /// one location was affected, or the operation is rejected with an error.
217    /// Useful for AI coding agents that need to ensure each edit targets a
218    /// unique location in the file.
219    pub unique: bool,
220    /// When true and the exact match fails (0 matches), attempt fuzzy
221    /// resolution via `resolve_with_fallback` (anchor + similarity matching).
222    /// On fuzzy success, the matched text is used for the replacement.
223    /// On fuzzy failure, the error includes "did you mean?" suggestions.
224    /// Only applies to literal (non-regex) patterns.
225    pub fuzzy: bool,
226    /// Context line(s) before the target for anchor-based fallback matching.
227    /// When the pattern matches multiple times, the match nearest to this
228    /// anchor text is selected.
229    pub before_context: Option<String>,
230    /// Context line(s) after the target for anchor-based fallback matching.
231    /// When the pattern matches multiple times, the match nearest to this
232    /// anchor text is selected.
233    pub after_context: Option<String>,
234}
235
236/// Write policy options for controlling file write transformations.
237#[derive(Debug, Clone, Default)]
238pub struct WritePolicyOptions {
239    /// Ensure non-empty files end with a newline.
240    pub ensure_final_newline: bool,
241    /// Normalize line endings. `None` means keep existing (`EolMode::Keep`).
242    pub normalize_eol: Option<EolMode>,
243    /// Remove trailing whitespace from each line.
244    pub trim_trailing_whitespace: bool,
245    /// Collapse consecutive blank lines into a single blank line.
246    pub collapse_blanks: bool,
247}
248
249// ---------------------------------------------------------------------------
250// Internal helpers
251// ---------------------------------------------------------------------------
252
253/// Convert user-facing `WritePolicyOptions` to the internal `WritePolicy`.
254///
255/// Only `tidy` currently accepts `&WritePolicyOptions` at the high-level API.
256/// Other mutating functions (`file_append`, `replace_text`, `doc_*`, `md_*`, etc.) default to
257/// `WritePolicy::default()`. For full control use a 1-op plan via `execute_plan`
258/// (which supports per-step write_policy) or the lower-level `write` + `atomic_write` primitives.
259pub fn make_write_policy(opts: &WritePolicyOptions) -> WritePolicy {
260    WritePolicy {
261        ensure_final_newline: opts.ensure_final_newline,
262        normalize_eol: opts.normalize_eol.unwrap_or(EolMode::Keep),
263        trim_trailing_whitespace: opts.trim_trailing_whitespace,
264        collapse_blanks: opts.collapse_blanks,
265    }
266}
267
268/// Generate a unified diff between two in-memory strings.
269///
270/// Returns an empty string when the contents are identical.
271/// The `path` parameter is used for the `--- a/` and `+++ b/` diff headers;
272/// pass `None` to use a generic `<content>` placeholder.
273///
274/// This is the same diff engine used internally by [`replace_in_content`],
275/// [`replace_text`], and other editing operations, exposed as a standalone
276/// public API for embedders that need to diff arbitrary strings without
277/// going through a full edit operation.
278pub fn text_diff(original: &str, modified: &str, path: Option<&str>) -> String {
279    make_diff(path.unwrap_or("<content>"), original, modified)
280}
281
282/// Parse unified diff text into structured patch files and hunks.
283///
284/// Handles standard unified diff format (`--- a/` / `+++ b/` / `@@`).
285/// Tolerant of embedded diffs in prose (only recognizes headers with
286/// `a/`/`b/` prefixes, `/dev/null`, tab timestamps, or `diff ` context).
287///
288/// Returns one [`PatchFile`] per file in the diff, each containing
289/// [`Hunk`]s with [`PatchLine`]s for context, added, and removed lines.
290///
291/// This complements [`text_diff`] (which generates diffs) and
292/// `apply_patch` (which applies diffs to files) by providing a
293/// parse-only step for embedders that need structured diff data
294/// without applying it.
295///
296/// # Errors
297///
298/// Returns an error if the diff text contains malformed hunk headers
299/// or is otherwise unparseable.
300pub fn parse_unified_diff(text: &str) -> Result<Vec<PatchFile>, String> {
301    crate::ops::patch::parse_patch(text)
302}
303
304fn make_diff(path: &str, old: &str, new: &str) -> String {
305    let file_diff = unified_diff(path, old, new);
306    let changed = file_diff.has_changes;
307    if !changed {
308        return String::new();
309    }
310    let result = DiffResult {
311        diffs: vec![file_diff],
312    };
313    format_diff_result(&result)
314}
315
316/// Generalized helper for Apply-mode mutations that need backup + guard.
317///
318/// Used by write_if_apply and special file ops (create/delete/rename cross-file).
319fn apply_mutation(
320    path: &Path,
321    mode: ApplyMode,
322    guard: Option<&PathGuard>,
323    prepare_backup: impl FnOnce(&mut BackupSession) -> anyhow::Result<()>,
324    perform_mutation: impl FnOnce() -> anyhow::Result<()>,
325) -> anyhow::Result<bool> {
326    if mode != ApplyMode::Apply {
327        return Ok(false);
328    }
329    ensure_contained(guard, path)?;
330    // Use the project root (parent of the file) as backup root.
331    // For library users, backup is best-effort.
332    let cwd = path.parent().unwrap_or_else(|| Path::new("."));
333    let mut backup = BackupSession::new(cwd)?;
334    prepare_backup(&mut backup)?;
335    perform_mutation()?;
336    backup.finalize()?;
337    Ok(true)
338}
339
340/// Generalized cross-file mutation helper (for rename and md cross-file moves).
341///
342/// Handles guard checks and backup for src (and optional dst).
343/// Centralizes the cross-file guard and backup logic.
344fn apply_cross_file_mutation(
345    src: &Path,
346    dst: Option<&Path>,
347    mode: ApplyMode,
348    guard: Option<&PathGuard>,
349    prepare_backup: impl FnOnce(&mut BackupSession) -> anyhow::Result<()>,
350    perform_mutation: impl FnOnce() -> anyhow::Result<()>,
351) -> anyhow::Result<bool> {
352    if mode != ApplyMode::Apply {
353        return Ok(false);
354    }
355    ensure_contained(guard, src)?;
356    if let Some(d) = dst {
357        ensure_contained(guard, d)?;
358    }
359    let cwd = src.parent().unwrap_or_else(|| Path::new("."));
360    let mut backup = BackupSession::new(cwd)?;
361    prepare_backup(&mut backup)?;
362    perform_mutation()?;
363    backup.finalize()?;
364    Ok(true)
365}
366
367fn write_if_apply(
368    path: &Path,
369    new_content: &str,
370    mode: ApplyMode,
371    policy: &WritePolicy,
372    guard: Option<&PathGuard>,
373) -> anyhow::Result<bool> {
374    apply_mutation(
375        path,
376        mode,
377        guard,
378        |backup| backup.save_before_write(path),
379        || atomic_write(path, new_content, policy),
380    )
381}
382
383/// Private helper to centralize the guard check and eliminate duplicated
384/// inline `if let Some(g) = guard { g.check_path... }` blocks in every
385/// write path.
386fn ensure_contained(guard: Option<&PathGuard>, path: &Path) -> anyhow::Result<()> {
387    if let Some(g) = guard {
388        g.check_path(&path.to_string_lossy())
389            .map_err(|e| anyhow::anyhow!("path rejected by workspace guard: {}", e))?;
390    }
391    Ok(())
392}
393
394fn build_edit_result(
395    path_str: &str,
396    original: String,
397    new_content: String,
398    applied: bool,
399    action: &'static str,
400    dest_path: Option<String>,
401) -> EditResult {
402    let diff = make_diff(path_str, &original, &new_content);
403    let changed = original != new_content;
404    EditResult {
405        path: path_str.to_string(),
406        original_content: original,
407        new_content,
408        diff,
409        applied,
410        changed,
411        action,
412        dest_path,
413        match_count: 0,
414    }
415}
416
417// ---------------------------------------------------------------------------
418// TX engine adapter (requires tx module: cli or files feature)
419// ---------------------------------------------------------------------------
420
421/// Execute a single `Operation` through the tx engine and return an `EditResult`.
422///
423/// This is the bridge between the library API (which uses `ApplyMode` and returns
424/// `EditResult`) and the tx engine (which uses `GlobalFlags` and returns
425/// `ExecutionResult`). All API write functions can delegate to this adapter
426/// instead of reimplementing read-transform-write-backup independently.
427///
428/// For cross-file operations (rename, move) pass `dest_path` to report the
429/// destination; single-file operations pass `None`.
430#[cfg(any(feature = "cli", feature = "files"))]
431pub(crate) fn execute_as_edit_result(
432    op: crate::plan::Operation,
433    mode: ApplyMode,
434    cwd: &Path,
435    guard: Option<&PathGuard>,
436    action: &'static str,
437    dest_path: Option<String>,
438) -> anyhow::Result<EditResult> {
439    let global = mode_to_global_flags(mode);
440    let options = crate::tx::engine::ExecuteOptions::from_global(cwd, &global, guard);
441    let result = crate::tx::engine::stage(crate::tx::engine::WriteRequest {
442        source: crate::tx::engine::WriteSource::Operations(vec![op]),
443        options,
444    })?;
445    execution_result_to_edit_result(result, mode, cwd, action, dest_path)
446}
447
448/// Map `ApplyMode` to `GlobalFlags` with the appropriate apply/check settings.
449#[cfg(any(feature = "cli", feature = "files"))]
450fn mode_to_global_flags(mode: ApplyMode) -> crate::cli::global::GlobalFlags {
451    let mut flags = crate::cli::global::GlobalFlags::default();
452    match mode {
453        ApplyMode::Apply => flags.apply = true,
454        ApplyMode::Check => flags.check = true,
455        ApplyMode::Preview => {} // default: no apply, no check
456    }
457    flags
458}
459
460/// Convert an `ExecutionResult` into an `EditResult`.
461///
462/// Handles commit for Apply mode, extracts per-file data from the engine result.
463#[cfg(any(feature = "cli", feature = "files"))]
464fn execution_result_to_edit_result(
465    result: crate::tx::engine::ExecutionResult,
466    mode: ApplyMode,
467    cwd: &Path,
468    action: &'static str,
469    dest_path: Option<String>,
470) -> anyhow::Result<EditResult> {
471    let has_changes = result.has_changes;
472
473    // Extract path + content from the engine result before potentially consuming it.
474    let (path_str, original, new_content) =
475        if let Some((abs_path, orig, new)) = result.exec_result.changes.first() {
476            let rel = crate::files::relative_display(abs_path, cwd);
477            (rel.to_string_lossy().to_string(), orig.clone(), new.clone())
478        } else if let Some(abs_path) = result.exec_result.deletions.iter().next() {
479            // File deletion: content is in the pending map.
480            let rel = crate::files::relative_display(abs_path, cwd);
481            let original = result
482                .exec_result
483                .pending
484                .get(abs_path)
485                .map(|(orig, _)| orig.clone())
486                .unwrap_or_default();
487            (rel.to_string_lossy().to_string(), original, String::new())
488        } else {
489            // No changes at all (e.g., replace with no matches + if_exists).
490            // Return an unchanged result. We need the path from the operation,
491            // but we don't have it here. Use empty path as fallback.
492            (String::new(), String::new(), String::new())
493        };
494
495    // Commit for Apply mode.
496    let applied = if mode == ApplyMode::Apply && has_changes {
497        result.commit()?;
498        true
499    } else {
500        false
501    };
502
503    Ok(build_edit_result(
504        &path_str,
505        original,
506        new_content,
507        applied,
508        action,
509        dest_path,
510    ))
511}
512
513// ---------------------------------------------------------------------------
514// Tests
515// ---------------------------------------------------------------------------
516
517#[cfg(test)]
518mod tests;