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::de::DeserializeOwned;
13use serde::{Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15use tokio::fs;
16
17use crate::image::has_supported_image_extension;
18
19pub async fn ensure_dir_exists(path: &Path) -> Result<()> {
21 if !path.exists() {
22 fs::create_dir_all(path)
23 .await
24 .with_context(|| format!("Failed to create directory: {}", path.display()))?;
25 }
26 Ok(())
27}
28
29pub async fn read_file_with_context(path: &Path, context: &str) -> Result<String> {
31 fs::read_to_string(path)
32 .await
33 .with_context(|| format!("Failed to read {}: {}", context, path.display()))
34}
35
36pub async fn write_file_with_context(path: &Path, content: &str, context: &str) -> Result<()> {
38 if let Some(parent) = path.parent() {
39 ensure_dir_exists(parent).await?;
40 }
41 fs::write(path, content)
42 .await
43 .with_context(|| format!("Failed to write {}: {}", context, path.display()))
44}
45
46pub async fn write_file_atomic_with_context(path: &Path, content: &str, context: &str) -> Result<()> {
58 if let Some(parent) = path.parent() {
59 ensure_dir_exists(parent).await?;
60 }
61
62 let temp_path = atomic_temp_path(path);
63
64 fs::write(&temp_path, content)
65 .await
66 .with_context(|| format!("Failed to write {}: {}", context, temp_path.display()))?;
67
68 if let Err(err) = fs::rename(&temp_path, path).await {
69 let _ = fs::remove_file(&temp_path).await;
70 return Err(err).with_context(|| format!("Failed to write {}: {}", context, path.display()));
71 }
72
73 Ok(())
74}
75
76fn atomic_temp_path(path: &Path) -> PathBuf {
79 use std::sync::atomic::{AtomicU64, Ordering};
80
81 static ATOMIC_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0);
82
83 let dir = path
84 .parent()
85 .filter(|parent| !parent.as_os_str().is_empty())
86 .unwrap_or_else(|| Path::new("."));
87 let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or("vtcode-atomic-write");
88 let nanos = std::time::SystemTime::now()
89 .duration_since(std::time::UNIX_EPOCH)
90 .map(|duration| duration.as_nanos())
91 .unwrap_or(0);
92 let counter = ATOMIC_WRITE_COUNTER.fetch_add(1, Ordering::Relaxed);
93
94 dir.join(format!(".{file_name}.tmp-{}-{nanos:x}-{counter:x}", std::process::id()))
95}
96
97pub async fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<()> {
99 let json = serde_json::to_string_pretty(data)
100 .with_context(|| format!("Failed to serialize data for {}", path.display()))?;
101
102 write_file_with_context(path, &json, "JSON data").await
103}
104
105pub async fn read_private_file_no_follow(path: &Path) -> Result<Vec<u8>> {
107 let path = path.to_path_buf();
108 tokio::task::spawn_blocking(move || crate::VtCodePaths::read_file_no_follow(&path))
109 .await
110 .context("private file read task panicked")?
111}
112
113pub async fn create_private_file(path: &Path) -> Result<std::fs::File> {
115 let path = path.to_path_buf();
116 tokio::task::spawn_blocking(move || crate::VtCodePaths::create_private_file(&path))
117 .await
118 .context("private file creation task panicked")?
119}
120
121pub async fn write_private_file_atomic(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
123 let path = path.to_path_buf();
124 let contents = contents.as_ref().to_vec();
125 tokio::task::spawn_blocking(move || crate::VtCodePaths::write_private_file_atomic(&path, &contents))
126 .await
127 .context("private file write task panicked")?
128}
129
130pub async fn write_private_file_atomic_if_absent(path: &Path, contents: impl AsRef<[u8]>) -> Result<bool> {
135 let path = path.to_path_buf();
136 let contents = contents.as_ref().to_vec();
137 tokio::task::spawn_blocking(move || crate::VtCodePaths::write_private_file_atomic_if_absent(&path, &contents))
138 .await
139 .context("private file create task panicked")?
140}
141
142pub async fn with_private_file_lock<T, F>(path: &Path, operation: F) -> Result<T>
144where
145 T: Send + 'static,
146 F: FnOnce() -> Result<T> + Send + 'static,
147{
148 let path = path.to_path_buf();
149 tokio::task::spawn_blocking(move || crate::VtCodePaths::with_private_file_lock(&path, operation))
150 .await
151 .context("private file lock task panicked")?
152}
153
154pub async fn read_private_json_file<T: DeserializeOwned>(path: &Path) -> Result<T> {
156 let contents = read_private_file_no_follow(path).await?;
157 serde_json::from_slice(&contents).with_context(|| format!("Failed to parse private JSON from {}", path.display()))
158}
159
160pub async fn write_private_json_file<T: Serialize>(path: &Path, data: &T) -> Result<()> {
162 let json = serde_json::to_vec_pretty(data)
163 .with_context(|| format!("Failed to serialize private JSON for {}", path.display()))?;
164 write_private_file_atomic(path, json).await
165}
166
167pub async fn read_json_file<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
169 let content = read_file_with_context(path, "JSON file").await?;
170
171 serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from {}", path.display()))
172}
173
174pub fn parse_json_with_context<T: for<'de> Deserialize<'de>>(content: &str, context: &str) -> Result<T> {
176 serde_json::from_str(content).with_context(|| format!("Failed to parse JSON from {context}"))
177}
178
179pub fn serialize_json_with_context<T: Serialize>(data: &T, context: &str) -> Result<String> {
181 serde_json::to_string(data).with_context(|| format!("Failed to serialize JSON for {context}"))
182}
183
184pub fn serialize_json_pretty_with_context<T: Serialize>(data: &T, context: &str) -> Result<String> {
186 serde_json::to_string_pretty(data).with_context(|| format!("Failed to pretty-serialize JSON for {context}"))
187}
188
189#[must_use]
195#[inline]
196pub fn try_parse_json<T: for<'de> Deserialize<'de>>(input: &str) -> Option<T> {
197 serde_json::from_str(input).ok()
198}
199
200#[must_use]
205#[inline]
206pub fn try_parse_json_value(input: &str) -> Option<serde_json::Value> {
207 serde_json::from_str(input).ok()
208}
209
210#[inline]
215pub fn parse_json_or_default<T: for<'de> Deserialize<'de> + Default>(input: &str, label: &str) -> T {
216 serde_json::from_str(input).unwrap_or_else(|err| {
217 tracing::debug!(label, %err, "JSON parse failed, using default");
218 T::default()
219 })
220}
221
222pub fn canonicalize_with_context(path: &Path, context: &str) -> Result<PathBuf> {
227 crate::paths::canonicalize(path)
228 .with_context(|| format!("Failed to canonicalize {} path: {}", context, path.display()))
229}
230
231pub async fn canonicalize_with_context_async(path: &Path, context: &str) -> Result<PathBuf> {
237 let path = path.to_path_buf();
238 let path_display = path.display().to_string();
239 let result = tokio::task::spawn_blocking(move || crate::paths::canonicalize(&path)).await?;
241 result.with_context(|| format!("Failed to canonicalize {context} path: {path_display}"))
242}
243
244pub async fn read_to_string_async(path: &Path) -> Result<String> {
246 fs::read_to_string(path)
247 .await
248 .with_context(|| format!("Failed to read {}", path.display()))
249}
250
251pub async fn write_async(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
253 fs::write(path, contents)
254 .await
255 .with_context(|| format!("Failed to write {}", path.display()))
256}
257
258pub async fn create_dir_all_async(path: &Path) -> Result<()> {
260 fs::create_dir_all(path)
261 .await
262 .with_context(|| format!("Failed to create {}", path.display()))
263}
264
265pub async fn remove_file_async(path: &Path) -> Result<()> {
267 fs::remove_file(path)
268 .await
269 .with_context(|| format!("Failed to remove {}", path.display()))
270}
271
272pub async fn rename_async(from: &Path, to: &Path) -> Result<()> {
274 fs::rename(from, to)
275 .await
276 .with_context(|| format!("Failed to rename {} to {}", from.display(), to.display()))
277}
278
279pub fn ensure_dir_exists_sync(path: &Path) -> Result<()> {
283 if !path.exists() {
284 std::fs::create_dir_all(path).with_context(|| format!("Failed to create directory: {}", path.display()))?;
285 }
286 Ok(())
287}
288
289pub fn read_file_with_context_sync(path: &Path, context: &str) -> Result<String> {
291 std::fs::read_to_string(path).with_context(|| format!("Failed to read {}: {}", context, path.display()))
292}
293
294pub fn write_file_with_context_sync(path: &Path, content: &str, context: &str) -> Result<()> {
296 if let Some(parent) = path.parent() {
297 ensure_dir_exists_sync(parent)?;
298 }
299 std::fs::write(path, content).with_context(|| format!("Failed to write {}: {}", context, path.display()))
300}
301
302pub fn write_json_file_sync<T: Serialize>(path: &Path, data: &T) -> Result<()> {
304 let json = serde_json::to_string_pretty(data)
305 .with_context(|| format!("Failed to serialize data for {}", path.display()))?;
306
307 write_file_with_context_sync(path, &json, "JSON data")
308}
309
310pub fn read_json_file_sync<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
312 let content = read_file_with_context_sync(path, "JSON file")?;
313
314 serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from {}", path.display()))
315}
316
317pub fn is_image_path(path: &Path) -> bool {
319 let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
320 return false;
321 };
322
323 matches!(extension, "bmp" | "gif" | "jpeg" | "jpg" | "png" | "svg" | "tif" | "tiff" | "webp")
324}
325
326pub fn is_windows_absolute_path(path: &str) -> bool {
328 let bytes = path.as_bytes();
329 bytes.len() > 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/')
330}
331
332pub fn unescape_whitespace(token: &str) -> String {
337 let mut result = String::with_capacity(token.len());
338 let mut chars = token.chars().peekable();
339 while let Some(ch) = chars.next() {
340 if ch == '\\'
341 && let Some(next) = chars.peek()
342 && next.is_ascii_whitespace()
343 {
344 result.push(*next);
345 chars.next();
346 continue;
347 }
348 result.push(ch);
349 }
350 result
351}
352
353pub fn trim_trailing_image_path<F>(raw: &str, candidate_check: F) -> &str
363where
364 F: Fn(&str) -> bool,
365{
366 if candidate_check(raw) {
367 return raw;
368 }
369 let mut candidate = raw.trim_end();
370 while let Some(last_space) = candidate.rfind(' ') {
371 candidate = &candidate[..last_space];
372 if candidate_check(candidate) {
373 return candidate;
374 }
375 }
376 raw
377}
378
379pub fn trim_trailing_image_path_str(raw: &str) -> &str {
384 trim_trailing_image_path(raw, |candidate| {
385 let unescaped = unescape_whitespace(candidate);
386 let mut path_str = unescaped.as_str();
387 if let Some(rest) = path_str.strip_prefix("file://") {
388 path_str = rest;
389 }
390 if let Some(rest) = path_str.strip_prefix("~/") {
391 if let Some(home) = dirs::home_dir() {
392 return has_supported_image_extension(&home.join(rest));
393 }
394 return false;
395 }
396 has_supported_image_extension(Path::new(path_str))
397 })
398}