1use std::path::{Path, PathBuf};
3
4use lean_ctx::core::cache::SessionCache;
5use lean_ctx::core::tokens::count_tokens;
6use crate::tools::edit_io::{
11 FileFingerprint, FilePreimage, default_backup_path, ensure_preimage_still_matches,
12 read_preimage, system_time_to_millis, write_atomic_bytes_with_permissions,
13};
14
15pub struct EditParams {
17 pub path: String,
18 pub old_string: String,
19 pub new_string: String,
20 pub replace_all: bool,
21 pub create: bool,
22 pub expected_blake3: Option<String>,
27 pub expected_size: Option<u64>,
28 pub expected_mtime_ms: Option<u64>,
29 pub backup: bool,
31 pub backup_path: Option<String>,
32 pub evidence: bool,
34 pub diff_artifact: bool,
37 pub diff_max_lines: usize,
38 pub allow_lossy_utf8: bool,
40}
41
42struct ReplaceArgs<'a> {
43 content: &'a str,
44 old_str: &'a str,
45 new_str: &'a str,
46 occurrences: usize,
47 replace_all: bool,
48}
49
50fn verify_expected_preimage(pre: &FilePreimage, params: &EditParams) -> Result<(), String> {
51 if let Some(expected) = params.expected_size
52 && expected != pre.fp.size
53 {
54 return Err(format!(
55 "ERROR: preimage mismatch for {}: expected_size={}, actual_size={}",
56 params.path, expected, pre.fp.size
57 ));
58 }
59 if let Some(expected) = params.expected_mtime_ms
60 && expected != pre.fp.mtime_ms
61 {
62 return Err(format!(
63 "ERROR: preimage mismatch for {}: expected_mtime_ms={}, actual_mtime_ms={}",
64 params.path, expected, pre.fp.mtime_ms
65 ));
66 }
67 if let Some(expected) = params.expected_blake3.as_deref()
68 && expected != pre.fp.blake3
69 {
70 return Err(format!(
71 "ERROR: preimage mismatch for {}: expected_blake3={}, actual_blake3={}",
72 params.path, expected, pre.fp.blake3
73 ));
74 }
75 Ok(())
76}
77
78pub(crate) fn build_diff_evidence(old: &str, new: &str, label: &str, max_lines: usize) -> String {
81 let diff = similar::TextDiff::from_lines(old, new)
82 .unified_diff()
83 .context_radius(3)
84 .header(label, label)
85 .to_string();
86 let diff = lean_ctx::core::redaction::redact_text(&diff);
91
92 let mut out = String::new();
93 for (i, line) in diff.lines().enumerate() {
94 if i >= max_lines {
95 out.push_str(&format!("\n... diff truncated (max_lines={max_lines})"));
96 break;
97 }
98 out.push_str(line);
99 out.push('\n');
100 }
101 out.trim_end_matches('\n').to_string()
102}
103
104pub enum CacheEffect {
111 None,
113 Invalidate,
115 StoreFull(String),
118}
119
120pub fn handle(cache: &mut SessionCache, params: &EditParams) -> String {
125 let last_mode = cache
126 .get(¶ms.path)
127 .map(|e| e.last_mode.clone())
128 .unwrap_or_default();
129 let (text, effect) = run_io(params, &last_mode);
130 record_outcome(params, &last_mode, &text, &effect);
131 apply_cache_effect(cache, ¶ms.path, effect);
132 text
133}
134
135pub fn record_outcome(params: &EditParams, last_mode: &str, text: &str, effect: &CacheEffect) {
142 if params.create || params.old_string.is_empty() {
144 return;
145 }
146 let success = matches!(effect, CacheEffect::Invalidate);
147 let not_found_failure = matches!(effect, CacheEffect::StoreFull(_))
148 || (matches!(effect, CacheEffect::None)
149 && text.starts_with("ERROR: old_string not found")
150 && !text.contains("already"));
151 if success {
152 if let Some(project_root) = crate::server::derive_project_root_from_cwd() {
153 lean_ctx::core::solution_auto_capture::capture_edit_decisions(
154 &project_root,
155 ¶ms.path,
156 ¶ms.old_string,
157 ¶ms.new_string,
158 );
159 }
160 }
161
162 if success || not_found_failure {
163 lean_ctx::core::edit_quality::record_edit_outcome(¶ms.path, last_mode, success);
164 }
165 if success {
169 lean_ctx::core::edit_metering::record_str_replace_success(
170 count_tokens(¶ms.old_string) as u64
171 );
172 } else if not_found_failure {
173 lean_ctx::core::edit_metering::record_str_replace_miss();
174 }
175}
176
177pub fn apply_cache_effect(cache: &mut SessionCache, path: &str, effect: CacheEffect) {
179 match effect {
180 CacheEffect::None => {}
181 CacheEffect::Invalidate => {
182 cache.invalidate(path);
183 }
184 CacheEffect::StoreFull(content) => {
185 cache.store(path, &content);
186 cache.mark_full_delivered(path);
187 }
188 }
189}
190
191pub const NO_CHANGE_PREFIX: &str = "No change:";
194
195const EMPTY_OLD_STRING_REFUSAL: &str = "ERROR: old_string must not be empty for a non-empty \
198 file (an empty old_string only creates a missing or empty file; use create=true to \
199 replace the whole file)";
200
201fn is_missing_or_empty(path: &Path) -> bool {
204 match std::fs::metadata(path) {
205 Ok(meta) => meta.is_file() && meta.len() == 0,
206 Err(error) => error.kind() == std::io::ErrorKind::NotFound,
207 }
208}
209
210pub fn run_io(params: &EditParams, last_mode: &str) -> (String, CacheEffect) {
216 let file_path = ¶ms.path;
217
218 if params.create {
219 return handle_create(file_path, ¶ms.new_string, params, false);
220 }
221
222 let cap = lean_ctx::core::limits::max_read_bytes();
223 let path = Path::new(file_path);
224
225 if params.old_string.is_empty() {
229 if is_missing_or_empty(path) {
230 return handle_create(file_path, ¶ms.new_string, params, true);
231 }
232 return (EMPTY_OLD_STRING_REFUSAL.into(), CacheEffect::None);
233 }
234 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
235 Ok(p) => p,
236 Err(e) => {
237 if !path.exists() {
240 let hint = crate::tools::edit_recovery::moved_or_deleted_hint(path);
241 return (format!("{e}{hint}"), CacheEffect::None);
242 }
243 return (e, CacheEffect::None);
244 }
245 };
246 if let Err(e) = verify_expected_preimage(&pre, params) {
247 return (e, CacheEffect::None);
248 }
249 let content = &pre.text;
250
251 if params.old_string == params.new_string {
252 if content.contains(¶ms.old_string) {
255 return (
256 format!(
257 "{NO_CHANGE_PREFIX} {file_path} already contains this text \
258 (old_string equals new_string)."
259 ),
260 CacheEffect::None,
261 );
262 }
263 return (
264 "ERROR: old_string and new_string are identical — nothing to change.".into(),
265 CacheEffect::None,
266 );
267 }
268
269 let uses_crlf = pre.uses_crlf;
270 let old_str = ¶ms.old_string;
271 let new_str = ¶ms.new_string;
272
273 let occurrences = content.matches(old_str).count();
274
275 if occurrences > 0 {
276 let args = ReplaceArgs {
277 content,
278 old_str,
279 new_str,
280 occurrences,
281 replace_all: params.replace_all,
282 };
283 return do_replace(path, &pre, params, cap, &args);
284 }
285
286 if uses_crlf && !old_str.contains('\r') {
287 let old_crlf = old_str.replace('\n', "\r\n");
288 let occ = content.matches(&old_crlf).count();
289 if occ > 0 {
290 let new_crlf = new_str.replace('\n', "\r\n");
291 let args = ReplaceArgs {
292 content,
293 old_str: &old_crlf,
294 new_str: &new_crlf,
295 occurrences: occ,
296 replace_all: params.replace_all,
297 };
298 return do_replace(path, &pre, params, cap, &args);
299 }
300 } else if !uses_crlf && old_str.contains("\r\n") {
301 let old_lf = old_str.replace("\r\n", "\n");
302 let occ = content.matches(&old_lf).count();
303 if occ > 0 {
304 let new_lf = new_str.replace("\r\n", "\n");
305 let args = ReplaceArgs {
306 content,
307 old_str: &old_lf,
308 new_str: &new_lf,
309 occurrences: occ,
310 replace_all: params.replace_all,
311 };
312 return do_replace(path, &pre, params, cap, &args);
313 }
314 }
315
316 let lossy_read = last_read_was_lossy(last_mode);
321
322 let normalized_content = trim_trailing_per_line(content);
323 let normalized_old = trim_trailing_per_line(old_str);
324 if !lossy_read && !normalized_old.is_empty() && normalized_content.contains(&normalized_old) {
325 let line_sep = if uses_crlf { "\r\n" } else { "\n" };
326 let adapted_new = adapt_new_string_to_line_sep(new_str, line_sep);
327 let adapted_old = find_original_span(content, &normalized_old);
328 if let Some(original_match) = adapted_old {
329 let occ = content.matches(&original_match).count();
330 let args = ReplaceArgs {
331 content,
332 old_str: &original_match,
333 new_str: &adapted_new,
334 occurrences: occ,
335 replace_all: params.replace_all,
336 };
337 return do_replace(path, &pre, params, cap, &args);
338 }
339 }
340
341 if !lossy_read && let Some(found) = super::reindent::find(content, old_str, new_str) {
343 let line_sep = if uses_crlf { "\r\n" } else { "\n" };
344 let replacement = adapt_new_string_to_line_sep(&found.replacement, line_sep);
345 let args = ReplaceArgs {
346 content,
347 old_str: &found.span,
348 new_str: &replacement,
349 occurrences: content.matches(found.span.as_str()).count(),
350 replace_all: params.replace_all,
351 };
352 let (out, effect) = do_replace(path, &pre, params, cap, &args);
353 if out.starts_with("ERROR") {
354 return (out, effect);
355 }
356 return (
357 format!(
358 "note: old_string matched with different indentation — applied at the \
359 file's indentation.\n{out}"
360 ),
361 effect,
362 );
363 }
364
365 let new_present = content.contains(new_str.as_str())
369 || (uses_crlf && content.contains(&new_str.replace('\n', "\r\n")));
370 if new_present && super::applied_edits::contains(path, old_str, new_str) {
371 return (
372 format!(
373 "{NO_CHANGE_PREFIX} {file_path} — this exact edit was already applied by an \
374 earlier ctx_edit call (old_string is absent, new_string is present)."
375 ),
376 CacheEffect::None,
377 );
378 }
379
380 let present_note = if content.contains(new_str) {
383 "\nnew_string exists in the file, but that does not establish the edit was \
384 already applied. Re-read the target region."
385 } else {
386 ""
387 };
388
389 let preview = if old_str.len() > 80 {
390 format!("{}...", &old_str[..old_str.floor_char_boundary(77)])
391 } else {
392 old_str.clone()
393 };
394 let hint = if uses_crlf {
395 " (file uses CRLF line endings)"
396 } else {
397 ""
398 };
399
400 let admitted = crate::tools::ctx_read::read_file_lossy_admitted_for_cli(file_path).ok();
404 let closest_hint = admitted
405 .as_ref()
406 .map(|source| find_closest_line_hint(&source.text, old_str))
407 .unwrap_or_default();
408 let cross_file = crate::tools::edit_recovery::cross_file_hint(path, old_str);
409
410 let (escalation, effect) = auto_escalate_reread(last_mode, admitted);
411 let lossy_note = if lossy_read {
412 format!(
413 "\nRefused: the last read of this file used mode=\"{last_mode}\", a lossy view, \
414 and old_string does not match the full file content exactly. Re-read the file \
415 with ctx_read mode=\"full\" (or \"anchored\") and retry with the exact text."
416 )
417 } else {
418 String::new()
419 };
420
421 (
422 format!(
423 "ERROR: old_string not found in {file_path}{hint}. \
424 Make sure it matches exactly (including whitespace/indentation).\n\
425 Searched for: {preview}{present_note}{closest_hint}{cross_file}{lossy_note}{escalation}"
426 ),
427 effect,
428 )
429}
430
431fn last_read_was_lossy(last_mode: &str) -> bool {
437 use crate::tools::ctx_read::ReadMode;
438 last_mode.parse::<ReadMode>().is_ok_and(|mode| {
439 mode.is_lossy_summary() || matches!(mode, ReadMode::Density(_) | ReadMode::Reference)
440 })
441}
442
443fn find_closest_line_hint(content: &str, old_str: &str) -> String {
446 let first_line = old_str.lines().next().unwrap_or("").trim();
447 if first_line.len() < 4 {
448 return String::new();
449 }
450
451 let mut best_line: Option<(usize, &str)> = None;
452
453 for (i, line) in content.lines().enumerate() {
454 if line.contains(first_line) {
455 best_line = Some((i + 1, line));
456 break;
457 }
458 }
459
460 if best_line.is_none() {
462 let keyword = first_line
463 .split(|c: char| !c.is_alphanumeric() && c != '_')
464 .find(|w| w.len() >= 4);
465
466 if let Some(keyword) = keyword {
467 for (i, line) in content.lines().enumerate() {
468 if line.contains(keyword) {
469 best_line = Some((i + 1, line));
470 break;
471 }
472 }
473 }
474 }
475
476 match best_line {
477 Some((line_num, line_content)) => {
478 let trimmed = line_content.trim();
479 let preview = if trimmed.len() > 100 {
480 format!("{}...", &trimmed[..trimmed.floor_char_boundary(97)])
481 } else {
482 trimmed.to_string()
483 };
484 format!(
485 "\nClosest match at line {line_num}: `{preview}`\n\
486 Hint: check indentation/whitespace differences."
487 )
488 }
489 None => String::new(),
490 }
491}
492
493fn auto_escalate_reread(
502 last_mode: &str,
503 admitted: Option<lean_ctx::core::context_admission::SourceAdmission>,
504) -> (String, CacheEffect) {
505 if last_mode.is_empty() || last_mode == "full" {
506 return (String::new(), CacheEffect::None);
507 }
508
509 let Some(admitted) = admitted else {
510 return (String::new(), CacheEffect::None);
511 };
512 let storable = admitted.storable;
513 let fresh_content = admitted.text;
514
515 let line_count = fresh_content.lines().count();
516 const MAX_LINES: usize = 300;
517
518 let content_preview = if line_count <= MAX_LINES {
519 fresh_content.clone()
520 } else {
521 let lines: Vec<&str> = fresh_content.lines().collect();
522 let head = &lines[..MAX_LINES / 2];
523 let tail = &lines[line_count - MAX_LINES / 2..];
524 let omitted = line_count - MAX_LINES;
525 format!(
526 "{}\n[... {omitted} lines omitted ...]\n{}",
527 head.join("\n"),
528 tail.join("\n")
529 )
530 };
531
532 (
533 format!(
534 "\n\n[auto-escalation] Last read used mode=\"{last_mode}\". \
535 Full content ({line_count}L) below — retry edit with exact text from here:\n\n{content_preview}"
536 ),
537 if storable {
538 CacheEffect::StoreFull(fresh_content)
539 } else {
540 CacheEffect::None
541 },
542 )
543}
544
545const LISTED_OCCURRENCES: usize = 5;
547
548fn occurrence_lines(content: &str, needle: &str, occurrences: usize) -> String {
552 let mut lines = Vec::with_capacity(LISTED_OCCURRENCES);
553 let (mut line, mut scanned) = (1, 0);
554 for (offset, _) in content.match_indices(needle).take(LISTED_OCCURRENCES) {
555 line += content[scanned..offset].matches('\n').count();
556 scanned = offset;
557 lines.push(line.to_string());
558 }
559 let mut out = format!("lines {}", lines.join(", "));
560 if occurrences > lines.len() {
561 out.push_str(&format!(" and {} more", occurrences - lines.len()));
562 }
563 out
564}
565
566fn do_replace(
567 path: &Path,
568 pre: &FilePreimage,
569 params: &EditParams,
570 cap: usize,
571 args: &ReplaceArgs<'_>,
572) -> (String, CacheEffect) {
573 if args.occurrences > 1 && !args.replace_all {
574 return (
575 format!(
576 "ERROR: old_string found {} times in {} ({}). \
577 Use replace_all=true to replace every occurrence, or add surrounding \
578 lines to old_string until it matches exactly one.",
579 args.occurrences,
580 path.display(),
581 occurrence_lines(args.content, args.old_str, args.occurrences)
582 ),
583 CacheEffect::None,
584 );
585 }
586
587 let new_content = if args.replace_all {
588 args.content.replace(args.old_str, args.new_str)
589 } else {
590 args.content.replacen(args.old_str, args.new_str, 1)
591 };
592
593 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
595 let health_notice =
596 match lean_ctx::core::code_health::gate::evaluate(args.content, &new_content, ext) {
597 lean_ctx::core::code_health::gate::GateOutcome::Block(reason) => {
598 return (
599 format!("ERROR: code-health gate: {reason}"),
600 CacheEffect::None,
601 );
602 }
603 lean_ctx::core::code_health::gate::GateOutcome::Allow(notice) => notice,
604 };
605
606 if let Err(e) = crate::tools::claims_gate::before_write(path, Some(args.content), &new_content)
608 {
609 return (e, CacheEffect::None);
610 }
611
612 if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
616 return (e, CacheEffect::None);
617 }
618
619 let backup_path = if params.backup {
620 let bp = params
621 .backup_path
622 .as_deref()
623 .map(PathBuf::from)
624 .or_else(|| default_backup_path(path));
625 let Some(bp) = bp else {
626 return (
627 format!("ERROR: cannot compute backup path for {}", path.display()),
628 CacheEffect::None,
629 );
630 };
631 if let Err(e) = write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
632 {
633 return (
634 format!("ERROR: cannot create backup {}: {e}", bp.display()),
635 CacheEffect::None,
636 );
637 }
638 Some(bp.to_string_lossy().to_string())
639 } else {
640 None
641 };
642
643 if let Err(e) =
644 write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
645 {
646 return (e, CacheEffect::None);
647 }
648 super::applied_edits::record(path, ¶ms.old_string, ¶ms.new_string);
650
651 let replacements = if args.replace_all {
652 args.occurrences
653 } else {
654 1
655 };
656 let replacements = u64::try_from(replacements).unwrap_or(u64::MAX);
657 let lines_added = u64::try_from(args.new_str.lines().count())
658 .unwrap_or(u64::MAX)
659 .saturating_mul(replacements);
660 let lines_removed = u64::try_from(args.old_str.lines().count())
661 .unwrap_or(u64::MAX)
662 .saturating_mul(replacements);
663 lean_ctx::core::edit_metering::record_loc_change(
664 Some(¶ms.path),
665 lines_added,
666 lines_removed,
667 );
668
669 if let Ok(mut bt) = lean_ctx::core::bounce_tracker::global().lock() {
670 bt.record_edit(¶ms.path);
671 }
672
673 let new_lines = new_content.lines().count();
674 let (net_added, net_removed) = net_line_change(args.old_str.lines(), args.new_str.lines());
679 let detail = (args.replace_all && args.occurrences > 1)
680 .then(|| format!("{} replacements", args.occurrences));
681
682 let short = path.file_name().map_or_else(
683 || path.to_string_lossy().to_string(),
684 |f| f.to_string_lossy().to_string(),
685 );
686
687 let mut out = edit_summary_line(
688 &short,
689 new_lines,
690 net_added.saturating_mul(replacements),
691 net_removed.saturating_mul(replacements),
692 detail.as_deref(),
693 );
694 if let Some(bp) = backup_path {
695 out.push_str(&format!("\nbackup: {bp}"));
696 }
697 if params.evidence {
698 let post_mtime_ms = std::fs::metadata(path)
699 .ok()
700 .and_then(|m| m.modified().ok())
701 .map_or(0, system_time_to_millis);
702 let post_fp = FileFingerprint {
703 size: new_content.len() as u64,
704 mtime_ms: post_mtime_ms,
705 blake3: lean_ctx::core::hasher::hash_hex(new_content.as_bytes()),
706 };
707 out.push_str(&format!(
708 "\npreimage: bytes={}, mtime_ms={}, blake3={}\n\
709postimage: bytes={}, mtime_ms={}, blake3={}",
710 pre.fp.size,
711 pre.fp.mtime_ms,
712 pre.fp.blake3,
713 post_fp.size,
714 post_fp.mtime_ms,
715 post_fp.blake3
716 ));
717 let diff = build_diff_evidence(args.content, &new_content, &short, params.diff_max_lines);
718 out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
719 out.push_str(&diff);
720 out.push_str("\n```");
721 } else if params.diff_artifact
722 && let Some(trailer) =
723 diff_artifact_trailer(args.content, &new_content, &short, params.diff_max_lines)
724 {
725 out.push('\n');
726 out.push_str(&trailer);
727 }
728 if let Some(notice) = health_notice {
729 out.push_str("\n\n");
730 out.push_str(¬ice);
731 }
732 (out, CacheEffect::Invalidate)
733}
734
735pub(crate) fn diff_artifact_trailer(
743 old: &str,
744 new: &str,
745 short: &str,
746 max_lines: usize,
747) -> Option<String> {
748 let id = diff_artifact_id(old, new, short, max_lines)?;
749 Some(format!("[diff:{id}]"))
750}
751
752pub(crate) fn diff_artifact_id(
754 old: &str,
755 new: &str,
756 short: &str,
757 max_lines: usize,
758) -> Option<String> {
759 if lean_ctx::core::config::Config::minimal_escape_hatch() {
760 return None;
761 }
762 let diff = build_diff_evidence(old, new, short, max_lines);
763 lean_ctx::core::archive::store("ctx_patch", &format!("diff {short}"), &diff, None)
764}
765
766pub(crate) fn net_line_change<S: AsRef<str>, T: AsRef<str>>(
770 old: impl IntoIterator<Item = S>,
771 new: impl IntoIterator<Item = T>,
772) -> (u64, u64) {
773 let old: Vec<S> = old.into_iter().collect();
774 let new: Vec<T> = new.into_iter().collect();
775 let prefix = old
776 .iter()
777 .zip(&new)
778 .take_while(|(a, b)| a.as_ref() == b.as_ref())
779 .count();
780 let suffix = old[prefix..]
781 .iter()
782 .rev()
783 .zip(new[prefix..].iter().rev())
784 .take_while(|(a, b)| a.as_ref() == b.as_ref())
785 .count();
786 let count = |n: usize| u64::try_from(n - prefix - suffix).unwrap_or(u64::MAX);
787 (count(new.len()), count(old.len()))
788}
789
790pub(crate) fn edit_summary_line(
794 short: &str,
795 lines_after: usize,
796 added: u64,
797 removed: u64,
798 detail: Option<&str>,
799) -> String {
800 let mut line = format!("patched {short}: {lines_after} lines (+{added} -{removed})");
801 if let Some(detail) = detail {
802 line.push_str(", ");
803 line.push_str(detail);
804 }
805 line
806}
807
808fn handle_create(
812 file_path: &str,
813 content: &str,
814 params: &EditParams,
815 only_if_empty: bool,
816) -> (String, CacheEffect) {
817 let path = Path::new(file_path);
818 let cap = lean_ctx::core::limits::max_read_bytes();
819
820 if let Err(e) = lean_ctx::core::pathjail::enforce_writable(path) {
824 return (format!("ERROR: {e}"), CacheEffect::None);
825 }
826
827 let mut preimage: Option<FilePreimage> = None;
828 if path.exists() {
829 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
830 Ok(p) => p,
831 Err(e) => return (e, CacheEffect::None),
832 };
833 if only_if_empty && !pre.bytes.is_empty() {
834 return (EMPTY_OLD_STRING_REFUSAL.into(), CacheEffect::None);
835 }
836 if let Err(e) = verify_expected_preimage(&pre, params) {
837 return (e, CacheEffect::None);
838 }
839 if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
843 return (e, CacheEffect::None);
844 }
845 preimage = Some(pre);
846 }
847
848 let before = preimage.as_ref().map(|pre| pre.text.as_str());
849 if let Err(e) = crate::tools::claims_gate::before_write(path, before, content) {
850 return (e, CacheEffect::None);
851 }
852
853 if let Some(parent) = path.parent()
854 && !parent.exists()
855 && let Err(e) = std::fs::create_dir_all(parent)
856 {
857 return (
858 format!("ERROR: cannot create directory {}: {e}", parent.display()),
859 CacheEffect::None,
860 );
861 }
862
863 let backup_path = if params.backup {
864 if let Some(pre) = &preimage {
865 let bp = params
866 .backup_path
867 .as_deref()
868 .map(PathBuf::from)
869 .or_else(|| default_backup_path(path));
870 let Some(bp) = bp else {
871 return (
872 format!("ERROR: cannot compute backup path for {}", path.display()),
873 CacheEffect::None,
874 );
875 };
876 if let Err(e) =
877 write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
878 {
879 return (
880 format!("ERROR: cannot create backup {}: {e}", bp.display()),
881 CacheEffect::None,
882 );
883 }
884 Some(bp.to_string_lossy().to_string())
885 } else {
886 None
887 }
888 } else {
889 None
890 };
891
892 let perms = preimage.as_ref().map(|p| &p.permissions);
893 if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), perms) {
894 return (e, CacheEffect::None);
895 }
896
897 let lines_added = u64::try_from(content.lines().count()).unwrap_or(u64::MAX);
898 let lines_removed = preimage.as_ref().map_or(0, |pre| {
899 u64::try_from(pre.text.lines().count()).unwrap_or(u64::MAX)
900 });
901 lean_ctx::core::edit_metering::record_loc_change(Some(file_path), lines_added, lines_removed);
902
903 let lines = content.lines().count();
904 let tokens = count_tokens(content);
905 let short = path.file_name().map_or_else(
906 || path.to_string_lossy().to_string(),
907 |f| f.to_string_lossy().to_string(),
908 );
909
910 let mut out = format!("✓ created {short}: {lines} lines, {tokens} tok");
911 if let Some(bp) = backup_path {
912 out.push_str(&format!("\nbackup: {bp}"));
913 }
914 (out, CacheEffect::Invalidate)
915}
916
917fn trim_trailing_per_line(s: &str) -> String {
918 s.lines().map(str::trim_end).collect::<Vec<_>>().join("\n")
919}
920
921fn adapt_new_string_to_line_sep(s: &str, sep: &str) -> String {
922 let normalized = s.replace("\r\n", "\n");
923 if sep == "\r\n" {
924 normalized.replace('\n', "\r\n")
925 } else {
926 normalized
927 }
928}
929
930fn find_original_span(content: &str, normalized_needle: &str) -> Option<String> {
933 let needle_lines: Vec<&str> = normalized_needle.lines().collect();
934 if needle_lines.is_empty() {
935 return None;
936 }
937
938 let content_lines: Vec<&str> = content.lines().collect();
939
940 'outer: for start in 0..content_lines.len() {
941 if start + needle_lines.len() > content_lines.len() {
942 break;
943 }
944 for (i, nl) in needle_lines.iter().enumerate() {
945 if content_lines[start + i].trim_end() != *nl {
946 continue 'outer;
947 }
948 }
949 let sep = if content.contains("\r\n") {
950 "\r\n"
951 } else {
952 "\n"
953 };
954 return Some(content_lines[start..start + needle_lines.len()].join(sep));
955 }
956 None
957}
958
959pub fn captured_solution_decision_kinds(old_content: &str, new_content: &str) -> Vec<&'static str> {
960 let mut kinds = Vec::new();
961
962 if new_content.contains("use std::") && !old_content.contains("use std::") {
963 kinds.push("stdlib");
964 }
965
966 if new_content.contains("// lean-ctx:") && !old_content.contains("// lean-ctx:") {
967 kinds.push("debt");
968 }
969
970 let old_deps = old_content
971 .lines()
972 .filter(|line| line.contains("[dependencies]") || line.contains("= \""))
973 .count();
974 let new_deps = new_content
975 .lines()
976 .filter(|line| line.contains("[dependencies]") || line.contains("= \""))
977 .count();
978 if new_deps < old_deps {
979 kinds.push("native");
980 }
981
982 let old_lines = old_content.lines().count();
983 let new_lines = new_content.lines().count();
984 if old_lines > 5 && new_lines < old_lines / 2 {
985 kinds.push("oneline");
986 }
987
988 kinds
989}
990
991#[cfg(test)]
992mod solution_auto_capture_tests {
993 use super::captured_solution_decision_kinds;
994
995 #[test]
996 fn classifies_decisions_from_completed_edit_content() {
997 let old = "use crate::core::Config;\n[dependencies]\nlegacy = \"1\"\nfn verbose() {\n first();\n second();\n third();\n fourth();\n fifth();\n sixth();\n}\n";
998 let new = "use crate::core::Config;\nuse std::collections::HashMap;\n[dependencies]\n// lean-ctx: replace legacy dependency\nfn concise() {}\n";
999
1000 assert_eq!(
1001 captured_solution_decision_kinds(old, new),
1002 ["stdlib", "debt", "native"]
1003 );
1004 }
1005}