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
8/// Ensure a directory exists, creating it if necessary
9pub async fn ensure_dir_exists(path: &Path) -> Result<()> {
10    if !path.exists() {
11        fs::create_dir_all(path)
12            .await
13            .with_context(|| format!("Failed to create directory: {}", path.display()))?;
14    }
15    Ok(())
16}
17
18/// Read a file with contextual error message
19pub async fn read_file_with_context(path: &Path, context: &str) -> Result<String> {
20    fs::read_to_string(path)
21        .await
22        .with_context(|| format!("Failed to read {}: {}", context, path.display()))
23}
24
25/// Write a file with contextual error message, ensuring parent directory exists
26pub async fn write_file_with_context(path: &Path, content: &str, context: &str) -> Result<()> {
27    if let Some(parent) = path.parent() {
28        ensure_dir_exists(parent).await?;
29    }
30    fs::write(path, content)
31        .await
32        .with_context(|| format!("Failed to write {}: {}", context, path.display()))
33}
34
35/// Write a JSON file
36pub async fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<()> {
37    let json = serde_json::to_string_pretty(data)
38        .with_context(|| format!("Failed to serialize data for {}", path.display()))?;
39
40    write_file_with_context(path, &json, "JSON data").await
41}
42
43/// Read and parse a JSON file
44pub async fn read_json_file<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
45    let content = read_file_with_context(path, "JSON file").await?;
46
47    serde_json::from_str(&content)
48        .with_context(|| format!("Failed to parse JSON from {}", path.display()))
49}
50
51/// Parse JSON with context for better error messages
52pub fn parse_json_with_context<T: for<'de> Deserialize<'de>>(
53    content: &str,
54    context: &str,
55) -> Result<T> {
56    serde_json::from_str(content).with_context(|| format!("Failed to parse JSON from {}", context))
57}
58
59/// Serialize JSON with context
60pub fn serialize_json_with_context<T: Serialize>(data: &T, context: &str) -> Result<String> {
61    serde_json::to_string(data).with_context(|| format!("Failed to serialize JSON for {}", context))
62}
63
64/// Serialize JSON pretty with context
65pub fn serialize_json_pretty_with_context<T: Serialize>(data: &T, context: &str) -> Result<String> {
66    serde_json::to_string_pretty(data)
67        .with_context(|| format!("Failed to pretty-serialize JSON for {}", context))
68}
69
70/// Parse JSON into a typed value, returning `None` on failure.
71///
72/// Intended for non-critical, best-effort parsing where a missing or malformed
73/// value should be silently ignored. Use `parse_json_with_context` when the
74/// caller needs an actionable error.
75#[must_use]
76#[inline]
77pub fn try_parse_json<T: for<'de> Deserialize<'de>>(input: &str) -> Option<T> {
78    serde_json::from_str(input).ok()
79}
80
81/// Parse JSON into an untyped `Value`, returning `None` on failure.
82///
83/// Same semantics as `try_parse_json` but avoids a type annotation at the call
84/// site when only dynamic inspection is needed.
85#[must_use]
86#[inline]
87pub fn try_parse_json_value(input: &str) -> Option<serde_json::Value> {
88    serde_json::from_str(input).ok()
89}
90
91/// Parse JSON into a typed value, falling back to `Default` on failure.
92///
93/// A parse failure is logged at `debug` level with the provided `label` so the
94/// failure is visible in traces without being fatal.
95#[inline]
96pub fn parse_json_or_default<T: for<'de> Deserialize<'de> + Default>(
97    input: &str,
98    label: &str,
99) -> T {
100    serde_json::from_str(input).unwrap_or_else(|err| {
101        tracing::debug!(label, %err, "JSON parse failed, using default");
102        T::default()
103    })
104}
105
106/// Canonicalize path with context
107pub fn canonicalize_with_context(path: &Path, context: &str) -> Result<PathBuf> {
108    path.canonicalize().with_context(|| {
109        format!(
110            "Failed to canonicalize {} path: {}",
111            context,
112            path.display()
113        )
114    })
115}
116
117/// Canonicalize path with context (async)
118pub async fn canonicalize_with_context_async(path: &Path, context: &str) -> Result<PathBuf> {
119    fs::canonicalize(path).await.with_context(|| {
120        format!(
121            "Failed to canonicalize {} path: {}",
122            context,
123            path.display()
124        )
125    })
126}
127
128/// Read a file to string with contextual error (async)
129pub async fn read_to_string_async(path: &Path) -> Result<String> {
130    fs::read_to_string(path)
131        .await
132        .with_context(|| format!("Failed to read {}", path.display()))
133}
134
135/// Write a file with contextual error (async)
136pub async fn write_async(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
137    fs::write(path, contents)
138        .await
139        .with_context(|| format!("Failed to write {}", path.display()))
140}
141
142/// Create directories recursively with contextual error (async)
143pub async fn create_dir_all_async(path: &Path) -> Result<()> {
144    fs::create_dir_all(path)
145        .await
146        .with_context(|| format!("Failed to create {}", path.display()))
147}
148
149/// Remove a file with contextual error (async)
150pub async fn remove_file_async(path: &Path) -> Result<()> {
151    fs::remove_file(path)
152        .await
153        .with_context(|| format!("Failed to remove {}", path.display()))
154}
155
156/// Rename a file with contextual error (async)
157pub async fn rename_async(from: &Path, to: &Path) -> Result<()> {
158    fs::rename(from, to)
159        .await
160        .with_context(|| format!("Failed to rename {} to {}", from.display(), to.display()))
161}
162
163// --- Sync Versions ---
164
165/// Ensure a directory exists (sync)
166pub fn ensure_dir_exists_sync(path: &Path) -> Result<()> {
167    if !path.exists() {
168        std::fs::create_dir_all(path)
169            .with_context(|| format!("Failed to create directory: {}", path.display()))?;
170    }
171    Ok(())
172}
173
174/// Read a file with contextual error message (sync)
175pub fn read_file_with_context_sync(path: &Path, context: &str) -> Result<String> {
176    std::fs::read_to_string(path)
177        .with_context(|| format!("Failed to read {}: {}", context, path.display()))
178}
179
180/// Write a file with contextual error message (sync)
181pub fn write_file_with_context_sync(path: &Path, content: &str, context: &str) -> Result<()> {
182    if let Some(parent) = path.parent() {
183        ensure_dir_exists_sync(parent)?;
184    }
185    std::fs::write(path, content)
186        .with_context(|| format!("Failed to write {}: {}", context, path.display()))
187}
188
189/// Write a JSON file (sync)
190pub fn write_json_file_sync<T: Serialize>(path: &Path, data: &T) -> Result<()> {
191    let json = serde_json::to_string_pretty(data)
192        .with_context(|| format!("Failed to serialize data for {}", path.display()))?;
193
194    write_file_with_context_sync(path, &json, "JSON data")
195}
196
197/// Read and parse a JSON file (sync)
198pub fn read_json_file_sync<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
199    let content = read_file_with_context_sync(path, "JSON file")?;
200
201    serde_json::from_str(&content)
202        .with_context(|| format!("Failed to parse JSON from {}", path.display()))
203}
204
205/// Check whether a path looks like an image file based on extension.
206pub fn is_image_path(path: &Path) -> bool {
207    let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
208        return false;
209    };
210
211    matches!(
212        extension,
213        _ if extension.eq_ignore_ascii_case("png")
214            || extension.eq_ignore_ascii_case("jpg")
215            || extension.eq_ignore_ascii_case("jpeg")
216            || extension.eq_ignore_ascii_case("gif")
217            || extension.eq_ignore_ascii_case("bmp")
218            || extension.eq_ignore_ascii_case("webp")
219            || extension.eq_ignore_ascii_case("tiff")
220            || extension.eq_ignore_ascii_case("tif")
221            || extension.eq_ignore_ascii_case("svg")
222    )
223}