patchloom/lib.rs
1#![deny(unsafe_code)]
2//! Patchloom: agent-grade repo operations as a Rust library.
3//!
4//! This crate provides both a CLI binary and a library API for structured
5//! file editing operations. The [`api`] module is the main entry point for
6//! library consumers.
7//!
8//! # Feature flags
9//!
10//! | Feature | Default | Description |
11//! |---------|---------|-------------|
12//! | `cli` | **yes** | CLI parser (clap) and all subcommand implementations. Disable for pure library use. |
13//! | `mcp` | **yes** | MCP server support (adds `tokio`, `rmcp`, `schemars`) |
14//! | `ast` | **yes** | AST-aware operations using tree-sitter (20 language grammars) |
15//! | `files` | no | File scanning helpers + library plan execution + search_directory + append etc. for pure-library use (no CLI/clap). |
16//! | `full` | no | Everything: `cli` + `mcp` + `ast` |
17//!
18//! ## Embedding as a library
19//!
20//! To use patchloom as a library (no CLI, no MCP):
21//!
22//! ```toml
23//! [dependencies]
24//! patchloom = { version = "0.22", default-features = false }
25//! ```
26//!
27//! Or with AST support:
28//!
29//! ```toml
30//! patchloom = { version = "0.22", default-features = false, features = ["ast"] }
31//! ```
32//!
33//! (Keep the version in sync with Cargo.toml / release-please. See the release checklist.)
34//!
35//! This gives you the [`api`] module (primary editing interface), [`ops`],
36//! and utility modules:
37//!
38//! - [`containment`] -- workspace path guard (flexible `AbsolutePathPolicy` via builder for temp dirs/extra roots in library use; strict `Reject` for MCP)
39//! - [`exec`] -- shell command execution with process-tree management
40//! - [`fallback`] -- multi-strategy edit recovery (exact, anchor, similarity)
41//! - [`files`] -- text I/O honesty (#1894): `classify_text_bytes`, `load_text_strict` (sole path),
42//! `try_read_text_file` / `SoftTextSkip` / `read_text_file` (walk soft skip), `is_binary` /
43//! `is_binary_file` (#1884), and (with "files") scan helpers
44//! - [`write`] -- atomic file writes with write-policy transformations
45//!
46//! With "files" feature you also get `api::search_directory`, `api::execute_plan`,
47//! `api::file_append`/`file_prepend`, and full plan execution for library use.
48//! For advanced search ignore (e.g. `.agentignore` on top of `.gitignore`) + custom walkers:
49//! Use `SearchOptions::exclude_patterns` and `custom_ignore_filenames` with `search_directory`/`search_file`,
50//! or collect paths with `files::collect_file_paths_with_ignores` (or your own `WalkBuilder`) then
51//! pair with the low-level `api::search_one_file` inside `par_process_files` + `format_search_results` / `build_context_lines`.
52//! See `api::search_one_file` (and its docs for custom `WalkBuilder` use), `api::SearchOptions`, and `files` module.
53//!
54//! Example (pure library with plans):
55//! ```rust,ignore
56//! use patchloom::api::{execute_plan, parse_plan, ApplyMode, file_append};
57//! use patchloom::containment::PathGuard;
58//! use std::path::Path;
59//!
60//! let guard = PathGuard::builder(std::env::current_dir().unwrap())
61//! .allow_temp_directory()
62//! .build()?;
63//!
64//! // Simple append via api
65//! let _ = file_append(Path::new("log.txt"), "entry\n", ApplyMode::Apply, Some(&guard))?;
66//!
67//! // Or via plan for atomic multi-op
68//! let plan_json = r#"{"version":1,"ops":[{"op":"file.append","path":"log.txt","content":"more\n"}]}"#;
69//! let plan = parse_plan(plan_json)?;
70//! let report = execute_plan(plan, Path::new("."), Some(&guard))?;
71//! assert!(report.ok);
72//! # Ok::<(), anyhow::Error>(())
73//! ```
74//!
75//! For AST signature edits (library embedder surface, #1459 / #821 / #1493):
76//!
77//! - In-memory: `api::ast_rewrite_signature_in_content` or
78//! `ast::rewrite::rewrite_function_signature` with `FunctionSigEdit`
79//! - Parse Rust fragments: `FunctionSigEdit::parse_rust("pub fn f(x: i32) -> T")`
80//! - Full-string rewrites accept a logical signature without trailing space;
81//! high-level helpers preserve the body gap before `{` (`splice_function_signature`, #1503)
82//! - On disk: `api::ast_rewrite_signature`, `api::ast_rename`, `api::ast_replace_in_symbol`
83//! - Multi-file rename: `api::ast_rename_batch` (same-file serialization, per-file results; #1495)
84//! - Plans / MCP: op `ast.rewrite_signature` / tool `ast_rewrite_signature`
85//!
86//! CLI `ast rewrite-signature` is still optional; library + plan + MCP cover embedders.
87//!
88//! Fail-closed text edits for agent hosts (#1492 / #1965 / #2005): use
89//! [`ReplaceOptions::for_agent`] so primary and fallback replace paths share one
90//! policy (`unique`, `require_change`, fuzzy with floor, `allow_absent_old: false`,
91//! `refuse_suspicious_fuzzy: true` for over-wide fuzzy auto-refuse).
92//! Zero matches become `EditErrorKind::NoMatch` (not `Ok(changed=false)`). Match
93//! kinds via `api::edit_error_kind(&err)` without scraping English. That helper
94//! also peels CLI/tx typed errors (`InvalidInputError` for empty patterns / bad
95//! regex, `NoMatchError`, `TypeErrorError` → `EditErrorKind::TypeError` for
96//! multi-doc bare keys (#1883), …) so hosts need not know which construction path
97//! produced the failure. Example:
98//!
99//! ```rust,no_run
100//! use patchloom::api::{self, ReplaceOptions, edit_error_kind, EditErrorKind};
101//! let opts = ReplaceOptions::for_agent();
102//! match api::replace_in_content("a b", "missing", "x", &opts) {
103//! Ok(r) => assert!(r.changed),
104//! Err(e) => assert_eq!(edit_error_kind(&e), Some(EditErrorKind::NoMatch)),
105//! }
106//! match api::replace_in_content("a b", "", "x", &ReplaceOptions::default()) {
107//! Err(e) => assert_eq!(edit_error_kind(&e), Some(EditErrorKind::InvalidInput)),
108//! Ok(_) => panic!("empty pattern must error"),
109//! }
110//! ```
111//!
112//! Shell command-position matching (#1494 / #1666): opt-in
113//! `ReplaceOptions.command_position` rewrites invocable tokens
114//! (`pip install`, `sudo -E pip`, `timeout 30 pip`, `nice -n 10 pip`, `setsid pip`,
115//! `busybox wget`, `flock /tmp/l pip`, `runuser -u app pip`, `chpst -u app pip`,
116//! `with-contenv pip`, `envdir /env pip`) without touching arguments (`uv pip`) or
117//! longer words (`pipenv`). Not the same as `word_boundary`. Incompatible with
118//! `regex`, `whole_line`, `multiline`, `nth`, insert before/after, fuzzy, and
119//! context anchors (typed `InvalidInput`). Works on `replace_text`,
120//! `replace_in_content`, `ContentEdit::Replace`, plan/MCP `command_position`, and
121//! CLI `--command-position`. Post-Apply validate/revert: use
122//! `api::run_post_write_validation` (#1663) or
123//! `backup::restore_path_from_session` / `restore_path_from_latest_backup` (#1660).
124//! After Apply, `EditResult.backup_session` names the session created for that
125//! write so hosts can call `restore_path_from_session` without re-listing
126//! backups (#1686). Nested monorepos: `backup::list_sessions_under` walks
127//! descendant `.patchloom/backups` roots (#1688). Ancestor discovery:
128//! `backup::find_backup_roots(path)` walks parents for roots that contain
129//! `.patchloom/backups` (#1934). File create/delete/rename/append and
130//! `ast_rewrite_signature` peel via `edit_error_kind` (`AlreadyExists` for
131//! dest-exists without force, `NotFound` for missing path I/O, `Binary` /
132//! `InvalidEncoding` for content SoftSkip, `InvalidInput` for dir/empty path,
133//! `NoMatch` for missing AST symbols, `GuardRejected`
134//! for PathGuard; #1935 / #1936 / #1947). Fuzzy policy:
135//! `ReplaceOptions.min_fuzzy_score` rejects weak similarity matches (#1687).
136//! Project-wide rename: `api::ast_rename_project` (#1689). Post-Apply hooks:
137//! `ReplaceOptions.post_write` / `WritePolicyOptions.post_write` / batch
138//! `AstRenameBatchOptions.post_write` (#1690).
139//!
140//! Match honesty for agents (#1662 / #1669 / #1674): `EditResult` /
141//! `ContentEditResult` / `ContentEditsResult` expose `match_mode`
142//! (`Exact` / `Fuzzy` / `Anchored`) and optional `match_score` so hosts can warn on
143//! low-confidence fuzzy sites without re-running the matcher. Plan/tx JSON
144//! (`PlanReport` / `TxOutput`) and MCP `batch_replace` / `execute_plan` include
145//! the same fields on each replace-backed change plus a worst-case aggregate.
146//!
147//! Non-anyhow hosts (#1659): branch with `api::classify_error(&*err as &dyn Error)`
148//! or `classify_error_ref` for `similar_targets`; `edit_error_kind` remains for
149//! `anyhow::Error` chains.
150//!
151//! For several ordered text edits on **one buffer** then a single write (agent intent engines):
152//! use `api::apply_content_edits` / `apply_content_edits_with_label` /
153//! `api::apply_content_edits_to_file` with
154//! `ContentEdit::{Replace, InsertBefore, InsertAfter, Append, Prepend}` (all-or-nothing).
155//! Results expose rolled-up `match_count` across replace ops. Multi-file multi-op remains
156//! `execute_plan`.
157//!
158//! **Note on results**: Single-file ops return `EditResult` (with `action`, `dest_path`,
159//! `match_count` for replace, and `removed` for `doc.delete` / `doc.delete_where`).
160//! `execute_plan` (library) returns `PlanReport` (typed TxOutput) with `ok`, `changes`
161//! (optional per-change `match_mode` / `match_score` / `match_count` for replace), `searches`, `reads`,
162//! `error`, plus `mutations` / aggregate `changed` / `removed` for deletes
163//! (including idempotent `removed: 0` no-ops) (#811, #1439, #1459, #1674).
164//! See `api::PlanReport`, `api::execute_plan`, and embedding docs. CLI/MCP retain (code, json) for compatibility.
165//!
166//! For library users needing relaxed containment (e.g. LLM agents using temp files or host experiment mode):
167//! ```rust,no_run
168//! use patchloom::containment::PathGuard;
169//! let guard = PathGuard::builder(std::env::current_dir().unwrap())
170//! .allow_temp_directory() // includes /tmp and handles macOS /tmp -> /private/tmp
171//! .build()
172//! .expect("guard");
173//! // pass to high-level api functions, e.g.
174//! let _ = patchloom::api::replace_text(
175//! std::path::Path::new("foo.txt"),
176//! "old",
177//! "new",
178//! &patchloom::api::ReplaceOptions::default(),
179//! patchloom::api::ApplyMode::Preview,
180//! Some(&guard),
181//! );
182//! ```
183//!
184//! The `files` module (pure helpers like `is_binary`, `is_binary_file` path preflight,
185//! `load_text_strict`, `read_text_file`, and scanning tools when "files" feature enabled)
186//! is always available. The `cli` and `cmd` modules require the `cli` feature.
187//!
188//! ## Embedder cookbook: text load, binary preflight, multi-doc (#1910 / #1909)
189//!
190//! | Need | Use |
191//! |------|-----|
192//! | Sole path as editable text (binary / bad UTF-8 → typed error) | [`api::load_text`] or [`files::load_text_strict`] |
193//! | Cheap binary path check before open (open fail → `false`) | [`api::is_binary_file`] / [`files::is_binary_file`] |
194//! | Map multi-doc bare key / wrong-root merge to tool `invalid_args` | [`EditErrorKind::TypeError`] / [`api::is_type_error`] |
195//! | Multi-doc YAML merge into document 0 | [`api::doc_merge`](..., `Some("0")`) |
196//! | Map empty pattern / directory target to `invalid_args` | [`EditErrorKind::InvalidInput`] / [`api::is_invalid_input`] |
197//! | Map sole binary / NUL content | [`EditErrorKind::Binary`] / [`api::is_binary`] (`binary`) |
198//! | Map invalid UTF-8 content | [`EditErrorKind::InvalidEncoding`] / [`api::is_invalid_encoding`] |
199//! | Force create over binary/unreadable prior | [`api::file_create`](..., `force: true`) (#1962) |
200//! | Path-only rename/delete non-text (byte backup, no OS dual-path) | [`api::file_rename`] / [`api::file_delete`] (#2031) |
201//! | Delete FIFO/socket/device/symlink under PathGuard | [`api::file_delete`] (dirs still refused; #2087) |
202//! | Rename symlink/FIFO/dangling (path-only; never rewrites link target) | [`api::file_rename`] (#2091) |
203//! | YAML presentation drift on library writes | [`EditResult::style_changed`] / [`api::is_style_changed`] (#2088) |
204//! | Morph-class freeform on disk | [`api::apply_fragment_to_file`] + [`FragmentPlacement`] (#2032) |
205//! | Host unit-test honesty rows (`#[non_exhaustive]`) | [`ContentEditHonesty::exact`] / [`ContentEditHonesty::fuzzy`] (#2033) |
206//! | Map create/rename dest-exists to force/overwrite recovery | [`EditErrorKind::AlreadyExists`] / [`api::is_already_exists`] |
207//! | CLI-stable kind string for host JSON envelopes | [`api::error_kind_str`] / [`api::peel_error`] |
208//! | Map missing path I/O to not-found (not generic op fail) | [`EditErrorKind::NotFound`] / [`api::is_not_found`] |
209//! | Map patch merge conflict markers | [`EditErrorKind::Conflicts`] / [`api::is_conflicts`] (distinct from batch [`EditErrorKind::ConflictingEdit`]) |
210//! | Map check/assert-count exit-2 soft failures | [`EditErrorKind::ChangesDetected`] / [`api::is_changes_detected`] |
211//! | PathGuard / `--contain` rejection | [`EditErrorKind::GuardRejected`] / [`api::is_guard_rejected`] |
212//! | Soft zero matches | [`EditErrorKind::NoMatch`] / [`api::is_no_match`] (JSON kind `no_matches`) |
213//! | Unique multi-match ambiguity | [`EditErrorKind::AmbiguousTarget`] / [`api::is_ambiguous`] (JSON `ambiguous`) |
214//! | Post-write format/lint failure | [`EditErrorKind::FormatFailed`] / [`api::is_format_failed`] |
215//! | Shared agent replace policy (primary + fallback) | [`ReplaceOptions::for_agent`] / [`AGENT_MIN_FUZZY_SCORE`] (#1965 / #2005) |
216//! | Over-wide fuzzy auto-refuse on `for_agent` | [`ReplaceOptions::refuse_suspicious_fuzzy`] / [`EditErrorKind::FuzzySpanSuspicious`] / [`api::is_fuzzy_span_suspicious`] (#2005) |
217//! | Custom over-wide fuzzy refuse | [`api::fuzzy_span_suspicious`] / [`FuzzySpanPolicy`] (#1981) |
218//! | Multi-op per-replace honesty | [`ContentEditsResult::op_honesty`] / [`ContentEditHonesty`] (#2006) |
219//! | Buffer multi-op over-wide fuzzy refuse | [`api::refuse_batch_if_suspicious_fuzzy`] (#2064) |
220//! | Plan/tx multi-path worst-case span | [`prefer_widest_matched_text`] / top-level `matched_text` (#2007) |
221//! | File multi-op pre-write span refuse | [`apply_content_edits_to_file_with_span_policy`] + [`FuzzySpanPolicy`] (#2008) |
222//! | Sole-path load failed as binary/encoding/invalid_input | [`api::is_load_text_strict_fail`] (#1963) |
223//! | Ordered host onboarding (primary + fallback + peels + multi-op) | [Embedder host checklist](docs/getting-started/embedder-host.md) (#2009) |
224//!
225//! `EditErrorKind` is `#[non_exhaustive]`: always include a wildcard arm when matching.
226//!
227//! ```rust,no_run
228//! use patchloom::api::{self, edit_error_kind, EditErrorKind, ApplyMode};
229//! use std::path::Path;
230//!
231//! let path = Path::new("stream.yaml");
232//! if api::is_binary_file(path) {
233//! // host: refuse tool call as invalid_args
234//! }
235//! let _text = api::load_text(path)?; // Binary / InvalidEncoding if non-text
236//! match api::doc_merge(path, serde_json::json!({"c": 3}), ApplyMode::Apply, None, Some("0")) {
237//! Ok(r) => assert!(r.changed),
238//! Err(e) if edit_error_kind(&e) == Some(EditErrorKind::TypeError) => {
239//! // multi-doc bare root / wrong type
240//! }
241//! Err(e) => return Err(e),
242//! }
243//! # Ok::<(), anyhow::Error>(())
244//! ```
245//!
246//! For pure library use with plans and execution (post #792), prefer
247//! `features = ["ast", "files"]` (or "files"). `execute_plan` is available
248//! under `any(feature = "cli", "files")` and delegates to the `tx` module.
249//!
250//! ## Migration for high-level api::* signature changes (PathGuard, #758)
251//!
252//! The addition of the trailing `guard: Option<&PathGuard>` parameter to all mutating
253//! functions (replace_text, doc_*, md_*, file_*, tidy, apply_patch, etc.) and to
254//! `execute_plan` is a source-breaking change from pre-#749 usage.
255//!
256//! ```rust,ignore
257//! // Before
258//! patchloom::api::doc_set(&p, "k", v, ApplyMode::Apply)?;
259//!
260//! // After (pass None to keep previous strict-root behavior, or a guard for relaxed)
261//! patchloom::api::doc_set(&p, "k", v, ApplyMode::Apply, None)?;
262//! ```
263//!
264//! See the "Using with PathGuard" section in the `api` module docs, the builder
265//! for relaxed policies, and AGENTS.md "High-level library API signature changes"
266//! for the full checklist (doctests, greps, examples, tests). `execute_plan` now
267//! also accepts the guard (threaded into tx; #755).
268//!
269//! With `features = ["ast"]`, the [`ast`] module provides tree-sitter parsing,
270//! symbol extraction, structural search, rename, and more for 20 languages.
271//!
272//! No `clap`, `tokio` or other heavy dependencies are pulled in when `cli` and `mcp` are disabled.
273//!
274//! ## Thread safety
275//!
276//! All public API types ([`api::EditResult`], [`api::ApplyMode`], etc.) are
277//! `Send + Sync`. Library functions are safe to call concurrently from
278//! multiple threads with one constraint:
279//!
280//! - **Different files**: fully safe. Multiple threads can edit different files
281//! simultaneously with no coordination.
282//! - **Same file**: the caller must serialize access. Concurrent writes to the
283//! same file are inherently racy (last writer wins). Use a mutex or other
284//! synchronization if you need to coordinate edits to a single file.
285//!
286//! Backup sessions use unique directory names (nanosecond timestamp +
287//! monotonic counter) so concurrent backup creation never collides.
288//!
289//! Configuration can be loaded once with [`config::CachedConfig`] and reused
290//! across threads, avoiding repeated disk reads.
291
292pub mod api;
293#[cfg(feature = "ast")]
294pub mod ast;
295pub mod backup;
296pub mod cli;
297#[cfg(feature = "cli")]
298pub mod cmd;
299pub mod config;
300pub mod containment;
301pub(crate) mod diff;
302pub mod exec;
303pub(crate) mod exit;
304pub mod fallback;
305pub mod files;
306// Fail-closed structured stdout helper (CLI + library agent hosts). Not CLI-only:
307// used by `GlobalFlags`, `cmd/doc`, and `api::format_search_results` (#1651 class).
308pub(crate) mod json_emit;
309pub mod ops;
310pub mod plan;
311pub mod schema;
312pub mod selector;
313pub mod write;
314
315// Re-exports for library ergonomics (no need to dig into api/plan when using ["ast","files"]).
316#[cfg(any(feature = "cli", feature = "files"))]
317pub use api::search_one_file;
318pub use api::{
319 AGENT_MIN_FUZZY_SCORE, ApplyFragmentSpec, ApplyMode, ContentEdit, ContentEditHonesty,
320 ContentEditResult, ContentEditsResult, DesugaredReplace, EditError, EditErrorKind, EditResult,
321 FragmentPlacement, FuzzySpanPolicy, Hunk, MatchMode, PatchFile, PatchLine, PeeledError,
322 PostWriteHooks, PostWriteOnFailure, ReplaceOptions, SearchOptions, SearchResult,
323 WritePolicyOptions, apply_content_edits, apply_content_edits_with_label,
324 apply_post_write_validator, build_apply_fragment_spec, build_context_lines, classify_error,
325 classify_error_ref, desugar_to_replace_fields, desugar_to_replace_operation, edit_error_kind,
326 edit_error_ref, error_kind_str, format_search_results, fuzzy_span_suspicious,
327 fuzzy_span_suspicious_with_policy, is_already_exists, is_ambiguous, is_binary, is_binary_file,
328 is_changes_detected, is_conflicts, is_format_failed, is_fuzzy_span_suspicious,
329 is_guard_rejected, is_invalid_encoding, is_invalid_input, is_lazy_marker_line,
330 is_load_text_strict_fail, is_no_match, is_not_found, is_style_changed, is_type_error,
331 load_text, load_text_strict, merge_match_modes, parse_unified_diff, peel_error,
332 plan_apply_fragment_to_replace, prefer_widest_matched_text, refuse_batch_if_suspicious_fuzzy,
333 run_post_write_validation, search_file, strip_lazy_markers, text_diff,
334};
335#[cfg(any(feature = "cli", feature = "files"))]
336pub use api::{
337 apply_content_edits_to_file, apply_content_edits_to_file_with_span_policy,
338 apply_fragment_to_file,
339};
340pub use plan::Plan;
341
342#[cfg(any(feature = "cli", feature = "files"))]
343pub(crate) mod tx;
344
345#[cfg(feature = "cli")]
346pub(crate) use files::*;
347
348// ---------------------------------------------------------------------------
349// Verbose logging
350// ---------------------------------------------------------------------------
351
352/// Global flag set once at startup; checked by the `verbose!` macro.
353static VERBOSE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
354
355/// Returns `true` if verbose mode is enabled.
356pub fn is_verbose() -> bool {
357 VERBOSE.load(std::sync::atomic::Ordering::Relaxed)
358}
359
360/// Enable verbose mode globally. Called once at startup.
361#[cfg_attr(not(feature = "cli"), allow(dead_code))]
362fn enable_verbose() {
363 VERBOSE.store(true, std::sync::atomic::Ordering::Relaxed);
364}
365
366/// Print a verbose diagnostic message to stderr.
367///
368/// Usage: `verbose!("processing {} files", count);`
369#[macro_export]
370macro_rules! verbose {
371 ($($arg:tt)*) => {
372 if $crate::is_verbose() {
373 eprintln!("[patchloom] {}", format!($($arg)*));
374 }
375 };
376}
377
378// ---------------------------------------------------------------------------
379// Bounded regex compilation
380// ---------------------------------------------------------------------------
381
382/// Create a [`regex::RegexBuilder`] with bounded compilation limits.
383///
384/// All user-supplied regex patterns must go through this function so that
385/// pathological patterns cannot exhaust memory (important when patchloom
386/// runs as an MCP server handling untrusted input).
387pub fn bounded_regex_builder(pattern: &str) -> regex::RegexBuilder {
388 let mut b = regex::RegexBuilder::new(pattern);
389 // 10 MiB compiled program + DFA cache limits.
390 b.size_limit(10 * 1024 * 1024);
391 b.dfa_size_limit(10 * 1024 * 1024);
392 b
393}
394
395/// Finish a [`bounded_regex_builder`] with typed [`exit::InvalidInputError`].
396///
397/// Use this instead of `.build()?` so CLI JSON and library `edit_error_kind`
398/// peel `invalid_input` for bad patterns without scraping the regex crate.
399pub fn bounded_regex_build(builder: &mut regex::RegexBuilder) -> anyhow::Result<regex::Regex> {
400 builder.build().map_err(|e| {
401 // Regex crate Display already starts with "regex parse error:".
402 anyhow::Error::new(exit::InvalidInputError { msg: e.to_string() })
403 })
404}
405
406/// Run the patchloom CLI. Returns the exit code as a u8.
407///
408/// Requires the `cli` feature (enabled by default).
409///
410/// CLI usage errors (unknown flags, invalid enum values, missing required
411/// args, unrecognized subcommands) map to [`exit::FAILURE`] (1). Clap's
412/// default exit 2 collides with [`exit::CHANGES_DETECTED`] (preview/`--check`
413/// pending changes), so agents and scripts must not treat clap's default as
414/// "changes detected."
415#[cfg(feature = "cli")]
416pub fn run() -> anyhow::Result<u8> {
417 use clap::Parser;
418
419 let cli = match crate::cli::Cli::try_parse() {
420 Ok(cli) => cli,
421 Err(e) => {
422 // Help/version print to stdout and are success; everything else
423 // (usage, invalid value, missing arg) is a general failure.
424 if !e.use_stderr() {
425 let _ = e.print();
426 return Ok(exit::SUCCESS);
427 }
428 // Peek argv: parse failed before GlobalFlags is available, but
429 // agents often pass --json/--jsonl globally and need a structured
430 // envelope instead of clap's human usage text.
431 let wants_json = std::env::args().any(|a| a == "--json");
432 let wants_jsonl = std::env::args().any(|a| a == "--jsonl");
433 if wants_json || wants_jsonl {
434 let msg = clap_usage_error_message(&e);
435 let payload = serde_json::json!({
436 "ok": false,
437 "error": msg,
438 "error_kind": "invalid_input",
439 });
440 // Fail-closed: never empty stdout (#1651).
441 let _ = json_emit::print_structured(&payload, wants_jsonl);
442 } else {
443 // Swallow broken-pipe on print (same as clap::Error::exit).
444 let _ = e.print();
445 }
446 return Ok(exit::FAILURE);
447 }
448 };
449
450 // Enable verbose mode from --verbose flag or PATCHLOOM_LOG env var.
451 if cli.global.verbose || std::env::var_os("PATCHLOOM_LOG").is_some() {
452 enable_verbose();
453 }
454
455 let structured = cli.global.json || cli.global.jsonl;
456 let compact = cli.global.jsonl;
457 match cmd::dispatch(cli) {
458 Ok(code) => Ok(code),
459 Err(e) if structured => {
460 let (output, code) = structured_dispatch_error(&e);
461 // Fail-closed: empty stdout is never valid for agents (#1651).
462 let primary_ok = json_emit::print_structured(&output, compact);
463 Ok(json_emit::exit_after_emit(primary_ok, code))
464 }
465 Err(e) => Err(e),
466 }
467}
468
469/// Compact clap usage text for JSON envelopes: drop the `error: ` prefix and
470/// trailing Usage / help footer so agents get a single actionable sentence.
471#[cfg(feature = "cli")]
472fn clap_usage_error_message(err: &clap::Error) -> String {
473 let raw = err.to_string();
474 let body = raw.strip_prefix("error: ").unwrap_or(&raw);
475 let mut lines = Vec::new();
476 for line in body.lines() {
477 let t = line.trim_end();
478 if t.is_empty() && lines.is_empty() {
479 continue;
480 }
481 if t.starts_with("Usage:")
482 || t.starts_with("For more information")
483 || t.starts_with("[possible values:")
484 {
485 // Keep possible-values lines: they are useful for agents.
486 if t.starts_with("[possible values:") {
487 lines.push(t.to_string());
488 }
489 if t.starts_with("Usage:") || t.starts_with("For more information") {
490 break;
491 }
492 continue;
493 }
494 // Indent under possible values is often " [possible values: …]"
495 if t.trim_start().starts_with("[possible values:") {
496 lines.push(t.trim_start().to_string());
497 continue;
498 }
499 if lines.len() >= 3 {
500 break;
501 }
502 lines.push(t.to_string());
503 }
504 let msg = lines
505 .join(" ")
506 .split_whitespace()
507 .collect::<Vec<_>>()
508 .join(" ");
509 if msg.is_empty() {
510 body.trim().to_string()
511 } else {
512 msg
513 }
514}
515
516/// Build the JSON error envelope and exit code for a dispatch `Err` under
517/// `--json` / `--jsonl`. Typed exit kinds (`NoMatchError`, `AmbiguousError`,
518/// `InvalidInputError`, `ParseErrorError`, …) get `error_kind` and exit codes.
519/// Post-write format failures also expose `backup_session` when known.
520#[cfg(feature = "cli")]
521fn structured_dispatch_error(err: &anyhow::Error) -> (serde_json::Value, u8) {
522 exit::structured_error_payload(err)
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[cfg(feature = "cli")]
530 #[test]
531 fn structured_dispatch_error_maps_no_match() {
532 let err: anyhow::Error = exit::NoMatchError {
533 msg: "missing target".into(),
534 }
535 .into();
536 let (payload, code) = structured_dispatch_error(&err);
537 assert_eq!(code, exit::NO_MATCHES);
538 assert_eq!(payload["ok"], false);
539 assert_eq!(payload["error_kind"], "no_matches");
540 assert!(
541 payload["error"]
542 .as_str()
543 .unwrap_or("")
544 .contains("missing target"),
545 "payload={payload}"
546 );
547 }
548
549 #[cfg(feature = "cli")]
550 #[test]
551 fn structured_dispatch_error_maps_ambiguous() {
552 let err: anyhow::Error = exit::AmbiguousError {
553 msg: "two hits".into(),
554 }
555 .into();
556 let (payload, code) = structured_dispatch_error(&err);
557 assert_eq!(code, exit::AMBIGUOUS);
558 assert_eq!(payload["error_kind"], "ambiguous");
559 }
560
561 #[cfg(feature = "cli")]
562 #[test]
563 fn structured_dispatch_error_generic_stays_failure() {
564 let err = anyhow::anyhow!("file already exists");
565 let (payload, code) = structured_dispatch_error(&err);
566 assert_eq!(code, exit::FAILURE);
567 assert!(payload.get("error_kind").is_none());
568 assert_eq!(payload["ok"], false);
569 }
570
571 #[cfg(feature = "cli")]
572 #[test]
573 fn structured_dispatch_error_maps_invalid_input() {
574 let err: anyhow::Error = exit::InvalidInputError {
575 msg: "path rejected by workspace guard: escapes".into(),
576 }
577 .into();
578 let (payload, code) = structured_dispatch_error(&err);
579 assert_eq!(code, exit::FAILURE);
580 assert_eq!(payload["error_kind"], "invalid_input");
581 assert!(
582 payload["error"]
583 .as_str()
584 .unwrap_or("")
585 .contains("workspace guard"),
586 "payload={payload}"
587 );
588 }
589
590 #[cfg(feature = "cli")]
591 #[test]
592 fn structured_dispatch_error_maps_io_not_found() {
593 let err: anyhow::Error = std::io::Error::new(std::io::ErrorKind::NotFound, "nope").into();
594 let err = err.context("reading missing.md");
595 let (payload, code) = structured_dispatch_error(&err);
596 assert_eq!(code, exit::FAILURE);
597 assert_eq!(payload["error_kind"], "not_found");
598 }
599
600 #[cfg(feature = "cli")]
601 #[test]
602 fn structured_dispatch_error_maps_format_failed() {
603 let err: anyhow::Error = exit::FormatFailedError::new("format command failed (false)")
604 .with_backup_session(Some("99_0".into()))
605 .into();
606 let err = err.context("files were written but formatting failed");
607 let (payload, code) = structured_dispatch_error(&err);
608 assert_eq!(code, exit::FAILURE);
609 assert_eq!(payload["error_kind"], "format_failed");
610 assert_eq!(payload["backup_session"], "99_0");
611 }
612
613 #[test]
614 fn bounded_regex_builder_compiles_valid_pattern() {
615 let re = bounded_regex_builder(r"\d+").build().unwrap();
616 assert!(re.is_match("abc123"));
617 }
618
619 #[test]
620 fn bounded_regex_builder_rejects_invalid_pattern() {
621 bounded_regex_builder(r"(unclosed")
622 .build()
623 .expect_err("expected error");
624 }
625
626 #[test]
627 fn bounded_regex_build_maps_invalid_input() {
628 let mut b = bounded_regex_builder(r"(unclosed");
629 let err = bounded_regex_build(&mut b).expect_err("expected error");
630 assert!(err.to_string().contains("regex parse error"));
631 assert!(crate::exit::is_invalid_input(&err));
632 assert_eq!(
633 crate::fallback::edit_error_kind(&err),
634 Some(crate::fallback::EditErrorKind::InvalidInput)
635 );
636 }
637
638 #[test]
639 fn bounded_regex_builder_applies_size_limit() {
640 // Verify that our builder applies size limits by comparing
641 // with a manually-limited builder. A small pattern with a
642 // 1-byte limit must fail, proving the mechanism works.
643 let mut tiny = regex::RegexBuilder::new(r"\d+");
644 tiny.size_limit(1);
645 assert!(tiny.build().is_err(), "1-byte limit should reject any NFA");
646
647 // Our builder should compile normal patterns just fine.
648 bounded_regex_builder(r"\d+")
649 .build()
650 .expect("simple pattern should compile");
651
652 // Verify the builder actually sets size_limit (not just dfa_size_limit)
653 // by building a moderately large pattern that fits 10 MiB.
654 let medium: String = (0..1000)
655 .map(|i| format!("word_{i}"))
656 .collect::<Vec<_>>()
657 .join("|");
658 bounded_regex_builder(&medium)
659 .build()
660 .expect("1K-alternation pattern should compile within 10 MiB limit");
661 }
662}