co_authors/
lib.rs

1use std::convert::{identity, TryFrom};
2
3use nom::{
4    bytes::complete::{is_not, tag, tag_no_case, take_until},
5    character::complete::space0,
6    combinator::{map, opt, verify},
7    error::ErrorKind,
8    multi::many1,
9    sequence::{delimited, preceded},
10    Err, IResult,
11};
12
13/// A co-author as parsed from a [Co-Authored-By trailer].
14///
15/// # Example
16///
17/// ```rust
18/// # use co_authors::CoAuthor;
19/// # use std::convert::TryFrom;
20/// let trailer = "Co-Authored-By: Alice <alice@wonderland.org>";
21/// let co_author = CoAuthor::try_from(trailer);
22/// assert_eq!(co_author, Ok(CoAuthor { name: "Alice", mail: Some("alice@wonderland.org") }));
23/// ```
24///
25/// [Co-Authored-By trailer]: https://github.blog/2018-01-29-commit-together-with-co-authors/
26#[derive(Copy, Clone, Debug, PartialEq, Eq)]
27pub struct CoAuthor<'a> {
28    pub name: &'a str,
29    pub mail: Option<&'a str>,
30}
31
32impl<'a> TryFrom<&'a str> for CoAuthor<'a> {
33    type Error = CoAuthorError;
34
35    fn try_from(line: &'a str) -> Result<Self, Self::Error> {
36        match co_author(line) {
37            Ok((_, (name, mail))) => Ok(CoAuthor { name, mail }),
38            Err(e) => match e {
39                Err::Incomplete(_) => Err(CoAuthorError::MissingTrailerKey),
40                Err::Error(e) | Err::Failure(e) => match e.code {
41                    ErrorKind::Tag => Err(CoAuthorError::MissingTrailerKey),
42                    ErrorKind::TakeUntil => Err(CoAuthorError::MissingMail),
43                    ErrorKind::Verify => Err(CoAuthorError::MissingName),
44                    otherwise => unreachable!("Unexpected error kind: {:?}", otherwise),
45                },
46            },
47        }
48    }
49}
50
51/// Possible errors when parsing the [CoAuthor].
52#[derive(Copy, Clone, Debug, PartialEq, Eq)]
53pub enum CoAuthorError {
54    /// The trailer is missing the `Co-Authored-By:` key.
55    MissingTrailerKey,
56    /// The name of the co-author is missing.
57    MissingName,
58    /// The mail of the co-author is missing.
59    MissingMail,
60}
61
62impl std::fmt::Display for CoAuthorError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            CoAuthorError::MissingTrailerKey => {
66                f.write_str("The trailer is missing the `Co-Authored-By:` key.")
67            }
68            CoAuthorError::MissingName => f.write_str("The name of the co-author is missing."),
69            CoAuthorError::MissingMail => f.write_str("The mail of the co-author is missing."),
70        }
71    }
72}
73
74impl std::error::Error for CoAuthorError {}
75
76#[deprecated(since = "0.1.0", note = "Use `CoAuthor::try_from` instead")]
77pub fn get_co_author(line: &str) -> Option<CoAuthor> {
78    let (_, (name, mail)) = co_author(line).ok()?;
79    Some(CoAuthor { name, mail })
80}
81
82fn co_author(input: &str) -> IResult<&str, (&str, Option<&str>)> {
83    let (input, name) = co_author_name(input)?;
84    let (input, email) = co_author_mail(input)?;
85    Ok((input, (name, email)))
86}
87
88fn co_author_name(input: &str) -> IResult<&str, &str> {
89    let co_author_name = take_until("<");
90    let co_author_name = map(co_author_name, str::trim);
91    let co_author_name = verify(co_author_name, |s: &str| !s.is_empty());
92    let co_author_name = preceded(co_authored_by, co_author_name);
93    identity(co_author_name)(input)
94}
95
96fn co_authored_by(input: &str) -> IResult<&str, Vec<()>> {
97    let co_authored_by = tag_no_case("co-authored-by:");
98    let co_authored_by = map(co_authored_by, |_| ());
99    let co_authored_by = delimited(space0, co_authored_by, space0);
100    let co_authored_by = many1(co_authored_by);
101    identity(co_authored_by)(input)
102}
103
104fn co_author_mail(input: &str) -> IResult<&str, Option<&str>> {
105    let co_author_mail = is_not("> \t");
106    let co_author_mail = delimited(tag("<"), co_author_mail, tag(">"));
107    let co_author_mail = opt(co_author_mail);
108    identity(co_author_mail)(input)
109}
110
111#[cfg(test)]
112mod tests {
113    use test_case::test_case;
114
115    use super::*;
116
117    #[test_case("co-authored-by: Alice <alice@wonderland.org>", "Alice <alice@wonderland.org>"; "lower case")]
118    #[test_case("Co-Authored-By: Alice <alice@wonderland.org>", "Alice <alice@wonderland.org>"; "camel case")]
119    #[test_case("CO-AUTHORED-BY: Alice <alice@wonderland.org>", "Alice <alice@wonderland.org>"; "upper case")]
120    #[test_case("Co-authored-by: Alice <alice@wonderland.org>", "Alice <alice@wonderland.org>"; "mixed case")]
121    #[test_case("Co-authored-by: Co-authored-by: Alice <alice@wonderland.org>", "Alice <alice@wonderland.org>"; "florentin case")]
122    fn test_co_authored_by(input: &str, expected: &str) {
123        let (result, _) = co_authored_by(input).unwrap();
124        assert_eq!(result, expected)
125    }
126
127    #[test_case("co-authored-by: Alice <alice@wonderland.org>", "Alice"; "alice")]
128    #[test_case("co-authored-by: Alice Bob <alice@wonderland.org>", "Alice Bob"; "alice bob")]
129    fn test_co_author_name(input: &str, expected: &str) {
130        let (_, result) = co_author_name(input).unwrap();
131        assert_eq!(result, expected)
132    }
133
134    #[test_case("<alice@wonderland.org>", "alice@wonderland.org"; "alice")]
135    #[test_case("<alice@wonderland.org> bob", "alice@wonderland.org"; "alice bob")]
136    #[test_case("<alice@wonderland.org> <charlie@wonderland.org>", "alice@wonderland.org"; "alice charlie")]
137    fn test_co_author_mail(input: &str, expected: &str) {
138        let (_, result) = co_author_mail(input).unwrap();
139        assert_eq!(result.unwrap(), expected)
140    }
141
142    #[test_case(""; "empty")]
143    #[test_case(" <alice@wonderland.org>"; "leading space")]
144    #[test_case("<alice@wonderland.org"; "missing close")]
145    #[test_case("<alice@wonderland.org&gt;"; "encoded close")]
146    #[test_case("alice@wonderland.org>"; "missing open")]
147    #[test_case("<alice and bob@wonderland.org>"; "contains whitespace")]
148    fn test_missing_co_author_mail(input: &str) {
149        let (_, result) = co_author_mail(input).unwrap();
150        assert_eq!(result, None)
151    }
152
153    #[test_case("co-authored-by: Alice <alice@wonderland.org>" => Some("Alice"); "alice")]
154    #[test_case("co-authored-by: Alice Keys <alice@wonderland.org>" => Some("Alice Keys"); "alice keys")]
155    #[test_case("Co-Authored-By:Alice<alice@wonderland.org>" => Some("Alice"); "no space alice")]
156    #[test_case("Some other content" => None; "none")]
157    fn test_get_co_author_name(input: &str) -> Option<&str> {
158        CoAuthor::try_from(input)
159            .ok()
160            .map(|co_author| co_author.name)
161    }
162
163    #[test_case("co-authored-by: Alice <alice@wonderland.org>" => Some("alice@wonderland.org"); "alice")]
164    #[test_case("co-authored-by: Alice Keys <alice@wonderland.org>" => Some("alice@wonderland.org"); "alice keys")]
165    #[test_case("co-authored-by: <alice@wonderland.org>" => None; "missing name")]
166    #[test_case("Some other content" => None; "none")]
167    fn test_get_co_author_mail(input: &str) -> Option<&str> {
168        CoAuthor::try_from(input)
169            .ok()
170            .and_then(|co_author| co_author.mail)
171    }
172
173    #[test_case("Alice <alice@wonderland.org>"; "missing")]
174    #[test_case("co-authored-by Alice <alice@wonderland.org>"; "missing colon")]
175    #[test_case("Co-Authored: Alice <alice@wonderland.org>"; "missing By")]
176    #[test_case("Authored-By: Alice <alice@wonderland.org>"; "missing Co")]
177    #[test_case(""; "empty input")]
178    fn test_missing_trailer_key(input: &str) {
179        let err = CoAuthor::try_from(input).unwrap_err();
180        assert_eq!(err, CoAuthorError::MissingTrailerKey)
181    }
182
183    #[test_case("Co-Authored-By: <alice@wonderland.org>"; "missing name")]
184    fn test_missing_name(input: &str) {
185        let err = CoAuthor::try_from(input).unwrap_err();
186        assert_eq!(err, CoAuthorError::MissingName)
187    }
188
189    #[test_case("Co-Authored-By: Alice"; "missing mail")]
190    fn test_missing_mail(input: &str) {
191        let err = CoAuthor::try_from(input).unwrap_err();
192        assert_eq!(err, CoAuthorError::MissingMail)
193    }
194}