1use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8use std::time::Duration;
9
10use crate::error::{AssetError, Result};
11
12pub const DEFAULT_MAX_CACHE_SIZE: &str = "1Gi";
14
15pub const DEFAULT_MAX_FILE_AGE: &str = "7d";
17
18#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "lowercase")]
21pub enum EvictionPolicy {
22 #[default]
24 Lru,
25 Fifo,
27 None,
30}
31
32#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
37pub struct AssetConfig {
38 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub cache_dir: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub max_cache_size: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub max_file_age: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub eviction_policy: Option<EvictionPolicy>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ResolvedAssetConfig {
56 pub cache_dir: PathBuf,
58 pub max_cache_size: u64,
60 pub max_file_age: Duration,
62 pub eviction_policy: EvictionPolicy,
63}
64
65impl AssetConfig {
66 pub fn resolve(&self) -> Result<ResolvedAssetConfig> {
68 let raw_dir = match self.cache_dir.as_deref() {
69 Some(path) => expand_path(path),
70 None => default_cache_dir()?,
71 };
72 let cache_dir = if raw_dir.is_relative() {
77 std::env::current_dir()
78 .map_err(|e| {
79 AssetError::cache_dir(format!("cannot resolve relative cache_dir: {e}"))
80 })?
81 .join(raw_dir)
82 } else {
83 raw_dir
84 };
85
86 let max_cache_size = parse_size(
87 self.max_cache_size
88 .as_deref()
89 .unwrap_or(DEFAULT_MAX_CACHE_SIZE),
90 )?;
91
92 let max_file_age =
93 parse_duration(self.max_file_age.as_deref().unwrap_or(DEFAULT_MAX_FILE_AGE))?;
94
95 Ok(ResolvedAssetConfig {
96 cache_dir,
97 max_cache_size,
98 max_file_age,
99 eviction_policy: self.eviction_policy.unwrap_or_default(),
100 })
101 }
102}
103
104pub fn default_cache_dir() -> Result<PathBuf> {
106 let base = dirs::cache_dir()
107 .ok_or_else(|| AssetError::cache_dir("unable to determine OS cache directory"))?;
108 Ok(base.join("devboy-tools").join("assets"))
109}
110
111pub fn expand_path(input: &str) -> PathBuf {
117 if let Some(rest) = input.strip_prefix("~/")
118 && let Some(home) = dirs::home_dir()
119 {
120 return home.join(rest);
121 }
122 if input == "~"
123 && let Some(home) = dirs::home_dir()
124 {
125 return home;
126 }
127 if let Some(rest) = input.strip_prefix("$HOME/")
128 && let Some(home) = dirs::home_dir()
129 {
130 return home.join(rest);
131 }
132 PathBuf::from(input)
133}
134
135pub fn parse_size(input: &str) -> Result<u64> {
148 let trimmed = input.trim();
149 if trimmed.is_empty() {
150 return Err(AssetError::config("size value is empty"));
151 }
152
153 let (number_part, unit_part) = split_value_and_unit(trimmed);
154 let number: f64 = number_part
155 .parse()
156 .map_err(|_| AssetError::config(format!("invalid size number: {number_part}")))?;
157
158 if !number.is_finite() {
159 return Err(AssetError::config(format!(
160 "non-finite size number: {input}",
161 )));
162 }
163 if number < 0.0 {
164 return Err(AssetError::config(format!("negative size: {input}")));
165 }
166
167 let mut unit = unit_part.to_ascii_lowercase();
169 if unit.ends_with('b') && unit != "b" {
170 unit.pop();
171 }
172 if unit == "b" {
173 unit.clear();
174 }
175
176 let multiplier: f64 = match unit.as_str() {
177 "" => 1.0,
178 "k" => 1_000.0,
179 "m" => 1_000_000.0,
180 "g" => 1_000_000_000.0,
181 "t" => 1_000_000_000_000.0,
182 "ki" => 1024.0,
183 "mi" => 1024f64.powi(2),
184 "gi" => 1024f64.powi(3),
185 "ti" => 1024f64.powi(4),
186 other => {
187 return Err(AssetError::config(format!("unknown size unit: {other}")));
188 }
189 };
190
191 let bytes = number * multiplier;
194 if !bytes.is_finite() || bytes < 0.0 || bytes > u64::MAX as f64 {
195 return Err(AssetError::config(format!(
196 "size overflows u64 bytes: {input}",
197 )));
198 }
199 Ok(bytes as u64)
200}
201
202pub fn parse_duration(input: &str) -> Result<Duration> {
207 let trimmed = input.trim();
208 if trimmed.is_empty() {
209 return Err(AssetError::config("duration value is empty"));
210 }
211
212 let (number_part, unit_part) = split_value_and_unit(trimmed);
213 if unit_part.is_empty() {
214 return Err(AssetError::config(format!(
215 "duration requires a unit suffix (s/m/h/d/w): {input}"
216 )));
217 }
218
219 let number: u64 = number_part
220 .parse()
221 .map_err(|_| AssetError::config(format!("invalid duration number: {number_part}")))?;
222
223 let multiplier: u64 = match unit_part.to_ascii_lowercase().as_str() {
224 "s" => 1,
225 "m" => 60,
226 "h" => 3_600,
227 "d" => 86_400,
228 "w" => 604_800,
229 other => {
230 return Err(AssetError::config(format!(
231 "unknown duration unit: {other}"
232 )));
233 }
234 };
235
236 let seconds = number
239 .checked_mul(multiplier)
240 .ok_or_else(|| AssetError::config(format!("duration overflows u64 seconds: {input}",)))?;
241
242 Ok(Duration::from_secs(seconds))
243}
244
245fn split_value_and_unit(input: &str) -> (&str, &str) {
247 let split_at = input
248 .find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-'))
249 .unwrap_or(input.len());
250 let (num, rest) = input.split_at(split_at);
251 (num.trim(), rest.trim())
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn size_parses_binary_units() {
260 assert_eq!(parse_size("1Ki").unwrap(), 1024);
261 assert_eq!(parse_size("1Mi").unwrap(), 1024 * 1024);
262 assert_eq!(parse_size("1Gi").unwrap(), 1024 * 1024 * 1024);
263 assert_eq!(parse_size("2GiB").unwrap(), 2 * 1024 * 1024 * 1024);
264 }
265
266 #[test]
267 fn size_parses_si_units() {
268 assert_eq!(parse_size("1K").unwrap(), 1_000);
269 assert_eq!(parse_size("5M").unwrap(), 5_000_000);
270 assert_eq!(parse_size("1G").unwrap(), 1_000_000_000);
271 }
272
273 #[test]
274 fn size_parses_plain_bytes() {
275 assert_eq!(parse_size("1024").unwrap(), 1024);
276 assert_eq!(parse_size("100B").unwrap(), 100);
277 assert_eq!(parse_size("0").unwrap(), 0);
278 }
279
280 #[test]
281 fn size_is_case_insensitive_and_whitespace_tolerant() {
282 assert_eq!(parse_size("1 gi").unwrap(), 1024 * 1024 * 1024);
283 assert_eq!(parse_size(" 2 MI ").unwrap(), 2 * 1024 * 1024);
284 }
285
286 #[test]
287 fn size_rejects_garbage() {
288 assert!(parse_size("").is_err());
289 assert!(parse_size("abc").is_err());
290 assert!(parse_size("1Zi").is_err());
291 assert!(parse_size("-1Gi").is_err());
292 }
293
294 #[test]
295 fn size_rejects_non_finite_values() {
296 assert!(parse_size("NaN").is_err());
299 assert!(parse_size("NaNGi").is_err());
300 assert!(parse_size("inf").is_err());
301 assert!(parse_size("infGi").is_err());
302 }
303
304 #[test]
305 fn size_rejects_overflow() {
306 assert!(parse_size("1e30").is_err());
308 }
309
310 #[test]
311 fn duration_parses_common_suffixes() {
312 assert_eq!(parse_duration("45s").unwrap(), Duration::from_secs(45));
313 assert_eq!(parse_duration("30m").unwrap(), Duration::from_secs(30 * 60));
314 assert_eq!(parse_duration("24h").unwrap(), Duration::from_secs(86_400));
315 assert_eq!(
316 parse_duration("7d").unwrap(),
317 Duration::from_secs(7 * 86_400)
318 );
319 assert_eq!(
320 parse_duration("2w").unwrap(),
321 Duration::from_secs(2 * 7 * 86_400)
322 );
323 }
324
325 #[test]
326 fn duration_rejects_invalid_input() {
327 assert!(parse_duration("").is_err());
328 assert!(parse_duration("10").is_err(), "missing unit");
329 assert!(parse_duration("abc").is_err());
330 assert!(parse_duration("1y").is_err(), "years not supported");
331 }
332
333 #[test]
334 fn duration_detects_overflow() {
335 let huge = format!("{}w", u64::MAX);
337 let err = parse_duration(&huge).unwrap_err();
338 assert!(matches!(err, AssetError::Config(_)));
339 assert!(err.to_string().contains("overflows"));
340 }
341
342 #[test]
343 fn expand_path_handles_tilde() {
344 let expanded = expand_path("~/foo");
345 let home = dirs::home_dir().unwrap();
346 assert_eq!(expanded, home.join("foo"));
347
348 let plain = expand_path("/tmp/devboy");
349 assert_eq!(plain, PathBuf::from("/tmp/devboy"));
350 }
351
352 #[test]
353 fn resolve_uses_defaults_when_empty() {
354 let cfg = AssetConfig::default();
355 let resolved = cfg.resolve().unwrap();
356 assert_eq!(
357 resolved.max_cache_size,
358 parse_size(DEFAULT_MAX_CACHE_SIZE).unwrap()
359 );
360 assert_eq!(
361 resolved.max_file_age,
362 parse_duration(DEFAULT_MAX_FILE_AGE).unwrap()
363 );
364 assert_eq!(resolved.eviction_policy, EvictionPolicy::Lru);
365 }
366
367 #[test]
368 fn resolve_honors_user_values() {
369 let tmp = tempfile::tempdir().unwrap();
370 let dir = tmp.path().to_string_lossy().to_string();
371 let cfg = AssetConfig {
372 cache_dir: Some(dir.clone()),
373 max_cache_size: Some("500Mi".into()),
374 max_file_age: Some("24h".into()),
375 eviction_policy: Some(EvictionPolicy::Fifo),
376 };
377 let resolved = cfg.resolve().unwrap();
378 assert!(resolved.cache_dir.is_absolute());
380 assert_eq!(resolved.cache_dir, PathBuf::from(&dir));
381 assert_eq!(resolved.max_cache_size, 500 * 1024 * 1024);
382 assert_eq!(resolved.max_file_age, Duration::from_secs(86_400));
383 assert_eq!(resolved.eviction_policy, EvictionPolicy::Fifo);
384 }
385
386 #[test]
387 fn resolve_absolutizes_relative_cache_dir() {
388 let cfg = AssetConfig {
389 cache_dir: Some("relative/path".into()),
390 ..Default::default()
391 };
392 let resolved = cfg.resolve().unwrap();
393 assert!(
394 resolved.cache_dir.is_absolute(),
395 "relative cache_dir should be absolutized: {:?}",
396 resolved.cache_dir,
397 );
398 assert!(resolved.cache_dir.ends_with("relative/path"));
399 }
400
401 #[test]
402 fn eviction_policy_serde() {
403 let toml_str = r#"eviction_policy = "lru""#;
404 let cfg: AssetConfig = toml::from_str(toml_str).unwrap();
405 assert_eq!(cfg.eviction_policy, Some(EvictionPolicy::Lru));
406
407 let toml_str = r#"eviction_policy = "none""#;
408 let cfg: AssetConfig = toml::from_str(toml_str).unwrap();
409 assert_eq!(cfg.eviction_policy, Some(EvictionPolicy::None));
410 }
411
412 #[test]
413 fn asset_config_toml_roundtrip() {
414 let toml_str = r#"
415cache_dir = "/tmp/custom"
416max_cache_size = "2Gi"
417max_file_age = "3d"
418eviction_policy = "lru"
419"#;
420 let cfg: AssetConfig = toml::from_str(toml_str).unwrap();
421 assert_eq!(cfg.cache_dir.as_deref(), Some("/tmp/custom"));
422 assert_eq!(cfg.max_cache_size.as_deref(), Some("2Gi"));
423
424 let resolved = cfg.resolve().unwrap();
425 assert_eq!(resolved.max_cache_size, 2 * 1024 * 1024 * 1024);
426 assert_eq!(resolved.max_file_age, Duration::from_secs(3 * 86_400));
427 }
428}