Skip to main content

opendev_tools_impl/
file_write.rs

1//! Write file tool — writes content to a file with atomic writes and directory creation.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
7
8use crate::diagnostics_helper;
9use crate::formatter;
10use crate::path_utils::{is_sensitive_file, resolve_file_path, validate_path_access};
11
12/// Tool for writing file contents.
13#[derive(Debug)]
14pub struct FileWriteTool;
15
16#[async_trait::async_trait]
17impl BaseTool for FileWriteTool {
18    fn name(&self) -> &str {
19        "write_file"
20    }
21
22    fn description(&self) -> &str {
23        "Write content to a file. Creates parent directories if needed. Uses atomic writes."
24    }
25
26    fn parameter_schema(&self) -> serde_json::Value {
27        serde_json::json!({
28            "type": "object",
29            "properties": {
30                "file_path": {
31                    "type": "string",
32                    "description": "Absolute path to the file to write"
33                },
34                "content": {
35                    "type": "string",
36                    "description": "Content to write to the file"
37                },
38                "create_dirs": {
39                    "type": "boolean",
40                    "description": "Create parent directories if they don't exist (default: true)"
41                }
42            },
43            "required": ["file_path", "content"]
44        })
45    }
46
47    async fn execute(
48        &self,
49        args: HashMap<String, serde_json::Value>,
50        ctx: &ToolContext,
51    ) -> ToolResult {
52        let file_path = match args.get("file_path").and_then(|v| v.as_str()) {
53            Some(p) => p,
54            None => return ToolResult::fail("file_path is required"),
55        };
56
57        let content = match args.get("content").and_then(|v| v.as_str()) {
58            Some(c) => c,
59            None => return ToolResult::fail("content is required"),
60        };
61
62        let create_dirs = args
63            .get("create_dirs")
64            .and_then(|v| v.as_bool())
65            .unwrap_or(true);
66
67        let path = resolve_file_path(file_path, &ctx.working_dir);
68
69        if let Err(msg) = validate_path_access(&path, &ctx.working_dir) {
70            return ToolResult::fail(msg);
71        }
72
73        // Warn about writing to sensitive files.
74        if let Some(reason) = is_sensitive_file(&path) {
75            return ToolResult::fail(format!(
76                "Refusing to write to {}: {} — this file likely contains secrets. \
77                 If you need to modify it, ask the user to do so manually.",
78                file_path, reason
79            ));
80        }
81
82        // Create parent directories if needed
83        if create_dirs {
84            if let Some(parent) = path.parent()
85                && !parent.exists()
86                && let Err(e) = std::fs::create_dir_all(parent)
87            {
88                return ToolResult::fail(format!("Failed to create directories: {e}"));
89            }
90        } else if let Some(parent) = path.parent()
91            && !parent.exists()
92        {
93            return ToolResult::fail(format!(
94                "Parent directory does not exist: {}",
95                parent.display()
96            ));
97        }
98
99        // Atomic write: write to temp file then rename
100        let dir = path.parent().unwrap_or(Path::new("."));
101        let tmp_path = dir.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
102
103        if let Err(e) = std::fs::write(&tmp_path, content) {
104            return ToolResult::fail(format!("Failed to write temp file: {e}"));
105        }
106
107        if let Err(e) = std::fs::rename(&tmp_path, &path) {
108            // Clean up temp file on rename failure
109            let _ = std::fs::remove_file(&tmp_path);
110            return ToolResult::fail(format!("Failed to rename temp file: {e}"));
111        }
112
113        // Auto-format if a formatter is available
114        let formatted =
115            formatter::format_file(path.to_str().unwrap_or(file_path), &ctx.working_dir);
116
117        let lines = content.lines().count();
118        let bytes = content.len();
119
120        let mut metadata = HashMap::new();
121        metadata.insert("lines".into(), serde_json::json!(lines));
122        metadata.insert("bytes".into(), serde_json::json!(bytes));
123        if formatted {
124            metadata.insert("formatted".into(), serde_json::json!(true));
125        }
126
127        let fmt_note = if formatted { " (formatted)" } else { "" };
128        let mut output = format!("Wrote {bytes} bytes ({lines} lines) to {file_path}{fmt_note}");
129
130        // Collect LSP diagnostics after write
131        if let Some(diag_output) =
132            diagnostics_helper::collect_post_edit_diagnostics(ctx, &path).await
133        {
134            output.push_str(&diag_output);
135        }
136
137        ToolResult::ok_with_metadata(output, metadata)
138    }
139}
140
141#[cfg(test)]
142#[path = "file_write_tests.rs"]
143mod tests;