use crate::cache::lockfile::{Ecosystem, LockfileInfo};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
pub fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
pub fn gb_to_bytes(gb: u32) -> u64 {
u64::from(gb) * 1024 * 1024 * 1024
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheSizeStatus {
Ok,
Warning,
Exceeded,
}
impl CacheSizeStatus {
pub fn from_usage(current_bytes: u64, limit_bytes: u64) -> Self {
if limit_bytes == 0 {
return Self::Ok;
}
let percent = (current_bytes as f64 / limit_bytes as f64) * 100.0;
if percent >= 100.0 {
Self::Exceeded
} else if percent >= 80.0 {
Self::Warning
} else {
Self::Ok
}
}
pub fn percentage(current_bytes: u64, limit_bytes: u64) -> f64 {
if limit_bytes == 0 {
return 0.0;
}
(current_bytes as f64 / limit_bytes as f64) * 100.0
}
}
pub mod labels {
pub const MINO_CACHE: &str = "io.mino.cache";
pub const ECOSYSTEM: &str = "io.mino.cache.ecosystem";
pub const HASH: &str = "io.mino.cache.hash";
pub const STATE: &str = "io.mino.cache.state";
pub const CREATED_AT: &str = "io.mino.cache.created_at";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CacheState {
Miss,
Building,
Complete,
}
impl CacheState {
pub fn from_label(s: &str) -> Self {
match s {
"complete" => Self::Complete,
"building" => Self::Building,
_ => Self::Building,
}
}
pub fn as_label(&self) -> &'static str {
match self {
Self::Miss => "building",
Self::Building => "building",
Self::Complete => "complete",
}
}
}
impl fmt::Display for CacheState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Miss => write!(f, "miss"),
Self::Building => write!(f, "building"),
Self::Complete => write!(f, "complete"),
}
}
}
#[derive(Debug, Clone)]
pub struct CacheVolume {
pub name: String,
pub ecosystem: Ecosystem,
pub hash: String,
pub state: CacheState,
pub created_at: DateTime<Utc>,
pub size_bytes: Option<u64>,
}
impl CacheVolume {
pub fn new(ecosystem: Ecosystem, hash: String, state: CacheState) -> Self {
Self {
name: format!("mino-cache-{}-{}", ecosystem, hash),
ecosystem,
hash,
state,
created_at: Utc::now(),
size_bytes: None,
}
}
pub fn from_lockfile(info: &LockfileInfo, state: CacheState) -> Self {
Self::new(info.ecosystem, info.hash.clone(), state)
}
pub fn labels(&self) -> HashMap<String, String> {
let mut labels = HashMap::new();
labels.insert(labels::MINO_CACHE.to_string(), "true".to_string());
labels.insert(labels::ECOSYSTEM.to_string(), self.ecosystem.to_string());
labels.insert(labels::HASH.to_string(), self.hash.clone());
labels.insert(labels::STATE.to_string(), self.state.as_label().to_string());
labels.insert(labels::CREATED_AT.to_string(), self.created_at.to_rfc3339());
labels
}
fn parse_ecosystem(s: &str) -> Option<Ecosystem> {
match s {
"npm" => Some(Ecosystem::Npm),
"yarn" => Some(Ecosystem::Yarn),
"pnpm" => Some(Ecosystem::Pnpm),
"cargo" => Some(Ecosystem::Cargo),
"pip" => Some(Ecosystem::Pip),
"poetry" => Some(Ecosystem::Poetry),
"uv" => Some(Ecosystem::Uv),
"go" => Some(Ecosystem::Go),
_ => None,
}
}
pub fn from_labels(name: &str, labels: &HashMap<String, String>) -> Option<Self> {
if labels.get(labels::MINO_CACHE) != Some(&"true".to_string()) {
return None;
}
let ecosystem = labels
.get(labels::ECOSYSTEM)
.and_then(|s| Self::parse_ecosystem(s))?;
let hash = labels.get(labels::HASH)?.clone();
let state = labels
.get(labels::STATE)
.map(|s| CacheState::from_label(s))
.unwrap_or(CacheState::Building);
let created_at = labels
.get(labels::CREATED_AT)
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
Some(Self {
name: name.to_string(),
ecosystem,
hash,
state,
created_at,
size_bytes: None,
})
}
pub fn is_older_than_days(&self, days: u32) -> bool {
let cutoff = Utc::now() - chrono::Duration::days(i64::from(days));
self.created_at < cutoff
}
}
#[derive(Debug, Clone)]
pub struct CacheMount {
pub volume_name: String,
pub container_path: String,
pub ecosystem: Ecosystem,
}
impl CacheMount {
pub fn volume_arg(&self) -> String {
format!("{}:{}", self.volume_name, self.container_path)
}
}
pub fn plan_cache_mounts(lockfiles: &[LockfileInfo]) -> Vec<CacheMount> {
lockfiles
.iter()
.map(|info| CacheMount {
volume_name: info.volume_name(),
container_path: "/cache".to_string(),
ecosystem: info.ecosystem,
})
.collect()
}
pub async fn resolve_state(volume_name: &str, label_state: CacheState) -> CacheState {
match crate::cache::sidecar::CacheSidecar::load(volume_name).await {
Ok(Some(sidecar)) => sidecar.state,
_ => label_state, }
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn cache_state_label_roundtrip() {
for state in [CacheState::Building, CacheState::Complete] {
let label = state.as_label();
let parsed = CacheState::from_label(label);
assert_eq!(parsed, state);
}
}
#[test]
fn cache_volume_new() {
let vol = CacheVolume::new(
Ecosystem::Npm,
"abc123def456".to_string(),
CacheState::Building,
);
assert_eq!(vol.name, "mino-cache-npm-abc123def456");
assert_eq!(vol.ecosystem, Ecosystem::Npm);
assert_eq!(vol.state, CacheState::Building);
}
#[test]
fn cache_volume_from_lockfile() {
let info = LockfileInfo {
ecosystem: Ecosystem::Cargo,
path: PathBuf::from("/test/Cargo.lock"),
hash: "a1b2c3d4e5f6".to_string(),
};
let vol = CacheVolume::from_lockfile(&info, CacheState::Complete);
assert_eq!(vol.name, "mino-cache-cargo-a1b2c3d4e5f6");
assert_eq!(vol.ecosystem, Ecosystem::Cargo);
}
#[test]
fn cache_volume_labels() {
let vol = CacheVolume::new(Ecosystem::Npm, "abc123".to_string(), CacheState::Building);
let labels = vol.labels();
assert_eq!(labels.get(labels::MINO_CACHE), Some(&"true".to_string()));
assert_eq!(labels.get(labels::ECOSYSTEM), Some(&"npm".to_string()));
assert_eq!(labels.get(labels::HASH), Some(&"abc123".to_string()));
assert_eq!(labels.get(labels::STATE), Some(&"building".to_string()));
}
#[test]
fn cache_volume_from_labels() {
let mut labels = HashMap::new();
labels.insert(labels::MINO_CACHE.to_string(), "true".to_string());
labels.insert(labels::ECOSYSTEM.to_string(), "cargo".to_string());
labels.insert(labels::HASH.to_string(), "xyz789".to_string());
labels.insert(labels::STATE.to_string(), "complete".to_string());
labels.insert(
labels::CREATED_AT.to_string(),
"2024-01-15T10:00:00Z".to_string(),
);
let vol = CacheVolume::from_labels("mino-cache-cargo-xyz789", &labels).unwrap();
assert_eq!(vol.ecosystem, Ecosystem::Cargo);
assert_eq!(vol.hash, "xyz789");
assert_eq!(vol.state, CacheState::Complete);
}
#[test]
fn cache_mount_volume_arg() {
let mount = CacheMount {
volume_name: "mino-cache-npm-abc123".to_string(),
container_path: "/cache".to_string(),
ecosystem: Ecosystem::Npm,
};
assert_eq!(mount.volume_arg(), "mino-cache-npm-abc123:/cache");
}
#[test]
fn plan_cache_mounts_creates_mounts() {
let lockfiles = vec![LockfileInfo {
ecosystem: Ecosystem::Npm,
path: PathBuf::from("/test/package-lock.json"),
hash: "abc123def456".to_string(),
}];
let mounts = plan_cache_mounts(&lockfiles);
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].volume_name, "mino-cache-npm-abc123def456");
assert_eq!(mounts[0].container_path, "/cache");
}
#[test]
fn cache_volume_from_labels_uv() {
let mut labels = HashMap::new();
labels.insert(labels::MINO_CACHE.to_string(), "true".to_string());
labels.insert(labels::ECOSYSTEM.to_string(), "uv".to_string());
labels.insert(labels::HASH.to_string(), "uvhash123456".to_string());
labels.insert(labels::STATE.to_string(), "building".to_string());
labels.insert(
labels::CREATED_AT.to_string(),
"2024-06-01T12:00:00Z".to_string(),
);
let vol = CacheVolume::from_labels("mino-cache-uv-uvhash123456", &labels).unwrap();
assert_eq!(vol.ecosystem, Ecosystem::Uv);
assert_eq!(vol.hash, "uvhash123456");
assert_eq!(vol.state, CacheState::Building);
}
}