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