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("path".to_string(), json!({"type": "string", "description": "Path to the file to read (relative or absolute)"}));
34 Tool {
35 name: "read_file".to_string(),
36 description: "Read the contents of a local file.".to_string(),
37 input_schema: ToolInputSchema::object(properties, vec!["path".to_string()]),
38 requires_approval: false,
39 ..Default::default()
40 }
41 }
42
43 fn write_file_tool() -> Tool {
44 let mut properties = HashMap::new();
45 properties.insert(
46 "path".to_string(),
47 json!({"type": "string", "description": "Path to the file to write"}),
48 );
49 properties.insert(
50 "content".to_string(),
51 json!({"type": "string", "description": "Content to write to the file"}),
52 );
53 Tool {
54 name: "write_file".to_string(),
55 description: "Create or overwrite a file with the given content.".to_string(),
56 input_schema: ToolInputSchema::object(
57 properties,
58 vec!["path".to_string(), "content".to_string()],
59 ),
60 requires_approval: true,
61 ..Default::default()
62 }
63 }
64
65 fn edit_file_tool() -> Tool {
66 let mut properties = HashMap::new();
67 properties.insert(
68 "path".to_string(),
69 json!({"type": "string", "description": "Path to the file to edit"}),
70 );
71 properties.insert(
72 "old_text".to_string(),
73 json!({"type": "string", "description": "Exact text to find in the file"}),
74 );
75 properties.insert(
76 "new_text".to_string(),
77 json!({"type": "string", "description": "Text to replace old_text with"}),
78 );
79 Tool {
80 name: "edit_file".to_string(),
81 description: "Replace the first occurrence of old_text with new_text in a file."
82 .to_string(),
83 input_schema: ToolInputSchema::object(
84 properties,
85 vec![
86 "path".to_string(),
87 "old_text".to_string(),
88 "new_text".to_string(),
89 ],
90 ),
91 requires_approval: true,
92 ..Default::default()
93 }
94 }
95
96 fn patch_file_tool() -> Tool {
97 let mut properties = HashMap::new();
98 properties.insert(
99 "path".to_string(),
100 json!({"type": "string", "description": "Path to the file to patch"}),
101 );
102 properties.insert(
103 "patch".to_string(),
104 json!({"type": "string", "description": "Unified diff patch to apply"}),
105 );
106 Tool {
107 name: "patch_file".to_string(),
108 description: "Apply a unified diff patch to a file.".to_string(),
109 input_schema: ToolInputSchema::object(
110 properties,
111 vec!["path".to_string(), "patch".to_string()],
112 ),
113 requires_approval: true,
114 ..Default::default()
115 }
116 }
117
118 fn list_directory_tool() -> Tool {
119 let mut properties = HashMap::new();
120 properties.insert(
121 "path".to_string(),
122 json!({"type": "string", "description": "Path to the directory to list"}),
123 );
124 properties.insert("recursive".to_string(), json!({"type": "boolean", "description": "Whether to list recursively", "default": false}));
125 Tool {
126 name: "list_directory".to_string(),
127 description: "List files and directories in a local path.".to_string(),
128 input_schema: ToolInputSchema::object(properties, vec!["path".to_string()]),
129 requires_approval: false,
130 ..Default::default()
131 }
132 }
133
134 fn search_files_tool() -> Tool {
135 let mut properties = HashMap::new();
136 properties.insert(
137 "path".to_string(),
138 json!({"type": "string", "description": "Directory to search in"}),
139 );
140 properties.insert(
141 "pattern".to_string(),
142 json!({"type": "string", "description": "File name pattern to match (glob pattern)"}),
143 );
144 Tool {
145 name: "search_files".to_string(),
146 description: "Search for files matching a glob pattern.".to_string(),
147 input_schema: ToolInputSchema::object(
148 properties,
149 vec!["path".to_string(), "pattern".to_string()],
150 ),
151 requires_approval: false,
152 ..Default::default()
153 }
154 }
155
156 fn delete_file_tool() -> Tool {
157 let mut properties = HashMap::new();
158 properties.insert(
159 "path".to_string(),
160 json!({"type": "string", "description": "Path to the file or directory to delete"}),
161 );
162 Tool {
163 name: "delete_file".to_string(),
164 description: "Delete a file or directory.".to_string(),
165 input_schema: ToolInputSchema::object(properties, vec!["path".to_string()]),
166 requires_approval: true,
167 ..Default::default()
168 }
169 }
170
171 fn create_directory_tool() -> Tool {
172 let mut properties = HashMap::new();
173 properties.insert(
174 "path".to_string(),
175 json!({"type": "string", "description": "Path to the directory to create"}),
176 );
177 Tool {
178 name: "create_directory".to_string(),
179 description: "Create a new directory (including parent directories).".to_string(),
180 input_schema: ToolInputSchema::object(properties, vec!["path".to_string()]),
181 requires_approval: true,
182 ..Default::default()
183 }
184 }
185
186 #[tracing::instrument(name = "tool.execute", skip(input, context), fields(tool_name))]
188 pub fn execute(
189 tool_use_id: &str,
190 tool_name: &str,
191 input: &Value,
192 context: &ToolContext,
193 ) -> ToolResult {
194 let result = match tool_name {
195 "read_file" => Self::read_file(input, context),
196 "write_file" => Self::write_file(input, context),
197 "edit_file" => Self::edit_file(input, context),
198 "patch_file" => Self::patch_file(input, context),
199 "list_directory" => Self::list_directory(input, context),
200 "search_files" => Self::search_files(input, context),
201 "delete_file" => Self::delete_file(input, context),
202 "create_directory" => Self::create_directory(input, context),
203 _ => Err(anyhow::anyhow!(
204 "Unknown file operation tool: {}",
205 tool_name
206 )),
207 };
208 match result {
209 Ok(output) => ToolResult::success(tool_use_id.to_string(), output),
210 Err(e) => ToolResult::error(
211 tool_use_id.to_string(),
212 format!("File operation failed: {}", e),
213 ),
214 }
215 }
216
217 fn read_file(input: &Value, context: &ToolContext) -> Result<String> {
218 #[derive(Deserialize)]
219 struct Input {
220 path: String,
221 }
222 let params: Input = serde_json::from_value(input.clone())?;
223 let full_path = Self::resolve_path(¶ms.path, context)?;
224 let content = fs::read_to_string(&full_path)
225 .with_context(|| format!("Failed to read file: {}", full_path.display()))?;
226 Ok(format!(
227 "File: {}\nSize: {} bytes\n\n{}",
228 full_path.display(),
229 content.len(),
230 content
231 ))
232 }
233
234 fn write_file(input: &Value, context: &ToolContext) -> Result<String> {
235 #[derive(Deserialize)]
236 struct Input {
237 path: String,
238 content: String,
239 }
240 let params: Input = serde_json::from_value(input.clone())?;
241 let full_path = Self::resolve_path(¶ms.path, context)?;
242
243 let content_hash = Sha256::digest(params.content.as_bytes());
245 let key = Self::derive_idempotency_key("write_file", &full_path, &content_hash);
246 if let Some(ref registry) = context.idempotency_registry
247 && let Some(record) = registry.get(&key)
248 {
249 tracing::debug!(path = %full_path.display(), "write_file: idempotent retry, returning cached result");
250 return Ok(record.cached_result);
251 }
252
253 if let Some(ref backend) = context.staging_backend {
255 let staged = StagedWrite {
256 key,
257 target_path: full_path.clone(),
258 content: params.content.clone(),
259 };
260 backend.stage(staged);
261 return Ok(format!(
262 "Staged write of {} bytes to {} (pending commit)",
263 params.content.len(),
264 full_path.display()
265 ));
266 }
267
268 if let Some(parent) = full_path.parent() {
270 fs::create_dir_all(parent).with_context(|| {
271 format!("Failed to create parent directory: {}", parent.display())
272 })?;
273 }
274 fs::write(&full_path, ¶ms.content)
275 .with_context(|| format!("Failed to write file: {}", full_path.display()))?;
276 let msg = format!(
277 "Successfully wrote {} bytes to {}",
278 params.content.len(),
279 full_path.display()
280 );
281 if let Some(ref registry) = context.idempotency_registry {
282 registry.record(
283 Self::derive_idempotency_key("write_file", &full_path, &content_hash),
284 msg.clone(),
285 );
286 }
287 Ok(msg)
288 }
289
290 fn edit_file(input: &Value, context: &ToolContext) -> Result<String> {
291 #[derive(Deserialize)]
292 struct Input {
293 path: String,
294 old_text: String,
295 new_text: String,
296 }
297 let params: Input = serde_json::from_value(input.clone())?;
298 let full_path = Self::resolve_path(¶ms.path, context)?;
299
300 let mut hasher = Sha256::new();
302 hasher.update(params.old_text.as_bytes());
303 hasher.update(b"\0");
304 hasher.update(params.new_text.as_bytes());
305 let content_hash = hasher.finalize();
306 let key = Self::derive_idempotency_key("edit_file", &full_path, &content_hash);
307
308 if let Some(ref registry) = context.idempotency_registry
310 && let Some(record) = registry.get(&key)
311 {
312 tracing::debug!(path = %full_path.display(), "edit_file: idempotent retry, returning cached result");
313 return Ok(record.cached_result);
314 }
315
316 let current = fs::read_to_string(&full_path)
318 .with_context(|| format!("Failed to read file: {}", full_path.display()))?;
319 if !current.contains(¶ms.old_text) {
320 return Err(anyhow::anyhow!(
321 "Text not found in file: '{}'",
322 params.old_text
323 ));
324 }
325 let new_content = current.replacen(¶ms.old_text, ¶ms.new_text, 1);
326
327 if let Some(ref backend) = context.staging_backend {
329 backend.stage(StagedWrite {
330 key,
331 target_path: full_path.clone(),
332 content: new_content,
333 });
334 return Ok(format!(
335 "Staged edit (1 replacement) in {} (pending commit)",
336 full_path.display()
337 ));
338 }
339
340 fs::write(&full_path, &new_content)
342 .with_context(|| format!("Failed to write file: {}", full_path.display()))?;
343 let msg = format!(
344 "Successfully replaced 1 occurrence(s) in {}",
345 full_path.display()
346 );
347 if let Some(ref registry) = context.idempotency_registry {
348 registry.record(
349 Self::derive_idempotency_key("edit_file", &full_path, &content_hash),
350 msg.clone(),
351 );
352 }
353 Ok(msg)
354 }
355
356 fn patch_file(input: &Value, context: &ToolContext) -> Result<String> {
357 #[derive(Deserialize)]
358 struct Input {
359 path: String,
360 patch: String,
361 }
362 let params: Input = serde_json::from_value(input.clone())?;
363 let full_path = Self::resolve_path(¶ms.path, context)?;
364
365 let patch_hash = Sha256::digest(params.patch.as_bytes());
367 let key = Self::derive_idempotency_key("patch_file", &full_path, &patch_hash);
368
369 if let Some(ref registry) = context.idempotency_registry
371 && let Some(record) = registry.get(&key)
372 {
373 tracing::debug!(path = %full_path.display(), "patch_file: idempotent retry, returning cached result");
374 return Ok(record.cached_result);
375 }
376
377 let content = fs::read_to_string(&full_path)
379 .with_context(|| format!("Failed to read file: {}", full_path.display()))?;
380 let patch: Patch<'_, str> = Patch::from_str(¶ms.patch)
381 .map_err(|e| anyhow::anyhow!("Failed to parse patch: {}", e))?;
382 let hunk_count = patch.hunks().len();
383 let new_content =
384 apply(&content, &patch).map_err(|e| anyhow::anyhow!("Failed to apply patch: {}", e))?;
385
386 if let Some(ref backend) = context.staging_backend {
388 backend.stage(StagedWrite {
389 key,
390 target_path: full_path.clone(),
391 content: new_content.to_string(),
392 });
393 return Ok(format!(
394 "Staged patch of {} hunk(s) to {} (pending commit)",
395 hunk_count,
396 full_path.display()
397 ));
398 }
399
400 fs::write(&full_path, new_content.as_str())
402 .with_context(|| format!("Failed to write file: {}", full_path.display()))?;
403 let msg = format!(
404 "Successfully applied patch with {} hunk(s) to {}",
405 hunk_count,
406 full_path.display()
407 );
408 if let Some(ref registry) = context.idempotency_registry {
409 registry.record(
410 Self::derive_idempotency_key("patch_file", &full_path, &patch_hash),
411 msg.clone(),
412 );
413 }
414 Ok(msg)
415 }
416
417 fn list_directory(input: &Value, context: &ToolContext) -> Result<String> {
418 #[derive(Deserialize)]
419 struct Input {
420 path: String,
421 #[serde(default)]
422 recursive: bool,
423 }
424 let params: Input = serde_json::from_value(input.clone())?;
425 let full_path = Self::resolve_path(¶ms.path, context)?;
426 if !full_path.is_dir() {
427 return Err(anyhow::anyhow!("Not a directory: {}", full_path.display()));
428 }
429
430 let mut entries = Vec::new();
431 if params.recursive {
432 for entry in WalkDir::new(&full_path).min_depth(1) {
433 let entry = entry?;
434 let path = entry.path();
435 let relative = path.strip_prefix(&full_path).unwrap_or(path);
436 let type_str = if path.is_dir() { "dir" } else { "file" };
437 entries.push(format!("{} - {}", type_str, relative.display()));
438 }
439 } else {
440 for entry in fs::read_dir(&full_path)? {
441 let entry = entry?;
442 let path = entry.path();
443 let name = entry.file_name();
444 let type_str = if path.is_dir() { "dir" } else { "file" };
445 entries.push(format!("{} - {}", type_str, name.to_string_lossy()));
446 }
447 }
448 entries.sort();
449 Ok(format!(
450 "Directory: {}\nEntries: {}\n\n{}",
451 full_path.display(),
452 entries.len(),
453 entries.join("\n")
454 ))
455 }
456
457 fn search_files(input: &Value, context: &ToolContext) -> Result<String> {
458 #[derive(Deserialize)]
459 struct Input {
460 path: String,
461 pattern: String,
462 }
463 let params: Input = serde_json::from_value(input.clone())?;
464 let full_path = Self::resolve_path(¶ms.path, context)?;
465 let glob_pattern = full_path.join(¶ms.pattern);
466 let pattern_str = glob_pattern.to_string_lossy().to_string();
467 let mut matches = Vec::new();
468 for entry in glob::glob(&pattern_str)? {
469 match entry {
470 Ok(path) => {
471 let relative = path.strip_prefix(&full_path).unwrap_or(&path);
472 matches.push(relative.display().to_string());
473 }
474 Err(e) => tracing::warn!("Error reading glob entry: {}", e),
475 }
476 }
477 matches.sort();
478 Ok(format!(
479 "Search pattern: {}\nMatches: {}\n\n{}",
480 params.pattern,
481 matches.len(),
482 matches.join("\n")
483 ))
484 }
485
486 fn delete_file(input: &Value, context: &ToolContext) -> Result<String> {
487 #[derive(Deserialize)]
488 struct Input {
489 path: String,
490 }
491 let params: Input = serde_json::from_value(input.clone())?;
492 let full_path = Self::resolve_path(¶ms.path, context)?;
493
494 if let Some(ref registry) = context.idempotency_registry {
496 let key = Self::derive_idempotency_key("delete_file", &full_path, b"");
497 if let Some(record) = registry.get(&key) {
498 tracing::debug!(path = %full_path.display(), "delete_file: idempotent retry, returning cached result");
499 return Ok(record.cached_result);
500 }
501 let msg = if full_path.is_dir() {
502 fs::remove_dir_all(&full_path).with_context(|| {
503 format!("Failed to delete directory: {}", full_path.display())
504 })?;
505 format!("Successfully deleted directory: {}", full_path.display())
506 } else {
507 fs::remove_file(&full_path)
508 .with_context(|| format!("Failed to delete file: {}", full_path.display()))?;
509 format!("Successfully deleted file: {}", full_path.display())
510 };
511 registry.record(key, msg.clone());
512 return Ok(msg);
513 }
514
515 if full_path.is_dir() {
516 fs::remove_dir_all(&full_path)
517 .with_context(|| format!("Failed to delete directory: {}", full_path.display()))?;
518 Ok(format!(
519 "Successfully deleted directory: {}",
520 full_path.display()
521 ))
522 } else {
523 fs::remove_file(&full_path)
524 .with_context(|| format!("Failed to delete file: {}", full_path.display()))?;
525 Ok(format!(
526 "Successfully deleted file: {}",
527 full_path.display()
528 ))
529 }
530 }
531
532 fn create_directory(input: &Value, context: &ToolContext) -> Result<String> {
533 #[derive(Deserialize)]
534 struct Input {
535 path: String,
536 }
537 let params: Input = serde_json::from_value(input.clone())?;
538 let full_path = Self::resolve_path(¶ms.path, context)?;
539
540 if let Some(ref registry) = context.idempotency_registry {
542 let key = Self::derive_idempotency_key("create_directory", &full_path, b"");
543 if let Some(record) = registry.get(&key) {
544 tracing::debug!(path = %full_path.display(), "create_directory: idempotent retry, returning cached result");
545 return Ok(record.cached_result);
546 }
547 fs::create_dir_all(&full_path)
548 .with_context(|| format!("Failed to create directory: {}", full_path.display()))?;
549 let msg = format!("Successfully created directory: {}", full_path.display());
550 registry.record(key, msg.clone());
551 return Ok(msg);
552 }
553
554 fs::create_dir_all(&full_path)
555 .with_context(|| format!("Failed to create directory: {}", full_path.display()))?;
556 Ok(format!(
557 "Successfully created directory: {}",
558 full_path.display()
559 ))
560 }
561
562 pub fn resolve_path(path: &str, context: &ToolContext) -> Result<PathBuf> {
564 let path = Path::new(path);
565 let resolved = if path.is_absolute() {
566 path.to_path_buf()
567 } else {
568 Path::new(&context.working_directory).join(path)
569 };
570 Ok(resolved.canonicalize().unwrap_or(resolved))
571 }
572
573 fn derive_idempotency_key(tool_name: &str, path: &Path, content_factor: &[u8]) -> String {
582 let mut hasher = Sha256::new();
583 hasher.update(tool_name.as_bytes());
584 hasher.update(b"\0");
585 hasher.update(path.to_string_lossy().as_bytes());
586 hasher.update(b"\0");
587 hasher.update(content_factor);
588 hex::encode(hasher.finalize())
589 }
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use tempfile::TempDir;
596
597 fn create_test_context(working_dir: &str) -> ToolContext {
598 ToolContext {
599 working_directory: working_dir.to_string(),
600 ..Default::default()
601 }
602 }
603
604 fn create_test_context_with_registry(working_dir: &str) -> ToolContext {
605 ToolContext {
606 working_directory: working_dir.to_string(),
607 idempotency_registry: Some(brainwires_core::IdempotencyRegistry::new()),
608 ..Default::default()
609 }
610 }
611
612 #[test]
613 fn test_get_tools() {
614 let tools = FileOpsTool::get_tools();
615 assert_eq!(tools.len(), 8);
616 let names: Vec<_> = tools.iter().map(|t| t.name.as_str()).collect();
617 assert!(names.contains(&"read_file"));
618 assert!(names.contains(&"write_file"));
619 assert!(names.contains(&"edit_file"));
620 assert!(names.contains(&"patch_file"));
621 }
622
623 #[test]
624 fn test_read_file() {
625 let temp_dir = TempDir::new().unwrap();
626 let test_file = temp_dir.path().join("test.txt");
627 fs::write(&test_file, "Hello, World!").unwrap();
628 let context = create_test_context(temp_dir.path().to_str().unwrap());
629 let input = json!({"path": "test.txt"});
630 let result = FileOpsTool::execute("1", "read_file", &input, &context);
631 assert!(!result.is_error);
632 assert!(result.content.contains("Hello, World!"));
633 }
634
635 #[test]
636 fn test_write_file() {
637 let temp_dir = TempDir::new().unwrap();
638 let context = create_test_context(temp_dir.path().to_str().unwrap());
639 let input = json!({"path": "new.txt", "content": "Test"});
640 let result = FileOpsTool::execute("2", "write_file", &input, &context);
641 assert!(!result.is_error);
642 assert!(temp_dir.path().join("new.txt").exists());
643 }
644
645 #[test]
646 fn test_edit_file() {
647 let temp_dir = TempDir::new().unwrap();
648 fs::write(
649 temp_dir.path().join("edit.txt"),
650 "Hello World! Hello World!",
651 )
652 .unwrap();
653 let context = create_test_context(temp_dir.path().to_str().unwrap());
654 let input = json!({"path": "edit.txt", "old_text": "World", "new_text": "Rust"});
655 let result = FileOpsTool::execute("3", "edit_file", &input, &context);
656 assert!(!result.is_error);
657 let content = fs::read_to_string(temp_dir.path().join("edit.txt")).unwrap();
658 assert_eq!(content, "Hello Rust! Hello World!");
659 }
660
661 #[test]
662 fn test_list_directory() {
663 let temp_dir = TempDir::new().unwrap();
664 fs::write(temp_dir.path().join("a.txt"), "").unwrap();
665 fs::write(temp_dir.path().join("b.txt"), "").unwrap();
666 let context = create_test_context(temp_dir.path().to_str().unwrap());
667 let input = json!({"path": ".", "recursive": false});
668 let result = FileOpsTool::execute("4", "list_directory", &input, &context);
669 assert!(!result.is_error);
670 assert!(result.content.contains("a.txt"));
671 assert!(result.content.contains("b.txt"));
672 }
673
674 #[test]
675 fn test_delete_file() {
676 let temp_dir = TempDir::new().unwrap();
677 let file = temp_dir.path().join("del.txt");
678 fs::write(&file, "").unwrap();
679 let context = create_test_context(temp_dir.path().to_str().unwrap());
680 let input = json!({"path": "del.txt"});
681 let result = FileOpsTool::execute("5", "delete_file", &input, &context);
682 assert!(!result.is_error);
683 assert!(!file.exists());
684 }
685
686 #[test]
689 fn test_write_file_idempotent_same_content() {
690 let temp_dir = TempDir::new().unwrap();
691 let ctx = create_test_context_with_registry(temp_dir.path().to_str().unwrap());
692 let input = json!({"path": "idem.txt", "content": "Hello"});
693
694 let r1 = FileOpsTool::execute("1", "write_file", &input, &ctx);
695 assert!(!r1.is_error);
696 assert!(temp_dir.path().join("idem.txt").exists());
697
698 fs::write(temp_dir.path().join("idem.txt"), "CORRUPTED").unwrap();
700
701 let r2 = FileOpsTool::execute("2", "write_file", &input, &ctx);
703 assert!(!r2.is_error);
704 let on_disk = fs::read_to_string(temp_dir.path().join("idem.txt")).unwrap();
705 assert_eq!(
706 on_disk, "CORRUPTED",
707 "Idempotent retry must not overwrite the file"
708 );
709 }
710
711 #[test]
712 fn test_write_file_different_content_not_idempotent() {
713 let temp_dir = TempDir::new().unwrap();
714 let ctx = create_test_context_with_registry(temp_dir.path().to_str().unwrap());
715
716 FileOpsTool::execute(
717 "1",
718 "write_file",
719 &json!({"path": "f.txt", "content": "v1"}),
720 &ctx,
721 );
722 FileOpsTool::execute(
723 "2",
724 "write_file",
725 &json!({"path": "f.txt", "content": "v2"}),
726 &ctx,
727 );
728
729 let on_disk = fs::read_to_string(temp_dir.path().join("f.txt")).unwrap();
730 assert_eq!(on_disk, "v2", "Different content must produce a new write");
731 }
732
733 #[test]
734 fn test_write_file_no_registry_always_writes() {
735 let temp_dir = TempDir::new().unwrap();
736 let ctx = create_test_context(temp_dir.path().to_str().unwrap()); let input = json!({"path": "f.txt", "content": "v1"});
738
739 FileOpsTool::execute("1", "write_file", &input, &ctx);
740 fs::write(temp_dir.path().join("f.txt"), "v_corrupted").unwrap();
741 FileOpsTool::execute("2", "write_file", &input, &ctx);
742
743 let on_disk = fs::read_to_string(temp_dir.path().join("f.txt")).unwrap();
744 assert_eq!(on_disk, "v1", "Without registry every call must go through");
745 }
746
747 #[test]
748 fn test_delete_file_idempotent() {
749 let temp_dir = TempDir::new().unwrap();
750 let ctx = create_test_context_with_registry(temp_dir.path().to_str().unwrap());
751 let file = temp_dir.path().join("del.txt");
752 fs::write(&file, "").unwrap();
753
754 let r1 = FileOpsTool::execute("1", "delete_file", &json!({"path": "del.txt"}), &ctx);
755 assert!(!r1.is_error);
756 assert!(!file.exists());
757
758 let r2 = FileOpsTool::execute("2", "delete_file", &json!({"path": "del.txt"}), &ctx);
760 assert!(
761 !r2.is_error,
762 "Idempotent delete must not fail on missing file"
763 );
764 }
765
766 #[test]
767 fn test_create_directory_idempotent() {
768 let temp_dir = TempDir::new().unwrap();
769 let ctx = create_test_context_with_registry(temp_dir.path().to_str().unwrap());
770
771 let r1 = FileOpsTool::execute("1", "create_directory", &json!({"path": "sub/dir"}), &ctx);
772 assert!(!r1.is_error);
773 assert!(temp_dir.path().join("sub/dir").is_dir());
774
775 let r2 = FileOpsTool::execute("2", "create_directory", &json!({"path": "sub/dir"}), &ctx);
776 assert!(
777 !r2.is_error,
778 "Second create_directory must return cached success"
779 );
780 }
781
782 #[test]
783 fn test_idempotency_registry_cloned_context_shares_state() {
784 let temp_dir = TempDir::new().unwrap();
785 let ctx = create_test_context_with_registry(temp_dir.path().to_str().unwrap());
786 let ctx2 = ctx.clone(); FileOpsTool::execute(
789 "1",
790 "write_file",
791 &json!({"path": "shared.txt", "content": "x"}),
792 &ctx,
793 );
794 fs::write(temp_dir.path().join("shared.txt"), "CORRUPTED").unwrap();
795
796 FileOpsTool::execute(
798 "2",
799 "write_file",
800 &json!({"path": "shared.txt", "content": "x"}),
801 &ctx2,
802 );
803 let on_disk = fs::read_to_string(temp_dir.path().join("shared.txt")).unwrap();
804 assert_eq!(
805 on_disk, "CORRUPTED",
806 "Cloned context must share idempotency state"
807 );
808 }
809
810 #[test]
813 fn test_write_file_staged_commit() {
814 use crate::transaction::TransactionManager;
815 use brainwires_core::StagingBackend;
816 use std::sync::Arc;
817
818 let temp_dir = TempDir::new().unwrap();
819 let target = temp_dir.path().join("staged.txt");
820 let mgr = Arc::new(TransactionManager::new().unwrap());
821 let ctx = ToolContext {
822 working_directory: temp_dir.path().to_str().unwrap().to_string(),
823 staging_backend: Some(mgr.clone()),
824 ..Default::default()
825 };
826
827 let result = FileOpsTool::execute(
828 "1",
829 "write_file",
830 &json!({"path": "staged.txt", "content": "staged content"}),
831 &ctx,
832 );
833 assert!(!result.is_error);
834 assert!(
835 result.content.contains("Staged"),
836 "Result must indicate staging"
837 );
838 assert!(!target.exists(), "File must not exist before commit");
839
840 mgr.commit().unwrap();
841 assert!(target.exists());
842 assert_eq!(fs::read_to_string(&target).unwrap(), "staged content");
843 }
844
845 #[test]
846 fn test_write_file_staged_rollback() {
847 use crate::transaction::TransactionManager;
848 use brainwires_core::StagingBackend;
849 use std::sync::Arc;
850
851 let temp_dir = TempDir::new().unwrap();
852 let target = temp_dir.path().join("rollback.txt");
853 let mgr = Arc::new(TransactionManager::new().unwrap());
854 let ctx = ToolContext {
855 working_directory: temp_dir.path().to_str().unwrap().to_string(),
856 staging_backend: Some(mgr.clone()),
857 ..Default::default()
858 };
859
860 FileOpsTool::execute(
861 "1",
862 "write_file",
863 &json!({"path": "rollback.txt", "content": "data"}),
864 &ctx,
865 );
866 mgr.rollback();
867 assert!(!target.exists(), "File must not exist after rollback");
868 }
869
870 #[test]
871 fn test_edit_file_staged_commit() {
872 use crate::transaction::TransactionManager;
873 use brainwires_core::StagingBackend;
874 use std::sync::Arc;
875
876 let temp_dir = TempDir::new().unwrap();
877 let target = temp_dir.path().join("edit.txt");
878 fs::write(&target, "Hello World").unwrap();
879
880 let mgr = Arc::new(TransactionManager::new().unwrap());
881 let ctx = ToolContext {
882 working_directory: temp_dir.path().to_str().unwrap().to_string(),
883 staging_backend: Some(mgr.clone()),
884 ..Default::default()
885 };
886
887 let result = FileOpsTool::execute(
888 "1",
889 "edit_file",
890 &json!({"path": "edit.txt", "old_text": "World", "new_text": "Rust"}),
891 &ctx,
892 );
893 assert!(!result.is_error);
894 assert!(
895 result.content.contains("Staged"),
896 "Result must indicate staging"
897 );
898
899 assert_eq!(fs::read_to_string(&target).unwrap(), "Hello World");
901
902 mgr.commit().unwrap();
903 assert_eq!(fs::read_to_string(&target).unwrap(), "Hello Rust");
904 }
905}