Skip to main content

vtcode_commons/
fs.rs

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