1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
// Copyright 2017 Aldrin J D'Souza.
// Licensed under the MIT License <https://opensource.org/licenses/MIT>

/// Commit parser
use std::str;
use chrono::{DateTime, Local};
use nom::{is_alphanumeric, IResult};

/// A single commit
#[derive(Serialize, Debug)]
pub struct Commit {
    /// The change SHA
    pub sha: String,

    /// The change author
    pub author: String,

    /// The change timestamp
    pub time: String,

    /// The change summary
    pub summary: String,

    /// The change number
    pub number: Option<u32>,

    /// The message lines
    pub lines: Vec<Line>,
}

/// A single line in the change message
#[derive(Default, Serialize, Debug)]
pub struct Line {
    /// The scope
    pub scope: Option<String>,

    /// The category
    pub category: Option<String>,

    /// The text
    pub text: Option<String>,
}

/// Parse the commit message to a Commit object
pub fn parse(lines: &[String], dt_format: &str) -> Commit {

    // Parsed commit
    Commit {
        // First line is the SHA
        sha: lines[0].clone(),

        // Second line is the author
        author: lines[1].clone(),

        // An optional change number (e.g. PR, issue number in Github)
        number: parse_number(&lines[3]),

        // The change message summary
        summary: parse_subject(&lines[3]),

        // Parse the timestamp
        time: parse_time(&lines[2], dt_format),

        // Parse the rest of the message lines
        lines: lines[4..].iter().map(|s| parse_line(s)).collect(),
    }
}

/// Parse the commit timestamp to local time
pub fn parse_time(line: &str, format: &str) -> String {

    // Parse the git timestamp string
    DateTime::parse_from_rfc2822(line)

        // Convert to local time zone
        .map(|t| t.with_timezone(&Local))

        // If that fails, assume "now"
        .unwrap_or_else(|_| Local::now())

        // Render with the given format 
        .format(format)

        // Done
        .to_string()
}

/// Parse the commit subject removing the numbers tags.
pub fn parse_subject(line: &str) -> String {

    // Find the first number opener on the commit subject
    let first_open = line.find("(#").unwrap_or_else(|| line.len());

    // Everything up to the first number opener is the subject
    String::from(line.get(0..first_open).unwrap_or_else(|| line).trim())
}

/// Parse the commit number
pub fn parse_number(line: &str) -> Option<u32> {

    // The commit number is the last number on the subject
    let last_open = line.rfind("(#");

    // The last number opener
    let last_close = line.rfind(')');

    // If no number found on subject line
    if last_open.is_none() || last_close.is_none() {

        // The commit has no number
        None
    } else {

        // Extract the bounds of the last number
        let end = last_close.unwrap();
        let start = last_open.unwrap() + "(#".len();

        // Parse it to a number
        let num = line.get(start..end).map(|s| s.parse().ok());

        // If valid, we have a number
        num.unwrap_or(None)
    }
}

/// Parse an individual message line
pub fn parse_line(line: &str) -> Line {

    // Parse the tags in the line
    match tagged_change(line) {

        // If parser succeeded, we have our line
        IResult::Done(_, l) => l,

        // If parser fails, draw a blank
        _ => Line::default(),
    }
}

// -- nom parsers follow -- //

/// A change message line is one of the following types
named!(tagged_change<&str, Line>,
       alt!(
           category_scope_change
               | category_scope
               | category_change
               | just_category
               | just_change
       ));

/// A line that has everything, i.e. category, scope and a change.
named!(category_scope_change<&str, Line>,
       do_parse!(
           tag!("-") >> category: tagname >>
               tag!("(") >> scope: tagname >>
               tag!("):") >> text: whatever >>
               (Line{
                   scope: Some(scope),
                   category: Some(category),
                   text: Some(text)
                })));

/// A line that has just a category and scope, but no text
named!(category_scope<&str, Line>,
       do_parse!(
           tag!("-") >> category: tagname >>
               tag!("(") >> scope: tagname >>
               tag!("):") >>
               (Line{
                   scope: Some(scope),
                   category: Some(category),
                   text: None
               })));

/// A line that has just a category
named!(just_category<&str, Line>,
       do_parse!(
           tag!("-") >> category: tagname >>
               tag!(":") >>
               (Line{
                   scope: None,
                   category: Some(category),
                   text: None
               })));

/// A line that has a category and a change, but no scope.
named!(category_change<&str, Line>,
       do_parse!(
           tag!("-") >> category: tagname >>
               tag!(":") >> text: whatever >>
               (Line{
                   scope: None,
                   category: Some(category),
                   text: Some(text)
               })));

/// A line that has just a simple change (no tags).
named!(just_change<&str, Line>,
       do_parse!(opt!(tag!("-")) >>
                 text: whatever >>
                 (Line{
                     scope: None,
                     category: None,
                     text: Some(text)
                 })));

/// Consume whatever is left and return a String
named!(whatever<&str, String>,
       map!(take_while1_s!(|_| true), String::from));

/// Consume an acceptable tag name and return a String
named!(tagname<&str, String>,
       map!(ws!(take_while1_s!(|c| is_alphanumeric(c as u8))), str::to_lowercase));