Skip to main content

lean_ctx/tools/registered/
ctx_edit.rs

1use rmcp::model::Tool;
2use rmcp::ErrorData;
3use serde_json::{json, Map, Value};
4
5use crate::server::tool_trait::{
6    get_bool, get_int, get_str, require_resolved_path, McpTool, ToolContext, ToolOutput,
7};
8use crate::tool_defs::tool_def;
9
10pub struct CtxEditTool;
11
12impl McpTool for CtxEditTool {
13    fn name(&self) -> &'static str {
14        "ctx_edit"
15    }
16
17    fn tool_def(&self) -> Tool {
18        tool_def(
19            "ctx_edit",
20            "Edit a file via search-and-replace. Works without native Read/Edit tools. Use this when the IDE's Edit tool requires Read but Read is unavailable.",
21            json!({
22                "type": "object",
23                "properties": {
24                    "path": { "type": "string", "description": "Absolute file path" },
25                    "old_string": { "type": "string", "description": "Exact text to find and replace (must be unique unless replace_all=true)" },
26                    "new_string": { "type": "string", "description": "Replacement text" },
27                    "replace_all": { "type": "boolean", "description": "Replace all occurrences (default: false)", "default": false },
28                    "create": { "type": "boolean", "description": "Create a new file with new_string as content (ignores old_string)", "default": false }
29                },
30                "required": ["path", "new_string"]
31            }),
32        )
33    }
34
35    fn handle(
36        &self,
37        args: &Map<String, Value>,
38        ctx: &ToolContext,
39    ) -> Result<ToolOutput, ErrorData> {
40        let path = require_resolved_path(ctx, args, "path")?;
41
42        let old_string = get_str(args, "old_string").unwrap_or_default();
43        let new_string = get_str(args, "new_string")
44            .ok_or_else(|| ErrorData::invalid_params("new_string is required", None))?;
45        let replace_all = get_bool(args, "replace_all").unwrap_or(false);
46        let create = get_bool(args, "create").unwrap_or(false);
47        let expected_md5 = get_str(args, "expected_md5");
48        let expected_size = get_int(args, "expected_size").and_then(|v| u64::try_from(v).ok());
49        let expected_mtime_ms =
50            get_int(args, "expected_mtime_ms").and_then(|v| u64::try_from(v).ok());
51        let backup = get_bool(args, "backup").unwrap_or(false);
52        let backup_path = get_str(args, "backup_path")
53            .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p));
54        let evidence = get_bool(args, "evidence").unwrap_or(true);
55        let diff_max_lines = get_int(args, "diff_max_lines")
56            .and_then(|v| usize::try_from(v.max(0)).ok())
57            .unwrap_or(200);
58        let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
59
60        let edit_params = crate::tools::ctx_edit::EditParams {
61            path: path.clone(),
62            old_string,
63            new_string,
64            replace_all,
65            create,
66            expected_md5,
67            expected_size,
68            expected_mtime_ms,
69            backup,
70            backup_path,
71            evidence,
72            diff_max_lines,
73            allow_lossy_utf8,
74        };
75
76        tokio::task::block_in_place(|| {
77            let cache_lock = ctx
78                .cache
79                .as_ref()
80                .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
81            let rt = tokio::runtime::Handle::current();
82
83            // Serialize edits to the SAME file via a cheap per-file lock. This
84            // lets the (slow) disk read/replace/write run WITHOUT holding the
85            // global cache write-lock, so concurrent agents editing different
86            // files never block each other (issue #320). Correctness for same-file
87            // edits is still guaranteed by the TOCTOU preimage guard + atomic
88            // rename inside run_io.
89            let file_lock = crate::core::path_locks::per_file_lock(&path);
90            let _file_guard = {
91                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
92                loop {
93                    if let Ok(guard) = file_lock.try_lock() {
94                        break guard;
95                    }
96                    if std::time::Instant::now() >= deadline {
97                        return Err(ErrorData::internal_error(
98                            format!("per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"),
99                            None,
100                        ));
101                    }
102                    std::thread::sleep(std::time::Duration::from_millis(20));
103                }
104            };
105
106            // Brief shared lock: read the recorded read-mode for auto-escalation.
107            // On contention we simply skip escalation rather than blocking I/O.
108            let last_mode = match rt.block_on(tokio::time::timeout(
109                std::time::Duration::from_secs(5),
110                cache_lock.read(),
111            )) {
112                Ok(cache) => cache
113                    .get(&path)
114                    .map(|e| e.last_mode.clone())
115                    .unwrap_or_default(),
116                Err(_) => String::new(),
117            };
118
119            // Heavy disk I/O — no global cache lock held here.
120            let (output, effect) = crate::tools::ctx_edit::run_io(&edit_params, &last_mode);
121
122            // Apply the deferred cache mutation under a brief exclusive lock.
123            if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
124                match rt.block_on(tokio::time::timeout(
125                    std::time::Duration::from_secs(5),
126                    cache_lock.write(),
127                )) {
128                    Ok(mut cache) => {
129                        crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
130                    }
131                    Err(_) => {
132                        tracing::warn!(
133                            "ctx_edit: cache write-lock timeout (5s) applying post-edit cache effect for {path}"
134                        );
135                    }
136                }
137            }
138
139            if let Some(session_lock) = ctx.session.as_ref() {
140                let guard = rt.block_on(tokio::time::timeout(
141                    std::time::Duration::from_secs(5),
142                    session_lock.write(),
143                ));
144                if let Ok(mut session) = guard {
145                    session.mark_modified(&path);
146                }
147            }
148
149            Ok(ToolOutput {
150                text: output,
151                original_tokens: 0,
152                saved_tokens: 0,
153                mode: None,
154                path: Some(path),
155                changed: false,
156            })
157        })
158    }
159}