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