1use crate::commit::Commit;
2use crate::config::{GitConfig, ProcessingStep};
3use crate::error::{Error as AppError, Result};
4use crate::summary::Summary;
5
6pub struct CommitProcessor<'cfg, 'sum> {
8 config: &'cfg GitConfig,
9 summary: &'sum mut Summary,
10}
11
12impl<'cfg, 'sum> CommitProcessor<'cfg, 'sum> {
13 #[must_use]
15 pub fn new(config: &'cfg GitConfig, summary: &'sum mut Summary) -> Self {
16 Self { config, summary }
17 }
18
19 pub fn run<'a>(&mut self, commits: &mut Vec<Commit<'a>>) -> Result<()> {
21 if let Some(order) = &self.config.processing_order {
22 self.run_with_order(commits, order);
23 } else {
24 self.run_legacy(commits);
25 }
26
27 if self.config.require_conventional {
28 self.check_conventional_commits(commits)?;
29 }
30 if self.config.fail_on_unmatched_commit {
31 self.check_unmatched_commits(commits)?;
32 }
33
34 Ok(())
35 }
36
37 fn run_with_order<'a>(&mut self, commits: &mut Vec<Commit<'a>>, order: &[ProcessingStep]) {
39 for step in order {
40 match step {
41 ProcessingStep::CommitPreprocessors => self.apply_commit_preprocessors(commits),
42 ProcessingStep::SplitCommits => self.apply_split_commits(commits),
43 ProcessingStep::ConventionalCommits => self.apply_conventional_commits(commits),
44 ProcessingStep::CommitParsers => self.apply_commit_parsers(commits),
45 ProcessingStep::LinkParsers => self.apply_link_parsers(commits),
46 }
47 }
48 }
49
50 fn run_legacy<'a>(&mut self, commits: &mut Vec<Commit<'a>>) {
52 let mut processed = Vec::new();
53 for commit in commits.iter() {
54 if let Some(commit) = self.process_single_commit(commit) {
55 if self.config.split_commits {
56 for line in commit.message.lines() {
57 let mut split_commit = commit.clone();
58 split_commit.message = line.to_string();
59 split_commit.links.clear();
60 if split_commit.message.is_empty() {
61 continue;
62 }
63 if let Some(split_commit) = self.process_single_commit(&split_commit) {
64 processed.push(split_commit);
65 }
66 }
67 } else {
68 processed.push(commit);
69 }
70 }
71 }
72 *commits = processed;
73 }
74
75 fn apply_commit_preprocessors<'a>(&mut self, commits: &mut Vec<Commit<'a>>) {
77 let mut processed = Vec::new();
78 for commit in commits.iter() {
79 match commit.clone().preprocess(&self.config.commit_preprocessors) {
80 Ok(commit) => {
81 self.summary.record_ok();
82 processed.push(commit);
83 }
84 Err(error) => {
85 self.summary.record_err(&error);
86 self.on_processing_error(commit, &error);
87 }
88 }
89 }
90 *commits = processed;
91 }
92
93 fn apply_split_commits<'a>(&mut self, commits: &mut Vec<Commit<'a>>) {
95 if !self.config.split_commits {
96 return;
97 }
98 let mut split_commits = Vec::new();
99 for commit in commits.iter() {
100 for line in commit.message.lines() {
101 if line.is_empty() {
102 continue;
103 }
104 let mut split_commit = commit.clone();
105 split_commit.message = line.to_string();
106 split_commit.links.clear();
107 split_commits.push(split_commit);
108 }
109 }
110 *commits = split_commits;
111 }
112
113 fn apply_conventional_commits<'a>(&mut self, commits: &mut Vec<Commit<'a>>) {
115 let mut processed = Vec::new();
116 for commit in commits.iter() {
117 if !self.config.conventional_commits {
118 self.summary.record_ok();
119 processed.push(commit.clone());
120 continue;
121 }
122
123 if !self.config.require_conventional &&
124 self.config.filter_unconventional &&
125 !self.config.split_commits
126 {
127 match commit.clone().into_conventional() {
128 Ok(commit) => {
129 self.summary.record_ok();
130 processed.push(commit);
131 }
132 Err(error) => {
133 self.summary.record_err(&error);
134 self.on_processing_error(commit, &error);
135 }
136 }
137 } else {
138 match commit.clone().into_conventional() {
139 Ok(commit) => {
140 self.summary.record_ok();
141 processed.push(commit);
142 }
143 Err(_) => {
144 self.summary.record_ok();
145 processed.push(commit.clone());
146 }
147 }
148 }
149 }
150 *commits = processed;
151 }
152
153 fn apply_commit_parsers<'a>(&mut self, commits: &mut Vec<Commit<'a>>) {
155 let mut processed = Vec::new();
156 for commit in commits.iter() {
157 match commit.clone().parse(
158 &self.config.commit_parsers,
159 self.config.protect_breaking_commits,
160 self.config.filter_commits,
161 ) {
162 Ok(commit) => {
163 self.summary.record_ok();
164 processed.push(commit);
165 }
166 Err(error) => {
167 self.summary.record_err(&error);
168 self.on_processing_error(commit, &error);
169 }
170 }
171 }
172 *commits = processed;
173 }
174
175 fn apply_link_parsers<'a>(&mut self, commits: &mut Vec<Commit<'a>>) {
177 let mut processed = Vec::new();
178 for commit in commits.iter() {
179 processed.push(commit.clone().parse_links(&self.config.link_parsers));
180 }
181 *commits = processed;
182 }
183
184 fn process_single_commit<'a>(&mut self, commit: &Commit<'a>) -> Option<Commit<'a>> {
186 match commit.process(self.config) {
187 Ok(commit) => {
188 self.summary.record_ok();
189 Some(commit)
190 }
191 Err(error) => {
192 self.summary.record_err(&error);
193 self.on_processing_error(commit, &error);
194 None
195 }
196 }
197 }
198
199 fn check_conventional_commits(&self, commits: &[Commit<'_>]) -> Result<()> {
201 tracing::debug!("Verifying that all commits are conventional");
202 let mut unconventional_count = 0;
203 commits.iter().for_each(|commit| {
204 if commit.conv.is_none() {
205 tracing::error!(
206 "Commit {id} is not conventional:\n{message}",
207 id = &commit.id[..7],
208 message = commit
209 .message
210 .lines()
211 .map(|line| { format!(" | {}", line.trim()) })
212 .collect::<Vec<String>>()
213 .join("\n")
214 );
215 unconventional_count += 1;
216 }
217 });
218
219 if unconventional_count > 0 {
220 return Err(AppError::UnconventionalCommitsError(unconventional_count));
221 }
222 Ok(())
223 }
224
225 fn check_unmatched_commits(&self, commits: &[Commit<'_>]) -> Result<()> {
227 tracing::debug!("Verifying that no commits are unmatched by commit parsers");
228 let mut unmatched_count = 0;
229 commits.iter().for_each(|commit| {
230 let is_unmatched = commit.group.is_none();
231 if is_unmatched {
232 tracing::error!(
233 "Commit {id} was not matched by any commit parser:\n{message}",
234 id = &commit.id[..7],
235 message = commit
236 .message
237 .lines()
238 .map(|line| { format!(" | {}", line.trim()) })
239 .collect::<Vec<String>>()
240 .join("\n")
241 );
242 unmatched_count += 1;
243 }
244 });
245
246 if unmatched_count > 0 {
247 return Err(AppError::UnmatchedCommitsError(unmatched_count));
248 }
249 Ok(())
250 }
251
252 fn on_processing_error(&self, commit: &Commit<'_>, error: &AppError) {
254 let short_id = commit.id.chars().take(7).collect::<String>();
255 let summary = commit.message.lines().next().unwrap_or_default().trim();
256 tracing::trace!("{short_id} - {error} ({summary})");
257 }
258}
259
260#[cfg(test)]
261mod test {
262 use regex::Regex;
263
264 use super::*;
265 use crate::config::{CommitParser, ProcessingStep};
266
267 #[test]
268 fn list_keeps_legacy_behavior_when_order_is_unset() -> Result<()> {
269 let mut commits = vec![Commit::new(
270 String::from("123123"),
271 String::from("chore(ci): update runner\nfix(ci): restore build"),
272 )];
273 let cfg = crate::config::GitConfig {
274 processing_order: None,
275 conventional_commits: true,
276 split_commits: true,
277 filter_commits: true,
278 commit_parsers: vec![
279 CommitParser {
280 sha: None,
281 message: Regex::new("^chore").ok(),
282 body: None,
283 footer: None,
284 group: None,
285 default_scope: None,
286 scope: None,
287 skip: Some(true),
288 field: None,
289 pattern: None,
290 },
291 CommitParser {
292 sha: None,
293 message: Regex::new("^fix").ok(),
294 body: None,
295 footer: None,
296 group: Some(String::from("fix")),
297 default_scope: None,
298 scope: None,
299 skip: None,
300 field: None,
301 pattern: None,
302 },
303 ],
304 ..Default::default()
305 };
306
307 CommitProcessor::new(&cfg, &mut Summary::default()).run(&mut commits)?;
308 assert!(commits.is_empty());
309
310 Ok(())
311 }
312
313 #[test]
314 fn list_supports_ordered_split_before_parsing() -> Result<()> {
315 let mut commits = vec![Commit::new(
316 String::from("123123"),
317 String::from("chore(ci): update runner\nfix(ci): restore build"),
318 )];
319 let cfg = crate::config::GitConfig {
320 processing_order: Some(vec![
321 ProcessingStep::CommitPreprocessors,
322 ProcessingStep::SplitCommits,
323 ProcessingStep::ConventionalCommits,
324 ProcessingStep::CommitParsers,
325 ProcessingStep::LinkParsers,
326 ]),
327 conventional_commits: true,
328 split_commits: true,
329 filter_commits: true,
330 commit_parsers: vec![
331 CommitParser {
332 sha: None,
333 message: Regex::new("^chore").ok(),
334 body: None,
335 footer: None,
336 group: None,
337 default_scope: None,
338 scope: None,
339 skip: Some(true),
340 field: None,
341 pattern: None,
342 },
343 CommitParser {
344 sha: None,
345 message: Regex::new("^fix").ok(),
346 body: None,
347 footer: None,
348 group: Some(String::from("fix")),
349 default_scope: None,
350 scope: None,
351 skip: None,
352 field: None,
353 pattern: None,
354 },
355 ],
356 ..Default::default()
357 };
358
359 CommitProcessor::new(&cfg, &mut Summary::default()).run(&mut commits)?;
360 assert_eq!(commits.len(), 1);
361 assert_eq!(commits[0].group.as_deref(), Some("fix"));
362 assert_eq!(commits[0].message, "fix(ci): restore build");
363
364 Ok(())
365 }
366}