1use std::fmt;
4use std::ops::Deref;
5use std::str::FromStr;
6
7use winnow::error::ContextError;
8use winnow::Parser;
9
10use crate::parser::parse;
11use crate::{Error, ErrorKind};
12
13const BREAKING_PHRASE: &str = "BREAKING CHANGE";
14const BREAKING_ARROW: &str = "BREAKING-CHANGE";
15
16#[cfg_attr(feature = "serde", derive(serde::Serialize))]
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct Commit<'a> {
20 ty: Type<'a>,
21 scope: Option<Scope<'a>>,
22 description: &'a str,
23 body: Option<&'a str>,
24 breaking: bool,
25 #[cfg_attr(feature = "serde", serde(skip))]
26 breaking_description: Option<&'a str>,
27 footers: Vec<Footer<'a>>,
28}
29
30impl<'a> Commit<'a> {
31 pub fn parse(string: &'a str) -> Result<Self, Error> {
39 let (ty, scope, breaking, description, body, footers) = parse::<ContextError>
40 .parse(string)
41 .map_err(|err| Error::with_nom(string, err))?;
42
43 let breaking_description = footers
44 .iter()
45 .find_map(|(k, _, v)| (k == &BREAKING_PHRASE || k == &BREAKING_ARROW).then_some(*v))
46 .or_else(|| breaking.then_some(description));
47 let breaking = breaking_description.is_some();
48 let footers: Result<Vec<_>, Error> = footers
49 .into_iter()
50 .map(|(k, s, v)| Ok(Footer::new(FooterToken::new_unchecked(k), s.parse()?, v)))
51 .collect();
52 let footers = footers?;
53
54 Ok(Self {
55 ty: Type::new_unchecked(ty),
56 scope: scope.map(Scope::new_unchecked),
57 description,
58 body,
59 breaking,
60 breaking_description,
61 footers,
62 })
63 }
64
65 pub fn type_(&self) -> Type<'a> {
67 self.ty
68 }
69
70 pub fn scope(&self) -> Option<Scope<'a>> {
72 self.scope
73 }
74
75 pub fn description(&self) -> &'a str {
77 self.description
78 }
79
80 pub fn body(&self) -> Option<&'a str> {
83 self.body
84 }
85
86 pub fn breaking(&self) -> bool {
101 self.breaking
102 }
103
104 pub fn breaking_description(&self) -> Option<&'a str> {
109 self.breaking_description
110 }
111
112 pub fn footers(&self) -> &[Footer<'a>] {
119 &self.footers
120 }
121}
122
123impl fmt::Display for Commit<'_> {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 f.write_str(self.type_().as_str())?;
126
127 if let Some(scope) = &self.scope() {
128 f.write_fmt(format_args!("({scope})"))?;
129 }
130
131 f.write_fmt(format_args!(": {}", &self.description()))?;
132
133 if let Some(body) = &self.body() {
134 f.write_fmt(format_args!("\n\n{body}"))?;
135 }
136
137 for footer in self.footers() {
138 write!(f, "\n\n{footer}")?;
139 }
140
141 Ok(())
142 }
143}
144
145#[cfg_attr(feature = "serde", derive(serde::Serialize))]
152#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
153pub struct Footer<'a> {
154 token: FooterToken<'a>,
155 sep: FooterSeparator,
156 value: &'a str,
157}
158
159impl<'a> Footer<'a> {
160 pub const fn new(token: FooterToken<'a>, sep: FooterSeparator, value: &'a str) -> Self {
162 Self { token, sep, value }
163 }
164
165 pub const fn token(&self) -> FooterToken<'a> {
167 self.token
168 }
169
170 pub const fn separator(&self) -> FooterSeparator {
172 self.sep
173 }
174
175 pub const fn value(&self) -> &'a str {
177 self.value
178 }
179
180 pub fn breaking(&self) -> bool {
182 self.token.breaking()
183 }
184}
185
186impl fmt::Display for Footer<'_> {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 let Self { token, sep, value } = self;
189 write!(f, "{token}{sep}{value}")
190 }
191}
192
193#[cfg_attr(feature = "serde", derive(serde::Serialize))]
195#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
196#[non_exhaustive]
197pub enum FooterSeparator {
198 Value,
200
201 Ref,
203}
204
205impl FooterSeparator {
206 pub fn as_str(self) -> &'static str {
208 match self {
209 FooterSeparator::Value => ":",
210 FooterSeparator::Ref => " #",
211 }
212 }
213}
214
215impl Deref for FooterSeparator {
216 type Target = str;
217
218 fn deref(&self) -> &Self::Target {
219 self.as_str()
220 }
221}
222
223impl PartialEq<&'_ str> for FooterSeparator {
224 fn eq(&self, other: &&str) -> bool {
225 self.as_str() == *other
226 }
227}
228
229impl fmt::Display for FooterSeparator {
230 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231 f.write_str(self)
232 }
233}
234
235impl FromStr for FooterSeparator {
236 type Err = Error;
237
238 fn from_str(sep: &str) -> Result<Self, Self::Err> {
239 match sep {
240 ":" => Ok(FooterSeparator::Value),
241 " #" => Ok(FooterSeparator::Ref),
242 _ => {
243 Err(Error::new(ErrorKind::InvalidFooter).set_context(Box::new(format!("{sep:?}"))))
244 }
245 }
246 }
247}
248
249macro_rules! unicase_components {
250 ($($ty:ident),+) => (
251 $(
252 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
254 pub struct $ty<'a>(unicase::UniCase<&'a str>);
255
256 impl<'a> $ty<'a> {
257 pub const fn new_unchecked(value: &'a str) -> Self {
259 $ty(unicase::UniCase::unicode(value))
260 }
261
262 pub fn as_str(&self) -> &'a str {
264 &self.0.into_inner()
265 }
266 }
267
268 impl Deref for $ty<'_> {
269 type Target = str;
270
271 fn deref(&self) -> &Self::Target {
272 self.as_str()
273 }
274 }
275
276 impl PartialEq<&'_ str> for $ty<'_> {
277 fn eq(&self, other: &&str) -> bool {
278 *self == $ty::new_unchecked(*other)
279 }
280 }
281
282 impl fmt::Display for $ty<'_> {
283 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284 self.0.fmt(f)
285 }
286 }
287
288 #[cfg(feature = "serde")]
289 impl serde::Serialize for $ty<'_> {
290 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
291 where
292 S: serde::Serializer,
293 {
294 serializer.serialize_str(self)
295 }
296 }
297 )+
298 )
299}
300
301unicase_components![Type, Scope, FooterToken];
302
303impl<'a> Type<'a> {
304 pub fn parse(sep: &'a str) -> Result<Self, Error> {
306 let t = crate::parser::type_::<ContextError>
307 .parse(sep)
308 .map_err(|err| Error::with_nom(sep, err))?;
309 Ok(Type::new_unchecked(t))
310 }
311}
312
313impl Type<'static> {
315 pub const FEAT: Type<'static> = Type::new_unchecked("feat");
317 pub const FIX: Type<'static> = Type::new_unchecked("fix");
319 pub const REVERT: Type<'static> = Type::new_unchecked("revert");
321 pub const DOCS: Type<'static> = Type::new_unchecked("docs");
323 pub const STYLE: Type<'static> = Type::new_unchecked("style");
325 pub const REFACTOR: Type<'static> = Type::new_unchecked("refactor");
327 pub const PERF: Type<'static> = Type::new_unchecked("perf");
329 pub const TEST: Type<'static> = Type::new_unchecked("test");
331 pub const CHORE: Type<'static> = Type::new_unchecked("chore");
333}
334
335impl<'a> Scope<'a> {
336 pub fn parse(sep: &'a str) -> Result<Self, Error> {
338 let t = crate::parser::scope::<ContextError>
339 .parse(sep)
340 .map_err(|err| Error::with_nom(sep, err))?;
341 Ok(Scope::new_unchecked(t))
342 }
343}
344
345impl<'a> FooterToken<'a> {
346 pub fn parse(sep: &'a str) -> Result<Self, Error> {
348 let t = crate::parser::token::<ContextError>
349 .parse(sep)
350 .map_err(|err| Error::with_nom(sep, err))?;
351 Ok(FooterToken::new_unchecked(t))
352 }
353
354 pub fn breaking(&self) -> bool {
356 self == &BREAKING_PHRASE || self == &BREAKING_ARROW
357 }
358}
359
360#[cfg(test)]
361mod test {
362 use super::*;
363 use crate::ErrorKind;
364 use indoc::indoc;
365 #[cfg(feature = "serde")]
366 use serde_test::Token;
367
368 #[test]
369 fn test_valid_simple_commit() {
370 let commit = Commit::parse("type(my scope): hello world").unwrap();
371
372 assert_eq!(commit.type_(), "type");
373 assert_eq!(commit.scope().unwrap(), "my scope");
374 assert_eq!(commit.description(), "hello world");
375 }
376
377 #[test]
378 fn test_trailing_whitespace_without_body() {
379 let commit = Commit::parse("type(my scope): hello world\n\n\n").unwrap();
380
381 assert_eq!(commit.type_(), "type");
382 assert_eq!(commit.scope().unwrap(), "my scope");
383 assert_eq!(commit.description(), "hello world");
384 }
385
386 #[test]
387 fn test_trailing_1_nl() {
388 let commit = Commit::parse("type: hello world\n").unwrap();
389
390 assert_eq!(commit.type_(), "type");
391 assert_eq!(commit.scope(), None);
392 assert_eq!(commit.description(), "hello world");
393 }
394
395 #[test]
396 fn test_trailing_2_nl() {
397 let commit = Commit::parse("type: hello world\n\n").unwrap();
398
399 assert_eq!(commit.type_(), "type");
400 assert_eq!(commit.scope(), None);
401 assert_eq!(commit.description(), "hello world");
402 }
403
404 #[test]
405 fn test_trailing_3_nl() {
406 let commit = Commit::parse("type: hello world\n\n\n").unwrap();
407
408 assert_eq!(commit.type_(), "type");
409 assert_eq!(commit.scope(), None);
410 assert_eq!(commit.description(), "hello world");
411 }
412
413 #[test]
414 fn test_parenthetical_statement() {
415 let commit = Commit::parse("type: hello world (#1)").unwrap();
416
417 assert_eq!(commit.type_(), "type");
418 assert_eq!(commit.scope(), None);
419 assert_eq!(commit.description(), "hello world (#1)");
420 }
421
422 #[test]
423 fn test_multiline_description() {
424 let err = Commit::parse(
425 "chore: Automate fastlane when a file in the fastlane directory is\nchanged (hopefully)",
426 ).unwrap_err();
427
428 assert_eq!(ErrorKind::InvalidBody, err.kind());
429 }
430
431 #[test]
432 fn test_issue_12_case_1() {
433 let commit = Commit::parse("chore: add .hello.txt (#1)\n\n").unwrap();
435
436 assert_eq!(commit.type_(), "chore");
437 assert_eq!(commit.scope(), None);
438 assert_eq!(commit.description(), "add .hello.txt (#1)");
439 }
440
441 #[test]
442 fn test_issue_12_case_2() {
443 let commit = Commit::parse("refactor: use fewer lines (#3)\n\n").unwrap();
445
446 assert_eq!(commit.type_(), "refactor");
447 assert_eq!(commit.scope(), None);
448 assert_eq!(commit.description(), "use fewer lines (#3)");
449 }
450
451 #[test]
452 fn test_breaking_change() {
453 let commit = Commit::parse("feat!: this is a breaking change").unwrap();
454 assert_eq!(Type::FEAT, commit.type_());
455 assert!(commit.breaking());
456 assert_eq!(
457 commit.breaking_description(),
458 Some("this is a breaking change")
459 );
460
461 let commit = Commit::parse(indoc!(
462 "feat: message
463
464 BREAKING CHANGE: breaking change"
465 ))
466 .unwrap();
467 assert_eq!(Type::FEAT, commit.type_());
468 assert_eq!("breaking change", commit.footers().first().unwrap().value());
469 assert!(commit.breaking());
470 assert_eq!(commit.breaking_description(), Some("breaking change"));
471
472 let commit = Commit::parse(indoc!(
473 "fix: message
474
475 BREAKING-CHANGE: it's broken"
476 ))
477 .unwrap();
478 assert_eq!(Type::FIX, commit.type_());
479 assert_eq!("it's broken", commit.footers().first().unwrap().value());
480 assert!(commit.breaking());
481 assert_eq!(commit.breaking_description(), Some("it's broken"));
482 }
483
484 #[test]
485 fn test_conjoined_footer() {
486 let commit = Commit::parse(
487 "fix(example): fix keepachangelog config example
488
489Fixes: #123, #124, #125",
490 )
491 .unwrap();
492 assert_eq!(Type::FIX, commit.type_());
493 assert_eq!(commit.body(), None);
494 assert_eq!(
495 commit.footers(),
496 [Footer::new(
497 FooterToken("Fixes".into()),
498 FooterSeparator::Value,
499 "#123, #124, #125"
500 ),]
501 );
502 }
503
504 #[test]
505 fn test_windows_line_endings() {
506 let commit =
507 Commit::parse("feat: thing\r\n\r\nbody\r\n\r\ncloses #1234\r\n\r\n\r\nBREAKING CHANGE: something broke\r\n\r\n")
508 .unwrap();
509 assert_eq!(commit.body(), Some("body"));
510 assert_eq!(
511 commit.footers(),
512 [
513 Footer::new(FooterToken("closes".into()), FooterSeparator::Ref, "1234"),
514 Footer::new(
515 FooterToken("BREAKING CHANGE".into()),
516 FooterSeparator::Value,
517 "something broke"
518 ),
519 ]
520 );
521 assert_eq!(commit.breaking_description(), Some("something broke"));
522 }
523
524 #[test]
525 fn test_extra_line_endings() {
526 let commit =
527 Commit::parse("feat: thing\n\n\n\n\nbody\n\n\n\n\ncloses #1234\n\n\n\n\n\nBREAKING CHANGE: something broke\n\n\n\n")
528 .unwrap();
529 assert_eq!(commit.body(), Some("body"));
530 assert_eq!(
531 commit.footers(),
532 [
533 Footer::new(FooterToken("closes".into()), FooterSeparator::Ref, "1234"),
534 Footer::new(
535 FooterToken("BREAKING CHANGE".into()),
536 FooterSeparator::Value,
537 "something broke"
538 ),
539 ]
540 );
541 assert_eq!(commit.breaking_description(), Some("something broke"));
542 }
543
544 #[test]
545 fn test_fake_footer() {
546 let commit = indoc! {"
547 fix: something something
548
549 First line of the body
550 IMPORTANT: Please see something else for details.
551 Another line here.
552 "};
553
554 let commit = Commit::parse(commit).unwrap();
555
556 assert_eq!(Type::FIX, commit.type_());
557 assert_eq!(None, commit.scope());
558 assert_eq!("something something", commit.description());
559 assert_eq!(
560 Some(indoc!(
561 "
562 First line of the body
563 IMPORTANT: Please see something else for details.
564 Another line here."
565 )),
566 commit.body()
567 );
568 let empty_footer: &[Footer<'_>] = &[];
569 assert_eq!(empty_footer, commit.footers());
570 }
571
572 #[test]
573 fn test_valid_complex_commit() {
574 let commit = indoc! {"
575 chore: improve changelog readability
576
577 Change date notation from YYYY-MM-DD to YYYY.MM.DD to make it a tiny bit
578 easier to parse while reading.
579
580 BREAKING CHANGE: Just kidding!
581 "};
582
583 let commit = Commit::parse(commit).unwrap();
584
585 assert_eq!(Type::CHORE, commit.type_());
586 assert_eq!(None, commit.scope());
587 assert_eq!("improve changelog readability", commit.description());
588 assert_eq!(
589 Some(indoc!(
590 "Change date notation from YYYY-MM-DD to YYYY.MM.DD to make it a tiny bit
591 easier to parse while reading."
592 )),
593 commit.body()
594 );
595 assert_eq!("Just kidding!", commit.footers().first().unwrap().value());
596 }
597
598 #[test]
599 fn test_missing_type() {
600 let err = Commit::parse("").unwrap_err();
601
602 assert_eq!(ErrorKind::MissingType, err.kind());
603 }
604
605 #[cfg(feature = "serde")]
606 #[test]
607 fn test_commit_serialize() {
608 let commit = Commit::parse("type(my scope): hello world").unwrap();
609 serde_test::assert_ser_tokens(
610 &commit,
611 &[
612 Token::Struct {
613 name: "Commit",
614 len: 6,
615 },
616 Token::Str("ty"),
617 Token::Str("type"),
618 Token::Str("scope"),
619 Token::Some,
620 Token::Str("my scope"),
621 Token::Str("description"),
622 Token::Str("hello world"),
623 Token::Str("body"),
624 Token::None,
625 Token::Str("breaking"),
626 Token::Bool(false),
627 Token::Str("footers"),
628 Token::Seq { len: Some(0) },
629 Token::SeqEnd,
630 Token::StructEnd,
631 ],
632 );
633 }
634}