Skip to main content

nika_media/
error.rs

1//! Media pipeline error types (NIKA-251..259)
2//!
3//! Derives `miette::Diagnostic` so that
4//! NikaError can use `#[diagnostic(transparent)]`.
5//! Display is implemented manually for human-friendly error messages
6//! (with human-readable byte sizes, clear field labels, etc.).
7
8use std::path::PathBuf;
9
10/// Media pipeline errors.
11///
12/// Error codes NIKA-251 through NIKA-259 cover the media extraction,
13/// detection, storage, and processing pipeline.
14#[derive(Debug, miette::Diagnostic)]
15pub enum MediaError {
16    /// NIKA-251: Declared MIME type conflicts with detected magic bytes
17    #[diagnostic(code(nika::mime_detection_failed))]
18    MimeDetectionFailed { reason: String },
19
20    /// NIKA-252: Media type is recognized but not supported for processing
21    #[diagnostic(code(nika::unsupported_media_type))]
22    UnsupportedMediaType { mime_type: String, reason: String },
23
24    /// NIKA-253: Referenced media hash not found in CAS store
25    #[diagnostic(code(nika::media_not_found))]
26    MediaNotFound { hash: String },
27
28    /// NIKA-254: CAS read-back verification failed
29    #[diagnostic(code(nika::hash_mismatch))]
30    HashMismatch { expected: String, actual: String },
31
32    /// NIKA-255: I/O error during CAS store read or write
33    #[diagnostic(code(nika::media_store_io))]
34    MediaStoreIo {
35        path: PathBuf,
36        source: std::io::Error,
37    },
38
39    /// NIKA-256: Base64 decoding failed for media content block
40    #[diagnostic(code(nika::base64_decode_failed))]
41    Base64DecodeFailed { source_desc: String, reason: String },
42
43    /// NIKA-257: Media content exceeds maximum allowed size
44    #[diagnostic(code(nika::media_too_large))]
45    Base64InputTooLarge { size: usize, max: usize },
46
47    /// NIKA-258: Content block decoded to zero bytes
48    #[diagnostic(code(nika::empty_media_content))]
49    EmptyMediaContent { task_id: String },
50
51    /// NIKA-259: Per-run media budget exceeded
52    #[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                // Show only the filename or last 2 components to avoid leaking
83                // full filesystem paths in user-facing output.
84                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                // `current` is actually the would-be total (existing + requested).
124                // Show it as the attempted total vs the limit, with remaining info.
125                let used = if *current > *max {
126                    // The budget was already partially used; show how much was used
127                    // before this attempt. We know: current = prev + requested_size,
128                    // and prev <= max (otherwise we'd have rejected earlier).
129                    // We can't recover `prev` here, so just show the attempted total.
130                    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    /// Get the NIKA error code for this error.
155    ///
156    /// Returns `&'static str` to match `NikaError::code()` signature.
157    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    /// Check if this error is potentially recoverable through retry.
172    pub fn is_recoverable(&self) -> bool {
173        matches!(self, Self::MediaStoreIo { .. })
174    }
175
176    /// Construct MimeDetectionFailed with optional server hint.
177    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
192/// Format bytes as human-readable size for error messages.
193///
194/// Inlined here to avoid a dependency on `crate::util::fs::format_size`
195/// (media module should remain low-dependency).
196fn 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
212/// Sanitize a filesystem path for user-facing display.
213///
214/// Shows only the last 2 path components (shard dir + filename) to avoid
215/// leaking the full workspace path in error messages or logs.
216fn 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        // Must NOT say "base64 input" since CAS uses this for raw bytes too
307        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        // Should NOT show the raw "(cas-direct)" sentinel to the user
337        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        // Must NOT expose the full path
380        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}