use rig::tool::{Tool, ToolContext};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{cmp, fs::read_to_string, path::PathBuf, sync::Arc};
use tokio::sync::Mutex;
use unicode_width::UnicodeWidthStr;
use crate::common::{
package_resolver::PackageResolver,
path_traversal::{DirectoryEntry, DirectoryListing, Location, PathTraversal},
};
#[derive(Clone, Debug, Deserialize, Serialize)]
struct SearchMatch {
location: Location,
preview: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SearchResults {
total_entries: usize,
returned_entries: usize,
truncated: bool,
entries: Vec<SearchMatch>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SearchTextArgs {
path: PathBuf,
text: String,
#[serde(default)]
start: Option<usize>,
#[serde(default)]
root: Option<String>,
}
pub struct SearchText {
package_resolver: Arc<Mutex<PackageResolver>>,
path_traversal: Arc<Mutex<PathTraversal>>,
has_pacmans: bool, ignore_description: String,
max_results: usize,
max_line_length: usize,
}
impl SearchText {
pub async fn new(
package_resolver: Arc<Mutex<PackageResolver>>,
path_traversal: Arc<Mutex<PathTraversal>>,
max_results: usize,
max_line_length: usize,
) -> Self {
let has_pacmans = !package_resolver.lock().await.list_pacmans().is_empty();
let ignore_description = {
let pt = path_traversal.lock().await;
super::ignore_description(pt.observe_hidden(), pt.observe_ignore())
};
Self {
package_resolver,
path_traversal,
has_pacmans,
ignore_description,
max_results,
max_line_length,
}
}
}
impl Tool for SearchText {
const NAME: &'static str = "search_text";
type Error = super::ToolError;
type Args = SearchTextArgs;
type Output = SearchResults;
#[tracing::instrument(skip(self, _context))]
async fn call(
&self,
_context: &mut ToolContext,
args: Self::Args,
) -> Result<Self::Output, Self::Error> {
let package_resolver = self.package_resolver.lock().await;
let path_traversal = self.path_traversal.lock().await;
let package_root = package_resolver
.package_root(args.root.as_deref())?
.map(PathBuf::from);
let path = path_traversal.absolute(&args.path, package_root.as_deref())?;
let entries = if path.is_dir() {
path_traversal.list_dir(&args.path, false, package_root.as_deref())?
} else {
DirectoryListing {
entries: vec![DirectoryEntry {
path: path.to_string_lossy().to_string(),
kind: super::DirectoryEntryKind::File,
}],
total_entries: 1,
truncated: false,
}
};
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(|(nr, line)| {
let line_content = if line.width() <= self.max_line_length {
line.clone()
} else {
let position = line
.find(&args.text)
.expect("Search pattern should be in preview");
let start_position = position.saturating_sub(self.max_line_length / 2);
let end_position =
cmp::min(position + self.max_line_length / 2, line.width());
let prefix = if start_position == 0 {
String::new()
} else {
format!("[clipped {} characters])", start_position)
};
let postfix = if end_position == line.width() {
String::new()
} else {
format!("[clipped {} characters])", line.width() - end_position)
};
let clipped_line = line
.chars()
.skip(start_position)
.take(end_position - start_position)
.collect::<String>();
prefix + &clipped_line + &postfix
};
SearchMatch {
location: Location {
path: PathBuf::from(path.clone()),
line: *nr,
},
preview: line_content,
}
})
.collect::<Vec<_>>()
})
.collect();
let start = args.start.unwrap_or_default();
let requested_matches = &matches[start..];
let final_matches = if requested_matches.len() > self.max_results {
&requested_matches[..self.max_results]
} 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 the given file, or recursively 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 truncated at {} entries. \
Content previews are truncated at {} characters. \
If results are truncated, use the `start` parameter to paginate through remaining results.",
self.ignore_description, self.max_results, self.max_line_length
)
}
fn parameters(&self) -> serde_json::Value {
let path_description = format!(
"Relative path to the file or directory to search *within* the {}. \
Use an empty string `\"\"` to search the entire {}.
**Never** include {}absolute paths in this parameter.
Search is recursive.",
if self.has_pacmans {
"selected root namespace"
} else {
"workspace root"
},
if self.has_pacmans {
"selected scope"
} else {
"workspace"
},
if self.has_pacmans {
"package identifiers (like `cargo:...`) or "
} else {
""
}
);
let mut params = json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": path_description
},
"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"]
});
if self.has_pacmans {
if let Some(props) = params["properties"].as_object_mut() {
props.insert(
"root".to_string(),
json!({
"type": "string",
"description": "Identifier of a package or dependency (e.g., `cargo:crate@version`) to set as the search scope. \
If omitted, defaults to the workspace root. This selects *which* codebase to look in, while `path` navigates inside it." }),
);
}
}
params
}
fn map_error(&self, error: Self::Error) -> rig::tool::ToolExecutionError {
rig::tool::ToolExecutionError::from_error(error)
}
}