lean_ctx/tools/ctx_patch/
mod.rs1mod anchors;
15mod apply;
16mod output;
17mod symbol;
18#[cfg(test)]
19mod tests;
20
21pub use anchors::AnchorOp;
22pub(crate) use symbol::{build_refactor_args, is_replace_symbol};
23
24use std::path::{Path, PathBuf};
25
26use crate::core::cache::SessionCache;
27use crate::core::tokens::count_tokens;
28use crate::tools::ctx_edit::{CacheEffect, apply_cache_effect, build_diff_evidence};
29use crate::tools::edit_io::{
30 default_backup_path, ensure_preimage_still_matches, read_preimage,
31 write_atomic_bytes_with_permissions,
32};
33
34pub struct PatchParams {
38 pub path: String,
39 pub ops: Vec<AnchorOp>,
40 pub expected_md5: Option<String>,
43 pub backup: bool,
44 pub backup_path: Option<String>,
45 pub evidence: bool,
46 pub diff_max_lines: usize,
47 pub allow_lossy_utf8: bool,
48 pub validate_syntax: bool,
52}
53
54pub fn parse_ops(
56 args: &serde_json::Map<String, serde_json::Value>,
57) -> Result<Vec<AnchorOp>, String> {
58 anchors::parse_ops(args)
59}
60
61pub fn handle(cache: &mut SessionCache, params: &PatchParams) -> String {
64 let last_mode = cache
65 .get(¶ms.path)
66 .map(|e| e.last_mode.clone())
67 .unwrap_or_default();
68 let (text, effect) = run_io(params, &last_mode);
69 record_outcome(params, &last_mode, &text, &effect);
70 apply_cache_effect(cache, ¶ms.path, effect);
71 text
72}
73
74pub fn record_outcome(params: &PatchParams, last_mode: &str, text: &str, effect: &CacheEffect) {
81 let success = matches!(effect, CacheEffect::Invalidate);
82 let conflict = matches!(effect, CacheEffect::None) && text.starts_with("CONFLICT:");
83 if success || conflict {
84 crate::core::edit_quality::record_anchored_edit_outcome(¶ms.path, last_mode, success);
85 }
86}
87
88pub fn run_io(params: &PatchParams, _last_mode: &str) -> (String, CacheEffect) {
92 let file_path = ¶ms.path;
93 let path = Path::new(file_path);
94 let cap = crate::core::limits::max_read_bytes();
95
96 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
97 Ok(p) => p,
98 Err(e) => {
99 if !path.exists() {
100 let hint = crate::tools::edit_recovery::moved_or_deleted_hint(path);
101 return (format!("{e}{hint}"), CacheEffect::None);
102 }
103 return (e, CacheEffect::None);
104 }
105 };
106
107 if let Some(expected) = params.expected_md5.as_deref()
108 && expected != pre.fp.md5
109 {
110 return (
111 format!(
112 "ERROR: preimage mismatch for {file_path}: expected_md5={expected}, actual_md5={}",
113 pre.fp.md5
114 ),
115 CacheEffect::None,
116 );
117 }
118
119 if params.ops.is_empty() {
120 return (
121 "ERROR: no edits provided (pass an op or ops:[…])".to_string(),
122 CacheEffect::None,
123 );
124 }
125
126 let (lines, sep, trailing) = apply::split_lines(&pre.text);
127
128 let edits = match apply::resolve_ops(&lines, ¶ms.ops) {
129 Ok(e) => e,
130 Err(apply::ResolveError::Conflict(misses)) => {
131 return (
132 output::render_conflict(file_path, &lines, &misses),
133 CacheEffect::None,
134 );
135 }
136 Err(apply::ResolveError::Invalid(msg)) => {
137 return (format!("ERROR: {msg}"), CacheEffect::None);
138 }
139 };
140
141 let n_edits = edits.len();
142 let lines_before = lines.len();
143 let new_lines = apply::apply_edits(lines.clone(), edits);
144 let new_content = apply::join_lines(&new_lines, sep, trailing);
145
146 if new_content == pre.text {
147 return (
148 "ERROR: edits produced no change to the file".to_string(),
149 CacheEffect::None,
150 );
151 }
152
153 let ext = Path::new(file_path)
154 .extension()
155 .and_then(|e| e.to_str())
156 .unwrap_or("");
157
158 if params.validate_syntax
161 && let Some(reason) = crate::core::syntax_validate::gate_edit(ext, &pre.text, &new_content)
162 {
163 return (reason, CacheEffect::None);
164 }
165
166 let health_notice = match crate::core::code_health::gate::evaluate(&pre.text, &new_content, ext)
168 {
169 crate::core::code_health::gate::GateOutcome::Block(reason) => {
170 return (
171 format!("ERROR: code-health gate: {reason}"),
172 CacheEffect::None,
173 );
174 }
175 crate::core::code_health::gate::GateOutcome::Allow(notice) => notice,
176 };
177
178 if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
180 return (e, CacheEffect::None);
181 }
182
183 let backup_path = match make_backup(params, path, &pre.bytes, &pre.permissions) {
184 Ok(bp) => bp,
185 Err(e) => return (e, CacheEffect::None),
186 };
187
188 if let Err(e) =
189 write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
190 {
191 return (e, CacheEffect::None);
192 }
193
194 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
195 bt.record_edit(file_path);
196 }
197
198 let mut out = render_success(
199 params,
200 &pre.text,
201 &new_content,
202 pre.fp.size,
203 pre.fp.mtime_ms,
204 &pre.fp.md5,
205 lines_before,
206 new_lines.len(),
207 n_edits,
208 backup_path,
209 );
210 if let Some(notice) = health_notice {
211 out.push_str("\n\n");
212 out.push_str(¬ice);
213 }
214 (out, CacheEffect::Invalidate)
215}
216
217fn make_backup(
219 params: &PatchParams,
220 path: &Path,
221 bytes: &[u8],
222 permissions: &std::fs::Permissions,
223) -> Result<Option<String>, String> {
224 if !params.backup {
225 return Ok(None);
226 }
227 let bp = params
228 .backup_path
229 .as_deref()
230 .map(PathBuf::from)
231 .or_else(|| default_backup_path(path))
232 .ok_or_else(|| format!("ERROR: cannot compute backup path for {}", path.display()))?;
233 write_atomic_bytes_with_permissions(&bp, bytes, Some(permissions))
234 .map_err(|e| format!("ERROR: cannot create backup {}: {e}", bp.display()))?;
235 Ok(Some(bp.to_string_lossy().to_string()))
236}
237
238#[allow(clippy::too_many_arguments)]
239fn render_success(
240 params: &PatchParams,
241 old_content: &str,
242 new_content: &str,
243 pre_size: u64,
244 pre_mtime_ms: u64,
245 pre_md5: &str,
246 lines_before: usize,
247 lines_after: usize,
248 n_edits: usize,
249 backup_path: Option<String>,
250) -> String {
251 let short = output::short_name(¶ms.path);
252 let line_delta = lines_after as i64 - lines_before as i64;
253 let delta_str = if line_delta >= 0 {
254 format!("+{line_delta}")
255 } else {
256 format!("{line_delta}")
257 };
258 let old_tokens = count_tokens(old_content);
259 let new_tokens = count_tokens(new_content);
260
261 let post_mtime_ms = std::fs::metadata(¶ms.path)
262 .ok()
263 .and_then(|m| m.modified().ok())
264 .map_or(0, crate::tools::edit_io::system_time_to_millis);
265 let post_md5 = crate::core::hasher::hash_hex(new_content.as_bytes());
266
267 let edit_word = if n_edits == 1 { "edit" } else { "edits" };
268 let mut out = format!(
269 "✓ {short}: {n_edits} anchored {edit_word}, {delta_str} lines ({old_tokens}→{new_tokens} tok)\n\
270preimage: bytes={pre_size}, mtime_ms={pre_mtime_ms}, md5={pre_md5}\n\
271postimage: bytes={}, mtime_ms={post_mtime_ms}, md5={post_md5}",
272 new_content.len()
273 );
274 if let Some(bp) = backup_path {
275 out.push_str(&format!("\nbackup: {bp}"));
276 }
277 if params.evidence {
278 let diff = build_diff_evidence(old_content, new_content, &short, params.diff_max_lines);
279 out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
280 out.push_str(&diff);
281 out.push_str("\n```");
282 }
283 out
284}