1use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use tokio::fs;
7
8use crate::image::has_supported_image_extension;
9
10pub async fn ensure_dir_exists(path: &Path) -> Result<()> {
12 if !path.exists() {
13 fs::create_dir_all(path)
14 .await
15 .with_context(|| format!("Failed to create directory: {}", path.display()))?;
16 }
17 Ok(())
18}
19
20pub async fn read_file_with_context(path: &Path, context: &str) -> Result<String> {
22 fs::read_to_string(path)
23 .await
24 .with_context(|| format!("Failed to read {}: {}", context, path.display()))
25}
26
27pub async fn write_file_with_context(path: &Path, content: &str, context: &str) -> Result<()> {
29 if let Some(parent) = path.parent() {
30 ensure_dir_exists(parent).await?;
31 }
32 fs::write(path, content)
33 .await
34 .with_context(|| format!("Failed to write {}: {}", context, path.display()))
35}
36
37pub async fn write_file_atomic_with_context(
49 path: &Path,
50 content: &str,
51 context: &str,
52) -> Result<()> {
53 if let Some(parent) = path.parent() {
54 ensure_dir_exists(parent).await?;
55 }
56
57 let temp_path = atomic_temp_path(path);
58
59 fs::write(&temp_path, content)
60 .await
61 .with_context(|| format!("Failed to write {}: {}", context, temp_path.display()))?;
62
63 if let Err(err) = fs::rename(&temp_path, path).await {
64 let _ = fs::remove_file(&temp_path).await;
65 return Err(err)
66 .with_context(|| format!("Failed to write {}: {}", context, path.display()));
67 }
68
69 Ok(())
70}
71
72fn atomic_temp_path(path: &Path) -> PathBuf {
75 use std::sync::atomic::{AtomicU64, Ordering};
76
77 static ATOMIC_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0);
78
79 let dir = path
80 .parent()
81 .filter(|parent| !parent.as_os_str().is_empty())
82 .unwrap_or_else(|| Path::new("."));
83 let file_name = path
84 .file_name()
85 .and_then(|name| name.to_str())
86 .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!(
94 ".{file_name}.tmp-{}-{nanos:x}-{counter:x}",
95 std::process::id()
96 ))
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_json_file<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
109 let content = read_file_with_context(path, "JSON file").await?;
110
111 serde_json::from_str(&content)
112 .with_context(|| format!("Failed to parse JSON from {}", path.display()))
113}
114
115pub fn parse_json_with_context<T: for<'de> Deserialize<'de>>(
117 content: &str,
118 context: &str,
119) -> Result<T> {
120 serde_json::from_str(content).with_context(|| format!("Failed to parse JSON from {context}"))
121}
122
123pub fn serialize_json_with_context<T: Serialize>(data: &T, context: &str) -> Result<String> {
125 serde_json::to_string(data).with_context(|| format!("Failed to serialize JSON for {context}"))
126}
127
128pub fn serialize_json_pretty_with_context<T: Serialize>(data: &T, context: &str) -> Result<String> {
130 serde_json::to_string_pretty(data)
131 .with_context(|| format!("Failed to pretty-serialize JSON for {context}"))
132}
133
134#[must_use]
140#[inline]
141pub fn try_parse_json<T: for<'de> Deserialize<'de>>(input: &str) -> Option<T> {
142 serde_json::from_str(input).ok()
143}
144
145#[must_use]
150#[inline]
151pub fn try_parse_json_value(input: &str) -> Option<serde_json::Value> {
152 serde_json::from_str(input).ok()
153}
154
155#[inline]
160pub fn parse_json_or_default<T: for<'de> Deserialize<'de> + Default>(
161 input: &str,
162 label: &str,
163) -> T {
164 serde_json::from_str(input).unwrap_or_else(|err| {
165 tracing::debug!(label, %err, "JSON parse failed, using default");
166 T::default()
167 })
168}
169
170pub fn canonicalize_with_context(path: &Path, context: &str) -> Result<PathBuf> {
172 path.canonicalize().with_context(|| {
173 format!(
174 "Failed to canonicalize {} path: {}",
175 context,
176 path.display()
177 )
178 })
179}
180
181pub async fn canonicalize_with_context_async(path: &Path, context: &str) -> Result<PathBuf> {
183 fs::canonicalize(path).await.with_context(|| {
184 format!(
185 "Failed to canonicalize {} path: {}",
186 context,
187 path.display()
188 )
189 })
190}
191
192pub async fn read_to_string_async(path: &Path) -> Result<String> {
194 fs::read_to_string(path)
195 .await
196 .with_context(|| format!("Failed to read {}", path.display()))
197}
198
199pub async fn write_async(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
201 fs::write(path, contents)
202 .await
203 .with_context(|| format!("Failed to write {}", path.display()))
204}
205
206pub async fn create_dir_all_async(path: &Path) -> Result<()> {
208 fs::create_dir_all(path)
209 .await
210 .with_context(|| format!("Failed to create {}", path.display()))
211}
212
213pub async fn remove_file_async(path: &Path) -> Result<()> {
215 fs::remove_file(path)
216 .await
217 .with_context(|| format!("Failed to remove {}", path.display()))
218}
219
220pub async fn rename_async(from: &Path, to: &Path) -> Result<()> {
222 fs::rename(from, to)
223 .await
224 .with_context(|| format!("Failed to rename {} to {}", from.display(), to.display()))
225}
226
227pub fn ensure_dir_exists_sync(path: &Path) -> Result<()> {
231 if !path.exists() {
232 std::fs::create_dir_all(path)
233 .with_context(|| format!("Failed to create directory: {}", path.display()))?;
234 }
235 Ok(())
236}
237
238pub fn read_file_with_context_sync(path: &Path, context: &str) -> Result<String> {
240 std::fs::read_to_string(path)
241 .with_context(|| format!("Failed to read {}: {}", context, path.display()))
242}
243
244pub fn write_file_with_context_sync(path: &Path, content: &str, context: &str) -> Result<()> {
246 if let Some(parent) = path.parent() {
247 ensure_dir_exists_sync(parent)?;
248 }
249 std::fs::write(path, content)
250 .with_context(|| format!("Failed to write {}: {}", context, path.display()))
251}
252
253pub fn write_json_file_sync<T: Serialize>(path: &Path, data: &T) -> Result<()> {
255 let json = serde_json::to_string_pretty(data)
256 .with_context(|| format!("Failed to serialize data for {}", path.display()))?;
257
258 write_file_with_context_sync(path, &json, "JSON data")
259}
260
261pub fn read_json_file_sync<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
263 let content = read_file_with_context_sync(path, "JSON file")?;
264
265 serde_json::from_str(&content)
266 .with_context(|| format!("Failed to parse JSON from {}", path.display()))
267}
268
269pub fn is_image_path(path: &Path) -> bool {
271 let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
272 return false;
273 };
274
275 matches!(
276 extension,
277 _ if extension.eq_ignore_ascii_case("png")
278 || extension.eq_ignore_ascii_case("jpg")
279 || extension.eq_ignore_ascii_case("jpeg")
280 || extension.eq_ignore_ascii_case("gif")
281 || extension.eq_ignore_ascii_case("bmp")
282 || extension.eq_ignore_ascii_case("webp")
283 || extension.eq_ignore_ascii_case("tiff")
284 || extension.eq_ignore_ascii_case("tif")
285 || extension.eq_ignore_ascii_case("svg")
286 )
287}
288
289pub fn is_windows_absolute_path(path: &str) -> bool {
291 let bytes = path.as_bytes();
292 bytes.len() > 2
293 && bytes[0].is_ascii_alphabetic()
294 && bytes[1] == b':'
295 && (bytes[2] == b'\\' || bytes[2] == b'/')
296}
297
298pub fn unescape_whitespace(token: &str) -> String {
303 let mut result = String::with_capacity(token.len());
304 let mut chars = token.chars().peekable();
305 while let Some(ch) = chars.next() {
306 if ch == '\\'
307 && let Some(next) = chars.peek()
308 && next.is_ascii_whitespace()
309 {
310 result.push(*next);
311 chars.next();
312 continue;
313 }
314 result.push(ch);
315 }
316 result
317}
318
319pub fn trim_trailing_image_path<F>(raw: &str, candidate_check: F) -> &str
329where
330 F: Fn(&str) -> bool,
331{
332 if candidate_check(raw) {
333 return raw;
334 }
335 let mut candidate = raw.trim_end();
336 while let Some(last_space) = candidate.rfind(' ') {
337 candidate = &candidate[..last_space];
338 if candidate_check(candidate) {
339 return candidate;
340 }
341 }
342 raw
343}
344
345pub fn trim_trailing_image_path_str(raw: &str) -> &str {
350 trim_trailing_image_path(raw, |candidate| {
351 let unescaped = unescape_whitespace(candidate);
352 let mut path_str = unescaped.as_str();
353 if let Some(rest) = path_str.strip_prefix("file://") {
354 path_str = rest;
355 }
356 if let Some(rest) = path_str.strip_prefix("~/") {
357 if let Some(home) = dirs::home_dir() {
358 return has_supported_image_extension(&home.join(rest));
359 }
360 return false;
361 }
362 has_supported_image_extension(Path::new(path_str))
363 })
364}