#![deny(unsafe_code)]
pub mod api;
#[cfg(feature = "ast")]
pub mod ast;
pub mod backup;
pub mod cli;
#[cfg(feature = "cli")]
pub mod cmd;
pub mod config;
pub mod containment;
pub(crate) mod diff;
pub mod exec;
pub(crate) mod exit;
pub mod fallback;
pub mod files;
pub(crate) mod json_emit;
pub mod ops;
pub mod plan;
pub mod schema;
pub mod selector;
pub mod write;
#[cfg(any(feature = "cli", feature = "files"))]
pub use api::apply_content_edits_to_file;
#[cfg(any(feature = "cli", feature = "files"))]
pub use api::search_one_file;
pub use api::{
ApplyMode, ContentEdit, ContentEditResult, ContentEditsResult, EditError, EditErrorKind,
EditResult, Hunk, PatchFile, PatchLine, ReplaceOptions, SearchOptions, SearchResult,
WritePolicyOptions, apply_content_edits, build_context_lines, edit_error_kind, edit_error_ref,
format_search_results, parse_unified_diff, search_file, text_diff,
};
pub use plan::Plan;
#[cfg(any(feature = "cli", feature = "files"))]
pub(crate) mod tx;
#[cfg(feature = "cli")]
pub(crate) use files::*;
static VERBOSE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn is_verbose() -> bool {
VERBOSE.load(std::sync::atomic::Ordering::Relaxed)
}
#[cfg_attr(not(feature = "cli"), allow(dead_code))]
fn enable_verbose() {
VERBOSE.store(true, std::sync::atomic::Ordering::Relaxed);
}
#[macro_export]
macro_rules! verbose {
($($arg:tt)*) => {
if $crate::is_verbose() {
eprintln!("[patchloom] {}", format!($($arg)*));
}
};
}
pub fn bounded_regex_builder(pattern: &str) -> regex::RegexBuilder {
let mut b = regex::RegexBuilder::new(pattern);
b.size_limit(10 * 1024 * 1024);
b.dfa_size_limit(10 * 1024 * 1024);
b
}
pub fn bounded_regex_build(builder: &mut regex::RegexBuilder) -> anyhow::Result<regex::Regex> {
builder.build().map_err(|e| {
anyhow::Error::new(exit::InvalidInputError { msg: e.to_string() })
})
}
#[cfg(feature = "cli")]
pub fn run() -> anyhow::Result<u8> {
use clap::Parser;
let cli = match crate::cli::Cli::try_parse() {
Ok(cli) => cli,
Err(e) => {
if !e.use_stderr() {
let _ = e.print();
return Ok(exit::SUCCESS);
}
let wants_json = std::env::args().any(|a| a == "--json");
let wants_jsonl = std::env::args().any(|a| a == "--jsonl");
if wants_json || wants_jsonl {
let msg = clap_usage_error_message(&e);
let payload = serde_json::json!({
"ok": false,
"error": msg,
"error_kind": "invalid_input",
});
let _ = json_emit::print_structured(&payload, wants_jsonl);
} else {
let _ = e.print();
}
return Ok(exit::FAILURE);
}
};
if cli.global.verbose || std::env::var_os("PATCHLOOM_LOG").is_some() {
enable_verbose();
}
let structured = cli.global.json || cli.global.jsonl;
let compact = cli.global.jsonl;
match cmd::dispatch(cli) {
Ok(code) => Ok(code),
Err(e) if structured => {
let (output, code) = structured_dispatch_error(&e);
let primary_ok = json_emit::print_structured(&output, compact);
Ok(json_emit::exit_after_emit(primary_ok, code))
}
Err(e) => Err(e),
}
}
#[cfg(feature = "cli")]
fn clap_usage_error_message(err: &clap::Error) -> String {
let raw = err.to_string();
let body = raw.strip_prefix("error: ").unwrap_or(&raw);
let mut lines = Vec::new();
for line in body.lines() {
let t = line.trim_end();
if t.is_empty() && lines.is_empty() {
continue;
}
if t.starts_with("Usage:")
|| t.starts_with("For more information")
|| t.starts_with("[possible values:")
{
if t.starts_with("[possible values:") {
lines.push(t.to_string());
}
if t.starts_with("Usage:") || t.starts_with("For more information") {
break;
}
continue;
}
if t.trim_start().starts_with("[possible values:") {
lines.push(t.trim_start().to_string());
continue;
}
if lines.len() >= 3 {
break;
}
lines.push(t.to_string());
}
let msg = lines
.join(" ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if msg.is_empty() {
body.trim().to_string()
} else {
msg
}
}
#[cfg(feature = "cli")]
fn structured_dispatch_error(err: &anyhow::Error) -> (serde_json::Value, u8) {
let (kind, code) = match exit::classify_typed_error(err) {
Some((k, c)) => (Some(k), c),
None => (None, exit::FAILURE),
};
let mut output = serde_json::json!({
"ok": false,
"error": format!("{err:#}")
});
if let Some(k) = kind {
output["error_kind"] = serde_json::Value::String(k.to_string());
}
(output, code)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "cli")]
#[test]
fn structured_dispatch_error_maps_no_match() {
let err: anyhow::Error = exit::NoMatchError {
msg: "missing target".into(),
}
.into();
let (payload, code) = structured_dispatch_error(&err);
assert_eq!(code, exit::NO_MATCHES);
assert_eq!(payload["ok"], false);
assert_eq!(payload["error_kind"], "no_matches");
assert!(
payload["error"]
.as_str()
.unwrap_or("")
.contains("missing target"),
"payload={payload}"
);
}
#[cfg(feature = "cli")]
#[test]
fn structured_dispatch_error_maps_ambiguous() {
let err: anyhow::Error = exit::AmbiguousError {
msg: "two hits".into(),
}
.into();
let (payload, code) = structured_dispatch_error(&err);
assert_eq!(code, exit::AMBIGUOUS);
assert_eq!(payload["error_kind"], "ambiguous");
}
#[cfg(feature = "cli")]
#[test]
fn structured_dispatch_error_generic_stays_failure() {
let err = anyhow::anyhow!("file already exists");
let (payload, code) = structured_dispatch_error(&err);
assert_eq!(code, exit::FAILURE);
assert!(payload.get("error_kind").is_none());
assert_eq!(payload["ok"], false);
}
#[cfg(feature = "cli")]
#[test]
fn structured_dispatch_error_maps_invalid_input() {
let err: anyhow::Error = exit::InvalidInputError {
msg: "path rejected by workspace guard: escapes".into(),
}
.into();
let (payload, code) = structured_dispatch_error(&err);
assert_eq!(code, exit::FAILURE);
assert_eq!(payload["error_kind"], "invalid_input");
assert!(
payload["error"]
.as_str()
.unwrap_or("")
.contains("workspace guard"),
"payload={payload}"
);
}
#[cfg(feature = "cli")]
#[test]
fn structured_dispatch_error_maps_io_not_found() {
let err: anyhow::Error = std::io::Error::new(std::io::ErrorKind::NotFound, "nope").into();
let err = err.context("reading missing.md");
let (payload, code) = structured_dispatch_error(&err);
assert_eq!(code, exit::FAILURE);
assert_eq!(payload["error_kind"], "not_found");
}
#[cfg(feature = "cli")]
#[test]
fn structured_dispatch_error_maps_format_failed() {
let err: anyhow::Error = exit::FormatFailedError {
msg: "format command failed (false)".into(),
}
.into();
let err = err.context("files were written but formatting failed");
let (payload, code) = structured_dispatch_error(&err);
assert_eq!(code, exit::FAILURE);
assert_eq!(payload["error_kind"], "format_failed");
}
#[test]
fn bounded_regex_builder_compiles_valid_pattern() {
let re = bounded_regex_builder(r"\d+").build().unwrap();
assert!(re.is_match("abc123"));
}
#[test]
fn bounded_regex_builder_rejects_invalid_pattern() {
bounded_regex_builder(r"(unclosed")
.build()
.expect_err("expected error");
}
#[test]
fn bounded_regex_build_maps_invalid_input() {
let mut b = bounded_regex_builder(r"(unclosed");
let err = bounded_regex_build(&mut b).expect_err("expected error");
assert!(err.to_string().contains("regex parse error"));
assert!(crate::exit::is_invalid_input(&err));
assert_eq!(
crate::fallback::edit_error_kind(&err),
Some(crate::fallback::EditErrorKind::InvalidInput)
);
}
#[test]
fn bounded_regex_builder_applies_size_limit() {
let mut tiny = regex::RegexBuilder::new(r"\d+");
tiny.size_limit(1);
assert!(tiny.build().is_err(), "1-byte limit should reject any NFA");
bounded_regex_builder(r"\d+")
.build()
.expect("simple pattern should compile");
let medium: String = (0..1000)
.map(|i| format!("word_{i}"))
.collect::<Vec<_>>()
.join("|");
bounded_regex_builder(&medium)
.build()
.expect("1K-alternation pattern should compile within 10 MiB limit");
}
}