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