use std::path::{Path, PathBuf};
use kernel::{
AxCode, AxError, CostTier, Effect, Payload, RenderIntent, Temporal, Tool, ToolCall, ToolMeta,
ToolName, ToolOutcome,
};
use serde_json::{Map, Value};
mod descent;
mod hit;
use super::chosen_path::{self, ReadBound, Walked};
use crate::elision::{self, Elided};
use descent::{Entry, Step, admissible};
use hit::{Limit, context_block};
const MATCH_CAP: usize = 64;
const FILE_BYTE_CAP: u64 = 1 << 20;
const UNREAD_SHOWN: usize = 16;
const CONTEXT_CAP: u8 = 4;
struct Predicate {
needle: String,
context: usize,
}
struct Findings {
hits: Vec<Value>,
spent: usize,
unreadable: u64,
unread: Vec<Value>,
stopped: Option<Limit>,
}
impl Findings {
fn could_not_look(&mut self, rel: &str, why: String) {
self.unreadable = self.unreadable.saturating_add(1);
if self.unread.len() < UNREAD_SHOWN {
self.unread
.push(serde_json::json!({ "path": rel, "why": why }));
}
}
}
pub struct SearchTool {
city_root: PathBuf,
bound: ReadBound,
meta: ToolMeta,
}
impl SearchTool {
pub const NAME: &'static str = "search";
pub fn new(city_root: &Path, bound: ReadBound) -> Result<SearchTool, AxError> {
Ok(SearchTool {
city_root: city_root.to_path_buf(),
bound,
meta: ToolMeta {
name: ToolName::parse(Self::NAME)?,
disclosure: "Find a substring in the files under one path. Literal text, no \
regular expressions; the line number it reports is the offset \
`read` continues from. A `grep` through `exec` returns the same \
text without the version and without the offset."
.to_owned(),
params: Payload::of(&serde_json::json!({
"type": "object",
"properties": {
"text": { "type": "string", "description": "the substring to look for, \
matched literally: this tool has no pattern syntax" },
"path": { "type": "string", "description": "a city-relative directory \
or file to search under; everything you may read when omitted" },
"context": { "type": "integer", "description": "how many lines to \
show each side of a hit, 0 to 4, default 0" },
},
"required": ["text"],
}))?,
effect: Effect::Read,
cost_tier: CostTier::Light,
timeout: None,
render: RenderIntent::Generic,
temporal: Temporal::Timeless,
},
})
}
fn predicate(call: &ToolCall) -> Result<Predicate, AxError> {
let args = call.args.as_map();
let needle = args
.get("text")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.ok_or_else(|| {
AxError::failure(
AxCode::InvalidArgs,
"search",
"missing non-empty string argument `text`",
)
.with_recovery(
"pass the literal text to look for; an empty predicate would match every \
line in the city",
)
})?;
let context = match args.get("context") {
None => 0,
Some(value) => {
let asked = value.as_u64().ok_or_else(|| {
AxError::failure(
AxCode::InvalidArgs,
"search",
"`context` is not a whole number of lines",
)
.with_recovery("pass `context` as an integer from 0 to 4, or leave it out")
})?;
usize::from(u8::try_from(asked).map_or(CONTEXT_CAP, |fits| fits.min(CONTEXT_CAP)))
}
};
Ok(Predicate {
needle: needle.to_owned(),
context,
})
}
fn start(&self, call: &ToolCall) -> Result<(PathBuf, String), AxError> {
match call.args.as_map().get("path").and_then(Value::as_str) {
None => Ok((self.city_root.clone(), String::new())),
Some(asked) => {
let spelled = chosen_path::within_city(&self.city_root, asked, "search")?;
if spelled.is_empty() {
return Ok((self.city_root.clone(), String::new()));
}
let addr = chosen_path::admit(&spelled, "search", &*self.bound)?;
let located = chosen_path::land(&self.city_root, &addr, "search", &*self.bound)?;
let chosen_path::Located::Present(path) = located else {
return Err(AxError::failure(
AxCode::InvalidArgs,
"search",
format!("{asked} is not a place in this city"),
)
.with_recovery("name a path that exists, or leave `path` out to search all"));
};
Ok((path, addr.as_str().to_owned()))
}
}
}
fn walk(&self, from: (PathBuf, String), looking: &Predicate) -> Findings {
let mut found = Findings {
hits: Vec::new(),
spent: 0,
unreadable: 0,
unread: Vec::new(),
stopped: None,
};
let mut pending = vec![Step::Visit(from.0, from.1)];
while let Some(step) = pending.pop() {
if found.stopped.is_some() {
break;
}
let (path, rel) = match step {
Step::Visit(path, rel) => (path, rel),
Step::Unread { child, why } => {
found.could_not_look(&child, why);
continue;
}
};
let path = match chosen_path::walked(&self.city_root, path, &rel, &*self.bound) {
Ok(Walked::Directory(path)) => path,
Ok(Walked::File(path)) => {
self.scan_file(&path, &rel, looking, &mut found);
continue;
}
Ok(Walked::Passed) => continue,
Err(unresolved) => {
found.could_not_look(&rel, unresolved.to_string());
continue;
}
};
match sorted_entries(&path) {
Ok(entries) => {
for name in entries.into_iter().rev() {
match admissible(&rel, &name, &*self.bound) {
Entry::Descend(child) => {
pending.push(Step::Visit(path.join(&name), child));
}
Entry::Unread { child, why } => {
pending.push(Step::Unread { child, why })
}
Entry::Passed => {}
}
}
}
Err(err) => found.could_not_look(&rel, format!("would not open: {err}")),
}
}
found
}
fn scan_file(&self, path: &Path, rel: &str, looking: &Predicate, found: &mut Findings) {
let opened = std::fs::metadata(path).and_then(|meta| {
if meta.len() > FILE_BYTE_CAP {
Ok(None)
} else {
std::fs::read(path).map(Some)
}
});
let bytes = match opened {
Ok(Some(bytes)) => bytes,
Ok(None) => {
let why = format!(
"larger than the {FILE_BYTE_CAP} bytes one search reads; `read` it an \
interval at a time"
);
return found.could_not_look(rel, why);
}
Err(err) => return found.could_not_look(rel, format!("would not open: {err}")),
};
let Ok(body) = String::from_utf8(bytes) else {
return;
};
let lines: Vec<&str> = body.lines().collect();
for (at, line) in lines.iter().enumerate() {
if !line.contains(&looking.needle) {
continue;
}
if found.hits.len() >= MATCH_CAP {
found.stopped = Some(Limit::Hits);
return;
}
let block = context_block(&lines, at, looking);
let budget = kernel::consts_policy::INTERVAL_CAP_BYTES;
let fits = found.spent.saturating_add(block.len()) <= budget;
if !fits && !found.hits.is_empty() {
found.stopped = Some(Limit::Bytes);
return;
}
let text = if fits {
block
} else {
found.stopped = Some(Limit::Bytes);
let keep = budget.saturating_sub(elision::marker_room(block.len()));
elision::splice(&block, keep, block.len(), Elided::Tail).text
};
found.spent = found.spent.saturating_add(text.len());
found
.hits
.push(serde_json::json!({ "path": rel, "line": at, "text": text }));
if found.stopped.is_some() {
return;
}
}
}
}
fn sorted_entries(path: &Path) -> std::io::Result<Vec<String>> {
let mut names: Vec<String> = std::fs::read_dir(path)?
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
Ok(names)
}
impl Tool for SearchTool {
fn meta(&self) -> &ToolMeta {
&self.meta
}
fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
if call.name != self.meta.name {
return Err(AxError::failure(
AxCode::InvalidArgs,
"search",
format!("call routed to the wrong tool: {}", call.name.as_str()),
)
.with_recovery(format!(
"call `{}`, the name this tool answers to",
self.meta.name.as_str()
)));
}
let looking = SearchTool::predicate(call)?;
let found = self.walk(self.start(call)?, &looking);
let mut out = Map::new();
out.insert("count".to_owned(), Value::Number(found.hits.len().into()));
out.insert("truncated".to_owned(), Value::Bool(found.stopped.is_some()));
if let Some(limit) = found.stopped {
out.insert("stopped".to_owned(), Value::String(limit.said()));
}
out.insert(
"unreadable".to_owned(),
Value::Number(found.unreadable.into()),
);
out.insert("unread".to_owned(), Value::Array(found.unread));
out.insert("matches".to_owned(), Value::Array(found.hits));
Ok(ToolOutcome {
result: Payload::new(out)?,
attachments: Vec::new(),
})
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
reason = "test code"
)]
mod tests;