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