patchloom 0.8.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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
//! Public library API for embedding patchloom in Rust applications.
//!
//! This module provides a clean, CLI-independent interface to patchloom's
//! editing operations. Functions accept `&Path`/`&str` parameters and return
//! `Result<EditResult>`, with no dependency on `clap` or process arguments.
//!
//! # Quick start
//!
//! ```rust,no_run
//! use patchloom::api::{self, ApplyMode, EditResult};
//! use std::path::{Path, PathBuf};
//!
//! // Replace text in a file (preview only)
//! let result = api::replace_text(
//!     Path::new("src/config.rs"),
//!     "old_value",
//!     "new_value",
//!     &api::ReplaceOptions::default(),
//!     ApplyMode::Preview,
//!     None,
//! ).unwrap();
//! println!("diff:\n{}", result.diff);
//! ```
//!
//! # Apply modes
//!
//! All write operations accept an [`ApplyMode`]:
//! - [`ApplyMode::Preview`] — compute the result without writing to disk.
//! - [`ApplyMode::Apply`] — write changes to disk with backup.
//! - [`ApplyMode::Check`] — report whether changes would occur (for CI).
//!
//! # Using with PathGuard (containment)
//!
//! All write operations accept an optional `guard: Option<&PathGuard>` (added for library users needing relaxed containment).
//! Pass `None` for no additional checks (default for most internal use).
//! Pass `Some(&guard)` to enforce the policy (e.g. allow temp dirs via the builder).
//!
//! Example:
//!
//! ```rust,no_run
//! use patchloom::api::{self, ApplyMode, ReplaceOptions};
//! use patchloom::containment::PathGuard;
//! use std::path::{Path, PathBuf};
//!
//! let guard = PathGuard::builder(std::env::current_dir().unwrap())
//!     .allow_temp_directory()  // includes /tmp + platform temp (macOS symlink safe)
//!     .build()
//!     .unwrap();
//!
//! let _ = api::replace_text(
//!     Path::new("src/main.rs"),
//!     "old",
//!     "new",
//!     &ReplaceOptions::default(),
//!     ApplyMode::Preview,
//!     Some(&guard),
//! );
//! ```
//!
//! **Guard semantics (see also #756):** 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` now accepts a guard (see its docs; #755) and performs upfront validation on declared paths.
//!
//! ## Guard & WritePolicy contract (#801 exhaustive audit)
//!
//! - 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`.
//! - Upfront declared paths checked for `execute_plan` under guard.
//! - No gaps found on review (greps for ensure/Backup/atomic in api/ + tx.rs + spot in ops).
//! - Regression: the `write_if_apply` + `ensure_contained` helpers + upfront in execute_plan + existing guard tests under ["files"] matrix.
//!
//! # Thread safety
//!
//! All types in this module are `Send + Sync`. Functions are safe to call
//! concurrently from multiple threads when operating on **different files**.
//! Concurrent edits to the **same file** are the caller's responsibility
//! to serialize (e.g., via a `Mutex` per file path).
//!
//! Backup sessions use unique directory names (nanosecond timestamp +
//! monotonic counter), so concurrent `ApplyMode::Apply` calls never collide
//! on backup directories.

use std::path::Path;

use crate::backup::BackupSession;
use crate::containment::PathGuard;
use crate::diff::{DiffResult, format_diff_result, unified_diff};
pub use crate::ops::patch::{Hunk, PatchFile, PatchLine};
use crate::write::{EolMode, WritePolicy, atomic_write};

#[cfg(any(feature = "cli", feature = "files"))]
pub use crate::tx::{
    TxChange, TxLintResult, TxOutput as PlanReport, TxReadResult, TxSearchMatch, TxSearchResult,
};

mod doc;
pub use self::doc::*;

mod replace;
pub use self::replace::*;

mod md;
pub use self::md::*;

mod file;
pub use self::file::*;

mod patch;
pub use self::patch::*;

mod tidy;
pub use self::tidy::*;

mod search;
pub use self::search::*;

mod read;
pub use self::read::*;

mod plan;
pub use self::plan::*;

/// The result of an editing operation.
#[derive(Debug, Clone)]
pub struct EditResult {
    /// Path to the affected file (as provided by the caller).
    pub path: String,
    /// The original file content before the edit.
    pub original_content: String,
    /// The new content after the edit.
    pub new_content: String,
    /// A unified diff between original and new content.
    pub diff: String,
    /// Whether the file was actually written to disk.
    pub applied: bool,
    /// Whether the content changed.
    pub changed: bool,
    /// Action/kind of the edit (e.g. "append", "create", "replace", "rename", "doc.set").
    /// Helps consumers (like Bline) distinguish cross-file or op type without parsing path.
    pub action: &'static str,
    /// For cross-file operations (e.g. `file_rename`, `md_move_section` with `to`),
    /// the destination path if different from `path`.
    pub dest_path: Option<String>,
    /// Number of times the search pattern matched in the original content.
    ///
    /// Only meaningful for replace operations; defaults to `0` for other
    /// operation types (doc, md, file, patch, tidy).
    pub match_count: usize,
}

