1use 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#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum EventData {
29 Create(CreateData),
31 Update(UpdateData),
33 Move(MoveData),
35 Assign(AssignData),
37 Comment(CommentData),
39 Link(LinkData),
41 Unlink(UnlinkData),
43 Delete(DeleteData),
45 Compact(CompactData),
47 Snapshot(SnapshotData),
49 Redact(RedactData),
51}
52
53impl EventData {
54 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 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#[derive(Debug)]
131pub struct DataParseError {
132 pub event_type: EventType,
134 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
160#[serde(rename_all = "lowercase")]
161pub enum AssignAction {
162 Assign,
164 Unassign,
166}
167
168impl AssignAction {
169 #[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct CreateData {
222 pub title: String,
224
225 pub kind: Kind,
227
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub size: Option<Size>,
231
232 #[serde(default, skip_serializing_if = "is_default_urgency")]
234 pub urgency: Urgency,
235
236 #[serde(default, skip_serializing_if = "Vec::is_empty")]
238 pub labels: Vec<String>,
239
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub parent: Option<String>,
243
244 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub causation: Option<String>,
248
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub description: Option<String>,
252
253 #[serde(flatten)]
255 pub extra: BTreeMap<String, serde_json::Value>,
256}
257
258#[allow(clippy::trivially_copy_pass_by_ref)] fn is_default_urgency(u: &Urgency) -> bool {
260 *u == Urgency::Default
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268pub struct UpdateData {
269 pub field: String,
271
272 pub value: serde_json::Value,
274
275 #[serde(flatten)]
277 pub extra: BTreeMap<String, serde_json::Value>,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct MoveData {
285 pub state: State,
287
288 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub reason: Option<String>,
291
292 #[serde(flatten)]
294 pub extra: BTreeMap<String, serde_json::Value>,
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301pub struct AssignData {
302 pub agent: String,
304
305 pub action: AssignAction,
307
308 #[serde(flatten)]
310 pub extra: BTreeMap<String, serde_json::Value>,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317pub struct CommentData {
318 pub body: String,
320
321 #[serde(flatten)]
323 pub extra: BTreeMap<String, serde_json::Value>,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330pub struct LinkData {
331 pub target: String,
333
334 pub link_type: String,
336
337 #[serde(flatten)]
339 pub extra: BTreeMap<String, serde_json::Value>,
340}
341
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
346pub struct UnlinkData {
347 pub target: String,
349
350 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub link_type: Option<String>,
353
354 #[serde(flatten)]
356 pub extra: BTreeMap<String, serde_json::Value>,
357}
358
359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363pub struct DeleteData {
364 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub reason: Option<String>,
367
368 #[serde(flatten)]
370 pub extra: BTreeMap<String, serde_json::Value>,
371}
372
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
377pub struct CompactData {
378 pub summary: String,
380
381 #[serde(flatten)]
383 pub extra: BTreeMap<String, serde_json::Value>,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391pub struct SnapshotData {
392 pub state: serde_json::Value,
394
395 #[serde(flatten)]
397 pub extra: BTreeMap<String, serde_json::Value>,
398}
399
400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406pub struct RedactData {
407 pub target_hash: String,
409
410 pub reason: String,
412
413 #[serde(flatten)]
415 pub extra: BTreeMap<String, serde_json::Value>,
416}
417
418#[cfg(test)]
423mod tests {
424 use super::*;
425 use serde_json::json;
426
427 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[test]
804 fn all_payload_types_preserve_unknown_fields() {
805 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 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}