Skip to main content

vtcode_core/tools/file_ops/
write.rs

1use super::FileOpsTool;
2use super::diff_preview::{build_diff_preview, diff_preview_error_skip, diff_preview_size_skip};
3mod chunked;
4mod fs_ops;
5use crate::config::constants::diff;
6use crate::config::constants::tools;
7use crate::tools::builder::ToolResponseBuilder;
8use crate::tools::edited_file_monitor::conflict_override_snapshot;
9use crate::tools::error_helpers::deserialize_tool_args;
10use crate::tools::traits::FileTool;
11use crate::tools::types::WriteInput;
12use crate::utils::file_utils::{ensure_dir_exists, read_file_with_context};
13use anyhow::{Context, Result, anyhow};
14use serde_json::{Value, json};
15use std::borrow::Cow;
16use std::future::Future;
17use std::io::ErrorKind;
18use tokio::io::AsyncWriteExt;
19
20const MAX_WRITE_BYTES: usize = 64_000;
21
22async fn write_text_file(path: &std::path::Path, content: &str) -> Result<()> {
23    let mut file = tokio::fs::OpenOptions::new()
24        .create(true)
25        .write(true)
26        .truncate(true)
27        .open(path)
28        .await
29        .with_context(|| format!("Failed to open file for writing: {}", path.display()))?;
30    file.write_all(content.as_bytes())
31        .await
32        .with_context(|| format!("Failed to write file content: {}", path.display()))?;
33    file.flush()
34        .await
35        .with_context(|| format!("Failed to flush file content: {}", path.display()))
36}
37
38async fn create_text_file(path: &std::path::Path, content: &str) -> Result<(), std::io::Error> {
39    let mut file = tokio::fs::OpenOptions::new()
40        .create_new(true)
41        .write(true)
42        .open(path)
43        .await?;
44    file.write_all(content.as_bytes()).await?;
45    file.flush().await
46}
47
48impl FileOpsTool {
49    /// Write file with various modes and chunking support for large content.
50    /// Inline-delegating wrapper that returns the inner future directly to
51    /// avoid an extra coroutine state machine (audit section 16).
52    pub fn write_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
53        self.write_file_internal(args, true)
54    }
55
56    pub(crate) async fn write_file_internal(
57        &self,
58        args: Value,
59        acquire_mutation: bool,
60    ) -> Result<Value> {
61        let input: WriteInput = deserialize_tool_args(&args, "write_file")?;
62        let override_snapshot = conflict_override_snapshot(&args);
63
64        let file_path = self.normalize_and_validate_user_path(&input.path).await?;
65
66        if self.should_exclude(&file_path).await {
67            return Err(anyhow!(
68                "Error: Path '{}' is excluded by .vtcodegitignore",
69                input.path
70            ));
71        }
72
73        let content_size = input.content.len();
74        if content_size > MAX_WRITE_BYTES {
75            return Err(anyhow!(
76                "Content exceeds safe write limit ({MAX_WRITE_BYTES} bytes, got {content_size} bytes). \
77                 Use unified_exec with a shell heredoc to write large files, e.g. \
78                 unified_exec(action='run', command=\"cat > '<path>' << '__VT_WRITE_EOF__'\\n<content>\\n__VT_WRITE_EOF__\")."
79            ));
80        }
81
82        let _mutation_lease = if acquire_mutation {
83            Some(self.edited_file_monitor.acquire_mutation(&file_path).await)
84        } else {
85            None
86        };
87
88        // Create parent directories if needed
89        if let Some(parent) = file_path.parent() {
90            ensure_dir_exists(parent).await?;
91        }
92
93        let file_exists = tokio::fs::try_exists(&file_path).await?;
94
95        let mut existing_content: Option<String> = None;
96        let mut diff_preview: Option<Value> = None;
97
98        if file_exists {
99            match read_file_with_context(&file_path, "existing file content").await {
100                Ok(content) => existing_content = Some(content),
101                Err(error) => {
102                    diff_preview = Some(diff_preview_error_skip(
103                        "failed_to_read_existing_content",
104                        Some(&error.to_string()),
105                    ));
106                }
107            }
108        }
109
110        let effective_mode = if input.overwrite
111            && input.mode != "overwrite"
112            && input.mode != "fail_if_exists"
113        {
114            return Err(anyhow!(
115                "Conflicting parameters: overwrite=true but mode='{}'. Use mode='overwrite' or omit overwrite.",
116                input.mode
117            ));
118        } else if input.overwrite {
119            "overwrite"
120        } else {
121            input.mode.as_str()
122        };
123
124        if effective_mode == "skip_if_exists" && file_exists {
125            return Ok(ToolResponseBuilder::new(tools::WRITE_FILE)
126                .success()
127                .message("File already exists")
128                .field("skipped", json!(true))
129                .field("reason", json!("File already exists"))
130                .build_json());
131        }
132        if effective_mode == "fail_if_exists" && file_exists {
133            return Err(anyhow!(
134                "File '{}' exists. Use mode='overwrite' (or overwrite=true) to replace, or choose append/skip_if_exists.",
135                input.path
136            ));
137        }
138
139        let intended_content = match effective_mode {
140            "overwrite" => Some(input.content.clone()),
141            "append" => existing_content
142                .as_ref()
143                .map(|content| format!("{content}{}", input.content))
144                .or_else(|| Some(input.content.clone())),
145            "skip_if_exists" | "fail_if_exists" => Some(input.content.clone()),
146            _ => None,
147        };
148
149        if let Some(conflict) = self
150            .edited_file_monitor
151            .detect_conflict(
152                &file_path,
153                intended_content.clone(),
154                override_snapshot.clone(),
155            )
156            .await?
157        {
158            return Ok(conflict.to_tool_output(&self.workspace_root));
159        }
160
161        let final_written_content = match effective_mode {
162            "append" => intended_content
163                .clone()
164                .unwrap_or_else(|| input.content.clone()),
165            _ => input.content.clone(),
166        };
167
168        if matches!(effective_mode, "overwrite" | "append")
169            && let Some(conflict) = self
170                .edited_file_monitor
171                .detect_conflict(&file_path, intended_content.clone(), override_snapshot)
172                .await?
173        {
174            return Ok(conflict.to_tool_output(&self.workspace_root));
175        }
176
177        match effective_mode {
178            "overwrite" => {
179                write_text_file(&file_path, &input.content).await?;
180            }
181            "append" => {
182                let mut file = tokio::fs::OpenOptions::new()
183                    .create(true)
184                    .append(true)
185                    .open(&file_path)
186                    .await?;
187                file.write_all(input.content.as_bytes()).await?;
188                file.flush().await?;
189            }
190            "skip_if_exists" => {
191                if let Err(err) = create_text_file(&file_path, &input.content).await {
192                    if err.kind() == ErrorKind::AlreadyExists {
193                        return Ok(ToolResponseBuilder::new(tools::WRITE_FILE)
194                            .success()
195                            .message("File already exists")
196                            .field("skipped", json!(true))
197                            .field("reason", json!("File already exists"))
198                            .build_json());
199                    }
200                    return Err(err).with_context(|| {
201                        format!("Failed to create file content: {}", file_path.display())
202                    });
203                }
204            }
205            "fail_if_exists" => {
206                if let Err(err) = create_text_file(&file_path, &input.content).await {
207                    if err.kind() == ErrorKind::AlreadyExists {
208                        return Err(anyhow!(
209                            "File '{}' exists. Use mode='overwrite' (or overwrite=true) to replace, or choose append/skip_if_exists.",
210                            input.path
211                        ));
212                    }
213                    return Err(err).with_context(|| {
214                        format!("Failed to create file content: {}", file_path.display())
215                    });
216                }
217            }
218            _ => {
219                return Err(anyhow!(
220                    "Error: Unsupported write mode '{}'. Allowed: overwrite, append, skip_if_exists, fail_if_exists.",
221                    effective_mode
222                ));
223            }
224        }
225
226        // Log write operation
227        self.log_write_operation(&file_path, content_size, false)
228            .await?;
229        if let Err(err) = self
230            .edited_file_monitor
231            .record_agent_write_text(&file_path, &final_written_content)
232        {
233            tracing::warn!(
234                path = %file_path.display(),
235                error = %err,
236                "Failed to refresh edited-file snapshot after write"
237            );
238        }
239
240        if diff_preview.is_none() {
241            let existing_snapshot = existing_content.as_deref();
242            let total_len = if effective_mode == "append" {
243                existing_snapshot
244                    .map(|content| content.len())
245                    .unwrap_or_default()
246                    + input.content.len()
247            } else {
248                input.content.len()
249            };
250
251            if total_len > diff::MAX_PREVIEW_BYTES
252                || existing_snapshot
253                    .map(|content| content.len() > diff::MAX_PREVIEW_BYTES)
254                    .unwrap_or(false)
255            {
256                diff_preview = Some(diff_preview_size_skip());
257            } else {
258                let final_snapshot: Cow<'_, str> = if effective_mode == "append" {
259                    if let Some(existing) = existing_snapshot {
260                        Cow::Owned(format!("{existing}{}", input.content))
261                    } else {
262                        Cow::Borrowed(input.content.as_str())
263                    }
264                } else {
265                    Cow::Borrowed(input.content.as_str())
266                };
267
268                diff_preview = Some(build_diff_preview(
269                    &input.path,
270                    existing_snapshot,
271                    final_snapshot.as_ref(),
272                ));
273            }
274        }
275
276        let mut builder = ToolResponseBuilder::new(tools::WRITE_FILE)
277            .success()
278            .message(format!(
279                "Successfully wrote file {}",
280                self.workspace_relative_display(&file_path)
281            ))
282            .field("path", json!(self.workspace_relative_display(&file_path)))
283            .field("mode", json!(effective_mode))
284            .field("bytes_written", json!(input.content.len()))
285            .field("file_existed", json!(file_exists));
286
287        if let Some(preview) = diff_preview {
288            builder = builder.field("diff_preview", preview);
289        }
290
291        Ok(builder.build_json())
292    }
293}