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