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;