made_core/events/
phase_changed.rs1use serde::{Deserialize, Serialize};
8
9use crate::error::DomainError;
10use crate::events::envelope::EventEnvelope;
11use crate::value_objects::TaskId;
12
13const MAX_PHASE_LEN: usize = 64;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct PhaseChangedEvent {
17 #[serde(flatten)]
18 envelope: EventEnvelope,
19 task_id: TaskId,
20 from_phase: String,
21 to_phase: String,
22}
23
24impl PhaseChangedEvent {
25 pub fn new(
26 envelope: EventEnvelope,
27 task_id: TaskId,
28 from_phase: impl Into<String>,
29 to_phase: impl Into<String>,
30 ) -> Result<Self, DomainError> {
31 let from_phase_str: String = from_phase.into();
32 let to_phase_str: String = to_phase.into();
33 let from_phase = Self::validate(&from_phase_str, "phase_changed.from_phase")?;
34 let to_phase = Self::validate(&to_phase_str, "phase_changed.to_phase")?;
35 Ok(Self {
36 envelope,
37 task_id,
38 from_phase,
39 to_phase,
40 })
41 }
42
43 fn validate(raw: &str, field: &'static str) -> Result<String, DomainError> {
44 let trimmed = raw.trim();
45 if trimmed.is_empty() {
46 return Err(DomainError::EmptyField { field });
47 }
48 if trimmed.len() > MAX_PHASE_LEN {
49 return Err(DomainError::FieldTooLong {
50 field,
51 actual: trimmed.len(),
52 max: MAX_PHASE_LEN,
53 });
54 }
55 Ok(trimmed.to_owned())
56 }
57
58 #[must_use]
59 pub fn envelope(&self) -> &EventEnvelope {
60 &self.envelope
61 }
62 #[must_use]
63 pub fn task_id(&self) -> &TaskId {
64 &self.task_id
65 }
66 #[must_use]
67 pub fn from_phase(&self) -> &str {
68 &self.from_phase
69 }
70 #[must_use]
71 pub fn to_phase(&self) -> &str {
72 &self.to_phase
73 }
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79 use crate::value_objects::EventId;
80 use time::macros::datetime;
81
82 fn env() -> EventEnvelope {
83 EventEnvelope::new(
84 EventId::new("e").unwrap(),
85 datetime!(2026-04-15 12:00:00 UTC),
86 "s",
87 None,
88 )
89 .unwrap()
90 }
91
92 #[test]
93 fn empty_phase_is_rejected() {
94 assert!(matches!(
95 PhaseChangedEvent::new(env(), TaskId::new("t").unwrap(), " ", "next").unwrap_err(),
96 DomainError::EmptyField {
97 field: "phase_changed.from_phase"
98 }
99 ));
100 }
101
102 #[test]
103 fn arbitrary_phase_labels_are_accepted() {
104 for (from_phase, to_phase) in [
105 ("open", "triaged"),
106 ("intake", "classification"),
107 ("sourcing", "negotiation"),
108 ] {
109 PhaseChangedEvent::new(env(), TaskId::new("t").unwrap(), from_phase, to_phase).unwrap();
110 }
111 }
112
113 #[test]
114 fn overlong_phase_is_rejected() {
115 let err = PhaseChangedEvent::new(
116 env(),
117 TaskId::new("t").unwrap(),
118 "from",
119 "x".repeat(MAX_PHASE_LEN + 1),
120 )
121 .unwrap_err();
122 assert!(matches!(err, DomainError::FieldTooLong { .. }));
123 }
124}