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