1use std::path::{Path, PathBuf};
24
25use kernel::{
26 AxCode, AxError, CostTier, Effect, Payload, RenderIntent, Temporal, Tool, ToolCall, ToolMeta,
27 ToolName, ToolOutcome,
28};
29use serde_json::{Map, Value};
30
31mod descent;
32mod hit;
33
34use super::chosen_path::{self, ReadBound, Walked};
35use crate::elision::{self, Elided};
36use descent::{Entry, Step, admissible};
37use hit::{Limit, context_block};
38
39const MATCH_CAP: usize = 64;
42
43const FILE_BYTE_CAP: u64 = 1 << 20;
48
49const UNREAD_SHOWN: usize = 16;
52
53const CONTEXT_CAP: u8 = 4;
55
56struct Predicate {
59 needle: String,
60 context: usize,
61}
62
63struct Findings {
65 hits: Vec<Value>,
66 spent: usize,
70 unreadable: u64,
71 unread: Vec<Value>,
73 stopped: Option<Limit>,
75}
76
77impl Findings {
78 fn could_not_look(&mut self, rel: &str, why: String) {
81 self.unreadable = self.unreadable.saturating_add(1);
82 if self.unread.len() < UNREAD_SHOWN {
83 self.unread
84 .push(serde_json::json!({ "path": rel, "why": why }));
85 }
86 }
87}
88
89pub struct SearchTool {
91 city_root: PathBuf,
92 bound: ReadBound,
95 meta: ToolMeta,
96}
97
98impl SearchTool {
99 pub const NAME: &'static str = "search";
102
103 pub fn new(city_root: &Path, bound: ReadBound) -> Result<SearchTool, AxError> {
107 Ok(SearchTool {
108 city_root: city_root.to_path_buf(),
109 bound,
110 meta: ToolMeta {
111 name: ToolName::parse(Self::NAME)?,
112 disclosure: "Find a substring in the files under one path. Literal text, no \
113 regular expressions; the line number it reports is the offset \
114 `read` continues from. A `grep` through `exec` returns the same \
115 text without the version and without the offset."
116 .to_owned(),
117 params: Payload::of(&serde_json::json!({
118 "type": "object",
119 "properties": {
120 "text": { "type": "string", "description": "the substring to look for, \
121 matched literally: this tool has no pattern syntax" },
122 "path": { "type": "string", "description": "a city-relative directory \
123 or file to search under; everything you may read when omitted" },
124 "context": { "type": "integer", "description": "how many lines to \
125 show each side of a hit, 0 to 4, default 0" },
126 },
127 "required": ["text"],
128 }))?,
129 effect: Effect::Read,
130 cost_tier: CostTier::Light,
131 timeout: None,
132 render: RenderIntent::Generic,
133 temporal: Temporal::Timeless,
134 },
135 })
136 }
137
138 fn predicate(call: &ToolCall) -> Result<Predicate, AxError> {
140 let args = call.args.as_map();
141 let needle = args
142 .get("text")
143 .and_then(Value::as_str)
144 .filter(|text| !text.is_empty())
145 .ok_or_else(|| {
146 AxError::failure(
147 AxCode::InvalidArgs,
148 "search",
149 "missing non-empty string argument `text`",
150 )
151 .with_recovery(
152 "pass the literal text to look for; an empty predicate would match every \
153 line in the city",
154 )
155 })?;
156 let context = match args.get("context") {
157 None => 0,
158 Some(value) => {
159 let asked = value.as_u64().ok_or_else(|| {
160 AxError::failure(
161 AxCode::InvalidArgs,
162 "search",
163 "`context` is not a whole number of lines",
164 )
165 .with_recovery("pass `context` as an integer from 0 to 4, or leave it out")
166 })?;
167 usize::from(u8::try_from(asked).map_or(CONTEXT_CAP, |fits| fits.min(CONTEXT_CAP)))
170 }
171 };
172 Ok(Predicate {
173 needle: needle.to_owned(),
174 context,
175 })
176 }
177
178 fn start(&self, call: &ToolCall) -> Result<(PathBuf, String), AxError> {
181 match call.args.as_map().get("path").and_then(Value::as_str) {
182 None => Ok((self.city_root.clone(), String::new())),
183 Some(asked) => {
184 let spelled = chosen_path::within_city(&self.city_root, asked, "search")?;
185 if spelled.is_empty() {
186 return Ok((self.city_root.clone(), String::new()));
187 }
188 let addr = chosen_path::admit(&spelled, "search", &*self.bound)?;
189 let located = chosen_path::land(&self.city_root, &addr, "search", &*self.bound)?;
190 let chosen_path::Located::Present(path) = located else {
191 return Err(AxError::failure(
192 AxCode::InvalidArgs,
193 "search",
194 format!("{asked} is not a place in this city"),
195 )
196 .with_recovery("name a path that exists, or leave `path` out to search all"));
197 };
198 Ok((path, addr.as_str().to_owned()))
199 }
200 }
201 }
202
203 fn walk(&self, from: (PathBuf, String), looking: &Predicate) -> Findings {
206 let mut found = Findings {
207 hits: Vec::new(),
208 spent: 0,
209 unreadable: 0,
210 unread: Vec::new(),
211 stopped: None,
212 };
213 let mut pending = vec![Step::Visit(from.0, from.1)];
214 while let Some(step) = pending.pop() {
215 if found.stopped.is_some() {
216 break;
217 }
218 let (path, rel) = match step {
219 Step::Visit(path, rel) => (path, rel),
220 Step::Unread { child, why } => {
221 found.could_not_look(&child, why);
222 continue;
223 }
224 };
225 let path = match chosen_path::walked(&self.city_root, path, &rel, &*self.bound) {
226 Ok(Walked::Directory(path)) => path,
227 Ok(Walked::File(path)) => {
228 self.scan_file(&path, &rel, looking, &mut found);
229 continue;
230 }
231 Ok(Walked::Passed) => continue,
232 Err(unresolved) => {
233 found.could_not_look(&rel, unresolved.to_string());
234 continue;
235 }
236 };
237 match sorted_entries(&path) {
238 Ok(entries) => {
239 for name in entries.into_iter().rev() {
242 match admissible(&rel, &name, &*self.bound) {
243 Entry::Descend(child) => {
244 pending.push(Step::Visit(path.join(&name), child));
245 }
246 Entry::Unread { child, why } => {
247 pending.push(Step::Unread { child, why })
248 }
249 Entry::Passed => {}
250 }
251 }
252 }
253 Err(err) => found.could_not_look(&rel, format!("would not open: {err}")),
254 }
255 }
256 found
257 }
258
259 fn scan_file(&self, path: &Path, rel: &str, looking: &Predicate, found: &mut Findings) {
261 let opened = std::fs::metadata(path).and_then(|meta| {
262 if meta.len() > FILE_BYTE_CAP {
263 Ok(None)
264 } else {
265 std::fs::read(path).map(Some)
266 }
267 });
268 let bytes = match opened {
269 Ok(Some(bytes)) => bytes,
270 Ok(None) => {
271 let why = format!(
272 "larger than the {FILE_BYTE_CAP} bytes one search reads; `read` it an \
273 interval at a time"
274 );
275 return found.could_not_look(rel, why);
276 }
277 Err(err) => return found.could_not_look(rel, format!("would not open: {err}")),
278 };
279 let Ok(body) = String::from_utf8(bytes) else {
283 return;
284 };
285 let lines: Vec<&str> = body.lines().collect();
286 for (at, line) in lines.iter().enumerate() {
287 if !line.contains(&looking.needle) {
288 continue;
289 }
290 if found.hits.len() >= MATCH_CAP {
291 found.stopped = Some(Limit::Hits);
292 return;
293 }
294 let block = context_block(&lines, at, looking);
301 let budget = kernel::consts_policy::INTERVAL_CAP_BYTES;
302 let fits = found.spent.saturating_add(block.len()) <= budget;
303 if !fits && !found.hits.is_empty() {
304 found.stopped = Some(Limit::Bytes);
305 return;
306 }
307 let text = if fits {
311 block
312 } else {
313 found.stopped = Some(Limit::Bytes);
314 let keep = budget.saturating_sub(elision::marker_room(block.len()));
315 elision::splice(&block, keep, block.len(), Elided::Tail).text
316 };
317 found.spent = found.spent.saturating_add(text.len());
318 found
319 .hits
320 .push(serde_json::json!({ "path": rel, "line": at, "text": text }));
321 if found.stopped.is_some() {
322 return;
323 }
324 }
325 }
326}
327
328fn sorted_entries(path: &Path) -> std::io::Result<Vec<String>> {
330 let mut names: Vec<String> = std::fs::read_dir(path)?
331 .filter_map(|entry| entry.ok())
332 .map(|entry| entry.file_name().to_string_lossy().into_owned())
333 .collect();
334 names.sort();
335 Ok(names)
336}
337
338impl Tool for SearchTool {
339 fn meta(&self) -> &ToolMeta {
340 &self.meta
341 }
342
343 fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
344 if call.name != self.meta.name {
345 return Err(AxError::failure(
346 AxCode::InvalidArgs,
347 "search",
348 format!("call routed to the wrong tool: {}", call.name.as_str()),
349 )
350 .with_recovery(format!(
351 "call `{}`, the name this tool answers to",
352 self.meta.name.as_str()
353 )));
354 }
355 let looking = SearchTool::predicate(call)?;
356 let found = self.walk(self.start(call)?, &looking);
357 let mut out = Map::new();
358 out.insert("count".to_owned(), Value::Number(found.hits.len().into()));
359 out.insert("truncated".to_owned(), Value::Bool(found.stopped.is_some()));
360 if let Some(limit) = found.stopped {
361 out.insert("stopped".to_owned(), Value::String(limit.said()));
362 }
363 out.insert(
367 "unreadable".to_owned(),
368 Value::Number(found.unreadable.into()),
369 );
370 out.insert("unread".to_owned(), Value::Array(found.unread));
371 out.insert("matches".to_owned(), Value::Array(found.hits));
372 Ok(ToolOutcome {
373 result: Payload::new(out)?,
374 attachments: Vec::new(),
375 })
376 }
377}
378
379#[cfg(test)]
380#[allow(
381 clippy::unwrap_used,
382 clippy::expect_used,
383 clippy::panic,
384 clippy::indexing_slicing,
385 reason = "test code"
386)]
387mod tests;