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