Skip to main content

brainwires_tool_builtins/
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(
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    /// Execute a file operation tool
211    #[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(&params.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(&params.path, context)?;
306
307        // 1. Idempotency check — return cached result on exact retry
308        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        // 2. Staging check — stage write for two-phase commit when backend present
318        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        // 3. Direct write
333        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, &params.content)
339            .with_context(|| format!("Failed to write file: {}", full_path.display()))?;
340
341        // 4. Read-back verification — detects concurrent clobber.
342        //
343        // The in-process file-lock manager serializes individual write syscalls,
344        // but it does NOT prevent two agents from independently overwriting each
345        // other's content: from the lock manager's perspective, those are valid
346        // sequential writes. Without this check, the losing writer would report
347        // `Success: true` while its content is silently gone.
348        //
349        // We re-read the file and compare bytes. A mismatch means another
350        // process modified the file between our write and our read-back, so we
351        // surface a tool-level error that the agent's LLM can see and handle
352        // (retry with a unique filename, coordinate, or abort — its choice).
353        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        // 5. Record intended content hash for post-validation clobber detection.
367        //
368        // The read-back above proves our bytes were on disk at time T; it
369        // cannot prove they remain on disk when the agent later finalises
370        // `Success: true` at some T' > T.  We record the hash so the agent's
371        // validation loop can re-read at finalisation and detect a clobber.
372        //
373        // No-op when no registry is attached (CLI-driven invocations).
374        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(&params.path, context)?;
400
401        // Idempotency key = tool + path + sha256(old_text '\0' new_text)
402        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        // 1. Idempotency check
410        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        // Compute new content (needed for both staging and direct write)
418        let current = fs::read_to_string(&full_path)
419            .with_context(|| format!("Failed to read file: {}", full_path.display()))?;
420        if !current.contains(&params.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(&params.old_text, &params.new_text, 1);
427
428        // 2. Staging check — stage the fully-computed new content
429        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        // 3. Direct write
442        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(&params.path, context)?;
465
466        // Idempotency key = tool + path + sha256(patch)
467        let patch_hash = Sha256::digest(params.patch.as_bytes());
468        let key = Self::derive_idempotency_key("patch_file", &full_path, &patch_hash);
469
470        // 1. Idempotency check
471        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        // Compute patched content (needed for both staging and direct write)
479        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(&params.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        // 2. Staging check — stage the fully-patched content
488        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        // 3. Direct write
502        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(&params.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(&params.path, context)?;
566        let glob_pattern = full_path.join(&params.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(&params.path, context)?;
594
595        // Idempotency: key = tool + path (no content factor; deleting same path twice is safe to deduplicate)
596        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(&params.path, context)?;
640
641        // Idempotency: key = tool + path
642        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    /// Resolve a path relative to the working directory
664    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    /// Derive an idempotency key for a mutating operation.
675    ///
676    /// The key is a hex-encoded SHA-256 hash of
677    /// `tool_name '\0' canonical_path '\0' content_factor`.
678    ///
679    /// `content_factor` encodes the operation payload so that:
680    /// - retries with identical content reuse the cached result
681    /// - genuinely different writes to the same path produce a new key
682    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        // Default limit is 2000 lines
747        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        // Read lines 10..=14 (offset=10, limit=5)
767        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    // ── Idempotency tests ─────────────────────────────────────────────────────
828
829    #[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        // Overwrite the file on disk to simulate a crash-then-retry scenario
840        fs::write(temp_dir.path().join("idem.txt"), "CORRUPTED").unwrap();
841
842        // Retry with identical inputs → cached result returned, file NOT re-written
843        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()); // no registry
878        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        // File is gone; second call must return cached result without error
900        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(); // cloned context shares the same registry
928
929        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        // Execute via the cloned context — same registry, so idempotent
938        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    // ── Staging backend (two-phase commit) tests ──────────────────────────────
952
953    #[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    // ── Concurrent clobber detection ──────────────────────────────────────────
1012    //
1013    // Regression test for the bug where two concurrent agents both reported
1014    // `Success: true` after writing conflicting content to the same file.
1015    //
1016    // Honest scope: the in-process FileLockManager serializes individual write
1017    // syscalls but does NOT prevent two writers from each issuing a full
1018    // overwrite. The fix — an immediate read-back after write — closes the
1019    // common race window where writer X's read-back lands after writer Y's
1020    // write. It does NOT catch the case where X's full write+readback
1021    // completes before Y's write begins (those are logically sequential and
1022    // indistinguishable from the non-concurrent case).
1023    //
1024    // So this test runs many iterations of tight concurrent writes and asserts
1025    // that across the batch, at least one writer observes the clobber and
1026    // returns an error. With the fix this is reliable (the race fires
1027    // overwhelmingly often); without the fix, both writers always return
1028    // success regardless of outcome — exactly the bug we're preventing.
1029    #[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            // Larger content widens the write+read window, making the race
1041            // fire more consistently.
1042            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            // Final file must be exactly one of the two inputs — write_file
1084            // does not produce interleaved bytes.
1085            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        // With tight concurrent writes across 128 iterations and 16 KiB
1094        // contents, the read-back race should fire on a substantial fraction
1095        // of runs. A single detection is enough to prove the mechanism works;
1096        // we require at least one to avoid confidence-free green tests.
1097        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        // Original content unchanged until commit
1137        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}