lean_ctx/tools/registered/
ctx_search.rs1use 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 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 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, 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 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 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
214fn 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 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}