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 r#continue: None,
289 field: None,
290 pattern: None,
291 },
292 CommitParser {
293 sha: None,
294 message: Regex::new("^fix").ok(),
295 body: None,
296 footer: None,
297 group: Some(String::from("fix")),
298 default_scope: None,
299 scope: None,
300 skip: None,
301 r#continue: None,
302 field: None,
303 pattern: None,
304 },
305 ],
306 ..Default::default()
307 };
308
309 CommitProcessor::new(&cfg, &mut Summary::default()).run(&mut commits)?;
310 assert!(commits.is_empty());
311
312 Ok(())
313 }
314
315 #[test]
316 fn list_supports_ordered_split_before_parsing() -> Result<()> {
317 let mut commits = vec![Commit::new(
318 String::from("123123"),
319 String::from("chore(ci): update runner\nfix(ci): restore build"),
320 )];
321 let cfg = crate::config::GitConfig {
322 processing_order: Some(vec![
323 ProcessingStep::CommitPreprocessors,
324 ProcessingStep::SplitCommits,
325 ProcessingStep::ConventionalCommits,
326 ProcessingStep::CommitParsers,
327 ProcessingStep::LinkParsers,
328 ]),
329 conventional_commits: true,
330 split_commits: true,
331 filter_commits: true,
332 commit_parsers: vec![
333 CommitParser {
334 sha: None,
335 message: Regex::new("^chore").ok(),
336 body: None,
337 footer: None,
338 group: None,
339 default_scope: None,
340 scope: None,
341 skip: Some(true),
342 r#continue: None,
343 field: None,
344 pattern: None,
345 },
346 CommitParser {
347 sha: None,
348 message: Regex::new("^fix").ok(),
349 body: None,
350 footer: None,
351 group: Some(String::from("fix")),
352 default_scope: None,
353 scope: None,
354 skip: None,
355 r#continue: None,
356 field: None,
357 pattern: None,
358 },
359 ],
360 ..Default::default()
361 };
362
363 CommitProcessor::new(&cfg, &mut Summary::default()).run(&mut commits)?;
364 assert_eq!(commits.len(), 1);
365 assert_eq!(commits[0].group.as_deref(), Some("fix"));
366 assert_eq!(commits[0].message, "fix(ci): restore build");
367
368 Ok(())
369 }
370}