Skip to main content

joy_core/model/
item.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4use 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    // Work capabilities
59    Conceive,
60    Plan,
61    Design,
62    Implement,
63    Test,
64    Review,
65    Document,
66    // Management capabilities
67    Create,
68    Assign,
69    Manage,
70    Delete,
71}
72
73impl Capability {
74    /// All capabilities in canonical order.
75    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    /// Whether this is a management capability (controls CLI permissions).
90    pub fn is_management(&self) -> bool {
91        matches!(
92            self,
93            Capability::Create | Capability::Assign | Capability::Manage | Capability::Delete
94        )
95    }
96
97    /// Whether this is a work capability (part of the development lifecycle).
98    pub fn is_work_capability(&self) -> bool {
99        !self.is_management()
100    }
101
102    /// All work capabilities in canonical order.
103    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    /// Whether this item is active (not closed or deferred).
181    pub fn is_active(&self) -> bool {
182        !matches!(self.status, Status::Closed | Status::Deferred)
183    }
184
185    /// Whether this item is blocked by any of the given open dependencies.
186    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
318/// Generate a slug from a title (lowercase, hyphens, max 40 chars).
319pub 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    // Collapse multiple hyphens and trim
326    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        // Cut at a char boundary near 40 bytes
342        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
357/// Build the filename for an item: {ID}-{slug}.yaml
358pub 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}