1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22#[serde(tag = "type", rename_all = "snake_case")]
23pub enum ArtifactKind {
24 Image {
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 width: Option<u32>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 height: Option<u32>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 format: Option<String>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 frame_index: Option<u32>,
41 },
42 Video {
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 width: Option<u32>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 height: Option<u32>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 fps: Option<f32>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 duration_secs: Option<f32>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 format: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 frame_count: Option<u32>,
65 },
66 Binary {
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 mime_type: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 byte_size: Option<u64>,
77 },
78 Text {
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 encoding: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 line_count: Option<u64>,
88 },
89
90 MemorySummary {
99 entry_count: usize,
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 entry_ids: Vec<String>,
106 },
107}
108
109impl ArtifactKind {
110 pub fn is_image(&self) -> bool {
112 matches!(self, Self::Image { .. })
113 }
114
115 pub fn is_video(&self) -> bool {
117 matches!(self, Self::Video { .. })
118 }
119
120 pub fn is_binary(&self) -> bool {
122 matches!(self, Self::Binary { .. })
123 }
124
125 pub fn is_text(&self) -> bool {
127 matches!(self, Self::Text { .. })
128 }
129
130 pub fn is_memory_summary(&self) -> bool {
132 matches!(self, Self::MemorySummary { .. })
133 }
134
135 pub fn display_label(&self) -> String {
137 match self {
138 Self::Image { format, .. } => match format.as_deref() {
139 Some(fmt) => format!("{} image", fmt),
140 None => "image".to_string(),
141 },
142 Self::Video { format, .. } => match format.as_deref() {
143 Some(fmt) => format!("{} video", fmt),
144 None => "video".to_string(),
145 },
146 Self::Binary { mime_type, .. } => match mime_type.as_deref() {
147 Some(mime) => format!("binary ({})", mime),
148 None => "binary".to_string(),
149 },
150 Self::Text { encoding, .. } => match encoding.as_deref() {
151 Some(enc) => format!("text ({})", enc),
152 None => "text".to_string(),
153 },
154 Self::MemorySummary { entry_count, .. } => {
155 format!("memory summary ({} entries)", entry_count)
156 }
157 }
158 }
159
160 pub fn video_metadata_summary(&self) -> String {
165 let Self::Video {
166 width,
167 height,
168 fps,
169 duration_secs,
170 format,
171 ..
172 } = self
173 else {
174 return String::new();
175 };
176
177 let mut parts: Vec<String> = Vec::new();
178 if let (Some(w), Some(h)) = (width, height) {
179 parts.push(format!("{}×{}", w, h));
180 }
181 if let Some(f) = fps {
182 parts.push(format!("{}fps", f));
183 }
184 if let Some(d) = duration_secs {
185 parts.push(format!("{:.1}s", d));
186 }
187 if let Some(fmt) = format {
188 parts.push(fmt.clone());
189 }
190
191 if parts.is_empty() {
192 "Video".to_string()
193 } else {
194 format!("Video: {}", parts.join(", "))
195 }
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn image_roundtrip_full() {
205 let kind = ArtifactKind::Image {
206 width: Some(1024),
207 height: Some(1024),
208 format: Some("PNG".to_string()),
209 frame_index: Some(0),
210 };
211 let json = serde_json::to_string(&kind).unwrap();
212 let back: ArtifactKind = serde_json::from_str(&json).unwrap();
213 assert_eq!(kind, back);
214 }
215
216 #[test]
217 fn image_roundtrip_minimal() {
218 let kind = ArtifactKind::Image {
219 width: None,
220 height: None,
221 format: None,
222 frame_index: None,
223 };
224 let json = serde_json::to_string(&kind).unwrap();
225 let back: ArtifactKind = serde_json::from_str(&json).unwrap();
226 assert_eq!(kind, back);
227 }
228
229 #[test]
230 fn image_serialized_has_type_tag() {
231 let kind = ArtifactKind::Image {
232 width: Some(1920),
233 height: Some(1080),
234 format: Some("EXR".to_string()),
235 frame_index: Some(5),
236 };
237 let json = serde_json::to_string(&kind).unwrap();
238 assert!(json.contains("\"type\":\"image\""), "json: {}", json);
239 assert!(json.contains("1920"));
240 assert!(json.contains("1080"));
241 assert!(json.contains("EXR"));
242 assert!(json.contains("5"));
243 }
244
245 #[test]
246 fn image_minimal_omits_none_fields() {
247 let kind = ArtifactKind::Image {
248 width: None,
249 height: None,
250 format: None,
251 frame_index: None,
252 };
253 let json = serde_json::to_string(&kind).unwrap();
254 assert_eq!(json, r#"{"type":"image"}"#, "json: {}", json);
256 }
257
258 #[test]
259 fn is_image() {
260 let kind = ArtifactKind::Image {
261 width: None,
262 height: None,
263 format: None,
264 frame_index: None,
265 };
266 assert!(kind.is_image());
267 }
268
269 #[test]
270 fn display_label_with_format() {
271 let kind = ArtifactKind::Image {
272 width: None,
273 height: None,
274 format: Some("PNG".to_string()),
275 frame_index: None,
276 };
277 assert_eq!(kind.display_label(), "PNG image");
278 }
279
280 #[test]
281 fn display_label_no_format() {
282 let kind = ArtifactKind::Image {
283 width: None,
284 height: None,
285 format: None,
286 frame_index: None,
287 };
288 assert_eq!(kind.display_label(), "image");
289 }
290
291 #[test]
294 fn binary_roundtrip_full() {
295 let kind = ArtifactKind::Binary {
296 mime_type: Some("application/zip".to_string()),
297 byte_size: Some(1_048_576),
298 };
299 let json = serde_json::to_string(&kind).unwrap();
300 let back: ArtifactKind = serde_json::from_str(&json).unwrap();
301 assert_eq!(kind, back);
302 }
303
304 #[test]
305 fn binary_roundtrip_minimal() {
306 let kind = ArtifactKind::Binary {
307 mime_type: None,
308 byte_size: None,
309 };
310 let json = serde_json::to_string(&kind).unwrap();
311 let back: ArtifactKind = serde_json::from_str(&json).unwrap();
312 assert_eq!(kind, back);
313 assert_eq!(json, r#"{"type":"binary"}"#, "json: {}", json);
314 }
315
316 #[test]
317 fn binary_serialized_has_type_tag() {
318 let kind = ArtifactKind::Binary {
319 mime_type: Some("application/octet-stream".to_string()),
320 byte_size: Some(512),
321 };
322 let json = serde_json::to_string(&kind).unwrap();
323 assert!(json.contains("\"type\":\"binary\""), "json: {}", json);
324 assert!(json.contains("application/octet-stream"));
325 assert!(json.contains("512"));
326 }
327
328 #[test]
329 fn is_binary() {
330 let kind = ArtifactKind::Binary {
331 mime_type: None,
332 byte_size: None,
333 };
334 assert!(kind.is_binary());
335 assert!(!kind.is_image());
336 assert!(!kind.is_text());
337 }
338
339 #[test]
340 fn binary_display_label_with_mime() {
341 let kind = ArtifactKind::Binary {
342 mime_type: Some("application/zip".to_string()),
343 byte_size: None,
344 };
345 assert_eq!(kind.display_label(), "binary (application/zip)");
346 }
347
348 #[test]
349 fn binary_display_label_no_mime() {
350 let kind = ArtifactKind::Binary {
351 mime_type: None,
352 byte_size: None,
353 };
354 assert_eq!(kind.display_label(), "binary");
355 }
356
357 #[test]
360 fn text_roundtrip_full() {
361 let kind = ArtifactKind::Text {
362 encoding: Some("utf-8".to_string()),
363 line_count: Some(200),
364 };
365 let json = serde_json::to_string(&kind).unwrap();
366 let back: ArtifactKind = serde_json::from_str(&json).unwrap();
367 assert_eq!(kind, back);
368 }
369
370 #[test]
371 fn text_roundtrip_minimal() {
372 let kind = ArtifactKind::Text {
373 encoding: None,
374 line_count: None,
375 };
376 let json = serde_json::to_string(&kind).unwrap();
377 let back: ArtifactKind = serde_json::from_str(&json).unwrap();
378 assert_eq!(kind, back);
379 assert_eq!(json, r#"{"type":"text"}"#, "json: {}", json);
380 }
381
382 #[test]
383 fn text_serialized_has_type_tag() {
384 let kind = ArtifactKind::Text {
385 encoding: Some("latin-1".to_string()),
386 line_count: Some(42),
387 };
388 let json = serde_json::to_string(&kind).unwrap();
389 assert!(json.contains("\"type\":\"text\""), "json: {}", json);
390 assert!(json.contains("latin-1"));
391 assert!(json.contains("42"));
392 }
393
394 #[test]
395 fn is_text() {
396 let kind = ArtifactKind::Text {
397 encoding: None,
398 line_count: None,
399 };
400 assert!(kind.is_text());
401 assert!(!kind.is_image());
402 assert!(!kind.is_binary());
403 }
404
405 #[test]
406 fn text_display_label_with_encoding() {
407 let kind = ArtifactKind::Text {
408 encoding: Some("utf-8".to_string()),
409 line_count: None,
410 };
411 assert_eq!(kind.display_label(), "text (utf-8)");
412 }
413
414 #[test]
415 fn text_display_label_no_encoding() {
416 let kind = ArtifactKind::Text {
417 encoding: None,
418 line_count: None,
419 };
420 assert_eq!(kind.display_label(), "text");
421 }
422
423 #[test]
426 fn video_roundtrip_full() {
427 let kind = ArtifactKind::Video {
428 width: Some(1920),
429 height: Some(1080),
430 fps: Some(24.0),
431 duration_secs: Some(6.2),
432 format: Some("MP4".to_string()),
433 frame_count: Some(149),
434 };
435 let json = serde_json::to_string(&kind).unwrap();
436 let back: ArtifactKind = serde_json::from_str(&json).unwrap();
437 assert_eq!(kind, back);
438 }
439
440 #[test]
441 fn video_roundtrip_minimal() {
442 let kind = ArtifactKind::Video {
443 width: None,
444 height: None,
445 fps: None,
446 duration_secs: None,
447 format: None,
448 frame_count: None,
449 };
450 let json = serde_json::to_string(&kind).unwrap();
451 let back: ArtifactKind = serde_json::from_str(&json).unwrap();
452 assert_eq!(kind, back);
453 assert_eq!(json, r#"{"type":"video"}"#, "json: {}", json);
454 }
455
456 #[test]
457 fn video_serialized_has_type_tag() {
458 let kind = ArtifactKind::Video {
459 width: Some(1920),
460 height: Some(1080),
461 fps: Some(30.0),
462 duration_secs: Some(10.5),
463 format: Some("MOV".to_string()),
464 frame_count: Some(315),
465 };
466 let json = serde_json::to_string(&kind).unwrap();
467 assert!(json.contains("\"type\":\"video\""), "json: {}", json);
468 assert!(json.contains("1920"));
469 assert!(json.contains("1080"));
470 assert!(json.contains("MOV"));
471 assert!(json.contains("315"));
472 }
473
474 #[test]
475 fn video_minimal_omits_none_fields() {
476 let kind = ArtifactKind::Video {
477 width: None,
478 height: None,
479 fps: None,
480 duration_secs: None,
481 format: None,
482 frame_count: None,
483 };
484 let json = serde_json::to_string(&kind).unwrap();
485 assert_eq!(json, r#"{"type":"video"}"#, "json: {}", json);
486 }
487
488 #[test]
489 fn is_video() {
490 let kind = ArtifactKind::Video {
491 width: None,
492 height: None,
493 fps: None,
494 duration_secs: None,
495 format: None,
496 frame_count: None,
497 };
498 assert!(kind.is_video());
499 assert!(!kind.is_image());
500 assert!(!kind.is_binary());
501 assert!(!kind.is_text());
502 }
503
504 #[test]
505 fn video_display_label_with_format() {
506 let kind = ArtifactKind::Video {
507 width: None,
508 height: None,
509 fps: None,
510 duration_secs: None,
511 format: Some("MP4".to_string()),
512 frame_count: None,
513 };
514 assert_eq!(kind.display_label(), "MP4 video");
515 }
516
517 #[test]
518 fn video_display_label_no_format() {
519 let kind = ArtifactKind::Video {
520 width: None,
521 height: None,
522 fps: None,
523 duration_secs: None,
524 format: None,
525 frame_count: None,
526 };
527 assert_eq!(kind.display_label(), "video");
528 }
529
530 #[test]
531 fn video_metadata_summary_full() {
532 let kind = ArtifactKind::Video {
533 width: Some(1920),
534 height: Some(1080),
535 fps: Some(24.0),
536 duration_secs: Some(6.2),
537 format: Some("MP4".to_string()),
538 frame_count: Some(149),
539 };
540 let summary = kind.video_metadata_summary();
541 assert!(
542 summary.contains("1920×1080"),
543 "should contain resolution; got: {}",
544 summary
545 );
546 assert!(
547 summary.contains("24fps") || summary.contains("24"),
548 "should contain fps; got: {}",
549 summary
550 );
551 assert!(
552 summary.contains("6.2s"),
553 "should contain duration; got: {}",
554 summary
555 );
556 assert!(
557 summary.contains("MP4"),
558 "should contain format; got: {}",
559 summary
560 );
561 assert!(
562 summary.starts_with("Video:"),
563 "should start with 'Video:'; got: {}",
564 summary
565 );
566 }
567
568 #[test]
569 fn video_metadata_summary_partial() {
570 let kind = ArtifactKind::Video {
572 width: None,
573 height: None,
574 fps: None,
575 duration_secs: None,
576 format: Some("WebM".to_string()),
577 frame_count: None,
578 };
579 let summary = kind.video_metadata_summary();
580 assert!(summary.contains("WebM"), "got: {}", summary);
581 assert!(summary.starts_with("Video:"), "got: {}", summary);
582 }
583
584 #[test]
585 fn video_metadata_summary_empty_fields() {
586 let kind = ArtifactKind::Video {
587 width: None,
588 height: None,
589 fps: None,
590 duration_secs: None,
591 format: None,
592 frame_count: None,
593 };
594 let summary = kind.video_metadata_summary();
595 assert_eq!(summary, "Video");
596 }
597
598 #[test]
599 fn video_metadata_summary_non_video_returns_empty() {
600 let kind = ArtifactKind::Image {
601 width: Some(100),
602 height: Some(100),
603 format: None,
604 frame_index: None,
605 };
606 assert_eq!(kind.video_metadata_summary(), "");
607 }
608}