lib.rs 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015
  1. use regex::RegexBuilder;
  2. use serde::Serialize;
  3. use serde_json::{json, Value};
  4. use std::borrow::Cow;
  5. use std::collections::BTreeSet;
  6. use std::fmt;
  7. use std::fs;
  8. use std::io;
  9. use std::path::{Path, PathBuf};
  10. use std::process::Command;
  11. #[derive(Debug, Clone, PartialEq, Eq)]
  12. pub struct ToolManifestEntry {
  13. pub name: String,
  14. pub source: ToolSource,
  15. }
  16. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  17. pub enum ToolSource {
  18. Base,
  19. Conditional,
  20. }
  21. #[derive(Debug, Clone, Default, PartialEq, Eq)]
  22. pub struct ToolRegistry {
  23. entries: Vec<ToolManifestEntry>,
  24. }
  25. impl ToolRegistry {
  26. #[must_use]
  27. pub fn new(entries: Vec<ToolManifestEntry>) -> Self {
  28. Self { entries }
  29. }
  30. #[must_use]
  31. pub fn entries(&self) -> &[ToolManifestEntry] {
  32. &self.entries
  33. }
  34. }
  35. #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
  36. pub struct TextContent {
  37. #[serde(rename = "type")]
  38. pub kind: &'static str,
  39. pub text: String,
  40. }
  41. #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
  42. pub struct ToolResult {
  43. pub content: Vec<TextContent>,
  44. }
  45. impl ToolResult {
  46. #[must_use]
  47. pub fn text(text: impl Into<String>) -> Self {
  48. Self {
  49. content: vec![TextContent {
  50. kind: "text",
  51. text: text.into(),
  52. }],
  53. }
  54. }
  55. }
  56. #[derive(Debug)]
  57. pub struct ToolError {
  58. message: Cow<'static, str>,
  59. }
  60. impl ToolError {
  61. #[must_use]
  62. pub fn new(message: impl Into<Cow<'static, str>>) -> Self {
  63. Self {
  64. message: message.into(),
  65. }
  66. }
  67. }
  68. impl fmt::Display for ToolError {
  69. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  70. f.write_str(&self.message)
  71. }
  72. }
  73. impl std::error::Error for ToolError {}
  74. impl From<io::Error> for ToolError {
  75. fn from(value: io::Error) -> Self {
  76. Self::new(value.to_string())
  77. }
  78. }
  79. impl From<regex::Error> for ToolError {
  80. fn from(value: regex::Error) -> Self {
  81. Self::new(value.to_string())
  82. }
  83. }
  84. pub trait Tool {
  85. fn name(&self) -> &'static str;
  86. fn description(&self) -> &'static str;
  87. fn input_schema(&self) -> Value;
  88. fn execute(&self, input: Value) -> Result<ToolResult, ToolError>;
  89. }
  90. fn schema_string(description: &str) -> Value {
  91. json!({ "type": "string", "description": description })
  92. }
  93. fn schema_number(description: &str) -> Value {
  94. json!({ "type": "number", "description": description })
  95. }
  96. fn schema_boolean(description: &str) -> Value {
  97. json!({ "type": "boolean", "description": description })
  98. }
  99. fn strict_object(properties: &Value, required: &[&str]) -> Value {
  100. json!({
  101. "type": "object",
  102. "properties": properties,
  103. "required": required,
  104. "additionalProperties": false,
  105. })
  106. }
  107. fn parse_string(input: &Value, key: &'static str) -> Result<String, ToolError> {
  108. input
  109. .get(key)
  110. .and_then(Value::as_str)
  111. .map(ToOwned::to_owned)
  112. .ok_or_else(|| ToolError::new(format!("missing or invalid string field: {key}")))
  113. }
  114. fn optional_string(input: &Value, key: &'static str) -> Result<Option<String>, ToolError> {
  115. match input.get(key) {
  116. None | Some(Value::Null) => Ok(None),
  117. Some(Value::String(value)) => Ok(Some(value.clone())),
  118. Some(_) => Err(ToolError::new(format!("invalid string field: {key}"))),
  119. }
  120. }
  121. fn optional_u64(input: &Value, key: &'static str) -> Result<Option<u64>, ToolError> {
  122. match input.get(key) {
  123. None | Some(Value::Null) => Ok(None),
  124. Some(value) => value
  125. .as_u64()
  126. .ok_or_else(|| ToolError::new(format!("invalid numeric field: {key}")))
  127. .map(Some),
  128. }
  129. }
  130. fn optional_bool(input: &Value, key: &'static str) -> Result<Option<bool>, ToolError> {
  131. match input.get(key) {
  132. None | Some(Value::Null) => Ok(None),
  133. Some(value) => value
  134. .as_bool()
  135. .ok_or_else(|| ToolError::new(format!("invalid boolean field: {key}")))
  136. .map(Some),
  137. }
  138. }
  139. fn absolute_path(path: &str) -> Result<PathBuf, ToolError> {
  140. let expanded = if let Some(rest) = path.strip_prefix("~/") {
  141. std::env::var_os("HOME")
  142. .map(PathBuf::from)
  143. .map_or_else(|| PathBuf::from(path), |home| home.join(rest))
  144. } else {
  145. PathBuf::from(path)
  146. };
  147. if expanded.is_absolute() {
  148. Ok(expanded)
  149. } else {
  150. Err(ToolError::new(format!("path must be absolute: {path}")))
  151. }
  152. }
  153. fn relative_display(path: &Path, base: &Path) -> String {
  154. path.strip_prefix(base).ok().map_or_else(
  155. || path.to_string_lossy().replace('\\', "/"),
  156. |value| value.to_string_lossy().replace('\\', "/"),
  157. )
  158. }
  159. fn line_slice(content: &str, offset: Option<u64>, limit: Option<u64>) -> String {
  160. let start = usize_from_u64(offset.unwrap_or(1).saturating_sub(1));
  161. let lines: Vec<&str> = content.lines().collect();
  162. let end = limit
  163. .map_or(lines.len(), |limit| {
  164. start.saturating_add(usize_from_u64(limit))
  165. })
  166. .min(lines.len());
  167. if start >= lines.len() {
  168. return String::new();
  169. }
  170. lines[start..end]
  171. .iter()
  172. .enumerate()
  173. .map(|(index, line)| format!("{:>6}\t{line}", start + index + 1))
  174. .collect::<Vec<_>>()
  175. .join("\n")
  176. }
  177. fn parse_page_range(pages: &str) -> Result<(u64, u64), ToolError> {
  178. if let Some((start, end)) = pages.split_once('-') {
  179. let start = start
  180. .trim()
  181. .parse::<u64>()
  182. .map_err(|_| ToolError::new("invalid pages parameter"))?;
  183. let end = end
  184. .trim()
  185. .parse::<u64>()
  186. .map_err(|_| ToolError::new("invalid pages parameter"))?;
  187. if start == 0 || end < start {
  188. return Err(ToolError::new("invalid pages parameter"));
  189. }
  190. Ok((start, end))
  191. } else {
  192. let page = pages
  193. .trim()
  194. .parse::<u64>()
  195. .map_err(|_| ToolError::new("invalid pages parameter"))?;
  196. if page == 0 {
  197. return Err(ToolError::new("invalid pages parameter"));
  198. }
  199. Ok((page, page))
  200. }
  201. }
  202. fn apply_single_edit(
  203. original: &str,
  204. old_string: &str,
  205. new_string: &str,
  206. replace_all: bool,
  207. ) -> Result<String, ToolError> {
  208. if old_string == new_string {
  209. return Err(ToolError::new(
  210. "No changes to make: old_string and new_string are exactly the same.",
  211. ));
  212. }
  213. if old_string.is_empty() {
  214. if original.is_empty() {
  215. return Ok(new_string.to_owned());
  216. }
  217. return Err(ToolError::new(
  218. "Cannot create new file - file already exists.",
  219. ));
  220. }
  221. let matches = original.matches(old_string).count();
  222. if matches == 0 {
  223. return Err(ToolError::new(format!(
  224. "String to replace not found in file.\nString: {old_string}"
  225. )));
  226. }
  227. if matches > 1 && !replace_all {
  228. return Err(ToolError::new(format!(
  229. "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}"
  230. )));
  231. }
  232. let updated = if replace_all {
  233. original.replace(old_string, new_string)
  234. } else {
  235. original.replacen(old_string, new_string, 1)
  236. };
  237. Ok(updated)
  238. }
  239. fn diff_hunks(_before: &str, _after: &str) -> Value {
  240. json!([])
  241. }
  242. fn usize_from_u64(value: u64) -> usize {
  243. usize::try_from(value).unwrap_or(usize::MAX)
  244. }
  245. pub struct BashTool;
  246. pub struct ReadTool;
  247. pub struct WriteTool;
  248. pub struct EditTool;
  249. pub struct GlobTool;
  250. pub struct GrepTool;
  251. impl Tool for BashTool {
  252. fn name(&self) -> &'static str {
  253. "Bash"
  254. }
  255. fn description(&self) -> &'static str {
  256. "Execute a shell command in the current environment."
  257. }
  258. fn input_schema(&self) -> Value {
  259. strict_object(
  260. &json!({
  261. "command": schema_string("The command to execute"),
  262. "timeout": schema_number("Optional timeout in milliseconds (max 600000)"),
  263. "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."),
  264. "run_in_background": schema_boolean("Set to true to run this command in the background. Use Read to read the output later."),
  265. "dangerouslyDisableSandbox": schema_boolean("Set this to true to dangerously override sandbox mode and run commands without sandboxing.")
  266. }),
  267. &["command"],
  268. )
  269. }
  270. fn execute(&self, input: Value) -> Result<ToolResult, ToolError> {
  271. let command = parse_string(&input, "command")?;
  272. let _timeout = optional_u64(&input, "timeout")?;
  273. let _description = optional_string(&input, "description")?;
  274. let run_in_background = optional_bool(&input, "run_in_background")?.unwrap_or(false);
  275. let _disable_sandbox = optional_bool(&input, "dangerouslyDisableSandbox")?.unwrap_or(false);
  276. if run_in_background {
  277. return Ok(ToolResult::text(
  278. "Background execution is not supported in this runtime.",
  279. ));
  280. }
  281. let output = Command::new("bash").arg("-lc").arg(&command).output()?;
  282. let mut rendered = String::new();
  283. if !output.stdout.is_empty() {
  284. rendered.push_str(&String::from_utf8_lossy(&output.stdout));
  285. }
  286. if !output.stderr.is_empty() {
  287. if !rendered.is_empty() && !rendered.ends_with('\n') {
  288. rendered.push('\n');
  289. }
  290. rendered.push_str(&String::from_utf8_lossy(&output.stderr));
  291. }
  292. if rendered.is_empty() {
  293. rendered = if output.status.success() {
  294. "Done".to_owned()
  295. } else {
  296. format!("Command exited with status {}", output.status)
  297. };
  298. }
  299. Ok(ToolResult::text(rendered.trim_end().to_owned()))
  300. }
  301. }
  302. impl Tool for ReadTool {
  303. fn name(&self) -> &'static str {
  304. "Read"
  305. }
  306. fn description(&self) -> &'static str {
  307. "Read a file from the local filesystem."
  308. }
  309. fn input_schema(&self) -> Value {
  310. strict_object(
  311. &json!({
  312. "file_path": schema_string("The absolute path to the file to read"),
  313. "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}),
  314. "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}),
  315. "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.")
  316. }),
  317. &["file_path"],
  318. )
  319. }
  320. fn execute(&self, input: Value) -> Result<ToolResult, ToolError> {
  321. let file_path = parse_string(&input, "file_path")?;
  322. let path = absolute_path(&file_path)?;
  323. let offset = optional_u64(&input, "offset")?;
  324. let limit = optional_u64(&input, "limit")?;
  325. let pages = optional_string(&input, "pages")?;
  326. let content = fs::read_to_string(&path)?;
  327. if path.extension().and_then(|ext| ext.to_str()) == Some("pdf") {
  328. if let Some(pages) = pages {
  329. let (start, end) = parse_page_range(&pages)?;
  330. return Ok(ToolResult::text(format!(
  331. "PDF page extraction is not implemented in Rust yet for {}. Requested pages {}-{}.",
  332. path.display(), start, end
  333. )));
  334. }
  335. }
  336. let rendered = if offset.is_some() || limit.is_some() {
  337. line_slice(&content, offset, limit)
  338. } else {
  339. line_slice(&content, Some(1), None)
  340. };
  341. Ok(ToolResult::text(rendered))
  342. }
  343. }
  344. impl Tool for WriteTool {
  345. fn name(&self) -> &'static str {
  346. "Write"
  347. }
  348. fn description(&self) -> &'static str {
  349. "Write a file to the local filesystem."
  350. }
  351. fn input_schema(&self) -> Value {
  352. strict_object(
  353. &json!({
  354. "file_path": schema_string("The absolute path to the file to write (must be absolute, not relative)"),
  355. "content": schema_string("The content to write to the file")
  356. }),
  357. &["file_path", "content"],
  358. )
  359. }
  360. fn execute(&self, input: Value) -> Result<ToolResult, ToolError> {
  361. let file_path = parse_string(&input, "file_path")?;
  362. let content = parse_string(&input, "content")?;
  363. let path = absolute_path(&file_path)?;
  364. let existed = path.exists();
  365. let original = if existed {
  366. Some(fs::read_to_string(&path)?)
  367. } else {
  368. None
  369. };
  370. if let Some(parent) = path.parent() {
  371. fs::create_dir_all(parent)?;
  372. }
  373. fs::write(&path, &content)?;
  374. let payload = json!({
  375. "type": if existed { "update" } else { "create" },
  376. "filePath": file_path,
  377. "content": content,
  378. "structuredPatch": diff_hunks(original.as_deref().unwrap_or(""), &content),
  379. "originalFile": original,
  380. "gitDiff": Value::Null,
  381. });
  382. Ok(ToolResult::text(payload.to_string()))
  383. }
  384. }
  385. impl Tool for EditTool {
  386. fn name(&self) -> &'static str {
  387. "Edit"
  388. }
  389. fn description(&self) -> &'static str {
  390. "A tool for editing files"
  391. }
  392. fn input_schema(&self) -> Value {
  393. strict_object(
  394. &json!({
  395. "file_path": schema_string("The absolute path to the file to modify"),
  396. "old_string": schema_string("The text to replace"),
  397. "new_string": schema_string("The text to replace it with (must be different from old_string)"),
  398. "replace_all": json!({"type":"boolean","description":"Replace all occurrences of old_string (default false)","default":false})
  399. }),
  400. &["file_path", "old_string", "new_string"],
  401. )
  402. }
  403. fn execute(&self, input: Value) -> Result<ToolResult, ToolError> {
  404. let file_path = parse_string(&input, "file_path")?;
  405. let old_string = parse_string(&input, "old_string")?;
  406. let new_string = parse_string(&input, "new_string")?;
  407. let replace_all = optional_bool(&input, "replace_all")?.unwrap_or(false);
  408. let path = absolute_path(&file_path)?;
  409. let original = if path.exists() {
  410. fs::read_to_string(&path)?
  411. } else {
  412. String::new()
  413. };
  414. let updated = apply_single_edit(&original, &old_string, &new_string, replace_all)?;
  415. if let Some(parent) = path.parent() {
  416. fs::create_dir_all(parent)?;
  417. }
  418. fs::write(&path, &updated)?;
  419. let payload = json!({
  420. "filePath": file_path,
  421. "oldString": old_string,
  422. "newString": new_string,
  423. "originalFile": original,
  424. "structuredPatch": diff_hunks("", ""),
  425. "userModified": false,
  426. "replaceAll": replace_all,
  427. "gitDiff": Value::Null,
  428. });
  429. Ok(ToolResult::text(payload.to_string()))
  430. }
  431. }
  432. impl Tool for GlobTool {
  433. fn name(&self) -> &'static str {
  434. "Glob"
  435. }
  436. fn description(&self) -> &'static str {
  437. "Fast file pattern matching tool"
  438. }
  439. fn input_schema(&self) -> Value {
  440. strict_object(
  441. &json!({
  442. "pattern": schema_string("The glob pattern to match files against"),
  443. "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.")
  444. }),
  445. &["pattern"],
  446. )
  447. }
  448. fn execute(&self, input: Value) -> Result<ToolResult, ToolError> {
  449. let pattern = parse_string(&input, "pattern")?;
  450. let root = optional_string(&input, "path")?
  451. .map(|path| absolute_path(&path))
  452. .transpose()?
  453. .unwrap_or(std::env::current_dir()?);
  454. let start = std::time::Instant::now();
  455. let mut filenames = Vec::new();
  456. visit_files(&root, &mut |path| {
  457. let relative = relative_display(path, &root);
  458. if glob_matches(&pattern, &relative) {
  459. filenames.push(relative);
  460. }
  461. })?;
  462. filenames.sort();
  463. let truncated = filenames.len() > 100;
  464. if truncated {
  465. filenames.truncate(100);
  466. }
  467. let payload = json!({
  468. "durationMs": start.elapsed().as_millis(),
  469. "numFiles": filenames.len(),
  470. "filenames": filenames,
  471. "truncated": truncated,
  472. });
  473. Ok(ToolResult::text(payload.to_string()))
  474. }
  475. }
  476. impl Tool for GrepTool {
  477. fn name(&self) -> &'static str {
  478. "Grep"
  479. }
  480. fn description(&self) -> &'static str {
  481. "Fast content search tool"
  482. }
  483. fn input_schema(&self) -> Value {
  484. strict_object(
  485. &json!({
  486. "pattern": schema_string("The regular expression pattern to search for in file contents"),
  487. "path": schema_string("File or directory to search in (rg PATH). Defaults to current working directory."),
  488. "glob": schema_string("Glob pattern to filter files (e.g. \"*.js\", \"*.{ts,tsx}\") - maps to rg --glob"),
  489. "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\"."},
  490. "-B": schema_number("Number of lines to show before each match (rg -B). Requires output_mode: \"content\", ignored otherwise."),
  491. "-A": schema_number("Number of lines to show after each match (rg -A). Requires output_mode: \"content\", ignored otherwise."),
  492. "-C": schema_number("Alias for context."),
  493. "context": schema_number("Number of lines to show before and after each match (rg -C). Requires output_mode: \"content\", ignored otherwise."),
  494. "-n": {"type":"boolean","description":"Show line numbers in output (rg -n). Requires output_mode: \"content\", ignored otherwise. Defaults to true."},
  495. "-i": schema_boolean("Case insensitive search (rg -i)"),
  496. "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."),
  497. "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)."),
  498. "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."),
  499. "multiline": schema_boolean("Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false.")
  500. }),
  501. &["pattern"],
  502. )
  503. }
  504. #[allow(clippy::too_many_lines)]
  505. fn execute(&self, input: Value) -> Result<ToolResult, ToolError> {
  506. let pattern = parse_string(&input, "pattern")?;
  507. let root = optional_string(&input, "path")?
  508. .map(|path| absolute_path(&path))
  509. .transpose()?
  510. .unwrap_or(std::env::current_dir()?);
  511. let glob = optional_string(&input, "glob")?;
  512. let output_mode = optional_string(&input, "output_mode")?
  513. .unwrap_or_else(|| "files_with_matches".to_owned());
  514. let context_before = usize_from_u64(optional_u64(&input, "-B")?.unwrap_or(0));
  515. let context_after = usize_from_u64(optional_u64(&input, "-A")?.unwrap_or(0));
  516. let context_c = optional_u64(&input, "-C")?;
  517. let context = optional_u64(&input, "context")?;
  518. let show_line_numbers = optional_bool(&input, "-n")?.unwrap_or(true);
  519. let case_insensitive = optional_bool(&input, "-i")?.unwrap_or(false);
  520. let file_type = optional_string(&input, "type")?;
  521. let head_limit = optional_u64(&input, "head_limit")?;
  522. let offset = usize_from_u64(optional_u64(&input, "offset")?.unwrap_or(0));
  523. let _multiline = optional_bool(&input, "multiline")?.unwrap_or(false);
  524. let shared_context = usize_from_u64(context.or(context_c).unwrap_or(0));
  525. let regex = RegexBuilder::new(&pattern)
  526. .case_insensitive(case_insensitive)
  527. .build()?;
  528. let mut matched_lines = Vec::new();
  529. let mut files_with_matches = Vec::new();
  530. let mut count_lines = Vec::new();
  531. let mut total_matches = 0usize;
  532. let candidates = collect_files(&root)?;
  533. for path in candidates {
  534. let relative = relative_display(&path, &root);
  535. if !matches_file_filter(&relative, glob.as_deref(), file_type.as_deref()) {
  536. continue;
  537. }
  538. let Ok(file_content) = fs::read_to_string(&path) else {
  539. continue;
  540. };
  541. let lines: Vec<&str> = file_content.lines().collect();
  542. let mut matched_indexes = Vec::new();
  543. let mut file_match_count = 0usize;
  544. for (index, line) in lines.iter().enumerate() {
  545. if regex.is_match(line) {
  546. matched_indexes.push(index);
  547. file_match_count += regex.find_iter(line).count().max(1);
  548. }
  549. }
  550. if matched_indexes.is_empty() {
  551. continue;
  552. }
  553. total_matches += file_match_count;
  554. files_with_matches.push(relative.clone());
  555. count_lines.push(format!("{relative}:{file_match_count}"));
  556. if output_mode == "content" {
  557. let mut included = BTreeSet::new();
  558. for index in matched_indexes {
  559. let before = if shared_context > 0 {
  560. shared_context
  561. } else {
  562. context_before
  563. };
  564. let after = if shared_context > 0 {
  565. shared_context
  566. } else {
  567. context_after
  568. };
  569. let start = index.saturating_sub(before);
  570. let end = (index + after).min(lines.len().saturating_sub(1));
  571. for line_index in start..=end {
  572. included.insert(line_index);
  573. }
  574. }
  575. for line_index in included {
  576. if show_line_numbers {
  577. matched_lines.push(format!(
  578. "{relative}:{}:{}",
  579. line_index + 1,
  580. lines[line_index]
  581. ));
  582. } else {
  583. matched_lines.push(format!("{relative}:{}", lines[line_index]));
  584. }
  585. }
  586. }
  587. }
  588. let rendered = match output_mode.as_str() {
  589. "content" => {
  590. let limited = apply_offset_limit(matched_lines, head_limit, offset);
  591. json!({
  592. "mode": "content",
  593. "numFiles": 0,
  594. "filenames": [],
  595. "content": limited.join("\n"),
  596. "numLines": limited.len(),
  597. "appliedOffset": (offset > 0).then_some(offset),
  598. })
  599. }
  600. "count" => {
  601. let limited = apply_offset_limit(count_lines, head_limit, offset);
  602. json!({
  603. "mode": "count",
  604. "numFiles": files_with_matches.len(),
  605. "filenames": [],
  606. "content": limited.join("\n"),
  607. "numMatches": total_matches,
  608. "appliedOffset": (offset > 0).then_some(offset),
  609. })
  610. }
  611. _ => {
  612. files_with_matches.sort();
  613. let limited = apply_offset_limit(files_with_matches, head_limit, offset);
  614. json!({
  615. "mode": "files_with_matches",
  616. "numFiles": limited.len(),
  617. "filenames": limited,
  618. "appliedOffset": (offset > 0).then_some(offset),
  619. })
  620. }
  621. };
  622. Ok(ToolResult::text(rendered.to_string()))
  623. }
  624. }
  625. fn apply_offset_limit<T>(items: Vec<T>, limit: Option<u64>, offset: usize) -> Vec<T> {
  626. let mut iter = items.into_iter().skip(offset);
  627. match limit {
  628. Some(0) | None => iter.collect(),
  629. Some(limit) => iter.by_ref().take(usize_from_u64(limit)).collect(),
  630. }
  631. }
  632. fn collect_files(root: &Path) -> Result<Vec<PathBuf>, ToolError> {
  633. let mut files = Vec::new();
  634. if root.is_file() {
  635. files.push(root.to_path_buf());
  636. return Ok(files);
  637. }
  638. visit_files(root, &mut |path| files.push(path.to_path_buf()))?;
  639. Ok(files)
  640. }
  641. fn visit_files(root: &Path, visitor: &mut dyn FnMut(&Path)) -> Result<(), ToolError> {
  642. if root.is_file() {
  643. visitor(root);
  644. return Ok(());
  645. }
  646. for entry in fs::read_dir(root)? {
  647. let entry = entry?;
  648. let path = entry.path();
  649. if path.is_dir() {
  650. visit_files(&path, visitor)?;
  651. } else if path.is_file() {
  652. visitor(&path);
  653. }
  654. }
  655. Ok(())
  656. }
  657. fn matches_file_filter(relative: &str, glob: Option<&str>, file_type: Option<&str>) -> bool {
  658. let glob_ok = glob.is_none_or(|pattern| {
  659. split_glob_patterns(pattern)
  660. .into_iter()
  661. .any(|single| glob_matches(&single, relative))
  662. });
  663. let type_ok = file_type.is_none_or(|kind| path_matches_type(relative, kind));
  664. glob_ok && type_ok
  665. }
  666. fn split_glob_patterns(patterns: &str) -> Vec<String> {
  667. let mut result = Vec::new();
  668. for raw in patterns.split_whitespace() {
  669. if raw.contains('{') && raw.contains('}') {
  670. result.push(raw.to_owned());
  671. } else {
  672. result.extend(
  673. raw.split(',')
  674. .filter(|part| !part.is_empty())
  675. .map(ToOwned::to_owned),
  676. );
  677. }
  678. }
  679. result
  680. }
  681. fn path_matches_type(relative: &str, kind: &str) -> bool {
  682. let extension = Path::new(relative)
  683. .extension()
  684. .and_then(|value| value.to_str())
  685. .unwrap_or_default();
  686. matches!(
  687. (kind, extension),
  688. ("rust", "rs")
  689. | ("js", "js")
  690. | ("ts", "ts")
  691. | ("tsx", "tsx")
  692. | ("py", "py")
  693. | ("go", "go")
  694. | ("java", "java")
  695. | ("json", "json")
  696. | ("md", "md")
  697. )
  698. }
  699. fn glob_matches(pattern: &str, path: &str) -> bool {
  700. expand_braces(pattern)
  701. .into_iter()
  702. .any(|expanded| glob_match_one(&expanded, path))
  703. }
  704. fn expand_braces(pattern: &str) -> Vec<String> {
  705. let Some(start) = pattern.find('{') else {
  706. return vec![pattern.to_owned()];
  707. };
  708. let Some(end_rel) = pattern[start..].find('}') else {
  709. return vec![pattern.to_owned()];
  710. };
  711. let end = start + end_rel;
  712. let prefix = &pattern[..start];
  713. let suffix = &pattern[end + 1..];
  714. pattern[start + 1..end]
  715. .split(',')
  716. .flat_map(|middle| expand_braces(&format!("{prefix}{middle}{suffix}")))
  717. .collect()
  718. }
  719. fn glob_match_one(pattern: &str, path: &str) -> bool {
  720. let pattern = pattern.replace('\\', "/");
  721. let path = path.replace('\\', "/");
  722. let pattern_parts: Vec<&str> = pattern.split('/').collect();
  723. let path_parts: Vec<&str> = path.split('/').collect();
  724. glob_match_parts(&pattern_parts, &path_parts)
  725. }
  726. fn glob_match_parts(pattern: &[&str], path: &[&str]) -> bool {
  727. if pattern.is_empty() {
  728. return path.is_empty();
  729. }
  730. if pattern[0] == "**" {
  731. if glob_match_parts(&pattern[1..], path) {
  732. return true;
  733. }
  734. if !path.is_empty() {
  735. return glob_match_parts(pattern, &path[1..]);
  736. }
  737. return false;
  738. }
  739. if path.is_empty() {
  740. return false;
  741. }
  742. if segment_matches(pattern[0], path[0]) {
  743. return glob_match_parts(&pattern[1..], &path[1..]);
  744. }
  745. false
  746. }
  747. fn segment_matches(pattern: &str, text: &str) -> bool {
  748. let p = pattern.as_bytes();
  749. let t = text.as_bytes();
  750. let (mut pi, mut ti, mut star_idx, mut match_idx) = (0usize, 0usize, None, 0usize);
  751. while ti < t.len() {
  752. if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
  753. pi += 1;
  754. ti += 1;
  755. } else if pi < p.len() && p[pi] == b'*' {
  756. star_idx = Some(pi);
  757. match_idx = ti;
  758. pi += 1;
  759. } else if let Some(star) = star_idx {
  760. pi = star + 1;
  761. match_idx += 1;
  762. ti = match_idx;
  763. } else {
  764. return false;
  765. }
  766. }
  767. while pi < p.len() && p[pi] == b'*' {
  768. pi += 1;
  769. }
  770. pi == p.len()
  771. }
  772. #[must_use]
  773. pub fn core_tools() -> Vec<Box<dyn Tool>> {
  774. vec![
  775. Box::new(BashTool),
  776. Box::new(ReadTool),
  777. Box::new(WriteTool),
  778. Box::new(EditTool),
  779. Box::new(GlobTool),
  780. Box::new(GrepTool),
  781. ]
  782. }
  783. #[cfg(test)]
  784. mod tests {
  785. use super::*;
  786. use serde_json::json;
  787. use tempfile::tempdir;
  788. fn text(result: &ToolResult) -> String {
  789. result.content[0].text.clone()
  790. }
  791. #[test]
  792. fn manifests_core_tools() {
  793. let names: Vec<_> = core_tools().into_iter().map(|tool| tool.name()).collect();
  794. assert_eq!(names, vec!["Bash", "Read", "Write", "Edit", "Glob", "Grep"]);
  795. }
  796. #[test]
  797. fn bash_executes_command() {
  798. let result = BashTool
  799. .execute(json!({ "command": "printf 'hello'" }))
  800. .unwrap();
  801. assert_eq!(text(&result), "hello");
  802. }
  803. #[test]
  804. fn read_schema_matches_expected_keys() {
  805. let schema = ReadTool.input_schema();
  806. let properties = schema["properties"].as_object().unwrap();
  807. assert_eq!(schema["required"], json!(["file_path"]));
  808. assert!(properties.contains_key("file_path"));
  809. assert!(properties.contains_key("offset"));
  810. assert!(properties.contains_key("limit"));
  811. assert!(properties.contains_key("pages"));
  812. }
  813. #[test]
  814. fn read_returns_numbered_lines() {
  815. let dir = tempdir().unwrap();
  816. let path = dir.path().join("sample.txt");
  817. fs::write(&path, "alpha\nbeta\ngamma\n").unwrap();
  818. let result = ReadTool
  819. .execute(json!({ "file_path": path.to_string_lossy(), "offset": 2, "limit": 1 }))
  820. .unwrap();
  821. assert_eq!(text(&result), " 2\tbeta");
  822. }
  823. #[test]
  824. fn write_creates_file_and_reports_create() {
  825. let dir = tempdir().unwrap();
  826. let path = dir.path().join("new.txt");
  827. let result = WriteTool
  828. .execute(json!({ "file_path": path.to_string_lossy(), "content": "hello" }))
  829. .unwrap();
  830. let payload: Value = serde_json::from_str(&text(&result)).unwrap();
  831. assert_eq!(payload["type"], "create");
  832. assert_eq!(fs::read_to_string(path).unwrap(), "hello");
  833. }
  834. #[test]
  835. fn edit_replaces_single_match() {
  836. let dir = tempdir().unwrap();
  837. let path = dir.path().join("edit.txt");
  838. fs::write(&path, "hello world\n").unwrap();
  839. let result = EditTool
  840. .execute(json!({
  841. "file_path": path.to_string_lossy(),
  842. "old_string": "world",
  843. "new_string": "rust",
  844. "replace_all": false
  845. }))
  846. .unwrap();
  847. let payload: Value = serde_json::from_str(&text(&result)).unwrap();
  848. assert_eq!(payload["replaceAll"], false);
  849. assert_eq!(fs::read_to_string(path).unwrap(), "hello rust\n");
  850. }
  851. #[test]
  852. fn glob_finds_matching_files() {
  853. let dir = tempdir().unwrap();
  854. fs::create_dir_all(dir.path().join("src/nested")).unwrap();
  855. fs::write(dir.path().join("src/lib.rs"), "").unwrap();
  856. fs::write(dir.path().join("src/nested/main.rs"), "").unwrap();
  857. fs::write(dir.path().join("README.md"), "").unwrap();
  858. let result = GlobTool
  859. .execute(json!({ "pattern": "**/*.rs", "path": dir.path().to_string_lossy() }))
  860. .unwrap();
  861. let payload: Value = serde_json::from_str(&text(&result)).unwrap();
  862. assert_eq!(payload["numFiles"], 2);
  863. assert_eq!(
  864. payload["filenames"],
  865. json!(["src/lib.rs", "src/nested/main.rs"])
  866. );
  867. }
  868. #[test]
  869. fn grep_supports_file_list_mode() {
  870. let dir = tempdir().unwrap();
  871. fs::write(dir.path().join("a.rs"), "fn main() {}\nlet alpha = 1;\n").unwrap();
  872. fs::write(dir.path().join("b.txt"), "alpha\nalpha\n").unwrap();
  873. let result = GrepTool
  874. .execute(json!({
  875. "pattern": "alpha",
  876. "path": dir.path().to_string_lossy(),
  877. "output_mode": "files_with_matches"
  878. }))
  879. .unwrap();
  880. let payload: Value = serde_json::from_str(&text(&result)).unwrap();
  881. assert_eq!(payload["filenames"], json!(["a.rs", "b.txt"]));
  882. }
  883. #[test]
  884. fn grep_supports_content_and_count_modes() {
  885. let dir = tempdir().unwrap();
  886. fs::write(dir.path().join("a.rs"), "alpha\nbeta\nalpha\n").unwrap();
  887. let content = GrepTool
  888. .execute(json!({
  889. "pattern": "alpha",
  890. "path": dir.path().to_string_lossy(),
  891. "output_mode": "content",
  892. "-n": true
  893. }))
  894. .unwrap();
  895. let content_payload: Value = serde_json::from_str(&text(&content)).unwrap();
  896. assert_eq!(content_payload["numLines"], 2);
  897. assert!(content_payload["content"]
  898. .as_str()
  899. .unwrap()
  900. .contains("a.rs:1:alpha"));
  901. let count = GrepTool
  902. .execute(json!({
  903. "pattern": "alpha",
  904. "path": dir.path().to_string_lossy(),
  905. "output_mode": "count"
  906. }))
  907. .unwrap();
  908. let count_payload: Value = serde_json::from_str(&text(&count)).unwrap();
  909. assert_eq!(count_payload["numMatches"], 2);
  910. assert_eq!(count_payload["content"], "a.rs:2");
  911. }
  912. }
备用站点 当前处于降级运行的备用站点,仅供应急访问,数据和功能可能不是最新。