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    /// Identity of the last writer of any kind. Stays in sync with `updated`
38    /// and serves as a recency hint for sort/UI. `history` carries the
39    /// full attribute-change list; this field is the legacy summary.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub updated_by: Option<String>,
42    /// Append-only audit list of attribute-level mutations (status, priority,
43    /// edit, deps, assignee, milestone, ...). Comment add / edit / rm do NOT
44    /// append here. `None` for legacy YAML written before this field existed
45    /// (display falls back to `updated` / `updated_by`); `Some(vec![])` for
46    /// items created after the field shipped but with no attribute mutations
47    /// yet. On first attribute mutation the vec gains its first entry.
48    #[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    /// Name of the Crypt zone this item belongs to. Absent or null
53    /// means the item is plaintext. The zone must be declared in the
54    /// project's `crypt.zones` registry. See ADR-038 and Crypt.md.
55    #[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    // Work capabilities
77    Conceive,
78    Plan,
79    Design,
80    Implement,
81    Test,
82    Review,
83    Document,
84    // Management capabilities
85    Create,
86    Assign,
87    Manage,
88    Delete,
89}
90
91impl Capability {
92    /// All capabilities in canonical order.
93    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    /// Whether this is a management capability (controls CLI permissions).
108    pub fn is_management(&self) -> bool {
109        matches!(
110            self,
111            Capability::Create | Capability::Assign | Capability::Manage | Capability::Delete
112        )
113    }
114
115    /// Whether this is a work capability (part of the development lifecycle).
116    pub fn is_work_capability(&self) -> bool {
117        !self.is_management()
118    }
119
120    /// All work capabilities in canonical order.
121    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/// One entry in an item's `history` or a comment's `edits` audit list.
160/// Records who touched the artifact and when.
161#[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    /// Original author. Immutable after creation; comment edits do not
170    /// overwrite this field. Editors are recorded in `edits`.
171    pub author: String,
172    /// Original creation timestamp. Immutable after creation.
173    pub date: DateTime<Utc>,
174    pub text: String,
175    /// Per-comment edit audit list. Each entry records one `joy comment
176    /// edit` invocation: timestamp and editor identity. Append-only.
177    #[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    /// Whether this item is active (not closed or deferred).
217    pub fn is_active(&self) -> bool {
218        !matches!(self.status, Status::Closed | Status::Deferred)
219    }
220
221    /// Whether this item is blocked by any of the given open dependencies.
222    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
354/// Generate a slug from a title (lowercase, hyphens, max 40 chars).
355pub 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    // Collapse multiple hyphens and trim
362    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        // Cut at a char boundary near 40 bytes
378        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
393/// Build the filename for an item: {ID}-{slug}.yaml
394pub 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}