1use indexmap::IndexMap;
2use serde::Serialize;
3
4use changeset_core::{BumpType, ChangeCategory, Changeset};
5
6use crate::error::{FormatError, ValidationError};
7use crate::parse::FRONT_MATTER_DELIMITER;
8
9#[derive(Serialize)]
10struct FrontMatterOutput<'a> {
11 #[serde(skip_serializing_if = "is_default_category")]
12 category: ChangeCategory,
13 #[serde(
14 skip_serializing_if = "Option::is_none",
15 rename = "consumedForPrerelease"
16 )]
17 consumed_for_prerelease: Option<&'a str>,
18 #[serde(skip_serializing_if = "std::ops::Not::not")]
19 graduate: bool,
20 #[serde(flatten)]
21 releases: IndexMap<&'a str, BumpType>,
22}
23
24fn is_default_category(category: &ChangeCategory) -> bool {
25 *category == ChangeCategory::default()
26}
27
28#[must_use = "serialization result should be handled"]
29pub fn serialize_changeset(changeset: &Changeset) -> Result<String, FormatError> {
30 if changeset.releases.is_empty() {
31 return Err(ValidationError::NoReleases.into());
32 }
33
34 let releases_map: IndexMap<&str, BumpType> = changeset
35 .releases
36 .iter()
37 .map(|r| (r.name.as_str(), r.bump_type))
38 .collect();
39
40 let front_matter = FrontMatterOutput {
41 category: changeset.category,
42 consumed_for_prerelease: changeset.consumed_for_prerelease.as_deref(),
43 graduate: changeset.graduate,
44 releases: releases_map,
45 };
46
47 let yaml = serde_yml::to_string(&front_matter)?;
48
49 let mut output = String::new();
50 output.push_str(FRONT_MATTER_DELIMITER);
51 output.push('\n');
52 output.push_str(&yaml);
53 output.push_str(FRONT_MATTER_DELIMITER);
54 output.push('\n');
55
56 if !changeset.summary.is_empty() {
57 output.push_str(&changeset.summary);
58 output.push('\n');
59 }
60
61 Ok(output)
62}
63
64#[cfg(test)]
65mod tests {
66 use changeset_core::PackageRelease;
67
68 use super::*;
69 use crate::parse::parse_changeset;
70
71 #[test]
72 fn roundtrip() {
73 let original = Changeset {
74 summary: "Test summary".to_string(),
75 releases: vec![
76 PackageRelease {
77 name: "crate-a".to_string(),
78 bump_type: BumpType::Minor,
79 },
80 PackageRelease {
81 name: "crate-b".to_string(),
82 bump_type: BumpType::Patch,
83 },
84 ],
85 category: ChangeCategory::default(),
86 consumed_for_prerelease: None,
87 graduate: false,
88 };
89
90 let serialized = serialize_changeset(&original).expect("should serialize");
91 let parsed = parse_changeset(&serialized).expect("should parse");
92
93 assert_eq!(parsed.summary, original.summary);
94 assert_eq!(parsed.releases.len(), original.releases.len());
95 assert_eq!(parsed.category, original.category);
96 assert_eq!(
97 parsed.consumed_for_prerelease,
98 original.consumed_for_prerelease
99 );
100
101 for (original_release, parsed_release) in
102 original.releases.iter().zip(parsed.releases.iter())
103 {
104 assert_eq!(parsed_release.name, original_release.name);
105 assert_eq!(parsed_release.bump_type, original_release.bump_type);
106 }
107 }
108
109 #[test]
110 fn preserves_order() {
111 let original = Changeset {
112 summary: "Test".to_string(),
113 releases: vec![
114 PackageRelease {
115 name: "zebra".to_string(),
116 bump_type: BumpType::Major,
117 },
118 PackageRelease {
119 name: "apple".to_string(),
120 bump_type: BumpType::Minor,
121 },
122 PackageRelease {
123 name: "banana".to_string(),
124 bump_type: BumpType::Patch,
125 },
126 ],
127 category: ChangeCategory::default(),
128 consumed_for_prerelease: None,
129 graduate: false,
130 };
131
132 let serialized = serialize_changeset(&original).expect("should serialize");
133 let parsed = parse_changeset(&serialized).expect("should parse");
134
135 assert_eq!(parsed.releases[0].name, "zebra");
136 assert_eq!(parsed.releases[1].name, "apple");
137 assert_eq!(parsed.releases[2].name, "banana");
138 }
139
140 #[test]
141 fn error_empty_releases() {
142 let changeset = Changeset {
143 summary: "Some summary".to_string(),
144 releases: vec![],
145 category: ChangeCategory::default(),
146 consumed_for_prerelease: None,
147 graduate: false,
148 };
149
150 let err = serialize_changeset(&changeset).expect_err("should fail");
151 assert!(err.to_string().contains("at least one release"));
152 }
153
154 #[test]
155 fn roundtrip_with_category() {
156 let original = Changeset {
157 summary: "Fixed a bug".to_string(),
158 releases: vec![PackageRelease {
159 name: "my-crate".to_string(),
160 bump_type: BumpType::Patch,
161 }],
162 category: ChangeCategory::Fixed,
163 consumed_for_prerelease: None,
164 graduate: false,
165 };
166
167 let serialized = serialize_changeset(&original).expect("should serialize");
168 let parsed = parse_changeset(&serialized).expect("should parse");
169
170 assert_eq!(parsed.category, ChangeCategory::Fixed);
171 assert_eq!(parsed.summary, original.summary);
172 }
173
174 #[test]
175 fn default_category_not_serialized() {
176 let changeset = Changeset {
177 summary: "Some change".to_string(),
178 releases: vec![PackageRelease {
179 name: "my-crate".to_string(),
180 bump_type: BumpType::Minor,
181 }],
182 category: ChangeCategory::Changed,
183 consumed_for_prerelease: None,
184 graduate: false,
185 };
186
187 let serialized = serialize_changeset(&changeset).expect("should serialize");
188 assert!(
189 !serialized.contains("category:"),
190 "Default category should not be serialized"
191 );
192 }
193
194 #[test]
195 fn non_default_category_serialized() {
196 let changeset = Changeset {
197 summary: "Security fix".to_string(),
198 releases: vec![PackageRelease {
199 name: "my-crate".to_string(),
200 bump_type: BumpType::Patch,
201 }],
202 category: ChangeCategory::Security,
203 consumed_for_prerelease: None,
204 graduate: false,
205 };
206
207 let serialized = serialize_changeset(&changeset).expect("should serialize");
208 assert!(
209 serialized.contains("category: security"),
210 "Non-default category should be serialized"
211 );
212 }
213
214 #[test]
215 fn roundtrip_with_consumed_for_prerelease() {
216 let original = Changeset {
217 summary: "Pre-release fix".to_string(),
218 releases: vec![PackageRelease {
219 name: "my-crate".to_string(),
220 bump_type: BumpType::Patch,
221 }],
222 category: ChangeCategory::Fixed,
223 consumed_for_prerelease: Some("1.0.1-alpha.1".to_string()),
224 graduate: false,
225 };
226
227 let serialized = serialize_changeset(&original).expect("should serialize");
228 let parsed = parse_changeset(&serialized).expect("should parse");
229
230 assert_eq!(
231 parsed.consumed_for_prerelease,
232 Some("1.0.1-alpha.1".to_string())
233 );
234 assert_eq!(parsed.category, ChangeCategory::Fixed);
235 assert_eq!(parsed.summary, original.summary);
236 }
237
238 #[test]
239 fn consumed_for_prerelease_serialized_with_camel_case() {
240 let changeset = Changeset {
241 summary: "Some change".to_string(),
242 releases: vec![PackageRelease {
243 name: "my-crate".to_string(),
244 bump_type: BumpType::Minor,
245 }],
246 category: ChangeCategory::Changed,
247 consumed_for_prerelease: Some("2.0.0-beta.3".to_string()),
248 graduate: false,
249 };
250
251 let serialized = serialize_changeset(&changeset).expect("should serialize");
252 assert!(
253 serialized.contains("consumedForPrerelease:"),
254 "consumedForPrerelease should be serialized with camelCase, got: {serialized}"
255 );
256 assert!(
257 serialized.contains("2.0.0-beta.3"),
258 "version value should be present in serialized output, got: {serialized}"
259 );
260 }
261
262 #[test]
263 fn consumed_for_prerelease_none_not_serialized() {
264 let changeset = Changeset {
265 summary: "Some change".to_string(),
266 releases: vec![PackageRelease {
267 name: "my-crate".to_string(),
268 bump_type: BumpType::Minor,
269 }],
270 category: ChangeCategory::Changed,
271 consumed_for_prerelease: None,
272 graduate: false,
273 };
274
275 let serialized = serialize_changeset(&changeset).expect("should serialize");
276 assert!(
277 !serialized.contains("consumedForPrerelease"),
278 "None consumed_for_prerelease should not be serialized"
279 );
280 }
281
282 #[test]
283 fn graduate_false_not_serialized() {
284 let changeset = Changeset {
285 summary: "Some change".to_string(),
286 releases: vec![PackageRelease {
287 name: "my-crate".to_string(),
288 bump_type: BumpType::Minor,
289 }],
290 category: ChangeCategory::Changed,
291 consumed_for_prerelease: None,
292 graduate: false,
293 };
294
295 let serialized = serialize_changeset(&changeset).expect("should serialize");
296 assert!(
297 !serialized.contains("graduate"),
298 "graduate: false should not be serialized, got: {serialized}"
299 );
300 }
301
302 #[test]
303 fn graduate_true_serialized() {
304 let changeset = Changeset {
305 summary: "Graduating to 1.0".to_string(),
306 releases: vec![PackageRelease {
307 name: "my-crate".to_string(),
308 bump_type: BumpType::Major,
309 }],
310 category: ChangeCategory::Added,
311 consumed_for_prerelease: None,
312 graduate: true,
313 };
314
315 let serialized = serialize_changeset(&changeset).expect("should serialize");
316 assert!(
317 serialized.contains("graduate: true"),
318 "graduate: true should be serialized, got: {serialized}"
319 );
320 }
321
322 #[test]
323 fn roundtrip_with_graduate() {
324 let original = Changeset {
325 summary: "Graduating to 1.0".to_string(),
326 releases: vec![PackageRelease {
327 name: "my-crate".to_string(),
328 bump_type: BumpType::Major,
329 }],
330 category: ChangeCategory::Added,
331 consumed_for_prerelease: None,
332 graduate: true,
333 };
334
335 let serialized = serialize_changeset(&original).expect("should serialize");
336 let parsed = parse_changeset(&serialized).expect("should parse");
337
338 assert!(parsed.graduate);
339 assert_eq!(parsed.category, ChangeCategory::Added);
340 assert_eq!(parsed.summary, original.summary);
341 }
342}