Skip to main content

git_cliff_core/
summary.rs

1use std::fmt::Display;
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::error::Error as AppError;
7
8/// Represents the category of errors that may occur while processing a commit.
9#[non_exhaustive]
10#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
11#[serde(rename_all = "camelCase")]
12pub enum CommitProcessingErrorKind {
13    /// Occurs while spawning or interacting with an OS command via a preprocessor.
14    Io,
15    /// Occurs when parsing a commit message into a conventional commit fails.
16    Parse,
17    /// Occurs when serializing or deserializing data to or from JSON fails.
18    Json,
19    /// Occurs when a referenced commit field is missing or has an unsupported type.
20    Field,
21    /// Occurs when a commit does not match any grouping rule.
22    Group,
23    /// Occurs when a commit is skipped intentionally due to configuration.
24    Skipped,
25    /// Occurs when an error does not fit into any other defined category.
26    Other,
27}
28
29impl From<AppError> for CommitProcessingErrorKind {
30    fn from(err: AppError) -> Self {
31        CommitProcessingErrorKind::from(&err)
32    }
33}
34
35impl From<&AppError> for CommitProcessingErrorKind {
36    fn from(err: &AppError) -> Self {
37        match err {
38            AppError::IoError(_) => CommitProcessingErrorKind::Io,
39            AppError::ParseError(_) => CommitProcessingErrorKind::Parse,
40            AppError::JsonError(_) => CommitProcessingErrorKind::Json,
41            AppError::FieldError(_) => CommitProcessingErrorKind::Field,
42            AppError::GroupError(msg) if msg.contains("Skipping commit") => {
43                CommitProcessingErrorKind::Skipped
44            }
45            AppError::GroupError(_) => CommitProcessingErrorKind::Group,
46            _ => CommitProcessingErrorKind::Other,
47        }
48    }
49}
50
51impl Display for CommitProcessingErrorKind {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        let s = match self {
54            CommitProcessingErrorKind::Io => "I/O error",
55            CommitProcessingErrorKind::Parse => "parse error",
56            CommitProcessingErrorKind::Json => "JSON error",
57            CommitProcessingErrorKind::Field => "field error",
58            CommitProcessingErrorKind::Group => "grouping error",
59            CommitProcessingErrorKind::Skipped => "intentionally skipped commit",
60            CommitProcessingErrorKind::Other => "other error",
61        };
62        f.write_str(s)
63    }
64}
65
66impl CommitProcessingErrorKind {
67    /// Whether this error kind should be surfaced as a warning summary.
68    #[must_use]
69    pub fn should_warn(self) -> bool {
70        matches!(
71            self,
72            CommitProcessingErrorKind::Io |
73                CommitProcessingErrorKind::Parse |
74                CommitProcessingErrorKind::Json |
75                CommitProcessingErrorKind::Field |
76                CommitProcessingErrorKind::Group |
77                CommitProcessingErrorKind::Other
78        )
79    }
80}
81
82/// Aggregated summary of commit processing results for a changelog.
83#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct Summary {
86    /// The total number of commits that were processed.
87    pub processed: usize,
88    /// The number of commits grouped by processing error kind.
89    ///
90    /// Each entry represents how many commits fell into a particular
91    /// [`CommitProcessingErrorKind`] during processing.
92    pub by_kind: IndexMap<CommitProcessingErrorKind, usize>,
93}
94
95impl Summary {
96    /// Records a successfully processed commit.
97    pub fn record_ok(&mut self) {
98        self.processed += 1;
99    }
100
101    /// Records a failed or skipped commit.
102    pub fn record_err(&mut self, err: &AppError) {
103        self.processed += 1;
104        let kind = CommitProcessingErrorKind::from(err);
105        *self.by_kind.entry(kind).or_insert(0) += 1;
106    }
107}
108
109#[cfg(test)]
110mod test {
111    use std::io::Error as StdIoError;
112
113    use git_conventional::Commit;
114
115    use super::*;
116    use crate::error::Error as AppError;
117
118    #[test]
119    fn commit_processing_error_kind_from_app_error() {
120        let err = AppError::IoError(StdIoError::other("something went wrong".to_string()));
121        let kind = CommitProcessingErrorKind::from(&err);
122        assert_eq!(kind, CommitProcessingErrorKind::Io);
123
124        let err = Commit::parse("")
125            .map_err(AppError::ParseError)
126            .expect_err("expected parse error");
127        let kind = CommitProcessingErrorKind::from(&err);
128        assert_eq!(kind, CommitProcessingErrorKind::Parse);
129
130        let err = serde_json::from_str::<serde_json::Value>("{ invalid json }")
131            .map_err(AppError::from)
132            .expect_err("expected JSON parse error");
133        let kind = CommitProcessingErrorKind::from(&err);
134        assert_eq!(kind, CommitProcessingErrorKind::Json);
135
136        let err = AppError::FieldError("missing field".into());
137        let kind = CommitProcessingErrorKind::from(&err);
138        assert_eq!(kind, CommitProcessingErrorKind::Field);
139
140        let err = AppError::GroupError("no matching group".into());
141        let kind = CommitProcessingErrorKind::from(&err);
142        assert_eq!(kind, CommitProcessingErrorKind::Group);
143
144        let err = AppError::GroupError("Skipping commit due to config".into());
145        let kind = CommitProcessingErrorKind::from(&err);
146        assert_eq!(kind, CommitProcessingErrorKind::Skipped);
147
148        let err = AppError::UnmatchedCommitsError(1);
149        let kind = CommitProcessingErrorKind::from(&err);
150        assert_eq!(kind, CommitProcessingErrorKind::Other);
151    }
152
153    #[test]
154    fn commit_processing_error_kind_from_app_error_owned() {
155        let err = AppError::IoError(StdIoError::other("something went wrong".to_string()));
156        let kind: CommitProcessingErrorKind = err.into();
157        assert_eq!(kind, CommitProcessingErrorKind::Io);
158
159        let err = Commit::parse("")
160            .map_err(AppError::ParseError)
161            .expect_err("expected parse error");
162        let kind: CommitProcessingErrorKind = err.into();
163        assert_eq!(kind, CommitProcessingErrorKind::Parse);
164
165        let err = serde_json::from_str::<serde_json::Value>("{ invalid json }")
166            .map_err(AppError::from)
167            .expect_err("expected JSON parse error");
168        let kind: CommitProcessingErrorKind = err.into();
169        assert_eq!(kind, CommitProcessingErrorKind::Json);
170
171        let err = AppError::FieldError("missing field".into());
172        let kind: CommitProcessingErrorKind = err.into();
173        assert_eq!(kind, CommitProcessingErrorKind::Field);
174
175        let err = AppError::GroupError("no matching group".into());
176        let kind: CommitProcessingErrorKind = err.into();
177        assert_eq!(kind, CommitProcessingErrorKind::Group);
178
179        let err = AppError::GroupError("Skipping commit due to config".into());
180        let kind: CommitProcessingErrorKind = err.into();
181        assert_eq!(kind, CommitProcessingErrorKind::Skipped);
182
183        let err = AppError::UnmatchedCommitsError(1);
184        let kind: CommitProcessingErrorKind = err.into();
185        assert_eq!(kind, CommitProcessingErrorKind::Other);
186    }
187
188    #[test]
189    fn commit_processing_error_kind_should_warn_or_not() {
190        assert!(CommitProcessingErrorKind::Io.should_warn());
191        assert!(CommitProcessingErrorKind::Parse.should_warn());
192        assert!(CommitProcessingErrorKind::Json.should_warn());
193        assert!(CommitProcessingErrorKind::Field.should_warn());
194        assert!(CommitProcessingErrorKind::Group.should_warn());
195        assert!(!CommitProcessingErrorKind::Skipped.should_warn());
196        assert!(CommitProcessingErrorKind::Other.should_warn());
197    }
198
199    #[test]
200    fn commit_processing_error_kind_display_is_human_readable() {
201        let kind = CommitProcessingErrorKind::Io;
202        let s = kind.to_string();
203        assert!(!s.is_empty());
204
205        let kind = CommitProcessingErrorKind::Parse;
206        let s = kind.to_string();
207        assert!(!s.is_empty());
208
209        let kind = CommitProcessingErrorKind::Json;
210        let s = kind.to_string();
211        assert!(!s.is_empty());
212
213        let kind = CommitProcessingErrorKind::Field;
214        let s = kind.to_string();
215        assert!(!s.is_empty());
216
217        let kind = CommitProcessingErrorKind::Group;
218        let s = kind.to_string();
219        assert!(!s.is_empty());
220
221        let kind = CommitProcessingErrorKind::Skipped;
222        let s = kind.to_string();
223        assert!(!s.is_empty());
224
225        let kind = CommitProcessingErrorKind::Other;
226        let s = kind.to_string();
227        assert!(!s.is_empty());
228    }
229
230    #[test]
231    fn summary_record_ok_increments_processed_only() {
232        let mut summary = Summary::default();
233        summary.record_ok();
234        assert_eq!(summary.processed, 1);
235        assert!(summary.by_kind.is_empty());
236    }
237
238    #[test]
239    fn summary_record_err_increments_processed_and_error_kind() {
240        let mut summary = Summary::default();
241        let err = AppError::FieldError("missing field".into());
242        summary.record_err(&err);
243        assert_eq!(summary.processed, 1);
244        assert_eq!(
245            summary.by_kind.get(&CommitProcessingErrorKind::Field),
246            Some(&1)
247        );
248    }
249
250    #[test]
251    fn summary_record_err_accumulates_same_kind() {
252        let mut summary = Summary::default();
253        let err = AppError::FieldError("missing field".into());
254        summary.record_err(&err);
255        summary.record_err(&err);
256        assert_eq!(summary.processed, 2);
257        assert_eq!(
258            summary.by_kind.get(&CommitProcessingErrorKind::Field),
259            Some(&2)
260        );
261    }
262}