Skip to main content

devboy_assets/
config.rs

1//! Configuration for the asset cache subsystem.
2//!
3//! Mirrors the `[assets]` section of `devboy.toml` and provides human-readable
4//! parsers for sizes (e.g. `"1Gi"`) and durations (e.g. `"7d"`).
5
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8use std::time::Duration;
9
10use crate::error::{AssetError, Result};
11
12/// Default cache size limit: 1 GiB.
13pub const DEFAULT_MAX_CACHE_SIZE: &str = "1Gi";
14
15/// Default maximum file age before LRU eviction kicks in: 7 days.
16pub const DEFAULT_MAX_FILE_AGE: &str = "7d";
17
18/// Eviction strategy applied by the rotator when the cache is over budget.
19#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "lowercase")]
21pub enum EvictionPolicy {
22    /// Least-recently-used (based on `last_accessed_ms` in the index).
23    #[default]
24    Lru,
25    /// First-in-first-out (based on `downloaded_at_ms` in the index).
26    Fifo,
27    /// Do not evict anything — useful for tests or when an external janitor
28    /// is managing cache size.
29    None,
30}
31
32/// Raw configuration as loaded from `devboy.toml`.
33///
34/// Use [`AssetConfig::resolve`] to produce a validated [`ResolvedAssetConfig`]
35/// with absolute paths and parsed sizes / durations.
36#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
37pub struct AssetConfig {
38    /// Custom cache directory. If unset, falls back to
39    /// `dirs::cache_dir()/devboy-tools/assets`.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub cache_dir: Option<String>,
42    /// Maximum cache size in human-readable form, e.g. `"1Gi"`.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub max_cache_size: Option<String>,
45    /// Maximum age for an unaccessed file before it becomes eligible for
46    /// eviction, e.g. `"7d"`.
47    #[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/// Fully validated asset configuration with parsed values.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ResolvedAssetConfig {
56    /// Absolute path to the cache directory.
57    pub cache_dir: PathBuf,
58    /// Hard limit on total cache size in bytes.
59    pub max_cache_size: u64,
60    /// Maximum age for an unaccessed file before eviction is allowed.
61    pub max_file_age: Duration,
62    pub eviction_policy: EvictionPolicy,
63}
64
65impl AssetConfig {
66    /// Parse and validate the configuration, applying defaults where needed.
67    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        // Ensure the resolved path is always absolute. A relative path
73        // would make the cache location depend on the process working
74        // directory and violate the documented contract on
75        // `ResolvedAssetConfig::cache_dir`.
76        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
104/// Default cache directory used when the user did not configure one.
105pub 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
111/// Expand a user-provided path — supports leading `~` and `$HOME` only.
112///
113/// We intentionally avoid pulling in a shell-expansion crate: configuration
114/// paths are authored by a human and the two cases above cover everything
115/// real users do.
116pub 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
135// =============================================================================
136// Human-readable parsers
137// =============================================================================
138
139/// Parse a human-readable size string into a byte count.
140///
141/// Accepts:
142/// - Plain numbers: `1024` → 1024 bytes
143/// - SI: `K`, `M`, `G`, `T` (1000-based)
144/// - Binary: `Ki`, `Mi`, `Gi`, `Ti` (1024-based)
145/// - With an optional trailing `B` (e.g. `1Gi`, `1GiB`)
146/// - Case-insensitive; whitespace between digits and unit is ignored
147pub 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    // Normalize: strip trailing B/b and lowercase
168    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    // Check the result is still in range. `as u64` would otherwise clamp
192    // NaN to 0 and silently cap anything past `u64::MAX`.
193    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
202/// Parse a human-readable duration such as `"7d"`, `"24h"`, `"30m"`, `"45s"`.
203///
204/// The suffix is required and may be one of `s`, `m`, `h`, `d`, `w`.
205/// Whitespace between the number and the suffix is allowed.
206pub 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    // Use checked_mul so comically large inputs surface as a config error
237    // instead of silently wrapping in release builds.
238    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
245/// Split a human-readable value string into numeric and unit parts.
246fn 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        // NaN / infinity parse as f64 but must be rejected — otherwise
297        // `as u64` would silently cast them to 0 or clamp.
298        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        // 1e30 bytes is far beyond u64::MAX (~1.8e19).
307        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        // u64::MAX weeks is guaranteed to overflow: 604_800 * MAX >> u64::MAX.
336        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        // The resolved path must be absolute and match the configured dir.
379        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}