Skip to main content

bones_core/model/
item.rs

1use serde::{Deserialize, Serialize};
2use std::{fmt, str::FromStr};
3
4/// The three kinds of work item.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "lowercase")]
7pub enum Kind {
8    Task,
9    Goal,
10    Bug,
11}
12
13impl Kind {
14    const fn as_str(self) -> &'static str {
15        match self {
16            Self::Task => "task",
17            Self::Goal => "goal",
18            Self::Bug => "bug",
19        }
20    }
21}
22
23/// The four lifecycle states.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum State {
27    Open,
28    Doing,
29    Done,
30    Archived,
31}
32
33impl State {
34    const fn as_str(self) -> &'static str {
35        match self {
36            Self::Open => "open",
37            Self::Doing => "doing",
38            Self::Done => "done",
39            Self::Archived => "archived",
40        }
41    }
42
43    /// Validate whether a transition from self to `target` is allowed.
44    ///
45    /// Valid transitions:
46    /// - `open -> doing`
47    /// - `open -> done`
48    /// - `doing -> done`
49    /// - `doing -> open` (reopen)
50    /// - `done -> archived`
51    /// - `done -> open` (reopen)
52    /// - `archived -> open` (reopen)
53    ///
54    /// # Errors
55    ///
56    /// Returns [`InvalidTransition`] if the transition is a no-op or not
57    /// allowed by the lifecycle rules.
58    pub fn can_transition_to(&self, target: Self) -> Result<(), InvalidTransition> {
59        if *self == target {
60            return Err(InvalidTransition {
61                from: *self,
62                to: target,
63                reason: "no-op transition is not allowed",
64            });
65        }
66
67        let allowed = matches!(
68            (*self, target),
69            (Self::Open, Self::Doing | Self::Done)
70                | (Self::Doing, Self::Done | Self::Open)
71                | (Self::Done, Self::Archived | Self::Open)
72                | (Self::Archived, Self::Open)
73        );
74
75        if allowed {
76            Ok(())
77        } else {
78            Err(InvalidTransition {
79                from: *self,
80                to: target,
81                reason: "transition not allowed by lifecycle rules",
82            })
83        }
84    }
85}
86
87/// Human override for computed priority.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
89#[serde(rename_all = "lowercase")]
90pub enum Urgency {
91    Urgent,
92    #[default]
93    Default,
94    Punt,
95}
96
97impl Urgency {
98    const fn as_str(self) -> &'static str {
99        match self {
100            Self::Urgent => "urgent",
101            Self::Default => "default",
102            Self::Punt => "punt",
103        }
104    }
105}
106
107/// Optional t-shirt sizing.
108///
109/// Legacy values `xxs` and `xxl` are accepted on deserialization and mapped
110/// to `Xs` and `Xl` respectively for backward compatibility with old events.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
112#[serde(rename_all = "lowercase")]
113pub enum Size {
114    Xs,
115    S,
116    M,
117    L,
118    Xl,
119}
120
121impl<'de> serde::Deserialize<'de> for Size {
122    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
123    where
124        D: serde::Deserializer<'de>,
125    {
126        let s = String::deserialize(deserializer)?;
127        match s.as_str() {
128            "xxs" | "xs" => Ok(Self::Xs),
129            "s" => Ok(Self::S),
130            "m" => Ok(Self::M),
131            "l" => Ok(Self::L),
132            "xxl" | "xl" => Ok(Self::Xl),
133            other => Err(serde::de::Error::unknown_variant(
134                other,
135                &["xs", "s", "m", "l", "xl"],
136            )),
137        }
138    }
139}
140
141impl Size {
142    const fn as_str(self) -> &'static str {
143        match self {
144            Self::Xs => "xs",
145            Self::S => "s",
146            Self::M => "m",
147            Self::L => "l",
148            Self::Xl => "xl",
149        }
150    }
151}
152
153/// All persisted fields for a work item (the projection-level aggregate).
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(default)]
156pub struct WorkItemFields {
157    pub id: String,
158    pub title: String,
159    pub description: Option<String>,
160    pub kind: Kind,
161    pub state: State,
162    pub urgency: Urgency,
163    pub size: Option<Size>,
164    pub parent_id: Option<String>,
165    pub assignees: Vec<String>,
166    pub labels: Vec<String>,
167    pub blocked_by: Vec<String>,
168    pub related_to: Vec<String>,
169    pub created_at: u64,
170    pub updated_at: u64,
171}
172
173impl Default for WorkItemFields {
174    fn default() -> Self {
175        Self {
176            id: String::new(),
177            title: String::new(),
178            description: None,
179            kind: Kind::Task,
180            state: State::Open,
181            urgency: Urgency::Default,
182            size: None,
183            parent_id: None,
184            assignees: Vec::new(),
185            labels: Vec::new(),
186            blocked_by: Vec::new(),
187            related_to: Vec::new(),
188            created_at: 0,
189            updated_at: 0,
190        }
191    }
192}
193
194/// Error returned when a state transition is invalid.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub struct InvalidTransition {
197    pub from: State,
198    pub to: State,
199    pub reason: &'static str,
200}
201
202/// Error returned when parsing an enum value from text.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct ParseEnumError {
205    pub expected: &'static str,
206    pub got: String,
207}
208
209impl fmt::Display for ParseEnumError {
210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211        write!(f, "invalid {}: '{}'", self.expected, self.got)
212    }
213}
214
215impl std::error::Error for ParseEnumError {}
216
217impl fmt::Display for Kind {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        f.write_str(self.as_str())
220    }
221}
222
223impl fmt::Display for State {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        f.write_str(self.as_str())
226    }
227}
228
229impl fmt::Display for Urgency {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        f.write_str(self.as_str())
232    }
233}
234
235impl fmt::Display for Size {
236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237        f.write_str(self.as_str())
238    }
239}
240
241fn normalize(input: &str) -> String {
242    input.trim().to_ascii_lowercase()
243}
244
245impl FromStr for Kind {
246    type Err = ParseEnumError;
247
248    fn from_str(s: &str) -> Result<Self, Self::Err> {
249        let normalized = normalize(s);
250        match normalized.as_str() {
251            "task" => Ok(Self::Task),
252            "goal" => Ok(Self::Goal),
253            "bug" => Ok(Self::Bug),
254            _ => Err(ParseEnumError {
255                expected: "kind",
256                got: s.to_string(),
257            }),
258        }
259    }
260}
261
262impl FromStr for State {
263    type Err = ParseEnumError;
264
265    fn from_str(s: &str) -> Result<Self, Self::Err> {
266        let normalized = normalize(s);
267        match normalized.as_str() {
268            "open" => Ok(Self::Open),
269            "doing" => Ok(Self::Doing),
270            "done" => Ok(Self::Done),
271            "archived" => Ok(Self::Archived),
272            _ => Err(ParseEnumError {
273                expected: "state",
274                got: s.to_string(),
275            }),
276        }
277    }
278}
279
280impl FromStr for Urgency {
281    type Err = ParseEnumError;
282
283    fn from_str(s: &str) -> Result<Self, Self::Err> {
284        let normalized = normalize(s);
285        match normalized.as_str() {
286            "urgent" => Ok(Self::Urgent),
287            "default" => Ok(Self::Default),
288            "punt" => Ok(Self::Punt),
289            _ => Err(ParseEnumError {
290                expected: "urgency",
291                got: s.to_string(),
292            }),
293        }
294    }
295}
296
297impl FromStr for Size {
298    type Err = ParseEnumError;
299
300    fn from_str(s: &str) -> Result<Self, Self::Err> {
301        let normalized = normalize(s);
302        match normalized.as_str() {
303            // Legacy compat: map xxs->xs, xxl->xl
304            "xxs" | "xs" => Ok(Self::Xs),
305            "s" => Ok(Self::S),
306            "m" => Ok(Self::M),
307            "l" => Ok(Self::L),
308            "xxl" | "xl" => Ok(Self::Xl),
309            _ => Err(ParseEnumError {
310                expected: "size",
311                got: s.to_string(),
312            }),
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::{InvalidTransition, Kind, Size, State, Urgency, WorkItemFields};
320    use std::str::FromStr;
321
322    #[test]
323    fn enum_json_roundtrips() {
324        assert_eq!(serde_json::to_string(&Kind::Task).unwrap(), "\"task\"");
325        assert_eq!(serde_json::to_string(&State::Doing).unwrap(), "\"doing\"");
326        assert_eq!(
327            serde_json::to_string(&Urgency::Default).unwrap(),
328            "\"default\""
329        );
330        assert_eq!(serde_json::to_string(&Size::Xl).unwrap(), "\"xl\"");
331
332        assert_eq!(serde_json::from_str::<Kind>("\"bug\"").unwrap(), Kind::Bug);
333        assert_eq!(
334            serde_json::from_str::<State>("\"open\"").unwrap(),
335            State::Open
336        );
337        assert_eq!(
338            serde_json::from_str::<Urgency>("\"urgent\"").unwrap(),
339            Urgency::Urgent
340        );
341        assert_eq!(serde_json::from_str::<Size>("\"xs\"").unwrap(), Size::Xs);
342    }
343
344    #[test]
345    fn display_parse_roundtrips() {
346        for value in [Kind::Task, Kind::Goal, Kind::Bug] {
347            let rendered = value.to_string();
348            let reparsed = Kind::from_str(&rendered).unwrap();
349            assert_eq!(value, reparsed);
350        }
351
352        for value in [State::Open, State::Doing, State::Done, State::Archived] {
353            let rendered = value.to_string();
354            let reparsed = State::from_str(&rendered).unwrap();
355            assert_eq!(value, reparsed);
356        }
357
358        for value in [Urgency::Urgent, Urgency::Default, Urgency::Punt] {
359            let rendered = value.to_string();
360            let reparsed = Urgency::from_str(&rendered).unwrap();
361            assert_eq!(value, reparsed);
362        }
363
364        for value in [Size::Xs, Size::S, Size::M, Size::L, Size::Xl] {
365            let rendered = value.to_string();
366            let reparsed = Size::from_str(&rendered).unwrap();
367            assert_eq!(value, reparsed);
368        }
369    }
370
371    #[test]
372    fn parse_rejects_unknown_values() {
373        assert!(Kind::from_str("epic").is_err());
374        assert!(State::from_str("active").is_err());
375        assert!(Urgency::from_str("hot").is_err());
376        assert!(Size::from_str("mega").is_err());
377    }
378
379    #[test]
380    fn state_transition_rules() {
381        assert!(State::Open.can_transition_to(State::Doing).is_ok());
382        assert!(State::Open.can_transition_to(State::Done).is_ok());
383        assert!(State::Doing.can_transition_to(State::Done).is_ok());
384        assert!(State::Doing.can_transition_to(State::Open).is_ok());
385        assert!(State::Done.can_transition_to(State::Archived).is_ok());
386        assert!(State::Done.can_transition_to(State::Open).is_ok());
387        assert!(State::Archived.can_transition_to(State::Open).is_ok());
388
389        assert!(matches!(
390            State::Open.can_transition_to(State::Archived),
391            Err(InvalidTransition {
392                from: State::Open,
393                to: State::Archived,
394                ..
395            })
396        ));
397
398        assert!(matches!(
399            State::Done.can_transition_to(State::Doing),
400            Err(InvalidTransition {
401                from: State::Done,
402                to: State::Doing,
403                ..
404            })
405        ));
406    }
407
408    #[test]
409    fn work_item_fields_default_is_stable() {
410        let fields = WorkItemFields::default();
411        assert_eq!(fields.id, "");
412        assert_eq!(fields.title, "");
413        assert_eq!(fields.kind, Kind::Task);
414        assert_eq!(fields.state, State::Open);
415        assert_eq!(fields.urgency, Urgency::Default);
416        assert!(fields.description.is_none());
417        assert!(fields.size.is_none());
418        assert!(fields.parent_id.is_none());
419        assert!(fields.assignees.is_empty());
420        assert!(fields.labels.is_empty());
421        assert!(fields.blocked_by.is_empty());
422        assert!(fields.related_to.is_empty());
423        assert_eq!(fields.created_at, 0);
424        assert_eq!(fields.updated_at, 0);
425    }
426}