changelog/
changelog.rs

1// Copyright 2017-2018 by Aldrin J D'Souza.
2// Licensed under the MIT License <https://opensource.org/licenses/MIT>
3
4use git;
5use std::str;
6use commit::{Commit, CommitList, Line};
7use std::collections::HashMap;
8use chrono::MIN_DATE;
9use chrono::prelude::*;
10use input::{Configuration, Conventions};
11
12/// A categorized changelog
13#[derive(Debug, Default, Serialize, Eq, PartialEq)]
14pub struct ChangeLog {
15    /// A list of scoped changes in the commit range.
16    pub scopes: Vec<Scope>,
17
18    /// A list of "interesting" commits in the range.
19    pub commits: Vec<Commit>,
20
21    /// The fetch url of the remote (useful for change number links)
22    pub remote_url: Option<String>,
23
24    /// The revision range for commits in this changelog
25    pub range: String,
26
27    /// The time range for the commits in this changelog
28    pub date: String,
29}
30
31/// Changes grouped by scope (e.g. "API", "Documentation", etc.).
32#[derive(Debug, Default, Serialize, Eq, PartialEq)]
33pub struct Scope {
34    /// The title of the scope, as defined in [`Conventions`](struct.Conventions.html).
35    pub title: String,
36
37    /// A list of categorized changes in this scope
38    pub categories: Vec<Category>,
39}
40
41/// Changes grouped by categories (e.g. "Fixes", "Breaking Changes", etc.).
42#[derive(Debug, Default, Serialize, Eq, PartialEq)]
43pub struct Category {
44    /// The title of the category, as defined in [`Conventions`](struct.Conventions.html).
45    pub title: String,
46
47    /// A list of changes in this category groups across all commits in range.
48    pub changes: Vec<String>,
49}
50
51impl ChangeLog {
52    /// Generate a new changelog for the default input range
53    pub fn new() -> Self {
54        Self::from_log(Vec::new(), &Configuration::new())
55    }
56
57    /// Generate a changelog for the given range
58    pub fn from_range(range: &str, config: &Configuration) -> Self {
59        Self::from_log(vec![range.to_string()], config)
60    }
61
62    /// Create a changelog from the given `git log` arguments
63    pub fn from_log(mut args: Vec<String>, config: &Configuration) -> Self {
64        // The default `git log` behavior is to list _all_ commits
65        if args.is_empty() {
66            // The default `git changelog` behavior is to list _all_ commits since last tag.
67            if let Ok(Some(tag)) = git::last_tag() {
68                args.push(format!("{}..HEAD", tag))
69            } else {
70                // If there are no tags, default to the last commit
71                args.push(String::from("HEAD^..HEAD"))
72            }
73        }
74
75        let header = args.join(" ");
76        let range = CommitList::from(args);
77        info!("Using revision range '{}'", range);
78
79        // Compute the change log
80        let mut log = Self::from(range, config);
81
82        // Record the range we used (it is used by the template)
83        log.range = header;
84
85        // Done.
86        log
87    }
88
89    /// Create a changelog from the given commits using the given conventions
90    pub fn from<T: Iterator<Item = Commit>>(commits: T, config: &Configuration) -> Self {
91        // Initialize a intermediate raw report
92        let mut raw = RawReport::new();
93
94        // Initialize the final change log
95        let mut changelog = ChangeLog::default();
96
97        // Walk through each commit in the range
98        for commit in commits {
99            // Offer it to the raw report
100            if raw.add(&commit, &config.conventions) {
101                // Inform the user we're picking this one
102                trace!("Interesting commit {}", commit);
103
104                // Add it to the final list
105                changelog.commits.push(commit);
106            } else {
107                // Inform the user we're ignoring this one
108                debug!("No interesting changes in commit {}", &commit);
109            }
110        }
111
112        // Prepare the final report
113        for scope in config.conventions.scope_titles() {
114            if let Some(mut categorized) = raw.slots.remove(&scope) {
115                let title = scope.to_owned();
116                let mut categories = Vec::new();
117
118                for category in config.conventions.category_titles() {
119                    let title = category.to_owned();
120                    if let Some(mut changes) = categorized.remove(&category) {
121                        categories.push(Category { title, changes });
122                    }
123                }
124                changelog.scopes.push({ Scope { title, categories } })
125            }
126        }
127
128        // Add the remote url, if we have one (it's used by links to commits and PRs)
129        let remote = match config.output.remote {
130            Some(ref r) => r,
131            None => "origin",
132        };
133        changelog.remote_url = git::get_remote_url(remote).unwrap_or(None);
134
135        // Add the last change date
136        changelog.date = raw.date.format("%Y-%m-%d").to_string();
137
138        changelog
139    }
140}
141
142/// Raw report
143struct RawReport<'a> {
144    /// The date of the last change in the range
145    date: Date<Utc>,
146    /// Placeholder slots for aggregation
147    slots: HashMap<&'a str, HashMap<&'a str, Vec<String>>>,
148}
149
150impl<'a> RawReport<'a> {
151    /// Initialize a new report
152    fn new() -> Self {
153        Self {
154            date: MIN_DATE,
155            slots: HashMap::default(),
156        }
157    }
158
159    /// A the given commit to the report with the given conventions
160    fn add<'c>(&mut self, commit: &'c Commit, conventions: &'a Conventions) -> bool {
161        // Track if this commit brought anything interesting
162        let mut interesting = false;
163
164        // The running current line
165        let mut current = Line::default();
166
167        // Take each line
168        for line in commit {
169            // If the line is categorized
170            if line.category.is_some() {
171                // close the current active line
172                interesting |= self.record(current, conventions);
173
174                // and reset it to a clean slate
175                current = Line::default();
176                current.scope = line.scope;
177                current.category = line.category;
178            }
179
180            // If we don't have any text yet
181            if current.text.is_none() {
182                // Initialize it with this line's text
183                current.text = line.text
184            } else if let Some(mut text) = current.text.as_mut() {
185                // Append this line text to the current text
186                text.push('\n');
187                text.push_str(&line.text.unwrap_or_default());
188            }
189        }
190
191        // We've read all the lines, close the running current
192        interesting |= self.record(current, conventions);
193
194        // Update the report time
195        if let Ok(time) = DateTime::parse_from_rfc2822(&commit.time) {
196            // Normalize commit timezones
197            let date = time.with_timezone(&Utc).date();
198
199            // If the commit date is after the current last date
200            if date > self.date {
201                // Update the report date
202                self.date = date;
203            }
204        }
205
206        // Done
207        interesting
208    }
209
210    /// Record the current line into the report
211    fn record(&mut self, current: Line, conventions: &'a Conventions) -> bool {
212        // Get the titles and for the current scope and category
213        let scope = conventions.scope_title(current.scope);
214        let category = conventions.category_title(current.category);
215
216        // If the titles are missing, the user is not interested in these changes
217        let interesting = category.is_some() && scope.is_some() && current.text.is_some();
218
219        // If the line is interesting
220        if interesting {
221            // Put it in its place
222            self.slots
223                .entry(scope.unwrap())
224                .or_insert_with(HashMap::new)
225                .entry(category.unwrap())
226                .or_insert_with(Vec::new)
227                .push(current.text.unwrap());
228        }
229
230        // Done
231        interesting
232    }
233}