use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ArtifactKind {
Image {
#[serde(default, skip_serializing_if = "Option::is_none")]
width: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
height: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
format: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
frame_index: Option<u32>,
},
Video {
#[serde(default, skip_serializing_if = "Option::is_none")]
width: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
height: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
fps: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
duration_secs: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
format: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
frame_count: Option<u32>,
},
Binary {
#[serde(default, skip_serializing_if = "Option::is_none")]
mime_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
byte_size: Option<u64>,
},
Text {
#[serde(default, skip_serializing_if = "Option::is_none")]
encoding: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
line_count: Option<u64>,
},
MemorySummary {
entry_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
entry_ids: Vec<String>,
},
}
impl ArtifactKind {
pub fn is_image(&self) -> bool {
matches!(self, Self::Image { .. })
}
pub fn is_video(&self) -> bool {
matches!(self, Self::Video { .. })
}
pub fn is_binary(&self) -> bool {
matches!(self, Self::Binary { .. })
}
pub fn is_text(&self) -> bool {
matches!(self, Self::Text { .. })
}
pub fn is_memory_summary(&self) -> bool {
matches!(self, Self::MemorySummary { .. })
}
pub fn display_label(&self) -> String {
match self {
Self::Image { format, .. } => match format.as_deref() {
Some(fmt) => format!("{} image", fmt),
None => "image".to_string(),
},
Self::Video { format, .. } => match format.as_deref() {
Some(fmt) => format!("{} video", fmt),
None => "video".to_string(),
},
Self::Binary { mime_type, .. } => match mime_type.as_deref() {
Some(mime) => format!("binary ({})", mime),
None => "binary".to_string(),
},
Self::Text { encoding, .. } => match encoding.as_deref() {
Some(enc) => format!("text ({})", enc),
None => "text".to_string(),
},
Self::MemorySummary { entry_count, .. } => {
format!("memory summary ({} entries)", entry_count)
}
}
}
pub fn video_metadata_summary(&self) -> String {
let Self::Video {
width,
height,
fps,
duration_secs,
format,
..
} = self
else {
return String::new();
};
let mut parts: Vec<String> = Vec::new();
if let (Some(w), Some(h)) = (width, height) {
parts.push(format!("{}×{}", w, h));
}
if let Some(f) = fps {
parts.push(format!("{}fps", f));
}
if let Some(d) = duration_secs {
parts.push(format!("{:.1}s", d));
}
if let Some(fmt) = format {
parts.push(fmt.clone());
}
if parts.is_empty() {
"Video".to_string()
} else {
format!("Video: {}", parts.join(", "))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn image_roundtrip_full() {
let kind = ArtifactKind::Image {
width: Some(1024),
height: Some(1024),
format: Some("PNG".to_string()),
frame_index: Some(0),
};
let json = serde_json::to_string(&kind).unwrap();
let back: ArtifactKind = serde_json::from_str(&json).unwrap();
assert_eq!(kind, back);
}
#[test]
fn image_roundtrip_minimal() {
let kind = ArtifactKind::Image {
width: None,
height: None,
format: None,
frame_index: None,
};
let json = serde_json::to_string(&kind).unwrap();
let back: ArtifactKind = serde_json::from_str(&json).unwrap();
assert_eq!(kind, back);
}
#[test]
fn image_serialized_has_type_tag() {
let kind = ArtifactKind::Image {
width: Some(1920),
height: Some(1080),
format: Some("EXR".to_string()),
frame_index: Some(5),
};
let json = serde_json::to_string(&kind).unwrap();
assert!(json.contains("\"type\":\"image\""), "json: {}", json);
assert!(json.contains("1920"));
assert!(json.contains("1080"));
assert!(json.contains("EXR"));
assert!(json.contains("5"));
}
#[test]
fn image_minimal_omits_none_fields() {
let kind = ArtifactKind::Image {
width: None,
height: None,
format: None,
frame_index: None,
};
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(json, r#"{"type":"image"}"#, "json: {}", json);
}
#[test]
fn is_image() {
let kind = ArtifactKind::Image {
width: None,
height: None,
format: None,
frame_index: None,
};
assert!(kind.is_image());
}
#[test]
fn display_label_with_format() {
let kind = ArtifactKind::Image {
width: None,
height: None,
format: Some("PNG".to_string()),
frame_index: None,
};
assert_eq!(kind.display_label(), "PNG image");
}
#[test]
fn display_label_no_format() {
let kind = ArtifactKind::Image {
width: None,
height: None,
format: None,
frame_index: None,
};
assert_eq!(kind.display_label(), "image");
}
#[test]
fn binary_roundtrip_full() {
let kind = ArtifactKind::Binary {
mime_type: Some("application/zip".to_string()),
byte_size: Some(1_048_576),
};
let json = serde_json::to_string(&kind).unwrap();
let back: ArtifactKind = serde_json::from_str(&json).unwrap();
assert_eq!(kind, back);
}
#[test]
fn binary_roundtrip_minimal() {
let kind = ArtifactKind::Binary {
mime_type: None,
byte_size: None,
};
let json = serde_json::to_string(&kind).unwrap();
let back: ArtifactKind = serde_json::from_str(&json).unwrap();
assert_eq!(kind, back);
assert_eq!(json, r#"{"type":"binary"}"#, "json: {}", json);
}
#[test]
fn binary_serialized_has_type_tag() {
let kind = ArtifactKind::Binary {
mime_type: Some("application/octet-stream".to_string()),
byte_size: Some(512),
};
let json = serde_json::to_string(&kind).unwrap();
assert!(json.contains("\"type\":\"binary\""), "json: {}", json);
assert!(json.contains("application/octet-stream"));
assert!(json.contains("512"));
}
#[test]
fn is_binary() {
let kind = ArtifactKind::Binary {
mime_type: None,
byte_size: None,
};
assert!(kind.is_binary());
assert!(!kind.is_image());
assert!(!kind.is_text());
}
#[test]
fn binary_display_label_with_mime() {
let kind = ArtifactKind::Binary {
mime_type: Some("application/zip".to_string()),
byte_size: None,
};
assert_eq!(kind.display_label(), "binary (application/zip)");
}
#[test]
fn binary_display_label_no_mime() {
let kind = ArtifactKind::Binary {
mime_type: None,
byte_size: None,
};
assert_eq!(kind.display_label(), "binary");
}
#[test]
fn text_roundtrip_full() {
let kind = ArtifactKind::Text {
encoding: Some("utf-8".to_string()),
line_count: Some(200),
};
let json = serde_json::to_string(&kind).unwrap();
let back: ArtifactKind = serde_json::from_str(&json).unwrap();
assert_eq!(kind, back);
}
#[test]
fn text_roundtrip_minimal() {
let kind = ArtifactKind::Text {
encoding: None,
line_count: None,
};
let json = serde_json::to_string(&kind).unwrap();
let back: ArtifactKind = serde_json::from_str(&json).unwrap();
assert_eq!(kind, back);
assert_eq!(json, r#"{"type":"text"}"#, "json: {}", json);
}
#[test]
fn text_serialized_has_type_tag() {
let kind = ArtifactKind::Text {
encoding: Some("latin-1".to_string()),
line_count: Some(42),
};
let json = serde_json::to_string(&kind).unwrap();
assert!(json.contains("\"type\":\"text\""), "json: {}", json);
assert!(json.contains("latin-1"));
assert!(json.contains("42"));
}
#[test]
fn is_text() {
let kind = ArtifactKind::Text {
encoding: None,
line_count: None,
};
assert!(kind.is_text());
assert!(!kind.is_image());
assert!(!kind.is_binary());
}
#[test]
fn text_display_label_with_encoding() {
let kind = ArtifactKind::Text {
encoding: Some("utf-8".to_string()),
line_count: None,
};
assert_eq!(kind.display_label(), "text (utf-8)");
}
#[test]
fn text_display_label_no_encoding() {
let kind = ArtifactKind::Text {
encoding: None,
line_count: None,
};
assert_eq!(kind.display_label(), "text");
}
#[test]
fn video_roundtrip_full() {
let kind = ArtifactKind::Video {
width: Some(1920),
height: Some(1080),
fps: Some(24.0),
duration_secs: Some(6.2),
format: Some("MP4".to_string()),
frame_count: Some(149),
};
let json = serde_json::to_string(&kind).unwrap();
let back: ArtifactKind = serde_json::from_str(&json).unwrap();
assert_eq!(kind, back);
}
#[test]
fn video_roundtrip_minimal() {
let kind = ArtifactKind::Video {
width: None,
height: None,
fps: None,
duration_secs: None,
format: None,
frame_count: None,
};
let json = serde_json::to_string(&kind).unwrap();
let back: ArtifactKind = serde_json::from_str(&json).unwrap();
assert_eq!(kind, back);
assert_eq!(json, r#"{"type":"video"}"#, "json: {}", json);
}
#[test]
fn video_serialized_has_type_tag() {
let kind = ArtifactKind::Video {
width: Some(1920),
height: Some(1080),
fps: Some(30.0),
duration_secs: Some(10.5),
format: Some("MOV".to_string()),
frame_count: Some(315),
};
let json = serde_json::to_string(&kind).unwrap();
assert!(json.contains("\"type\":\"video\""), "json: {}", json);
assert!(json.contains("1920"));
assert!(json.contains("1080"));
assert!(json.contains("MOV"));
assert!(json.contains("315"));
}
#[test]
fn video_minimal_omits_none_fields() {
let kind = ArtifactKind::Video {
width: None,
height: None,
fps: None,
duration_secs: None,
format: None,
frame_count: None,
};
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(json, r#"{"type":"video"}"#, "json: {}", json);
}
#[test]
fn is_video() {
let kind = ArtifactKind::Video {
width: None,
height: None,
fps: None,
duration_secs: None,
format: None,
frame_count: None,
};
assert!(kind.is_video());
assert!(!kind.is_image());
assert!(!kind.is_binary());
assert!(!kind.is_text());
}
#[test]
fn video_display_label_with_format() {
let kind = ArtifactKind::Video {
width: None,
height: None,
fps: None,
duration_secs: None,
format: Some("MP4".to_string()),
frame_count: None,
};
assert_eq!(kind.display_label(), "MP4 video");
}
#[test]
fn video_display_label_no_format() {
let kind = ArtifactKind::Video {
width: None,
height: None,
fps: None,
duration_secs: None,
format: None,
frame_count: None,
};
assert_eq!(kind.display_label(), "video");
}
#[test]
fn video_metadata_summary_full() {
let kind = ArtifactKind::Video {
width: Some(1920),
height: Some(1080),
fps: Some(24.0),
duration_secs: Some(6.2),
format: Some("MP4".to_string()),
frame_count: Some(149),
};
let summary = kind.video_metadata_summary();
assert!(
summary.contains("1920×1080"),
"should contain resolution; got: {}",
summary
);
assert!(
summary.contains("24fps") || summary.contains("24"),
"should contain fps; got: {}",
summary
);
assert!(
summary.contains("6.2s"),
"should contain duration; got: {}",
summary
);
assert!(
summary.contains("MP4"),
"should contain format; got: {}",
summary
);
assert!(
summary.starts_with("Video:"),
"should start with 'Video:'; got: {}",
summary
);
}
#[test]
fn video_metadata_summary_partial() {
let kind = ArtifactKind::Video {
width: None,
height: None,
fps: None,
duration_secs: None,
format: Some("WebM".to_string()),
frame_count: None,
};
let summary = kind.video_metadata_summary();
assert!(summary.contains("WebM"), "got: {}", summary);
assert!(summary.starts_with("Video:"), "got: {}", summary);
}
#[test]
fn video_metadata_summary_empty_fields() {
let kind = ArtifactKind::Video {
width: None,
height: None,
fps: None,
duration_secs: None,
format: None,
frame_count: None,
};
let summary = kind.video_metadata_summary();
assert_eq!(summary, "Video");
}
#[test]
fn video_metadata_summary_non_video_returns_empty() {
let kind = ArtifactKind::Image {
width: Some(100),
height: Some(100),
format: None,
frame_index: None,
};
assert_eq!(kind.video_metadata_summary(), "");
}
}