Skip to main content

joy_core/model/
release.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4use chrono::NaiveDate;
5use serde::{Deserialize, Serialize};
6
7/// A contributor to a release with item count.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9pub struct Contributor {
10    pub id: String,
11    pub events: usize,
12    pub items: usize,
13}
14
15/// A released item reference (ID + title snapshot).
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct ReleaseItem {
18    pub id: String,
19    pub title: String,
20}
21
22/// Items grouped by type within a release.
23#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
24pub struct ReleaseItems {
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub epics: Vec<ReleaseItem>,
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub stories: Vec<ReleaseItem>,
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub tasks: Vec<ReleaseItem>,
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub bugs: Vec<ReleaseItem>,
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub reworks: Vec<ReleaseItem>,
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub decisions: Vec<ReleaseItem>,
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub ideas: Vec<ReleaseItem>,
39}
40
41impl ReleaseItems {
42    pub fn is_empty(&self) -> bool {
43        self.epics.is_empty()
44            && self.stories.is_empty()
45            && self.tasks.is_empty()
46            && self.bugs.is_empty()
47            && self.reworks.is_empty()
48            && self.decisions.is_empty()
49            && self.ideas.is_empty()
50    }
51
52    pub fn total(&self) -> usize {
53        self.epics.len()
54            + self.stories.len()
55            + self.tasks.len()
56            + self.bugs.len()
57            + self.reworks.len()
58            + self.decisions.len()
59            + self.ideas.len()
60    }
61}
62
63/// A release snapshot.
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct Release {
66    pub version: String,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub title: Option<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub description: Option<String>,
71    pub date: NaiveDate,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub previous: Option<String>,
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub contributors: Vec<Contributor>,
76    pub items: ReleaseItems,
77}
78
79/// Compute the next semver version from a current version string.
80pub fn bump_version(current: &str, bump: Bump) -> String {
81    let v = current.strip_prefix('v').unwrap_or(current);
82    let parts: Vec<&str> = v.splitn(3, '.').collect();
83    let major: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
84    let minor: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
85    let patch: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
86
87    match bump {
88        Bump::Major => format!("v{}.0.0", major + 1),
89        Bump::Minor => format!("v{}.{}.0", major, minor + 1),
90        Bump::Patch => format!("v{}.{}.{}", major, minor, patch + 1),
91    }
92}
93
94#[derive(Debug, Clone, Copy)]
95pub enum Bump {
96    Major,
97    Minor,
98    Patch,
99}
100
101impl std::str::FromStr for Bump {
102    type Err = String;
103    fn from_str(s: &str) -> Result<Self, String> {
104        match s.to_lowercase().as_str() {
105            "major" => Ok(Self::Major),
106            "minor" => Ok(Self::Minor),
107            "patch" => Ok(Self::Patch),
108            _ => Err(format!("invalid bump: {s} (use major, minor, or patch)")),
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn bump_patch() {
119        assert_eq!(bump_version("v0.3.1", Bump::Patch), "v0.3.2");
120    }
121
122    #[test]
123    fn bump_minor() {
124        assert_eq!(bump_version("v0.3.1", Bump::Minor), "v0.4.0");
125    }
126
127    #[test]
128    fn bump_major() {
129        assert_eq!(bump_version("v0.3.1", Bump::Major), "v1.0.0");
130    }
131
132    #[test]
133    fn bump_without_prefix() {
134        assert_eq!(bump_version("1.2.3", Bump::Patch), "v1.2.4");
135    }
136
137    #[test]
138    fn bump_from_zero() {
139        assert_eq!(bump_version("v0.0.0", Bump::Patch), "v0.0.1");
140        assert_eq!(bump_version("v0.0.0", Bump::Minor), "v0.1.0");
141        assert_eq!(bump_version("v0.0.0", Bump::Major), "v1.0.0");
142    }
143
144    #[test]
145    fn release_items_total() {
146        let items = ReleaseItems {
147            bugs: vec![ReleaseItem {
148                id: "X-0001".into(),
149                title: "fix".into(),
150            }],
151            stories: vec![ReleaseItem {
152                id: "X-0002".into(),
153                title: "feat".into(),
154            }],
155            ..Default::default()
156        };
157        assert_eq!(items.total(), 2);
158        assert!(!items.is_empty());
159    }
160
161    #[test]
162    fn release_roundtrip() {
163        let release = Release {
164            version: "v0.4.0".into(),
165            title: Some("Test release".into()),
166            description: None,
167            date: NaiveDate::from_ymd_opt(2026, 3, 22).unwrap(),
168            previous: Some("v0.3.1".into()),
169            contributors: vec![Contributor {
170                id: "human:test@x.com".into(),
171                events: 12,
172                items: 3,
173            }],
174            items: ReleaseItems {
175                bugs: vec![ReleaseItem {
176                    id: "X-0001".into(),
177                    title: "fix".into(),
178                }],
179                ..Default::default()
180            },
181        };
182        let yaml = serde_yaml_ng::to_string(&release).unwrap();
183        let parsed: Release = serde_yaml_ng::from_str(&yaml).unwrap();
184        assert_eq!(release, parsed);
185    }
186}