use rig::tool::{Tool, ToolContext};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{
fs::read_to_string,
path::{Path, PathBuf},
};
#[derive(Clone, Debug, Deserialize, Serialize)]
struct SearchMatch {
location: super::Location,
preview: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SearchResults {
total_entries: usize,
returned_entries: usize,
truncated: bool,
entries: Vec<SearchMatch>,
}
#[derive(Deserialize, Serialize)]
pub struct SearchTextArgs {
path: PathBuf,
text: String,
#[serde(default)]
start: Option<usize>,
}
#[derive(Deserialize, Serialize)]
pub struct SearchText {
base_dir: PathBuf,
max_results: usize,
}
impl SearchText {
pub fn new(base_dir: &Path, max_results: usize) -> Self {
Self {
base_dir: base_dir.to_path_buf(),
max_results,
}
}
}
impl Tool for SearchText {
const NAME: &'static str = "search_text";
type Error = super::ToolError;
type Args = SearchTextArgs;
type Output = SearchResults;
async fn call(
&self,
_context: &mut ToolContext,
args: Self::Args,
) -> Result<Self::Output, Self::Error> {
let path = super::allowed_canonical_path(&args.path, &self.base_dir)?;
let entries = super::list_dir(&path, true)?;
let matches: Vec<SearchMatch> = entries
.entries
.iter()
.filter_map(|e| match e.kind {
super::DirectoryEntryKind::File => {
read_to_string(&e.path).ok().map(|v| (e.path.clone(), v))
}
super::DirectoryEntryKind::Directory => None,
})
.map(|(path, content)| {
(
path,
content
.lines()
.enumerate()
.filter(|(_nr, line)| line.contains(&args.text))
.map(|(nr, line)| (nr, line.to_owned()))
.collect::<Vec<(usize, String)>>(),
)
})
.filter(|(_path, content)| !content.is_empty())
.flat_map(|(path, content)| {
content
.iter()
.map(move |(nr, line)| SearchMatch {
location: super::Location {
path: PathBuf::from(path.clone()),
line: *nr,
},
preview: line.clone(),
})
.collect::<Vec<_>>()
})
.collect();
let start = args.start.unwrap_or_default();
let requested_matches = &matches[start..];
let final_matches = if requested_matches.len() > 100 {
&requested_matches[..100]
} else {
requested_matches
};
Ok(SearchResults {
total_entries: matches.len(),
returned_entries: final_matches.len(),
truncated: final_matches.len() != matches.len(),
entries: final_matches.to_vec(),
})
}
fn description(&self) -> String {
format!(
"Search for text in all files within the given directory and its subdirectories. \
Returns matches with file paths, line numbers, and content previews. \
The search is case-sensitive. Results are clipped at {} entries.",
self.max_results
)
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the directory to search in, \
relative to workspace root directory. \
Use an empty string for the workspace root directory. \
Search is recursive."
},
"text": {
"type": "string",
"description": "The exact text string to search for. \
The search is case-sensitive."
},
"start": {
"type": "integer",
"description": "Optional, zero-based index of the first result to return. \
Use this to paginate through results if a previous call was truncated."
},
},
"required": ["path", "text"]
})
}
fn map_error(&self, error: Self::Error) -> rig::tool::ToolExecutionError {
rig::tool::ToolExecutionError::from_error(error)
}
}
#[tokio::test]
async fn test_search_text() {
let base_dir = std::env::current_dir().expect("Cannot get current directory");
let st = SearchText::new(&base_dir, 100);
let result = st
.call(
&mut ToolContext::default(),
SearchTextArgs {
path: PathBuf::from("src"),
text: "unwrap()".to_string(),
start: None,
},
)
.await
.unwrap();
println!("Result: {:?}", result);
}