Skip to main content

ta_changeset/
artifact_kind.rs

1// artifact_kind.rs — ArtifactKind enum for typed artifact metadata (v0.14.15+).
2//
3// Describes the semantic kind of an artifact so the draft review pipeline can
4// render appropriate summaries. For example, binary image artifacts suppress
5// the text diff and show a human-readable frame/resolution summary instead.
6
7use serde::{Deserialize, Serialize};
8
9/// Semantic kind of an artifact produced by a connector or agent.
10///
11/// Stored on [`Artifact`] as an optional field. When absent, the artifact is
12/// treated as a generic file. The `ta draft view` renderer uses this to pick
13/// the appropriate display format (e.g. suppress binary diffs for images).
14///
15/// # Extensibility
16/// Future connectors can add new variants here (e.g. `Audio`, `PointCloud`).
17/// The `Image` and `Video` variants are intentionally generic — they are not
18/// tied to Unreal Engine or any other specific connector.
19///
20/// [`Artifact`]: crate::draft_package::Artifact
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22#[serde(tag = "type", rename_all = "snake_case")]
23pub enum ArtifactKind {
24    /// A raster image artifact (PNG, EXR, JPEG, …).
25    ///
26    /// Fields are optional — connectors fill in what they know. Width and
27    /// height are in pixels; `frame_index` is zero-based within a sequence.
28    Image {
29        /// Image width in pixels, if known.
30        #[serde(default, skip_serializing_if = "Option::is_none")]
31        width: Option<u32>,
32        /// Image height in pixels, if known.
33        #[serde(default, skip_serializing_if = "Option::is_none")]
34        height: Option<u32>,
35        /// File format string, e.g. `"PNG"`, `"EXR"`, `"JPEG"`.
36        #[serde(default, skip_serializing_if = "Option::is_none")]
37        format: Option<String>,
38        /// Zero-based frame index within a render sequence, if applicable.
39        #[serde(default, skip_serializing_if = "Option::is_none")]
40        frame_index: Option<u32>,
41    },
42    /// A video artifact (MP4, MOV, WebM, …) produced by render pipelines.
43    ///
44    /// Text diff is suppressed — `ta draft view` shows a metadata summary like
45    /// "Video: 1920×1080, 24fps, 6.2s, MP4" instead of binary content.
46    Video {
47        /// Frame width in pixels, if known.
48        #[serde(default, skip_serializing_if = "Option::is_none")]
49        width: Option<u32>,
50        /// Frame height in pixels, if known.
51        #[serde(default, skip_serializing_if = "Option::is_none")]
52        height: Option<u32>,
53        /// Frames per second, if known.
54        #[serde(default, skip_serializing_if = "Option::is_none")]
55        fps: Option<f32>,
56        /// Duration in seconds, if known.
57        #[serde(default, skip_serializing_if = "Option::is_none")]
58        duration_secs: Option<f32>,
59        /// Container/format string, e.g. `"MP4"`, `"MOV"`, `"WebM"`.
60        #[serde(default, skip_serializing_if = "Option::is_none")]
61        format: Option<String>,
62        /// Total frame count, if known.
63        #[serde(default, skip_serializing_if = "Option::is_none")]
64        frame_count: Option<u32>,
65    },
66    /// An opaque binary artifact (compiled output, archive, model weights, …).
67    ///
68    /// Text diff is suppressed — `ta draft view` shows a hex summary or
69    /// `(binary file, N bytes)` instead.
70    Binary {
71        /// MIME type string, e.g. `"application/octet-stream"`, `"application/zip"`.
72        #[serde(default, skip_serializing_if = "Option::is_none")]
73        mime_type: Option<String>,
74        /// File size in bytes, if known.
75        #[serde(default, skip_serializing_if = "Option::is_none")]
76        byte_size: Option<u64>,
77    },
78    /// A raw text artifact (generated script, config file, data file, …).
79    ///
80    /// Full unified diff is rendered in `ta draft view`.
81    Text {
82        /// Character encoding, e.g. `"utf-8"`, `"latin-1"`. Defaults to UTF-8 if absent.
83        #[serde(default, skip_serializing_if = "Option::is_none")]
84        encoding: Option<String>,
85        /// Number of lines in the file, if known.
86        #[serde(default, skip_serializing_if = "Option::is_none")]
87        line_count: Option<u64>,
88    },
89
90    /// A memory summary artifact produced by an analysis/learning goal run (v0.15.13.2).
91    ///
92    /// Produced when `ta draft build` detects an empty overlay diff but finds memory
93    /// entries written by this goal run. The changeset holds a rendered summary of all
94    /// entries (key, scope, value) for human review.
95    ///
96    /// On approve: entries remain in the store — they were written during the run.
97    /// On deny: entries are removed from the store using `entry_ids`.
98    MemorySummary {
99        /// Number of memory entries created during this goal run.
100        entry_count: usize,
101        /// Entry IDs (as strings) used by `ta draft deny` to remove them from the store.
102        ///
103        /// These are the `entry_id` (UUID) values from each `MemoryEntry`.
104        #[serde(default, skip_serializing_if = "Vec::is_empty")]
105        entry_ids: Vec<String>,
106    },
107}
108
109impl ArtifactKind {
110    /// Returns true if this is an image kind (binary; diff should be suppressed).
111    pub fn is_image(&self) -> bool {
112        matches!(self, Self::Image { .. })
113    }
114
115    /// Returns true if this is a video kind (binary; diff should be suppressed).
116    pub fn is_video(&self) -> bool {
117        matches!(self, Self::Video { .. })
118    }
119
120    /// Returns true if this is a binary kind (diff should be suppressed).
121    pub fn is_binary(&self) -> bool {
122        matches!(self, Self::Binary { .. })
123    }
124
125    /// Returns true if this is a text kind (full diff should be rendered).
126    pub fn is_text(&self) -> bool {
127        matches!(self, Self::Text { .. })
128    }
129
130    /// Returns true if this is a memory summary kind (no file diff; rendered as entry list).
131    pub fn is_memory_summary(&self) -> bool {
132        matches!(self, Self::MemorySummary { .. })
133    }
134
135    /// Returns a short human-readable label for display (e.g. `"MP4 video"`, `"PNG image"`).
136    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    /// Returns a compact metadata summary for display in `ta draft view`.
161    ///
162    /// For `Video`: `"Video: 1920×1080, 24fps, 6.2s, MP4"` (omits unknown fields).
163    /// For other kinds: returns an empty string.
164    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        // Only the type tag should appear; all None fields are skipped.
255        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    // ── Binary variant tests ──
292
293    #[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    // ── Text variant tests ──
358
359    #[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    // ── Video variant tests ──
424
425    #[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        // Only format known — resolution/fps/duration absent.
571        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}