Skip to main content

bones_core/event/
data.rs

1//! Typed payload data structs for each event type.
2//!
3//! Each event type has a corresponding data struct that defines the JSON
4//! payload schema. Unknown fields are preserved via `#[serde(flatten)]`
5//! for forward compatibility.
6
7use crate::model::item::{Kind, Size, State, Urgency};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::fmt;
11use std::str::FromStr;
12
13use super::types::EventType;
14
15// ---------------------------------------------------------------------------
16// EventData — the unified payload enum
17// ---------------------------------------------------------------------------
18
19/// Typed payload for an event. The discriminant comes from [`EventType`],
20/// not from the JSON itself (it is an external tag in TSJSON).
21///
22/// **Serde note:** `EventData` implements `Serialize` manually (dispatching
23/// to the inner struct) but does **not** implement `Deserialize` directly.
24/// Use [`EventData::deserialize_for`] with the known [`EventType`] to
25/// deserialize from JSON. The [`Event`](super::Event) struct handles this
26/// in its custom `Deserialize` impl.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum EventData {
29    /// Payload for `item.create`.
30    Create(CreateData),
31    /// Payload for `item.update`.
32    Update(UpdateData),
33    /// Payload for `item.move`.
34    Move(MoveData),
35    /// Payload for `item.assign`.
36    Assign(AssignData),
37    /// Payload for `item.comment`.
38    Comment(CommentData),
39    /// Payload for `item.link`.
40    Link(LinkData),
41    /// Payload for `item.unlink`.
42    Unlink(UnlinkData),
43    /// Payload for `item.delete`.
44    Delete(DeleteData),
45    /// Payload for `item.compact`.
46    Compact(CompactData),
47    /// Payload for `item.snapshot`.
48    Snapshot(SnapshotData),
49    /// Payload for `item.redact`.
50    Redact(RedactData),
51}
52
53impl EventData {
54    /// Deserialize a JSON string into the correct `EventData` variant based
55    /// on the event type.
56    ///
57    /// This is the primary deserialization entry point since the type
58    /// discriminant lives in a separate TSJSON field, not in the JSON payload.
59    ///
60    /// # Errors
61    ///
62    /// Returns a [`DataParseError`] if the JSON is malformed or does not match
63    /// the expected schema for the given event type.
64    pub fn deserialize_for(event_type: EventType, json: &str) -> Result<Self, DataParseError> {
65        let result = match event_type {
66            EventType::Create => serde_json::from_str::<CreateData>(json).map(EventData::Create),
67            EventType::Update => serde_json::from_str::<UpdateData>(json).map(EventData::Update),
68            EventType::Move => serde_json::from_str::<MoveData>(json).map(EventData::Move),
69            EventType::Assign => serde_json::from_str::<AssignData>(json).map(EventData::Assign),
70            EventType::Comment => serde_json::from_str::<CommentData>(json).map(EventData::Comment),
71            EventType::Link => serde_json::from_str::<LinkData>(json).map(EventData::Link),
72            EventType::Unlink => serde_json::from_str::<UnlinkData>(json).map(EventData::Unlink),
73            EventType::Delete => serde_json::from_str::<DeleteData>(json).map(EventData::Delete),
74            EventType::Compact => serde_json::from_str::<CompactData>(json).map(EventData::Compact),
75            EventType::Snapshot => {
76                serde_json::from_str::<SnapshotData>(json).map(EventData::Snapshot)
77            }
78            EventType::Redact => serde_json::from_str::<RedactData>(json).map(EventData::Redact),
79        };
80
81        result.map_err(|source| DataParseError { event_type, source })
82    }
83
84    /// Serialize the payload to a [`serde_json::Value`].
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if the inner struct fails to serialize (should not
89    /// happen with well-formed data).
90    pub fn to_json_value(&self) -> Result<serde_json::Value, serde_json::Error> {
91        match self {
92            Self::Create(d) => serde_json::to_value(d),
93            Self::Update(d) => serde_json::to_value(d),
94            Self::Move(d) => serde_json::to_value(d),
95            Self::Assign(d) => serde_json::to_value(d),
96            Self::Comment(d) => serde_json::to_value(d),
97            Self::Link(d) => serde_json::to_value(d),
98            Self::Unlink(d) => serde_json::to_value(d),
99            Self::Delete(d) => serde_json::to_value(d),
100            Self::Compact(d) => serde_json::to_value(d),
101            Self::Snapshot(d) => serde_json::to_value(d),
102            Self::Redact(d) => serde_json::to_value(d),
103        }
104    }
105}
106
107impl Serialize for EventData {
108    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
109        match self {
110            Self::Create(d) => d.serialize(serializer),
111            Self::Update(d) => d.serialize(serializer),
112            Self::Move(d) => d.serialize(serializer),
113            Self::Assign(d) => d.serialize(serializer),
114            Self::Comment(d) => d.serialize(serializer),
115            Self::Link(d) => d.serialize(serializer),
116            Self::Unlink(d) => d.serialize(serializer),
117            Self::Delete(d) => d.serialize(serializer),
118            Self::Compact(d) => d.serialize(serializer),
119            Self::Snapshot(d) => d.serialize(serializer),
120            Self::Redact(d) => d.serialize(serializer),
121        }
122    }
123}
124
125// ---------------------------------------------------------------------------
126// DataParseError
127// ---------------------------------------------------------------------------
128
129/// Error returned when deserializing an event's JSON payload fails.
130#[derive(Debug)]
131pub struct DataParseError {
132    /// The event type that was being deserialized.
133    pub event_type: EventType,
134    /// The underlying JSON parse error.
135    pub source: serde_json::Error,
136}
137
138impl fmt::Display for DataParseError {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        write!(
141            f,
142            "invalid {} data payload: {}",
143            self.event_type, self.source
144        )
145    }
146}
147
148impl std::error::Error for DataParseError {
149    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
150        Some(&self.source)
151    }
152}
153
154// ---------------------------------------------------------------------------
155// AssignAction enum
156// ---------------------------------------------------------------------------
157
158/// Whether an agent is being assigned or unassigned.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
160#[serde(rename_all = "lowercase")]
161pub enum AssignAction {
162    /// Assign an agent to the work item.
163    Assign,
164    /// Remove an agent from the work item.
165    Unassign,
166}
167
168impl AssignAction {
169    /// Return the canonical string form.
170    #[must_use]
171    pub const fn as_str(self) -> &'static str {
172        match self {
173            Self::Assign => "assign",
174            Self::Unassign => "unassign",
175        }
176    }
177}
178
179impl fmt::Display for AssignAction {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        f.write_str(self.as_str())
182    }
183}
184
185impl FromStr for AssignAction {
186    type Err = ParseAssignActionError;
187
188    fn from_str(s: &str) -> Result<Self, Self::Err> {
189        match s {
190            "assign" => Ok(Self::Assign),
191            "unassign" => Ok(Self::Unassign),
192            _ => Err(ParseAssignActionError(s.to_string())),
193        }
194    }
195}
196
197/// Error returned when parsing an invalid assign action string.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct ParseAssignActionError(pub String);
200
201impl fmt::Display for ParseAssignActionError {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        write!(
204            f,
205            "invalid assign action '{}': expected 'assign' or 'unassign'",
206            self.0
207        )
208    }
209}
210
211impl std::error::Error for ParseAssignActionError {}
212
213// ---------------------------------------------------------------------------
214// Payload structs — one per event type
215// ---------------------------------------------------------------------------
216
217/// Payload for `item.create`.
218///
219/// Creates a new work item with initial field values.
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct CreateData {
222    /// Title of the new work item (required).
223    pub title: String,
224
225    /// Kind of work item.
226    pub kind: Kind,
227
228    /// Optional t-shirt size estimate.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub size: Option<Size>,
231
232    /// Priority override.
233    #[serde(default, skip_serializing_if = "is_default_urgency")]
234    pub urgency: Urgency,
235
236    /// Initial labels.
237    #[serde(default, skip_serializing_if = "Vec::is_empty")]
238    pub labels: Vec<String>,
239
240    /// Optional parent item ID (for hierarchical items).
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub parent: Option<String>,
243
244    /// Optional causation reference — the item that triggered this creation.
245    /// Purely audit-trail metadata, not a dependency link.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub causation: Option<String>,
248
249    /// Optional initial description.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub description: Option<String>,
252
253    /// Unknown fields preserved for forward compatibility.
254    #[serde(flatten)]
255    pub extra: BTreeMap<String, serde_json::Value>,
256}
257
258#[allow(clippy::trivially_copy_pass_by_ref)] // serde's skip_serializing_if requires &T -> bool
259fn is_default_urgency(u: &Urgency) -> bool {
260    *u == Urgency::Default
261}
262
263/// Payload for `item.update`.
264///
265/// Updates a single field on a work item. The `value` is a dynamic JSON
266/// value since different fields have different types.
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268pub struct UpdateData {
269    /// Name of the field being updated (e.g. "title", "description", "size").
270    pub field: String,
271
272    /// New value for the field.
273    pub value: serde_json::Value,
274
275    /// Unknown fields preserved for forward compatibility.
276    #[serde(flatten)]
277    pub extra: BTreeMap<String, serde_json::Value>,
278}
279
280/// Payload for `item.move`.
281///
282/// Transitions a work item to a new lifecycle state.
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct MoveData {
285    /// Target state.
286    pub state: State,
287
288    /// Optional reason for the transition (e.g. "Shipped in commit 9f3a2b1").
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub reason: Option<String>,
291
292    /// Unknown fields preserved for forward compatibility.
293    #[serde(flatten)]
294    pub extra: BTreeMap<String, serde_json::Value>,
295}
296
297/// Payload for `item.assign`.
298///
299/// Assigns or unassigns an agent to/from a work item.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301pub struct AssignData {
302    /// Agent identifier being assigned/unassigned.
303    pub agent: String,
304
305    /// Whether this is an assignment or removal.
306    pub action: AssignAction,
307
308    /// Unknown fields preserved for forward compatibility.
309    #[serde(flatten)]
310    pub extra: BTreeMap<String, serde_json::Value>,
311}
312
313/// Payload for `item.comment`.
314///
315/// Adds a comment or note to a work item.
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317pub struct CommentData {
318    /// Comment body text.
319    pub body: String,
320
321    /// Unknown fields preserved for forward compatibility.
322    #[serde(flatten)]
323    pub extra: BTreeMap<String, serde_json::Value>,
324}
325
326/// Payload for `item.link`.
327///
328/// Adds a dependency or relationship between work items.
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330pub struct LinkData {
331    /// Target item ID of the link.
332    pub target: String,
333
334    /// Type of relationship (e.g. `blocks`, `related_to`).
335    pub link_type: String,
336
337    /// Unknown fields preserved for forward compatibility.
338    #[serde(flatten)]
339    pub extra: BTreeMap<String, serde_json::Value>,
340}
341
342/// Payload for `item.unlink`.
343///
344/// Removes a dependency or relationship between work items.
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
346pub struct UnlinkData {
347    /// Target item ID to unlink.
348    pub target: String,
349
350    /// Type of relationship being removed (e.g. `blocks`, `related_to`).
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub link_type: Option<String>,
353
354    /// Unknown fields preserved for forward compatibility.
355    #[serde(flatten)]
356    pub extra: BTreeMap<String, serde_json::Value>,
357}
358
359/// Payload for `item.delete`.
360///
361/// Soft-deletes a work item (tombstone). Minimal payload.
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363pub struct DeleteData {
364    /// Optional reason for deletion.
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub reason: Option<String>,
367
368    /// Unknown fields preserved for forward compatibility.
369    #[serde(flatten)]
370    pub extra: BTreeMap<String, serde_json::Value>,
371}
372
373/// Payload for `item.compact`.
374///
375/// Replaces the item's description with a summary (memory decay).
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
377pub struct CompactData {
378    /// Summary text replacing the original description.
379    pub summary: String,
380
381    /// Unknown fields preserved for forward compatibility.
382    #[serde(flatten)]
383    pub extra: BTreeMap<String, serde_json::Value>,
384}
385
386/// Payload for `item.snapshot`.
387///
388/// Lattice-compacted full state for a completed item. Replaces the event
389/// history with a single snapshot event during log compaction.
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391pub struct SnapshotData {
392    /// The full compacted state of the work item as a JSON object.
393    pub state: serde_json::Value,
394
395    /// Unknown fields preserved for forward compatibility.
396    #[serde(flatten)]
397    pub extra: BTreeMap<String, serde_json::Value>,
398}
399
400/// Payload for `item.redact`.
401///
402/// Targets a prior event by hash for payload redaction in the projection.
403/// The original event remains in the log (Merkle integrity preserved) but
404/// projections hide the content.
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406pub struct RedactData {
407    /// Hash of the event whose payload should be redacted.
408    pub target_hash: String,
409
410    /// Reason for redaction (e.g. "accidental secret exposure", "legal erasure").
411    pub reason: String,
412
413    /// Unknown fields preserved for forward compatibility.
414    #[serde(flatten)]
415    pub extra: BTreeMap<String, serde_json::Value>,
416}
417
418// ---------------------------------------------------------------------------
419// Tests
420// ---------------------------------------------------------------------------
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use serde_json::json;
426
427    // === CreateData =========================================================
428
429    #[test]
430    fn create_data_full_roundtrip() {
431        let data = CreateData {
432            title: "Fix auth retry".into(),
433            kind: Kind::Task,
434            size: Some(Size::M),
435            urgency: Urgency::Default,
436            labels: vec!["backend".into()],
437            parent: None,
438            causation: Some("bn-x1y2".into()),
439            description: None,
440            extra: BTreeMap::new(),
441        };
442        let json = serde_json::to_string(&data).expect("serialize");
443        let deser: CreateData = serde_json::from_str(&json).expect("deserialize");
444        assert_eq!(data, deser);
445    }
446
447    #[test]
448    fn create_data_minimal() {
449        let json = r#"{"title":"Hello","kind":"task"}"#;
450        let data: CreateData = serde_json::from_str(json).expect("deserialize");
451        assert_eq!(data.title, "Hello");
452        assert_eq!(data.kind, Kind::Task);
453        assert_eq!(data.urgency, Urgency::Default);
454        assert!(data.labels.is_empty());
455        assert!(data.parent.is_none());
456        assert!(data.causation.is_none());
457        assert!(data.description.is_none());
458    }
459
460    #[test]
461    fn create_data_with_unknown_fields() {
462        let json = r#"{"title":"Test","kind":"bug","future_field":"value123"}"#;
463        let data: CreateData = serde_json::from_str(json).expect("deserialize");
464        assert_eq!(data.title, "Test");
465        assert_eq!(data.kind, Kind::Bug);
466        assert_eq!(data.extra.get("future_field"), Some(&json!("value123")));
467
468        // Roundtrip preserves the unknown field
469        let reserialized = serde_json::to_string(&data).expect("serialize");
470        assert!(reserialized.contains("future_field"));
471    }
472
473    #[test]
474    fn create_data_plan_example() {
475        // From plan.md example line
476        let json = r#"{"kind":"task","labels":["backend"],"size":"m","title":"Fix auth retry"}"#;
477        let data: CreateData = serde_json::from_str(json).expect("deserialize");
478        assert_eq!(data.title, "Fix auth retry");
479        assert_eq!(data.kind, Kind::Task);
480        assert_eq!(data.size, Some(Size::M));
481        assert_eq!(data.labels, vec!["backend"]);
482    }
483
484    // === UpdateData =========================================================
485
486    #[test]
487    fn update_data_string_field() {
488        let data = UpdateData {
489            field: "title".into(),
490            value: json!("New title"),
491            extra: BTreeMap::new(),
492        };
493        let json = serde_json::to_string(&data).expect("serialize");
494        let deser: UpdateData = serde_json::from_str(&json).expect("deserialize");
495        assert_eq!(data, deser);
496    }
497
498    #[test]
499    fn update_data_array_field() {
500        let data = UpdateData {
501            field: "labels".into(),
502            value: json!(["frontend", "urgent"]),
503            extra: BTreeMap::new(),
504        };
505        let json = serde_json::to_string(&data).expect("serialize");
506        let deser: UpdateData = serde_json::from_str(&json).expect("deserialize");
507        assert_eq!(data, deser);
508    }
509
510    // === MoveData ===========================================================
511
512    #[test]
513    fn move_data_without_reason() {
514        let json = r#"{"state":"doing"}"#;
515        let data: MoveData = serde_json::from_str(json).expect("deserialize");
516        assert_eq!(data.state, State::Doing);
517        assert!(data.reason.is_none());
518    }
519
520    #[test]
521    fn move_data_with_reason() {
522        let json = r#"{"state":"done","reason":"Shipped in commit 9f3a2b1"}"#;
523        let data: MoveData = serde_json::from_str(json).expect("deserialize");
524        assert_eq!(data.state, State::Done);
525        assert_eq!(data.reason.as_deref(), Some("Shipped in commit 9f3a2b1"));
526    }
527
528    #[test]
529    fn move_data_roundtrip() {
530        let data = MoveData {
531            state: State::Archived,
532            reason: Some("No longer needed".into()),
533            extra: BTreeMap::new(),
534        };
535        let json = serde_json::to_string(&data).expect("serialize");
536        let deser: MoveData = serde_json::from_str(&json).expect("deserialize");
537        assert_eq!(data, deser);
538    }
539
540    // === AssignData =========================================================
541
542    #[test]
543    fn assign_data_roundtrip() {
544        let data = AssignData {
545            agent: "claude-abc".into(),
546            action: AssignAction::Assign,
547            extra: BTreeMap::new(),
548        };
549        let json = serde_json::to_string(&data).expect("serialize");
550        let deser: AssignData = serde_json::from_str(&json).expect("deserialize");
551        assert_eq!(data, deser);
552    }
553
554    #[test]
555    fn assign_data_unassign() {
556        let json = r#"{"agent":"gemini-xyz","action":"unassign"}"#;
557        let data: AssignData = serde_json::from_str(json).expect("deserialize");
558        assert_eq!(data.agent, "gemini-xyz");
559        assert_eq!(data.action, AssignAction::Unassign);
560    }
561
562    // === CommentData ========================================================
563
564    #[test]
565    fn comment_data_roundtrip() {
566        let data = CommentData {
567            body: "Root cause is a race in token refresh.".into(),
568            extra: BTreeMap::new(),
569        };
570        let json = serde_json::to_string(&data).expect("serialize");
571        let deser: CommentData = serde_json::from_str(&json).expect("deserialize");
572        assert_eq!(data, deser);
573    }
574
575    // === LinkData ===========================================================
576
577    #[test]
578    fn link_data_blocks() {
579        let data = LinkData {
580            target: "bn-c7d2".into(),
581            link_type: "blocks".into(),
582            extra: BTreeMap::new(),
583        };
584        let json = serde_json::to_string(&data).expect("serialize");
585        let deser: LinkData = serde_json::from_str(&json).expect("deserialize");
586        assert_eq!(data, deser);
587    }
588
589    #[test]
590    fn link_data_related() {
591        let data = LinkData {
592            target: "bn-a7x".into(),
593            link_type: "related_to".into(),
594            extra: BTreeMap::new(),
595        };
596        let json = serde_json::to_string(&data).expect("serialize");
597        assert!(json.contains("related_to"));
598    }
599
600    // === UnlinkData =========================================================
601
602    #[test]
603    fn unlink_data_roundtrip() {
604        let data = UnlinkData {
605            target: "bn-c7d2".into(),
606            link_type: Some("blocks".into()),
607            extra: BTreeMap::new(),
608        };
609        let json = serde_json::to_string(&data).expect("serialize");
610        let deser: UnlinkData = serde_json::from_str(&json).expect("deserialize");
611        assert_eq!(data, deser);
612    }
613
614    #[test]
615    fn unlink_data_without_link_type() {
616        let json = r#"{"target":"bn-a7x"}"#;
617        let data: UnlinkData = serde_json::from_str(json).expect("deserialize");
618        assert_eq!(data.target, "bn-a7x");
619        assert!(data.link_type.is_none());
620    }
621
622    // === DeleteData =========================================================
623
624    #[test]
625    fn delete_data_empty() {
626        let json = "{}";
627        let data: DeleteData = serde_json::from_str(json).expect("deserialize");
628        assert!(data.reason.is_none());
629    }
630
631    #[test]
632    fn delete_data_with_reason() {
633        let data = DeleteData {
634            reason: Some("Duplicate of bn-xyz".into()),
635            extra: BTreeMap::new(),
636        };
637        let json = serde_json::to_string(&data).expect("serialize");
638        let deser: DeleteData = serde_json::from_str(&json).expect("deserialize");
639        assert_eq!(data, deser);
640    }
641
642    // === CompactData ========================================================
643
644    #[test]
645    fn compact_data_roundtrip() {
646        let data = CompactData {
647            summary: "Auth token refresh race condition fix.".into(),
648            extra: BTreeMap::new(),
649        };
650        let json = serde_json::to_string(&data).expect("serialize");
651        let deser: CompactData = serde_json::from_str(&json).expect("deserialize");
652        assert_eq!(data, deser);
653    }
654
655    // === SnapshotData =======================================================
656
657    #[test]
658    fn snapshot_data_roundtrip() {
659        let state = json!({
660            "id": "bn-a3f8",
661            "title": "Fix auth retry",
662            "kind": "task",
663            "state": "done",
664            "urgency": "default",
665            "labels": ["backend"],
666            "assignees": ["claude-abc"]
667        });
668        let data = SnapshotData {
669            state,
670            extra: BTreeMap::new(),
671        };
672        let json = serde_json::to_string(&data).expect("serialize");
673        let deser: SnapshotData = serde_json::from_str(&json).expect("deserialize");
674        assert_eq!(data, deser);
675    }
676
677    // === RedactData =========================================================
678
679    #[test]
680    fn redact_data_roundtrip() {
681        let data = RedactData {
682            target_hash: "blake3:a1b2c3d4e5f6".into(),
683            reason: "Accidental secret exposure".into(),
684            extra: BTreeMap::new(),
685        };
686        let json = serde_json::to_string(&data).expect("serialize");
687        let deser: RedactData = serde_json::from_str(&json).expect("deserialize");
688        assert_eq!(data, deser);
689    }
690
691    // === EventData::deserialize_for =========================================
692
693    #[test]
694    fn deserialize_for_create() {
695        let json = r#"{"title":"Test","kind":"task"}"#;
696        let data = EventData::deserialize_for(EventType::Create, json).expect("should parse");
697        assert!(matches!(data, EventData::Create(_)));
698    }
699
700    #[test]
701    fn deserialize_for_update() {
702        let json = r#"{"field":"title","value":"New"}"#;
703        let data = EventData::deserialize_for(EventType::Update, json).expect("should parse");
704        assert!(matches!(data, EventData::Update(_)));
705    }
706
707    #[test]
708    fn deserialize_for_move() {
709        let json = r#"{"state":"doing"}"#;
710        let data = EventData::deserialize_for(EventType::Move, json).expect("should parse");
711        assert!(matches!(data, EventData::Move(_)));
712    }
713
714    #[test]
715    fn deserialize_for_assign() {
716        let json = r#"{"agent":"alice","action":"assign"}"#;
717        let data = EventData::deserialize_for(EventType::Assign, json).expect("should parse");
718        assert!(matches!(data, EventData::Assign(_)));
719    }
720
721    #[test]
722    fn deserialize_for_comment() {
723        let json = r#"{"body":"Hello world"}"#;
724        let data = EventData::deserialize_for(EventType::Comment, json).expect("should parse");
725        assert!(matches!(data, EventData::Comment(_)));
726    }
727
728    #[test]
729    fn deserialize_for_link() {
730        let json = r#"{"target":"bn-abc","link_type":"blocks"}"#;
731        let data = EventData::deserialize_for(EventType::Link, json).expect("should parse");
732        assert!(matches!(data, EventData::Link(_)));
733    }
734
735    #[test]
736    fn deserialize_for_unlink() {
737        let json = r#"{"target":"bn-abc"}"#;
738        let data = EventData::deserialize_for(EventType::Unlink, json).expect("should parse");
739        assert!(matches!(data, EventData::Unlink(_)));
740    }
741
742    #[test]
743    fn deserialize_for_delete() {
744        let json = "{}";
745        let data = EventData::deserialize_for(EventType::Delete, json).expect("should parse");
746        assert!(matches!(data, EventData::Delete(_)));
747    }
748
749    #[test]
750    fn deserialize_for_compact() {
751        let json = r#"{"summary":"TL;DR"}"#;
752        let data = EventData::deserialize_for(EventType::Compact, json).expect("should parse");
753        assert!(matches!(data, EventData::Compact(_)));
754    }
755
756    #[test]
757    fn deserialize_for_snapshot() {
758        let json = r#"{"state":{"id":"bn-a3f8","title":"Test"}}"#;
759        let data = EventData::deserialize_for(EventType::Snapshot, json).expect("should parse");
760        assert!(matches!(data, EventData::Snapshot(_)));
761    }
762
763    #[test]
764    fn deserialize_for_redact() {
765        let json = r#"{"target_hash":"blake3:abc","reason":"oops"}"#;
766        let data = EventData::deserialize_for(EventType::Redact, json).expect("should parse");
767        assert!(matches!(data, EventData::Redact(_)));
768    }
769
770    #[test]
771    fn deserialize_for_error_includes_event_type() {
772        let err =
773            EventData::deserialize_for(EventType::Create, "not json").expect_err("should fail");
774        assert!(err.to_string().contains("item.create"));
775    }
776
777    #[test]
778    fn deserialize_for_error_missing_required_field() {
779        // CreateData requires title and kind
780        let err = EventData::deserialize_for(EventType::Create, r#"{"kind":"task"}"#)
781            .expect_err("should fail");
782        assert!(err.to_string().contains("item.create"));
783    }
784
785    // === AssignAction =======================================================
786
787    #[test]
788    fn assign_action_display_fromstr_roundtrip() {
789        for action in [AssignAction::Assign, AssignAction::Unassign] {
790            let s = action.to_string();
791            let reparsed: AssignAction = s.parse().expect("should parse");
792            assert_eq!(action, reparsed);
793        }
794    }
795
796    #[test]
797    fn assign_action_rejects_unknown() {
798        assert!("add".parse::<AssignAction>().is_err());
799    }
800
801    // === Forward compatibility =============================================
802
803    #[test]
804    fn all_payload_types_preserve_unknown_fields() {
805        // Test that every payload struct preserves unknown fields via #[serde(flatten)]
806        let test_cases: Vec<(&str, EventType)> = vec![
807            (r#"{"title":"T","kind":"task","x":1}"#, EventType::Create),
808            (r#"{"field":"f","value":"v","x":1}"#, EventType::Update),
809            (r#"{"state":"open","x":1}"#, EventType::Move),
810            (
811                r#"{"agent":"a","action":"assign","x":1}"#,
812                EventType::Assign,
813            ),
814            (r#"{"body":"b","x":1}"#, EventType::Comment),
815            (
816                r#"{"target":"t","link_type":"blocks","x":1}"#,
817                EventType::Link,
818            ),
819            (r#"{"target":"t","x":1}"#, EventType::Unlink),
820            (r#"{"x":1}"#, EventType::Delete),
821            (r#"{"summary":"s","x":1}"#, EventType::Compact),
822            (r#"{"state":{},"x":1}"#, EventType::Snapshot),
823            (
824                r#"{"target_hash":"h","reason":"r","x":1}"#,
825                EventType::Redact,
826            ),
827        ];
828
829        for (json_str, event_type) in test_cases {
830            let data = EventData::deserialize_for(event_type, json_str)
831                .unwrap_or_else(|e| panic!("failed for {event_type}: {e}"));
832
833            // Roundtrip serialization should preserve the unknown "x" field
834            let reserialized = serde_json::to_string(&data).expect("serialize");
835            assert!(
836                reserialized.contains("\"x\":1") || reserialized.contains("\"x\": 1"),
837                "unknown field lost for {event_type}: {reserialized}"
838            );
839        }
840    }
841}