changelog/
commit.rs

1// Copyright 2017-2018 by Aldrin J D'Souza.
2// Licensed under the MIT License <https://opensource.org/licenses/MIT>
3// Commit fetch and parsing logic
4use git;
5use std::{fmt, str};
6use nom::{is_alphanumeric, IResult};
7
8/// A single commit
9#[derive(Debug, Default, Serialize, Eq, PartialEq)]
10pub struct Commit {
11    /// The SHA
12    pub sha: String,
13
14    /// The author
15    pub author: String,
16
17    /// The timestamp
18    pub time: String,
19
20    /// The summary
21    pub summary: String,
22
23    /// The change number
24    pub number: Option<u32>,
25
26    /// The message
27    pub message: String,
28}
29
30/// A list of commit revisions
31pub struct CommitList {
32    /// The log command
33    input: String,
34
35    /// The commits in the log
36    commits: Vec<String>,
37}
38
39/// The commit message
40pub struct CommitMessage<'a>(Vec<&'a str>);
41
42/// A single line in a commit change message
43#[derive(Default, Debug)]
44pub struct Line {
45    /// The scope
46    pub scope: Option<String>,
47
48    /// The category
49    pub category: Option<String>,
50
51    /// The text
52    pub text: Option<String>,
53}
54
55impl<T: AsRef<str>> From<T> for Commit {
56    /// Construct a commit from the revision
57    fn from(input: T) -> Self {
58        let revision = input.as_ref();
59        match git::get_commit_message(revision) {
60            Ok(lines) => Commit::from_lines(lines),
61            Err(why) => {
62                error!("Commit {} will be skipped (Reason: {})", revision, why);
63                Commit::default()
64            }
65        }
66    }
67}
68
69impl Commit {
70    pub fn from_lines(mut lines: Vec<String>) -> Self {
71        let mut commit = Self::default();
72
73        commit.sha = lines.remove(0);
74        commit.author = lines.remove(0);
75        commit.time = lines.remove(0);
76
77        let subject = lines.remove(0);
78        commit.number = parse_number(&subject);
79        commit.summary = parse_subject(&subject);
80        commit.message = lines.join("\n");
81
82        commit
83    }
84}
85
86impl<'a> From<&'a str> for CommitList {
87    /// Convenience constructor from a simple range
88    fn from(range: &str) -> Self {
89        Self::from(vec![range.to_string()])
90    }
91}
92
93impl From<Vec<String>> for CommitList {
94    /// Generate a commit list from the list of strings, interpreting them as `git log` arguments.
95    fn from(git_log_args: Vec<String>) -> Self {
96        // Record the log input
97        let input = git_log_args.join(" ");
98
99        // Get the commits that `git log` would have returned
100        let commits = match git::commits_in_log(&git_log_args) {
101            Ok(commits) => commits,
102            Err(why) => {
103                error!("Invalid log input {} (Reason: {})", input, why);
104                vec![]
105            }
106        };
107        CommitList { commits, input }
108    }
109}
110
111impl Iterator for CommitList {
112    type Item = Commit;
113    fn next(&mut self) -> Option<Self::Item> {
114        self.commits.pop().map(Commit::from)
115    }
116}
117
118impl<'a> IntoIterator for &'a Commit {
119    type Item = Line;
120    type IntoIter = CommitMessage<'a>;
121    fn into_iter(self) -> Self::IntoIter {
122        CommitMessage(self.message.lines().collect())
123    }
124}
125
126impl<'a> Iterator for CommitMessage<'a> {
127    type Item = Line;
128    fn next(&mut self) -> Option<Self::Item> {
129        if self.0.is_empty() {
130            None
131        } else {
132            Some(parse_line(self.0.remove(0)))
133        }
134    }
135}
136
137impl fmt::Display for Commit {
138    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
139        write!(f, "{}:{}", self.sha, self.summary)
140    }
141}
142
143impl fmt::Display for CommitList {
144    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
145        write!(f, "{} ({} commits)", self.input, self.commits.len())
146    }
147}
148
149/// Parse the commit subject removing the numbers tags.
150fn parse_subject(line: &str) -> String {
151    // Find the first number opener on the commit subject
152    let first_open = line.find("(#").unwrap_or_else(|| line.len());
153
154    // Everything up to the first number opener is the subject
155    String::from(line.get(0..first_open).unwrap_or_else(|| line).trim())
156}
157
158/// Parse the commit number
159fn parse_number(line: &str) -> Option<u32> {
160    // The commit number is the last number on the subject
161    let last_open = line.rfind("(#");
162
163    // The last number opener
164    let last_close = line.rfind(')');
165
166    // If no number found on subject line
167    if last_open.is_none() || last_close.is_none() {
168        // The commit has no number
169        None
170    } else {
171        // Extract the bounds of the last number
172        let end = last_close.unwrap();
173        let start = last_open.unwrap() + "(#".len();
174
175        // Parse it to a number
176        let num = line.get(start..end).map(|s| s.parse().ok());
177
178        // If valid, we have a number
179        num.unwrap_or(None)
180    }
181}
182
183/// Parse an individual message line
184fn parse_line(line: &str) -> Line {
185    // Parse the tags in the line
186    match tagged_change(line) {
187        // If parser succeeded, we have our line
188        IResult::Done(_, l) => l,
189
190        // If parser fails, draw a blank
191        _ => Line::default(),
192    }
193}
194
195/// A change message line is one of the following types
196named!(tagged_change<&str, Line>,
197       alt!(with_category
198           | with_category_scope
199           | with_category_text
200           | with_category_scope_text
201           | with_text
202       ));
203
204/// A line that has just a simple change (no tags).
205named!(with_text<&str, Line>,
206       do_parse!(opt!(tag!("-")) >>
207                 text: whatever >>
208                 (Line{
209                     scope: None,
210                     category: None,
211                     text: Some(text)
212                 })));
213
214/// A line that has just a category
215named!(with_category<&str, Line>,
216       do_parse!(
217           tag!("-") >> category: tagname >>
218               tag!(":") >> eof!() >>
219               (Line{
220                   scope: None,
221                   category: Some(category),
222                   text: None
223               })));
224
225/// A line that has just a category and scope, but no change text
226named!(with_category_scope<&str, Line>,
227       do_parse!(
228           tag!("-") >> category: tagname >>
229               tag!("(") >> scope: tagname >>
230               tag!("):") >> eof!() >>
231               (Line{
232                   scope: Some(scope),
233                   category: Some(category),
234                   text: None
235               })));
236
237/// A line that has a category and a change text, but no scope.
238named!(with_category_text<&str, Line>,
239       do_parse!(
240           tag!("-") >> category: tagname >>
241               tag!(":") >> text: whatever >>
242               (Line{
243                   scope: None,
244                   category: Some(category),
245                   text: Some(text)
246               })));
247
248/// A line that has everything, i.e. category, scope and a change.
249named!(with_category_scope_text<&str, Line>,
250       do_parse!(
251           tag!("-") >> category: tagname >>
252               tag!("(") >> scope: tagname >>
253               tag!("):") >> text: whatever >>
254               (Line{
255                   scope: Some(scope),
256                   category: Some(category),
257                   text: Some(text)
258                })));
259
260/// Consume whatever is left and return a String
261named!(whatever<&str, String>,
262       map!(take_while1_s!(|_| true), String::from));
263
264/// Consume an acceptable tag name and return a String
265named!(tagname<&str, String>,
266       map!(ws!(take_while1_s!(|c| is_alphanumeric(c as u8))), str::to_lowercase));
267
268#[cfg(test)]
269mod tests {
270    #[test]
271    fn commit_fetch() {
272        use super::{Commit, CommitList};
273        let head = Commit::from("2c5dda2e");
274        let list = CommitList::from("2c5dda2e^..2c5dda2e");
275        assert_eq!(list.to_string(), "2c5dda2e^..2c5dda2e (1 commits)");
276        let also_head = list.into_iter().next().unwrap();
277        assert_eq!(head.sha, also_head.sha);
278        assert!(head.to_string().starts_with("2c5dda2e"));
279    }
280
281    #[test]
282    fn negative() {
283        assert!(super::Commit::from("no-such-commit").summary.is_empty());
284        assert_eq!(super::CommitList::from("bad-range").into_iter().count(), 0);
285    }
286
287    #[test]
288    fn commit_lines() {
289        use super::Commit;
290        let reference = &Commit::from("2c5dda2e5ec6d0ad7abdcd20661bf2cb846ee5f2");
291        assert_eq!(reference.into_iter().count(), 17);
292    }
293
294    #[test]
295    fn commit_parse_summary() {
296        use super::{parse_number, parse_subject};
297
298        // most common - simple PR merge
299        let message = "foo bar (#123)";
300        assert_eq!(parse_subject(message), "foo bar");
301        assert_eq!(parse_number(message), Some(123));
302
303        // not a PR merge
304        let message = "foo bar ()()";
305        assert_eq!(parse_subject(message), message);
306        assert_eq!(parse_number(message), None);
307
308        // cherry-picked multi-PR commits
309        let message = "foo bar #123 (#101)(#103)";
310        assert_eq!(parse_subject(message), "foo bar #123");
311        assert_eq!(parse_number(message), Some(103));
312    }
313
314    #[test]
315    fn commit_parse_line() {
316        use commit::parse_line;
317
318        let line = parse_line("- break(shell): foo bar");
319        assert_eq!(Some(String::from("shell")), line.scope);
320        assert_eq!(Some(String::from("break")), line.category);
321        assert_eq!(Some(String::from(" foo bar")), line.text);
322
323        let line = parse_line("-BREAK ( Shell ): foo bar");
324        assert_eq!(Some(String::from("shell")), line.scope);
325        assert_eq!(Some(String::from("break")), line.category);
326        assert_eq!(Some(String::from(" foo bar")), line.text);
327
328        let line = parse_line("- break(shell):");
329        assert_eq!(Some(String::from("shell")), line.scope);
330        assert_eq!(Some(String::from("break")), line.category);
331        assert_eq!(None, line.text);
332
333        let line = parse_line("- break ( SHELL ):");
334        assert_eq!(Some(String::from("shell")), line.scope);
335        assert_eq!(Some(String::from("break")), line.category);
336        assert_eq!(None, line.text);
337
338        let line = parse_line("-fix:");
339        assert_eq!(None, line.scope);
340        assert_eq!(Some(String::from("fix")), line.category);
341        assert_eq!(None, line.text);
342
343        let line = parse_line("- fix: foo bar");
344        assert_eq!(None, line.scope);
345        assert_eq!(Some(String::from("fix")), line.category);
346        assert_eq!(Some(String::from(" foo bar")), line.text);
347
348        let line = parse_line("- FIX  : foo bar");
349        assert_eq!(None, line.scope);
350        assert_eq!(Some(String::from("fix")), line.category);
351        assert_eq!(Some(String::from(" foo bar")), line.text);
352
353        let line = parse_line("- foo bar");
354        assert_eq!(None, line.scope);
355        assert_eq!(None, line.category);
356        assert_eq!(Some(String::from(" foo bar")), line.text);
357
358        let line = parse_line("foo bar");
359        assert_eq!(None, line.scope);
360        assert_eq!(None, line.category);
361        assert_eq!(Some(String::from("foo bar")), line.text);
362
363        let line = parse_line("");
364        assert_eq!(None, line.text);
365        assert_eq!(None, line.scope);
366        assert_eq!(None, line.category);
367    }
368}