1use serde::{Deserialize, Serialize};
2use std::{fmt, str::FromStr};
3
4#[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#[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 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#[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#[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#[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#[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#[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 "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}