1#![allow(
35 clippy::must_use_candidate,
36 clippy::doc_markdown,
37 clippy::use_self,
38 clippy::redundant_closure_for_method_calls,
39 clippy::cast_possible_wrap,
40 clippy::cast_sign_loss,
41 clippy::cast_possible_truncation,
42 clippy::too_many_lines,
43 clippy::redundant_clone,
44 clippy::match_same_arms
45)]
46
47use std::collections::HashSet;
48
49use crate::clock::itc::Stamp;
50use crate::clock::text::stamp_from_text;
51use crate::crdt::OrSet;
52use crate::crdt::gset::GSet;
53use crate::crdt::lww::LwwRegister;
54use crate::crdt::merge::Merge;
55use crate::crdt::state::{EpochPhaseState, Phase};
56use crate::event::Event;
57use crate::event::data::{AssignAction, EventData};
58use crate::event::types::EventType;
59use crate::model::item::{Kind, Size, State, Urgency};
60
61use super::Timestamp;
62
63#[derive(Debug, Clone)]
72pub struct WorkItemState {
73 pub title: LwwRegister<String>,
75 pub description: LwwRegister<String>,
77 pub kind: LwwRegister<Kind>,
79 pub state: EpochPhaseState,
81 pub size: LwwRegister<Option<Size>>,
83 pub urgency: LwwRegister<Urgency>,
85 pub parent: LwwRegister<String>,
87 pub assignees: OrSet<String>,
89 pub labels: OrSet<String>,
91 pub blocked_by: OrSet<String>,
93 pub related_to: OrSet<String>,
95 pub comments: GSet<String>,
97 pub deleted: LwwRegister<bool>,
99 pub created_at: u64,
101 pub updated_at: u64,
103}
104
105impl WorkItemState {
106 pub fn new() -> Self {
111 let zero_stamp = Stamp::seed();
112 let zero_ts = 0u64;
113 let zero_agent = String::new();
114 let zero_hash = String::new();
115
116 Self {
117 title: LwwRegister::new(
118 String::new(),
119 zero_stamp.clone(),
120 zero_ts,
121 zero_agent.clone(),
122 zero_hash.clone(),
123 ),
124 description: LwwRegister::new(
125 String::new(),
126 zero_stamp.clone(),
127 zero_ts,
128 zero_agent.clone(),
129 zero_hash.clone(),
130 ),
131 kind: LwwRegister::new(
132 Kind::Task,
133 zero_stamp.clone(),
134 zero_ts,
135 zero_agent.clone(),
136 zero_hash.clone(),
137 ),
138 state: EpochPhaseState::new(),
139 size: LwwRegister::new(
140 None,
141 zero_stamp.clone(),
142 zero_ts,
143 zero_agent.clone(),
144 zero_hash.clone(),
145 ),
146 urgency: LwwRegister::new(
147 Urgency::Default,
148 zero_stamp.clone(),
149 zero_ts,
150 zero_agent.clone(),
151 zero_hash.clone(),
152 ),
153 parent: LwwRegister::new(
154 String::new(),
155 zero_stamp.clone(),
156 zero_ts,
157 zero_agent.clone(),
158 zero_hash.clone(),
159 ),
160 assignees: OrSet::new(),
161 labels: OrSet::new(),
162 blocked_by: OrSet::new(),
163 related_to: OrSet::new(),
164 comments: GSet::new(),
165 deleted: LwwRegister::new(false, zero_stamp, zero_ts, zero_agent, zero_hash),
166 created_at: 0,
167 updated_at: 0,
168 }
169 }
170
171 pub fn merge(&mut self, other: &WorkItemState) {
177 self.title.merge(&other.title);
178 self.description.merge(&other.description);
179 self.kind.merge(&other.kind);
180 self.state.merge(&other.state);
181 self.size.merge(&other.size);
182 self.urgency.merge(&other.urgency);
183 self.parent.merge(&other.parent);
184
185 self.assignees.merge(other.assignees.clone());
187 self.labels.merge(other.labels.clone());
188 self.blocked_by.merge(other.blocked_by.clone());
189 self.related_to.merge(other.related_to.clone());
190
191 self.comments.merge(other.comments.clone());
193
194 self.deleted.merge(&other.deleted);
196
197 if other.created_at != 0 && (self.created_at == 0 || other.created_at < self.created_at) {
199 self.created_at = other.created_at;
200 }
201 if other.updated_at > self.updated_at {
202 self.updated_at = other.updated_at;
203 }
204 }
205
206 pub fn apply_event(&mut self, event: &Event) {
215 let wall_ts = event.wall_ts_us as u64;
216
217 if self.created_at == 0 || wall_ts < self.created_at {
219 self.created_at = wall_ts;
220 }
221 if wall_ts > self.updated_at {
222 self.updated_at = wall_ts;
223 }
224
225 let stamp = stamp_from_text(&event.itc)
227 .unwrap_or_else(|| derive_stamp_from_hash(&event.event_hash));
228 let agent_id = event.agent.clone();
229 let event_hash = event.event_hash.clone();
230
231 match event.event_type {
232 EventType::Create => {
233 if let EventData::Create(data) = &event.data {
234 self.title = LwwRegister::new(
235 data.title.clone(),
236 stamp.clone(),
237 wall_ts,
238 agent_id.clone(),
239 event_hash.clone(),
240 );
241 self.kind = LwwRegister::new(
242 data.kind,
243 stamp.clone(),
244 wall_ts,
245 agent_id.clone(),
246 event_hash.clone(),
247 );
248 if let Some(size) = data.size {
249 self.size = LwwRegister::new(
250 Some(size),
251 stamp.clone(),
252 wall_ts,
253 agent_id.clone(),
254 event_hash.clone(),
255 );
256 }
257 self.urgency = LwwRegister::new(
258 data.urgency,
259 stamp.clone(),
260 wall_ts,
261 agent_id.clone(),
262 event_hash.clone(),
263 );
264 if let Some(desc) = &data.description {
265 self.description = LwwRegister::new(
266 desc.clone(),
267 stamp.clone(),
268 wall_ts,
269 agent_id.clone(),
270 event_hash.clone(),
271 );
272 }
273 if let Some(parent) = &data.parent {
274 self.parent = LwwRegister::new(
275 parent.clone(),
276 stamp.clone(),
277 wall_ts,
278 agent_id.clone(),
279 event_hash.clone(),
280 );
281 }
282 for label in &data.labels {
284 let tag = make_orset_tag(wall_ts, &agent_id, &event_hash, label);
285 self.labels.add(label.clone(), tag);
286 }
287 }
288 }
289
290 EventType::Update => {
291 if let EventData::Update(data) = &event.data {
292 match data.field.as_str() {
293 "title" => {
294 if let Some(s) = data.value.as_str() {
295 self.title = LwwRegister::new(
296 s.to_string(),
297 stamp,
298 wall_ts,
299 agent_id,
300 event_hash,
301 );
302 }
303 }
304 "description" => {
305 let desc = data
306 .value
307 .as_str()
308 .map(|s| s.to_string())
309 .unwrap_or_default();
310 self.description =
311 LwwRegister::new(desc, stamp, wall_ts, agent_id, event_hash);
312 }
313 "kind" => {
314 if let Some(kind) =
315 data.value.as_str().and_then(|s| s.parse::<Kind>().ok())
316 {
317 self.kind =
318 LwwRegister::new(kind, stamp, wall_ts, agent_id, event_hash);
319 }
320 }
321 "size" => {
322 let size = data.value.as_str().and_then(|s| s.parse::<Size>().ok());
323 self.size =
324 LwwRegister::new(size, stamp, wall_ts, agent_id, event_hash);
325 }
326 "urgency" => {
327 if let Some(urgency) =
328 data.value.as_str().and_then(|s| s.parse::<Urgency>().ok())
329 {
330 self.urgency =
331 LwwRegister::new(urgency, stamp, wall_ts, agent_id, event_hash);
332 }
333 }
334 "parent" => {
335 let parent = data
336 .value
337 .as_str()
338 .map(|s| s.to_string())
339 .unwrap_or_default();
340 self.parent =
341 LwwRegister::new(parent, stamp, wall_ts, agent_id, event_hash);
342 }
343 "labels" => {
344 if let Some(obj) = data.value.as_object() {
346 let action =
347 obj.get("action").and_then(|v| v.as_str()).unwrap_or("");
348 let label = obj
349 .get("label")
350 .and_then(|v| v.as_str())
351 .unwrap_or("")
352 .to_string();
353
354 if !label.is_empty() {
355 match action {
356 "add" => {
357 let tag = make_orset_tag(
358 wall_ts,
359 &agent_id,
360 &event_hash,
361 &label,
362 );
363 self.labels.add(label, tag);
364 }
365 "remove" => {
366 self.labels.remove(&label);
367 }
368 _ => {} }
370 }
371 }
372 }
373 _ => {} }
375 }
376 }
377
378 EventType::Move => {
379 if let EventData::Move(data) = &event.data {
380 let target_phase = state_to_phase(data.state);
382 apply_phase_transition(&mut self.state, target_phase);
383 }
384 }
385
386 EventType::Assign => {
387 if let EventData::Assign(data) = &event.data {
388 match data.action {
389 AssignAction::Assign => {
390 let tag = make_orset_tag(wall_ts, &agent_id, &event_hash, &data.agent);
391 self.assignees.add(data.agent.clone(), tag);
392 }
393 AssignAction::Unassign => {
394 self.assignees.remove(&data.agent);
395 }
396 }
397 }
398 }
399
400 EventType::Comment => {
401 if let EventData::Comment(_) = &event.data {
402 self.comments.insert(event.event_hash.clone());
404 }
405 }
406
407 EventType::Link => {
408 if let EventData::Link(data) = &event.data {
409 let tag = make_orset_tag(wall_ts, &agent_id, &event_hash, &data.target);
410 match data.link_type.as_str() {
411 "blocks" | "blocked_by" => {
412 self.blocked_by.add(data.target.clone(), tag);
413 }
414 "related_to" | "related" => {
415 self.related_to.add(data.target.clone(), tag);
416 }
417 _ => {} }
419 }
420 }
421
422 EventType::Unlink => {
423 if let EventData::Unlink(data) = &event.data {
424 let is_blocked = data
425 .link_type
426 .as_ref()
427 .is_none_or(|lt| lt == "blocks" || lt == "blocked_by");
428 let is_related = data
429 .link_type
430 .as_ref()
431 .is_none_or(|lt| lt == "related_to" || lt == "related");
432
433 if is_blocked {
434 self.blocked_by.remove(&data.target);
435 }
436 if is_related {
437 self.related_to.remove(&data.target);
438 }
439 }
440 }
441
442 EventType::Delete => {
443 self.deleted = LwwRegister::new(true, stamp, wall_ts, agent_id, event_hash);
445 }
446
447 EventType::Compact => {
448 if let EventData::Compact(data) = &event.data {
449 self.description = LwwRegister::new(
451 data.summary.clone(),
452 stamp,
453 wall_ts,
454 agent_id,
455 event_hash,
456 );
457 }
458 }
459
460 EventType::Snapshot => {
461 }
465
466 EventType::Redact => {
467 }
470 }
471 }
472
473 pub const fn is_deleted(&self) -> bool {
475 self.deleted.value
476 }
477
478 pub const fn phase(&self) -> Phase {
480 self.state.phase
481 }
482
483 pub const fn epoch(&self) -> u64 {
485 self.state.epoch
486 }
487
488 pub fn assignee_names(&self) -> HashSet<&String> {
490 self.assignees.values()
491 }
492
493 pub fn label_names(&self) -> HashSet<&String> {
495 self.labels.values()
496 }
497
498 pub fn blocked_by_ids(&self) -> HashSet<&String> {
500 self.blocked_by.values()
501 }
502
503 pub fn related_to_ids(&self) -> HashSet<&String> {
505 self.related_to.values()
506 }
507
508 pub const fn comment_hashes(&self) -> &HashSet<String> {
510 &self.comments.elements
511 }
512}
513
514impl Default for WorkItemState {
515 fn default() -> Self {
516 Self::new()
517 }
518}
519
520const fn state_to_phase(state: State) -> Phase {
526 match state {
527 State::Open => Phase::Open,
528 State::Doing => Phase::Doing,
529 State::Done => Phase::Done,
530 State::Archived => Phase::Archived,
531 }
532}
533
534fn apply_phase_transition(state: &mut EpochPhaseState, target: Phase) {
541 if target == Phase::Open && state.phase > Phase::Open {
542 state.reopen();
544 } else if target > state.phase {
545 let _ = state.advance(target);
547 } else if target < state.phase && target != Phase::Open {
548 state.reopen();
550 let _ = state.advance(target);
551 }
552 }
554
555fn derive_stamp_from_hash(event_hash: &str) -> Stamp {
561 use std::hash::{Hash, Hasher};
562 let mut hasher = std::collections::hash_map::DefaultHasher::new();
563 event_hash.hash(&mut hasher);
564 let bits = hasher.finish();
565
566 let mut stamp = Stamp::seed();
571 for i in 0..8 {
572 let (left, right) = stamp.fork();
573 stamp = if (bits >> i) & 1 == 0 { left } else { right };
574 }
575 stamp.event();
576 stamp
577}
578
579fn make_orset_tag(wall_ts: u64, agent: &str, event_hash: &str, suffix: &str) -> Timestamp {
584 use chrono::TimeZone;
585 use std::hash::{Hash, Hasher};
586
587 let secs = wall_ts / 1_000_000;
588 let nsecs = ((wall_ts % 1_000_000) * 1_000) as u32;
589 let wall = chrono::Utc
590 .timestamp_opt(secs as i64, nsecs)
591 .single()
592 .unwrap_or_else(chrono::Utc::now);
593
594 let mut hasher = std::collections::hash_map::DefaultHasher::new();
595 agent.hash(&mut hasher);
596 let actor = hasher.finish();
597
598 let mut hasher = std::collections::hash_map::DefaultHasher::new();
599 event_hash.hash(&mut hasher);
600 suffix.hash(&mut hasher);
601 let event_hash_u64 = hasher.finish();
602
603 Timestamp {
604 wall,
605 actor,
606 event_hash: event_hash_u64,
607 itc: wall_ts,
608 }
609}
610
611#[cfg(test)]
616mod tests {
617 use super::*;
618 use crate::clock::itc::Stamp;
619 use crate::event::Event;
620 use crate::event::data::*;
621 use crate::event::types::EventType;
622 use crate::model::item::{Kind, Size, State, Urgency};
623 use crate::model::item_id::ItemId;
624 use std::collections::BTreeMap;
625
626 fn make_event(
631 event_type: EventType,
632 data: EventData,
633 wall_ts_us: i64,
634 agent: &str,
635 event_hash: &str,
636 ) -> Event {
637 let mut stamp = Stamp::seed();
638 stamp.event();
639 Event {
640 wall_ts_us,
641 agent: agent.to_string(),
642 itc: stamp.to_string(),
643 parents: vec![],
644 event_type,
645 item_id: ItemId::new_unchecked("bn-test1"),
646 data,
647 event_hash: event_hash.to_string(),
648 }
649 }
650
651 fn create_event(title: &str, wall_ts: i64, agent: &str, hash: &str) -> Event {
652 make_event(
653 EventType::Create,
654 EventData::Create(CreateData {
655 title: title.to_string(),
656 kind: Kind::Task,
657 size: Some(Size::M),
658 urgency: Urgency::Default,
659 labels: vec!["backend".to_string()],
660 parent: None,
661 causation: None,
662 description: Some("A description".to_string()),
663 extra: BTreeMap::new(),
664 }),
665 wall_ts,
666 agent,
667 hash,
668 )
669 }
670
671 fn update_title_event(title: &str, wall_ts: i64, agent: &str, hash: &str) -> Event {
672 make_event(
673 EventType::Update,
674 EventData::Update(UpdateData {
675 field: "title".to_string(),
676 value: serde_json::Value::String(title.to_string()),
677 extra: BTreeMap::new(),
678 }),
679 wall_ts,
680 agent,
681 hash,
682 )
683 }
684
685 fn move_event(state: State, wall_ts: i64, agent: &str, hash: &str) -> Event {
686 make_event(
687 EventType::Move,
688 EventData::Move(MoveData {
689 state,
690 reason: None,
691 extra: BTreeMap::new(),
692 }),
693 wall_ts,
694 agent,
695 hash,
696 )
697 }
698
699 fn assign_event(
700 target_agent: &str,
701 action: AssignAction,
702 wall_ts: i64,
703 agent: &str,
704 hash: &str,
705 ) -> Event {
706 make_event(
707 EventType::Assign,
708 EventData::Assign(AssignData {
709 agent: target_agent.to_string(),
710 action,
711 extra: BTreeMap::new(),
712 }),
713 wall_ts,
714 agent,
715 hash,
716 )
717 }
718
719 fn comment_event(body: &str, wall_ts: i64, agent: &str, hash: &str) -> Event {
720 make_event(
721 EventType::Comment,
722 EventData::Comment(CommentData {
723 body: body.to_string(),
724 extra: BTreeMap::new(),
725 }),
726 wall_ts,
727 agent,
728 hash,
729 )
730 }
731
732 fn link_event(target: &str, link_type: &str, wall_ts: i64, agent: &str, hash: &str) -> Event {
733 make_event(
734 EventType::Link,
735 EventData::Link(LinkData {
736 target: target.to_string(),
737 link_type: link_type.to_string(),
738 extra: BTreeMap::new(),
739 }),
740 wall_ts,
741 agent,
742 hash,
743 )
744 }
745
746 fn unlink_event(
747 target: &str,
748 link_type: Option<&str>,
749 wall_ts: i64,
750 agent: &str,
751 hash: &str,
752 ) -> Event {
753 make_event(
754 EventType::Unlink,
755 EventData::Unlink(UnlinkData {
756 target: target.to_string(),
757 link_type: link_type.map(|s| s.to_string()),
758 extra: BTreeMap::new(),
759 }),
760 wall_ts,
761 agent,
762 hash,
763 )
764 }
765
766 fn delete_event(wall_ts: i64, agent: &str, hash: &str) -> Event {
767 make_event(
768 EventType::Delete,
769 EventData::Delete(DeleteData {
770 reason: Some("duplicate".to_string()),
771 extra: BTreeMap::new(),
772 }),
773 wall_ts,
774 agent,
775 hash,
776 )
777 }
778
779 fn compact_event(summary: &str, wall_ts: i64, agent: &str, hash: &str) -> Event {
780 make_event(
781 EventType::Compact,
782 EventData::Compact(CompactData {
783 summary: summary.to_string(),
784 extra: BTreeMap::new(),
785 }),
786 wall_ts,
787 agent,
788 hash,
789 )
790 }
791
792 fn label_add_event(label: &str, wall_ts: i64, agent: &str, hash: &str) -> Event {
793 make_event(
794 EventType::Update,
795 EventData::Update(UpdateData {
796 field: "labels".to_string(),
797 value: serde_json::json!({"action": "add", "label": label}),
798 extra: BTreeMap::new(),
799 }),
800 wall_ts,
801 agent,
802 hash,
803 )
804 }
805
806 fn label_remove_event(label: &str, wall_ts: i64, agent: &str, hash: &str) -> Event {
807 make_event(
808 EventType::Update,
809 EventData::Update(UpdateData {
810 field: "labels".to_string(),
811 value: serde_json::json!({"action": "remove", "label": label}),
812 extra: BTreeMap::new(),
813 }),
814 wall_ts,
815 agent,
816 hash,
817 )
818 }
819
820 #[test]
825 fn default_state_is_empty() {
826 let state = WorkItemState::new();
827 assert_eq!(state.title.value, "");
828 assert_eq!(state.description.value, "");
829 assert_eq!(state.kind.value, Kind::Task);
830 assert_eq!(state.state, EpochPhaseState::new());
831 assert_eq!(state.size.value, None);
832 assert_eq!(state.urgency.value, Urgency::Default);
833 assert_eq!(state.parent.value, "");
834 assert!(state.assignees.is_empty());
835 assert!(state.labels.is_empty());
836 assert!(state.blocked_by.is_empty());
837 assert!(state.related_to.is_empty());
838 assert!(state.comments.is_empty());
839 assert!(!state.is_deleted());
840 assert_eq!(state.created_at, 0);
841 assert_eq!(state.updated_at, 0);
842 }
843
844 #[test]
845 fn default_impl_matches_new() {
846 let a = WorkItemState::new();
847 let b = WorkItemState::default();
848 assert_eq!(a.title.value, b.title.value);
850 assert_eq!(a.state, b.state);
851 assert_eq!(a.created_at, b.created_at);
852 }
853
854 #[test]
859 fn apply_create_sets_fields() {
860 let mut state = WorkItemState::new();
861 let event = create_event("Fix auth", 1000, "alice", "blake3:create1");
862 state.apply_event(&event);
863
864 assert_eq!(state.title.value, "Fix auth");
865 assert_eq!(state.kind.value, Kind::Task);
866 assert_eq!(state.size.value, Some(Size::M));
867 assert_eq!(state.urgency.value, Urgency::Default);
868 assert_eq!(state.description.value, "A description");
869 assert!(state.label_names().contains(&"backend".to_string()));
870 assert_eq!(state.created_at, 1000);
871 assert_eq!(state.updated_at, 1000);
872 }
873
874 #[test]
879 fn apply_update_title() {
880 let mut state = WorkItemState::new();
881 state.apply_event(&create_event("Old", 1000, "alice", "blake3:c1"));
882 state.apply_event(&update_title_event("New Title", 2000, "alice", "blake3:u1"));
883 assert_eq!(state.title.value, "New Title");
884 }
885
886 #[test]
887 fn apply_update_description() {
888 let mut state = WorkItemState::new();
889 let event = make_event(
890 EventType::Update,
891 EventData::Update(UpdateData {
892 field: "description".to_string(),
893 value: serde_json::Value::String("Updated desc".to_string()),
894 extra: BTreeMap::new(),
895 }),
896 2000,
897 "alice",
898 "blake3:u2",
899 );
900 state.apply_event(&event);
901 assert_eq!(state.description.value, "Updated desc");
902 }
903
904 #[test]
905 fn apply_update_kind() {
906 let mut state = WorkItemState::new();
907 let event = make_event(
908 EventType::Update,
909 EventData::Update(UpdateData {
910 field: "kind".to_string(),
911 value: serde_json::Value::String("bug".to_string()),
912 extra: BTreeMap::new(),
913 }),
914 2000,
915 "alice",
916 "blake3:u3",
917 );
918 state.apply_event(&event);
919 assert_eq!(state.kind.value, Kind::Bug);
920 }
921
922 #[test]
923 fn apply_update_size() {
924 let mut state = WorkItemState::new();
925 let event = make_event(
926 EventType::Update,
927 EventData::Update(UpdateData {
928 field: "size".to_string(),
929 value: serde_json::Value::String("xl".to_string()),
930 extra: BTreeMap::new(),
931 }),
932 2000,
933 "alice",
934 "blake3:u4",
935 );
936 state.apply_event(&event);
937 assert_eq!(state.size.value, Some(Size::Xl));
938 }
939
940 #[test]
941 fn apply_update_urgency() {
942 let mut state = WorkItemState::new();
943 let event = make_event(
944 EventType::Update,
945 EventData::Update(UpdateData {
946 field: "urgency".to_string(),
947 value: serde_json::Value::String("urgent".to_string()),
948 extra: BTreeMap::new(),
949 }),
950 2000,
951 "alice",
952 "blake3:u5",
953 );
954 state.apply_event(&event);
955 assert_eq!(state.urgency.value, Urgency::Urgent);
956 }
957
958 #[test]
959 fn apply_update_parent() {
960 let mut state = WorkItemState::new();
961 let event = make_event(
962 EventType::Update,
963 EventData::Update(UpdateData {
964 field: "parent".to_string(),
965 value: serde_json::Value::String("bn-parent1".to_string()),
966 extra: BTreeMap::new(),
967 }),
968 2000,
969 "alice",
970 "blake3:u6",
971 );
972 state.apply_event(&event);
973 assert_eq!(state.parent.value, "bn-parent1");
974 }
975
976 #[test]
977 fn apply_update_labels_add_remove() {
978 let mut state = WorkItemState::new();
979 state.apply_event(&label_add_event("frontend", 1000, "alice", "blake3:la1"));
980 assert!(state.label_names().contains(&"frontend".to_string()));
981
982 state.apply_event(&label_add_event("urgent", 2000, "alice", "blake3:la2"));
983 assert_eq!(state.labels.len(), 2);
984
985 state.apply_event(&label_remove_event("frontend", 3000, "alice", "blake3:lr1"));
986 assert!(!state.label_names().contains(&"frontend".to_string()));
987 assert!(state.label_names().contains(&"urgent".to_string()));
988 }
989
990 #[test]
991 fn apply_update_unknown_field_is_noop() {
992 let mut state = WorkItemState::new();
993 let event = make_event(
994 EventType::Update,
995 EventData::Update(UpdateData {
996 field: "nonexistent_field".to_string(),
997 value: serde_json::Value::String("whatever".to_string()),
998 extra: BTreeMap::new(),
999 }),
1000 2000,
1001 "alice",
1002 "blake3:u7",
1003 );
1004 let before_title = state.title.value.clone();
1005 state.apply_event(&event);
1006 assert_eq!(state.title.value, before_title);
1007 }
1008
1009 #[test]
1014 fn apply_move_forward() {
1015 let mut state = WorkItemState::new();
1016 state.apply_event(&move_event(State::Doing, 1000, "alice", "blake3:m1"));
1017 assert_eq!(state.phase(), Phase::Doing);
1018
1019 state.apply_event(&move_event(State::Done, 2000, "alice", "blake3:m2"));
1020 assert_eq!(state.phase(), Phase::Done);
1021 }
1022
1023 #[test]
1024 fn apply_move_reopen() {
1025 let mut state = WorkItemState::new();
1026 state.apply_event(&move_event(State::Done, 1000, "alice", "blake3:m1"));
1027 assert_eq!(state.phase(), Phase::Done);
1028 assert_eq!(state.epoch(), 0);
1029
1030 state.apply_event(&move_event(State::Open, 2000, "alice", "blake3:m2"));
1031 assert_eq!(state.phase(), Phase::Open);
1032 assert_eq!(state.epoch(), 1);
1033 }
1034
1035 #[test]
1036 fn apply_move_archived_then_reopen() {
1037 let mut state = WorkItemState::new();
1038 state.apply_event(&move_event(State::Done, 1000, "alice", "blake3:m1"));
1039 state.apply_event(&move_event(State::Archived, 2000, "alice", "blake3:m2"));
1040 assert_eq!(state.phase(), Phase::Archived);
1041 assert_eq!(state.epoch(), 0);
1042
1043 state.apply_event(&move_event(State::Open, 3000, "alice", "blake3:m3"));
1044 assert_eq!(state.phase(), Phase::Open);
1045 assert_eq!(state.epoch(), 1);
1046 }
1047
1048 #[test]
1053 fn apply_assign_and_unassign() {
1054 let mut state = WorkItemState::new();
1055 state.apply_event(&assign_event(
1056 "alice",
1057 AssignAction::Assign,
1058 1000,
1059 "admin",
1060 "blake3:a1",
1061 ));
1062 assert!(state.assignee_names().contains(&"alice".to_string()));
1063
1064 state.apply_event(&assign_event(
1065 "bob",
1066 AssignAction::Assign,
1067 2000,
1068 "admin",
1069 "blake3:a2",
1070 ));
1071 assert_eq!(state.assignees.len(), 2);
1072
1073 state.apply_event(&assign_event(
1074 "alice",
1075 AssignAction::Unassign,
1076 3000,
1077 "admin",
1078 "blake3:a3",
1079 ));
1080 assert!(!state.assignee_names().contains(&"alice".to_string()));
1081 assert!(state.assignee_names().contains(&"bob".to_string()));
1082 }
1083
1084 #[test]
1089 fn apply_comment_adds_to_gset() {
1090 let mut state = WorkItemState::new();
1091 state.apply_event(&comment_event("hello", 1000, "alice", "blake3:c1"));
1092 state.apply_event(&comment_event("world", 2000, "bob", "blake3:c2"));
1093
1094 assert_eq!(state.comments.len(), 2);
1095 assert!(state.comment_hashes().contains("blake3:c1"));
1096 assert!(state.comment_hashes().contains("blake3:c2"));
1097 }
1098
1099 #[test]
1100 fn apply_duplicate_comment_is_idempotent() {
1101 let mut state = WorkItemState::new();
1102 let event = comment_event("hello", 1000, "alice", "blake3:c1");
1103 state.apply_event(&event);
1104 state.apply_event(&event);
1105 assert_eq!(state.comments.len(), 1);
1106 }
1107
1108 #[test]
1113 fn apply_link_blocks() {
1114 let mut state = WorkItemState::new();
1115 state.apply_event(&link_event(
1116 "bn-blocker",
1117 "blocks",
1118 1000,
1119 "alice",
1120 "blake3:l1",
1121 ));
1122 assert!(state.blocked_by_ids().contains(&"bn-blocker".to_string()));
1123 }
1124
1125 #[test]
1126 fn apply_link_related() {
1127 let mut state = WorkItemState::new();
1128 state.apply_event(&link_event(
1129 "bn-related",
1130 "related_to",
1131 1000,
1132 "alice",
1133 "blake3:l2",
1134 ));
1135 assert!(state.related_to_ids().contains(&"bn-related".to_string()));
1136 }
1137
1138 #[test]
1139 fn apply_unlink_blocks() {
1140 let mut state = WorkItemState::new();
1141 state.apply_event(&link_event("bn-b1", "blocks", 1000, "alice", "blake3:l1"));
1142 assert!(!state.blocked_by.is_empty());
1143
1144 state.apply_event(&unlink_event(
1145 "bn-b1",
1146 Some("blocks"),
1147 2000,
1148 "alice",
1149 "blake3:ul1",
1150 ));
1151 assert!(state.blocked_by.is_empty());
1152 }
1153
1154 #[test]
1155 fn apply_unlink_related() {
1156 let mut state = WorkItemState::new();
1157 state.apply_event(&link_event(
1158 "bn-r1",
1159 "related_to",
1160 1000,
1161 "alice",
1162 "blake3:l1",
1163 ));
1164 state.apply_event(&unlink_event(
1165 "bn-r1",
1166 Some("related_to"),
1167 2000,
1168 "alice",
1169 "blake3:ul1",
1170 ));
1171 assert!(state.related_to.is_empty());
1172 }
1173
1174 #[test]
1179 fn apply_delete_sets_flag() {
1180 let mut state = WorkItemState::new();
1181 assert!(!state.is_deleted());
1182
1183 state.apply_event(&delete_event(1000, "alice", "blake3:d1"));
1184 assert!(state.is_deleted());
1185 }
1186
1187 #[test]
1192 fn apply_compact_replaces_description() {
1193 let mut state = WorkItemState::new();
1194 state.apply_event(&create_event("Title", 1000, "alice", "blake3:c1"));
1195 assert_eq!(state.description.value, "A description");
1196
1197 state.apply_event(&compact_event("TL;DR summary", 2000, "alice", "blake3:cp1"));
1198 assert_eq!(state.description.value, "TL;DR summary");
1199 }
1200
1201 #[test]
1206 fn timestamps_track_min_max() {
1207 let mut state = WorkItemState::new();
1208 state.apply_event(&create_event("T", 5000, "alice", "blake3:c1"));
1209 assert_eq!(state.created_at, 5000);
1210 assert_eq!(state.updated_at, 5000);
1211
1212 state.apply_event(&update_title_event("T2", 3000, "bob", "blake3:u1"));
1213 assert_eq!(state.created_at, 3000); assert_eq!(state.updated_at, 5000); state.apply_event(&update_title_event("T3", 8000, "carol", "blake3:u2"));
1217 assert_eq!(state.created_at, 3000);
1218 assert_eq!(state.updated_at, 8000);
1219 }
1220
1221 #[test]
1226 fn merge_lww_fields() {
1227 let mut a = WorkItemState::new();
1228 a.apply_event(&create_event("Title A", 1000, "alice", "blake3:a1"));
1229
1230 let mut b = WorkItemState::new();
1231 b.apply_event(&create_event("Title B", 2000, "bob", "blake3:b1"));
1232
1233 a.merge(&b);
1234 assert_eq!(a.title.value, "Title B");
1236 }
1237
1238 #[test]
1239 fn merge_epoch_phase() {
1240 let mut a = WorkItemState::new();
1241 a.apply_event(&move_event(State::Doing, 1000, "alice", "blake3:m1"));
1242
1243 let mut b = WorkItemState::new();
1244 b.apply_event(&move_event(State::Done, 2000, "bob", "blake3:m2"));
1245
1246 a.merge(&b);
1247 assert_eq!(a.phase(), Phase::Done);
1249 }
1250
1251 #[test]
1252 fn merge_epoch_phase_reopen_wins() {
1253 let mut a = WorkItemState::new();
1254 a.apply_event(&move_event(State::Done, 1000, "alice", "blake3:m1"));
1255
1256 let mut b = WorkItemState::new();
1257 b.apply_event(&move_event(State::Done, 1000, "bob", "blake3:m2"));
1258 b.apply_event(&move_event(State::Open, 2000, "bob", "blake3:m3"));
1259
1260 a.merge(&b);
1261 assert_eq!(a.epoch(), 1);
1263 assert_eq!(a.phase(), Phase::Open);
1264 }
1265
1266 #[test]
1267 fn merge_orset_assignees() {
1268 let mut a = WorkItemState::new();
1269 a.apply_event(&assign_event(
1270 "alice",
1271 AssignAction::Assign,
1272 1000,
1273 "admin",
1274 "blake3:a1",
1275 ));
1276
1277 let mut b = WorkItemState::new();
1278 b.apply_event(&assign_event(
1279 "bob",
1280 AssignAction::Assign,
1281 1000,
1282 "admin",
1283 "blake3:a2",
1284 ));
1285
1286 a.merge(&b);
1287 assert!(a.assignee_names().contains(&"alice".to_string()));
1288 assert!(a.assignee_names().contains(&"bob".to_string()));
1289 }
1290
1291 #[test]
1292 fn merge_gset_comments() {
1293 let mut a = WorkItemState::new();
1294 a.apply_event(&comment_event("c1", 1000, "alice", "blake3:c1"));
1295
1296 let mut b = WorkItemState::new();
1297 b.apply_event(&comment_event("c2", 2000, "bob", "blake3:c2"));
1298
1299 a.merge(&b);
1300 assert_eq!(a.comments.len(), 2);
1301 assert!(a.comment_hashes().contains("blake3:c1"));
1302 assert!(a.comment_hashes().contains("blake3:c2"));
1303 }
1304
1305 #[test]
1306 fn merge_deleted_lww() {
1307 let mut a = WorkItemState::new();
1308 let mut b = WorkItemState::new();
1311 b.apply_event(&delete_event(2000, "bob", "blake3:d1"));
1312
1313 a.merge(&b);
1314 assert!(a.is_deleted());
1316 }
1317
1318 #[test]
1319 fn merge_timestamps() {
1320 let mut a = WorkItemState::new();
1321 a.apply_event(&create_event("A", 5000, "alice", "blake3:a1"));
1322
1323 let mut b = WorkItemState::new();
1324 b.apply_event(&create_event("B", 3000, "bob", "blake3:b1"));
1325 b.apply_event(&update_title_event("B2", 8000, "bob", "blake3:b2"));
1326
1327 a.merge(&b);
1328 assert_eq!(a.created_at, 3000);
1329 assert_eq!(a.updated_at, 8000);
1330 }
1331
1332 fn make_state_a() -> WorkItemState {
1337 let mut s = WorkItemState::new();
1338 s.apply_event(&create_event("Title A", 1000, "alice", "blake3:a1"));
1339 s.apply_event(&move_event(State::Doing, 2000, "alice", "blake3:a2"));
1340 s.apply_event(&assign_event(
1341 "alice",
1342 AssignAction::Assign,
1343 3000,
1344 "admin",
1345 "blake3:a3",
1346 ));
1347 s.apply_event(&comment_event("comment a", 4000, "alice", "blake3:a4"));
1348 s.apply_event(&link_event("bn-b1", "blocks", 5000, "alice", "blake3:a5"));
1349 s
1350 }
1351
1352 fn make_state_b() -> WorkItemState {
1353 let mut s = WorkItemState::new();
1354 s.apply_event(&create_event("Title B", 1500, "bob", "blake3:b1"));
1355 s.apply_event(&move_event(State::Done, 2500, "bob", "blake3:b2"));
1356 s.apply_event(&assign_event(
1357 "bob",
1358 AssignAction::Assign,
1359 3500,
1360 "admin",
1361 "blake3:b3",
1362 ));
1363 s.apply_event(&comment_event("comment b", 4500, "bob", "blake3:b4"));
1364 s.apply_event(&link_event("bn-r1", "related_to", 5500, "bob", "blake3:b5"));
1365 s
1366 }
1367
1368 fn make_state_c() -> WorkItemState {
1369 let mut s = WorkItemState::new();
1370 s.apply_event(&create_event("Title C", 1200, "carol", "blake3:c1"));
1371 s.apply_event(&assign_event(
1372 "carol",
1373 AssignAction::Assign,
1374 3200,
1375 "admin",
1376 "blake3:c3",
1377 ));
1378 s.apply_event(&label_add_event("urgent", 4200, "carol", "blake3:c4"));
1379 s
1380 }
1381
1382 fn states_equal(a: &WorkItemState, b: &WorkItemState) -> bool {
1384 a.title.value == b.title.value
1385 && a.title.wall_ts == b.title.wall_ts
1386 && a.description.value == b.description.value
1387 && a.kind.value == b.kind.value
1388 && a.state == b.state
1389 && a.size.value == b.size.value
1390 && a.urgency.value == b.urgency.value
1391 && a.parent.value == b.parent.value
1392 && a.assignees == b.assignees
1393 && a.labels == b.labels
1394 && a.blocked_by == b.blocked_by
1395 && a.related_to == b.related_to
1396 && a.comments == b.comments
1397 && a.deleted.value == b.deleted.value
1398 && a.created_at == b.created_at
1399 && a.updated_at == b.updated_at
1400 }
1401
1402 #[test]
1403 fn merge_commutative() {
1404 let a = make_state_a();
1405 let b = make_state_b();
1406
1407 let mut ab = a.clone();
1408 ab.merge(&b);
1409
1410 let mut ba = b.clone();
1411 ba.merge(&a);
1412
1413 assert!(
1414 states_equal(&ab, &ba),
1415 "merge should be commutative\n ab.title={}, ba.title={}\n ab.state={:?}, ba.state={:?}",
1416 ab.title.value,
1417 ba.title.value,
1418 ab.state,
1419 ba.state,
1420 );
1421 }
1422
1423 #[test]
1424 fn merge_associative() {
1425 let a = make_state_a();
1426 let b = make_state_b();
1427 let c = make_state_c();
1428
1429 let mut ab_c = a.clone();
1431 ab_c.merge(&b);
1432 ab_c.merge(&c);
1433
1434 let mut bc = b.clone();
1436 bc.merge(&c);
1437 let mut a_bc = a.clone();
1438 a_bc.merge(&bc);
1439
1440 assert!(states_equal(&ab_c, &a_bc), "merge should be associative");
1441 }
1442
1443 #[test]
1444 fn merge_idempotent() {
1445 let a = make_state_a();
1446 let before = a.clone();
1447 let mut merged = a.clone();
1448 merged.merge(&before);
1449
1450 assert!(
1451 states_equal(&merged, &before),
1452 "merge with self should be idempotent"
1453 );
1454 }
1455
1456 #[test]
1461 fn full_lifecycle() {
1462 let mut state = WorkItemState::new();
1463
1464 state.apply_event(&create_event("Fix auth", 1000, "alice", "blake3:e1"));
1466 assert_eq!(state.title.value, "Fix auth");
1467 assert_eq!(state.phase(), Phase::Open);
1468
1469 state.apply_event(&assign_event(
1471 "bob",
1472 AssignAction::Assign,
1473 2000,
1474 "alice",
1475 "blake3:e2",
1476 ));
1477 assert!(state.assignee_names().contains(&"bob".to_string()));
1478
1479 state.apply_event(&move_event(State::Doing, 3000, "bob", "blake3:e3"));
1481 assert_eq!(state.phase(), Phase::Doing);
1482
1483 state.apply_event(&comment_event("Found root cause", 4000, "bob", "blake3:e4"));
1485 assert_eq!(state.comments.len(), 1);
1486
1487 state.apply_event(&update_title_event(
1489 "Fix auth retry logic",
1490 5000,
1491 "bob",
1492 "blake3:e5",
1493 ));
1494 assert_eq!(state.title.value, "Fix auth retry logic");
1495
1496 state.apply_event(&link_event("bn-dep1", "blocks", 6000, "bob", "blake3:e6"));
1498 assert!(!state.blocked_by.is_empty());
1499
1500 state.apply_event(&unlink_event(
1502 "bn-dep1",
1503 Some("blocks"),
1504 7000,
1505 "bob",
1506 "blake3:e7",
1507 ));
1508 assert!(state.blocked_by.is_empty());
1509
1510 state.apply_event(&move_event(State::Done, 8000, "bob", "blake3:e8"));
1512 assert_eq!(state.phase(), Phase::Done);
1513
1514 state.apply_event(&label_add_event("shipped", 9000, "alice", "blake3:e9"));
1516 assert!(state.label_names().contains(&"shipped".to_string()));
1517
1518 assert_eq!(state.created_at, 1000);
1519 assert_eq!(state.updated_at, 9000);
1520 }
1521
1522 #[test]
1527 fn divergent_branches_merge_correctly() {
1528 let create = create_event("Shared Title", 1000, "alice", "blake3:c1");
1533
1534 let mut branch_a = WorkItemState::new();
1536 branch_a.apply_event(&create);
1537 branch_a.apply_event(&update_title_event(
1538 "Alice's Title",
1539 2000,
1540 "alice",
1541 "blake3:a1",
1542 ));
1543 branch_a.apply_event(&move_event(State::Doing, 3000, "alice", "blake3:a2"));
1544 branch_a.apply_event(&assign_event(
1545 "alice",
1546 AssignAction::Assign,
1547 4000,
1548 "alice",
1549 "blake3:a3",
1550 ));
1551
1552 let mut branch_b = WorkItemState::new();
1554 branch_b.apply_event(&create);
1555 branch_b.apply_event(&update_title_event("Bob's Title", 2500, "bob", "blake3:b1"));
1556 branch_b.apply_event(&label_add_event("urgent", 3500, "bob", "blake3:b2"));
1557 branch_b.apply_event(&assign_event(
1558 "bob",
1559 AssignAction::Assign,
1560 4500,
1561 "bob",
1562 "blake3:b3",
1563 ));
1564
1565 let mut merged_ab = branch_a.clone();
1567 merged_ab.merge(&branch_b);
1568
1569 let mut merged_ba = branch_b.clone();
1570 merged_ba.merge(&branch_a);
1571
1572 assert!(states_equal(&merged_ab, &merged_ba));
1573
1574 assert_eq!(merged_ab.title.value, "Bob's Title");
1576
1577 assert_eq!(merged_ab.phase(), Phase::Doing);
1579
1580 assert!(merged_ab.assignee_names().contains(&"alice".to_string()));
1582 assert!(merged_ab.assignee_names().contains(&"bob".to_string()));
1583
1584 assert!(merged_ab.label_names().contains(&"urgent".to_string()));
1586 assert!(merged_ab.label_names().contains(&"backend".to_string()));
1588 }
1589
1590 #[test]
1595 fn merge_default_with_default() {
1596 let a = WorkItemState::new();
1597 let b = WorkItemState::new();
1598 let mut merged = a.clone();
1599 merged.merge(&b);
1600
1601 assert_eq!(merged.title.value, "");
1602 assert_eq!(merged.state, EpochPhaseState::new());
1603 assert_eq!(merged.created_at, 0);
1604 }
1605
1606 #[test]
1607 fn merge_with_default_is_identity() {
1608 let a = make_state_a();
1609 let mut merged = a.clone();
1610 merged.merge(&WorkItemState::new());
1611
1612 assert!(states_equal(&merged, &a));
1613 }
1614
1615 #[test]
1616 fn apply_events_then_merge_equals_merge_then_apply() {
1617 let e1 = create_event("Title", 1000, "alice", "blake3:e1");
1620 let e2 = update_title_event("Updated", 2000, "bob", "blake3:e2");
1621 let e3 = assign_event("carol", AssignAction::Assign, 3000, "admin", "blake3:e3");
1622
1623 let mut path1_a = WorkItemState::new();
1625 path1_a.apply_event(&e1);
1626 path1_a.apply_event(&e2);
1627
1628 let mut path1_b = WorkItemState::new();
1629 path1_b.apply_event(&e1);
1630 path1_b.apply_event(&e3);
1631
1632 path1_a.merge(&path1_b);
1633
1634 let mut path2_b = WorkItemState::new();
1636 path2_b.apply_event(&e1);
1637 path2_b.apply_event(&e3);
1638
1639 let mut path2_a = WorkItemState::new();
1640 path2_a.apply_event(&e1);
1641 path2_a.apply_event(&e2);
1642
1643 path2_b.merge(&path2_a);
1644
1645 assert!(states_equal(&path1_a, &path2_b));
1646 }
1647
1648 #[test]
1649 fn snapshot_event_is_noop() {
1650 let mut state = WorkItemState::new();
1651 state.apply_event(&create_event("Title", 1000, "alice", "blake3:c1"));
1652
1653 let snapshot_event = make_event(
1654 EventType::Snapshot,
1655 EventData::Snapshot(SnapshotData {
1656 state: serde_json::json!({"title": "Snapshot Title"}),
1657 extra: BTreeMap::new(),
1658 }),
1659 2000,
1660 "compactor",
1661 "blake3:s1",
1662 );
1663
1664 let title_before = state.title.value.clone();
1665 state.apply_event(&snapshot_event);
1666 assert_eq!(state.title.value, title_before);
1668 }
1669
1670 #[test]
1671 fn redact_event_is_noop() {
1672 let mut state = WorkItemState::new();
1673 state.apply_event(&create_event("Title", 1000, "alice", "blake3:c1"));
1674
1675 let redact_event = make_event(
1676 EventType::Redact,
1677 EventData::Redact(RedactData {
1678 target_hash: "blake3:c1".to_string(),
1679 reason: "secret".to_string(),
1680 extra: BTreeMap::new(),
1681 }),
1682 2000,
1683 "admin",
1684 "blake3:r1",
1685 );
1686
1687 let title_before = state.title.value.clone();
1688 state.apply_event(&redact_event);
1689 assert_eq!(state.title.value, title_before);
1690 }
1691
1692 #[test]
1697 fn accessor_methods() {
1698 let mut state = WorkItemState::new();
1699 state.apply_event(&create_event("T", 1000, "alice", "blake3:c1"));
1700 state.apply_event(&assign_event(
1701 "alice",
1702 AssignAction::Assign,
1703 2000,
1704 "admin",
1705 "blake3:a1",
1706 ));
1707 state.apply_event(&link_event("bn-b1", "blocks", 3000, "alice", "blake3:l1"));
1708 state.apply_event(&link_event(
1709 "bn-r1",
1710 "related_to",
1711 4000,
1712 "alice",
1713 "blake3:l2",
1714 ));
1715 state.apply_event(&comment_event("hi", 5000, "alice", "blake3:cm1"));
1716
1717 assert!(state.assignee_names().contains(&"alice".to_string()));
1718 assert!(state.label_names().contains(&"backend".to_string()));
1719 assert!(state.blocked_by_ids().contains(&"bn-b1".to_string()));
1720 assert!(state.related_to_ids().contains(&"bn-r1".to_string()));
1721 assert!(state.comment_hashes().contains("blake3:cm1"));
1722 assert_eq!(state.phase(), Phase::Open);
1723 assert_eq!(state.epoch(), 0);
1724 assert!(!state.is_deleted());
1725 }
1726}