Skip to main content

lean_ctx/tools/registered/
ctx_patch.rs

1use 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    // Schema diet (#576 pattern): the advertised surface carries only the
18    // functional teaching (anchor source, op routing, batch atomicity).
19    // Handler-only params stay supported but unadvertised: expected_md5,
20    // backup, backup_path, validate_syntax, evidence, diff_max_lines,
21    // allow_lossy_utf8 — same hidden-params contract as ctx_edit.
22    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        // replace_symbol is a whole-symbol rewrite — delegate to the LSP/IDE-aware
58        // ctx_refactor so there is one symbol-edit implementation (epic #1008).
59        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            // Serialize edits to the SAME file via the shared per-file lock (the
99            // same registry ctx_edit/ctx_read use), so anchored and str_replace
100            // edits of one file never interleave (issue #320). Correctness across
101            // processes still rests on the TOCTOU preimage guard + atomic rename.
102            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            // Heavy disk I/O — no global cache lock held here.
133            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
176/// Handle `op="replace_symbol"` by translating to `ctx_refactor`'s
177/// `replace_symbol_body` and dispatching through it. The symbol-resolution,
178/// CONFLICT guard and atomic write all live in ctx_refactor — this is a thin,
179/// pure-mapping adapter (mapping logic lives in `ctx_patch::symbol`).
180fn 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    // Resolve `path` at the boundary when given (the name route resolves its own
188    // path inside ctx_refactor). abs_path is unused by the symbol-edit branch but
189    // we mirror ctx_refactor's wrapper to keep jail behaviour identical.
190    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}