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
9use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14use tokio::fs;
15
16use crate::image::has_supported_image_extension;
17
18pub 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
28pub 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
35pub 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
45pub 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
75fn 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
96pub 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
104pub 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
111pub 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
116pub 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
121pub 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#[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#[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#[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
159pub 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
168pub 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 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
181pub 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
188pub 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
195pub 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
202pub 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
209pub 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
216pub 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
226pub 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
231pub 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
239pub 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
247pub 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
254pub 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
263pub 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
269pub 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
290pub 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
316pub 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}