Skip to main content

git_cliff_core/
process.rs

1use crate::commit::Commit;
2use crate::config::{GitConfig, ProcessingStep};
3use crate::error::{Error as AppError, Result};
4use crate::summary::Summary;
5
6/// Stateful commit-processing pipeline.
7pub struct CommitProcessor<'cfg, 'sum> {
8    config: &'cfg GitConfig,
9    summary: &'sum mut Summary,
10}
11
12impl<'cfg, 'sum> CommitProcessor<'cfg, 'sum> {
13    /// Creates a processor bound to config and summary output.
14    #[must_use]
15    pub fn new(config: &'cfg GitConfig, summary: &'sum mut Summary) -> Self {
16        Self { config, summary }
17    }
18
19    /// Runs commit processing and final validation checks.
20    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    /// Applies commit processing steps in the configured linear order.
38    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    /// Preserves the historical non-linear processing flow for compatibility.
51    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    /// Applies commit preprocessors to all commits.
76    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    /// Splits commit messages by line when `split_commits` is enabled.
94    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    /// Parses commits as conventional according to current config rules.
114    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    /// Applies commit parsers for grouping/filtering.
154    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    /// Applies link parsers without filtering commits.
176    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    /// Processes one commit with the legacy single-pass pipeline.
185    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    /// Validates that all processed commits are conventional.
200    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    /// Validates that all processed commits matched at least one parser.
226    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    /// Emits a trace log entry for a commit-processing failure.
253    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}