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