1mod anchors;
18mod apply;
19mod metering;
20mod output;
21mod symbol;
22#[cfg(test)]
23mod tests;
24
25pub use anchors::AnchorOp;
26pub(crate) use symbol::{build_refactor_args, is_replace_symbol};
27
28use std::path::{Path, PathBuf};
29
30use crate::core::cache::SessionCache;
31use crate::core::tokens::count_tokens;
32use crate::tools::ctx_edit::{CacheEffect, apply_cache_effect, build_diff_evidence};
33use crate::tools::edit_io::{
34 default_backup_path, ensure_preimage_still_matches, read_preimage,
35 write_atomic_bytes_with_permissions,
36};
37
38pub struct PatchParams {
42 pub path: String,
43 pub ops: Vec<AnchorOp>,
44 pub expected_md5: Option<String>,
47 pub backup: bool,
48 pub backup_path: Option<String>,
49 pub evidence: bool,
50 pub diff_max_lines: usize,
51 pub allow_lossy_utf8: bool,
52 pub validate_syntax: bool,
56}
57
58pub fn parse_ops(
60 args: &serde_json::Map<String, serde_json::Value>,
61) -> Result<Vec<AnchorOp>, String> {
62 anchors::parse_ops(args)
63}
64
65pub fn handle(cache: &mut SessionCache, params: &PatchParams) -> String {
68 let last_mode = cache
69 .get(¶ms.path)
70 .map(|e| e.last_mode.clone())
71 .unwrap_or_default();
72 let (text, effect) = run_io(params, &last_mode);
73 record_outcome(params, &last_mode, &text, &effect);
74 apply_cache_effect(cache, ¶ms.path, effect);
75 text
76}
77
78pub fn record_outcome(params: &PatchParams, last_mode: &str, text: &str, effect: &CacheEffect) {
85 let success = matches!(effect, CacheEffect::Invalidate);
86 let conflict = matches!(effect, CacheEffect::None) && text.starts_with("CONFLICT:");
87 if success || conflict {
88 crate::core::edit_quality::record_anchored_edit_outcome(¶ms.path, last_mode, success);
89 }
90}
91
92pub fn run_io(params: &PatchParams, _last_mode: &str) -> (String, CacheEffect) {
96 let file_path = ¶ms.path;
97 let path = Path::new(file_path);
98 let cap = crate::core::limits::max_read_bytes();
99
100 if let Some(content) = single_create_op(¶ms.ops) {
103 return match content {
104 Ok(text) => handle_create(params, path, text),
105 Err(e) => (e, CacheEffect::None),
106 };
107 }
108
109 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
110 Ok(p) => p,
111 Err(e) => {
112 if !path.exists() {
113 let hint = crate::tools::edit_recovery::moved_or_deleted_hint(path);
114 return (format!("{e}{hint}"), CacheEffect::None);
115 }
116 return (e, CacheEffect::None);
117 }
118 };
119
120 if let Some(expected) = params.expected_md5.as_deref()
121 && expected != pre.fp.md5
122 {
123 return (
124 format!(
125 "ERROR: preimage mismatch for {file_path}: expected_md5={expected}, actual_md5={}",
126 pre.fp.md5
127 ),
128 CacheEffect::None,
129 );
130 }
131
132 if params.ops.is_empty() {
133 return (
134 "ERROR: no edits provided (pass an op or ops:[…])".to_string(),
135 CacheEffect::None,
136 );
137 }
138
139 let (bom, body) = match pre.text.strip_prefix('\u{feff}') {
145 Some(rest) => ("\u{feff}", rest),
146 None => ("", pre.text.as_str()),
147 };
148 let (lines, sep, trailing) = apply::split_lines(body);
149
150 let edits = match apply::resolve_ops(&lines, ¶ms.ops) {
151 Ok(e) => e,
152 Err(apply::ResolveError::Conflict(misses)) => {
153 crate::core::edit_metering::record_anchored_conflict();
156 return (
157 output::render_conflict(file_path, &lines, &misses),
158 CacheEffect::None,
159 );
160 }
161 Err(apply::ResolveError::Invalid(msg)) => {
162 return (format!("ERROR: {msg}"), CacheEffect::None);
163 }
164 };
165
166 let avoided_tokens = metering::avoided_output_tokens(&lines, ¶ms.ops);
169
170 let n_edits = edits.len();
171 let lines_before = lines.len();
172 let new_lines = apply::apply_edits(lines.clone(), edits);
173 let new_content = format!("{bom}{}", apply::join_lines(&new_lines, sep, trailing));
174
175 if new_content == pre.text {
176 return (
177 "ERROR: edits produced no change to the file".to_string(),
178 CacheEffect::None,
179 );
180 }
181
182 let ext = Path::new(file_path)
183 .extension()
184 .and_then(|e| e.to_str())
185 .unwrap_or("");
186
187 if params.validate_syntax
190 && let Some(reason) = crate::core::syntax_validate::gate_edit(ext, &pre.text, &new_content)
191 {
192 return (reason, CacheEffect::None);
193 }
194
195 let health_notice = match crate::core::code_health::gate::evaluate(&pre.text, &new_content, ext)
197 {
198 crate::core::code_health::gate::GateOutcome::Block(reason) => {
199 return (
200 format!("ERROR: code-health gate: {reason}"),
201 CacheEffect::None,
202 );
203 }
204 crate::core::code_health::gate::GateOutcome::Allow(notice) => notice,
205 };
206
207 if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
212 return (e, CacheEffect::None);
213 }
214
215 let backup_path = match make_backup(params, path, &pre.bytes, &pre.permissions) {
216 Ok(bp) => bp,
217 Err(e) => return (e, CacheEffect::None),
218 };
219
220 if let Err(e) =
221 write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
222 {
223 return (e, CacheEffect::None);
224 }
225
226 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
227 bt.record_edit(file_path);
228 }
229
230 crate::core::edit_metering::record_anchored_success(n_edits as u64, avoided_tokens);
232
233 let mut out = render_success(
234 params,
235 &pre.text,
236 &new_content,
237 pre.fp.size,
238 pre.fp.mtime_ms,
239 &pre.fp.md5,
240 lines_before,
241 new_lines.len(),
242 n_edits,
243 backup_path,
244 );
245 if let Some(notice) = health_notice {
246 out.push_str("\n\n");
247 out.push_str(¬ice);
248 }
249 (out, CacheEffect::Invalidate)
250}
251
252fn single_create_op(ops: &[AnchorOp]) -> Option<Result<&str, String>> {
256 let create = ops.iter().find_map(|op| match op {
257 AnchorOp::Create { new_text } => Some(new_text.as_str()),
258 _ => None,
259 })?;
260 if ops.len() > 1 {
261 return Some(Err(
262 "ERROR: create cannot be batched with anchored ops — a new file has no \
263 preimage to anchor against; send create as a single op"
264 .to_string(),
265 ));
266 }
267 Some(Ok(create))
268}
269
270fn handle_create(params: &PatchParams, path: &Path, content: &str) -> (String, CacheEffect) {
274 if path.exists() {
275 return (
276 format!(
277 "ERROR: {} already exists — create is for new files only. \
278 Use anchored ops (ctx_read mode=\"anchored\" → set_line/replace_lines) to modify it.",
279 params.path
280 ),
281 CacheEffect::None,
282 );
283 }
284
285 if let Err(e) = crate::core::pathjail::enforce_writable(path) {
288 return (format!("ERROR: {e}"), CacheEffect::None);
289 }
290
291 if let Some(parent) = path.parent()
292 && !parent.exists()
293 && let Err(e) = std::fs::create_dir_all(parent)
294 {
295 return (
296 format!("ERROR: cannot create directory {}: {e}", parent.display()),
297 CacheEffect::None,
298 );
299 }
300
301 if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), None) {
302 return (e, CacheEffect::None);
303 }
304
305 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
306 bt.record_edit(¶ms.path);
307 }
308
309 let lines = content.lines().count();
310 let tokens = count_tokens(content);
311 let short = output::short_name(¶ms.path);
312 let post_md5 = crate::core::hasher::hash_hex(content.as_bytes());
313 let mut out = format!(
314 "✓ created {short}: {lines} lines, {tokens} tok\npostimage: bytes={}, md5={post_md5}",
315 content.len()
316 );
317 if params.evidence {
318 let diff = build_diff_evidence("", content, &short, params.diff_max_lines);
319 out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
320 out.push_str(&diff);
321 out.push_str("\n```");
322 }
323 (out, CacheEffect::Invalidate)
324}
325
326fn make_backup(
328 params: &PatchParams,
329 path: &Path,
330 bytes: &[u8],
331 permissions: &std::fs::Permissions,
332) -> Result<Option<String>, String> {
333 if !params.backup {
334 return Ok(None);
335 }
336 let bp = params
337 .backup_path
338 .as_deref()
339 .map(PathBuf::from)
340 .or_else(|| default_backup_path(path))
341 .ok_or_else(|| format!("ERROR: cannot compute backup path for {}", path.display()))?;
342 write_atomic_bytes_with_permissions(&bp, bytes, Some(permissions))
343 .map_err(|e| format!("ERROR: cannot create backup {}: {e}", bp.display()))?;
344 Ok(Some(bp.to_string_lossy().to_string()))
345}
346
347#[allow(clippy::too_many_arguments)]
348fn render_success(
349 params: &PatchParams,
350 old_content: &str,
351 new_content: &str,
352 pre_size: u64,
353 pre_mtime_ms: u64,
354 pre_md5: &str,
355 lines_before: usize,
356 lines_after: usize,
357 n_edits: usize,
358 backup_path: Option<String>,
359) -> String {
360 let short = output::short_name(¶ms.path);
361 let line_delta = lines_after as i64 - lines_before as i64;
362 let delta_str = if line_delta >= 0 {
363 format!("+{line_delta}")
364 } else {
365 format!("{line_delta}")
366 };
367 let old_tokens = count_tokens(old_content);
368 let new_tokens = count_tokens(new_content);
369
370 let post_mtime_ms = std::fs::metadata(¶ms.path)
371 .ok()
372 .and_then(|m| m.modified().ok())
373 .map_or(0, crate::tools::edit_io::system_time_to_millis);
374 let post_md5 = crate::core::hasher::hash_hex(new_content.as_bytes());
375
376 let edit_word = if n_edits == 1 { "edit" } else { "edits" };
377 let mut out = format!(
378 "✓ {short}: {n_edits} anchored {edit_word}, {delta_str} lines ({old_tokens}→{new_tokens} tok)\n\
379preimage: bytes={pre_size}, mtime_ms={pre_mtime_ms}, md5={pre_md5}\n\
380postimage: bytes={}, mtime_ms={post_mtime_ms}, md5={post_md5}",
381 new_content.len()
382 );
383 if let Some(bp) = backup_path {
384 out.push_str(&format!("\nbackup: {bp}"));
385 }
386 if params.evidence {
387 let diff = build_diff_evidence(old_content, new_content, &short, params.diff_max_lines);
388 out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
389 out.push_str(&diff);
390 out.push_str("\n```");
391 let balance = brace_balance(new_content);
392 if balance == 0 {
393 out.push_str("\nbrace-balance: ok (matched)");
394 } else {
395 out.push_str(&format!(
396 "\n⚠ brace-balance: {} unmatched '{{' — verify file integrity",
397 balance.abs()
398 ));
399 }
400 }
401 out
402}
403
404fn brace_balance(content: &str) -> i64 {
407 content.chars().fold(0i64, |acc, c| match c {
408 '{' => acc + 1,
409 '}' => acc - 1,
410 _ => acc,
411 })
412}