1use indexmap::IndexMap;
2use serde::Deserialize;
3use serde_with::{MapPreventDuplicates, serde_as};
4
5use crate::error::{FormatError, FrontMatterError, ValidationError};
6use changeset_core::{BumpType, ChangeCategory, Changeset, PackageRelease};
7
8pub(crate) const FRONT_MATTER_DELIMITER: &str = "---";
9
10const MAX_INPUT_SIZE: usize = 100 * 1024 * 1024;
11
12#[serde_as]
13#[derive(Deserialize)]
14struct FrontMatter {
15 #[serde(default)]
16 category: ChangeCategory,
17 #[serde(default, rename = "consumedForPrerelease")]
18 consumed_for_prerelease: Option<String>,
19 #[serde(default)]
20 graduate: bool,
21 #[serde(flatten)]
22 #[serde_as(as = "MapPreventDuplicates<_, _>")]
23 releases: IndexMap<String, BumpType>,
24}
25
26#[must_use = "parsing result should be handled"]
27pub fn parse_changeset(content: &str) -> Result<Changeset, FormatError> {
28 if content.len() > MAX_INPUT_SIZE {
29 return Err(ValidationError::InputTooLarge {
30 max_bytes: MAX_INPUT_SIZE,
31 }
32 .into());
33 }
34
35 let (yaml_content, body) = extract_front_matter(content)?;
36
37 let parsed: FrontMatter = serde_yml::from_str(yaml_content)?;
38
39 if parsed.releases.is_empty() {
40 return Err(ValidationError::NoReleases.into());
41 }
42
43 let releases = parsed
44 .releases
45 .into_iter()
46 .map(|(name, bump_type)| PackageRelease::new(name, bump_type))
47 .collect();
48
49 Ok(
50 Changeset::new(body.trim().to_string(), releases, parsed.category)
51 .with_consumed_for_prerelease(parsed.consumed_for_prerelease)
52 .with_graduate(parsed.graduate),
53 )
54}
55
56fn extract_front_matter(content: &str) -> Result<(&str, &str), FormatError> {
57 let trimmed = content.trim_start();
58
59 if !trimmed.starts_with(FRONT_MATTER_DELIMITER) {
60 return Err(FrontMatterError::MissingOpeningDelimiter.into());
61 }
62
63 let after_opening = &trimmed[FRONT_MATTER_DELIMITER.len()..];
64 let after_opening = strip_line_ending(after_opening);
65
66 let Some(closing_pos) = find_closing_delimiter(after_opening) else {
67 return Err(FrontMatterError::MissingClosingDelimiter.into());
68 };
69
70 let yaml_content = &after_opening[..closing_pos];
71 let yaml_content = yaml_content.trim_end_matches('\r');
72 if yaml_content.trim().is_empty() {
73 return Err(FrontMatterError::EmptyFrontMatter.into());
74 }
75
76 let after_closing = &after_opening[closing_pos + FRONT_MATTER_DELIMITER.len()..];
77 let body = strip_line_ending(after_closing);
78
79 Ok((yaml_content, body))
80}
81
82fn find_closing_delimiter(content: &str) -> Option<usize> {
83 if content.starts_with(FRONT_MATTER_DELIMITER) {
84 return Some(0);
85 }
86 if let Some(pos) = content.find("\r\n---") {
87 return Some(pos + 2);
88 }
89 if let Some(pos) = content.find("\n---") {
90 return Some(pos + 1);
91 }
92 None
93}
94
95fn strip_line_ending(s: &str) -> &str {
96 s.strip_prefix("\r\n")
97 .or_else(|| s.strip_prefix('\n'))
98 .unwrap_or(s)
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 #[test]
106 fn single_crate_with_summary() {
107 let content = r#"---
108"my-package": patch
109---
110Fix critical bug in authentication flow.
111"#;
112
113 let changeset = parse_changeset(content).expect("should parse");
114 assert_eq!(changeset.releases().len(), 1);
115 assert_eq!(changeset.releases()[0].name(), "my-package");
116 assert_eq!(changeset.releases()[0].bump_type(), BumpType::Patch);
117 assert_eq!(
118 changeset.summary(),
119 "Fix critical bug in authentication flow."
120 );
121 }
122
123 #[test]
124 fn multiple_crates_preserves_order() {
125 let content = r#"---
126"crate-one": major
127"crate-two": minor
128"crate-three": patch
129---
130Breaking change to API.
131"#;
132
133 let changeset = parse_changeset(content).expect("should parse");
134 assert_eq!(changeset.releases().len(), 3);
135
136 assert_eq!(changeset.releases()[0].name(), "crate-one");
137 assert_eq!(changeset.releases()[0].bump_type(), BumpType::Major);
138 assert_eq!(changeset.releases()[1].name(), "crate-two");
139 assert_eq!(changeset.releases()[1].bump_type(), BumpType::Minor);
140 assert_eq!(changeset.releases()[2].name(), "crate-three");
141 assert_eq!(changeset.releases()[2].bump_type(), BumpType::Patch);
142 }
143
144 #[test]
145 fn multiline_summary() {
146 let content = r#"---
147"my-crate": minor
148---
149This is a multiline summary.
150
151It contains multiple paragraphs and describes the change in detail.
152
153- Feature one
154- Feature two
155"#;
156
157 let changeset = parse_changeset(content).expect("should parse");
158 assert!(changeset.summary().contains("multiline summary"));
159 assert!(changeset.summary().contains("Feature one"));
160 assert!(changeset.summary().contains("Feature two"));
161 }
162
163 #[test]
164 fn empty_body() {
165 let content = r#"---
166"my-crate": patch
167---
168"#;
169
170 let changeset = parse_changeset(content).expect("should parse");
171 assert!(changeset.summary().is_empty());
172 }
173
174 #[test]
175 fn delimiter_inside_summary() {
176 let content = r#"---
177"my-crate": patch
178---
179Summary with --- inside text should not break parsing.
180"#;
181
182 let changeset = parse_changeset(content).expect("should parse");
183 assert!(changeset.summary().contains("---"));
184 }
185
186 #[test]
187 fn windows_line_endings() {
188 let content = "---\r\n\"my-crate\": patch\r\n---\r\nWindows style summary.\r\n";
189
190 let changeset = parse_changeset(content).expect("should parse");
191 assert_eq!(changeset.releases().len(), 1);
192 assert_eq!(changeset.releases()[0].name(), "my-crate");
193 assert!(changeset.summary().contains("Windows style summary"));
194 }
195
196 #[test]
197 fn mixed_line_endings() {
198 let content = "---\r\n\"crate-a\": major\n\"crate-b\": minor\r\n---\nMixed endings.\r\n";
199
200 let changeset = parse_changeset(content).expect("should parse");
201 assert_eq!(changeset.releases().len(), 2);
202 assert_eq!(changeset.releases()[0].name(), "crate-a");
203 assert_eq!(changeset.releases()[1].name(), "crate-b");
204 }
205
206 #[test]
207 fn no_trailing_newline() {
208 let content = "---\n\"my-crate\": patch\n---\nSummary without trailing newline";
209
210 let changeset = parse_changeset(content).expect("should parse");
211 assert_eq!(changeset.summary(), "Summary without trailing newline");
212 }
213
214 #[test]
215 fn unicode_crate_name_and_summary() {
216 let content = r#"---
217"über-crate": minor
218---
219Добавлена поддержка Unicode 🎉
220"#;
221
222 let changeset = parse_changeset(content).expect("should parse");
223 assert_eq!(changeset.releases()[0].name(), "über-crate");
224 assert!(changeset.summary().contains("Добавлена"));
225 assert!(changeset.summary().contains("🎉"));
226 }
227
228 #[test]
229 fn very_long_summary() {
230 let long_summary = "A".repeat(10000);
231 let content = format!("---\n\"my-crate\": patch\n---\n{long_summary}\n");
232
233 let changeset = parse_changeset(&content).expect("should parse");
234 assert_eq!(changeset.summary().len(), 10000);
235 }
236
237 #[test]
238 fn whitespace_only_summary() {
239 let content = "---\n\"my-crate\": patch\n---\n \n\t\n \n";
240
241 let changeset = parse_changeset(content).expect("should parse");
242 assert!(changeset.summary().is_empty());
243 }
244
245 #[test]
246 fn error_missing_opening_delimiter() {
247 let content = r#"
248"my-crate": patch
249---
250Some summary.
251"#;
252
253 let err = parse_changeset(content).expect_err("should fail");
254 assert!(err.to_string().contains("opening delimiter"));
255 }
256
257 #[test]
258 fn error_missing_closing_delimiter() {
259 let content = r#"---
260"my-crate": patch
261Some summary without closing delimiter.
262"#;
263
264 let err = parse_changeset(content).expect_err("should fail");
265 assert!(err.to_string().contains("closing delimiter"));
266 }
267
268 #[test]
269 fn error_empty_front_matter() {
270 let content = r#"---
271---
272Some summary.
273"#;
274
275 let err = parse_changeset(content).expect_err("should fail");
276 assert!(err.to_string().contains("empty"));
277 }
278
279 #[test]
280 fn error_invalid_bump_type() {
281 let content = r#"---
282"my-crate": invalid
283---
284Some summary.
285"#;
286
287 let err = parse_changeset(content).expect_err("should fail");
288 assert!(err.to_string().contains("YAML"));
289 }
290
291 #[test]
292 fn error_empty_releases() {
293 let content = r#"---
294{}
295---
296Some summary.
297"#;
298
299 let err = parse_changeset(content).expect_err("should fail");
300 assert!(err.to_string().contains("at least one release"));
301 }
302
303 #[test]
304 fn error_input_too_large() {
305 let huge_content = "a".repeat(MAX_INPUT_SIZE + 1);
306
307 let err = parse_changeset(&huge_content).expect_err("should fail");
308 assert!(err.to_string().contains("maximum size"));
309 }
310
311 #[test]
312 fn error_duplicate_package() {
313 let content = r#"---
314"my-crate": major
315"my-crate": patch
316---
317Some summary.
318"#;
319
320 let err = parse_changeset(content).expect_err("should fail");
321 let err_str = err.to_string();
322 assert!(
323 err_str.contains("duplicate"),
324 "Expected 'duplicate' in error message, got: {err_str}"
325 );
326 }
327
328 #[test]
329 fn category_defaults_to_changed() {
330 let content = r#"---
331"my-crate": patch
332---
333Some summary.
334"#;
335
336 let changeset = parse_changeset(content).expect("should parse");
337 assert_eq!(changeset.category(), ChangeCategory::Changed);
338 }
339
340 #[test]
341 fn parses_category_fixed() {
342 let content = r#"---
343category: fixed
344"my-crate": patch
345---
346Fixed a bug.
347"#;
348
349 let changeset = parse_changeset(content).expect("should parse");
350 assert_eq!(changeset.category(), ChangeCategory::Fixed);
351 assert_eq!(changeset.releases()[0].name(), "my-crate");
352 }
353
354 #[test]
355 fn parses_category_added() {
356 let content = r#"---
357category: added
358"my-feature": minor
359---
360Added new feature.
361"#;
362
363 let changeset = parse_changeset(content).expect("should parse");
364 assert_eq!(changeset.category(), ChangeCategory::Added);
365 }
366
367 #[test]
368 fn parses_category_deprecated() {
369 let content = r#"---
370category: deprecated
371"old-api": minor
372---
373Deprecated old API.
374"#;
375
376 let changeset = parse_changeset(content).expect("should parse");
377 assert_eq!(changeset.category(), ChangeCategory::Deprecated);
378 }
379
380 #[test]
381 fn parses_category_removed() {
382 let content = r#"---
383category: removed
384"old-feature": major
385---
386Removed old feature.
387"#;
388
389 let changeset = parse_changeset(content).expect("should parse");
390 assert_eq!(changeset.category(), ChangeCategory::Removed);
391 }
392
393 #[test]
394 fn parses_category_security() {
395 let content = r#"---
396category: security
397"auth-module": patch
398---
399Fixed security vulnerability.
400"#;
401
402 let changeset = parse_changeset(content).expect("should parse");
403 assert_eq!(changeset.category(), ChangeCategory::Security);
404 }
405
406 #[test]
407 fn parses_category_changed() {
408 let content = r#"---
409category: changed
410"my-crate": minor
411---
412Changed behavior.
413"#;
414
415 let changeset = parse_changeset(content).expect("should parse");
416 assert_eq!(changeset.category(), ChangeCategory::Changed);
417 }
418
419 #[test]
420 fn error_invalid_category() {
421 let content = r#"---
422category: unknown
423"my-crate": patch
424---
425Some summary.
426"#;
427
428 let err = parse_changeset(content).expect_err("should fail");
429 assert!(err.to_string().contains("YAML"));
430 }
431
432 #[test]
433 fn consumed_for_prerelease_defaults_to_none() {
434 let content = r#"---
435"my-crate": patch
436---
437Some summary.
438"#;
439
440 let changeset = parse_changeset(content).expect("should parse");
441 assert_eq!(changeset.consumed_for_prerelease(), None);
442 }
443
444 #[test]
445 fn parses_consumed_for_prerelease() {
446 let content = r#"---
447consumedForPrerelease: 1.0.1-alpha.1
448"my-crate": patch
449---
450Some summary.
451"#;
452
453 let changeset = parse_changeset(content).expect("should parse");
454 assert_eq!(
455 changeset.consumed_for_prerelease(),
456 Some(&"1.0.1-alpha.1".to_string())
457 );
458 }
459
460 #[test]
461 fn parses_consumed_for_prerelease_with_category() {
462 let content = r#"---
463category: fixed
464consumedForPrerelease: 2.0.0-beta.3
465"my-crate": patch
466---
467Fixed a bug.
468"#;
469
470 let changeset = parse_changeset(content).expect("should parse");
471 assert_eq!(changeset.category(), ChangeCategory::Fixed);
472 assert_eq!(
473 changeset.consumed_for_prerelease(),
474 Some(&"2.0.0-beta.3".to_string())
475 );
476 }
477
478 #[test]
479 fn parses_consumed_for_prerelease_with_quoted_value() {
480 let content = r#"---
481consumedForPrerelease: "1.2.3-rc.1"
482"my-crate": minor
483---
484Release candidate.
485"#;
486
487 let changeset = parse_changeset(content).expect("should parse");
488 assert_eq!(
489 changeset.consumed_for_prerelease(),
490 Some(&"1.2.3-rc.1".to_string())
491 );
492 }
493
494 #[test]
495 fn graduate_defaults_to_false() {
496 let content = r#"---
497"my-crate": patch
498---
499Some summary.
500"#;
501
502 let changeset = parse_changeset(content).expect("should parse");
503 assert!(!changeset.graduate());
504 }
505
506 #[test]
507 fn parses_graduate_true() {
508 let content = r#"---
509graduate: true
510"my-crate": major
511---
512Graduate to 1.0.0.
513"#;
514
515 let changeset = parse_changeset(content).expect("should parse");
516 assert!(changeset.graduate());
517 }
518
519 #[test]
520 fn parses_graduate_false() {
521 let content = r#"---
522graduate: false
523"my-crate": major
524---
525Major bump.
526"#;
527
528 let changeset = parse_changeset(content).expect("should parse");
529 assert!(!changeset.graduate());
530 }
531
532 #[test]
533 fn parses_none_bump_type_with_body() {
534 let content = r#"---
535"my-crate": none
536---
537Internal refactoring only.
538"#;
539
540 let changeset = parse_changeset(content).expect("should parse");
541 assert_eq!(changeset.releases().len(), 1);
542 assert_eq!(changeset.releases()[0].name(), "my-crate");
543 assert_eq!(changeset.releases()[0].bump_type(), BumpType::None);
544 assert_eq!(changeset.summary(), "Internal refactoring only.");
545 }
546
547 #[test]
548 fn parses_none_bump_type_with_empty_body() {
549 let content = r#"---
550"my-crate": none
551---
552"#;
553
554 let changeset = parse_changeset(content).expect("should parse");
555 assert_eq!(changeset.releases()[0].bump_type(), BumpType::None);
556 assert!(changeset.summary().is_empty());
557 }
558
559 #[test]
560 fn parses_mixed_none_and_patch_bump_types() {
561 let content = r#"---
562"crate-a": none
563"crate-b": patch
564---
565Mixed bump types.
566"#;
567
568 let changeset = parse_changeset(content).expect("should parse");
569 assert_eq!(changeset.releases().len(), 2);
570 assert_eq!(changeset.releases()[0].bump_type(), BumpType::None);
571 assert_eq!(changeset.releases()[1].bump_type(), BumpType::Patch);
572 }
573
574 #[test]
575 fn yaml_null_does_not_parse_as_bump_type_none() {
576 let content = r#"---
577"my-crate": None
578---
579Summary.
580"#;
581
582 let err = parse_changeset(content)
583 .expect_err("YAML None (null) should not parse as BumpType::None");
584 assert!(err.to_string().contains("YAML"));
585 }
586
587 #[test]
588 fn yaml_uppercase_none_does_not_parse_as_bump_type_none() {
589 let content = r#"---
590"my-crate": NONE
591---
592Summary.
593"#;
594
595 let err =
596 parse_changeset(content).expect_err("YAML NONE should not parse as BumpType::None");
597 assert!(err.to_string().contains("YAML"));
598 }
599
600 #[test]
601 fn parses_graduate_with_category() {
602 let content = r#"---
603category: added
604graduate: true
605"my-crate": major
606---
607New major release.
608"#;
609
610 let changeset = parse_changeset(content).expect("should parse");
611 assert!(changeset.graduate());
612 assert_eq!(changeset.category(), ChangeCategory::Added);
613 }
614}