Skip to main content

brainwires_tool_system/
file_ops.rs

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
13/// File operations tool implementation
14pub struct FileOpsTool;
15
16impl FileOpsTool {
17    /// Get all file operation tool definitions
18    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    /// Execute a file operation tool
187    #[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(&params.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(&params.path, context)?;
242
243        // 1. Idempotency check — return cached result on exact retry
244        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        // 2. Staging check — stage write for two-phase commit when backend present
254        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        // 3. Direct write
269        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, &params.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(&params.path, context)?;
299
300        // Idempotency key = tool + path + sha256(old_text '\0' new_text)
301        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        // 1. Idempotency check
309        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        // Compute new content (needed for both staging and direct write)
317        let current = fs::read_to_string(&full_path)
318            .with_context(|| format!("Failed to read file: {}", full_path.display()))?;
319        if !current.contains(&params.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(&params.old_text, &params.new_text, 1);
326
327        // 2. Staging check — stage the fully-computed new content
328        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        // 3. Direct write
341        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(&params.path, context)?;
364
365        // Idempotency key = tool + path + sha256(patch)
366        let patch_hash = Sha256::digest(params.patch.as_bytes());
367        let key = Self::derive_idempotency_key("patch_file", &full_path, &patch_hash);
368
369        // 1. Idempotency check
370        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        // Compute patched content (needed for both staging and direct write)
378        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(&params.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        // 2. Staging check — stage the fully-patched content
387        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        // 3. Direct write
401        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(&params.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(&params.path, context)?;
465        let glob_pattern = full_path.join(&params.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(&params.path, context)?;
493
494        // Idempotency: key = tool + path (no content factor; deleting same path twice is safe to deduplicate)
495        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(&params.path, context)?;
539
540        // Idempotency: key = tool + path
541        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    /// Resolve a path relative to the working directory
563    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    /// Derive an idempotency key for a mutating operation.
574    ///
575    /// The key is a hex-encoded SHA-256 hash of
576    /// `tool_name '\0' canonical_path '\0' content_factor`.
577    ///
578    /// `content_factor` encodes the operation payload so that:
579    /// - retries with identical content reuse the cached result
580    /// - genuinely different writes to the same path produce a new key
581    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    // ── Idempotency tests ─────────────────────────────────────────────────────
687
688    #[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        // Overwrite the file on disk to simulate a crash-then-retry scenario
699        fs::write(temp_dir.path().join("idem.txt"), "CORRUPTED").unwrap();
700
701        // Retry with identical inputs → cached result returned, file NOT re-written
702        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()); // no registry
737        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        // File is gone; second call must return cached result without error
759        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(); // cloned context shares the same registry
787
788        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        // Execute via the cloned context — same registry, so idempotent
797        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    // ── Staging backend (two-phase commit) tests ──────────────────────────────
811
812    #[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        // Original content unchanged until commit
900        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}