Files
claw-code/rust/crates/tools/src/lib.rs
Yeachan-Heo 44e4758078 feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification

All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00

1016 lines
34 KiB
Rust

use regex::RegexBuilder;
use serde::Serialize;
use serde_json::{json, Value};
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolManifestEntry {
pub name: String,
pub source: ToolSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolSource {
Base,
Conditional,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ToolRegistry {
entries: Vec<ToolManifestEntry>,
}
impl ToolRegistry {
#[must_use]
pub fn new(entries: Vec<ToolManifestEntry>) -> Self {
Self { entries }
}
#[must_use]
pub fn entries(&self) -> &[ToolManifestEntry] {
&self.entries
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TextContent {
#[serde(rename = "type")]
pub kind: &'static str,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ToolResult {
pub content: Vec<TextContent>,
}
impl ToolResult {
#[must_use]
pub fn text(text: impl Into<String>) -> Self {
Self {
content: vec![TextContent {
kind: "text",
text: text.into(),
}],
}
}
}
#[derive(Debug)]
pub struct ToolError {
message: Cow<'static, str>,
}
impl ToolError {
#[must_use]
pub fn new(message: impl Into<Cow<'static, str>>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for ToolError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ToolError {}
impl From<io::Error> for ToolError {
fn from(value: io::Error) -> Self {
Self::new(value.to_string())
}
}
impl From<regex::Error> for ToolError {
fn from(value: regex::Error) -> Self {
Self::new(value.to_string())
}
}
pub trait Tool {
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn input_schema(&self) -> Value;
fn execute(&self, input: Value) -> Result<ToolResult, ToolError>;
}
fn schema_string(description: &str) -> Value {
json!({ "type": "string", "description": description })
}
fn schema_number(description: &str) -> Value {
json!({ "type": "number", "description": description })
}
fn schema_boolean(description: &str) -> Value {
json!({ "type": "boolean", "description": description })
}
fn strict_object(properties: &Value, required: &[&str]) -> Value {
json!({
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": false,
})
}
fn parse_string(input: &Value, key: &'static str) -> Result<String, ToolError> {
input
.get(key)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| ToolError::new(format!("missing or invalid string field: {key}")))
}
fn optional_string(input: &Value, key: &'static str) -> Result<Option<String>, ToolError> {
match input.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(value)) => Ok(Some(value.clone())),
Some(_) => Err(ToolError::new(format!("invalid string field: {key}"))),
}
}
fn optional_u64(input: &Value, key: &'static str) -> Result<Option<u64>, ToolError> {
match input.get(key) {
None | Some(Value::Null) => Ok(None),
Some(value) => value
.as_u64()
.ok_or_else(|| ToolError::new(format!("invalid numeric field: {key}")))
.map(Some),
}
}
fn optional_bool(input: &Value, key: &'static str) -> Result<Option<bool>, ToolError> {
match input.get(key) {
None | Some(Value::Null) => Ok(None),
Some(value) => value
.as_bool()
.ok_or_else(|| ToolError::new(format!("invalid boolean field: {key}")))
.map(Some),
}
}
fn absolute_path(path: &str) -> Result<PathBuf, ToolError> {
let expanded = if let Some(rest) = path.strip_prefix("~/") {
std::env::var_os("HOME")
.map(PathBuf::from)
.map_or_else(|| PathBuf::from(path), |home| home.join(rest))
} else {
PathBuf::from(path)
};
if expanded.is_absolute() {
Ok(expanded)
} else {
Err(ToolError::new(format!("path must be absolute: {path}")))
}
}
fn relative_display(path: &Path, base: &Path) -> String {
path.strip_prefix(base).ok().map_or_else(
|| path.to_string_lossy().replace('\\', "/"),
|value| value.to_string_lossy().replace('\\', "/"),
)
}
fn line_slice(content: &str, offset: Option<u64>, limit: Option<u64>) -> String {
let start = usize_from_u64(offset.unwrap_or(1).saturating_sub(1));
let lines: Vec<&str> = content.lines().collect();
let end = limit
.map_or(lines.len(), |limit| {
start.saturating_add(usize_from_u64(limit))
})
.min(lines.len());
if start >= lines.len() {
return String::new();
}
lines[start..end]
.iter()
.enumerate()
.map(|(index, line)| format!("{:>6}\t{line}", start + index + 1))
.collect::<Vec<_>>()
.join("\n")
}
fn parse_page_range(pages: &str) -> Result<(u64, u64), ToolError> {
if let Some((start, end)) = pages.split_once('-') {
let start = start
.trim()
.parse::<u64>()
.map_err(|_| ToolError::new("invalid pages parameter"))?;
let end = end
.trim()
.parse::<u64>()
.map_err(|_| ToolError::new("invalid pages parameter"))?;
if start == 0 || end < start {
return Err(ToolError::new("invalid pages parameter"));
}
Ok((start, end))
} else {
let page = pages
.trim()
.parse::<u64>()
.map_err(|_| ToolError::new("invalid pages parameter"))?;
if page == 0 {
return Err(ToolError::new("invalid pages parameter"));
}
Ok((page, page))
}
}
fn apply_single_edit(
original: &str,
old_string: &str,
new_string: &str,
replace_all: bool,
) -> Result<String, ToolError> {
if old_string == new_string {
return Err(ToolError::new(
"No changes to make: old_string and new_string are exactly the same.",
));
}
if old_string.is_empty() {
if original.is_empty() {
return Ok(new_string.to_owned());
}
return Err(ToolError::new(
"Cannot create new file - file already exists.",
));
}
let matches = original.matches(old_string).count();
if matches == 0 {
return Err(ToolError::new(format!(
"String to replace not found in file.\nString: {old_string}"
)));
}
if matches > 1 && !replace_all {
return Err(ToolError::new(format!(
"Found {matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: {old_string}"
)));
}
let updated = if replace_all {
original.replace(old_string, new_string)
} else {
original.replacen(old_string, new_string, 1)
};
Ok(updated)
}
fn diff_hunks(_before: &str, _after: &str) -> Value {
json!([])
}
fn usize_from_u64(value: u64) -> usize {
usize::try_from(value).unwrap_or(usize::MAX)
}
pub struct BashTool;
pub struct ReadTool;
pub struct WriteTool;
pub struct EditTool;
pub struct GlobTool;
pub struct GrepTool;
impl Tool for BashTool {
fn name(&self) -> &'static str {
"Bash"
}
fn description(&self) -> &'static str {
"Execute a shell command in the current environment."
}
fn input_schema(&self) -> Value {
strict_object(
&json!({
"command": schema_string("The command to execute"),
"timeout": schema_number("Optional timeout in milliseconds (max 600000)"),
"description": schema_string("Clear, concise description of what this command does in active voice. Never use words like \"complex\" or \"risk\" in the description - just describe what it does."),
"run_in_background": schema_boolean("Set to true to run this command in the background. Use Read to read the output later."),
"dangerouslyDisableSandbox": schema_boolean("Set this to true to dangerously override sandbox mode and run commands without sandboxing.")
}),
&["command"],
)
}
fn execute(&self, input: Value) -> Result<ToolResult, ToolError> {
let command = parse_string(&input, "command")?;
let _timeout = optional_u64(&input, "timeout")?;
let _description = optional_string(&input, "description")?;
let run_in_background = optional_bool(&input, "run_in_background")?.unwrap_or(false);
let _disable_sandbox = optional_bool(&input, "dangerouslyDisableSandbox")?.unwrap_or(false);
if run_in_background {
return Ok(ToolResult::text(
"Background execution is not supported in this runtime.",
));
}
let output = Command::new("bash").arg("-lc").arg(&command).output()?;
let mut rendered = String::new();
if !output.stdout.is_empty() {
rendered.push_str(&String::from_utf8_lossy(&output.stdout));
}
if !output.stderr.is_empty() {
if !rendered.is_empty() && !rendered.ends_with('\n') {
rendered.push('\n');
}
rendered.push_str(&String::from_utf8_lossy(&output.stderr));
}
if rendered.is_empty() {
rendered = if output.status.success() {
"Done".to_owned()
} else {
format!("Command exited with status {}", output.status)
};
}
Ok(ToolResult::text(rendered.trim_end().to_owned()))
}
}
impl Tool for ReadTool {
fn name(&self) -> &'static str {
"Read"
}
fn description(&self) -> &'static str {
"Read a file from the local filesystem."
}
fn input_schema(&self) -> Value {
strict_object(
&json!({
"file_path": schema_string("The absolute path to the file to read"),
"offset": json!({"type":"number","description":"The line number to start reading from. Only provide if the file is too large to read at once","minimum":0}),
"limit": json!({"type":"number","description":"The number of lines to read. Only provide if the file is too large to read at once.","exclusiveMinimum":0}),
"pages": schema_string("Page range for PDF files (e.g., \"1-5\", \"3\", \"10-20\"). Only applicable to PDF files. Maximum 20 pages per request.")
}),
&["file_path"],
)
}
fn execute(&self, input: Value) -> Result<ToolResult, ToolError> {
let file_path = parse_string(&input, "file_path")?;
let path = absolute_path(&file_path)?;
let offset = optional_u64(&input, "offset")?;
let limit = optional_u64(&input, "limit")?;
let pages = optional_string(&input, "pages")?;
let content = fs::read_to_string(&path)?;
if path.extension().and_then(|ext| ext.to_str()) == Some("pdf") {
if let Some(pages) = pages {
let (start, end) = parse_page_range(&pages)?;
return Ok(ToolResult::text(format!(
"PDF page extraction is not implemented in Rust yet for {}. Requested pages {}-{}.",
path.display(), start, end
)));
}
}
let rendered = if offset.is_some() || limit.is_some() {
line_slice(&content, offset, limit)
} else {
line_slice(&content, Some(1), None)
};
Ok(ToolResult::text(rendered))
}
}
impl Tool for WriteTool {
fn name(&self) -> &'static str {
"Write"
}
fn description(&self) -> &'static str {
"Write a file to the local filesystem."
}
fn input_schema(&self) -> Value {
strict_object(
&json!({
"file_path": schema_string("The absolute path to the file to write (must be absolute, not relative)"),
"content": schema_string("The content to write to the file")
}),
&["file_path", "content"],
)
}
fn execute(&self, input: Value) -> Result<ToolResult, ToolError> {
let file_path = parse_string(&input, "file_path")?;
let content = parse_string(&input, "content")?;
let path = absolute_path(&file_path)?;
let existed = path.exists();
let original = if existed {
Some(fs::read_to_string(&path)?)
} else {
None
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, &content)?;
let payload = json!({
"type": if existed { "update" } else { "create" },
"filePath": file_path,
"content": content,
"structuredPatch": diff_hunks(original.as_deref().unwrap_or(""), &content),
"originalFile": original,
"gitDiff": Value::Null,
});
Ok(ToolResult::text(payload.to_string()))
}
}
impl Tool for EditTool {
fn name(&self) -> &'static str {
"Edit"
}
fn description(&self) -> &'static str {
"A tool for editing files"
}
fn input_schema(&self) -> Value {
strict_object(
&json!({
"file_path": schema_string("The absolute path to the file to modify"),
"old_string": schema_string("The text to replace"),
"new_string": schema_string("The text to replace it with (must be different from old_string)"),
"replace_all": json!({"type":"boolean","description":"Replace all occurrences of old_string (default false)","default":false})
}),
&["file_path", "old_string", "new_string"],
)
}
fn execute(&self, input: Value) -> Result<ToolResult, ToolError> {
let file_path = parse_string(&input, "file_path")?;
let old_string = parse_string(&input, "old_string")?;
let new_string = parse_string(&input, "new_string")?;
let replace_all = optional_bool(&input, "replace_all")?.unwrap_or(false);
let path = absolute_path(&file_path)?;
let original = if path.exists() {
fs::read_to_string(&path)?
} else {
String::new()
};
let updated = apply_single_edit(&original, &old_string, &new_string, replace_all)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, &updated)?;
let payload = json!({
"filePath": file_path,
"oldString": old_string,
"newString": new_string,
"originalFile": original,
"structuredPatch": diff_hunks("", ""),
"userModified": false,
"replaceAll": replace_all,
"gitDiff": Value::Null,
});
Ok(ToolResult::text(payload.to_string()))
}
}
impl Tool for GlobTool {
fn name(&self) -> &'static str {
"Glob"
}
fn description(&self) -> &'static str {
"Fast file pattern matching tool"
}
fn input_schema(&self) -> Value {
strict_object(
&json!({
"pattern": schema_string("The glob pattern to match files against"),
"path": schema_string("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.")
}),
&["pattern"],
)
}
fn execute(&self, input: Value) -> Result<ToolResult, ToolError> {
let pattern = parse_string(&input, "pattern")?;
let root = optional_string(&input, "path")?
.map(|path| absolute_path(&path))
.transpose()?
.unwrap_or(std::env::current_dir()?);
let start = std::time::Instant::now();
let mut filenames = Vec::new();
visit_files(&root, &mut |path| {
let relative = relative_display(path, &root);
if glob_matches(&pattern, &relative) {
filenames.push(relative);
}
})?;
filenames.sort();
let truncated = filenames.len() > 100;
if truncated {
filenames.truncate(100);
}
let payload = json!({
"durationMs": start.elapsed().as_millis(),
"numFiles": filenames.len(),
"filenames": filenames,
"truncated": truncated,
});
Ok(ToolResult::text(payload.to_string()))
}
}
impl Tool for GrepTool {
fn name(&self) -> &'static str {
"Grep"
}
fn description(&self) -> &'static str {
"Fast content search tool"
}
fn input_schema(&self) -> Value {
strict_object(
&json!({
"pattern": schema_string("The regular expression pattern to search for in file contents"),
"path": schema_string("File or directory to search in (rg PATH). Defaults to current working directory."),
"glob": schema_string("Glob pattern to filter files (e.g. \"*.js\", \"*.{ts,tsx}\") - maps to rg --glob"),
"output_mode": {"type":"string","enum":["content","files_with_matches","count"],"description":"Output mode: \"content\" shows matching lines (supports -A/-B/-C context, -n line numbers, head_limit), \"files_with_matches\" shows file paths (supports head_limit), \"count\" shows match counts (supports head_limit). Defaults to \"files_with_matches\"."},
"-B": schema_number("Number of lines to show before each match (rg -B). Requires output_mode: \"content\", ignored otherwise."),
"-A": schema_number("Number of lines to show after each match (rg -A). Requires output_mode: \"content\", ignored otherwise."),
"-C": schema_number("Alias for context."),
"context": schema_number("Number of lines to show before and after each match (rg -C). Requires output_mode: \"content\", ignored otherwise."),
"-n": {"type":"boolean","description":"Show line numbers in output (rg -n). Requires output_mode: \"content\", ignored otherwise. Defaults to true."},
"-i": schema_boolean("Case insensitive search (rg -i)"),
"type": schema_string("File type to search (rg --type). Common types: js, py, rust, go, java, etc. More efficient than include for standard file types."),
"head_limit": schema_number("Limit output to first N lines/entries, equivalent to \"| head -N\". Works across all output modes: content (limits output lines), files_with_matches (limits file paths), count (limits count entries). Defaults to 250 when unspecified. Pass 0 for unlimited (use sparingly — large result sets waste context)."),
"offset": schema_number("Skip first N lines/entries before applying head_limit, equivalent to \"| tail -n +N | head -N\". Works across all output modes. Defaults to 0."),
"multiline": schema_boolean("Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false.")
}),
&["pattern"],
)
}
#[allow(clippy::too_many_lines)]
fn execute(&self, input: Value) -> Result<ToolResult, ToolError> {
let pattern = parse_string(&input, "pattern")?;
let root = optional_string(&input, "path")?
.map(|path| absolute_path(&path))
.transpose()?
.unwrap_or(std::env::current_dir()?);
let glob = optional_string(&input, "glob")?;
let output_mode = optional_string(&input, "output_mode")?
.unwrap_or_else(|| "files_with_matches".to_owned());
let context_before = usize_from_u64(optional_u64(&input, "-B")?.unwrap_or(0));
let context_after = usize_from_u64(optional_u64(&input, "-A")?.unwrap_or(0));
let context_c = optional_u64(&input, "-C")?;
let context = optional_u64(&input, "context")?;
let show_line_numbers = optional_bool(&input, "-n")?.unwrap_or(true);
let case_insensitive = optional_bool(&input, "-i")?.unwrap_or(false);
let file_type = optional_string(&input, "type")?;
let head_limit = optional_u64(&input, "head_limit")?;
let offset = usize_from_u64(optional_u64(&input, "offset")?.unwrap_or(0));
let _multiline = optional_bool(&input, "multiline")?.unwrap_or(false);
let shared_context = usize_from_u64(context.or(context_c).unwrap_or(0));
let regex = RegexBuilder::new(&pattern)
.case_insensitive(case_insensitive)
.build()?;
let mut matched_lines = Vec::new();
let mut files_with_matches = Vec::new();
let mut count_lines = Vec::new();
let mut total_matches = 0usize;
let candidates = collect_files(&root)?;
for path in candidates {
let relative = relative_display(&path, &root);
if !matches_file_filter(&relative, glob.as_deref(), file_type.as_deref()) {
continue;
}
let Ok(file_content) = fs::read_to_string(&path) else {
continue;
};
let lines: Vec<&str> = file_content.lines().collect();
let mut matched_indexes = Vec::new();
let mut file_match_count = 0usize;
for (index, line) in lines.iter().enumerate() {
if regex.is_match(line) {
matched_indexes.push(index);
file_match_count += regex.find_iter(line).count().max(1);
}
}
if matched_indexes.is_empty() {
continue;
}
total_matches += file_match_count;
files_with_matches.push(relative.clone());
count_lines.push(format!("{relative}:{file_match_count}"));
if output_mode == "content" {
let mut included = BTreeSet::new();
for index in matched_indexes {
let before = if shared_context > 0 {
shared_context
} else {
context_before
};
let after = if shared_context > 0 {
shared_context
} else {
context_after
};
let start = index.saturating_sub(before);
let end = (index + after).min(lines.len().saturating_sub(1));
for line_index in start..=end {
included.insert(line_index);
}
}
for line_index in included {
if show_line_numbers {
matched_lines.push(format!(
"{relative}:{}:{}",
line_index + 1,
lines[line_index]
));
} else {
matched_lines.push(format!("{relative}:{}", lines[line_index]));
}
}
}
}
let rendered = match output_mode.as_str() {
"content" => {
let limited = apply_offset_limit(matched_lines, head_limit, offset);
json!({
"mode": "content",
"numFiles": 0,
"filenames": [],
"content": limited.join("\n"),
"numLines": limited.len(),
"appliedOffset": (offset > 0).then_some(offset),
})
}
"count" => {
let limited = apply_offset_limit(count_lines, head_limit, offset);
json!({
"mode": "count",
"numFiles": files_with_matches.len(),
"filenames": [],
"content": limited.join("\n"),
"numMatches": total_matches,
"appliedOffset": (offset > 0).then_some(offset),
})
}
_ => {
files_with_matches.sort();
let limited = apply_offset_limit(files_with_matches, head_limit, offset);
json!({
"mode": "files_with_matches",
"numFiles": limited.len(),
"filenames": limited,
"appliedOffset": (offset > 0).then_some(offset),
})
}
};
Ok(ToolResult::text(rendered.to_string()))
}
}
fn apply_offset_limit<T>(items: Vec<T>, limit: Option<u64>, offset: usize) -> Vec<T> {
let mut iter = items.into_iter().skip(offset);
match limit {
Some(0) | None => iter.collect(),
Some(limit) => iter.by_ref().take(usize_from_u64(limit)).collect(),
}
}
fn collect_files(root: &Path) -> Result<Vec<PathBuf>, ToolError> {
let mut files = Vec::new();
if root.is_file() {
files.push(root.to_path_buf());
return Ok(files);
}
visit_files(root, &mut |path| files.push(path.to_path_buf()))?;
Ok(files)
}
fn visit_files(root: &Path, visitor: &mut dyn FnMut(&Path)) -> Result<(), ToolError> {
if root.is_file() {
visitor(root);
return Ok(());
}
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
visit_files(&path, visitor)?;
} else if path.is_file() {
visitor(&path);
}
}
Ok(())
}
fn matches_file_filter(relative: &str, glob: Option<&str>, file_type: Option<&str>) -> bool {
let glob_ok = glob.is_none_or(|pattern| {
split_glob_patterns(pattern)
.into_iter()
.any(|single| glob_matches(&single, relative))
});
let type_ok = file_type.is_none_or(|kind| path_matches_type(relative, kind));
glob_ok && type_ok
}
fn split_glob_patterns(patterns: &str) -> Vec<String> {
let mut result = Vec::new();
for raw in patterns.split_whitespace() {
if raw.contains('{') && raw.contains('}') {
result.push(raw.to_owned());
} else {
result.extend(
raw.split(',')
.filter(|part| !part.is_empty())
.map(ToOwned::to_owned),
);
}
}
result
}
fn path_matches_type(relative: &str, kind: &str) -> bool {
let extension = Path::new(relative)
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default();
matches!(
(kind, extension),
("rust", "rs")
| ("js", "js")
| ("ts", "ts")
| ("tsx", "tsx")
| ("py", "py")
| ("go", "go")
| ("java", "java")
| ("json", "json")
| ("md", "md")
)
}
fn glob_matches(pattern: &str, path: &str) -> bool {
expand_braces(pattern)
.into_iter()
.any(|expanded| glob_match_one(&expanded, path))
}
fn expand_braces(pattern: &str) -> Vec<String> {
let Some(start) = pattern.find('{') else {
return vec![pattern.to_owned()];
};
let Some(end_rel) = pattern[start..].find('}') else {
return vec![pattern.to_owned()];
};
let end = start + end_rel;
let prefix = &pattern[..start];
let suffix = &pattern[end + 1..];
pattern[start + 1..end]
.split(',')
.flat_map(|middle| expand_braces(&format!("{prefix}{middle}{suffix}")))
.collect()
}
fn glob_match_one(pattern: &str, path: &str) -> bool {
let pattern = pattern.replace('\\', "/");
let path = path.replace('\\', "/");
let pattern_parts: Vec<&str> = pattern.split('/').collect();
let path_parts: Vec<&str> = path.split('/').collect();
glob_match_parts(&pattern_parts, &path_parts)
}
fn glob_match_parts(pattern: &[&str], path: &[&str]) -> bool {
if pattern.is_empty() {
return path.is_empty();
}
if pattern[0] == "**" {
if glob_match_parts(&pattern[1..], path) {
return true;
}
if !path.is_empty() {
return glob_match_parts(pattern, &path[1..]);
}
return false;
}
if path.is_empty() {
return false;
}
if segment_matches(pattern[0], path[0]) {
return glob_match_parts(&pattern[1..], &path[1..]);
}
false
}
fn segment_matches(pattern: &str, text: &str) -> bool {
let p = pattern.as_bytes();
let t = text.as_bytes();
let (mut pi, mut ti, mut star_idx, mut match_idx) = (0usize, 0usize, None, 0usize);
while ti < t.len() {
if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
pi += 1;
ti += 1;
} else if pi < p.len() && p[pi] == b'*' {
star_idx = Some(pi);
match_idx = ti;
pi += 1;
} else if let Some(star) = star_idx {
pi = star + 1;
match_idx += 1;
ti = match_idx;
} else {
return false;
}
}
while pi < p.len() && p[pi] == b'*' {
pi += 1;
}
pi == p.len()
}
#[must_use]
pub fn core_tools() -> Vec<Box<dyn Tool>> {
vec![
Box::new(BashTool),
Box::new(ReadTool),
Box::new(WriteTool),
Box::new(EditTool),
Box::new(GlobTool),
Box::new(GrepTool),
]
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
fn text(result: &ToolResult) -> String {
result.content[0].text.clone()
}
#[test]
fn manifests_core_tools() {
let names: Vec<_> = core_tools().into_iter().map(|tool| tool.name()).collect();
assert_eq!(names, vec!["Bash", "Read", "Write", "Edit", "Glob", "Grep"]);
}
#[test]
fn bash_executes_command() {
let result = BashTool
.execute(json!({ "command": "printf 'hello'" }))
.unwrap();
assert_eq!(text(&result), "hello");
}
#[test]
fn read_schema_matches_expected_keys() {
let schema = ReadTool.input_schema();
let properties = schema["properties"].as_object().unwrap();
assert_eq!(schema["required"], json!(["file_path"]));
assert!(properties.contains_key("file_path"));
assert!(properties.contains_key("offset"));
assert!(properties.contains_key("limit"));
assert!(properties.contains_key("pages"));
}
#[test]
fn read_returns_numbered_lines() {
let dir = tempdir().unwrap();
let path = dir.path().join("sample.txt");
fs::write(&path, "alpha\nbeta\ngamma\n").unwrap();
let result = ReadTool
.execute(json!({ "file_path": path.to_string_lossy(), "offset": 2, "limit": 1 }))
.unwrap();
assert_eq!(text(&result), " 2\tbeta");
}
#[test]
fn write_creates_file_and_reports_create() {
let dir = tempdir().unwrap();
let path = dir.path().join("new.txt");
let result = WriteTool
.execute(json!({ "file_path": path.to_string_lossy(), "content": "hello" }))
.unwrap();
let payload: Value = serde_json::from_str(&text(&result)).unwrap();
assert_eq!(payload["type"], "create");
assert_eq!(fs::read_to_string(path).unwrap(), "hello");
}
#[test]
fn edit_replaces_single_match() {
let dir = tempdir().unwrap();
let path = dir.path().join("edit.txt");
fs::write(&path, "hello world\n").unwrap();
let result = EditTool
.execute(json!({
"file_path": path.to_string_lossy(),
"old_string": "world",
"new_string": "rust",
"replace_all": false
}))
.unwrap();
let payload: Value = serde_json::from_str(&text(&result)).unwrap();
assert_eq!(payload["replaceAll"], false);
assert_eq!(fs::read_to_string(path).unwrap(), "hello rust\n");
}
#[test]
fn glob_finds_matching_files() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src/nested")).unwrap();
fs::write(dir.path().join("src/lib.rs"), "").unwrap();
fs::write(dir.path().join("src/nested/main.rs"), "").unwrap();
fs::write(dir.path().join("README.md"), "").unwrap();
let result = GlobTool
.execute(json!({ "pattern": "**/*.rs", "path": dir.path().to_string_lossy() }))
.unwrap();
let payload: Value = serde_json::from_str(&text(&result)).unwrap();
assert_eq!(payload["numFiles"], 2);
assert_eq!(
payload["filenames"],
json!(["src/lib.rs", "src/nested/main.rs"])
);
}
#[test]
fn grep_supports_file_list_mode() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("a.rs"), "fn main() {}\nlet alpha = 1;\n").unwrap();
fs::write(dir.path().join("b.txt"), "alpha\nalpha\n").unwrap();
let result = GrepTool
.execute(json!({
"pattern": "alpha",
"path": dir.path().to_string_lossy(),
"output_mode": "files_with_matches"
}))
.unwrap();
let payload: Value = serde_json::from_str(&text(&result)).unwrap();
assert_eq!(payload["filenames"], json!(["a.rs", "b.txt"]));
}
#[test]
fn grep_supports_content_and_count_modes() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("a.rs"), "alpha\nbeta\nalpha\n").unwrap();
let content = GrepTool
.execute(json!({
"pattern": "alpha",
"path": dir.path().to_string_lossy(),
"output_mode": "content",
"-n": true
}))
.unwrap();
let content_payload: Value = serde_json::from_str(&text(&content)).unwrap();
assert_eq!(content_payload["numLines"], 2);
assert!(content_payload["content"]
.as_str()
.unwrap()
.contains("a.rs:1:alpha"));
let count = GrepTool
.execute(json!({
"pattern": "alpha",
"path": dir.path().to_string_lossy(),
"output_mode": "count"
}))
.unwrap();
let count_payload: Value = serde_json::from_str(&text(&count)).unwrap();
assert_eq!(count_payload["numMatches"], 2);
assert_eq!(count_payload["content"], "a.rs:2");
}
}