Skip to main content

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}