Skip to main content

made_core/events/
phase_changed.rs

1//! [`PhaseChangedEvent`] — a task moved between domain phases.
2//!
3//! The phase labels are free-form strings because different domains
4//! carry different lifecycles. MADE does not enumerate
5//! them.
6
7use 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}