Skip to main content

lean_ctx/tools/registered/
ctx_search.rs

1use rmcp::model::Tool;
2use rmcp::ErrorData;
3use serde_json::{json, Map, Value};
4
5use crate::server::tool_trait::{get_bool, get_int, get_str, McpTool, ToolContext, ToolOutput};
6use crate::tool_defs::tool_def;
7
8pub struct CtxSearchTool;
9
10impl McpTool for CtxSearchTool {
11    fn name(&self) -> &'static str {
12        "ctx_search"
13    }
14
15    fn tool_def(&self) -> Tool {
16        tool_def(
17            "ctx_search",
18            "Search code by regex. Prefer over native Grep/rg/find (compact output).\n\
19             Respects .gitignore; supports multi-root via `paths` array. Secret-like files skipped unless role allows.",
20            json!({
21                "type": "object",
22                "properties": {
23                    "pattern": { "type": "string", "description": "Regex pattern" },
24                    "path": { "type": "string", "description": "Directory to search" },
25                    "paths": {
26                        "type": "array",
27                        "items": { "type": "string" },
28                        "description": "Multiple directories to search (alternative to path)"
29                    },
30                    "include": { "type": "string", "description": "File filter glob (e.g. *.ts, *.{rs,ts}, src/**/*.tsx)" },
31                    "ext": { "type": "string", "description": "Deprecated alias for `include`: a bare extension like `rs` or `.rs` is treated as `*.rs`. Prefer `include`." },
32                    "max_results": { "type": "integer", "description": "Max results (default: 20)" },
33                    "ignore_gitignore": { "type": "boolean", "description": "Set true to scan ALL files including .gitignore'd paths (default: false). Requires role policy (e.g. admin)." }
34                },
35                "required": ["pattern"]
36            }),
37        )
38    }
39
40    fn handle(
41        &self,
42        args: &Map<String, Value>,
43        ctx: &ToolContext,
44    ) -> Result<ToolOutput, ErrorData> {
45        let pattern = get_str(args, "pattern")
46            .ok_or_else(|| ErrorData::invalid_params("pattern is required", None))?;
47        let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx);
48        // `include` is the canonical glob filter; `ext` is the deprecated alias
49        // (bare extension → `*.{ext}`). `include` wins when both are supplied.
50        let include =
51            get_str(args, "include").or_else(|| get_str(args, "ext").map(|e| ext_to_include(&e)));
52        let max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
53        let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
54
55        if no_gitignore {
56            if let Err(e) = crate::core::io_boundary::ensure_ignore_gitignore_allowed("ctx_search")
57            {
58                return Ok(ToolOutput::simple(e));
59            }
60        }
61
62        let crp = ctx.crp_mode;
63        let respect = !no_gitignore;
64        let allow_secret_paths = crate::core::roles::active_role().io.allow_secret_paths;
65
66        if !resolved.is_multi {
67            return search_single(
68                &pattern,
69                &resolved.roots[0],
70                include.as_deref(),
71                max,
72                crp,
73                respect,
74                allow_secret_paths,
75            );
76        }
77
78        let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
79        let per_root_max = (max / resolved.roots.len()).max(5);
80        let mut combined = String::new();
81        let mut total_original: usize = 0;
82        let mut total_observed: usize = 0;
83        let mut total_sent: usize = 0;
84
85        for root in &resolved.roots {
86            let pat = pattern.clone();
87            let r = root.clone();
88            let inc = include.clone();
89
90            let search_result = tokio::task::block_in_place(|| {
91                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
92                    crate::tools::ctx_search::handle(
93                        &pat,
94                        &r,
95                        inc.as_deref(),
96                        per_root_max,
97                        crp,
98                        respect,
99                        allow_secret_paths,
100                    )
101                }))
102                .ok()
103            });
104
105            let Some(outcome) = search_result else {
106                combined.push_str(&format!("── {root} ──\nERROR: search panicked\n\n"));
107                continue;
108            };
109            let result = outcome.text;
110
111            if result.starts_with("ERROR:") || result.trim().is_empty() {
112                if !result.trim().is_empty() {
113                    combined.push_str(&format!("── {root} ──\n{result}\n\n"));
114                }
115                continue;
116            }
117
118            combined.push_str(&format!("── {root} ──\n{result}\n\n"));
119            total_original += outcome.modeled_baseline;
120            total_observed += outcome.observed_tokens;
121            total_sent += crate::core::tokens::count_tokens(&result);
122        }
123
124        if combined.is_empty() {
125            combined = "No matches found across any root.".to_string();
126        }
127
128        let final_out =
129            crate::core::protocol::append_savings(&combined, total_original, total_sent);
130        let saved = total_original.saturating_sub(total_sent);
131        crate::core::savings_ledger::record_tool_event(
132            "ctx_search",
133            total_observed,
134            total_observed.saturating_sub(total_sent),
135        );
136
137        Ok(ToolOutput {
138            text: final_out,
139            original_tokens: total_original,
140            saved_tokens: saved,
141            mode: None,
142            path: None,
143            changed: false,
144            shell_outcome: None,
145        })
146    }
147}
148
149fn search_single(
150    pattern: &str,
151    path: &str,
152    include: Option<&str>,
153    max: usize,
154    crp: crate::tools::CrpMode,
155    respect_gitignore: bool,
156    allow_secret_paths: bool,
157) -> Result<ToolOutput, ErrorData> {
158    let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
159    let pattern_clone = pattern.to_string();
160    let path_clone = path.to_string();
161
162    let search_result = tokio::task::block_in_place(|| {
163        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
164            crate::tools::ctx_search::handle(
165                &pattern_clone,
166                &path_clone,
167                include,
168                max,
169                crp,
170                respect_gitignore,
171                allow_secret_paths,
172            )
173        }));
174        match result {
175            Ok(r) => Ok(r),
176            Err(_) => Err("search task panicked"),
177        }
178    });
179
180    let outcome = match search_result {
181        Ok(r) => r,
182        Err(e) => {
183            return Err(ErrorData::internal_error(
184                format!("search task failed: {e}"),
185                None,
186            ));
187        }
188    };
189    let result = outcome.text;
190    let original = outcome.modeled_baseline;
191
192    if result.starts_with("ERROR:") {
193        return Err(ErrorData::invalid_params(result, None));
194    }
195
196    let sent = crate::core::tokens::count_tokens(&result);
197    let saved = original.saturating_sub(sent);
198    let final_out = crate::core::protocol::append_savings(&result, original, sent);
199    crate::core::savings_ledger::record_tool_event(
200        "ctx_search",
201        outcome.observed_tokens,
202        outcome.observed_tokens.saturating_sub(sent),
203    );
204
205    Ok(ToolOutput {
206        text: final_out,
207        original_tokens: original,
208        saved_tokens: saved,
209        mode: None,
210        path: Some(path.to_string()),
211        changed: false,
212        shell_outcome: None,
213    })
214}
215
216/// Translate the deprecated `ext` parameter into an `include` glob.
217///
218/// The historical `ext` accepted a bare extension (`rs` or `.rs`) and matched it
219/// exactly; the equivalent glob is `*.{ext}` (the `glob` crate's `*` spans path
220/// separators, so it still matches at any depth, preserving the old behaviour).
221/// A value that already looks like a glob/path (`*`, `{`, `?`, `/`) is passed
222/// through untouched so any power user who put a pattern in `ext` keeps working.
223fn ext_to_include(ext: &str) -> String {
224    if ext.contains(['*', '{', '?', '/']) {
225        return ext.to_string();
226    }
227    let bare = ext.strip_prefix('.').unwrap_or(ext);
228    format!("*.{bare}")
229}
230
231#[cfg(test)]
232mod tests {
233    use super::ext_to_include;
234
235    #[test]
236    fn ext_alias_bare_extension_becomes_glob() {
237        assert_eq!(ext_to_include("rs"), "*.rs");
238        assert_eq!(ext_to_include("ts"), "*.ts");
239    }
240
241    #[test]
242    fn ext_alias_strips_leading_dot() {
243        assert_eq!(ext_to_include(".rs"), "*.rs");
244        assert_eq!(ext_to_include(".tsx"), "*.tsx");
245    }
246
247    #[test]
248    fn ext_alias_passes_through_glob_like_values() {
249        // Already a glob/path → keep verbatim, don't double-wrap.
250        assert_eq!(ext_to_include("*.rs"), "*.rs");
251        assert_eq!(ext_to_include("*.{rs,ts}"), "*.{rs,ts}");
252        assert_eq!(ext_to_include("src/**/*.tsx"), "src/**/*.tsx");
253    }
254}