Skip to main content

changeset_parse/
parse.rs

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}