1use chrono::{DateTime, Utc};
40use serde::{Deserialize, Serialize};
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct Commit {
62 pub hash: String,
64 pub short_hash: String,
66 pub message: String,
68 pub author: String,
70 pub author_email: String,
72 pub date: DateTime<Utc>,
74 pub files_changed: Vec<String>,
76 pub commit_type: Option<CommitType>,
78 pub scope: Option<String>,
80 pub breaking: bool,
82 pub body: Option<String>,
84}
85
86impl Commit {
87 #[must_use]
89 pub fn new(
90 hash: String,
91 message: String,
92 author: String,
93 author_email: String,
94 date: DateTime<Utc>,
95 ) -> Self {
96 let short_hash = hash.chars().take(7).collect();
97 let (commit_type, scope, breaking) = Self::parse_conventional(&message);
98
99 Self {
100 hash,
101 short_hash,
102 message,
103 author,
104 author_email,
105 date,
106 files_changed: Vec::new(),
107 commit_type,
108 scope,
109 breaking,
110 body: None,
111 }
112 }
113
114 fn parse_conventional(message: &str) -> (Option<CommitType>, Option<String>, bool) {
116 let parts: Vec<&str> = message.splitn(2, ':').collect();
117 if parts.len() < 2 {
118 return (None, None, false);
119 }
120
121 let prefix = parts[0].trim();
122
123 let type_str = prefix.trim_end_matches('!');
125
126 let scope = type_str.find(')').and_then(|end| {
128 type_str.find('(').and_then(|start| {
129 let scope_str = &type_str[start + 1..end];
130 if scope_str.is_empty() {
131 None
132 } else {
133 Some(scope_str.to_string())
134 }
135 })
136 });
137
138 let base_type = type_str
140 .find('(')
141 .map_or(type_str, |start| &type_str[..start]);
142
143 let breaking = prefix.ends_with('!');
144 let commit_type = CommitType::parse_from_str(base_type);
145
146 let has_breaking_body = message
148 .lines()
149 .skip(1)
150 .any(|line| line.to_uppercase().contains("BREAKING CHANGE"));
151
152 (commit_type, scope, breaking || has_breaking_body)
153 }
154
155 #[must_use]
157 pub const fn commit_type(&self) -> Option<CommitType> {
158 self.commit_type
159 }
160
161 #[must_use]
163 pub const fn is_conventional(&self) -> bool {
164 self.commit_type.is_some()
165 }
166
167 #[must_use]
169 pub fn affects_scope(&self, scope: &str) -> bool {
170 self.scope.as_deref() == Some(scope)
171 }
172
173 #[must_use]
175 pub fn is_type(&self, commit_type: CommitType) -> bool {
176 self.commit_type == Some(commit_type)
177 }
178
179 #[must_use]
181 pub fn short_message(&self) -> &str {
182 self.message.lines().next().unwrap_or(&self.message)
183 }
184
185 #[must_use]
187 pub fn changelog_message(&self) -> &str {
188 self.short_message().split_once(':').map_or_else(
189 || self.short_message(),
190 |(_, description)| description.trim(),
191 )
192 }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
217#[serde(rename_all = "lowercase")]
218pub enum CommitType {
219 Feat,
221 Fix,
223 Docs,
225 Style,
227 Refactor,
229 Perf,
231 Test,
233 Build,
235 Ci,
237 Chore,
239 Revert,
241}
242
243impl CommitType {
244 #[must_use]
246 pub fn parse_from_str(s: &str) -> Option<Self> {
247 match s.to_lowercase().as_str() {
248 "feat" => Some(Self::Feat),
249 "fix" => Some(Self::Fix),
250 "docs" => Some(Self::Docs),
251 "style" => Some(Self::Style),
252 "refactor" => Some(Self::Refactor),
253 "perf" => Some(Self::Perf),
254 "test" => Some(Self::Test),
255 "build" => Some(Self::Build),
256 "ci" => Some(Self::Ci),
257 "chore" => Some(Self::Chore),
258 "revert" => Some(Self::Revert),
259 _ => None,
260 }
261 }
262
263 #[must_use]
265 pub const fn is_breaking(self) -> bool {
266 matches!(self, Self::Feat | Self::Fix | Self::Perf | Self::Refactor)
267 }
268
269 #[must_use]
271 pub const fn is_feature(self) -> bool {
272 matches!(self, Self::Feat)
273 }
274
275 #[must_use]
277 pub const fn is_fix(self) -> bool {
278 matches!(self, Self::Fix | Self::Perf)
279 }
280
281 #[must_use]
283 pub const fn is_changeloggable(self) -> bool {
284 matches!(self, Self::Feat | Self::Fix | Self::Perf | Self::Refactor)
285 }
286}
287
288impl std::fmt::Display for CommitType {
289 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290 match self {
291 Self::Feat => write!(f, "feat"),
292 Self::Fix => write!(f, "fix"),
293 Self::Docs => write!(f, "docs"),
294 Self::Style => write!(f, "style"),
295 Self::Refactor => write!(f, "refactor"),
296 Self::Perf => write!(f, "perf"),
297 Self::Test => write!(f, "test"),
298 Self::Build => write!(f, "build"),
299 Self::Ci => write!(f, "ci"),
300 Self::Chore => write!(f, "chore"),
301 Self::Revert => write!(f, "revert"),
302 }
303 }
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct CommitHistory {
309 pub commits: Vec<Commit>,
311 pub since: Option<String>,
313 pub until: Option<String>,
315}
316
317impl CommitHistory {
318 #[must_use]
320 pub const fn new(commits: Vec<Commit>) -> Self {
321 Self {
322 commits,
323 since: None,
324 until: None,
325 }
326 }
327
328 #[must_use]
330 pub fn by_type(&self, commit_type: CommitType) -> Vec<&Commit> {
331 self.commits
332 .iter()
333 .filter(|c| c.commit_type == Some(commit_type))
334 .collect()
335 }
336
337 #[must_use]
339 pub fn breaking_changes(&self) -> Vec<&Commit> {
340 self.commits.iter().filter(|c| c.breaking).collect()
341 }
342
343 #[must_use]
345 pub fn features(&self) -> Vec<&Commit> {
346 self.commits
347 .iter()
348 .filter(|c| c.commit_type == Some(CommitType::Feat))
349 .collect()
350 }
351
352 #[must_use]
354 pub fn fixes(&self) -> Vec<&Commit> {
355 self.commits
356 .iter()
357 .filter(|c| c.commit_type == Some(CommitType::Fix))
358 .collect()
359 }
360
361 #[must_use]
363 pub fn changeloggable(&self) -> Vec<&Commit> {
364 self.commits
365 .iter()
366 .filter(|c| c.commit_type.is_some_and(CommitType::is_changeloggable))
367 .collect()
368 }
369
370 #[must_use]
372 pub fn count_by_type(&self) -> std::collections::HashMap<CommitType, usize> {
373 let mut counts = std::collections::HashMap::new();
374 for commit in &self.commits {
375 if let Some(ct) = commit.commit_type {
376 *counts.entry(ct).or_insert(0) += 1;
377 }
378 }
379 counts
380 }
381
382 #[must_use]
384 pub const fn is_empty(&self) -> bool {
385 self.commits.is_empty()
386 }
387
388 #[must_use]
390 pub const fn len(&self) -> usize {
391 self.commits.len()
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn test_conventional_commit_parsing() {
401 let commit = Commit::new(
402 "abc123".to_string(),
403 "feat: add new feature".to_string(),
404 "Author".to_string(),
405 "author@example.com".to_string(),
406 Utc::now(),
407 );
408 assert_eq!(commit.commit_type, Some(CommitType::Feat));
409 assert!(!commit.breaking);
410 assert!(commit.is_conventional());
411 }
412
413 #[test]
414 fn test_breaking_commit_parsing() {
415 let commit = Commit::new(
416 "abc123".to_string(),
417 "feat!: breaking API change".to_string(),
418 "Author".to_string(),
419 "author@example.com".to_string(),
420 Utc::now(),
421 );
422 assert_eq!(commit.commit_type, Some(CommitType::Feat));
423 assert!(commit.breaking);
424 }
425
426 #[test]
427 fn test_scope_parsing() {
428 let commit = Commit::new(
429 "abc123".to_string(),
430 "feat(api): add new endpoint".to_string(),
431 "Author".to_string(),
432 "author@example.com".to_string(),
433 Utc::now(),
434 );
435 assert_eq!(commit.commit_type, Some(CommitType::Feat));
436 assert_eq!(commit.scope, Some("api".to_string()));
437 assert!(commit.affects_scope("api"));
438 }
439
440 #[test]
441 fn test_non_conventional_commit() {
442 let commit = Commit::new(
443 "abc123".to_string(),
444 "Add some stuff".to_string(),
445 "Author".to_string(),
446 "author@example.com".to_string(),
447 Utc::now(),
448 );
449 assert_eq!(commit.commit_type, None);
450 assert!(!commit.is_conventional());
451 }
452
453 #[test]
454 fn test_commit_history_filtering() {
455 let commits = vec![
456 Commit::new(
457 "1".to_string(),
458 "feat: feature 1".to_string(),
459 "A".to_string(),
460 "a@a.com".to_string(),
461 Utc::now(),
462 ),
463 Commit::new(
464 "2".to_string(),
465 "fix: bug fix".to_string(),
466 "A".to_string(),
467 "a@a.com".to_string(),
468 Utc::now(),
469 ),
470 Commit::new(
471 "3".to_string(),
472 "feat: feature 2".to_string(),
473 "A".to_string(),
474 "a@a.com".to_string(),
475 Utc::now(),
476 ),
477 ];
478 let history = CommitHistory::new(commits);
479 assert_eq!(history.features().len(), 2);
480 assert_eq!(history.fixes().len(), 1);
481 assert_eq!(history.changeloggable().len(), 3);
482 }
483}