1use anyhow::{Context, Result};
2use diffy::{Patch, apply};
3use serde::Deserialize;
4use serde_json::{Value, json};
5use sha2::{Digest, Sha256};
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use walkdir::WalkDir;
10
11use brainwires_core::{StagedWrite, Tool, ToolContext, ToolInputSchema, ToolResult};
12
13pub struct FileOpsTool;
15
16impl FileOpsTool {
17 pub fn get_tools() -> Vec<Tool> {
19 vec![
20 Self::read_file_tool(),
21 Self::write_file_tool(),
22 Self::edit_file_tool(),
23 Self::patch_file_tool(),
24 Self::list_directory_tool(),
25 Self::search_files_tool(),
26 Self::delete_file_tool(),
27 Self::create_directory_tool(),
28 ]
29 }
30
31 fn read_file_tool() -> Tool {
32 let mut properties = HashMap::new();
33 properties.insert(
34 "path".to_string(),
35 json!({"type": "string", "description": "Path to the file to read (relative or absolute)"}),
36 );
37 properties.insert(
38 "offset".to_string(),
39 json!({
40 "type": "number",
41 "description": "Line number to start reading from (1-based, default 1)",
42 "default": 1
43 }),
44 );
45 properties.insert(
46 "limit".to_string(),
47 json!({
48 "type": "number",
49 "description": "Maximum lines to read (default 2000). Output truncation marker is appended if the file is larger.",
50 "default": 2000
51 }),
52 );
53 Tool {
54 name: "read_file".to_string(),
55 description: "Read the contents of a local file. Defaults to the first 2000 lines; use offset+limit for paged reads of large files.".to_string(),
56 input_schema: ToolInputSchema::object(properties, vec!["path".to_string()]),
57 requires_approval: false,
58 ..Default::default()
59 }
60 }
61
62 fn write_file_tool() -> Tool {
63 let mut properties = HashMap::new();
64 properties.insert(
65 "path".to_string(),
66 json!({"type": "string", "description": "Path to the file to write"}),
67 );
68 properties.insert(
69 "content".to_string(),
70 json!({"type": "string", "description": "Content to write to the file"}),
71 );
72 Tool {
73 name: "write_file".to_string(),
74 description: "Create or overwrite a file with the given content.".to_string(),
75 input_schema: ToolInputSchema::object(
76 properties,
77 vec!["path".to_string(), "content".to_string()],
78 ),
79 requires_approval: true,
80 serialize: true,
81 ..Default::default()
82 }
83 }
84
85 fn edit_file_tool() -> Tool {
86 let mut properties = HashMap::new();
87 properties.insert(
88 "path".to_string(),
89 json!({"type": "string", "description": "Path to the file to edit"}),
90 );
91 properties.insert(
92 "old_text".to_string(),
93 json!({"type": "string", "description": "Exact text to find in the file"}),
94 );
95 properties.insert(
96 "new_text".to_string(),
97 json!({"type": "string", "description": "Text to replace old_text with"}),
98 );
99 Tool {
100 name: "edit_file".to_string(),
101 description: "Replace the first occurrence of old_text with new_text in a file."
102 .to_string(),
103 input_schema: ToolInputSchema::object(
104 properties,
105 vec![
106 "path".to_string(),
107 "old_text".to_string(),
108 "new_text".to_string(),
109 ],
110 ),
111 requires_approval: true,
112 serialize: true,
113 ..Default::default()
114 }
115 }
116
117 fn patch_file_tool() -> Tool {
118 let mut properties = HashMap::new();
119 properties.insert(
120 "path".to_string(),
121 json!({"type": "string", "description": "Path to the file to patch"}),
122 );
123 properties.insert(
124 "patch".to_string(),
125 json!({"type": "string", "description": "Unified diff patch to apply"}),
126 );
127 Tool {
128 name: "patch_file".to_string(),
129 description: "Apply a unified diff patch to a file.".to_string(),
130 input_schema: ToolInputSchema::object(
131 properties,
132 vec!["path".to_string(), "patch".to_string()],
133 ),
134 requires_approval: true,
135 serialize: true,
136 ..Default::default()
137 }
138 }
139
140 fn list_directory_tool() -> Tool {
141 let mut properties = HashMap::new();
142 properties.insert(
143 "path".to_string(),
144 json!({"type": "string", "description": "Path to the directory to list"}),
145 );
146 properties.insert("recursive".to_string(), json!({"type": "boolean", "description": "Whether to list recursively", "default": false}));
147 Tool {
148 name: "list_directory".to_string(),
149 description: "List files and directories in a local path.".to_string(),
150 input_schema: ToolInputSchema::object(properties, vec!["path".to_string()]),
151 requires_approval: false,
152 ..Default::default()
153 }
154 }
155
156 fn search_files_tool() -> Tool {
157 let mut properties = HashMap::new();
158 properties.insert(
159 "path".to_string(),
160 json!({"type": "string", "description": "Directory to search in"}),
161 );
162 properties.insert(
163 "pattern".to_string(),
164 json!({"type": "string", "description": "File name pattern to match (glob pattern)"}),
165 );
166 Tool {
167 name: "search_files".to_string(),
168 description: "Search for files matching a glob pattern.".to_string(),
169 input_schema: ToolInputSchema::object(
170 properties,
171 vec!["path".to_string(), "pattern".to_string()],
172 ),
173 requires_approval: false,
174 ..Default::default()
175 }
176 }
177
178 fn delete_file_tool() -> Tool {
179 let mut properties = HashMap::new();
180 properties.insert(
181 "path".to_string(),
182 json!({"type": "string", "description": "Path to the file or directory to delete"}),
183 );
184 Tool {
185 name: "delete_file".to_string(),
186 description: "Delete a file or directory.".to_string(),
187 input_schema: ToolInputSchema::object(properties, vec!["path".to_string()]),
188 requires_approval: true,
189 serialize: true,
190 ..Default::default()
191 }
192 }
193
194 fn create_directory_tool() -> Tool {
195 let mut properties = HashMap::new();
196 properties.insert(
197 "path".to_string(),
198 json!({"type": "string", "description": "Path to the directory to create"}),
199 );
200 Tool {
201 name: "create_directory".to_string(),
202 description: "Create a new directory (including parent directories).".to_string(),
203 input_schema: ToolInputSchema::object(properties, vec!["path".to_string()]),
204 requires_approval: true,
205 serialize: true,
206 ..Default::default()
207 }
208 }
209
210 #[tracing::instrument(name = "tool.execute", skip(input, context), fields(tool_name))]
212 pub fn execute(
213 tool_use_id: &str,
214 tool_name: &str,
215 input: &Value,
216 context: &ToolContext,
217 ) -> ToolResult {
218 let result = match tool_name {
219 "read_file" => Self::read_file(input, context),
220 "write_file" => Self::write_file(input, context),
221 "edit_file" => Self::edit_file(input, context),
222 "patch_file" => Self::patch_file(input, context),
223 "list_directory" => Self::list_directory(input, context),
224 "search_files" => Self::search_files(input, context),
225 "delete_file" => Self::delete_file(input, context),
226 "create_directory" => Self::create_directory(input, context),
227 _ => Err(anyhow::anyhow!(
228 "Unknown file operation tool: {}",
229 tool_name
230 )),
231 };
232 match result {
233 Ok(output) => ToolResult::success(tool_use_id.to_string(), output),
234 Err(e) => ToolResult::error(
235 tool_use_id.to_string(),
236 format!("File operation failed: {}", e),
237 ),
238 }
239 }
240
241 fn read_file(input: &Value, context: &ToolContext) -> Result<String> {
242 #[derive(Deserialize)]
243 struct Input {
244 path: String,
245 #[serde(default = "default_read_offset")]
246 offset: u32,
247 #[serde(default = "default_read_limit")]
248 limit: u32,
249 }
250 fn default_read_offset() -> u32 {
251 1
252 }
253 fn default_read_limit() -> u32 {
254 2000
255 }
256 let params: Input = serde_json::from_value(input.clone())?;
257 let full_path = Self::resolve_path(¶ms.path, context)?;
258 let content = fs::read_to_string(&full_path)
259 .with_context(|| format!("Failed to read file: {}", full_path.display()))?;
260 let total_bytes = content.len();
261 let total_lines = content.lines().count();
262
263 let start = params.offset.saturating_sub(1) as usize;
264 let limit = params.limit.max(1) as usize;
265 let sliced: String = content
266 .lines()
267 .skip(start)
268 .take(limit)
269 .collect::<Vec<_>>()
270 .join("\n");
271
272 let end = (start + limit).min(total_lines);
273 let truncated = end < total_lines;
274 let header = if truncated {
275 format!(
276 "File: {}\nSize: {} bytes, {} lines total\nShowing lines {}-{} of {} (... truncated: call again with offset={} to continue)\n\n",
277 full_path.display(),
278 total_bytes,
279 total_lines,
280 start + 1,
281 end,
282 total_lines,
283 end + 1,
284 )
285 } else {
286 format!(
287 "File: {}\nSize: {} bytes, {} lines total\nShowing lines {}-{}\n\n",
288 full_path.display(),
289 total_bytes,
290 total_lines,
291 start + 1,
292 end.max(start + 1),
293 )
294 };
295 Ok(format!("{}{}", header, sliced))
296 }
297
298 fn write_file(input: &Value, context: &ToolContext) -> Result<String> {
299 #[derive(Deserialize)]
300 struct Input {
301 path: String,
302 content: String,
303 }
304 let params: Input = serde_json::from_value(input.clone())?;
305 let full_path = Self::resolve_path(¶ms.path, context)?;
306
307 let content_hash = Sha256::digest(params.content.as_bytes());
309 let key = Self::derive_idempotency_key("write_file", &full_path, &content_hash);
310 if let Some(ref registry) = context.idempotency_registry
311 && let Some(record) = registry.get(&key)
312 {
313 tracing::debug!(path = %full_path.display(), "write_file: idempotent retry, returning cached result");
314 return Ok(record.cached_result);
315 }
316
317 if let Some(ref backend) = context.staging_backend {
319 let staged = StagedWrite {
320 key,
321 target_path: full_path.clone(),
322 content: params.content.clone(),
323 };
324 backend.stage(staged);
325 return Ok(format!(
326 "Staged write of {} bytes to {} (pending commit)",
327 params.content.len(),
328 full_path.display()
329 ));
330 }
331
332 if let Some(parent) = full_path.parent() {
334 fs::create_dir_all(parent).with_context(|| {
335 format!("Failed to create parent directory: {}", parent.display())
336 })?;
337 }
338 fs::write(&full_path, ¶ms.content)
339 .with_context(|| format!("Failed to write file: {}", full_path.display()))?;
340
341 let readback = fs::read(&full_path)
354 .with_context(|| format!("post-write readback failed for {}", full_path.display()))?;
355 if readback.as_slice() != params.content.as_bytes() {
356 return Err(anyhow::anyhow!(
357 "Write to {} succeeded but immediate read-back returned {} bytes (wrote {} bytes). \
358 This indicates concurrent modification by another process. \
359 Use a unique filename or coordinate with the other writer.",
360 full_path.display(),
361 readback.len(),
362 params.content.len()
363 ));
364 }
365
366 let content_hash_bytes: [u8; 32] = content_hash.into();
375 context.record_write(full_path.clone(), content_hash_bytes);
376
377 let msg = format!(
378 "Successfully wrote {} bytes to {}",
379 params.content.len(),
380 full_path.display()
381 );
382 if let Some(ref registry) = context.idempotency_registry {
383 registry.record(
384 Self::derive_idempotency_key("write_file", &full_path, &content_hash),
385 msg.clone(),
386 );
387 }
388 Ok(msg)
389 }
390
391 fn edit_file(input: &Value, context: &ToolContext) -> Result<String> {
392 #[derive(Deserialize)]
393 struct Input {
394 path: String,
395 old_text: String,
396 new_text: String,
397 }
398 let params: Input = serde_json::from_value(input.clone())?;
399 let full_path = Self::resolve_path(¶ms.path, context)?;
400
401 let mut hasher = Sha256::new();
403 hasher.update(params.old_text.as_bytes());
404 hasher.update(b"\0");
405 hasher.update(params.new_text.as_bytes());
406 let content_hash = hasher.finalize();
407 let key = Self::derive_idempotency_key("edit_file", &full_path, &content_hash);
408
409 if let Some(ref registry) = context.idempotency_registry
411 && let Some(record) = registry.get(&key)
412 {
413 tracing::debug!(path = %full_path.display(), "edit_file: idempotent retry, returning cached result");
414 return Ok(record.cached_result);
415 }
416
417 let current = fs::read_to_string(&full_path)
419 .with_context(|| format!("Failed to read file: {}", full_path.display()))?;
420 if !current.contains(¶ms.old_text) {
421 return Err(anyhow::anyhow!(
422 "Text not found in file: '{}'",
423 params.old_text
424 ));
425 }
426 let new_content = current.replacen(¶ms.old_text, ¶ms.new_text, 1);
427
428 if let Some(ref backend) = context.staging_backend {
430 backend.stage(StagedWrite {
431 key,
432 target_path: full_path.clone(),
433 content: new_content,
434 });
435 return Ok(format!(
436 "Staged edit (1 replacement) in {} (pending commit)",
437 full_path.display()
438 ));
439 }
440
441 fs::write(&full_path, &new_content)
443 .with_context(|| format!("Failed to write file: {}", full_path.display()))?;
444 let msg = format!(
445 "Successfully replaced 1 occurrence(s) in {}",
446 full_path.display()
447 );
448 if let Some(ref registry) = context.idempotency_registry {
449 registry.record(
450 Self::derive_idempotency_key("edit_file", &full_path, &content_hash),
451 msg.clone(),
452 );
453 }
454 Ok(msg)
455 }
456
457 fn patch_file(input: &Value, context: &ToolContext) -> Result<String> {
458 #[derive(Deserialize)]
459 struct Input {
460 path: String,
461 patch: String,
462 }
463 let params: Input = serde_json::from_value(input.clone())?;
464 let full_path = Self::resolve_path(¶ms.path, context)?;
465
466 let patch_hash = Sha256::digest(params.patch.as_bytes());
468 let key = Self::derive_idempotency_key("patch_file", &full_path, &patch_hash);
469
470 if let Some(ref registry) = context.idempotency_registry
472 && let Some(record) = registry.get(&key)
473 {
474 tracing::debug!(path = %full_path.display(), "patch_file: idempotent retry, returning cached result");
475 return Ok(record.cached_result);
476 }
477
478 let content = fs::read_to_string(&full_path)
480 .with_context(|| format!("Failed to read file: {}", full_path.display()))?;
481 let patch: Patch<'_, str> = Patch::from_str(¶ms.patch)
482 .map_err(|e| anyhow::anyhow!("Failed to parse patch: {}", e))?;
483 let hunk_count = patch.hunks().len();
484 let new_content =
485 apply(&content, &patch).map_err(|e| anyhow::anyhow!("Failed to apply patch: {}", e))?;
486
487 if let Some(ref backend) = context.staging_backend {
489 backend.stage(StagedWrite {
490 key,
491 target_path: full_path.clone(),
492 content: new_content.to_string(),
493 });
494 return Ok(format!(
495 "Staged patch of {} hunk(s) to {} (pending commit)",
496 hunk_count,
497 full_path.display()
498 ));
499 }
500
501 fs::write(&full_path, new_content.as_str())
503 .with_context(|| format!("Failed to write file: {}", full_path.display()))?;
504 let msg = format!(
505 "Successfully applied patch with {} hunk(s) to {}",
506 hunk_count,
507 full_path.display()
508 );
509 if let Some(ref registry) = context.idempotency_registry {
510 registry.record(
511 Self::derive_idempotency_key("patch_file", &full_path, &patch_hash),
512 msg.clone(),
513 );
514 }
515 Ok(msg)
516 }
517
518 fn list_directory(input: &Value, context: &ToolContext) -> Result<String> {
519 #[derive(Deserialize)]
520 struct Input {
521 path: String,
522 #[serde(default)]
523 recursive: bool,
524 }
525 let params: Input = serde_json::from_value(input.clone())?;
526 let full_path = Self::resolve_path(¶ms.path, context)?;
527 if !full_path.is_dir() {
528 return Err(anyhow::anyhow!("Not a directory: {}", full_path.display()));
529 }
530
531 let mut entries = Vec::new();
532 if params.recursive {
533 for entry in WalkDir::new(&full_path).min_depth(1) {
534 let entry = entry?;
535 let path = entry.path();
536 let relative = path.strip_prefix(&full_path).unwrap_or(path);
537 let type_str = if path.is_dir() { "dir" } else { "file" };
538 entries.push(format!("{} - {}", type_str, relative.display()));
539 }
540 } else {
541 for entry in fs::read_dir(&full_path)? {
542 let entry = entry?;
543 let path = entry.path();
544 let name = entry.file_name();
545 let type_str = if path.is_dir() { "dir" } else { "file" };
546 entries.push(format!("{} - {}", type_str, name.to_string_lossy()));
547 }
548 }
549 entries.sort();
550 Ok(format!(
551 "Directory: {}\nEntries: {}\n\n{}",
552 full_path.display(),
553 entries.len(),
554 entries.join("\n")
555 ))
556 }
557
558 fn search_files(input: &Value, context: &ToolContext) -> Result<String> {
559 #[derive(Deserialize)]
560 struct Input {
561 path: String,
562 pattern: String,
563 }
564 let params: Input = serde_json::from_value(input.clone())?;
565 let full_path = Self::resolve_path(¶ms.path, context)?;
566 let glob_pattern = full_path.join(¶ms.pattern);
567 let pattern_str = glob_pattern.to_string_lossy().to_string();
568 let mut matches = Vec::new();
569 for entry in glob::glob(&pattern_str)? {
570 match entry {
571 Ok(path) => {
572 let relative = path.strip_prefix(&full_path).unwrap_or(&path);
573 matches.push(relative.display().to_string());
574 }
575 Err(e) => tracing::warn!("Error reading glob entry: {}", e),
576 }
577 }
578 matches.sort();
579 Ok(format!(
580 "Search pattern: {}\nMatches: {}\n\n{}",
581 params.pattern,
582 matches.len(),
583 matches.join("\n")
584 ))
585 }
586
587 fn delete_file(input: &Value, context: &ToolContext) -> Result<String> {
588 #[derive(Deserialize)]
589 struct Input {
590 path: String,
591 }
592 let params: Input = serde_json::from_value(input.clone())?;
593 let full_path = Self::resolve_path(¶ms.path, context)?;
594
595 if let Some(ref registry) = context.idempotency_registry {
597 let key = Self::derive_idempotency_key("delete_file", &full_path, b"");
598 if let Some(record) = registry.get(&key) {
599 tracing::debug!(path = %full_path.display(), "delete_file: idempotent retry, returning cached result");
600 return Ok(record.cached_result);
601 }
602 let msg = if full_path.is_dir() {
603 fs::remove_dir_all(&full_path).with_context(|| {
604 format!("Failed to delete directory: {}", full_path.display())
605 })?;
606 format!("Successfully deleted directory: {}", full_path.display())
607 } else {
608 fs::remove_file(&full_path)
609 .with_context(|| format!("Failed to delete file: {}", full_path.display()))?;
610 format!("Successfully deleted file: {}", full_path.display())
611 };
612 registry.record(key, msg.clone());
613 return Ok(msg);
614 }
615
616 if full_path.is_dir() {
617 fs::remove_dir_all(&full_path)
618 .with_context(|| format!("Failed to delete directory: {}", full_path.display()))?;
619 Ok(format!(
620 "Successfully deleted directory: {}",
621 full_path.display()
622 ))
623 } else {
624 fs::remove_file(&full_path)
625 .with_context(|| format!("Failed to delete file: {}", full_path.display()))?;
626 Ok(format!(
627 "Successfully deleted file: {}",
628 full_path.display()
629 ))
630 }
631 }
632
633 fn create_directory(input: &Value, context: &ToolContext) -> Result<String> {
634 #[derive(Deserialize)]
635 struct Input {
636 path: String,
637 }
638 let params: Input = serde_json::from_value(input.clone())?;
639 let full_path = Self::resolve_path(¶ms.path, context)?;
640
641 if let Some(ref registry) = context.idempotency_registry {
643 let key = Self::derive_idempotency_key("create_directory", &full_path, b"");
644 if let Some(record) = registry.get(&key) {
645 tracing::debug!(path = %full_path.display(), "create_directory: idempotent retry, returning cached result");
646 return Ok(record.cached_result);
647 }
648 fs::create_dir_all(&full_path)
649 .with_context(|| format!("Failed to create directory: {}", full_path.display()))?;
650 let msg = format!("Successfully created directory: {}", full_path.display());
651 registry.record(key, msg.clone());
652 return Ok(msg);
653 }
654
655 fs::create_dir_all(&full_path)
656 .with_context(|| format!("Failed to create directory: {}", full_path.display()))?;
657 Ok(format!(
658 "Successfully created directory: {}",
659 full_path.display()
660 ))
661 }
662
663 pub fn resolve_path(path: &str, context: &ToolContext) -> Result<PathBuf> {
665 let path = Path::new(path);
666 let resolved = if path.is_absolute() {
667 path.to_path_buf()
668 } else {
669 Path::new(&context.working_directory).join(path)
670 };
671 Ok(resolved.canonicalize().unwrap_or(resolved))
672 }
673
674 fn derive_idempotency_key(tool_name: &str, path: &Path, content_factor: &[u8]) -> String {
683 let mut hasher = Sha256::new();
684 hasher.update(tool_name.as_bytes());
685 hasher.update(b"\0");
686 hasher.update(path.to_string_lossy().as_bytes());
687 hasher.update(b"\0");
688 hasher.update(content_factor);
689 hex::encode(hasher.finalize())
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696 use tempfile::TempDir;
697
698 fn create_test_context(working_dir: &str) -> ToolContext {
699 ToolContext {
700 working_directory: working_dir.to_string(),
701 ..Default::default()
702 }
703 }
704
705 fn create_test_context_with_registry(working_dir: &str) -> ToolContext {
706 ToolContext {
707 working_directory: working_dir.to_string(),
708 idempotency_registry: Some(brainwires_core::IdempotencyRegistry::new()),
709 ..Default::default()
710 }
711 }
712
713 #[test]
714 fn test_get_tools() {
715 let tools = FileOpsTool::get_tools();
716 assert_eq!(tools.len(), 8);
717 let names: Vec<_> = tools.iter().map(|t| t.name.as_str()).collect();
718 assert!(names.contains(&"read_file"));
719 assert!(names.contains(&"write_file"));
720 assert!(names.contains(&"edit_file"));
721 assert!(names.contains(&"patch_file"));
722 }
723
724 #[test]
725 fn test_read_file() {
726 let temp_dir = TempDir::new().unwrap();
727 let test_file = temp_dir.path().join("test.txt");
728 fs::write(&test_file, "Hello, World!").unwrap();
729 let context = create_test_context(temp_dir.path().to_str().unwrap());
730 let input = json!({"path": "test.txt"});
731 let result = FileOpsTool::execute("1", "read_file", &input, &context);
732 assert!(!result.is_error);
733 assert!(result.content.contains("Hello, World!"));
734 }
735
736 #[test]
737 fn test_read_file_truncates_large_file_and_emits_marker() {
738 let temp_dir = TempDir::new().unwrap();
739 let test_file = temp_dir.path().join("big.txt");
740 let body = (1..=3000)
741 .map(|i| format!("line {}", i))
742 .collect::<Vec<_>>()
743 .join("\n");
744 fs::write(&test_file, &body).unwrap();
745 let context = create_test_context(temp_dir.path().to_str().unwrap());
746 let input = json!({"path": "big.txt"});
748 let result = FileOpsTool::execute("1", "read_file", &input, &context);
749 assert!(!result.is_error);
750 assert!(result.content.contains("truncated"));
751 assert!(result.content.contains("line 1\n"));
752 assert!(result.content.contains("line 2000"));
753 assert!(!result.content.contains("line 2001"));
754 }
755
756 #[test]
757 fn test_read_file_respects_offset_and_limit() {
758 let temp_dir = TempDir::new().unwrap();
759 let test_file = temp_dir.path().join("paged.txt");
760 let body = (1..=100)
761 .map(|i| format!("row {}", i))
762 .collect::<Vec<_>>()
763 .join("\n");
764 fs::write(&test_file, &body).unwrap();
765 let context = create_test_context(temp_dir.path().to_str().unwrap());
766 let input = json!({"path": "paged.txt", "offset": 10, "limit": 5});
768 let result = FileOpsTool::execute("1", "read_file", &input, &context);
769 assert!(!result.is_error);
770 assert!(result.content.contains("row 10"));
771 assert!(result.content.contains("row 14"));
772 assert!(!result.content.contains("row 15"));
773 assert!(!result.content.contains("row 9\n"));
774 }
775
776 #[test]
777 fn test_write_file() {
778 let temp_dir = TempDir::new().unwrap();
779 let context = create_test_context(temp_dir.path().to_str().unwrap());
780 let input = json!({"path": "new.txt", "content": "Test"});
781 let result = FileOpsTool::execute("2", "write_file", &input, &context);
782 assert!(!result.is_error);
783 assert!(temp_dir.path().join("new.txt").exists());
784 }
785
786 #[test]
787 fn test_edit_file() {
788 let temp_dir = TempDir::new().unwrap();
789 fs::write(
790 temp_dir.path().join("edit.txt"),
791 "Hello World! Hello World!",
792 )
793 .unwrap();
794 let context = create_test_context(temp_dir.path().to_str().unwrap());
795 let input = json!({"path": "edit.txt", "old_text": "World", "new_text": "Rust"});
796 let result = FileOpsTool::execute("3", "edit_file", &input, &context);
797 assert!(!result.is_error);
798 let content = fs::read_to_string(temp_dir.path().join("edit.txt")).unwrap();
799 assert_eq!(content, "Hello Rust! Hello World!");
800 }
801
802 #[test]
803 fn test_list_directory() {
804 let temp_dir = TempDir::new().unwrap();
805 fs::write(temp_dir.path().join("a.txt"), "").unwrap();
806 fs::write(temp_dir.path().join("b.txt"), "").unwrap();
807 let context = create_test_context(temp_dir.path().to_str().unwrap());
808 let input = json!({"path": ".", "recursive": false});
809 let result = FileOpsTool::execute("4", "list_directory", &input, &context);
810 assert!(!result.is_error);
811 assert!(result.content.contains("a.txt"));
812 assert!(result.content.contains("b.txt"));
813 }
814
815 #[test]
816 fn test_delete_file() {
817 let temp_dir = TempDir::new().unwrap();
818 let file = temp_dir.path().join("del.txt");
819 fs::write(&file, "").unwrap();
820 let context = create_test_context(temp_dir.path().to_str().unwrap());
821 let input = json!({"path": "del.txt"});
822 let result = FileOpsTool::execute("5", "delete_file", &input, &context);
823 assert!(!result.is_error);
824 assert!(!file.exists());
825 }
826
827 #[test]
830 fn test_write_file_idempotent_same_content() {
831 let temp_dir = TempDir::new().unwrap();
832 let ctx = create_test_context_with_registry(temp_dir.path().to_str().unwrap());
833 let input = json!({"path": "idem.txt", "content": "Hello"});
834
835 let r1 = FileOpsTool::execute("1", "write_file", &input, &ctx);
836 assert!(!r1.is_error);
837 assert!(temp_dir.path().join("idem.txt").exists());
838
839 fs::write(temp_dir.path().join("idem.txt"), "CORRUPTED").unwrap();
841
842 let r2 = FileOpsTool::execute("2", "write_file", &input, &ctx);
844 assert!(!r2.is_error);
845 let on_disk = fs::read_to_string(temp_dir.path().join("idem.txt")).unwrap();
846 assert_eq!(
847 on_disk, "CORRUPTED",
848 "Idempotent retry must not overwrite the file"
849 );
850 }
851
852 #[test]
853 fn test_write_file_different_content_not_idempotent() {
854 let temp_dir = TempDir::new().unwrap();
855 let ctx = create_test_context_with_registry(temp_dir.path().to_str().unwrap());
856
857 FileOpsTool::execute(
858 "1",
859 "write_file",
860 &json!({"path": "f.txt", "content": "v1"}),
861 &ctx,
862 );
863 FileOpsTool::execute(
864 "2",
865 "write_file",
866 &json!({"path": "f.txt", "content": "v2"}),
867 &ctx,
868 );
869
870 let on_disk = fs::read_to_string(temp_dir.path().join("f.txt")).unwrap();
871 assert_eq!(on_disk, "v2", "Different content must produce a new write");
872 }
873
874 #[test]
875 fn test_write_file_no_registry_always_writes() {
876 let temp_dir = TempDir::new().unwrap();
877 let ctx = create_test_context(temp_dir.path().to_str().unwrap()); let input = json!({"path": "f.txt", "content": "v1"});
879
880 FileOpsTool::execute("1", "write_file", &input, &ctx);
881 fs::write(temp_dir.path().join("f.txt"), "v_corrupted").unwrap();
882 FileOpsTool::execute("2", "write_file", &input, &ctx);
883
884 let on_disk = fs::read_to_string(temp_dir.path().join("f.txt")).unwrap();
885 assert_eq!(on_disk, "v1", "Without registry every call must go through");
886 }
887
888 #[test]
889 fn test_delete_file_idempotent() {
890 let temp_dir = TempDir::new().unwrap();
891 let ctx = create_test_context_with_registry(temp_dir.path().to_str().unwrap());
892 let file = temp_dir.path().join("del.txt");
893 fs::write(&file, "").unwrap();
894
895 let r1 = FileOpsTool::execute("1", "delete_file", &json!({"path": "del.txt"}), &ctx);
896 assert!(!r1.is_error);
897 assert!(!file.exists());
898
899 let r2 = FileOpsTool::execute("2", "delete_file", &json!({"path": "del.txt"}), &ctx);
901 assert!(
902 !r2.is_error,
903 "Idempotent delete must not fail on missing file"
904 );
905 }
906
907 #[test]
908 fn test_create_directory_idempotent() {
909 let temp_dir = TempDir::new().unwrap();
910 let ctx = create_test_context_with_registry(temp_dir.path().to_str().unwrap());
911
912 let r1 = FileOpsTool::execute("1", "create_directory", &json!({"path": "sub/dir"}), &ctx);
913 assert!(!r1.is_error);
914 assert!(temp_dir.path().join("sub/dir").is_dir());
915
916 let r2 = FileOpsTool::execute("2", "create_directory", &json!({"path": "sub/dir"}), &ctx);
917 assert!(
918 !r2.is_error,
919 "Second create_directory must return cached success"
920 );
921 }
922
923 #[test]
924 fn test_idempotency_registry_cloned_context_shares_state() {
925 let temp_dir = TempDir::new().unwrap();
926 let ctx = create_test_context_with_registry(temp_dir.path().to_str().unwrap());
927 let ctx2 = ctx.clone(); FileOpsTool::execute(
930 "1",
931 "write_file",
932 &json!({"path": "shared.txt", "content": "x"}),
933 &ctx,
934 );
935 fs::write(temp_dir.path().join("shared.txt"), "CORRUPTED").unwrap();
936
937 FileOpsTool::execute(
939 "2",
940 "write_file",
941 &json!({"path": "shared.txt", "content": "x"}),
942 &ctx2,
943 );
944 let on_disk = fs::read_to_string(temp_dir.path().join("shared.txt")).unwrap();
945 assert_eq!(
946 on_disk, "CORRUPTED",
947 "Cloned context must share idempotency state"
948 );
949 }
950
951 #[test]
954 fn test_write_file_staged_commit() {
955 use brainwires_core::StagingBackend;
956 use brainwires_tool_runtime::TransactionManager;
957 use std::sync::Arc;
958
959 let temp_dir = TempDir::new().unwrap();
960 let target = temp_dir.path().join("staged.txt");
961 let mgr = Arc::new(TransactionManager::new().unwrap());
962 let ctx = ToolContext {
963 working_directory: temp_dir.path().to_str().unwrap().to_string(),
964 staging_backend: Some(mgr.clone()),
965 ..Default::default()
966 };
967
968 let result = FileOpsTool::execute(
969 "1",
970 "write_file",
971 &json!({"path": "staged.txt", "content": "staged content"}),
972 &ctx,
973 );
974 assert!(!result.is_error);
975 assert!(
976 result.content.contains("Staged"),
977 "Result must indicate staging"
978 );
979 assert!(!target.exists(), "File must not exist before commit");
980
981 mgr.commit().unwrap();
982 assert!(target.exists());
983 assert_eq!(fs::read_to_string(&target).unwrap(), "staged content");
984 }
985
986 #[test]
987 fn test_write_file_staged_rollback() {
988 use brainwires_core::StagingBackend;
989 use brainwires_tool_runtime::TransactionManager;
990 use std::sync::Arc;
991
992 let temp_dir = TempDir::new().unwrap();
993 let target = temp_dir.path().join("rollback.txt");
994 let mgr = Arc::new(TransactionManager::new().unwrap());
995 let ctx = ToolContext {
996 working_directory: temp_dir.path().to_str().unwrap().to_string(),
997 staging_backend: Some(mgr.clone()),
998 ..Default::default()
999 };
1000
1001 FileOpsTool::execute(
1002 "1",
1003 "write_file",
1004 &json!({"path": "rollback.txt", "content": "data"}),
1005 &ctx,
1006 );
1007 mgr.rollback();
1008 assert!(!target.exists(), "File must not exist after rollback");
1009 }
1010
1011 #[test]
1030 fn write_file_detects_concurrent_clobber() {
1031 use std::sync::{Arc, Barrier};
1032 use std::thread;
1033
1034 const ITERATIONS: usize = 128;
1035 let mut errors_observed = 0usize;
1036
1037 for _ in 0..ITERATIONS {
1038 let temp_dir = TempDir::new().unwrap();
1039 let working_dir = temp_dir.path().to_str().unwrap().to_string();
1040 let content_a = "A".repeat(16 * 1024);
1043 let content_b = "B".repeat(16 * 1024);
1044 let barrier = Arc::new(Barrier::new(2));
1045
1046 let b1 = barrier.clone();
1047 let wd1 = working_dir.clone();
1048 let ca = content_a.clone();
1049 let t1 = thread::spawn(move || {
1050 let ctx = ToolContext {
1051 working_directory: wd1,
1052 ..Default::default()
1053 };
1054 b1.wait();
1055 FileOpsTool::execute(
1056 "a",
1057 "write_file",
1058 &json!({"path": "conflict.txt", "content": ca}),
1059 &ctx,
1060 )
1061 });
1062
1063 let b2 = barrier.clone();
1064 let wd2 = working_dir.clone();
1065 let cb = content_b.clone();
1066 let t2 = thread::spawn(move || {
1067 let ctx = ToolContext {
1068 working_directory: wd2,
1069 ..Default::default()
1070 };
1071 b2.wait();
1072 FileOpsTool::execute(
1073 "b",
1074 "write_file",
1075 &json!({"path": "conflict.txt", "content": cb}),
1076 &ctx,
1077 )
1078 });
1079
1080 let r1 = t1.join().unwrap();
1081 let r2 = t2.join().unwrap();
1082
1083 let on_disk = fs::read_to_string(temp_dir.path().join("conflict.txt")).unwrap();
1086 assert!(on_disk == content_a || on_disk == content_b);
1087
1088 if r1.is_error || r2.is_error {
1089 errors_observed += 1;
1090 }
1091 }
1092
1093 assert!(
1098 errors_observed >= 1,
1099 "Expected at least one concurrent writer to observe the clobber \
1100 across {} iterations; saw {}. This suggests the read-back check \
1101 is not engaging.",
1102 ITERATIONS,
1103 errors_observed
1104 );
1105 }
1106
1107 #[test]
1108 fn test_edit_file_staged_commit() {
1109 use brainwires_core::StagingBackend;
1110 use brainwires_tool_runtime::TransactionManager;
1111 use std::sync::Arc;
1112
1113 let temp_dir = TempDir::new().unwrap();
1114 let target = temp_dir.path().join("edit.txt");
1115 fs::write(&target, "Hello World").unwrap();
1116
1117 let mgr = Arc::new(TransactionManager::new().unwrap());
1118 let ctx = ToolContext {
1119 working_directory: temp_dir.path().to_str().unwrap().to_string(),
1120 staging_backend: Some(mgr.clone()),
1121 ..Default::default()
1122 };
1123
1124 let result = FileOpsTool::execute(
1125 "1",
1126 "edit_file",
1127 &json!({"path": "edit.txt", "old_text": "World", "new_text": "Rust"}),
1128 &ctx,
1129 );
1130 assert!(!result.is_error);
1131 assert!(
1132 result.content.contains("Staged"),
1133 "Result must indicate staging"
1134 );
1135
1136 assert_eq!(fs::read_to_string(&target).unwrap(), "Hello World");
1138
1139 mgr.commit().unwrap();
1140 assert_eq!(fs::read_to_string(&target).unwrap(), "Hello Rust");
1141 }
1142}