Skip to main content

changeset_parse/
serialize.rs

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