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}