1use std::path::PathBuf;
9
10#[derive(Debug, miette::Diagnostic)]
15pub enum MediaError {
16 #[diagnostic(code(nika::mime_detection_failed))]
18 MimeDetectionFailed { reason: String },
19
20 #[diagnostic(code(nika::unsupported_media_type))]
22 UnsupportedMediaType { mime_type: String, reason: String },
23
24 #[diagnostic(code(nika::media_not_found))]
26 MediaNotFound { hash: String },
27
28 #[diagnostic(code(nika::hash_mismatch))]
30 HashMismatch { expected: String, actual: String },
31
32 #[diagnostic(code(nika::media_store_io))]
34 MediaStoreIo {
35 path: PathBuf,
36 source: std::io::Error,
37 },
38
39 #[diagnostic(code(nika::base64_decode_failed))]
41 Base64DecodeFailed { source_desc: String, reason: String },
42
43 #[diagnostic(code(nika::media_too_large))]
45 Base64InputTooLarge { size: usize, max: usize },
46
47 #[diagnostic(code(nika::empty_media_content))]
49 EmptyMediaContent { task_id: String },
50
51 #[diagnostic(code(nika::run_budget_exceeded))]
53 RunBudgetExceeded { current: u64, max: u64 },
54}
55
56impl std::fmt::Display for MediaError {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 Self::MimeDetectionFailed { reason } => {
60 write!(f, "[NIKA-251] MIME detection failed: {reason}")
61 }
62
63 Self::UnsupportedMediaType { mime_type, reason } => {
64 write!(
65 f,
66 "[NIKA-252] unsupported media type '{mime_type}': {reason}"
67 )
68 }
69
70 Self::MediaNotFound { hash } => {
71 write!(f, "[NIKA-253] media not found in store: {hash}")
72 }
73
74 Self::HashMismatch { expected, actual } => {
75 write!(
76 f,
77 "[NIKA-254] CAS hash mismatch (expected {expected}, got {actual})"
78 )
79 }
80
81 Self::MediaStoreIo { path, source } => {
82 let display_path = sanitize_path_for_display(path);
85 write!(
86 f,
87 "[NIKA-255] media store I/O error at {display_path}: {source}"
88 )
89 }
90
91 Self::Base64DecodeFailed {
92 source_desc,
93 reason,
94 } => {
95 write!(
96 f,
97 "[NIKA-256] base64 decode failed for {source_desc}: {reason}"
98 )
99 }
100
101 Self::Base64InputTooLarge { size, max } => {
102 write!(
103 f,
104 "[NIKA-257] media content too large ({}, limit is {})",
105 format_size(*size as u64),
106 format_size(*max as u64),
107 )
108 }
109
110 Self::EmptyMediaContent { task_id } => {
111 if task_id == "(cas-direct)" {
112 write!(
113 f,
114 "[NIKA-258] empty media content received by CAS store \
115 (internal guard: data was empty before storage)"
116 )
117 } else {
118 write!(f, "[NIKA-258] empty media content from task '{task_id}'")
119 }
120 }
121
122 Self::RunBudgetExceeded { current, max } => {
123 let used = if *current > *max {
126 format!(
131 "attempted total: {}, limit: {}",
132 format_size(*current),
133 format_size(*max),
134 )
135 } else {
136 format!("at {}, limit: {}", format_size(*current), format_size(*max),)
137 };
138 write!(f, "[NIKA-259] run media budget exceeded ({used})")
139 }
140 }
141 }
142}
143
144impl std::error::Error for MediaError {
145 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
146 match self {
147 Self::MediaStoreIo { source, .. } => Some(source),
148 _ => None,
149 }
150 }
151}
152
153impl MediaError {
154 pub fn code(&self) -> &'static str {
158 match self {
159 Self::MimeDetectionFailed { .. } => "NIKA-251",
160 Self::UnsupportedMediaType { .. } => "NIKA-252",
161 Self::MediaNotFound { .. } => "NIKA-253",
162 Self::HashMismatch { .. } => "NIKA-254",
163 Self::MediaStoreIo { .. } => "NIKA-255",
164 Self::Base64DecodeFailed { .. } => "NIKA-256",
165 Self::Base64InputTooLarge { .. } => "NIKA-257",
166 Self::EmptyMediaContent { .. } => "NIKA-258",
167 Self::RunBudgetExceeded { .. } => "NIKA-259",
168 }
169 }
170
171 pub fn is_recoverable(&self) -> bool {
173 matches!(self, Self::MediaStoreIo { .. })
174 }
175
176 pub fn mime_detection_failed(inspected_bytes: usize, server_hint: Option<String>) -> Self {
178 let reason = match &server_hint {
179 Some(hint) => format!(
180 "could not identify file type from {inspected_bytes} bytes inspected \
181 (server hint '{hint}' was not usable)"
182 ),
183 None => format!(
184 "could not identify file type from {inspected_bytes} bytes inspected \
185 and no server MIME hint was provided"
186 ),
187 };
188 Self::MimeDetectionFailed { reason }
189 }
190}
191
192fn format_size(bytes: u64) -> String {
197 const KB: u64 = 1024;
198 const MB: u64 = KB * 1024;
199 const GB: u64 = MB * 1024;
200
201 if bytes >= GB {
202 format!("{:.1} GB", bytes as f64 / GB as f64)
203 } else if bytes >= MB {
204 format!("{:.1} MB", bytes as f64 / MB as f64)
205 } else if bytes >= KB {
206 format!("{:.1} KB", bytes as f64 / KB as f64)
207 } else {
208 format!("{bytes} bytes")
209 }
210}
211
212fn sanitize_path_for_display(path: &std::path::Path) -> String {
217 let components: Vec<_> = path.components().rev().take(2).collect();
218 match components.len() {
219 0 => "<unknown>".to_string(),
220 1 => components[0].as_os_str().to_string_lossy().to_string(),
221 _ => format!(
222 "{}/{}",
223 components[1].as_os_str().to_string_lossy(),
224 components[0].as_os_str().to_string_lossy(),
225 ),
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn format_size_human_readable() {
235 assert_eq!(format_size(0), "0 bytes");
236 assert_eq!(format_size(512), "512 bytes");
237 assert_eq!(format_size(1024), "1.0 KB");
238 assert_eq!(format_size(1_048_576), "1.0 MB");
239 assert_eq!(format_size(104_857_600), "100.0 MB");
240 assert_eq!(format_size(1_073_741_824), "1.0 GB");
241 }
242
243 #[test]
244 fn sanitize_path_shows_last_two_components() {
245 let path = PathBuf::from("/home/user/.nika/media/store/af/1349b9abc");
246 let display = sanitize_path_for_display(&path);
247 assert_eq!(display, "af/1349b9abc");
248 }
249
250 #[test]
251 fn sanitize_path_single_component() {
252 let path = PathBuf::from("filename");
253 let display = sanitize_path_for_display(&path);
254 assert_eq!(display, "filename");
255 }
256
257 #[test]
258 fn display_mime_detection_failed_no_hint() {
259 let err = MediaError::mime_detection_failed(8192, None);
260 let msg = err.to_string();
261 assert!(msg.contains("NIKA-251"), "missing code: {msg}");
262 assert!(msg.contains("8192 bytes"), "missing byte count: {msg}");
263 assert!(
264 msg.contains("no server MIME hint"),
265 "missing guidance: {msg}"
266 );
267 }
268
269 #[test]
270 fn display_mime_detection_failed_with_hint() {
271 let err = MediaError::mime_detection_failed(100, Some("application/octet-stream".into()));
272 let msg = err.to_string();
273 assert!(msg.contains("NIKA-251"), "missing code: {msg}");
274 assert!(
275 msg.contains("application/octet-stream"),
276 "missing hint: {msg}"
277 );
278 assert!(msg.contains("not usable"), "missing guidance: {msg}");
279 }
280
281 #[test]
282 fn display_mime_cross_category_conflict() {
283 let err = MediaError::MimeDetectionFailed {
284 reason: "MIME category conflict: server declared 'audio/wav' but magic bytes detected 'image/png'".into(),
285 };
286 let msg = err.to_string();
287 assert!(msg.contains("NIKA-251"), "missing code: {msg}");
288 assert!(msg.contains("audio/wav"), "missing server type: {msg}");
289 assert!(msg.contains("image/png"), "missing detected type: {msg}");
290 }
291
292 #[test]
293 fn display_base64_input_too_large_human_readable() {
294 let err = MediaError::Base64InputTooLarge {
295 size: 150 * 1024 * 1024,
296 max: 100 * 1024 * 1024,
297 };
298 let msg = err.to_string();
299 assert!(msg.contains("NIKA-257"), "missing code: {msg}");
300 assert!(msg.contains("150.0 MB"), "missing human size: {msg}");
301 assert!(msg.contains("100.0 MB"), "missing human max: {msg}");
302 assert!(
303 msg.contains("media content too large"),
304 "wrong label: {msg}"
305 );
306 assert!(
308 !msg.contains("base64 input"),
309 "should not say 'base64 input': {msg}"
310 );
311 }
312
313 #[test]
314 fn display_base64_input_too_large_small_sizes() {
315 let err = MediaError::Base64InputTooLarge {
316 size: 200,
317 max: 100,
318 };
319 let msg = err.to_string();
320 assert!(msg.contains("200 bytes"), "missing size: {msg}");
321 assert!(msg.contains("100 bytes"), "missing max: {msg}");
322 }
323
324 #[test]
325 fn display_empty_media_cas_direct() {
326 let err = MediaError::EmptyMediaContent {
327 task_id: "(cas-direct)".into(),
328 };
329 let msg = err.to_string();
330 assert!(msg.contains("NIKA-258"), "missing code: {msg}");
331 assert!(msg.contains("CAS store"), "should mention CAS: {msg}");
332 assert!(
333 msg.contains("internal guard"),
334 "should say internal guard: {msg}"
335 );
336 assert!(
338 !msg.contains("(cas-direct)"),
339 "should not show sentinel: {msg}"
340 );
341 }
342
343 #[test]
344 fn display_empty_media_with_task_id() {
345 let err = MediaError::EmptyMediaContent {
346 task_id: "generate_image".into(),
347 };
348 let msg = err.to_string();
349 assert!(msg.contains("NIKA-258"), "missing code: {msg}");
350 assert!(msg.contains("generate_image"), "missing task_id: {msg}");
351 }
352
353 #[test]
354 fn display_run_budget_exceeded_human_readable() {
355 let err = MediaError::RunBudgetExceeded {
356 current: 600 * 1024 * 1024,
357 max: 500 * 1024 * 1024,
358 };
359 let msg = err.to_string();
360 assert!(msg.contains("NIKA-259"), "missing code: {msg}");
361 assert!(msg.contains("600.0 MB"), "missing attempted total: {msg}");
362 assert!(msg.contains("500.0 MB"), "missing limit: {msg}");
363 assert!(
364 msg.contains("attempted total"),
365 "should say attempted: {msg}"
366 );
367 }
368
369 #[test]
370 fn display_media_store_io_sanitized_path() {
371 let err = MediaError::MediaStoreIo {
372 path: PathBuf::from("/Users/secret/.nika/media/store/af/1349b9"),
373 source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
374 };
375 let msg = err.to_string();
376 assert!(msg.contains("NIKA-255"), "missing code: {msg}");
377 assert!(msg.contains("af/1349b9"), "missing path tail: {msg}");
378 assert!(msg.contains("denied"), "missing OS error: {msg}");
379 assert!(!msg.contains("/Users/secret"), "leaking full path: {msg}");
381 }
382
383 #[test]
384 fn display_hash_mismatch_shows_both() {
385 let err = MediaError::HashMismatch {
386 expected: "blake3:aaaa".into(),
387 actual: "blake3:bbbb".into(),
388 };
389 let msg = err.to_string();
390 assert!(msg.contains("blake3:aaaa"), "missing expected: {msg}");
391 assert!(msg.contains("blake3:bbbb"), "missing actual: {msg}");
392 }
393
394 #[test]
395 fn all_variants_contain_error_code() {
396 let errors: Vec<MediaError> = vec![
397 MediaError::mime_detection_failed(0, None),
398 MediaError::UnsupportedMediaType {
399 mime_type: "video/mp4".into(),
400 reason: "not supported".into(),
401 },
402 MediaError::MediaNotFound {
403 hash: "blake3:xxx".into(),
404 },
405 MediaError::HashMismatch {
406 expected: "blake3:aaa".into(),
407 actual: "blake3:bbb".into(),
408 },
409 MediaError::MediaStoreIo {
410 path: "/tmp/fail".into(),
411 source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
412 },
413 MediaError::Base64DecodeFailed {
414 source_desc: "test".into(),
415 reason: "bad".into(),
416 },
417 MediaError::Base64InputTooLarge {
418 size: 200,
419 max: 100,
420 },
421 MediaError::EmptyMediaContent {
422 task_id: "t1".into(),
423 },
424 MediaError::RunBudgetExceeded {
425 current: 600,
426 max: 500,
427 },
428 ];
429
430 let expected_codes = [
431 "NIKA-251", "NIKA-252", "NIKA-253", "NIKA-254", "NIKA-255", "NIKA-256", "NIKA-257",
432 "NIKA-258", "NIKA-259",
433 ];
434
435 for (i, (err, code)) in errors.iter().zip(expected_codes.iter()).enumerate() {
436 let display = err.to_string();
437 assert!(!display.is_empty(), "Error {i} Display is empty");
438 assert!(
439 display.contains(code),
440 "Error {i} Display missing code: {display}"
441 );
442 assert_eq!(err.code(), *code, "Error {i} code mismatch");
443 }
444 }
445}