1use chrono::NaiveDate;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9pub struct Contributor {
10 pub id: String,
11 pub events: usize,
12 pub items: usize,
13}
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct ReleaseItem {
18 pub id: String,
19 pub title: String,
20}
21
22#[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#[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
79pub 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}