use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use crate::error::{AssetError, Result};
pub const DEFAULT_MAX_CACHE_SIZE: &str = "1Gi";
pub const DEFAULT_MAX_FILE_AGE: &str = "7d";
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum EvictionPolicy {
#[default]
Lru,
Fifo,
None,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct AssetConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_cache_size: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_file_age: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub eviction_policy: Option<EvictionPolicy>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedAssetConfig {
pub cache_dir: PathBuf,
pub max_cache_size: u64,
pub max_file_age: Duration,
pub eviction_policy: EvictionPolicy,
}
impl AssetConfig {
pub fn resolve(&self) -> Result<ResolvedAssetConfig> {
let raw_dir = match self.cache_dir.as_deref() {
Some(path) => expand_path(path),
None => default_cache_dir()?,
};
let cache_dir = if raw_dir.is_relative() {
std::env::current_dir()
.map_err(|e| {
AssetError::cache_dir(format!("cannot resolve relative cache_dir: {e}"))
})?
.join(raw_dir)
} else {
raw_dir
};
let max_cache_size = parse_size(
self.max_cache_size
.as_deref()
.unwrap_or(DEFAULT_MAX_CACHE_SIZE),
)?;
let max_file_age =
parse_duration(self.max_file_age.as_deref().unwrap_or(DEFAULT_MAX_FILE_AGE))?;
Ok(ResolvedAssetConfig {
cache_dir,
max_cache_size,
max_file_age,
eviction_policy: self.eviction_policy.unwrap_or_default(),
})
}
}
pub fn default_cache_dir() -> Result<PathBuf> {
let base = dirs::cache_dir()
.ok_or_else(|| AssetError::cache_dir("unable to determine OS cache directory"))?;
Ok(base.join("devboy-tools").join("assets"))
}
pub fn expand_path(input: &str) -> PathBuf {
if let Some(rest) = input.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest);
}
if input == "~"
&& let Some(home) = dirs::home_dir()
{
return home;
}
if let Some(rest) = input.strip_prefix("$HOME/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest);
}
PathBuf::from(input)
}
pub fn parse_size(input: &str) -> Result<u64> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(AssetError::config("size value is empty"));
}
let (number_part, unit_part) = split_value_and_unit(trimmed);
let number: f64 = number_part
.parse()
.map_err(|_| AssetError::config(format!("invalid size number: {number_part}")))?;
if !number.is_finite() {
return Err(AssetError::config(format!(
"non-finite size number: {input}",
)));
}
if number < 0.0 {
return Err(AssetError::config(format!("negative size: {input}")));
}
let mut unit = unit_part.to_ascii_lowercase();
if unit.ends_with('b') && unit != "b" {
unit.pop();
}
if unit == "b" {
unit.clear();
}
let multiplier: f64 = match unit.as_str() {
"" => 1.0,
"k" => 1_000.0,
"m" => 1_000_000.0,
"g" => 1_000_000_000.0,
"t" => 1_000_000_000_000.0,
"ki" => 1024.0,
"mi" => 1024f64.powi(2),
"gi" => 1024f64.powi(3),
"ti" => 1024f64.powi(4),
other => {
return Err(AssetError::config(format!("unknown size unit: {other}")));
}
};
let bytes = number * multiplier;
if !bytes.is_finite() || bytes < 0.0 || bytes > u64::MAX as f64 {
return Err(AssetError::config(format!(
"size overflows u64 bytes: {input}",
)));
}
Ok(bytes as u64)
}
pub fn parse_duration(input: &str) -> Result<Duration> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(AssetError::config("duration value is empty"));
}
let (number_part, unit_part) = split_value_and_unit(trimmed);
if unit_part.is_empty() {
return Err(AssetError::config(format!(
"duration requires a unit suffix (s/m/h/d/w): {input}"
)));
}
let number: u64 = number_part
.parse()
.map_err(|_| AssetError::config(format!("invalid duration number: {number_part}")))?;
let multiplier: u64 = match unit_part.to_ascii_lowercase().as_str() {
"s" => 1,
"m" => 60,
"h" => 3_600,
"d" => 86_400,
"w" => 604_800,
other => {
return Err(AssetError::config(format!(
"unknown duration unit: {other}"
)));
}
};
let seconds = number
.checked_mul(multiplier)
.ok_or_else(|| AssetError::config(format!("duration overflows u64 seconds: {input}",)))?;
Ok(Duration::from_secs(seconds))
}
fn split_value_and_unit(input: &str) -> (&str, &str) {
let split_at = input
.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-'))
.unwrap_or(input.len());
let (num, rest) = input.split_at(split_at);
(num.trim(), rest.trim())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn size_parses_binary_units() {
assert_eq!(parse_size("1Ki").unwrap(), 1024);
assert_eq!(parse_size("1Mi").unwrap(), 1024 * 1024);
assert_eq!(parse_size("1Gi").unwrap(), 1024 * 1024 * 1024);
assert_eq!(parse_size("2GiB").unwrap(), 2 * 1024 * 1024 * 1024);
}
#[test]
fn size_parses_si_units() {
assert_eq!(parse_size("1K").unwrap(), 1_000);
assert_eq!(parse_size("5M").unwrap(), 5_000_000);
assert_eq!(parse_size("1G").unwrap(), 1_000_000_000);
}
#[test]
fn size_parses_plain_bytes() {
assert_eq!(parse_size("1024").unwrap(), 1024);
assert_eq!(parse_size("100B").unwrap(), 100);
assert_eq!(parse_size("0").unwrap(), 0);
}
#[test]
fn size_is_case_insensitive_and_whitespace_tolerant() {
assert_eq!(parse_size("1 gi").unwrap(), 1024 * 1024 * 1024);
assert_eq!(parse_size(" 2 MI ").unwrap(), 2 * 1024 * 1024);
}
#[test]
fn size_rejects_garbage() {
assert!(parse_size("").is_err());
assert!(parse_size("abc").is_err());
assert!(parse_size("1Zi").is_err());
assert!(parse_size("-1Gi").is_err());
}
#[test]
fn size_rejects_non_finite_values() {
assert!(parse_size("NaN").is_err());
assert!(parse_size("NaNGi").is_err());
assert!(parse_size("inf").is_err());
assert!(parse_size("infGi").is_err());
}
#[test]
fn size_rejects_overflow() {
assert!(parse_size("1e30").is_err());
}
#[test]
fn duration_parses_common_suffixes() {
assert_eq!(parse_duration("45s").unwrap(), Duration::from_secs(45));
assert_eq!(parse_duration("30m").unwrap(), Duration::from_secs(30 * 60));
assert_eq!(parse_duration("24h").unwrap(), Duration::from_secs(86_400));
assert_eq!(
parse_duration("7d").unwrap(),
Duration::from_secs(7 * 86_400)
);
assert_eq!(
parse_duration("2w").unwrap(),
Duration::from_secs(2 * 7 * 86_400)
);
}
#[test]
fn duration_rejects_invalid_input() {
assert!(parse_duration("").is_err());
assert!(parse_duration("10").is_err(), "missing unit");
assert!(parse_duration("abc").is_err());
assert!(parse_duration("1y").is_err(), "years not supported");
}
#[test]
fn duration_detects_overflow() {
let huge = format!("{}w", u64::MAX);
let err = parse_duration(&huge).unwrap_err();
assert!(matches!(err, AssetError::Config(_)));
assert!(err.to_string().contains("overflows"));
}
#[test]
fn expand_path_handles_tilde() {
let expanded = expand_path("~/foo");
let home = dirs::home_dir().unwrap();
assert_eq!(expanded, home.join("foo"));
let plain = expand_path("/tmp/devboy");
assert_eq!(plain, PathBuf::from("/tmp/devboy"));
}
#[test]
fn resolve_uses_defaults_when_empty() {
let cfg = AssetConfig::default();
let resolved = cfg.resolve().unwrap();
assert_eq!(
resolved.max_cache_size,
parse_size(DEFAULT_MAX_CACHE_SIZE).unwrap()
);
assert_eq!(
resolved.max_file_age,
parse_duration(DEFAULT_MAX_FILE_AGE).unwrap()
);
assert_eq!(resolved.eviction_policy, EvictionPolicy::Lru);
}
#[test]
fn resolve_honors_user_values() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_string_lossy().to_string();
let cfg = AssetConfig {
cache_dir: Some(dir.clone()),
max_cache_size: Some("500Mi".into()),
max_file_age: Some("24h".into()),
eviction_policy: Some(EvictionPolicy::Fifo),
};
let resolved = cfg.resolve().unwrap();
assert!(resolved.cache_dir.is_absolute());
assert_eq!(resolved.cache_dir, PathBuf::from(&dir));
assert_eq!(resolved.max_cache_size, 500 * 1024 * 1024);
assert_eq!(resolved.max_file_age, Duration::from_secs(86_400));
assert_eq!(resolved.eviction_policy, EvictionPolicy::Fifo);
}
#[test]
fn resolve_absolutizes_relative_cache_dir() {
let cfg = AssetConfig {
cache_dir: Some("relative/path".into()),
..Default::default()
};
let resolved = cfg.resolve().unwrap();
assert!(
resolved.cache_dir.is_absolute(),
"relative cache_dir should be absolutized: {:?}",
resolved.cache_dir,
);
assert!(resolved.cache_dir.ends_with("relative/path"));
}
#[test]
fn eviction_policy_serde() {
let toml_str = r#"eviction_policy = "lru""#;
let cfg: AssetConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.eviction_policy, Some(EvictionPolicy::Lru));
let toml_str = r#"eviction_policy = "none""#;
let cfg: AssetConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.eviction_policy, Some(EvictionPolicy::None));
}
#[test]
fn asset_config_toml_roundtrip() {
let toml_str = r#"
cache_dir = "/tmp/custom"
max_cache_size = "2Gi"
max_file_age = "3d"
eviction_policy = "lru"
"#;
let cfg: AssetConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.cache_dir.as_deref(), Some("/tmp/custom"));
assert_eq!(cfg.max_cache_size.as_deref(), Some("2Gi"));
let resolved = cfg.resolve().unwrap();
assert_eq!(resolved.max_cache_size, 2 * 1024 * 1024 * 1024);
assert_eq!(resolved.max_file_age, Duration::from_secs(3 * 86_400));
}
}