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