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