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