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