Skip to main content

lean_ctx/tools/registered/
ctx_edit.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 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            "Search-and-replace edit with race-condition guards — for simple text replacement in a single file.\n\
21             For editing code you've read, prefer ctx_patch (hash-anchored): it never makes you reproduce old text byte-for-byte. Read with ctx_read(mode=\"anchored\") first.\n\
22             old_string must be unique unless replace_all=true. create=true writes new files.\n\
23             backup creates .bak. MD5/size/mtime pre-guards prevent race conditions.\n\
24             ANTIPATTERN: Do NOT loop on failures — switch to ctx_patch (anchored), or verify file content and adjust old_string.\n\
25             For LSP-aware refactoring (rename, move, inline), use ctx_refactor.",
26            json!({
27                "type": "object",
28                "properties": {
29                    "path": { "type": "string", "description": "File path to edit" },
30                    "old_string": { "type": "string", "description": "Text to replace (unique unless replace_all=true)" },
31                    "new_string": { "type": "string", "description": "Replacement text" },
32                    "replace_all": { "type": "boolean", "description": "Replace all occurrences (default false)", "default": false },
33                    "create": { "type": "boolean", "description": "Create file", "default": false }
34                },
35                "required": ["path", "new_string"]
36            }),
37        )
38    }
39
40    fn handle(
41        &self,
42        args: &Map<String, Value>,
43        ctx: &ToolContext,
44    ) -> Result<ToolOutput, ErrorData> {
45        let path = require_resolved_path(ctx, args, "path")?;
46
47        let old_string = get_str(args, "old_string").unwrap_or_default();
48        let new_string = get_str(args, "new_string")
49            .ok_or_else(|| ErrorData::invalid_params("new_string is required", None))?;
50        let replace_all = get_bool(args, "replace_all").unwrap_or(false);
51        let create = get_bool(args, "create").unwrap_or(false);
52        let expected_md5 = get_str(args, "expected_md5");
53        let expected_size = get_int(args, "expected_size").and_then(|v| u64::try_from(v).ok());
54        let expected_mtime_ms =
55            get_int(args, "expected_mtime_ms").and_then(|v| u64::try_from(v).ok());
56        let backup = get_bool(args, "backup").unwrap_or(false);
57        let backup_path = get_str(args, "backup_path")
58            .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p));
59        let evidence = get_bool(args, "evidence").unwrap_or(true);
60        let diff_max_lines = get_int(args, "diff_max_lines")
61            .and_then(|v| usize::try_from(v.max(0)).ok())
62            .unwrap_or(200);
63        let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
64
65        let edit_params = crate::tools::ctx_edit::EditParams {
66            path: path.clone(),
67            old_string,
68            new_string,
69            replace_all,
70            create,
71            expected_md5,
72            expected_size,
73            expected_mtime_ms,
74            backup,
75            backup_path,
76            evidence,
77            diff_max_lines,
78            allow_lossy_utf8,
79        };
80
81        tokio::task::block_in_place(|| {
82            let cache_lock = ctx
83                .cache
84                .as_ref()
85                .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
86            let rt = tokio::runtime::Handle::current();
87
88            // Serialize edits to the SAME file via a cheap per-file lock. This
89            // lets the (slow) disk read/replace/write run WITHOUT holding the
90            // global cache write-lock, so concurrent agents editing different
91            // files never block each other (issue #320). Correctness for same-file
92            // edits is still guaranteed by the TOCTOU preimage guard + atomic
93            // rename inside run_io.
94            let file_lock = crate::core::path_locks::per_file_lock(&path);
95            let _file_guard = {
96                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
97                loop {
98                    if let Ok(guard) = file_lock.try_lock() {
99                        break guard;
100                    }
101                    if std::time::Instant::now() >= deadline {
102                        return Err(ErrorData::internal_error(
103                            format!(
104                                "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
105                            ),
106                            None,
107                        ));
108                    }
109                    std::thread::sleep(std::time::Duration::from_millis(20));
110                }
111            };
112
113            // Brief shared lock: read the recorded read-mode for auto-escalation.
114            // On contention we simply skip escalation rather than blocking I/O.
115            let last_mode = match rt.block_on(tokio::time::timeout(
116                std::time::Duration::from_secs(5),
117                cache_lock.read(),
118            )) {
119                Ok(cache) => cache
120                    .get(&path)
121                    .map(|e| e.last_mode.clone())
122                    .unwrap_or_default(),
123                Err(_) => String::new(),
124            };
125
126            // Heavy disk I/O — no global cache lock held here.
127            let (output, effect) = crate::tools::ctx_edit::run_io(&edit_params, &last_mode);
128
129            // Quality loop (#494): feed success/old_string-miss back into
130            // per-(ext × mode) stats and the one-shot read escalation.
131            crate::tools::ctx_edit::record_outcome(&edit_params, &last_mode, &output, &effect);
132
133            // Apply the deferred cache mutation under a brief exclusive lock.
134            if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
135                match rt.block_on(tokio::time::timeout(
136                    std::time::Duration::from_secs(5),
137                    cache_lock.write(),
138                )) {
139                    Ok(mut cache) => {
140                        crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
141                    }
142                    Err(_) => {
143                        tracing::warn!(
144                            "ctx_edit: cache write-lock timeout (5s) applying post-edit cache effect for {path}"
145                        );
146                    }
147                }
148            }
149
150            if let Some(session_lock) = ctx.session.as_ref() {
151                let guard = rt.block_on(tokio::time::timeout(
152                    std::time::Duration::from_secs(5),
153                    session_lock.write(),
154                ));
155                if let Ok(mut session) = guard {
156                    session.mark_modified(&path);
157                }
158            }
159
160            Ok(ToolOutput {
161                text: output,
162                original_tokens: 0,
163                saved_tokens: 0,
164                mode: None,
165                path: Some(path),
166                changed: false,
167                shell_outcome: None,
168            })
169        })
170    }
171}