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 read_private_json_file<T: DeserializeOwned>(path: &Path) -> Result<T> {
132 let contents = read_private_file_no_follow(path).await?;
133 serde_json::from_slice(&contents).with_context(|| format!("Failed to parse private JSON from {}", path.display()))
134}
135
136pub async fn write_private_json_file<T: Serialize>(path: &Path, data: &T) -> Result<()> {
138 let json = serde_json::to_vec_pretty(data)
139 .with_context(|| format!("Failed to serialize private JSON for {}", path.display()))?;
140 write_private_file_atomic(path, json).await
141}
142
143pub async fn read_json_file<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
145 let content = read_file_with_context(path, "JSON file").await?;
146
147 serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from {}", path.display()))
148}
149
150pub fn parse_json_with_context<T: for<'de> Deserialize<'de>>(content: &str, context: &str) -> Result<T> {
152 serde_json::from_str(content).with_context(|| format!("Failed to parse JSON from {context}"))
153}
154
155pub fn serialize_json_with_context<T: Serialize>(data: &T, context: &str) -> Result<String> {
157 serde_json::to_string(data).with_context(|| format!("Failed to serialize JSON for {context}"))
158}
159
160pub fn serialize_json_pretty_with_context<T: Serialize>(data: &T, context: &str) -> Result<String> {
162 serde_json::to_string_pretty(data).with_context(|| format!("Failed to pretty-serialize JSON for {context}"))
163}
164
165#[must_use]
171#[inline]
172pub fn try_parse_json<T: for<'de> Deserialize<'de>>(input: &str) -> Option<T> {
173 serde_json::from_str(input).ok()
174}
175
176#[must_use]
181#[inline]
182pub fn try_parse_json_value(input: &str) -> Option<serde_json::Value> {
183 serde_json::from_str(input).ok()
184}
185
186#[inline]
191pub fn parse_json_or_default<T: for<'de> Deserialize<'de> + Default>(input: &str, label: &str) -> T {
192 serde_json::from_str(input).unwrap_or_else(|err| {
193 tracing::debug!(label, %err, "JSON parse failed, using default");
194 T::default()
195 })
196}
197
198pub fn canonicalize_with_context(path: &Path, context: &str) -> Result<PathBuf> {
203 crate::paths::canonicalize(path)
204 .with_context(|| format!("Failed to canonicalize {} path: {}", context, path.display()))
205}
206
207pub async fn canonicalize_with_context_async(path: &Path, context: &str) -> Result<PathBuf> {
213 let path = path.to_path_buf();
214 let path_display = path.display().to_string();
215 let result = tokio::task::spawn_blocking(move || crate::paths::canonicalize(&path)).await?;
217 result.with_context(|| format!("Failed to canonicalize {context} path: {path_display}"))
218}
219
220pub async fn read_to_string_async(path: &Path) -> Result<String> {
222 fs::read_to_string(path)
223 .await
224 .with_context(|| format!("Failed to read {}", path.display()))
225}
226
227pub async fn write_async(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
229 fs::write(path, contents)
230 .await
231 .with_context(|| format!("Failed to write {}", path.display()))
232}
233
234pub async fn create_dir_all_async(path: &Path) -> Result<()> {
236 fs::create_dir_all(path)
237 .await
238 .with_context(|| format!("Failed to create {}", path.display()))
239}
240
241pub async fn remove_file_async(path: &Path) -> Result<()> {
243 fs::remove_file(path)
244 .await
245 .with_context(|| format!("Failed to remove {}", path.display()))
246}
247
248pub async fn rename_async(from: &Path, to: &Path) -> Result<()> {
250 fs::rename(from, to)
251 .await
252 .with_context(|| format!("Failed to rename {} to {}", from.display(), to.display()))
253}
254
255pub fn ensure_dir_exists_sync(path: &Path) -> Result<()> {
259 if !path.exists() {
260 std::fs::create_dir_all(path).with_context(|| format!("Failed to create directory: {}", path.display()))?;
261 }
262 Ok(())
263}
264
265pub fn read_file_with_context_sync(path: &Path, context: &str) -> Result<String> {
267 std::fs::read_to_string(path).with_context(|| format!("Failed to read {}: {}", context, path.display()))
268}
269
270pub fn write_file_with_context_sync(path: &Path, content: &str, context: &str) -> Result<()> {
272 if let Some(parent) = path.parent() {
273 ensure_dir_exists_sync(parent)?;
274 }
275 std::fs::write(path, content).with_context(|| format!("Failed to write {}: {}", context, path.display()))
276}
277
278pub fn write_json_file_sync<T: Serialize>(path: &Path, data: &T) -> Result<()> {
280 let json = serde_json::to_string_pretty(data)
281 .with_context(|| format!("Failed to serialize data for {}", path.display()))?;
282
283 write_file_with_context_sync(path, &json, "JSON data")
284}
285
286pub fn read_json_file_sync<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
288 let content = read_file_with_context_sync(path, "JSON file")?;
289
290 serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from {}", path.display()))
291}
292
293pub fn is_image_path(path: &Path) -> bool {
295 let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
296 return false;
297 };
298
299 matches!(extension, "bmp" | "gif" | "jpeg" | "jpg" | "png" | "svg" | "tif" | "tiff" | "webp")
300}
301
302pub fn is_windows_absolute_path(path: &str) -> bool {
304 let bytes = path.as_bytes();
305 bytes.len() > 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/')
306}
307
308pub fn unescape_whitespace(token: &str) -> String {
313 let mut result = String::with_capacity(token.len());
314 let mut chars = token.chars().peekable();
315 while let Some(ch) = chars.next() {
316 if ch == '\\'
317 && let Some(next) = chars.peek()
318 && next.is_ascii_whitespace()
319 {
320 result.push(*next);
321 chars.next();
322 continue;
323 }
324 result.push(ch);
325 }
326 result
327}
328
329pub fn trim_trailing_image_path<F>(raw: &str, candidate_check: F) -> &str
339where
340 F: Fn(&str) -> bool,
341{
342 if candidate_check(raw) {
343 return raw;
344 }
345 let mut candidate = raw.trim_end();
346 while let Some(last_space) = candidate.rfind(' ') {
347 candidate = &candidate[..last_space];
348 if candidate_check(candidate) {
349 return candidate;
350 }
351 }
352 raw
353}
354
355pub fn trim_trailing_image_path_str(raw: &str) -> &str {
360 trim_trailing_image_path(raw, |candidate| {
361 let unescaped = unescape_whitespace(candidate);
362 let mut path_str = unescaped.as_str();
363 if let Some(rest) = path_str.strip_prefix("file://") {
364 path_str = rest;
365 }
366 if let Some(rest) = path_str.strip_prefix("~/") {
367 if let Some(home) = dirs::home_dir() {
368 return has_supported_image_extension(&home.join(rest));
369 }
370 return false;
371 }
372 has_supported_image_extension(Path::new(path_str))
373 })
374}