cargo_semantic_release/
changes.rs

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/// Structure that represents the changes in a git repository
9#[derive(Debug)]
10pub struct Changes {
11    /// Vector of commits with major changes
12    major: Vec<ConventionalCommit>,
13    /// Vector of commits with minor changes
14    minor: Vec<ConventionalCommit>,
15    /// Vector of commits with patch changes
16    patch: Vec<ConventionalCommit>,
17    /// Vector of commits with other changes
18    other: Vec<ConventionalCommit>,
19}
20
21impl Changes {
22    /// Sort the commits from a given repo into `major`, `minor`, `patch` and `other`
23    /// change categories according to their commit intentions.
24    ///
25    /// Commits are fetched since the latest version tag. If there are no version tags yet
26    /// then all the commits from the repository are fetched.
27    ///
28    /// ## Returns
29    ///
30    /// The [`Changes`] structure with the sorted commits or error type.
31    ///
32    /// ## Example
33    /// ```
34    /// use git2::Repository;
35    /// use cargo_semantic_release::Changes;
36    ///
37    /// let git_repo = Repository::open(".").unwrap();
38    ///
39    /// let changes = Changes::from_repo(&git_repo).expect("error during fetching changes");
40    /// println!("changes: {changes}")
41    /// ```
42    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    /// Evaluate the changes find in a repository to figure out the semantic version action
151    ///
152    /// ## Returns
153    ///
154    /// [`SemanticVersionAction`] enum for the suggested semantic version change.
155    ///
156    /// ## Example
157    ///
158    /// ```
159    ///  use git2::Repository;
160    ///  use cargo_semantic_release::Changes;
161    ///
162    ///  let git_repo = Repository::open(".").unwrap();
163    ///
164    ///  let action = Changes::from_repo(&git_repo).expect("Error during fetching changes").define_action_for_semantic_version();
165    ///  println!("suggested change of semantic version: {}", action);
166    /// ```
167    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    /// Compare two [`Changes`] struct to see if they have the same elements.
191    ///
192    /// # Returns
193    ///
194    /// `true` if the two structure has the same elements regardless they order, `false` otherwise.
195    ///
196    /// # Example
197    ///
198    /// ```
199    /// use git2::AttrValue::True;
200    /// use git2::Repository;
201    /// use cargo_semantic_release::Changes;
202    ///
203    /// let git_repo = Repository::open(".").unwrap();
204    ///
205    /// let changes_1 = Changes::from_repo(&git_repo).expect("error during fetching changes");
206    /// let changes_2 = Changes::from_repo(&git_repo).expect("error during fetching changes");
207    ///
208    /// assert_eq!(changes_1, changes_2);
209    /// ```
210    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    /// Format the values in [`Changes`]
223    ///
224    /// Example output:
225    /// ```shell
226    ///major:
227    ///         :boom: Introduce breaking change
228    ///
229    /// minor:
230    ///         :sparkles: Add new feature
231    ///
232    /// patch:
233    ///         :recycle: Refactor codebase
234    ///
235    /// other:
236    ///         :bulb: Add comments
237    /// ```
238    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/// Enum to represent the action for semantic version
255#[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        // Given
392        let repository = MockedRepository::new();
393
394        // When
395        let result = Changes::from_repo(&repository).unwrap();
396
397        // Then
398        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        // Given
410        let mut repository = MockedRepository::new();
411        repository.commit_fetching_fails = true;
412
413        // When
414        let result = Changes::from_repo(&repository);
415
416        // Then
417        assert!(result.is_err(), "Expected error, but got Ok");
418    }
419
420    #[test]
421    fn creating_from_only_major_conventional_commits() {
422        // Given
423        let commit_messages = vec!["๐Ÿ’ฅ introduce breaking changes"];
424        let repository = MockedRepository::from_commits(commit_messages.clone());
425
426        // When
427        let result = Changes::from_repo(&repository).unwrap();
428
429        // Then
430        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        // Given
442        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        // When
456        let result = Changes::from_repo(&repository).unwrap();
457
458        // Then
459        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        // Given
471        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        // When
518        let result = Changes::from_repo(&repository).unwrap();
519
520        // Then
521        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        // When
558        let result = Changes::from_repo(&repository).unwrap();
559
560        // Then
561        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        // Given
573        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        // When
587        let result = Changes::from_repo(&repository).unwrap();
588
589        // Then
590        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        // Given
602        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        // When
617        let result = Changes::from_repo(&repository);
618
619        // Then
620        assert!(result.is_err(), "Expected Error, got Ok");
621    }
622
623    #[test]
624    fn creating_with_try_from() {
625        // Given
626        let commit_messages = vec!["๐Ÿ’ฅ introduce breaking changes"];
627        let (_temp_dir, repository) = repo_init(Some(commit_messages.clone()));
628
629        // When
630        let result = Changes::try_from(&repository).unwrap();
631
632        // Then
633        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        // Given
651        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        // When
661        let result = changes.define_action_for_semantic_version();
662
663        // Then
664        assert_eq!(result, SemanticVersionAction::Keep);
665    }
666
667    #[test]
668    fn has_patch_changes() {
669        // Given
670        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        // When
682        let result = changes.define_action_for_semantic_version();
683
684        // Then
685        assert_eq!(result, SemanticVersionAction::IncrementPatch);
686    }
687
688    #[test]
689    fn has_minor_changes() {
690        // Given
691        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        // When
705        let result = changes.define_action_for_semantic_version();
706
707        // Then
708        assert_eq!(result, SemanticVersionAction::IncrementMinor);
709    }
710
711    #[test]
712    fn has_major_changes() {
713        // Given
714        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        // When
730        let result = changes.define_action_for_semantic_version();
731
732        // Then
733        assert_eq!(result, SemanticVersionAction::IncrementMajor);
734    }
735}