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_json_file<T: Serialize>(path: &Path, data: &T) -> Result<()> {
39 let json = serde_json::to_string_pretty(data)
40 .with_context(|| format!("Failed to serialize data for {}", path.display()))?;
41
42 write_file_with_context(path, &json, "JSON data").await
43}
44
45pub async fn read_json_file<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
47 let content = read_file_with_context(path, "JSON file").await?;
48
49 serde_json::from_str(&content)
50 .with_context(|| format!("Failed to parse JSON from {}", path.display()))
51}
52
53pub fn parse_json_with_context<T: for<'de> Deserialize<'de>>(
55 content: &str,
56 context: &str,
57) -> Result<T> {
58 serde_json::from_str(content).with_context(|| format!("Failed to parse JSON from {context}"))
59}
60
61pub fn serialize_json_with_context<T: Serialize>(data: &T, context: &str) -> Result<String> {
63 serde_json::to_string(data).with_context(|| format!("Failed to serialize JSON for {context}"))
64}
65
66pub fn serialize_json_pretty_with_context<T: Serialize>(data: &T, context: &str) -> Result<String> {
68 serde_json::to_string_pretty(data)
69 .with_context(|| format!("Failed to pretty-serialize JSON for {context}"))
70}
71
72#[must_use]
78#[inline]
79pub fn try_parse_json<T: for<'de> Deserialize<'de>>(input: &str) -> Option<T> {
80 serde_json::from_str(input).ok()
81}
82
83#[must_use]
88#[inline]
89pub fn try_parse_json_value(input: &str) -> Option<serde_json::Value> {
90 serde_json::from_str(input).ok()
91}
92
93#[inline]
98pub fn parse_json_or_default<T: for<'de> Deserialize<'de> + Default>(
99 input: &str,
100 label: &str,
101) -> T {
102 serde_json::from_str(input).unwrap_or_else(|err| {
103 tracing::debug!(label, %err, "JSON parse failed, using default");
104 T::default()
105 })
106}
107
108pub fn canonicalize_with_context(path: &Path, context: &str) -> Result<PathBuf> {
110 path.canonicalize().with_context(|| {
111 format!(
112 "Failed to canonicalize {} path: {}",
113 context,
114 path.display()
115 )
116 })
117}
118
119pub async fn canonicalize_with_context_async(path: &Path, context: &str) -> Result<PathBuf> {
121 fs::canonicalize(path).await.with_context(|| {
122 format!(
123 "Failed to canonicalize {} path: {}",
124 context,
125 path.display()
126 )
127 })
128}
129
130pub async fn read_to_string_async(path: &Path) -> Result<String> {
132 fs::read_to_string(path)
133 .await
134 .with_context(|| format!("Failed to read {}", path.display()))
135}
136
137pub async fn write_async(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
139 fs::write(path, contents)
140 .await
141 .with_context(|| format!("Failed to write {}", path.display()))
142}
143
144pub async fn create_dir_all_async(path: &Path) -> Result<()> {
146 fs::create_dir_all(path)
147 .await
148 .with_context(|| format!("Failed to create {}", path.display()))
149}
150
151pub async fn remove_file_async(path: &Path) -> Result<()> {
153 fs::remove_file(path)
154 .await
155 .with_context(|| format!("Failed to remove {}", path.display()))
156}
157
158pub async fn rename_async(from: &Path, to: &Path) -> Result<()> {
160 fs::rename(from, to)
161 .await
162 .with_context(|| format!("Failed to rename {} to {}", from.display(), to.display()))
163}
164
165pub fn ensure_dir_exists_sync(path: &Path) -> Result<()> {
169 if !path.exists() {
170 std::fs::create_dir_all(path)
171 .with_context(|| format!("Failed to create directory: {}", path.display()))?;
172 }
173 Ok(())
174}
175
176pub fn read_file_with_context_sync(path: &Path, context: &str) -> Result<String> {
178 std::fs::read_to_string(path)
179 .with_context(|| format!("Failed to read {}: {}", context, path.display()))
180}
181
182pub fn write_file_with_context_sync(path: &Path, content: &str, context: &str) -> Result<()> {
184 if let Some(parent) = path.parent() {
185 ensure_dir_exists_sync(parent)?;
186 }
187 std::fs::write(path, content)
188 .with_context(|| format!("Failed to write {}: {}", context, path.display()))
189}
190
191pub fn write_json_file_sync<T: Serialize>(path: &Path, data: &T) -> Result<()> {
193 let json = serde_json::to_string_pretty(data)
194 .with_context(|| format!("Failed to serialize data for {}", path.display()))?;
195
196 write_file_with_context_sync(path, &json, "JSON data")
197}
198
199pub fn read_json_file_sync<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
201 let content = read_file_with_context_sync(path, "JSON file")?;
202
203 serde_json::from_str(&content)
204 .with_context(|| format!("Failed to parse JSON from {}", path.display()))
205}
206
207pub fn is_image_path(path: &Path) -> bool {
209 let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
210 return false;
211 };
212
213 matches!(
214 extension,
215 _ if extension.eq_ignore_ascii_case("png")
216 || extension.eq_ignore_ascii_case("jpg")
217 || extension.eq_ignore_ascii_case("jpeg")
218 || extension.eq_ignore_ascii_case("gif")
219 || extension.eq_ignore_ascii_case("bmp")
220 || extension.eq_ignore_ascii_case("webp")
221 || extension.eq_ignore_ascii_case("tiff")
222 || extension.eq_ignore_ascii_case("tif")
223 || extension.eq_ignore_ascii_case("svg")
224 )
225}
226
227pub fn is_windows_absolute_path(path: &str) -> bool {
229 let bytes = path.as_bytes();
230 bytes.len() > 2
231 && bytes[0].is_ascii_alphabetic()
232 && bytes[1] == b':'
233 && (bytes[2] == b'\\' || bytes[2] == b'/')
234}
235
236pub fn unescape_whitespace(token: &str) -> String {
241 let mut result = String::with_capacity(token.len());
242 let mut chars = token.chars().peekable();
243 while let Some(ch) = chars.next() {
244 if ch == '\\'
245 && let Some(next) = chars.peek()
246 && next.is_ascii_whitespace()
247 {
248 result.push(*next);
249 chars.next();
250 continue;
251 }
252 result.push(ch);
253 }
254 result
255}
256
257pub fn trim_trailing_image_path<F>(raw: &str, candidate_check: F) -> &str
267where
268 F: Fn(&str) -> bool,
269{
270 if candidate_check(raw) {
271 return raw;
272 }
273 let mut candidate = raw.trim_end();
274 while let Some(last_space) = candidate.rfind(' ') {
275 candidate = &candidate[..last_space];
276 if candidate_check(candidate) {
277 return candidate;
278 }
279 }
280 raw
281}
282
283pub fn trim_trailing_image_path_str(raw: &str) -> &str {
288 trim_trailing_image_path(raw, |candidate| {
289 let unescaped = unescape_whitespace(candidate);
290 let mut path_str = unescaped.as_str();
291 if let Some(rest) = path_str.strip_prefix("file://") {
292 path_str = rest;
293 }
294 if let Some(rest) = path_str.strip_prefix("~/") {
295 if let Some(home) = dirs::home_dir() {
296 return has_supported_image_extension(&home.join(rest));
297 }
298 return false;
299 }
300 has_supported_image_extension(Path::new(path_str))
301 })
302}