1mod anchors;
19mod apply;
20mod metering;
21mod output;
22mod symbol;
23#[cfg(test)]
24mod tests;
25
26pub use anchors::AnchorOp;
27pub(crate) use symbol::{build_refactor_args, is_replace_symbol};
28
29use std::path::{Path, PathBuf};
30
31use crate::tools::ctx_edit::{CacheEffect, apply_cache_effect, build_diff_evidence};
32use crate::tools::edit_io::{
33 default_backup_path, ensure_preimage_still_matches, read_preimage,
34 write_atomic_bytes_with_permissions,
35};
36use lean_ctx::core::cache::SessionCache;
37
38pub struct PatchParams {
42 pub path: String,
43 pub ops: Vec<AnchorOp>,
44 pub expected_blake3: Option<String>,
50 pub backup: bool,
51 pub backup_path: Option<String>,
52 pub evidence: bool,
53 pub diff_artifact: bool,
56 pub diff_max_lines: usize,
57 pub allow_lossy_utf8: bool,
58 pub validate_syntax: bool,
62}
63
64pub fn parse_ops(
66 args: &serde_json::Map<String, serde_json::Value>,
67) -> Result<Vec<AnchorOp>, String> {
68 anchors::parse_ops(args)
69}
70
71pub fn handle(cache: &mut SessionCache, params: &PatchParams) -> String {
74 let last_mode = cache
75 .get(¶ms.path)
76 .map(|e| e.last_mode.clone())
77 .unwrap_or_default();
78 let (text, effect) = run_io(params, &last_mode);
79 record_outcome(params, &last_mode, &text, &effect);
80 apply_cache_effect(cache, ¶ms.path, effect);
81 text
82}
83
84pub fn record_outcome(params: &PatchParams, last_mode: &str, text: &str, effect: &CacheEffect) {
91 let success = matches!(effect, CacheEffect::Invalidate);
92 let conflict = matches!(effect, CacheEffect::None) && text.starts_with("CONFLICT:");
93 if success || conflict {
94 lean_ctx::core::edit_quality::record_anchored_edit_outcome(
95 ¶ms.path,
96 last_mode,
97 success,
98 );
99 }
100}
101
102pub fn run_io(params: &PatchParams, _last_mode: &str) -> (String, CacheEffect) {
106 let file_path = ¶ms.path;
107 let path = Path::new(file_path);
108 let cap = lean_ctx::core::limits::max_read_bytes();
109
110 if let Some(content) = single_create_op(¶ms.ops) {
113 return match content {
114 Ok(text) => handle_create(params, path, text),
115 Err(e) => (e, CacheEffect::None),
116 };
117 }
118
119 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
120 Ok(p) => p,
121 Err(e) => {
122 if !path.exists() {
123 let hint = crate::tools::edit_recovery::moved_or_deleted_hint(path);
124 return (format!("{e}{hint}"), CacheEffect::None);
125 }
126 return (e, CacheEffect::None);
127 }
128 };
129
130 if let Some(expected) = params.expected_blake3.as_deref()
131 && expected != pre.fp.blake3
132 {
133 return (
134 format!(
135 "ERROR: preimage mismatch for {file_path}: expected_blake3={expected}, actual_blake3={}",
136 pre.fp.blake3
137 ),
138 CacheEffect::None,
139 );
140 }
141
142 if params.ops.is_empty() {
143 return (
144 "ERROR: no edits provided (pass an op or ops:[…])".to_string(),
145 CacheEffect::None,
146 );
147 }
148
149 let (bom, body) = match pre.text.strip_prefix('\u{feff}') {
155 Some(rest) => ("\u{feff}", rest),
156 None => ("", pre.text.as_str()),
157 };
158 let (lines, sep, trailing) = apply::split_lines(body);
159
160 let edits = match apply::resolve_ops(&lines, ¶ms.ops) {
161 Ok(e) => e,
162 Err(apply::ResolveError::Conflict(misses)) => {
163 lean_ctx::core::edit_metering::record_anchored_conflict();
166 return (
167 output::render_conflict(file_path, &lines, &misses),
168 CacheEffect::None,
169 );
170 }
171 Err(apply::ResolveError::Invalid(msg)) => {
172 return (format!("ERROR: {msg}"), CacheEffect::None);
173 }
174 };
175
176 let avoided_tokens = metering::avoided_output_tokens(&lines, ¶ms.ops);
179
180 let n_edits = edits.len();
181 let net_change = edits.iter().fold((0u64, 0u64), |(added, removed), edit| {
182 let (a, r) = edit.net_change(&lines);
183 (added.saturating_add(a), removed.saturating_add(r))
184 });
185 let lines_added = edits.iter().fold(0u64, |total, edit| {
186 total.saturating_add(u64::try_from(edit.new_lines.len()).unwrap_or(u64::MAX))
187 });
188 let lines_removed = edits.iter().fold(0u64, |total, edit| {
189 total.saturating_add(u64::try_from(edit.remove_count).unwrap_or(u64::MAX))
190 });
191 let new_lines = apply::apply_edits(lines.clone(), edits);
192 let new_content = format!("{bom}{}", apply::join_lines(&new_lines, sep, trailing));
193
194 if new_content == pre.text {
195 return (
196 "ERROR: edits produced no change to the file".to_string(),
197 CacheEffect::None,
198 );
199 }
200
201 let ext = Path::new(file_path)
202 .extension()
203 .and_then(|e| e.to_str())
204 .unwrap_or("");
205
206 if params.validate_syntax
209 && let Some(reason) =
210 lean_ctx::core::syntax_validate::gate_edit(ext, &pre.text, &new_content)
211 {
212 return (reason, CacheEffect::None);
213 }
214
215 let health_notice =
217 match lean_ctx::core::code_health::gate::evaluate(&pre.text, &new_content, ext) {
218 lean_ctx::core::code_health::gate::GateOutcome::Block(reason) => {
219 return (
220 format!("ERROR: code-health gate: {reason}"),
221 CacheEffect::None,
222 );
223 }
224 lean_ctx::core::code_health::gate::GateOutcome::Allow(notice) => notice,
225 };
226
227 if let Err(e) = crate::tools::claims_gate::before_write(path, Some(&pre.text), &new_content) {
229 return (e, CacheEffect::None);
230 }
231
232 if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
237 return (e, CacheEffect::None);
238 }
239
240 let backup_path = match make_backup(params, path, &pre.bytes, &pre.permissions) {
241 Ok(bp) => bp,
242 Err(e) => return (e, CacheEffect::None),
243 };
244
245 if let Err(e) =
246 write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
247 {
248 return (e, CacheEffect::None);
249 }
250
251 lean_ctx::core::edit_metering::record_loc_change(Some(file_path), lines_added, lines_removed);
252
253 if let Ok(mut bt) = lean_ctx::core::bounce_tracker::global().lock() {
254 bt.record_edit(file_path);
255 }
256
257 lean_ctx::core::edit_metering::record_anchored_success(n_edits as u64, avoided_tokens);
259
260 let mut out = render_success(
261 params,
262 &pre,
263 &new_content,
264 new_lines.len(),
265 net_change,
266 n_edits,
267 backup_path,
268 );
269 if let Some(notice) = health_notice {
270 out.push_str("\n\n");
271 out.push_str(¬ice);
272 }
273 (out, CacheEffect::Invalidate)
274}
275
276fn single_create_op(ops: &[AnchorOp]) -> Option<Result<&str, String>> {
280 let create = ops.iter().find_map(|op| match op {
281 AnchorOp::Create { new_text } => Some(new_text.as_str()),
282 _ => None,
283 })?;
284 if ops.len() > 1 {
285 return Some(Err(
286 "ERROR: create cannot be batched with anchored ops — a new file has no \
287 preimage to anchor against; send create as a single op"
288 .to_string(),
289 ));
290 }
291 Some(Ok(create))
292}
293
294fn handle_create(params: &PatchParams, path: &Path, content: &str) -> (String, CacheEffect) {
301 let empty_file = std::fs::symlink_metadata(path)
302 .ok()
303 .filter(|m| m.file_type().is_file() && m.len() == 0);
304 if empty_file.is_none() && path.exists() {
305 return (
306 format!(
307 "ERROR: {} already exists — create is for new or empty files only. \
308 Use anchored ops (ctx_read mode=\"anchored\" → set_line/replace_lines) to modify it.",
309 params.path
310 ),
311 CacheEffect::None,
312 );
313 }
314
315 if let Err(e) = lean_ctx::core::pathjail::enforce_writable(path) {
318 return (format!("ERROR: {e}"), CacheEffect::None);
319 }
320
321 if let Err(e) = crate::tools::claims_gate::before_write(path, None, content) {
322 return (e, CacheEffect::None);
323 }
324
325 if let Some(parent) = path.parent()
326 && !parent.exists()
327 && let Err(e) = std::fs::create_dir_all(parent)
328 {
329 return (
330 format!("ERROR: cannot create directory {}: {e}", parent.display()),
331 CacheEffect::None,
332 );
333 }
334
335 let permissions = empty_file.map(|m| m.permissions());
336 if let Err(e) =
337 write_atomic_bytes_with_permissions(path, content.as_bytes(), permissions.as_ref())
338 {
339 return (e, CacheEffect::None);
340 }
341
342 let lines_added = u64::try_from(content.lines().count()).unwrap_or(u64::MAX);
343 lean_ctx::core::edit_metering::record_loc_change(Some(¶ms.path), lines_added, 0);
344
345 if let Ok(mut bt) = lean_ctx::core::bounce_tracker::global().lock() {
346 bt.record_edit(¶ms.path);
347 }
348
349 let lines = content.lines().count();
350 let short = output::short_name(¶ms.path);
351 let mut out = format!("created {short}: {lines} lines");
352 if params.evidence {
353 out.push_str(&format!(
354 "\npostimage: bytes={}, blake3={}",
355 content.len(),
356 lean_ctx::core::hasher::hash_hex(content.as_bytes())
357 ));
358 let diff = build_diff_evidence("", content, &short, params.diff_max_lines);
359 out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
360 out.push_str(&diff);
361 out.push_str("\n```");
362 } else if params.diff_artifact
363 && let Some(trailer) = crate::tools::ctx_edit::diff_artifact_trailer(
364 "",
365 content,
366 &short,
367 params.diff_max_lines,
368 )
369 {
370 out.push('\n');
371 out.push_str(&trailer);
372 }
373 (out, CacheEffect::Invalidate)
374}
375
376fn make_backup(
378 params: &PatchParams,
379 path: &Path,
380 bytes: &[u8],
381 permissions: &std::fs::Permissions,
382) -> Result<Option<String>, String> {
383 if !params.backup {
384 return Ok(None);
385 }
386 let bp = params
387 .backup_path
388 .as_deref()
389 .map(PathBuf::from)
390 .or_else(|| default_backup_path(path))
391 .ok_or_else(|| format!("ERROR: cannot compute backup path for {}", path.display()))?;
392 write_atomic_bytes_with_permissions(&bp, bytes, Some(permissions))
393 .map_err(|e| format!("ERROR: cannot create backup {}: {e}", bp.display()))?;
394 Ok(Some(bp.to_string_lossy().to_string()))
395}
396
397fn render_success(
401 params: &PatchParams,
402 pre: &crate::tools::edit_io::FilePreimage,
403 new_content: &str,
404 lines_after: usize,
405 (lines_added, lines_removed): (u64, u64),
406 n_edits: usize,
407 backup_path: Option<String>,
408) -> String {
409 let short = output::short_name(¶ms.path);
410 let detail = (n_edits > 1).then(|| format!("{n_edits} edits"));
411 let mut out = crate::tools::ctx_edit::edit_summary_line(
412 &short,
413 lines_after,
414 lines_added,
415 lines_removed,
416 detail.as_deref(),
417 );
418 if let Some(bp) = backup_path {
419 out.push_str(&format!("\nbackup: {bp}"));
420 }
421 if params.evidence {
422 let post_mtime_ms = std::fs::metadata(¶ms.path)
423 .ok()
424 .and_then(|m| m.modified().ok())
425 .map_or(0, crate::tools::edit_io::system_time_to_millis);
426 out.push_str(&format!(
427 "\npreimage: bytes={}, mtime_ms={}, blake3={}\n\
428postimage: bytes={}, mtime_ms={post_mtime_ms}, blake3={}",
429 pre.fp.size,
430 pre.fp.mtime_ms,
431 pre.fp.blake3,
432 new_content.len(),
433 lean_ctx::core::hasher::hash_hex(new_content.as_bytes())
434 ));
435 if params.ops.len() > 1 {
436 out.push('\n');
437 out.push_str(&render_op_ledger(¶ms.ops));
438 }
439 let old_content = pre.text.as_str();
440 let diff = build_diff_evidence(old_content, new_content, &short, params.diff_max_lines);
441 out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
442 out.push_str(&diff);
443 out.push_str("\n```");
444 let balance = brace_balance(new_content);
445 if balance == 0 {
446 out.push_str("\nbrace-balance: ok (matched)");
447 } else {
448 out.push_str(&format!(
449 "\n⚠ brace-balance: {} unmatched '{{' — verify file integrity",
450 balance.abs()
451 ));
452 }
453 } else if params.diff_artifact
454 && let Some(trailer) = crate::tools::ctx_edit::diff_artifact_trailer(
455 &pre.text,
456 new_content,
457 &short,
458 params.diff_max_lines,
459 )
460 {
461 out.push('\n');
462 out.push_str(&trailer);
463 }
464 out
465}
466
467fn render_op_ledger(ops: &[AnchorOp]) -> String {
483 let mut out = format!("ops: {n}/{n} applied", n = ops.len());
484 for (index, op) in ops.iter().enumerate() {
485 let (kind, span, produced) = match op {
486 AnchorOp::SetLine { line, new_text, .. } => {
487 ("set_line", line.to_string(), produced_lines(new_text))
488 }
489 AnchorOp::ReplaceLines {
490 start_line,
491 end_line,
492 new_text,
493 ..
494 } => (
495 "replace_lines",
496 format!("{start_line}-{end_line}"),
497 produced_lines(new_text),
498 ),
499 AnchorOp::InsertAfter { line, new_text, .. } => {
500 ("insert_after", line.to_string(), produced_lines(new_text))
501 }
502 AnchorOp::Delete {
503 start_line,
504 end_line,
505 ..
506 } => ("delete", format!("{start_line}-{end_line}"), 0),
507 AnchorOp::Create { new_text } => ("create", "-".to_string(), produced_lines(new_text)),
508 };
509 let line_word = if produced == 1 { "line" } else { "lines" };
510 out.push_str(&format!(
511 "\n {}. {kind} {span} → {produced} {line_word}",
512 index + 1
513 ));
514 }
515 out
516}
517
518fn produced_lines(new_text: &str) -> usize {
521 if new_text.is_empty() {
522 0
523 } else {
524 new_text.lines().count()
525 }
526}
527
528fn brace_balance(content: &str) -> i64 {
531 content.chars().fold(0i64, |acc, c| match c {
532 '{' => acc + 1,
533 '}' => acc - 1,
534 _ => acc,
535 })
536}