1use std::path::{Path, PathBuf};
16
17use async_trait::async_trait;
18
19use crate::constants::MAX_RESPONSE_CHARS as MAX_FILE_READ_BYTES;
20use crate::domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
21
22use super::super::ctx::{ExecContext, ProgressEvent};
23use super::ToolExecutor;
24
25fn defn(name: &str, description: &str, input_schema: serde_json::Value) -> ToolDefinition {
29 ToolDefinition {
30 name: name.to_string(),
31 description: description.to_string(),
32 input_schema,
33 }
34}
35
36pub struct ReadFileTool;
39
40#[async_trait]
41impl ToolExecutor for ReadFileTool {
42 fn name(&self) -> &'static str {
43 "read_file"
44 }
45
46 fn schema(&self) -> ToolDefinition {
47 defn(
48 "read_file",
49 "Read the contents of one or more files from disk. Prefer relative paths; absolute paths must resolve inside the project directory or the call is rejected.",
50 serde_json::json!({
51 "type": "object",
52 "properties": {
53 "path": { "type": "string", "description": "File to read (single)." },
54 "paths": {
55 "type": "array",
56 "items": { "type": "string" },
57 "description": "Multiple files to read in parallel."
58 }
59 },
60 "oneOf": [
61 { "required": ["path"] },
62 { "required": ["paths"] }
63 ]
64 }),
65 )
66 }
67
68 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
69 let paths = match extract_paths(&args) {
70 Ok(p) => p,
71 Err(e) => return ToolOutcome::error(e, 0.0),
72 };
73 if paths.is_empty() {
74 return ToolOutcome::error("read_file requires at least one path", 0.0);
75 }
76
77 let start = std::time::Instant::now();
78 let workdir = ctx.workdir.clone();
79 let mut combined = String::new();
80
81 for (idx, raw_path) in paths.iter().enumerate() {
82 tokio::select! {
85 biased;
86 _ = ctx.token.cancelled() => {
87 return ToolOutcome::cancelled();
88 },
89 read = read_one(&workdir, raw_path) => {
90 match read {
91 Ok(content) => {
92 if paths.len() > 1 {
93 let _ = ctx.progress.send(ProgressEvent::Status(
94 format!("read {}/{}: {}", idx + 1, paths.len(), raw_path),
95 )).await;
96 combined.push_str(&format!(
97 "=== {} ===\n{}\n\n",
98 raw_path, content
99 ));
100 } else {
101 combined = content;
102 }
103 },
104 Err(e) => {
105 return ToolOutcome::error(
106 format!("{}: {}", raw_path, e),
107 start.elapsed().as_secs_f64(),
108 );
109 },
110 }
111 },
112 }
113 }
114
115 let duration_secs = start.elapsed().as_secs_f64();
116 let line_count = combined.lines().count();
117 let byte_count = combined.len();
118 let truncated = combined.contains("[TRUNCATED: file exceeded read cap]");
119 ToolOutcome::success(
120 combined,
121 format!(
122 "{} {} read",
123 line_count,
124 plural(line_count, "line", "lines")
125 ),
126 duration_secs,
127 )
128 .with_metadata(ToolRunMetadata {
129 detail: ToolMetadata::ReadFile {
130 paths,
131 line_count,
132 byte_count,
133 truncated,
134 },
135 line_count: Some(line_count),
136 byte_count: Some(byte_count),
137 ..ToolRunMetadata::default()
138 })
139 }
140}
141
142pub struct EditFileTool;
146
147#[async_trait]
148impl ToolExecutor for EditFileTool {
149 fn name(&self) -> &'static str {
150 "edit_file"
151 }
152
153 fn schema(&self) -> ToolDefinition {
154 defn(
155 "edit_file",
156 "Replace exactly one occurrence of `old_string` with `new_string` in the file at `path`. Fails if `old_string` doesn't appear or appears more than once — add surrounding context until the match is unique.",
157 serde_json::json!({
158 "type": "object",
159 "properties": {
160 "path": { "type": "string" },
161 "old_string": { "type": "string", "description": "Exact text to replace. Must appear exactly once." },
162 "new_string": { "type": "string", "description": "Replacement text." }
163 },
164 "required": ["path", "old_string", "new_string"]
165 }),
166 )
167 }
168
169 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
170 let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
171 return err("edit_file requires 'path'", 0.0);
172 };
173 let Some(old_string) = args.get("old_string").and_then(|v| v.as_str()) else {
174 return err("edit_file requires 'old_string'", 0.0);
175 };
176 let Some(new_string) = args.get("new_string").and_then(|v| v.as_str()) else {
177 return err("edit_file requires 'new_string'", 0.0);
178 };
179
180 let start = std::time::Instant::now();
181 let abs = match resolve_path_safe(&ctx.workdir, raw_path) {
182 Ok(p) => p,
183 Err(e) => return err(&format!("edit_file: {}", e), 0.0),
184 };
185 let old_owned = old_string.to_string();
186 let new_owned = new_string.to_string();
187 let abs_clone = abs.clone();
188 let display_path = raw_path.to_string();
189
190 tokio::select! {
191 biased;
192 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
193 result = tokio::task::spawn_blocking(move || edit_blocking(&abs_clone, &old_owned, &new_owned)) => {
194 match result {
195 Ok(Ok(replacements)) => {
196 let duration_secs = start.elapsed().as_secs_f64();
197 ToolOutcome::success(
198 format!("Edited {} ({} replacement{})",
199 display_path,
200 replacements,
201 if replacements == 1 { "" } else { "s" }),
202 format!("{} replacement{}", replacements, if replacements == 1 { "" } else { "s" }),
203 duration_secs,
204 )
205 .with_metadata(ToolRunMetadata {
206 detail: ToolMetadata::EditFile {
207 path: display_path,
208 replacements,
209 },
210 ..ToolRunMetadata::default()
211 })
212 },
213 Ok(Err(e)) => err(&format!("edit_file({}): {}", display_path, e),
214 start.elapsed().as_secs_f64()),
215 Err(e) => err(&format!("edit_file join error: {}", e),
216 start.elapsed().as_secs_f64()),
217 }
218 }
219 }
220 }
221}
222
223pub struct DeleteFileTool;
227
228#[async_trait]
229impl ToolExecutor for DeleteFileTool {
230 fn name(&self) -> &'static str {
231 "delete_file"
232 }
233
234 fn schema(&self) -> ToolDefinition {
235 defn(
236 "delete_file",
237 "Remove a file from disk. Fails on directories — use `execute_command rm -rf` for those.",
238 serde_json::json!({
239 "type": "object",
240 "properties": { "path": { "type": "string" } },
241 "required": ["path"]
242 }),
243 )
244 }
245
246 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
247 let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
248 return err("delete_file requires 'path'", 0.0);
249 };
250 let start = std::time::Instant::now();
251 let abs = match resolve_path_safe(&ctx.workdir, raw_path) {
252 Ok(p) => p,
253 Err(e) => return err(&format!("delete_file: {}", e), 0.0),
254 };
255 let display = raw_path.to_string();
256
257 tokio::select! {
258 biased;
259 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
260 result = tokio::task::spawn_blocking(move || std::fs::remove_file(&abs)) => {
261 match result {
262 Ok(Ok(())) => {
263 let duration_secs = start.elapsed().as_secs_f64();
264 ToolOutcome::success(
265 format!("Deleted {}", display),
266 "file deleted",
267 duration_secs,
268 )
269 .with_metadata(ToolRunMetadata {
270 detail: ToolMetadata::DeleteFile { path: display },
271 ..ToolRunMetadata::default()
272 })
273 },
274 Ok(Err(e)) => err(&format!("delete_file({}): {}", display, e),
275 start.elapsed().as_secs_f64()),
276 Err(e) => err(&format!("delete_file join error: {}", e),
277 start.elapsed().as_secs_f64()),
278 }
279 }
280 }
281 }
282}
283
284pub struct CreateDirectoryTool;
286
287#[async_trait]
288impl ToolExecutor for CreateDirectoryTool {
289 fn name(&self) -> &'static str {
290 "create_directory"
291 }
292
293 fn schema(&self) -> ToolDefinition {
294 defn(
295 "create_directory",
296 "Create a directory (and any missing parents) at the given path.",
297 serde_json::json!({
298 "type": "object",
299 "properties": { "path": { "type": "string" } },
300 "required": ["path"]
301 }),
302 )
303 }
304
305 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
306 let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
307 return err("create_directory requires 'path'", 0.0);
308 };
309 let start = std::time::Instant::now();
310 let abs = match resolve_path_safe(&ctx.workdir, raw_path) {
311 Ok(p) => p,
312 Err(e) => return err(&format!("create_directory: {}", e), 0.0),
313 };
314 let display = raw_path.to_string();
315
316 tokio::select! {
317 biased;
318 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
319 result = tokio::task::spawn_blocking(move || std::fs::create_dir_all(&abs)) => {
320 match result {
321 Ok(Ok(())) => {
322 let duration_secs = start.elapsed().as_secs_f64();
323 ToolOutcome::success(
324 format!("Created directory {}", display),
325 "directory created",
326 duration_secs,
327 )
328 .with_metadata(ToolRunMetadata {
329 detail: ToolMetadata::CreateDirectory { path: display },
330 ..ToolRunMetadata::default()
331 })
332 },
333 Ok(Err(e)) => err(&format!("create_directory({}): {}", display, e),
334 start.elapsed().as_secs_f64()),
335 Err(e) => err(&format!("create_directory join error: {}", e),
336 start.elapsed().as_secs_f64()),
337 }
338 }
339 }
340 }
341}
342
343pub struct WriteFileTool;
345
346#[async_trait]
347impl ToolExecutor for WriteFileTool {
348 fn name(&self) -> &'static str {
349 "write_file"
350 }
351
352 fn schema(&self) -> ToolDefinition {
353 defn(
354 "write_file",
355 "Write (overwrite) a file at `path` with `content`. Creates parent directories automatically. Prefer `edit_file` for small targeted changes.",
356 serde_json::json!({
357 "type": "object",
358 "properties": {
359 "path": { "type": "string" },
360 "content": { "type": "string" }
361 },
362 "required": ["path", "content"]
363 }),
364 )
365 }
366
367 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
368 let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
369 return ToolOutcome::error("write_file requires 'path' (string)", 0.0);
370 };
371 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
372 return ToolOutcome::error("write_file requires 'content' (string)", 0.0);
373 };
374
375 let start = std::time::Instant::now();
376 let abs_path = match resolve_path_safe(&ctx.workdir, path) {
377 Ok(p) => p,
378 Err(e) => return ToolOutcome::error(format!("write_file: {}", e), 0.0),
379 };
380 let display_path = path.to_string();
381 let line_count = content.lines().count();
382 let byte_count = content.len();
383 let created = Some(!abs_path.exists());
384 let content = content.to_string();
385
386 tokio::select! {
387 biased;
388 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
389 result = tokio::task::spawn_blocking(move || write_one_blocking(&abs_path, &content)) => {
390 match result {
391 Ok(Ok(actual_line_count)) => {
392 let duration_secs = start.elapsed().as_secs_f64();
393 ToolOutcome::success(
394 format!("Wrote {} ({} lines)", display_path, actual_line_count),
395 format!("{} {} written", actual_line_count, plural(actual_line_count, "line", "lines")),
396 duration_secs,
397 )
398 .with_metadata(ToolRunMetadata {
399 detail: ToolMetadata::WriteFile {
400 path: display_path,
401 line_count,
402 byte_count,
403 created,
404 },
405 line_count: Some(line_count),
406 byte_count: Some(byte_count),
407 ..ToolRunMetadata::default()
408 })
409 },
410 Ok(Err(e)) => ToolOutcome::error(
411 format!("write_file({}): {}", display_path, e),
412 start.elapsed().as_secs_f64(),
413 ),
414 Err(e) => ToolOutcome::error(
415 format!("write_file join error: {}", e),
416 start.elapsed().as_secs_f64(),
417 ),
418 }
419 }
420 }
421 }
422}
423
424fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
427 if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
429 return Ok(vec![p.to_string()]);
430 }
431 if let Some(arr) = args.get("paths").and_then(|v| v.as_array()) {
432 let mut out = Vec::with_capacity(arr.len());
433 for v in arr {
434 let Some(s) = v.as_str() else {
435 return Err("read_file 'paths' must be an array of strings".to_string());
436 };
437 out.push(s.to_string());
438 }
439 return Ok(out);
440 }
441 Err("read_file requires 'path' or 'paths'".to_string())
442}
443
444fn resolve_path_safe(workdir: &Path, raw: &str) -> Result<PathBuf, String> {
460 let p = PathBuf::from(raw);
461 let candidate = if p.is_absolute() { p } else { workdir.join(p) };
462
463 let root = std::fs::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf());
467
468 let canonical =
469 std::fs::canonicalize(&candidate).unwrap_or_else(|_| lexical_normalize(&candidate));
470
471 if canonical.starts_with(&root) {
472 Ok(candidate)
473 } else {
474 Err(format!(
475 "path '{}' is outside the project directory '{}'",
476 raw,
477 workdir.display()
478 ))
479 }
480}
481
482fn lexical_normalize(p: &Path) -> PathBuf {
487 use std::path::Component;
488 let mut out = PathBuf::new();
489 for comp in p.components() {
490 match comp {
491 Component::ParentDir => {
492 if !out.pop() {
496 out.push("..");
497 }
498 },
499 Component::CurDir => {},
500 other => out.push(other.as_os_str()),
501 }
502 }
503 out
504}
505
506async fn read_one(workdir: &Path, raw: &str) -> std::io::Result<String> {
507 let abs = resolve_path_safe(workdir, raw)
508 .map_err(|msg| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
509 let abs_clone = abs.clone();
510 let content = tokio::task::spawn_blocking(move || {
511 let data = std::fs::read(&abs_clone)?;
512 if data.len() > MAX_FILE_READ_BYTES {
513 let mut s = String::from_utf8_lossy(&data).into_owned();
515 let cut = s.floor_char_boundary(MAX_FILE_READ_BYTES);
516 s.truncate(cut);
517 s.push_str("\n\n[TRUNCATED: file exceeded read cap]");
518 Ok::<_, std::io::Error>(s)
519 } else {
520 Ok(String::from_utf8_lossy(&data).into_owned())
521 }
522 })
523 .await
524 .map_err(|e| std::io::Error::other(e.to_string()))??;
525 let _ = abs;
526 Ok(content)
527}
528
529fn write_one_blocking(path: &Path, content: &str) -> std::io::Result<usize> {
530 if let Some(parent) = path.parent() {
531 std::fs::create_dir_all(parent)?;
532 }
533 std::fs::write(path, content)?;
534 Ok(content.lines().count())
535}
536
537fn edit_blocking(path: &Path, old_string: &str, new_string: &str) -> std::io::Result<usize> {
538 let current = std::fs::read_to_string(path)?;
539 let count = current.matches(old_string).count();
540 if count == 0 {
541 return Err(std::io::Error::other(
542 "old_string not found (is the snippet correct? use read_file to verify)",
543 ));
544 }
545 if count > 1 {
546 return Err(std::io::Error::other(format!(
547 "old_string appears {} times — add more context so the match is unique",
548 count
549 )));
550 }
551 let updated = current.replacen(old_string, new_string, 1);
552 std::fs::write(path, updated)?;
553 Ok(1)
554}
555
556fn err(msg: &str, duration_secs: f64) -> ToolOutcome {
557 ToolOutcome::error(msg, duration_secs)
558}
559
560fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
561 if count == 1 { singular } else { plural }
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use crate::domain::{ToolCallId, TurnId};
568 use crate::providers::ctx::test_exec_context;
569 use std::fs;
570
571 fn temp_root(name: &str) -> PathBuf {
572 let p = std::env::temp_dir().join(format!("mermaid_providers_fs_{}", name));
573 let _ = fs::remove_dir_all(&p);
574 fs::create_dir_all(&p).expect("create tmpdir");
575 p
576 }
577
578 #[tokio::test]
579 async fn read_file_returns_content() {
580 let dir = temp_root("read_ok");
581 fs::write(dir.join("a.txt"), "hello").expect("write");
582 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
583
584 let tool = ReadFileTool;
585 let outcome = tool
586 .execute(serde_json::json!({"path": "a.txt"}), ctx)
587 .await;
588 assert!(outcome.is_success(), "expected success: {:?}", outcome);
589 assert_eq!(outcome.output(), "hello");
590 let _ = fs::remove_dir_all(&dir);
591 }
592
593 #[tokio::test]
594 async fn read_file_missing_path_errors() {
595 let dir = temp_root("read_missing_path");
596 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
597 let outcome = ReadFileTool.execute(serde_json::json!({}), ctx).await;
598 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
599 let _ = fs::remove_dir_all(&dir);
600 }
601
602 #[tokio::test]
603 async fn read_file_nonexistent_errors() {
604 let dir = temp_root("read_nonex");
605 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
606 let outcome = ReadFileTool
607 .execute(serde_json::json!({"path": "does_not_exist.txt"}), ctx)
608 .await;
609 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
610 let _ = fs::remove_dir_all(&dir);
611 }
612
613 #[tokio::test]
614 async fn read_file_with_multiple_paths_joins_contents() {
615 let dir = temp_root("read_multi");
616 fs::write(dir.join("a.txt"), "alpha").expect("write");
617 fs::write(dir.join("b.txt"), "beta").expect("write");
618 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
619 let outcome = ReadFileTool
620 .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
621 .await;
622 assert!(outcome.is_success(), "expected success: {:?}", outcome);
623 let output = outcome.output();
624 assert!(output.contains("=== a.txt ==="));
625 assert!(output.contains("alpha"));
626 assert!(output.contains("=== b.txt ==="));
627 assert!(output.contains("beta"));
628 let _ = fs::remove_dir_all(&dir);
629 }
630
631 #[tokio::test]
632 async fn read_file_respects_cancellation() {
633 let dir = temp_root("read_cancel");
634 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
639 ctx.token.cancel();
640 let outcome = ReadFileTool
641 .execute(serde_json::json!({"path": "x.txt"}), ctx)
642 .await;
643 assert!(outcome.was_cancelled());
644 let _ = fs::remove_dir_all(&dir);
645 }
646
647 #[tokio::test]
648 async fn write_file_creates_and_counts_lines() {
649 let dir = temp_root("write_ok");
650 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
651 let outcome = WriteFileTool
652 .execute(
653 serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3\n"}),
654 ctx,
655 )
656 .await;
657 assert!(outcome.is_success(), "expected success: {:?}", outcome);
658 assert!(outcome.output().contains("3 lines"));
659 let written = fs::read_to_string(dir.join("out.txt")).expect("read");
660 assert!(written.contains("line1"));
661 let _ = fs::remove_dir_all(&dir);
662 }
663
664 #[tokio::test]
665 async fn write_file_creates_parent_dirs() {
666 let dir = temp_root("write_parents");
667 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
668 let outcome = WriteFileTool
669 .execute(
670 serde_json::json!({
671 "path": "sub/nested/out.txt",
672 "content": "deep",
673 }),
674 ctx,
675 )
676 .await;
677 assert!(outcome.is_success(), "expected success: {:?}", outcome);
678 assert!(dir.join("sub/nested/out.txt").exists());
679 let _ = fs::remove_dir_all(&dir);
680 }
681
682 #[tokio::test]
683 async fn write_file_missing_content_errors() {
684 let dir = temp_root("write_missing");
685 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
686 let outcome = WriteFileTool
687 .execute(serde_json::json!({"path": "x.txt"}), ctx)
688 .await;
689 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
690 let _ = fs::remove_dir_all(&dir);
691 }
692
693 #[tokio::test]
699 async fn read_file_rejects_absolute_path_outside_workdir() {
700 let dir = temp_root("read_abs_escape");
701 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
702 let outcome = ReadFileTool
704 .execute(serde_json::json!({"path": "/etc/passwd"}), ctx)
705 .await;
706 let error = outcome.error_message().expect("expected error");
707 assert!(
708 error.contains("outside the project"),
709 "expected security reject, got: {}",
710 error
711 );
712 let _ = fs::remove_dir_all(&dir);
713 }
714
715 #[tokio::test]
717 async fn read_file_accepts_absolute_path_inside_workdir() {
718 let dir = temp_root("read_abs_inside");
719 let file = dir.join("hello.txt");
720 fs::write(&file, "ok").expect("write fixture");
721 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
722 let outcome = ReadFileTool
723 .execute(
724 serde_json::json!({"path": file.to_string_lossy().to_string()}),
725 ctx,
726 )
727 .await;
728 assert!(outcome.is_success(), "expected success: {:?}", outcome);
729 let _ = fs::remove_dir_all(&dir);
730 }
731
732 #[tokio::test]
736 async fn write_file_rejects_relative_parent_escape() {
737 let dir = temp_root("write_dotdot_escape");
738 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
739 let outcome = WriteFileTool
740 .execute(
741 serde_json::json!({
742 "path": "../escape.txt",
743 "content": "should not write",
744 }),
745 ctx,
746 )
747 .await;
748 let error = outcome.error_message().expect("expected error");
749 assert!(
750 error.contains("outside the project"),
751 "expected security reject, got: {}",
752 error
753 );
754 let _ = fs::remove_dir_all(&dir);
755 }
756
757 #[tokio::test]
761 async fn create_directory_rejects_absolute_path_outside_workdir() {
762 let dir = temp_root("mkdir_abs_escape");
763 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
764 let outcome = CreateDirectoryTool
765 .execute(
766 serde_json::json!({"path": "/tmp/mermaid_fs_escape_target"}),
767 ctx,
768 )
769 .await;
770 let error = outcome.error_message().expect("expected error");
771 assert!(
772 error.contains("outside the project"),
773 "expected security reject, got: {}",
774 error
775 );
776 let _ = fs::remove_dir_all(&dir);
777 }
778}