lean_ctx/tools/registered/
ctx_patch.rs1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{
6 McpTool, ToolContext, ToolOutput, get_bool, get_int, get_str, require_resolved_path,
7};
8use crate::tool_defs::tool_def;
9
10pub struct CtxPatchTool;
11
12impl McpTool for CtxPatchTool {
13 fn name(&self) -> &'static str {
14 "ctx_patch"
15 }
16
17 fn tool_def(&self) -> Tool {
23 tool_def(
24 "ctx_patch",
25 "Hash-anchored edit — patch by (line, hash) anchor; never reproduce old text byte-for-byte.\n\
26 Anchors N:hh| come from ctx_read(mode=\"anchored\") or ctx_search(anchored=true).\n\
27 op=set_line one line; replace_lines start_*..end_* range; insert_after (line 0 = top); delete; \
28 replace_symbol (name + new_body); create writes a NEW file from new_text.\n\
29 new_text=\"\" deletes. Batch via ops:[{op,line,hash,new_text},…] — one preimage, applied all-or-nothing.\n\
30 Stale anchor → CONFLICT with fresh anchors to retry (no partial writes).",
31 json!({
32 "type": "object",
33 "properties": {
34 "path": { "type": "string" },
35 "op": { "type": "string", "enum": ["set_line", "replace_lines", "insert_after", "delete", "replace_symbol", "create"] },
36 "line": { "type": "integer" },
37 "hash": { "type": "string" },
38 "start_line": { "type": "integer" },
39 "start_hash": { "type": "string" },
40 "end_line": { "type": "integer" },
41 "end_hash": { "type": "string" },
42 "new_text": { "type": "string" },
43 "name": { "type": "string" },
44 "new_body": { "type": "string" },
45 "ops": { "type": "array", "items": { "type": "object" } }
46 },
47 "required": ["path"]
48 }),
49 )
50 }
51
52 fn handle(
53 &self,
54 args: &Map<String, Value>,
55 ctx: &ToolContext,
56 ) -> Result<ToolOutput, ErrorData> {
57 if crate::tools::ctx_patch::is_replace_symbol(args) {
60 return delegate_replace_symbol(args, ctx);
61 }
62
63 let path = require_resolved_path(ctx, args, "path")?;
64
65 let ops = crate::tools::ctx_patch::parse_ops(args)
66 .map_err(|e| ErrorData::invalid_params(e, None))?;
67
68 let expected_md5 = get_str(args, "expected_md5");
69 let backup = get_bool(args, "backup").unwrap_or(false);
70 let backup_path = get_str(args, "backup_path")
71 .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p));
72 let evidence = get_bool(args, "evidence").unwrap_or(true);
73 let diff_max_lines = get_int(args, "diff_max_lines")
74 .and_then(|v| usize::try_from(v.max(0)).ok())
75 .unwrap_or(200);
76 let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
77 let validate_syntax = get_bool(args, "validate_syntax").unwrap_or(true);
78
79 let patch_params = crate::tools::ctx_patch::PatchParams {
80 path: path.clone(),
81 ops,
82 expected_md5,
83 backup,
84 backup_path,
85 evidence,
86 diff_max_lines,
87 allow_lossy_utf8,
88 validate_syntax,
89 };
90
91 tokio::task::block_in_place(|| {
92 let cache_lock = ctx
93 .cache
94 .as_ref()
95 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
96 let rt = tokio::runtime::Handle::current();
97
98 let file_lock = crate::core::path_locks::per_file_lock(&path);
103 let _file_guard = {
104 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
105 loop {
106 if let Ok(guard) = file_lock.try_lock() {
107 break guard;
108 }
109 if std::time::Instant::now() >= deadline {
110 return Err(ErrorData::internal_error(
111 format!(
112 "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
113 ),
114 None,
115 ));
116 }
117 std::thread::sleep(std::time::Duration::from_millis(20));
118 }
119 };
120
121 let last_mode = match rt.block_on(tokio::time::timeout(
122 std::time::Duration::from_secs(5),
123 cache_lock.read(),
124 )) {
125 Ok(cache) => cache
126 .get(&path)
127 .map(|e| e.last_mode.clone())
128 .unwrap_or_default(),
129 Err(_) => String::new(),
130 };
131
132 let (output, effect) = crate::tools::ctx_patch::run_io(&patch_params, &last_mode);
134
135 crate::tools::ctx_patch::record_outcome(&patch_params, &last_mode, &output, &effect);
136
137 if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
138 match rt.block_on(tokio::time::timeout(
139 std::time::Duration::from_secs(5),
140 cache_lock.write(),
141 )) {
142 Ok(mut cache) => {
143 crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
144 }
145 Err(_) => {
146 tracing::warn!(
147 "ctx_patch: cache write-lock timeout (5s) applying post-edit cache effect for {path}"
148 );
149 }
150 }
151 }
152
153 if let Some(session_lock) = ctx.session.as_ref() {
154 let guard = rt.block_on(tokio::time::timeout(
155 std::time::Duration::from_secs(5),
156 session_lock.write(),
157 ));
158 if let Ok(mut session) = guard {
159 session.mark_modified(&path);
160 }
161 }
162
163 Ok(ToolOutput {
164 text: output,
165 original_tokens: 0,
166 saved_tokens: 0,
167 mode: None,
168 path: Some(path),
169 changed: false,
170 shell_outcome: None,
171 })
172 })
173 }
174}
175
176fn delegate_replace_symbol(
181 args: &Map<String, Value>,
182 ctx: &ToolContext,
183) -> Result<ToolOutput, ErrorData> {
184 let refactor_args = crate::tools::ctx_patch::build_refactor_args(args)
185 .map_err(|e| ErrorData::invalid_params(e, None))?;
186
187 let has_path = args.get("path").and_then(Value::as_str).is_some();
191 let abs_path = if has_path {
192 require_resolved_path(ctx, args, "path")?
193 } else {
194 String::new()
195 };
196
197 let args_value = Value::Object(refactor_args);
198 let result = crate::tools::ctx_refactor::handle(&args_value, &ctx.project_root, &abs_path);
199 let changed = !result.starts_with("ERROR") && !result.starts_with("CONFLICT");
200
201 Ok(ToolOutput {
202 text: result,
203 original_tokens: 0,
204 saved_tokens: 0,
205 mode: Some("replace_symbol".to_string()),
206 path: get_str(args, "path"),
207 changed,
208 shell_outcome: None,
209 })
210}