Skip to main content

changeset_parse/
parse.rs

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}