1use crate::repo::ConventionalCommit;
2pub use crate::repo::RepositoryExtension;
3use git2::Repository;
4use std::collections::HashSet;
5use std::error::Error;
6use std::fmt::Display;
7
8#[derive(Debug)]
10pub struct Changes {
11 major: Vec<ConventionalCommit>,
13 minor: Vec<ConventionalCommit>,
15 patch: Vec<ConventionalCommit>,
17 other: Vec<ConventionalCommit>,
19}
20
21impl Changes {
22 pub fn from_repo(repository: &impl RepositoryExtension) -> Result<Self, Box<dyn Error>> {
43 let major_intentions = [(":boom:", "๐ฅ")];
44 let minor_intentions = [
45 (":sparkles:", "โจ"),
46 (":children_crossing:", "๐ธ"),
47 (":lipstick:", "๐"),
48 (":iphone:", "๐ฑ"),
49 (":egg:", "๐ฅ"),
50 (":chart_with_upwards_trend:", "๐"),
51 (":heavy_plus_sign:", "โ"),
52 (":heavy_minus_sign:", "โ"),
53 (":passport_control:", "๐"),
54 ];
55 let patch_intentions = [
56 (":art:", "๐จ"),
57 (":ambulance:", "๐๏ธ"),
58 (":lock:", "๐๏ธ"),
59 (":bug:", "๐"),
60 (":zap:", "โก๏ธ"),
61 (":goal_net:", "๐ฅ
"),
62 (":alien:", "๐ฝ๏ธ"),
63 (":wheelchair:", "โฟ๏ธ"),
64 (":speech_balloon:", "๐ฌ"),
65 (":mag:", "๐๏ธ"),
66 (":fire:", "๐ฅ"),
67 (":white_check_mark:", "โ
"),
68 (":closed_lock_with_key:", "๐"),
69 (":rotating_light:", "๐จ"),
70 (":green_heart:", "๐"),
71 (":arrow_down:", "โฌ๏ธ"),
72 (":arrow_up:", "โฌ๏ธ"),
73 (":pushpin:", "๐"),
74 (":construction_worker:", "๐ท"),
75 (":recycle:", "โป๏ธ"),
76 (":wrench:", "๐ง"),
77 (":hammer:", "๐จ"),
78 (":globe_with_meridians:", "๐"),
79 (":package:", "๐ฆ๏ธ"),
80 (":truck:", "๐"),
81 (":bento:", "๐ฑ"),
82 (":card_file_box:", "๐๏ธ"),
83 (":loud_sound:", "๐"),
84 (":mute:", "๐"),
85 (":building_construction:", "๐๏ธ"),
86 (":camera_flash:", "๐ธ"),
87 (":label:", "๐ท๏ธ"),
88 (":seedling:", "๐ฑ"),
89 (":triangular_flag_on_post:", "๐ฉ"),
90 (":dizzy:", "๐ซ"),
91 (":adhesive_bandage:", "๐ฉน"),
92 (":monocle_face:", "๐ง"),
93 (":necktie:", "๐"),
94 (":stethoscope:", "๐ฉบ"),
95 (":technologist:", "๐งโ๐ป"),
96 (":thread:", "๐งต"),
97 (":safety_vest:", "๐ฆบ"),
98 ];
99 let other_intentions = [
100 (":memo:", "๐"),
101 (":rocket:", "๐"),
102 (":tada:", "๐"),
103 (":bookmark:", "๐"),
104 (":construction:", "๐ง"),
105 (":pencil2:", "โ๏ธ"),
106 (":poop:", "๐ฉ"),
107 (":rewind:", "โช๏ธ"),
108 (":twisted_rightwards_arrows:", "๐"),
109 (":page_facing_up:", "๐"),
110 (":bulb:", "๐ก"),
111 (":beers:", "๐ป"),
112 (":bust_in_silhouette:", "๐ฅ"),
113 (":clown_face:", "๐คก"),
114 (":see_no_evil:", "๐"),
115 (":alembic:", "โ๏ธ"),
116 (":wastebasket:", "๐๏ธ"),
117 (":coffin:", "โฐ๏ธ"),
118 (":test_tube:", "๐งช"),
119 (":bricks:", "๐งฑ"),
120 (":money_with_wings:", "๐ธ"),
121 ];
122
123 let version_tag = repository.get_latest_version_tag()?;
124
125 let unsorted_commits = match version_tag {
126 Some(version_tag) => repository.fetch_commits_until(version_tag.commit_oid),
127 None => repository.fetch_all_commits(),
128 };
129
130 match unsorted_commits {
131 Ok(unsorted_commits) => Ok(Self {
132 major: get_commits_with_intention(
133 unsorted_commits.clone(),
134 major_intentions.to_vec(),
135 ),
136 minor: get_commits_with_intention(
137 unsorted_commits.clone(),
138 minor_intentions.to_vec(),
139 ),
140 patch: get_commits_with_intention(
141 unsorted_commits.clone(),
142 patch_intentions.to_vec(),
143 ),
144 other: get_commits_with_intention(unsorted_commits, other_intentions.to_vec()),
145 }),
146 Err(e) => Err(e),
147 }
148 }
149
150 pub fn define_action_for_semantic_version(self) -> SemanticVersionAction {
168 if !self.major.is_empty() {
169 return SemanticVersionAction::IncrementMajor;
170 }
171 if !self.minor.is_empty() {
172 return SemanticVersionAction::IncrementMinor;
173 }
174 if !self.patch.is_empty() {
175 return SemanticVersionAction::IncrementPatch;
176 }
177 SemanticVersionAction::Keep
178 }
179}
180
181impl TryFrom<&Repository> for Changes {
182 type Error = Box<dyn Error>;
183
184 fn try_from(value: &Repository) -> Result<Self, Self::Error> {
185 Self::from_repo(value)
186 }
187}
188
189impl PartialEq for Changes {
190 fn eq(&self, other: &Self) -> bool {
211 self.major.iter().collect::<HashSet<_>>() == other.major.iter().collect::<HashSet<_>>()
212 && self.minor.iter().collect::<HashSet<_>>()
213 == other.minor.iter().collect::<HashSet<_>>()
214 && self.patch.iter().collect::<HashSet<_>>()
215 == other.patch.iter().collect::<HashSet<_>>()
216 && self.other.iter().collect::<HashSet<_>>()
217 == other.other.iter().collect::<HashSet<_>>()
218 }
219}
220
221impl Display for Changes {
222 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 let major_changes = convert_to_string_vector(self.major.clone());
240 let minor_changes = convert_to_string_vector(self.minor.clone());
241 let patch_changes = convert_to_string_vector(self.patch.clone());
242 let other_changes = convert_to_string_vector(self.other.clone());
243 write!(
244 f,
245 "major:\n\t{}\nminor:\n\t{}\npatch:\n\t{}\nother:\n\t{}",
246 major_changes.join("\t"),
247 minor_changes.join("\t"),
248 patch_changes.join("\t"),
249 other_changes.join("\t")
250 )
251 }
252}
253
254#[derive(PartialEq, Debug)]
256pub enum SemanticVersionAction {
257 IncrementMajor,
258 IncrementMinor,
259 IncrementPatch,
260 Keep,
261}
262
263impl Display for SemanticVersionAction {
264 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265 let msg = match self {
266 SemanticVersionAction::IncrementMajor => "increment major version",
267 SemanticVersionAction::IncrementMinor => "increment minor version",
268 SemanticVersionAction::IncrementPatch => "increment patch version",
269 SemanticVersionAction::Keep => "keep version",
270 };
271 write!(f, "{}", msg)
272 }
273}
274
275fn convert_to_string_vector(commits: Vec<ConventionalCommit>) -> Vec<String> {
276 commits
277 .into_iter()
278 .map(|commit| commit.message().to_string())
279 .collect::<Vec<String>>()
280}
281
282fn get_commits_with_intention(
283 commits: Vec<ConventionalCommit>,
284 intentions: Vec<(&str, &str)>,
285) -> Vec<ConventionalCommit> {
286 commits
287 .into_iter()
288 .filter(|commit| {
289 intentions.iter().any(|intention| {
290 commit.message.contains(intention.0) || commit.message.contains(intention.1)
291 })
292 })
293 .collect()
294}
295
296#[cfg(test)]
297mod changes_tests {
298 use crate::changes::{Changes, RepositoryExtension};
299 use crate::repo::{ConventionalCommit, VersionTag};
300 use crate::test_util::{repo_init, MockError};
301 use git2::Oid;
302 use semver::Version;
303 use std::error::Error;
304
305 fn convert(messages: Vec<&str>) -> Vec<ConventionalCommit> {
306 messages
307 .iter()
308 .map(|commit_message| ConventionalCommit {
309 message: commit_message.to_string(),
310 })
311 .collect()
312 }
313
314 struct MockedRepository {
315 commits: Vec<ConventionalCommit>,
316 commit_fetching_fails: bool,
317 commit_with_latest_tag: Option<String>,
318 latest_version_tag: Option<VersionTag>,
319 tag_fetching_fails: bool,
320 }
321
322 impl RepositoryExtension for MockedRepository {
323 fn fetch_commits_until(
324 &self,
325 stop_oid: Oid,
326 ) -> Result<Vec<ConventionalCommit>, Box<dyn Error>> {
327 assert_eq!(
328 stop_oid,
329 self.latest_version_tag.as_ref().unwrap().commit_oid,
330 "fetch_commits_until is not called with the latest version tag"
331 );
332 if self.commit_fetching_fails {
333 Err(Box::new(MockError))
334 } else {
335 let commits = self
336 .commits
337 .clone()
338 .into_iter()
339 .rev()
340 .map(|commit| commit.message.clone())
341 .take_while(|message| {
342 message.as_str() != self.commit_with_latest_tag.as_ref().unwrap().as_str()
343 })
344 .map(|message| ConventionalCommit { message })
345 .collect();
346 Ok(commits)
347 }
348 }
349
350 fn fetch_all_commits(&self) -> Result<Vec<ConventionalCommit>, Box<dyn Error>> {
351 if self.commit_fetching_fails {
352 Err(Box::new(MockError))
353 } else {
354 Ok(self.commits.clone())
355 }
356 }
357
358 fn get_latest_version_tag(&self) -> Result<Option<VersionTag>, Box<dyn Error>> {
359 if self.tag_fetching_fails {
360 Err(Box::new(MockError))
361 } else {
362 Ok(self.latest_version_tag.clone())
363 }
364 }
365 }
366
367 impl MockedRepository {
368 fn from_commits(commits: Vec<&str>) -> Self {
369 Self {
370 commits: convert(commits),
371 commit_fetching_fails: false,
372 commit_with_latest_tag: None,
373 latest_version_tag: None,
374 tag_fetching_fails: false,
375 }
376 }
377
378 fn new() -> Self {
379 Self {
380 commits: Vec::new(),
381 commit_fetching_fails: false,
382 commit_with_latest_tag: None,
383 latest_version_tag: None,
384 tag_fetching_fails: false,
385 }
386 }
387 }
388
389 #[test]
390 fn creating_from_empty_commit_list() {
391 let repository = MockedRepository::new();
393
394 let result = Changes::from_repo(&repository).unwrap();
396
397 let expected_result = Changes {
399 major: Vec::new(),
400 minor: Vec::new(),
401 patch: Vec::new(),
402 other: Vec::new(),
403 };
404 assert_eq!(result, expected_result);
405 }
406
407 #[test]
408 fn error_during_fetching_commits() {
409 let mut repository = MockedRepository::new();
411 repository.commit_fetching_fails = true;
412
413 let result = Changes::from_repo(&repository);
415
416 assert!(result.is_err(), "Expected error, but got Ok");
418 }
419
420 #[test]
421 fn creating_from_only_major_conventional_commits() {
422 let commit_messages = vec!["๐ฅ introduce breaking changes"];
424 let repository = MockedRepository::from_commits(commit_messages.clone());
425
426 let result = Changes::from_repo(&repository).unwrap();
428
429 let expected_result = Changes {
431 major: convert(commit_messages),
432 minor: Vec::new(),
433 patch: Vec::new(),
434 other: Vec::new(),
435 };
436 assert_eq!(result, expected_result);
437 }
438
439 #[test]
440 fn creating_from_only_minor_conventional_commits() {
441 let commit_messages = vec![
443 ":sparkles: introduce new feature",
444 ":children_crossing: improve user experience / usability",
445 "๐ add or update the UI and style files",
446 ":iphone: work on responsive design",
447 ":egg: add or update an easter egg",
448 ":chart_with_upwards_trend: add or update analytics or track code",
449 ":heavy_plus_sign: add a dependency",
450 ":heavy_minus_sign: remove a dependency",
451 ":passport_control: work on code related to authorization, roles and permissions",
452 ];
453 let repository = MockedRepository::from_commits(commit_messages.clone());
454
455 let result = Changes::from_repo(&repository).unwrap();
457
458 let expected_result = Changes {
460 major: Vec::new(),
461 minor: convert(commit_messages),
462 patch: Vec::new(),
463 other: Vec::new(),
464 };
465 assert_eq!(result, expected_result);
466 }
467
468 #[test]
469 fn creating_from_only_patch_conventional_commits() {
470 let commit_messages = vec![
472 ":art: improve structure / format of the code",
473 ":ambulance: critical hotfix",
474 ":lock: fix security or privacy issues",
475 "๐ fix a bug",
476 ":zap: improve performance",
477 ":goal_net: catch errors",
478 ":alien: update code due to external API changes",
479 ":wheelchair: improve accessibility",
480 ":speech_balloon: add or update text and literals",
481 ":mag: improve SEO",
482 ":fire: remove code or files",
483 ":white_check_mark: add, update, or pass tests",
484 ":closed_lock_with_key: add or update secrets",
485 ":rotating_light: fix compiler / linter warnings",
486 ":green_heart: fix CI build",
487 ":arrow_down: downgrade dependencies",
488 ":arrow_up: upgrade dependencies",
489 ":pushpin: pin dependencies to specific versions",
490 ":construction_worker: add or update CI build system",
491 ":recycle: refactor code",
492 ":wrench: add or update configuration files",
493 ":hammer: add or update development scripts",
494 ":globe_with_meridians: internationalization and localization",
495 ":package: add or update compiled files or packages",
496 ":truck: move or rename resources (e.g.: files, paths, routes",
497 ":bento: add or update assets",
498 ":card_file_box: perform database related changes",
499 ":loud_sound: add or update logs",
500 ":mute: remove logs",
501 ":building_construction: make architectural changes",
502 ":camera_flash: add or update snapshots",
503 ":label: add or update types",
504 ":seedling: add or update seed files",
505 ":triangular_flag_on_post: add, update, or remove feature flags",
506 ":dizzy: add or update animations an transitions",
507 ":adhesive_bandage: simple fix for a non critical issue",
508 ":monocle_face: data exploration / inspection",
509 ":necktie: add or update business logic",
510 ":stethoscope: add or update healthcheck",
511 ":technologist: improve developer experience",
512 ":thread: add or update code related to multithreading or concurrency",
513 ":safety_vest: add or update code related to validation",
514 ];
515 let repository = MockedRepository::from_commits(commit_messages.clone());
516
517 let result = Changes::from_repo(&repository).unwrap();
519
520 let expected_result = Changes {
522 major: Vec::new(),
523 minor: Vec::new(),
524 patch: convert(commit_messages),
525 other: Vec::new(),
526 };
527 assert_eq!(result, expected_result);
528 }
529
530 #[test]
531 fn creating_from_only_other_conventional_commits() {
532 let commit_messages = vec![
533 ":memo: add or update documentation",
534 ":rocket: deploy stuff",
535 ":tada: begin a project",
536 ":bookmark: release / version tags",
537 ":construction: work in progress",
538 ":pencil2: fix typos",
539 ":poop: write bad code that needs to be improved",
540 ":rewind: revert changes",
541 ":twisted_rightwards_arrows: merge branches",
542 ":page_facing_up: add or update license",
543 ":bulb: add or update comments in source code",
544 "๐ป write code drunkenly",
545 ":bust_in_silhouette: add or update contributor(s)",
546 ":clown_face: mock things",
547 ":see_no_evil: add or update a .gitignore file",
548 ":alembic: perform experiments",
549 ":wastebasket: deprecate code that needs to be cleaned up",
550 ":coffin: remove dead code",
551 ":test_tube: add a failing test",
552 ":bricks: infrastructure related changes",
553 ":money_with_wings: add sponsorship or money related infrastructure",
554 ];
555 let repository = MockedRepository::from_commits(commit_messages.clone());
556
557 let result = Changes::from_repo(&repository).unwrap();
559
560 let expected_result = Changes {
562 major: Vec::new(),
563 minor: Vec::new(),
564 patch: Vec::new(),
565 other: convert(commit_messages),
566 };
567 assert_eq!(result, expected_result);
568 }
569
570 #[test]
571 fn creating_from_repo_with_tags() {
572 let commit_messages = vec![
574 "๐ฅ introduce breaking changes",
575 ":sparkles: introduce new feature",
576 ":money_with_wings: add sponsorship or money related infrastructure",
577 ":memo: add or update documentation",
578 ];
579 let mut repository = MockedRepository::from_commits(commit_messages.clone());
580 repository.latest_version_tag = Some(VersionTag {
581 version: Version::new(1, 0, 0),
582 commit_oid: Oid::zero(),
583 });
584 repository.commit_with_latest_tag = Some(commit_messages[1].into());
585
586 let result = Changes::from_repo(&repository).unwrap();
588
589 let expected_result = Changes {
591 major: Vec::new(),
592 minor: Vec::new(),
593 patch: Vec::new(),
594 other: convert(commit_messages[2..].to_vec()),
595 };
596 assert_eq!(result, expected_result);
597 }
598
599 #[test]
600 fn error_during_fetching_latest_tag() {
601 let commit_messages = vec![
603 ":sparkles: introduce new feature",
604 ":children_crossing: improve user experience / usability",
605 "๐ add or update the UI and style files",
606 ":iphone: work on responsive design",
607 ":egg: add or update an easter egg",
608 ":chart_with_upwards_trend: add or update analytics or track code",
609 ":heavy_plus_sign: add a dependency",
610 ":heavy_minus_sign: remove a dependency",
611 ":passport_control: work on code related to authorization, roles and permissions",
612 ];
613 let mut repository = MockedRepository::from_commits(commit_messages.clone());
614 repository.tag_fetching_fails = true;
615
616 let result = Changes::from_repo(&repository);
618
619 assert!(result.is_err(), "Expected Error, got Ok");
621 }
622
623 #[test]
624 fn creating_with_try_from() {
625 let commit_messages = vec!["๐ฅ introduce breaking changes"];
627 let (_temp_dir, repository) = repo_init(Some(commit_messages.clone()));
628
629 let result = Changes::try_from(&repository).unwrap();
631
632 let expected_result = Changes {
634 major: convert(commit_messages),
635 minor: Vec::new(),
636 patch: Vec::new(),
637 other: Vec::new(),
638 };
639 assert_eq!(result, expected_result);
640 }
641}
642
643#[cfg(test)]
644mod evaluate_changes_tests {
645 use crate::changes::{Changes, SemanticVersionAction};
646 use crate::repo::ConventionalCommit;
647
648 #[test]
649 fn has_no_changes() {
650 let changes = Changes {
652 major: Vec::new(),
653 minor: Vec::new(),
654 patch: Vec::new(),
655 other: vec![ConventionalCommit {
656 message: "other commit".to_string(),
657 }],
658 };
659
660 let result = changes.define_action_for_semantic_version();
662
663 assert_eq!(result, SemanticVersionAction::Keep);
665 }
666
667 #[test]
668 fn has_patch_changes() {
669 let changes = Changes {
671 major: Vec::new(),
672 minor: Vec::new(),
673 patch: vec![ConventionalCommit {
674 message: "patch commit".to_string(),
675 }],
676 other: vec![ConventionalCommit {
677 message: "other commit".to_string(),
678 }],
679 };
680
681 let result = changes.define_action_for_semantic_version();
683
684 assert_eq!(result, SemanticVersionAction::IncrementPatch);
686 }
687
688 #[test]
689 fn has_minor_changes() {
690 let changes = Changes {
692 major: Vec::new(),
693 minor: vec![ConventionalCommit {
694 message: "minor commit".to_string(),
695 }],
696 patch: vec![ConventionalCommit {
697 message: "patch commit".to_string(),
698 }],
699 other: vec![ConventionalCommit {
700 message: "other commit".to_string(),
701 }],
702 };
703
704 let result = changes.define_action_for_semantic_version();
706
707 assert_eq!(result, SemanticVersionAction::IncrementMinor);
709 }
710
711 #[test]
712 fn has_major_changes() {
713 let changes = Changes {
715 major: vec![ConventionalCommit {
716 message: "major commit".to_string(),
717 }],
718 minor: vec![ConventionalCommit {
719 message: "minor commit".to_string(),
720 }],
721 patch: vec![ConventionalCommit {
722 message: "patch commit".to_string(),
723 }],
724 other: vec![ConventionalCommit {
725 message: "other commit".to_string(),
726 }],
727 };
728
729 let result = changes.define_action_for_semantic_version();
731
732 assert_eq!(result, SemanticVersionAction::IncrementMajor);
734 }
735}