/// Result of an in-memory content edit (no file path, no applied flag).
///
/// Returned by [`replace::replace_in_content`] for callers that work on
/// in-memory buffers rather than files on disk.
#[derive(Debug, Clone)]
pub struct ContentEditResult {
    /// The original content before the edit.
    pub original: String,
    /// The content after the edit.
    pub new_content: String,
    /// A unified diff between original and new content.
    pub diff: String,
    /// Whether the content changed.
    pub changed: bool,
    /// Number of times the search pattern matched in the original content.
    ///
    /// Populated regardless of whether replacements were applied (e.g. even
    /// when `if_exists` suppresses the error on zero matches, or when `nth`
    /// limits which match is replaced). Embedders can use this to enforce
    /// their own ambiguity policies without pre-scanning the content.
    pub match_count: usize,
}

/// Controls whether an operation writes to disk.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApplyMode {
    /// Compute the result without writing. Returns the diff and new content.
    Preview,
    /// Write changes to disk with backup support.
    Apply,
    /// Report whether changes would occur, without writing.
    Check,
}

/// Options for text replacement operations.
#[derive(Debug, Clone, Default)]
pub struct ReplaceOptions {
    /// Use regex mode for the `from` pattern.
    pub regex: bool,
    /// Replace only the Nth match (1-based). `None` means replace all.
    pub nth: Option<usize>,
    /// Case-insensitive matching.
    pub case_insensitive: bool,
    /// Enable multiline matching (dot matches newlines in regex mode).
    pub multiline: bool,
    /// Text to insert before each match instead of replacing.
    /// Mutually exclusive with `to` (the replacement text) and `insert_after`.
    pub insert_before: Option<String>,
    /// Text to insert after each match instead of replacing.
    /// Mutually exclusive with `to` (the replacement text) and `insert_before`.
    pub insert_after: Option<String>,
    /// Delete/replace entire lines containing the match rather than just the
    /// matched text. When `to` is empty, matching lines are removed.
    pub whole_line: bool,
    /// Restrict matching to a 1-based inclusive line range `(start, end)`.
    /// Requires `whole_line` to be `true`.
    pub range: Option<(usize, Option<usize>)>,
    /// Return success (no error) even when the pattern matches nothing.
    pub if_exists: bool,
    /// When true, match only at word boundaries (`\b` in regex terms).
    /// Prevents `SetupFile` from matching inside `BenchSetupFile`.
    /// The pattern is auto-escaped for regex metacharacters before
    /// wrapping with `\b` anchors.
    pub word_boundary: bool,
    /// When true, the operation fails if the pattern matches more than once.
    ///
    /// This enforces unambiguous edits: the caller is guaranteed that exactly
    /// one location was affected, or the operation is rejected with an error.
    /// Useful for AI coding agents that need to ensure each edit targets a
    /// unique location in the file.
    pub unique: bool,
}

/// Write policy options for controlling file write transformations.
#[derive(Debug, Clone, Default)]
pub struct WritePolicyOptions {
    /// Ensure non-empty files end with a newline.
    pub ensure_final_newline: bool,
    /// Normalize line endings. `None` means keep existing (`EolMode::Keep`).
    pub normalize_eol: Option<EolMode>,
    /// Remove trailing whitespace from each line.
    pub trim_trailing_whitespace: bool,
    /// Collapse consecutive blank lines into a single blank line.
    pub collapse_blanks: bool,
}

/// Backward-compatible alias for [`EolMode`](crate::write::EolMode).
#[deprecated(
    since = "0.6.0",
    note = "use crate::write::EolMode instead (Lf, Crlf, Cr, Keep)"
)]
pub type EolNormalization = EolMode;

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/// Convert user-facing `WritePolicyOptions` to the internal `WritePolicy`.
///
/// Note (for #821): only `tidy` currently accepts `&WritePolicyOptions` at the high-level API.
/// Other mutating functions (`file_append`, `replace_text`, `doc_*`, `md_*`, etc.) default to
/// `WritePolicy::default()` for ergonomics and library backward compat. For full control use a
/// 1-op plan via `execute_plan` (which supports per-step write_policy) or the lower-level
/// `write` + `atomic_write` primitives. Differences from MCP (which uses strict pre-checks + defaults)
/// are intentional.
pub fn make_write_policy(opts: &WritePolicyOptions) -> WritePolicy {
    WritePolicy {
        ensure_final_newline: opts.ensure_final_newline,
        normalize_eol: opts.normalize_eol.unwrap_or(EolMode::Keep),
        trim_trailing_whitespace: opts.trim_trailing_whitespace,
        collapse_blanks: opts.collapse_blanks,
    }
}

