use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MediaRef {
pub hash: String,
pub mime_type: String,
pub size_bytes: u64,
pub path: PathBuf,
pub extension: String,
pub created_by: String,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub metadata: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)] pub enum MediaType {
Image,
Audio,
Document,
Unknown,
}
impl MediaType {
#[allow(dead_code)] pub fn from_mime(mime: &str) -> Self {
if mime.starts_with("image/") {
Self::Image
} else if mime.starts_with("audio/") {
Self::Audio
} else if mime.starts_with("application/pdf")
|| mime.starts_with("application/vnd.openxmlformats")
|| mime.starts_with("application/msword")
{
Self::Document
} else {
Self::Unknown
}
}
}
pub struct MediaBudget {
run_bytes: AtomicU64,
max_per_run: u64,
}
impl MediaBudget {
pub const DEFAULT_MAX_PER_RUN: u64 = 500 * 1024 * 1024;
pub fn new() -> Self {
Self {
run_bytes: AtomicU64::new(0),
max_per_run: Self::DEFAULT_MAX_PER_RUN,
}
}
pub fn with_max_per_run(max_per_run: u64) -> Self {
Self {
run_bytes: AtomicU64::new(0),
max_per_run,
}
}
pub fn check_and_add(&self, size: u64, _task_id: &str) -> Result<(), super::error::MediaError> {
let result = self
.run_bytes
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
let new_total = current + size;
if new_total > self.max_per_run {
None } else {
Some(new_total) }
});
match result {
Ok(_) => Ok(()),
Err(current) => Err(super::error::MediaError::RunBudgetExceeded {
current: current + size,
max: self.max_per_run,
}),
}
}
pub fn rollback(&self, size: u64) {
let _ = self
.run_bytes
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
Some(current.saturating_sub(size))
});
}
pub fn current_bytes(&self) -> u64 {
self.run_bytes.load(Ordering::Acquire)
}
}
impl Default for MediaBudget {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn media_budget_under_limit() {
let budget = MediaBudget::with_max_per_run(1024);
assert!(budget.check_and_add(512, "t1").is_ok());
assert_eq!(budget.current_bytes(), 512);
}
#[test]
fn media_budget_exceeds_limit() {
let budget = MediaBudget::with_max_per_run(1024);
budget.check_and_add(512, "t1").unwrap();
let err = budget.check_and_add(1024, "t2").unwrap_err();
assert_eq!(err.code(), "NIKA-259");
assert_eq!(budget.current_bytes(), 512);
}
#[test]
fn media_budget_accumulated_tracking() {
let budget = MediaBudget::with_max_per_run(1000);
budget.check_and_add(300, "t1").unwrap();
budget.check_and_add(300, "t2").unwrap();
budget.check_and_add(300, "t3").unwrap();
assert_eq!(budget.current_bytes(), 900);
assert!(budget.check_and_add(200, "t4").is_err());
}
#[test]
fn media_ref_serde_roundtrip() {
let mr = MediaRef {
hash: "blake3:af1349b9".to_string(),
mime_type: "image/png".to_string(),
size_bytes: 4096,
path: PathBuf::from("/tmp/store/af/1349b9"),
extension: "png".to_string(),
created_by: "gen_img".to_string(),
metadata: serde_json::Map::new(),
};
let json = serde_json::to_string(&mr).unwrap();
let back: MediaRef = serde_json::from_str(&json).unwrap();
assert_eq!(mr, back);
assert!(json.contains("blake3:af1349b9"));
}
#[test]
fn media_type_from_mime() {
assert_eq!(MediaType::from_mime("image/png"), MediaType::Image);
assert_eq!(MediaType::from_mime("image/jpeg"), MediaType::Image);
assert_eq!(MediaType::from_mime("audio/wav"), MediaType::Audio);
assert_eq!(MediaType::from_mime("audio/mpeg"), MediaType::Audio);
assert_eq!(MediaType::from_mime("application/pdf"), MediaType::Document);
assert_eq!(MediaType::from_mime("text/plain"), MediaType::Unknown);
}
#[test]
fn media_budget_rollback_restores_capacity() {
let budget = MediaBudget::with_max_per_run(1000);
budget.check_and_add(600, "t1").unwrap();
assert_eq!(budget.current_bytes(), 600);
budget.rollback(600);
assert_eq!(
budget.current_bytes(),
0,
"rollback should restore budget to 0"
);
budget.check_and_add(900, "t2").unwrap();
assert_eq!(budget.current_bytes(), 900);
}
#[test]
fn media_budget_partial_rollback() {
let budget = MediaBudget::with_max_per_run(1000);
budget.check_and_add(300, "t1").unwrap();
budget.check_and_add(400, "t2").unwrap();
assert_eq!(budget.current_bytes(), 700);
budget.rollback(400);
assert_eq!(
budget.current_bytes(),
300,
"partial rollback should leave t1's allocation intact"
);
budget.check_and_add(700, "t3").unwrap();
assert_eq!(budget.current_bytes(), 1000);
}
#[test]
fn media_budget_rollback_then_reuse() {
let budget = MediaBudget::with_max_per_run(100);
budget.check_and_add(100, "t1").unwrap();
assert!(
budget.check_and_add(1, "t2").is_err(),
"should be at capacity"
);
budget.rollback(100);
assert_eq!(budget.current_bytes(), 0);
budget.check_and_add(100, "t3").unwrap();
assert_eq!(budget.current_bytes(), 100);
}
}