Skip to main content

lean_ctx/tools/registered/
ctx_search.rs

1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_bool, get_int, get_str};
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 pattern search — use when you know the exact pattern. For understanding code or\n\
19             finding answers, use ctx_compose FIRST (one call replaces search+read+symbol chains).\n\
20             pattern required; include='*.rs'; path scopes; max_results=N (default 20).\n\
21             paths=['dir1','dir2'] for multi-root. ignore_gitignore bypasses .gitignore (needs role).",
22            json!({
23                "type": "object",
24                "properties": {
25                    "pattern": { "type": "string", "description": "Regex pattern" },
26                    "path": { "type": "string", "description": "Search dir" },
27                    "paths": {
28                        "type": "array",
29                        "items": { "type": "string" },
30                        "description": "Multi-root (alternative to path)"
31                    },
32                    "include": { "type": "string", "description": "Glob filter: *.ts, src/**/*.rs" },
33                    "max_results": { "type": "integer", "description": "Max results (default: 20)" },
34                    "ignore_gitignore": { "type": "boolean", "description": "Scan gitignored (needs role)" }
35                },
36                "required": ["pattern"]
37            }),
38        )
39    }
40
41    fn handle(
42        &self,
43        args: &Map<String, Value>,
44        ctx: &ToolContext,
45    ) -> Result<ToolOutput, ErrorData> {
46        let pattern = get_str(args, "pattern")
47            .ok_or_else(|| ErrorData::invalid_params("pattern is required", None))?;
48        let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
49            .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
50        // `include` is the canonical glob filter; `ext` is the deprecated alias
51        // (bare extension → `*.{ext}`). `include` wins when both are supplied.
52        let include =
53            get_str(args, "include").or_else(|| get_str(args, "ext").map(|e| ext_to_include(&e)));
54        let max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
55        let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
56
57        if no_gitignore
58            && let Err(e) = crate::core::io_boundary::ensure_ignore_gitignore_allowed("ctx_search")
59        {
60            return Ok(ToolOutput::simple(e));
61        }
62
63        let crp = ctx.crp_mode;
64        let respect = !no_gitignore;
65        let allow_secret_paths = crate::core::roles::active_role().io.allow_secret_paths;
66
67        if !resolved.is_multi {
68            return search_single(
69                &pattern,
70                &resolved.roots[0],
71                include.as_deref(),
72                max,
73                crp,
74                respect,
75                allow_secret_paths,
76            );
77        }
78
79        let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
80        let per_root_max = (max / resolved.roots.len()).max(5);
81        let mut combined = String::new();
82        let mut total_observed: usize = 0;
83        let mut total_sent: usize = 0;
84
85        for root in &resolved.roots {
86            let search_result = tokio::task::block_in_place(|| {
87                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
88                    crate::tools::ctx_search::handle(
89                        &pattern,
90                        root,
91                        include.as_deref(),
92                        per_root_max,
93                        crp,
94                        respect,
95                        allow_secret_paths,
96                    )
97                }))
98                .ok()
99            });
100
101            let Some(outcome) = search_result else {
102                combined.push_str(&format!("── {root} ──\nERROR: search panicked\n\n"));
103                continue;
104            };
105            let result = outcome.text;
106
107            if result.trim().is_empty() {
108                continue;
109            }
110
111            combined.push_str(&format!("── {root} ──\n{result}\n\n"));
112
113            if result.starts_with("ERROR:") {
114                continue;
115            }
116
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        // #685: `actual_tokens` is the *sent* output, not the saving — passing
133        // `saved` here recorded `actual=observed−sent` and `saved=sent` (both
134        // wrong). Align with cli_grep/cli_shell, which pass the output count.
135        crate::core::savings_ledger::record_tool_event("ctx_search", total_observed, total_sent);
136
137        Ok(ToolOutput {
138            text: final_out,
139            original_tokens: total_observed,
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
160    let search_result = tokio::task::block_in_place(|| {
161        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
162            crate::tools::ctx_search::handle(
163                pattern,
164                path,
165                include,
166                max,
167                crp,
168                respect_gitignore,
169                allow_secret_paths,
170            )
171        }));
172        match result {
173            Ok(r) => Ok(r),
174            Err(_) => Err("search task panicked"),
175        }
176    });
177
178    let outcome = match search_result {
179        Ok(r) => r,
180        Err(e) => {
181            return Err(ErrorData::internal_error(
182                format!("search task failed: {e}"),
183                None,
184            ));
185        }
186    };
187    let result = outcome.text;
188    // Observed tokens only — the modeled native-grep baseline stays out of
189    // dashboard/footer/ledger (GL #573); see the multi-root branch above.
190    let observed = outcome.observed_tokens;
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 = observed.saturating_sub(sent);
198    let final_out = crate::core::protocol::append_savings(&result, observed, sent);
199    // #685: pass the *sent* output as `actual_tokens` (not `saved`); see the
200    // multi-root branch above for why the previous arg was a double bug.
201    crate::core::savings_ledger::record_tool_event("ctx_search", observed, sent);
202
203    Ok(ToolOutput {
204        text: final_out,
205        original_tokens: observed,
206        saved_tokens: saved,
207        mode: None,
208        path: Some(path.to_string()),
209        changed: false,
210        shell_outcome: None,
211    })
212}
213
214/// Translate the deprecated `ext` parameter into an `include` glob.
215///
216/// The historical `ext` accepted a bare extension (`rs` or `.rs`) and matched it
217/// exactly; the equivalent glob is `*.{ext}` (the `glob` crate's `*` spans path
218/// separators, so it still matches at any depth, preserving the old behaviour).
219/// A value that already looks like a glob/path (`*`, `{`, `?`, `/`) is passed
220/// through untouched so any power user who put a pattern in `ext` keeps working.
221fn ext_to_include(ext: &str) -> String {
222    if ext.contains(['*', '{', '?', '/']) {
223        return ext.to_string();
224    }
225    let bare = ext.strip_prefix('.').unwrap_or(ext);
226    format!("*.{bare}")
227}
228
229#[cfg(test)]
230mod tests {
231    use super::ext_to_include;
232
233    #[test]
234    fn ext_alias_bare_extension_becomes_glob() {
235        assert_eq!(ext_to_include("rs"), "*.rs");
236        assert_eq!(ext_to_include("ts"), "*.ts");
237    }
238
239    #[test]
240    fn ext_alias_strips_leading_dot() {
241        assert_eq!(ext_to_include(".rs"), "*.rs");
242        assert_eq!(ext_to_include(".tsx"), "*.tsx");
243    }
244
245    #[test]
246    fn ext_alias_passes_through_glob_like_values() {
247        // Already a glob/path → keep verbatim, don't double-wrap.
248        assert_eq!(ext_to_include("*.rs"), "*.rs");
249        assert_eq!(ext_to_include("*.{rs,ts}"), "*.{rs,ts}");
250        assert_eq!(ext_to_include("src/**/*.tsx"), "src/**/*.tsx");
251    }
252}