/// Generate a unified diff between two in-memory strings.
///
/// Returns an empty string when the contents are identical.
/// The `path` parameter is used for the `--- a/` and `+++ b/` diff headers;
/// pass `None` to use a generic `<content>` placeholder.
///
/// This is the same diff engine used internally by [`replace_in_content`],
/// [`replace_text`], and other editing operations, exposed as a standalone
/// public API for embedders that need to diff arbitrary strings without
/// going through a full edit operation.
pub fn text_diff(original: &str, modified: &str, path: Option<&str>) -> String {
    make_diff(path.unwrap_or("<content>"), original, modified)
}

/// Parse unified diff text into structured patch files and hunks.
///
/// Handles standard unified diff format (`--- a/` / `+++ b/` / `@@`).
/// Tolerant of embedded diffs in prose (only recognizes headers with
/// `a/`/`b/` prefixes, `/dev/null`, tab timestamps, or `diff ` context).
///
/// Returns one [`PatchFile`] per file in the diff, each containing
/// [`Hunk`]s with [`PatchLine`]s for context, added, and removed lines.
///
/// This complements [`text_diff`] (which generates diffs) and
/// `apply_patch` (which applies diffs to files) by providing a
/// parse-only step for embedders that need structured diff data
/// without applying it.
///
/// # Errors
///
/// Returns an error if the diff text contains malformed hunk headers
/// or is otherwise unparseable.
pub fn parse_unified_diff(text: &str) -> Result<Vec<PatchFile>, String> {
    crate::ops::patch::parse_patch(text)
}

fn make_diff(path: &str, old: &str, new: &str) -> String {
    let file_diff = unified_diff(path, old, new);
    let changed = file_diff.has_changes;
    if !changed {
        return String::new();
    }
    let result = DiffResult {
        diffs: vec![file_diff],
    };
    format_diff_result(&result)
}

/// Generalized helper for Apply-mode mutations that need backup + guard.
///
/// Used by write_if_apply and special file ops (create/delete/rename cross-file).
fn apply_mutation(
    path: &Path,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
    prepare_backup: impl FnOnce(&mut BackupSession) -> anyhow::Result<()>,
    perform_mutation: impl FnOnce() -> anyhow::Result<()>,
) -> anyhow::Result<bool> {
    if mode != ApplyMode::Apply {
        return Ok(false);
    }
    ensure_contained(guard, path)?;
    // Use the project root (parent of the file) as backup root.
    // For library users, backup is best-effort.
    let cwd = path.parent().unwrap_or_else(|| Path::new("."));
    let mut backup = BackupSession::new(cwd)?;
    prepare_backup(&mut backup)?;
    perform_mutation()?;
    backup.finalize()?;
    Ok(true)
}

/// Generalized cross-file mutation helper (for rename and md cross-file moves).
///
/// Handles guard checks and backup for src (and optional dst).
/// Used to centralize the cross-file logic per code review #839.
fn apply_cross_file_mutation(
    src: &Path,
    dst: Option<&Path>,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
    prepare_backup: impl FnOnce(&mut BackupSession) -> anyhow::Result<()>,
    perform_mutation: impl FnOnce() -> anyhow::Result<()>,
) -> anyhow::Result<bool> {
    if mode != ApplyMode::Apply {
        return Ok(false);
    }
    ensure_contained(guard, src)?;
    if let Some(d) = dst {
        ensure_contained(guard, d)?;
    }
    let cwd = src.parent().unwrap_or_else(|| Path::new("."));
    let mut backup = BackupSession::new(cwd)?;
    prepare_backup(&mut backup)?;
    perform_mutation()?;
    backup.finalize()?;
    Ok(true)
}

fn write_if_apply(
    path: &Path,
    new_content: &str,
    mode: ApplyMode,
    policy: &WritePolicy,
    guard: Option<&PathGuard>,
) -> anyhow::Result<bool> {
    apply_mutation(
        path,
        mode,
        guard,
        |backup| backup.save_before_write(path),
        || atomic_write(path, new_content, policy),
    )
}

/// Private helper to centralize the guard check and eliminate duplicated
/// inline `if let Some(g) = guard { g.check_path... }` blocks in every
/// write path.
fn ensure_contained(guard: Option<&PathGuard>, path: &Path) -> anyhow::Result<()> {
    if let Some(g) = guard {
        g.check_path(&path.to_string_lossy())
            .map_err(|e| anyhow::anyhow!("path rejected by workspace guard: {}", e))?;
    }
    Ok(())
}

