Skip to main content

vtcode_commons/
fs.rs

1#![expect(
2    clippy::indexing_slicing,
3    clippy::string_slice,
4    clippy::let_underscore_must_use,
5    unused_results,
6    reason = "Filesystem helpers validate path lengths and intentionally ignore local cleanup results."
7)]
8
9//! File utility functions for common operations
10
11use anyhow::{Context, Result};
12use serde::de::DeserializeOwned;
13use serde::{Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15use tokio::fs;
16
17use crate::image::has_supported_image_extension;
18
19/// Ensure a directory exists, creating it if necessary
20pub async fn ensure_dir_exists(path: &Path) -> Result<()> {
21    if !path.exists() {
22        fs::create_dir_all(path)
23            .await
24            .with_context(|| format!("Failed to create directory: {}", path.display()))?;
25    }
26    Ok(())
27}
28
29/// Read a file with contextual error message
30pub async fn read_file_with_context(path: &Path, context: &str) -> Result<String> {
31    fs::read_to_string(path)
32        .await
33        .with_context(|| format!("Failed to read {}: {}", context, path.display()))
34}
35
36/// Write a file with contextual error message, ensuring parent directory exists
37pub async fn write_file_with_context(path: &Path, content: &str, context: &str) -> Result<()> {
38    if let Some(parent) = path.parent() {
39        ensure_dir_exists(parent).await?;
40    }
41    fs::write(path, content)
42        .await
43        .with_context(|| format!("Failed to write {}: {}", context, path.display()))
44}
45
46/// Write a file atomically with a contextual error message, ensuring the
47/// parent directory exists.
48///
49/// The content is first written to a temporary file created in the same
50/// directory as `path` (so the final rename stays on the same filesystem and
51/// is therefore atomic), then the temp file is renamed onto `path`. This
52/// prevents concurrent readers -- e.g. another vtcode process sharing the
53/// same workspace -- from ever observing a partially written file.
54///
55/// On rename failure the temp file is best-effort removed before returning
56/// the error.
57pub async fn write_file_atomic_with_context(path: &Path, content: &str, context: &str) -> Result<()> {
58    if let Some(parent) = path.parent() {
59        ensure_dir_exists(parent).await?;
60    }
61
62    let temp_path = atomic_temp_path(path);
63
64    fs::write(&temp_path, content)
65        .await
66        .with_context(|| format!("Failed to write {}: {}", context, temp_path.display()))?;
67
68    if let Err(err) = fs::rename(&temp_path, path).await {
69        let _ = fs::remove_file(&temp_path).await;
70        return Err(err).with_context(|| format!("Failed to write {}: {}", context, path.display()));
71    }
72
73    Ok(())
74}
75
76/// Build a unique temp file path in the same directory as `path`, suitable
77/// for a write-then-rename atomic publish of `path`.
78fn atomic_temp_path(path: &Path) -> PathBuf {
79    use std::sync::atomic::{AtomicU64, Ordering};
80
81    static ATOMIC_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0);
82
83    let dir = path
84        .parent()
85        .filter(|parent| !parent.as_os_str().is_empty())
86        .unwrap_or_else(|| Path::new("."));
87    let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or("vtcode-atomic-write");
88    let nanos = std::time::SystemTime::now()
89        .duration_since(std::time::UNIX_EPOCH)
90        .map(|duration| duration.as_nanos())
91        .unwrap_or(0);
92    let counter = ATOMIC_WRITE_COUNTER.fetch_add(1, Ordering::Relaxed);
93
94    dir.join(format!(".{file_name}.tmp-{}-{nanos:x}-{counter:x}", std::process::id()))
95}
96
97/// Write a JSON file
98pub async fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<()> {
99    let json = serde_json::to_string_pretty(data)
100        .with_context(|| format!("Failed to serialize data for {}", path.display()))?;
101
102    write_file_with_context(path, &json, "JSON data").await
103}
104
105/// Read a category-owned private file without following symlinks.
106pub async fn read_private_file_no_follow(path: &Path) -> Result<Vec<u8>> {
107    let path = path.to_path_buf();
108    tokio::task::spawn_blocking(move || crate::VtCodePaths::read_file_no_follow(&path))
109        .await
110        .context("private file read task panicked")?
111}
112
113/// Create a category-owned private file without following a final symlink.
114pub async fn create_private_file(path: &Path) -> Result<std::fs::File> {
115    let path = path.to_path_buf();
116    tokio::task::spawn_blocking(move || crate::VtCodePaths::create_private_file(&path))
117        .await
118        .context("private file creation task panicked")?
119}
120
121/// Atomically write a category-owned private file without following symlinks.
122pub async fn write_private_file_atomic(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
123    let path = path.to_path_buf();
124    let contents = contents.as_ref().to_vec();
125    tokio::task::spawn_blocking(move || crate::VtCodePaths::write_private_file_atomic(&path, &contents))
126        .await
127        .context("private file write task panicked")?
128}
129
130/// Read and deserialize a category-owned private JSON file.
131pub async fn read_private_json_file<T: DeserializeOwned>(path: &Path) -> Result<T> {
132    let contents = read_private_file_no_follow(path).await?;
133    serde_json::from_slice(&contents).with_context(|| format!("Failed to parse private JSON from {}", path.display()))
134}
135
136/// Serialize and atomically write a category-owned private JSON file.
137pub async fn write_private_json_file<T: Serialize>(path: &Path, data: &T) -> Result<()> {
138    let json = serde_json::to_vec_pretty(data)
139        .with_context(|| format!("Failed to serialize private JSON for {}", path.display()))?;
140    write_private_file_atomic(path, json).await
141}
142
143/// Read and parse a JSON file
144pub async fn read_json_file<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
145    let content = read_file_with_context(path, "JSON file").await?;
146
147    serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from {}", path.display()))
148}
149
150/// Parse JSON with context for better error messages
151pub fn parse_json_with_context<T: for<'de> Deserialize<'de>>(content: &str, context: &str) -> Result<T> {
152    serde_json::from_str(content).with_context(|| format!("Failed to parse JSON from {context}"))
153}
154
155/// Serialize JSON with context
156pub fn serialize_json_with_context<T: Serialize>(data: &T, context: &str) -> Result<String> {
157    serde_json::to_string(data).with_context(|| format!("Failed to serialize JSON for {context}"))
158}
159
160/// Serialize JSON pretty with context
161pub fn serialize_json_pretty_with_context<T: Serialize>(data: &T, context: &str) -> Result<String> {
162    serde_json::to_string_pretty(data).with_context(|| format!("Failed to pretty-serialize JSON for {context}"))
163}
164
165/// Parse JSON into a typed value, returning `None` on failure.
166///
167/// Intended for non-critical, best-effort parsing where a missing or malformed
168/// value should be silently ignored. Use `parse_json_with_context` when the
169/// caller needs an actionable error.
170#[must_use]
171#[inline]
172pub fn try_parse_json<T: for<'de> Deserialize<'de>>(input: &str) -> Option<T> {
173    serde_json::from_str(input).ok()
174}
175
176/// Parse JSON into an untyped `Value`, returning `None` on failure.
177///
178/// Same semantics as `try_parse_json` but avoids a type annotation at the call
179/// site when only dynamic inspection is needed.
180#[must_use]
181#[inline]
182pub fn try_parse_json_value(input: &str) -> Option<serde_json::Value> {
183    serde_json::from_str(input).ok()
184}
185
186/// Parse JSON into a typed value, falling back to `Default` on failure.
187///
188/// A parse failure is logged at `debug` level with the provided `label` so the
189/// failure is visible in traces without being fatal.
190#[inline]
191pub fn parse_json_or_default<T: for<'de> Deserialize<'de> + Default>(input: &str, label: &str) -> T {
192    serde_json::from_str(input).unwrap_or_else(|err| {
193        tracing::debug!(label, %err, "JSON parse failed, using default");
194        T::default()
195    })
196}
197
198/// Canonicalize path with context.
199///
200/// Uses [`crate::paths::canonicalize`] (backed by `dunce`) to avoid Windows
201/// `\\?\` verbatim prefixes from `std::fs::canonicalize`.
202pub fn canonicalize_with_context(path: &Path, context: &str) -> Result<PathBuf> {
203    crate::paths::canonicalize(path)
204        .with_context(|| format!("Failed to canonicalize {} path: {}", context, path.display()))
205}
206
207/// Canonicalize path with context (async).
208///
209/// `dunce::canonicalize` is a synchronous syscall; we wrap it in
210/// `spawn_blocking` to preserve the async interface without blocking the
211/// runtime, matching the behaviour of `tokio::fs::canonicalize`.
212pub async fn canonicalize_with_context_async(path: &Path, context: &str) -> Result<PathBuf> {
213    let path = path.to_path_buf();
214    let path_display = path.display().to_string();
215    // `?` coerces JoinError → anyhow::Error via the blanket From impl.
216    let result = tokio::task::spawn_blocking(move || crate::paths::canonicalize(&path)).await?;
217    result.with_context(|| format!("Failed to canonicalize {context} path: {path_display}"))
218}
219
220/// Read a file to string with contextual error (async)
221pub async fn read_to_string_async(path: &Path) -> Result<String> {
222    fs::read_to_string(path)
223        .await
224        .with_context(|| format!("Failed to read {}", path.display()))
225}
226
227/// Write a file with contextual error (async)
228pub async fn write_async(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
229    fs::write(path, contents)
230        .await
231        .with_context(|| format!("Failed to write {}", path.display()))
232}
233
234/// Create directories recursively with contextual error (async)
235pub async fn create_dir_all_async(path: &Path) -> Result<()> {
236    fs::create_dir_all(path)
237        .await
238        .with_context(|| format!("Failed to create {}", path.display()))
239}
240
241/// Remove a file with contextual error (async)
242pub async fn remove_file_async(path: &Path) -> Result<()> {
243    fs::remove_file(path)
244        .await
245        .with_context(|| format!("Failed to remove {}", path.display()))
246}
247
248/// Rename a file with contextual error (async)
249pub async fn rename_async(from: &Path, to: &Path) -> Result<()> {
250    fs::rename(from, to)
251        .await
252        .with_context(|| format!("Failed to rename {} to {}", from.display(), to.display()))
253}
254
255// --- Sync Versions ---
256
257/// Ensure a directory exists (sync)
258pub fn ensure_dir_exists_sync(path: &Path) -> Result<()> {
259    if !path.exists() {
260        std::fs::create_dir_all(path).with_context(|| format!("Failed to create directory: {}", path.display()))?;
261    }
262    Ok(())
263}
264
265/// Read a file with contextual error message (sync)
266pub fn read_file_with_context_sync(path: &Path, context: &str) -> Result<String> {
267    std::fs::read_to_string(path).with_context(|| format!("Failed to read {}: {}", context, path.display()))
268}
269
270/// Write a file with contextual error message (sync)
271pub fn write_file_with_context_sync(path: &Path, content: &str, context: &str) -> Result<()> {
272    if let Some(parent) = path.parent() {
273        ensure_dir_exists_sync(parent)?;
274    }
275    std::fs::write(path, content).with_context(|| format!("Failed to write {}: {}", context, path.display()))
276}
277
278/// Write a JSON file (sync)
279pub fn write_json_file_sync<T: Serialize>(path: &Path, data: &T) -> Result<()> {
280    let json = serde_json::to_string_pretty(data)
281        .with_context(|| format!("Failed to serialize data for {}", path.display()))?;
282
283    write_file_with_context_sync(path, &json, "JSON data")
284}
285
286/// Read and parse a JSON file (sync)
287pub fn read_json_file_sync<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
288    let content = read_file_with_context_sync(path, "JSON file")?;
289
290    serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from {}", path.display()))
291}
292
293/// Check whether a path looks like an image file based on extension.
294pub fn is_image_path(path: &Path) -> bool {
295    let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
296        return false;
297    };
298
299    matches!(extension, "bmp" | "gif" | "jpeg" | "jpg" | "png" | "svg" | "tif" | "tiff" | "webp")
300}
301
302/// Check whether a string is a Windows absolute path (e.g., `C:\...` or `C:/...`).
303pub fn is_windows_absolute_path(path: &str) -> bool {
304    let bytes = path.as_bytes();
305    bytes.len() > 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/')
306}
307
308/// Remove backslash-escaped whitespace from a token.
309///
310/// A backslash followed by an ASCII whitespace character is replaced by the
311/// whitespace character itself.  All other characters are passed through.
312pub fn unescape_whitespace(token: &str) -> String {
313    let mut result = String::with_capacity(token.len());
314    let mut chars = token.chars().peekable();
315    while let Some(ch) = chars.next() {
316        if ch == '\\'
317            && let Some(next) = chars.peek()
318            && next.is_ascii_whitespace()
319        {
320            result.push(*next);
321            chars.next();
322            continue;
323        }
324        result.push(ch);
325    }
326    result
327}
328
329/// Trim trailing text from a raw image path match.
330///
331/// When a regex greedily matches an image path that contains spaces, it may
332/// also consume trailing prose (e.g., "/path/to/image.png can you see").
333/// This function walks backwards through whitespace-delimited tokens to find
334/// the longest prefix that looks like a valid image path.
335///
336/// The `candidate_check` closure receives a trimmed candidate string and
337/// returns `true` if it should be accepted as a valid image path.
338pub fn trim_trailing_image_path<F>(raw: &str, candidate_check: F) -> &str
339where
340    F: Fn(&str) -> bool,
341{
342    if candidate_check(raw) {
343        return raw;
344    }
345    let mut candidate = raw.trim_end();
346    while let Some(last_space) = candidate.rfind(' ') {
347        candidate = &candidate[..last_space];
348        if candidate_check(candidate) {
349            return candidate;
350        }
351    }
352    raw
353}
354
355/// Convenience wrapper for [`trim_trailing_image_path`] that checks
356/// image file extensions via [`has_supported_image_extension`].
357///
358/// Handles `file://` scheme and `~/` home expansion before checking.
359pub fn trim_trailing_image_path_str(raw: &str) -> &str {
360    trim_trailing_image_path(raw, |candidate| {
361        let unescaped = unescape_whitespace(candidate);
362        let mut path_str = unescaped.as_str();
363        if let Some(rest) = path_str.strip_prefix("file://") {
364            path_str = rest;
365        }
366        if let Some(rest) = path_str.strip_prefix("~/") {
367            if let Some(home) = dirs::home_dir() {
368                return has_supported_image_extension(&home.join(rest));
369            }
370            return false;
371        }
372        has_supported_image_extension(Path::new(path_str))
373    })
374}