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    /// Name of the Crypt zone this item belongs to. Absent or null
40    /// means the item is plaintext. The zone must be declared in the
41    /// project's `crypt.zones` registry. See ADR-038 and Crypt.md.
42    #[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    // Work capabilities
64    Conceive,
65    Plan,
66    Design,
67    Implement,
68    Test,
69    Review,
70    Document,
71    // Management capabilities
72    Create,
73    Assign,
74    Manage,
75    Delete,
76}
77
78impl Capability {
79    /// All capabilities in canonical order.
80    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    /// Whether this is a management capability (controls CLI permissions).
95    pub fn is_management(&self) -> bool {
96        matches!(
97            self,
98            Capability::Create | Capability::Assign | Capability::Manage | Capability::Delete
99        )
100    }
101
102    /// Whether this is a work capability (part of the development lifecycle).
103    pub fn is_work_capability(&self) -> bool {
104        !self.is_management()
105    }
106
107    /// All work capabilities in canonical order.
108    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    /// Whether this item is active (not closed or deferred).
187    pub fn is_active(&self) -> bool {
188        !matches!(self.status, Status::Closed | Status::Deferred)
189    }
190
191    /// Whether this item is blocked by any of the given open dependencies.
192    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
324/// Generate a slug from a title (lowercase, hyphens, max 40 chars).
325pub 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    // Collapse multiple hyphens and trim
332    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        // Cut at a char boundary near 40 bytes
348        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
363/// Build the filename for an item: {ID}-{slug}.yaml
364pub 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}