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//! | Morph-class freeform on disk | [`api::apply_fragment_to_file`] + [`FragmentPlacement`] (#2032) |
202//! | Host unit-test honesty rows (`#[non_exhaustive]`) | [`ContentEditHonesty::exact`] / [`ContentEditHonesty::fuzzy`] (#2033) |
203//! | Map create/rename dest-exists to force/overwrite recovery | [`EditErrorKind::AlreadyExists`] / [`api::is_already_exists`] |
204//! | CLI-stable kind string for host JSON envelopes | [`api::error_kind_str`] / [`api::peel_error`] |
205//! | Map missing path I/O to not-found (not generic op fail) | [`EditErrorKind::NotFound`] / [`api::is_not_found`] |
206//! | Map patch merge conflict markers | [`EditErrorKind::Conflicts`] / [`api::is_conflicts`] (distinct from batch [`EditErrorKind::ConflictingEdit`]) |
207//! | Map check/assert-count exit-2 soft failures | [`EditErrorKind::ChangesDetected`] / [`api::is_changes_detected`] |
208//! | PathGuard / `--contain` rejection | [`EditErrorKind::GuardRejected`] / [`api::is_guard_rejected`] |
209//! | Soft zero matches | [`EditErrorKind::NoMatch`] / [`api::is_no_match`] (JSON kind `no_matches`) |
210//! | Unique multi-match ambiguity | [`EditErrorKind::AmbiguousTarget`] / [`api::is_ambiguous`] (JSON `ambiguous`) |
211//! | Post-write format/lint failure | [`EditErrorKind::FormatFailed`] / [`api::is_format_failed`] |
212//! | Shared agent replace policy (primary + fallback) | [`ReplaceOptions::for_agent`] / [`AGENT_MIN_FUZZY_SCORE`] (#1965 / #2005) |
213//! | Over-wide fuzzy auto-refuse on `for_agent` | [`ReplaceOptions::refuse_suspicious_fuzzy`] / [`EditErrorKind::FuzzySpanSuspicious`] / [`api::is_fuzzy_span_suspicious`] (#2005) |
214//! | Custom over-wide fuzzy refuse | [`api::fuzzy_span_suspicious`] / [`FuzzySpanPolicy`] (#1981) |
215//! | Multi-op per-replace honesty | [`ContentEditsResult::op_honesty`] / [`ContentEditHonesty`] (#2006) |
216//! | Buffer multi-op over-wide fuzzy refuse | [`api::refuse_batch_if_suspicious_fuzzy`] (#2064) |
217//! | Plan/tx multi-path worst-case span | [`prefer_widest_matched_text`] / top-level `matched_text` (#2007) |
218//! | File multi-op pre-write span refuse | [`apply_content_edits_to_file_with_span_policy`] + [`FuzzySpanPolicy`] (#2008) |
219//! | Sole-path load failed as binary/encoding/invalid_input | [`api::is_load_text_strict_fail`] (#1963) |
220//! | Ordered host onboarding (primary + fallback + peels + multi-op) | [Embedder host checklist](docs/getting-started/embedder-host.md) (#2009) |
221//!
222//! `EditErrorKind` is `#[non_exhaustive]`: always include a wildcard arm when matching.
223//!
224//! ```rust,no_run
225//! use patchloom::api::{self, edit_error_kind, EditErrorKind, ApplyMode};
226//! use std::path::Path;
227//!
228//! let path = Path::new("stream.yaml");
229//! if api::is_binary_file(path) {
230//!     // host: refuse tool call as invalid_args
231//! }
232//! let _text = api::load_text(path)?; // Binary / InvalidEncoding if non-text
233//! match api::doc_merge(path, serde_json::json!({"c": 3}), ApplyMode::Apply, None, Some("0")) {
234//!     Ok(r) => assert!(r.changed),
235//!     Err(e) if edit_error_kind(&e) == Some(EditErrorKind::TypeError) => {
236//!         // multi-doc bare root / wrong type
237//!     }
238//!     Err(e) => return Err(e),
239//! }
240//! # Ok::<(), anyhow::Error>(())
241//! ```
242//!
243//! For pure library use with plans and execution (post #792), prefer
244//! `features = ["ast", "files"]` (or "files"). `execute_plan` is available
245//! under `any(feature = "cli", "files")` and delegates to the `tx` module.
246//!
247//! ## Migration for high-level api::* signature changes (PathGuard, #758)
248//!
249//! The addition of the trailing `guard: Option<&PathGuard>` parameter to all mutating
250//! functions (replace_text, doc_*, md_*, file_*, tidy, apply_patch, etc.) and to
251//! `execute_plan` is a source-breaking change from pre-#749 usage.
252//!
253//! ```rust,ignore
254//! // Before
255//! patchloom::api::doc_set(&p, "k", v, ApplyMode::Apply)?;
256//!
257//! // After (pass None to keep previous strict-root behavior, or a guard for relaxed)
258//! patchloom::api::doc_set(&p, "k", v, ApplyMode::Apply, None)?;
259//! ```
260//!
261//! See the "Using with PathGuard" section in the `api` module docs, the builder
262//! for relaxed policies, and AGENTS.md "High-level library API signature changes"
263//! for the full checklist (doctests, greps, examples, tests). `execute_plan` now
264//! also accepts the guard (threaded into tx; #755).
265//!
266//! With `features = ["ast"]`, the [`ast`] module provides tree-sitter parsing,
267//! symbol extraction, structural search, rename, and more for 20 languages.
268//!
269//! No `clap`, `tokio` or other heavy dependencies are pulled in when `cli` and `mcp` are disabled.
270//!
271//! ## Thread safety
272//!
273//! All public API types ([`api::EditResult`], [`api::ApplyMode`], etc.) are
274//! `Send + Sync`. Library functions are safe to call concurrently from
275//! multiple threads with one constraint:
276//!
277//! - **Different files**: fully safe. Multiple threads can edit different files
278//!   simultaneously with no coordination.
279//! - **Same file**: the caller must serialize access. Concurrent writes to the
280//!   same file are inherently racy (last writer wins). Use a mutex or other
281//!   synchronization if you need to coordinate edits to a single file.
282//!
283//! Backup sessions use unique directory names (nanosecond timestamp +
284//! monotonic counter) so concurrent backup creation never collides.
285//!
286//! Configuration can be loaded once with [`config::CachedConfig`] and reused
287//! across threads, avoiding repeated disk reads.
288
289pub mod api;
290#[cfg(feature = "ast")]
291pub mod ast;
292pub mod backup;
293pub mod cli;
294#[cfg(feature = "cli")]
295pub mod cmd;
296pub mod config;
297pub mod containment;
298pub(crate) mod diff;
299pub mod exec;
300pub(crate) mod exit;
301pub mod fallback;
302pub mod files;
303// Fail-closed structured stdout helper (CLI + library agent hosts). Not CLI-only:
304// used by `GlobalFlags`, `cmd/doc`, and `api::format_search_results` (#1651 class).
305pub(crate) mod json_emit;
306pub mod ops;
307pub mod plan;
308pub mod schema;
309pub mod selector;
310pub mod write;
311
312// Re-exports for library ergonomics (no need to dig into api/plan when using ["ast","files"]).
313#[cfg(any(feature = "cli", feature = "files"))]
314pub use api::search_one_file;
315pub use api::{
316    AGENT_MIN_FUZZY_SCORE, ApplyFragmentSpec, ApplyMode, ContentEdit, ContentEditHonesty,
317    ContentEditResult, ContentEditsResult, DesugaredReplace, EditError, EditErrorKind, EditResult,
318    FragmentPlacement, FuzzySpanPolicy, Hunk, MatchMode, PatchFile, PatchLine, PeeledError,
319    PostWriteHooks, PostWriteOnFailure, ReplaceOptions, SearchOptions, SearchResult,
320    WritePolicyOptions, apply_content_edits, apply_content_edits_with_label,
321    apply_post_write_validator, build_apply_fragment_spec, build_context_lines, classify_error,
322    classify_error_ref, desugar_to_replace_fields, desugar_to_replace_operation, edit_error_kind,
323    edit_error_ref, error_kind_str, format_search_results, fuzzy_span_suspicious,
324    fuzzy_span_suspicious_with_policy, is_already_exists, is_ambiguous, is_binary, is_binary_file,
325    is_changes_detected, is_conflicts, is_format_failed, is_fuzzy_span_suspicious,
326    is_guard_rejected, is_invalid_encoding, is_invalid_input, is_lazy_marker_line,
327    is_load_text_strict_fail, is_no_match, is_not_found, is_type_error, load_text,
328    load_text_strict, merge_match_modes, parse_unified_diff, peel_error,
329    plan_apply_fragment_to_replace, prefer_widest_matched_text, refuse_batch_if_suspicious_fuzzy,
330    run_post_write_validation, search_file, strip_lazy_markers, text_diff,
331};
332#[cfg(any(feature = "cli", feature = "files"))]
333pub use api::{
334    apply_content_edits_to_file, apply_content_edits_to_file_with_span_policy,
335    apply_fragment_to_file,
336};
337pub use plan::Plan;
338
339#[cfg(any(feature = "cli", feature = "files"))]
340pub(crate) mod tx;
341
342#[cfg(feature = "cli")]
343pub(crate) use files::*;
344
345// ---------------------------------------------------------------------------
346// Verbose logging
347// ---------------------------------------------------------------------------
348
349/// Global flag set once at startup; checked by the `verbose!` macro.
350static VERBOSE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
351
352/// Returns `true` if verbose mode is enabled.
353pub fn is_verbose() -> bool {
354    VERBOSE.load(std::sync::atomic::Ordering::Relaxed)
355}
356
357/// Enable verbose mode globally. Called once at startup.
358#[cfg_attr(not(feature = "cli"), allow(dead_code))]
359fn enable_verbose() {
360    VERBOSE.store(true, std::sync::atomic::Ordering::Relaxed);
361}
362
363/// Print a verbose diagnostic message to stderr.
364///
365/// Usage: `verbose!("processing {} files", count);`
366#[macro_export]
367macro_rules! verbose {
368    ($($arg:tt)*) => {
369        if $crate::is_verbose() {
370            eprintln!("[patchloom] {}", format!($($arg)*));
371        }
372    };
373}
374
375// ---------------------------------------------------------------------------
376// Bounded regex compilation
377// ---------------------------------------------------------------------------
378
379/// Create a [`regex::RegexBuilder`] with bounded compilation limits.
380///
381/// All user-supplied regex patterns must go through this function so that
382/// pathological patterns cannot exhaust memory (important when patchloom
383/// runs as an MCP server handling untrusted input).
384pub fn bounded_regex_builder(pattern: &str) -> regex::RegexBuilder {
385    let mut b = regex::RegexBuilder::new(pattern);
386    // 10 MiB compiled program + DFA cache limits.
387    b.size_limit(10 * 1024 * 1024);
388    b.dfa_size_limit(10 * 1024 * 1024);
389    b
390}
391
392/// Finish a [`bounded_regex_builder`] with typed [`exit::InvalidInputError`].
393///
394/// Use this instead of `.build()?` so CLI JSON and library `edit_error_kind`
395/// peel `invalid_input` for bad patterns without scraping the regex crate.
396pub fn bounded_regex_build(builder: &mut regex::RegexBuilder) -> anyhow::Result<regex::Regex> {
397    builder.build().map_err(|e| {
398        // Regex crate Display already starts with "regex parse error:".
399        anyhow::Error::new(exit::InvalidInputError { msg: e.to_string() })
400    })
401}
402
403/// Run the patchloom CLI. Returns the exit code as a u8.
404///
405/// Requires the `cli` feature (enabled by default).
406///
407/// CLI usage errors (unknown flags, invalid enum values, missing required
408/// args, unrecognized subcommands) map to [`exit::FAILURE`] (1). Clap's
409/// default exit 2 collides with [`exit::CHANGES_DETECTED`] (preview/`--check`
410/// pending changes), so agents and scripts must not treat clap's default as
411/// "changes detected."
412#[cfg(feature = "cli")]
413pub fn run() -> anyhow::Result<u8> {
414    use clap::Parser;
415
416    let cli = match crate::cli::Cli::try_parse() {
417        Ok(cli) => cli,
418        Err(e) => {
419            // Help/version print to stdout and are success; everything else
420            // (usage, invalid value, missing arg) is a general failure.
421            if !e.use_stderr() {
422                let _ = e.print();
423                return Ok(exit::SUCCESS);
424            }
425            // Peek argv: parse failed before GlobalFlags is available, but
426            // agents often pass --json/--jsonl globally and need a structured
427            // envelope instead of clap's human usage text.
428            let wants_json = std::env::args().any(|a| a == "--json");
429            let wants_jsonl = std::env::args().any(|a| a == "--jsonl");
430            if wants_json || wants_jsonl {
431                let msg = clap_usage_error_message(&e);
432                let payload = serde_json::json!({
433                    "ok": false,
434                    "error": msg,
435                    "error_kind": "invalid_input",
436                });
437                // Fail-closed: never empty stdout (#1651).
438                let _ = json_emit::print_structured(&payload, wants_jsonl);
439            } else {
440                // Swallow broken-pipe on print (same as clap::Error::exit).
441                let _ = e.print();
442            }
443            return Ok(exit::FAILURE);
444        }
445    };
446
447    // Enable verbose mode from --verbose flag or PATCHLOOM_LOG env var.
448    if cli.global.verbose || std::env::var_os("PATCHLOOM_LOG").is_some() {
449        enable_verbose();
450    }
451
452    let structured = cli.global.json || cli.global.jsonl;
453    let compact = cli.global.jsonl;
454    match cmd::dispatch(cli) {
455        Ok(code) => Ok(code),
456        Err(e) if structured => {
457            let (output, code) = structured_dispatch_error(&e);
458            // Fail-closed: empty stdout is never valid for agents (#1651).
459            let primary_ok = json_emit::print_structured(&output, compact);
460            Ok(json_emit::exit_after_emit(primary_ok, code))
461        }
462        Err(e) => Err(e),
463    }
464}
465
466/// Compact clap usage text for JSON envelopes: drop the `error: ` prefix and
467/// trailing Usage / help footer so agents get a single actionable sentence.
468#[cfg(feature = "cli")]
469fn clap_usage_error_message(err: &clap::Error) -> String {
470    let raw = err.to_string();
471    let body = raw.strip_prefix("error: ").unwrap_or(&raw);
472    let mut lines = Vec::new();
473    for line in body.lines() {
474        let t = line.trim_end();
475        if t.is_empty() && lines.is_empty() {
476            continue;
477        }
478        if t.starts_with("Usage:")
479            || t.starts_with("For more information")
480            || t.starts_with("[possible values:")
481        {
482            // Keep possible-values lines: they are useful for agents.
483            if t.starts_with("[possible values:") {
484                lines.push(t.to_string());
485            }
486            if t.starts_with("Usage:") || t.starts_with("For more information") {
487                break;
488            }
489            continue;
490        }
491        // Indent under possible values is often "  [possible values: …]"
492        if t.trim_start().starts_with("[possible values:") {
493            lines.push(t.trim_start().to_string());
494            continue;
495        }
496        if lines.len() >= 3 {
497            break;
498        }
499        lines.push(t.to_string());
500    }
501    let msg = lines
502        .join(" ")
503        .split_whitespace()
504        .collect::<Vec<_>>()
505        .join(" ");
506    if msg.is_empty() {
507        body.trim().to_string()
508    } else {
509        msg
510    }
511}
512
513/// Build the JSON error envelope and exit code for a dispatch `Err` under
514/// `--json` / `--jsonl`. Typed exit kinds (`NoMatchError`, `AmbiguousError`,
515/// `InvalidInputError`, `ParseErrorError`, …) get `error_kind` and exit codes.
516/// Post-write format failures also expose `backup_session` when known.
517#[cfg(feature = "cli")]
518fn structured_dispatch_error(err: &anyhow::Error) -> (serde_json::Value, u8) {
519    exit::structured_error_payload(err)
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[cfg(feature = "cli")]
527    #[test]
528    fn structured_dispatch_error_maps_no_match() {
529        let err: anyhow::Error = exit::NoMatchError {
530            msg: "missing target".into(),
531        }
532        .into();
533        let (payload, code) = structured_dispatch_error(&err);
534        assert_eq!(code, exit::NO_MATCHES);
535        assert_eq!(payload["ok"], false);
536        assert_eq!(payload["error_kind"], "no_matches");
537        assert!(
538            payload["error"]
539                .as_str()
540                .unwrap_or("")
541                .contains("missing target"),
542            "payload={payload}"
543        );
544    }
545
546    #[cfg(feature = "cli")]
547    #[test]
548    fn structured_dispatch_error_maps_ambiguous() {
549        let err: anyhow::Error = exit::AmbiguousError {
550            msg: "two hits".into(),
551        }
552        .into();
553        let (payload, code) = structured_dispatch_error(&err);
554        assert_eq!(code, exit::AMBIGUOUS);
555        assert_eq!(payload["error_kind"], "ambiguous");
556    }
557
558    #[cfg(feature = "cli")]
559    #[test]
560    fn structured_dispatch_error_generic_stays_failure() {
561        let err = anyhow::anyhow!("file already exists");
562        let (payload, code) = structured_dispatch_error(&err);
563        assert_eq!(code, exit::FAILURE);
564        assert!(payload.get("error_kind").is_none());
565        assert_eq!(payload["ok"], false);
566    }
567
568    #[cfg(feature = "cli")]
569    #[test]
570    fn structured_dispatch_error_maps_invalid_input() {
571        let err: anyhow::Error = exit::InvalidInputError {
572            msg: "path rejected by workspace guard: escapes".into(),
573        }
574        .into();
575        let (payload, code) = structured_dispatch_error(&err);
576        assert_eq!(code, exit::FAILURE);
577        assert_eq!(payload["error_kind"], "invalid_input");
578        assert!(
579            payload["error"]
580                .as_str()
581                .unwrap_or("")
582                .contains("workspace guard"),
583            "payload={payload}"
584        );
585    }
586
587    #[cfg(feature = "cli")]
588    #[test]
589    fn structured_dispatch_error_maps_io_not_found() {
590        let err: anyhow::Error = std::io::Error::new(std::io::ErrorKind::NotFound, "nope").into();
591        let err = err.context("reading missing.md");
592        let (payload, code) = structured_dispatch_error(&err);
593        assert_eq!(code, exit::FAILURE);
594        assert_eq!(payload["error_kind"], "not_found");
595    }
596
597    #[cfg(feature = "cli")]
598    #[test]
599    fn structured_dispatch_error_maps_format_failed() {
600        let err: anyhow::Error = exit::FormatFailedError::new("format command failed (false)")
601            .with_backup_session(Some("99_0".into()))
602            .into();
603        let err = err.context("files were written but formatting failed");
604        let (payload, code) = structured_dispatch_error(&err);
605        assert_eq!(code, exit::FAILURE);
606        assert_eq!(payload["error_kind"], "format_failed");
607        assert_eq!(payload["backup_session"], "99_0");
608    }
609
610    #[test]
611    fn bounded_regex_builder_compiles_valid_pattern() {
612        let re = bounded_regex_builder(r"\d+").build().unwrap();
613        assert!(re.is_match("abc123"));
614    }
615
616    #[test]
617    fn bounded_regex_builder_rejects_invalid_pattern() {
618        bounded_regex_builder(r"(unclosed")
619            .build()
620            .expect_err("expected error");
621    }
622
623    #[test]
624    fn bounded_regex_build_maps_invalid_input() {
625        let mut b = bounded_regex_builder(r"(unclosed");
626        let err = bounded_regex_build(&mut b).expect_err("expected error");
627        assert!(err.to_string().contains("regex parse error"));
628        assert!(crate::exit::is_invalid_input(&err));
629        assert_eq!(
630            crate::fallback::edit_error_kind(&err),
631            Some(crate::fallback::EditErrorKind::InvalidInput)
632        );
633    }
634
635    #[test]
636    fn bounded_regex_builder_applies_size_limit() {
637        // Verify that our builder applies size limits by comparing
638        // with a manually-limited builder. A small pattern with a
639        // 1-byte limit must fail, proving the mechanism works.
640        let mut tiny = regex::RegexBuilder::new(r"\d+");
641        tiny.size_limit(1);
642        assert!(tiny.build().is_err(), "1-byte limit should reject any NFA");
643
644        // Our builder should compile normal patterns just fine.
645        bounded_regex_builder(r"\d+")
646            .build()
647            .expect("simple pattern should compile");
648
649        // Verify the builder actually sets size_limit (not just dfa_size_limit)
650        // by building a moderately large pattern that fits 10 MiB.
651        let medium: String = (0..1000)
652            .map(|i| format!("word_{i}"))
653            .collect::<Vec<_>>()
654            .join("|");
655        bounded_regex_builder(&medium)
656            .build()
657            .expect("1K-alternation pattern should compile within 10 MiB limit");
658    }
659}