fn build_edit_result(
    path_str: &str,
    original: String,
    new_content: String,
    applied: bool,
    action: &'static str,
    dest_path: Option<String>,
) -> EditResult {
    let diff = make_diff(path_str, &original, &new_content);
    let changed = original != new_content;
    EditResult {
        path: path_str.to_string(),
        original_content: original,
        new_content,
        diff,
        applied,
        changed,
        action,
        dest_path,
        match_count: 0,
    }
}

// ---------------------------------------------------------------------------
// TX engine adapter (requires tx module: cli or files feature)
// ---------------------------------------------------------------------------

/// Execute a single `Operation` through the tx engine and return an `EditResult`.
///
/// This is the bridge between the library API (which uses `ApplyMode` and returns
/// `EditResult`) and the tx engine (which uses `GlobalFlags` and returns
/// `ExecutionResult`). All API write functions can delegate to this adapter
/// instead of reimplementing read-transform-write-backup independently.
#[cfg(any(feature = "cli", feature = "files"))]
pub(crate) fn execute_as_edit_result(
    op: crate::plan::Operation,
    mode: ApplyMode,
    cwd: &Path,
    guard: Option<&PathGuard>,
    action: &'static str,
) -> anyhow::Result<EditResult> {
    let global = mode_to_global_flags(mode);
    let options = crate::tx::engine::ExecuteOptions {
        cwd,
        global: &global,
        guard,
    };
    let result = crate::tx::engine::execute_single(op, options)?;
    execution_result_to_edit_result(result, mode, cwd, action, None)
}

/// Like `execute_as_edit_result` but for cross-file operations (rename, move)
/// where the destination path differs from the source.
#[cfg(any(feature = "cli", feature = "files"))]
pub(crate) fn execute_cross_file_as_edit_result(
    op: crate::plan::Operation,
    mode: ApplyMode,
    cwd: &Path,
    guard: Option<&PathGuard>,
    action: &'static str,
    dest_path: Option<String>,
) -> anyhow::Result<EditResult> {
    let global = mode_to_global_flags(mode);
    let options = crate::tx::engine::ExecuteOptions {
        cwd,
        global: &global,
        guard,
    };
    let result = crate::tx::engine::execute_single(op, options)?;
    execution_result_to_edit_result(result, mode, cwd, action, dest_path)
}

/// Map `ApplyMode` to `GlobalFlags` with the appropriate apply/check settings.
#[cfg(any(feature = "cli", feature = "files"))]
fn mode_to_global_flags(mode: ApplyMode) -> crate::cli::global::GlobalFlags {
    let mut flags = crate::cli::global::GlobalFlags::default();
    match mode {
        ApplyMode::Apply => flags.apply = true,
        ApplyMode::Check => flags.check = true,
        ApplyMode::Preview => {} // default: no apply, no check
    }
    flags
}

/// Convert an `ExecutionResult` into an `EditResult`.
///
/// Handles commit for Apply mode, extracts per-file data from the engine result.
#[cfg(any(feature = "cli", feature = "files"))]
fn execution_result_to_edit_result(
    result: crate::tx::engine::ExecutionResult,
    mode: ApplyMode,
    cwd: &Path,
    action: &'static str,
    dest_path: Option<String>,
) -> anyhow::Result<EditResult> {
    let has_changes = result.has_changes;

    // Extract path + content from the engine result before potentially consuming it.
    let (path_str, original, new_content) =
        if let Some((abs_path, orig, new)) = result.exec_result.changes.first() {
            let rel = crate::files::relative_display(abs_path, cwd);
            (rel.to_string_lossy().to_string(), orig.clone(), new.clone())
        } else if let Some(abs_path) = result.exec_result.deletions.iter().next() {
            // File deletion: content is in the pending map.
            let rel = crate::files::relative_display(abs_path, cwd);
            let original = result
                .exec_result
                .pending
                .get(abs_path)
                .map(|(orig, _)| orig.clone())
                .unwrap_or_default();
            (rel.to_string_lossy().to_string(), original, String::new())
        } else {
            // No changes at all (e.g., replace with no matches + if_exists).
            // Return an unchanged result. We need the path from the operation,
            // but we don't have it here. Use empty path as fallback.
            (String::new(), String::new(), String::new())
        };

    // Commit for Apply mode.
    let applied = if mode == ApplyMode::Apply && has_changes {
        result.commit()?;
        true
    } else {
        false
    };

    Ok(build_edit_result(
        &path_str,
        original,
        new_content,
        applied,
        action,
        dest_path,
    ))
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests;