use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use locode_host::{FsError, Host, rg_program};
use locode_tools::{Tool, ToolCtx, ToolError, ToolKind, ToolOutput};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
const GLOB_LIMIT: usize = 100;
pub(crate) struct ClaudeGlob {
host: Arc<Host>,
}
impl ClaudeGlob {
pub(crate) fn new(host: Arc<Host>) -> Self {
Self { host }
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct GlobArgs {
#[schemars(description = "The glob pattern to match files against")]
pattern: String,
#[schemars(
description = "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - simply omit it for the default behavior. Must be a valid directory path if provided."
)]
#[serde(default)]
path: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct GlobOutput {
filenames: Vec<String>,
num_files: usize,
truncated: bool,
#[serde(skip)]
body: String,
}
impl ToolOutput for GlobOutput {
fn to_prompt_text(&self) -> String {
self.body.clone()
}
}
#[async_trait]
impl Tool for ClaudeGlob {
type Args = GlobArgs;
type Output = GlobOutput;
fn kind(&self) -> ToolKind {
ToolKind::Glob
}
#[allow(clippy::unnecessary_literal_bound)] fn description(&self) -> &str {
include_str!("descriptions/glob.md")
}
async fn run(&self, ctx: &ToolCtx, args: GlobArgs) -> Result<Self::Output, ToolError> {
let search_dir: PathBuf = match args.path.as_deref().filter(|p| !p.is_empty()) {
Some(p) => {
let resolved = self
.host
.resolve_in_jail(&ctx.cwd, Path::new(p))
.await
.map_err(|e| ToolError::Respond(e.to_string()))?;
match self.host.read_dir(&ctx.cwd, Path::new(p)).await {
Ok(_) => resolved,
Err(e) if matches!(&e, FsError::Io { source, .. } if source.kind() == std::io::ErrorKind::NotFound) =>
{
return Err(ToolError::Respond(format!(
"Directory does not exist: {p}. Note: your current working directory is {}.",
ctx.cwd.display()
)));
}
Err(_) => {
return Err(ToolError::Respond(format!("Path is not a directory: {p}")));
}
}
}
None => ctx.cwd.clone(),
};
let rg_args: Vec<String> = vec![
"--files".into(),
"--glob".into(),
args.pattern.clone(),
"--sort=modified".into(),
"--no-ignore".into(),
"--hidden".into(),
];
let out = self
.host
.run_capture(&rg_program(), &rg_args, &search_dir, None, &ctx.cancel)
.await
.map_err(|e| {
ToolError::Respond(format!(
"ripgrep (rg) could not be run ({e}); install rg or set LOCODE_RG_PATH."
))
})?;
match out.exit_code {
Some(0) => {}
Some(1) => {
return Ok(GlobOutput {
filenames: Vec::new(),
num_files: 0,
truncated: false,
body: "No files found".to_string(),
});
}
_ => {
return Err(ToolError::Respond(format!(
"glob failed: {}",
if out.stderr.is_empty() {
"ripgrep error".to_owned()
} else {
out.stderr
}
)));
}
}
let all: Vec<String> = out
.stdout
.lines()
.filter(|l| !l.is_empty())
.map(|line| {
let abs = search_dir.join(line);
abs.strip_prefix(&ctx.cwd)
.unwrap_or(&abs)
.display()
.to_string()
})
.collect();
let num_files = all.len();
let truncated = num_files > GLOB_LIMIT;
let filenames: Vec<String> = all.into_iter().take(GLOB_LIMIT).collect();
let body = if filenames.is_empty() {
"No files found".to_string()
} else if truncated {
format!(
"{}\n(Results are truncated. Consider using a more specific path or pattern.)",
filenames.join("\n")
)
} else {
filenames.join("\n")
};
Ok(GlobOutput {
filenames,
num_files,
truncated,
body,
})
}
}
#[cfg(test)]
mod tests {
#[test]
fn description_is_the_pinned_glob_md() {
let desc = include_str!("descriptions/glob.md");
assert_eq!(desc.len(), 371, "glob.md byte length changed");
assert!(desc.starts_with("- Fast file pattern matching tool"));
assert!(desc.trim_end().ends_with("use the Agent tool instead"));
}
}