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(path: &Path, content: &str, context: &str) -> Result<()> {
49 if let Some(parent) = path.parent() {
50 ensure_dir_exists(parent).await?;
51 }
52
53 let temp_path = atomic_temp_path(path);
54
55 fs::write(&temp_path, content)
56 .await
57 .with_context(|| format!("Failed to write {}: {}", context, temp_path.display()))?;
58
59 if let Err(err) = fs::rename(&temp_path, path).await {
60 let _ = fs::remove_file(&temp_path).await;
61 return Err(err).with_context(|| format!("Failed to write {}: {}", context, path.display()));
62 }
63
64 Ok(())
65}
66
67fn atomic_temp_path(path: &Path) -> PathBuf {
70 use std::sync::atomic::{AtomicU64, Ordering};
71
72 static ATOMIC_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0);
73
74 let dir = path
75 .parent()
76 .filter(|parent| !parent.as_os_str().is_empty())
77 .unwrap_or_else(|| Path::new("."));
78 let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or("vtcode-atomic-write");
79 let nanos = std::time::SystemTime::now()
80 .duration_since(std::time::UNIX_EPOCH)
81 .map(|duration| duration.as_nanos())
82 .unwrap_or(0);
83 let counter = ATOMIC_WRITE_COUNTER.fetch_add(1, Ordering::Relaxed);
84
85 dir.join(format!(".{file_name}.tmp-{}-{nanos:x}-{counter:x}", std::process::id()))
86}
87
88pub async fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<()> {
90 let json = serde_json::to_string_pretty(data)
91 .with_context(|| format!("Failed to serialize data for {}", path.display()))?;
92
93 write_file_with_context(path, &json, "JSON data").await
94}
95
96pub async fn read_json_file<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
98 let content = read_file_with_context(path, "JSON file").await?;
99
100 serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from {}", path.display()))
101}
102
103pub fn parse_json_with_context<T: for<'de> Deserialize<'de>>(content: &str, context: &str) -> Result<T> {
105 serde_json::from_str(content).with_context(|| format!("Failed to parse JSON from {context}"))
106}
107
108pub fn serialize_json_with_context<T: Serialize>(data: &T, context: &str) -> Result<String> {
110 serde_json::to_string(data).with_context(|| format!("Failed to serialize JSON for {context}"))
111}
112
113pub fn serialize_json_pretty_with_context<T: Serialize>(data: &T, context: &str) -> Result<String> {
115 serde_json::to_string_pretty(data).with_context(|| format!("Failed to pretty-serialize JSON for {context}"))
116}
117
118#[must_use]
124#[inline]
125pub fn try_parse_json<T: for<'de> Deserialize<'de>>(input: &str) -> Option<T> {
126 serde_json::from_str(input).ok()
127}
128
129#[must_use]
134#[inline]
135pub fn try_parse_json_value(input: &str) -> Option<serde_json::Value> {
136 serde_json::from_str(input).ok()
137}
138
139#[inline]
144pub fn parse_json_or_default<T: for<'de> Deserialize<'de> + Default>(input: &str, label: &str) -> T {
145 serde_json::from_str(input).unwrap_or_else(|err| {
146 tracing::debug!(label, %err, "JSON parse failed, using default");
147 T::default()
148 })
149}
150
151pub fn canonicalize_with_context(path: &Path, context: &str) -> Result<PathBuf> {
156 crate::paths::canonicalize(path)
157 .with_context(|| format!("Failed to canonicalize {} path: {}", context, path.display()))
158}
159
160pub async fn canonicalize_with_context_async(path: &Path, context: &str) -> Result<PathBuf> {
166 let path = path.to_path_buf();
167 let path_display = path.display().to_string();
168 let result = tokio::task::spawn_blocking(move || crate::paths::canonicalize(&path)).await?;
170 result.with_context(|| format!("Failed to canonicalize {context} path: {path_display}"))
171}
172
173pub async fn read_to_string_async(path: &Path) -> Result<String> {
175 fs::read_to_string(path)
176 .await
177 .with_context(|| format!("Failed to read {}", path.display()))
178}
179
180pub async fn write_async(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
182 fs::write(path, contents)
183 .await
184 .with_context(|| format!("Failed to write {}", path.display()))
185}
186
187pub async fn create_dir_all_async(path: &Path) -> Result<()> {
189 fs::create_dir_all(path)
190 .await
191 .with_context(|| format!("Failed to create {}", path.display()))
192}
193
194pub async fn remove_file_async(path: &Path) -> Result<()> {
196 fs::remove_file(path)
197 .await
198 .with_context(|| format!("Failed to remove {}", path.display()))
199}
200
201pub async fn rename_async(from: &Path, to: &Path) -> Result<()> {
203 fs::rename(from, to)
204 .await
205 .with_context(|| format!("Failed to rename {} to {}", from.display(), to.display()))
206}
207
208pub fn ensure_dir_exists_sync(path: &Path) -> Result<()> {
212 if !path.exists() {
213 std::fs::create_dir_all(path).with_context(|| format!("Failed to create directory: {}", path.display()))?;
214 }
215 Ok(())
216}
217
218pub fn read_file_with_context_sync(path: &Path, context: &str) -> Result<String> {
220 std::fs::read_to_string(path).with_context(|| format!("Failed to read {}: {}", context, path.display()))
221}
222
223pub fn write_file_with_context_sync(path: &Path, content: &str, context: &str) -> Result<()> {
225 if let Some(parent) = path.parent() {
226 ensure_dir_exists_sync(parent)?;
227 }
228 std::fs::write(path, content).with_context(|| format!("Failed to write {}: {}", context, path.display()))
229}
230
231pub fn write_json_file_sync<T: Serialize>(path: &Path, data: &T) -> Result<()> {
233 let json = serde_json::to_string_pretty(data)
234 .with_context(|| format!("Failed to serialize data for {}", path.display()))?;
235
236 write_file_with_context_sync(path, &json, "JSON data")
237}
238
239pub fn read_json_file_sync<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
241 let content = read_file_with_context_sync(path, "JSON file")?;
242
243 serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from {}", path.display()))
244}
245
246pub fn is_image_path(path: &Path) -> bool {
248 let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
249 return false;
250 };
251
252 matches!(
253 extension,
254 _ if extension.eq_ignore_ascii_case("png")
255 || extension.eq_ignore_ascii_case("jpg")
256 || extension.eq_ignore_ascii_case("jpeg")
257 || extension.eq_ignore_ascii_case("gif")
258 || extension.eq_ignore_ascii_case("bmp")
259 || extension.eq_ignore_ascii_case("webp")
260 || extension.eq_ignore_ascii_case("tiff")
261 || extension.eq_ignore_ascii_case("tif")
262 || extension.eq_ignore_ascii_case("svg")
263 )
264}
265
266pub fn is_windows_absolute_path(path: &str) -> bool {
268 let bytes = path.as_bytes();
269 bytes.len() > 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/')
270}
271
272pub fn unescape_whitespace(token: &str) -> String {
277 let mut result = String::with_capacity(token.len());
278 let mut chars = token.chars().peekable();
279 while let Some(ch) = chars.next() {
280 if ch == '\\'
281 && let Some(next) = chars.peek()
282 && next.is_ascii_whitespace()
283 {
284 result.push(*next);
285 chars.next();
286 continue;
287 }
288 result.push(ch);
289 }
290 result
291}
292
293pub fn trim_trailing_image_path<F>(raw: &str, candidate_check: F) -> &str
303where
304 F: Fn(&str) -> bool,
305{
306 if candidate_check(raw) {
307 return raw;
308 }
309 let mut candidate = raw.trim_end();
310 while let Some(last_space) = candidate.rfind(' ') {
311 candidate = &candidate[..last_space];
312 if candidate_check(candidate) {
313 return candidate;
314 }
315 }
316 raw
317}
318
319pub fn trim_trailing_image_path_str(raw: &str) -> &str {
324 trim_trailing_image_path(raw, |candidate| {
325 let unescaped = unescape_whitespace(candidate);
326 let mut path_str = unescaped.as_str();
327 if let Some(rest) = path_str.strip_prefix("file://") {
328 path_str = rest;
329 }
330 if let Some(rest) = path_str.strip_prefix("~/") {
331 if let Some(home) = dirs::home_dir() {
332 return has_supported_image_extension(&home.join(rest));
333 }
334 return false;
335 }
336 has_supported_image_extension(Path::new(path_str))
337 })
338}