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}