| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015 |
- 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");
- }
- }
|