1use chrono::NaiveDate;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9pub struct Contributor {
10 pub id: crate::member_ref::MemberRef,
14 pub events: usize,
15 pub items: usize,
16}
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct ReleaseItem {
21 pub id: String,
22 pub title: String,
23}
24
25#[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#[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
82pub 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}