1use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8pub struct Item {
9 pub id: String,
10 pub title: String,
11 #[serde(rename = "type")]
12 pub item_type: ItemType,
13 pub status: Status,
14 pub priority: Priority,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub parent: Option<String>,
17 #[serde(default, skip_serializing_if = "Vec::is_empty")]
18 pub assignees: Vec<Assignee>,
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
20 pub deps: Vec<String>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub milestone: Option<String>,
23 #[serde(default, skip_serializing_if = "Vec::is_empty")]
24 pub tags: Vec<String>,
25 #[serde(default, skip_serializing_if = "Vec::is_empty")]
26 pub capabilities: Vec<Capability>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub mode: Option<super::config::InteractionLevel>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub effort: Option<u8>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub version: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub created_by: Option<String>,
35 pub created: DateTime<Utc>,
36 pub updated: DateTime<Utc>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub description: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub crypt_zone: Option<String>,
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 pub comments: Vec<Comment>,
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49#[serde(rename_all = "lowercase")]
50pub enum ItemType {
51 Epic,
52 Story,
53 Task,
54 Bug,
55 Rework,
56 Decision,
57 Idea,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum Capability {
63 Conceive,
65 Plan,
66 Design,
67 Implement,
68 Test,
69 Review,
70 Document,
71 Create,
73 Assign,
74 Manage,
75 Delete,
76}
77
78impl Capability {
79 pub const ALL: &[Capability] = &[
81 Capability::Conceive,
82 Capability::Plan,
83 Capability::Design,
84 Capability::Implement,
85 Capability::Test,
86 Capability::Review,
87 Capability::Document,
88 Capability::Create,
89 Capability::Assign,
90 Capability::Manage,
91 Capability::Delete,
92 ];
93
94 pub fn is_management(&self) -> bool {
96 matches!(
97 self,
98 Capability::Create | Capability::Assign | Capability::Manage | Capability::Delete
99 )
100 }
101
102 pub fn is_work_capability(&self) -> bool {
104 !self.is_management()
105 }
106
107 pub fn work_capabilities() -> Vec<Capability> {
109 Self::ALL
110 .iter()
111 .filter(|c| c.is_work_capability())
112 .copied()
113 .collect()
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118#[serde(rename_all = "lowercase")]
119pub enum Status {
120 New,
121 Open,
122 #[serde(rename = "in-progress")]
123 InProgress,
124 Review,
125 Closed,
126 Deferred,
127}
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130#[serde(rename_all = "lowercase")]
131pub enum Priority {
132 Low,
133 Medium,
134 High,
135 Critical,
136 Extreme,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140pub struct Assignee {
141 pub member: String,
142 #[serde(rename = "as", default, skip_serializing_if = "Vec::is_empty")]
143 pub capabilities: Vec<Capability>,
144}
145
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub struct Comment {
148 pub author: String,
149 pub date: DateTime<Utc>,
150 pub text: String,
151}
152
153impl Item {
154 pub fn new(
155 id: String,
156 title: String,
157 item_type: ItemType,
158 priority: Priority,
159 capabilities: Vec<Capability>,
160 ) -> Self {
161 let now = Utc::now();
162 Self {
163 id,
164 title,
165 item_type,
166 status: Status::New,
167 priority,
168 parent: None,
169 assignees: Vec::new(),
170 deps: Vec::new(),
171 milestone: None,
172 tags: Vec::new(),
173 capabilities,
174 mode: None,
175 effort: None,
176 version: None,
177 created_by: None,
178 created: now,
179 updated: now,
180 description: None,
181 crypt_zone: None,
182 comments: Vec::new(),
183 }
184 }
185
186 pub fn is_active(&self) -> bool {
188 !matches!(self.status, Status::Closed | Status::Deferred)
189 }
190
191 pub fn is_blocked_by(&self, items: &[Item]) -> bool {
193 if self.deps.is_empty() {
194 return false;
195 }
196 items
197 .iter()
198 .any(|dep| self.deps.contains(&dep.id) && dep.is_active())
199 }
200}
201
202impl std::fmt::Display for Capability {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 match self {
205 Capability::Conceive => write!(f, "conceive"),
206 Capability::Plan => write!(f, "plan"),
207 Capability::Design => write!(f, "design"),
208 Capability::Implement => write!(f, "implement"),
209 Capability::Test => write!(f, "test"),
210 Capability::Review => write!(f, "review"),
211 Capability::Document => write!(f, "document"),
212 Capability::Create => write!(f, "create"),
213 Capability::Assign => write!(f, "assign"),
214 Capability::Manage => write!(f, "manage"),
215 Capability::Delete => write!(f, "delete"),
216 }
217 }
218}
219
220impl std::str::FromStr for Capability {
221 type Err = String;
222 fn from_str(s: &str) -> Result<Self, Self::Err> {
223 match s.to_lowercase().as_str() {
224 "conceive" | "con" => Ok(Capability::Conceive),
225 "plan" | "pln" => Ok(Capability::Plan),
226 "design" | "des" => Ok(Capability::Design),
227 "implement" | "imp" => Ok(Capability::Implement),
228 "test" | "tst" => Ok(Capability::Test),
229 "review" | "rev" => Ok(Capability::Review),
230 "document" | "doc" => Ok(Capability::Document),
231 "create" | "crt" => Ok(Capability::Create),
232 "assign" | "asg" => Ok(Capability::Assign),
233 "manage" | "mng" => Ok(Capability::Manage),
234 "delete" | "del" => Ok(Capability::Delete),
235 _ => Err(format!("unknown capability: {s}")),
236 }
237 }
238}
239
240impl std::fmt::Display for ItemType {
241 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242 match self {
243 ItemType::Epic => write!(f, "epic"),
244 ItemType::Story => write!(f, "story"),
245 ItemType::Task => write!(f, "task"),
246 ItemType::Bug => write!(f, "bug"),
247 ItemType::Rework => write!(f, "rework"),
248 ItemType::Decision => write!(f, "decision"),
249 ItemType::Idea => write!(f, "idea"),
250 }
251 }
252}
253
254impl std::fmt::Display for Status {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 match self {
257 Status::New => write!(f, "new"),
258 Status::Open => write!(f, "open"),
259 Status::InProgress => write!(f, "in-progress"),
260 Status::Review => write!(f, "review"),
261 Status::Closed => write!(f, "closed"),
262 Status::Deferred => write!(f, "deferred"),
263 }
264 }
265}
266
267impl std::fmt::Display for Priority {
268 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269 match self {
270 Priority::Low => write!(f, "low"),
271 Priority::Medium => write!(f, "medium"),
272 Priority::High => write!(f, "high"),
273 Priority::Critical => write!(f, "critical"),
274 Priority::Extreme => write!(f, "extreme"),
275 }
276 }
277}
278
279impl std::str::FromStr for ItemType {
280 type Err = String;
281 fn from_str(s: &str) -> Result<Self, Self::Err> {
282 match s.to_lowercase().as_str() {
283 "epic" | "epc" => Ok(ItemType::Epic),
284 "story" | "str" => Ok(ItemType::Story),
285 "task" | "tsk" => Ok(ItemType::Task),
286 "bug" => Ok(ItemType::Bug),
287 "rework" | "rwk" => Ok(ItemType::Rework),
288 "decision" | "dec" => Ok(ItemType::Decision),
289 "idea" | "ide" => Ok(ItemType::Idea),
290 _ => Err(format!("unknown item type: {s}")),
291 }
292 }
293}
294
295impl std::str::FromStr for Status {
296 type Err = String;
297 fn from_str(s: &str) -> Result<Self, Self::Err> {
298 match s.to_lowercase().as_str() {
299 "new" => Ok(Status::New),
300 "open" | "opn" => Ok(Status::Open),
301 "in-progress" | "wip" => Ok(Status::InProgress),
302 "review" | "rev" => Ok(Status::Review),
303 "closed" | "don" => Ok(Status::Closed),
304 "deferred" | "def" => Ok(Status::Deferred),
305 _ => Err(format!("unknown status: {s}")),
306 }
307 }
308}
309
310impl std::str::FromStr for Priority {
311 type Err = String;
312 fn from_str(s: &str) -> Result<Self, Self::Err> {
313 match s.to_lowercase().as_str() {
314 "low" => Ok(Priority::Low),
315 "medium" | "med" => Ok(Priority::Medium),
316 "high" | "hig" => Ok(Priority::High),
317 "critical" | "crt" => Ok(Priority::Critical),
318 "extreme" | "ext" => Ok(Priority::Extreme),
319 _ => Err(format!("unknown priority: {s}")),
320 }
321 }
322}
323
324pub fn slugify(title: &str) -> String {
326 let slug: String = title
327 .to_lowercase()
328 .chars()
329 .map(|c| if c.is_alphanumeric() { c } else { '-' })
330 .collect();
331 let mut result = String::new();
333 let mut prev_hyphen = false;
334 for c in slug.chars() {
335 if c == '-' {
336 if !prev_hyphen && !result.is_empty() {
337 result.push('-');
338 }
339 prev_hyphen = true;
340 } else {
341 result.push(c);
342 prev_hyphen = false;
343 }
344 }
345 let trimmed = result.trim_end_matches('-');
346 if trimmed.len() > 40 {
347 let mut end = 40;
349 while end > 0 && !trimmed.is_char_boundary(end) {
350 end -= 1;
351 }
352 let cut = &trimmed[..end];
353 let cut = cut.trim_end_matches('-');
354 match cut.rfind('-') {
355 Some(pos) if pos > 10 => cut[..pos].to_string(),
356 _ => cut.to_string(),
357 }
358 } else {
359 trimmed.to_string()
360 }
361}
362
363pub fn item_filename(id: &str, title: &str) -> String {
365 format!("{}-{}.yaml", id, slugify(title))
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn item_roundtrip() {
374 let mut item = Item::new(
375 "IT-0001".into(),
376 "Login page".into(),
377 ItemType::Story,
378 Priority::High,
379 vec![Capability::Plan, Capability::Implement, Capability::Review],
380 );
381 item.parent = Some("EP-0001".into());
382 item.description = Some("Implement the login page.".into());
383 item.tags = vec!["frontend".into()];
384
385 let yaml = serde_yaml_ng::to_string(&item).unwrap();
386 let parsed: Item = serde_yaml_ng::from_str(&yaml).unwrap();
387 assert_eq!(item, parsed);
388 }
389
390 #[test]
391 fn item_snapshot() {
392 use chrono::TimeZone;
393 let fixed = Utc.with_ymd_and_hms(2026, 3, 9, 10, 0, 0).unwrap();
394 let mut item = Item::new(
395 "IT-002A".into(),
396 "Payment Integration".into(),
397 ItemType::Story,
398 Priority::High,
399 vec![Capability::Plan, Capability::Implement, Capability::Review],
400 );
401 item.created = fixed;
402 item.updated = fixed;
403 item.parent = Some("EP-0001".into());
404 item.milestone = Some("MS-01".into());
405 item.deps = vec!["IT-0017".into(), "IT-0026".into()];
406 item.tags = vec!["backend".into(), "payments".into()];
407 item.description =
408 Some("Integrate Stripe for payment processing.\nMust support EUR and USD.\n".into());
409
410 let yaml = serde_yaml_ng::to_string(&item).unwrap();
411 insta::assert_snapshot!(yaml);
412 }
413
414 #[test]
415 fn slugify_basic() {
416 assert_eq!(slugify("Payment Integration"), "payment-integration");
417 }
418
419 #[test]
420 fn slugify_special_chars() {
421 assert_eq!(slugify("Fix: crash on Ümlauts!"), "fix-crash-on-ümlauts");
422 }
423
424 #[test]
425 fn slugify_long_title() {
426 let title = "This is a very long title that should be truncated at a reasonable length";
427 let slug = slugify(title);
428 assert!(slug.len() <= 40);
429 }
430
431 #[test]
432 fn item_filename_basic() {
433 assert_eq!(
434 item_filename("IT-0001", "Login page"),
435 "IT-0001-login-page.yaml"
436 );
437 }
438
439 #[test]
440 fn is_active_checks() {
441 let mut item = Item::new(
442 "IT-0001".into(),
443 "Test".into(),
444 ItemType::Task,
445 Priority::Low,
446 vec![Capability::Implement],
447 );
448 assert!(item.is_active());
449 item.status = Status::Closed;
450 assert!(!item.is_active());
451 item.status = Status::Deferred;
452 assert!(!item.is_active());
453 item.status = Status::InProgress;
454 assert!(item.is_active());
455 }
456
457 #[test]
458 fn parse_item_type() {
459 assert_eq!("story".parse::<ItemType>().unwrap(), ItemType::Story);
460 assert_eq!("Epic".parse::<ItemType>().unwrap(), ItemType::Epic);
461 assert!("unknown".parse::<ItemType>().is_err());
462 }
463
464 #[test]
465 fn parse_priority() {
466 assert_eq!("critical".parse::<Priority>().unwrap(), Priority::Critical);
467 assert!("invalid".parse::<Priority>().is_err());
468 }
469
470 #[test]
471 fn parse_status() {
472 assert_eq!("in-progress".parse::<Status>().unwrap(), Status::InProgress);
473 assert!("invalid".parse::<Status>().is_err());
474 }
475}