1use std::path::PathBuf;
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct MediaRef {
15 pub hash: String,
17
18 pub mime_type: String,
20
21 pub size_bytes: u64,
23
24 pub path: PathBuf,
26
27 pub extension: String,
29
30 pub created_by: String,
32
33 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
37 pub metadata: serde_json::Map<String, serde_json::Value>,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43#[allow(dead_code)] pub enum MediaType {
45 Image,
46 Audio,
47 Document,
48 Unknown,
49}
50
51impl MediaType {
52 #[allow(dead_code)] pub fn from_mime(mime: &str) -> Self {
55 if mime.starts_with("image/") {
56 Self::Image
57 } else if mime.starts_with("audio/") {
58 Self::Audio
59 } else if mime.starts_with("application/pdf")
60 || mime.starts_with("application/vnd.openxmlformats")
61 || mime.starts_with("application/msword")
62 {
63 Self::Document
64 } else {
65 Self::Unknown
66 }
67 }
68}
69
70pub struct MediaBudget {
76 run_bytes: AtomicU64,
77 max_per_run: u64,
78}
79
80impl MediaBudget {
81 pub const DEFAULT_MAX_PER_RUN: u64 = 500 * 1024 * 1024;
83
84 pub fn new() -> Self {
86 Self {
87 run_bytes: AtomicU64::new(0),
88 max_per_run: Self::DEFAULT_MAX_PER_RUN,
89 }
90 }
91
92 pub fn with_max_per_run(max_per_run: u64) -> Self {
94 Self {
95 run_bytes: AtomicU64::new(0),
96 max_per_run,
97 }
98 }
99
100 pub fn check_and_add(&self, size: u64, _task_id: &str) -> Result<(), super::error::MediaError> {
105 let result = self
106 .run_bytes
107 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
108 let new_total = current + size;
109 if new_total > self.max_per_run {
110 None } else {
112 Some(new_total) }
114 });
115 match result {
116 Ok(_) => Ok(()),
117 Err(current) => Err(super::error::MediaError::RunBudgetExceeded {
118 current: current + size,
119 max: self.max_per_run,
120 }),
121 }
122 }
123
124 pub fn rollback(&self, size: u64) {
130 let _ = self
131 .run_bytes
132 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
133 Some(current.saturating_sub(size))
134 });
135 }
136
137 pub fn current_bytes(&self) -> u64 {
139 self.run_bytes.load(Ordering::Acquire)
140 }
141}
142
143impl Default for MediaBudget {
144 fn default() -> Self {
145 Self::new()
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn media_budget_under_limit() {
155 let budget = MediaBudget::with_max_per_run(1024);
156 assert!(budget.check_and_add(512, "t1").is_ok());
157 assert_eq!(budget.current_bytes(), 512);
158 }
159
160 #[test]
161 fn media_budget_exceeds_limit() {
162 let budget = MediaBudget::with_max_per_run(1024);
163 budget.check_and_add(512, "t1").unwrap();
164 let err = budget.check_and_add(1024, "t2").unwrap_err();
165 assert_eq!(err.code(), "NIKA-259");
166 assert_eq!(budget.current_bytes(), 512);
168 }
169
170 #[test]
171 fn media_budget_accumulated_tracking() {
172 let budget = MediaBudget::with_max_per_run(1000);
173 budget.check_and_add(300, "t1").unwrap();
174 budget.check_and_add(300, "t2").unwrap();
175 budget.check_and_add(300, "t3").unwrap();
176 assert_eq!(budget.current_bytes(), 900);
177 assert!(budget.check_and_add(200, "t4").is_err());
179 }
180
181 #[test]
182 fn media_ref_serde_roundtrip() {
183 let mr = MediaRef {
184 hash: "blake3:af1349b9".to_string(),
185 mime_type: "image/png".to_string(),
186 size_bytes: 4096,
187 path: PathBuf::from("/tmp/store/af/1349b9"),
188 extension: "png".to_string(),
189 created_by: "gen_img".to_string(),
190 metadata: serde_json::Map::new(),
191 };
192 let json = serde_json::to_string(&mr).unwrap();
193 let back: MediaRef = serde_json::from_str(&json).unwrap();
194 assert_eq!(mr, back);
195 assert!(json.contains("blake3:af1349b9"));
196 }
197
198 #[test]
199 fn media_type_from_mime() {
200 assert_eq!(MediaType::from_mime("image/png"), MediaType::Image);
201 assert_eq!(MediaType::from_mime("image/jpeg"), MediaType::Image);
202 assert_eq!(MediaType::from_mime("audio/wav"), MediaType::Audio);
203 assert_eq!(MediaType::from_mime("audio/mpeg"), MediaType::Audio);
204 assert_eq!(MediaType::from_mime("application/pdf"), MediaType::Document);
205 assert_eq!(MediaType::from_mime("text/plain"), MediaType::Unknown);
206 }
207
208 #[test]
214 fn media_budget_rollback_restores_capacity() {
215 let budget = MediaBudget::with_max_per_run(1000);
216
217 budget.check_and_add(600, "t1").unwrap();
219 assert_eq!(budget.current_bytes(), 600);
220
221 budget.rollback(600);
223 assert_eq!(
224 budget.current_bytes(),
225 0,
226 "rollback should restore budget to 0"
227 );
228
229 budget.check_and_add(900, "t2").unwrap();
231 assert_eq!(budget.current_bytes(), 900);
232 }
233
234 #[test]
235 fn media_budget_partial_rollback() {
236 let budget = MediaBudget::with_max_per_run(1000);
237
238 budget.check_and_add(300, "t1").unwrap();
239 budget.check_and_add(400, "t2").unwrap();
240 assert_eq!(budget.current_bytes(), 700);
241
242 budget.rollback(400);
244 assert_eq!(
245 budget.current_bytes(),
246 300,
247 "partial rollback should leave t1's allocation intact"
248 );
249
250 budget.check_and_add(700, "t3").unwrap();
252 assert_eq!(budget.current_bytes(), 1000);
253 }
254
255 #[test]
256 fn media_budget_rollback_then_reuse() {
257 let budget = MediaBudget::with_max_per_run(100);
258
259 budget.check_and_add(100, "t1").unwrap();
261 assert!(
262 budget.check_and_add(1, "t2").is_err(),
263 "should be at capacity"
264 );
265
266 budget.rollback(100);
268 assert_eq!(budget.current_bytes(), 0);
269
270 budget.check_and_add(100, "t3").unwrap();
272 assert_eq!(budget.current_bytes(), 100);
273 }
274}