Skip to main content

changeset_parse/
serialize.rs

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_none_bump_type() {
324        let original = Changeset {
325            summary: "Internal refactoring".to_string(),
326            releases: vec![PackageRelease {
327                name: "my-crate".to_string(),
328                bump_type: BumpType::None,
329            }],
330            category: ChangeCategory::default(),
331            consumed_for_prerelease: None,
332            graduate: false,
333        };
334
335        let serialized = serialize_changeset(&original).expect("should serialize");
336        let parsed = parse_changeset(&serialized).expect("should parse");
337
338        assert_eq!(parsed.releases[0].bump_type, BumpType::None);
339        assert_eq!(parsed.summary, original.summary);
340    }
341
342    #[test]
343    fn roundtrip_with_graduate() {
344        let original = Changeset {
345            summary: "Graduating to 1.0".to_string(),
346            releases: vec![PackageRelease {
347                name: "my-crate".to_string(),
348                bump_type: BumpType::Major,
349            }],
350            category: ChangeCategory::Added,
351            consumed_for_prerelease: None,
352            graduate: true,
353        };
354
355        let serialized = serialize_changeset(&original).expect("should serialize");
356        let parsed = parse_changeset(&serialized).expect("should parse");
357
358        assert!(parsed.graduate);
359        assert_eq!(parsed.category, ChangeCategory::Added);
360        assert_eq!(parsed.summary, original.summary);
361    }
362}