Skip to content

Commit 8785d2c

Browse files
committed
feat: add output CLI argument with enum for format control
1 parent 2456935 commit 8785d2c

File tree

6 files changed

+250
-41
lines changed

6 files changed

+250
-41
lines changed

src/commits/commit/mod.rs

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,65 @@
1-
use git2::{Oid, Repository};
1+
use crate::linting_results::CommitError;
22

3-
pub(super) struct Commit {
4-
commit_hash: git2::Oid,
3+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
4+
pub struct Commit {
5+
pub hash: String,
6+
pub message: String,
57
number_of_parents: usize,
68
}
79

810
impl Commit {
9-
pub(super) fn from_git(repository: &Repository, oid: Oid) -> Result<Self, git2::Error> {
10-
let commit = repository.find_commit(oid)?;
11+
pub(super) fn from_git(commit: &git2::Commit) -> Commit {
1112
let number_of_parents = commit.parents().len();
13+
let message = match commit.message().map(|m| m.to_string()) {
14+
Some(message) => {
15+
trace!(
16+
"Found the commit message {message:?} for the commit with the hash '{}'.",
17+
commit.id()
18+
);
19+
message
20+
}
21+
None => {
22+
warn!(
23+
"Can not find commit message for the commit with the hash '{}'.",
24+
commit.id()
25+
);
26+
String::new()
27+
}
28+
};
29+
1230
debug!(
1331
"The commit with the hash '{}' has {:?} parents.",
1432
commit.id(),
1533
number_of_parents,
1634
);
1735

18-
Ok(Commit {
19-
commit_hash: commit.id(),
36+
Commit {
37+
hash: commit.id().to_string(),
38+
message,
2039
number_of_parents,
21-
})
40+
}
2241
}
2342

2443
pub(super) fn is_merge_commit(&self) -> bool {
2544
let is_merge_commit = self.number_of_parents > 1;
2645

2746
if is_merge_commit {
28-
warn!("Commit {:?} is a merge commit.", self.commit_hash);
47+
warn!("Commit {:?} is a merge commit.", self.hash);
2948
}
3049

3150
is_merge_commit
3251
}
52+
53+
/// Lint this commit and return any linting errors found.
54+
pub(crate) fn lint(&self) -> Vec<CommitError> {
55+
let mut errors = Vec::new();
56+
57+
if self.is_merge_commit() {
58+
errors.push(CommitError::MergeCommit);
59+
}
60+
61+
errors
62+
}
3363
}
3464

3565
#[cfg(test)]

src/commits/mod.rs

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
use std::collections::VecDeque;
1+
use std::collections::{HashMap, VecDeque};
22

33
use anyhow::{bail, Context, Result};
44
use git2::{Oid, Repository, Revwalk};
55

6-
use crate::commits::commit::Commit;
6+
use crate::linting_results::{CommitError, CommitErrors, CommitsError, LintingResults};
77

8-
mod commit;
8+
pub mod commit;
9+
pub use commit::Commit;
910

1011
/// A representation of a range of commits within a Git repository, which can have various lints performed upon it after construction.
1112
pub struct Commits {
@@ -20,14 +21,52 @@ impl Commits {
2021
get_commits_till_head_from_oid(repository, oid)
2122
}
2223

23-
/// A lint that can be performed on the range of commits, which returns true if any of the
24-
/// commits are merge commits, i.e. has multiple parents.
25-
pub fn contains_merge_commits(&self) -> bool {
26-
self.commits.iter().any(|commit| commit.is_merge_commit())
27-
}
24+
/// Lint all commits and return the linting results if any issues are found.
25+
pub fn lint(&self, max_commits: Option<usize>) -> Option<LintingResults> {
26+
let mut commit_errors: HashMap<Commit, Vec<CommitError>> = HashMap::new();
27+
28+
// Check each commit for linting errors
29+
for commit in self.commits.iter().cloned() {
30+
let errors = commit.lint();
31+
32+
if !errors.is_empty() {
33+
warn!(
34+
"Found {} linting errors for the commit {:?}.",
35+
errors.len(),
36+
commit.hash
37+
);
38+
commit_errors.insert(commit, errors);
39+
}
40+
}
2841

29-
pub fn len(&self) -> usize {
30-
self.commits.len()
42+
// Check for aggregate errors
43+
let commits_errors = max_commits.and_then(|max| {
44+
if self.commits.len() > max {
45+
Some(vec![CommitsError::MaxCommitsExceeded {
46+
max_commits: max,
47+
actual_commits: self.commits.len(),
48+
}])
49+
} else {
50+
None
51+
}
52+
});
53+
54+
// Return None if no issues found, otherwise build LintingResults
55+
match (&commit_errors.is_empty(), &commits_errors) {
56+
(true, None) => None,
57+
(true, Some(ce)) => Some(LintingResults {
58+
commit_errors: None,
59+
commits_errors: Some(ce.clone()),
60+
}),
61+
(false, None) => Some(LintingResults {
62+
commit_errors: Some(CommitErrors::new(self.commits.clone(), commit_errors)),
63+
commits_errors: None,
64+
}),
65+
(false, Some(ce)) => Some(LintingResults {
66+
commit_errors: Some(CommitErrors::new(self.commits.clone(), commit_errors)),
67+
commits_errors: Some(ce.clone()),
68+
}),
69+
}
3170
}
3271
}
3372

@@ -49,14 +88,17 @@ fn get_commits_till_head_from_oid(
4988
let revwalker = get_revwalker(repository, from_commit_hash)?;
5089
let mut commits = VecDeque::new();
5190

52-
for commit in revwalker {
53-
let oid = commit?;
54-
55-
let commit = Commit::from_git(repository, oid)
56-
.context(format!("Can not find a commit with the hash '{oid}'."))?;
91+
for oid in revwalker {
92+
let oid = oid?;
93+
let commit = repository.find_commit(oid)?;
94+
let commit = Commit::from_git(&commit);
5795
commits.push_front(commit);
5896
}
5997

98+
if commits.is_empty() {
99+
bail!("No Git commits within the provided range.");
100+
}
101+
60102
info!("Found {} commits within the provided range.", commits.len());
61103
Ok(Commits { commits })
62104
}

src/linting_results/mod.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
use std::collections::{HashMap, VecDeque};
2+
3+
use crate::commits::commit::Commit;
4+
5+
mod pretty;
6+
7+
/// The representation of an error that an individual commit can have.
8+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
9+
pub enum CommitError {
10+
/// Commit is a merge commit (has multiple parents).
11+
MergeCommit,
12+
}
13+
14+
/// The representation of an error for the collection of commits as a whole.
15+
#[derive(Debug, Clone, PartialEq, Eq)]
16+
pub enum CommitsError {
17+
/// The number of commits exceeds the maximum allowed.
18+
MaxCommitsExceeded { max_commits: usize, actual_commits: usize },
19+
}
20+
21+
/// Per-commit linting errors.
22+
pub struct CommitErrors {
23+
pub(crate) order: VecDeque<Commit>,
24+
pub(crate) errors: HashMap<Commit, Vec<CommitError>>,
25+
}
26+
27+
impl CommitErrors {
28+
pub(crate) fn new(
29+
order: VecDeque<Commit>,
30+
errors: HashMap<Commit, Vec<CommitError>>,
31+
) -> Self {
32+
CommitErrors { order, errors }
33+
}
34+
}
35+
36+
/// A representation of all linting errors found in the range of commits.
37+
pub struct LintingResults {
38+
pub commit_errors: Option<CommitErrors>,
39+
pub commits_errors: Option<Vec<CommitsError>>,
40+
}
41+
42+
impl LintingResults {
43+
/// Get a pretty representation of the linting results as a string, suitable as output for a user.
44+
pub fn pretty(&self) -> String {
45+
pretty::print_all(self)
46+
}
47+
}

src/linting_results/pretty.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
use std::fmt::Write;
2+
3+
use ansi_term::Colour::Red;
4+
5+
use super::{CommitError, CommitsError, LintingResults};
6+
7+
pub(crate) fn print_all(results: &LintingResults) -> String {
8+
let mut output = String::new();
9+
let red = Red.bold();
10+
11+
// Print per-commit errors
12+
if let Some(commit_errors) = &results.commit_errors {
13+
for commit in &commit_errors.order {
14+
if let Some(errors) = commit_errors.errors.get(commit) {
15+
let _ = writeln!(output, "{} - {}", red.paint("Commit Hash"), commit.hash);
16+
let _ = writeln!(output, "{} - {:?}", red.paint("Message"), commit.message);
17+
18+
for error in errors {
19+
match error {
20+
CommitError::MergeCommit => {
21+
let _ = writeln!(
22+
output,
23+
"\t{} - Commit is a merge commit.",
24+
red.paint("X")
25+
);
26+
}
27+
}
28+
}
29+
30+
let _ = writeln!(output);
31+
}
32+
}
33+
34+
// Print summary of commit errors
35+
let total_linting_errors: usize =
36+
commit_errors.errors.values().map(|x| x.len()).sum();
37+
38+
let _ = writeln!(
39+
output,
40+
"{} - Found {total_linting_errors} separate linting errors across {} commits.",
41+
red.paint("X"),
42+
commit_errors.errors.len()
43+
);
44+
}
45+
46+
// Print aggregate errors
47+
if let Some(commits_errors) = &results.commits_errors {
48+
for commits_error in commits_errors {
49+
match commits_error {
50+
CommitsError::MaxCommitsExceeded {
51+
max_commits,
52+
actual_commits,
53+
} => {
54+
let _ = writeln!(
55+
output,
56+
"{} - Maximum commits exceeded: found {} commits, but maximum allowed is {}.",
57+
red.paint("X"),
58+
actual_commits,
59+
max_commits
60+
);
61+
}
62+
}
63+
}
64+
}
65+
66+
output
67+
}

src/main.rs

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
extern crate log;
33
extern crate pretty_env_logger;
44

5-
use anyhow::{bail, Context, Result};
5+
use anyhow::{Context, Result};
66
use clap::Parser;
77
use git2::Repository;
88

99
mod commits;
10+
mod linting_results;
11+
mod output;
1012

1113
use crate::commits::Commits;
14+
use crate::output::Output;
1215

1316
const ERROR_EXIT_CODE: i32 = 1;
1417

@@ -27,6 +30,13 @@ pub(crate) struct Arguments {
2730
)]
2831
pub(crate) verbose: bool,
2932

33+
#[arg(
34+
long,
35+
default_value = "default",
36+
help = "Specifies the format for outputting results, acceptable values are quiet, default, and pretty."
37+
)]
38+
pub(crate) output: Output,
39+
3040
#[arg(
3141
help = "The Git reference from where to start taking the range of commits from till HEAD to lint. The range is inclusive of HEAD and exclusive of the provided reference.",
3242
default_value = "origin/HEAD"
@@ -47,29 +57,34 @@ fn main() {
4757
info!("Version {}.", env!("CARGO_PKG_VERSION"));
4858
debug!("The command line arguments provided are {arguments:?}.");
4959

50-
if let Err(err) = run(arguments) {
51-
error!("{err:?}");
52-
std::process::exit(ERROR_EXIT_CODE);
60+
match run(arguments) {
61+
Ok(exit_code) => std::process::exit(exit_code),
62+
Err(err) => {
63+
error!("{err:?}");
64+
std::process::exit(ERROR_EXIT_CODE);
65+
}
5366
}
5467
}
5568

56-
fn run(arguments: Arguments) -> Result<()> {
69+
fn run(arguments: Arguments) -> Result<i32> {
5770
let repository = Repository::open_from_env().context("Unable to open the Git repository.")?;
5871
let commits = Commits::from_git(&repository, arguments.from)?;
5972

60-
if commits.contains_merge_commits() {
61-
bail!("Contains merge commits.");
62-
}
63-
64-
if let Some(max_commits) = arguments.max_commits {
65-
if commits.len() > max_commits {
66-
bail!(format!(
67-
"Exceeded the maximum number of commits {:?} with {:?} commits.",
68-
arguments.max_commits,
69-
commits.len()
70-
));
73+
if let Some(linting_results) = commits.lint(arguments.max_commits) {
74+
match arguments.output {
75+
Output::Quiet => {}
76+
Output::Default => {
77+
println!("{}", linting_results.pretty());
78+
}
79+
Output::Pretty => {
80+
println!("{}", linting_results.pretty());
81+
}
7182
}
83+
84+
// As we don't want an error printed but linting failed so want to exit unsuccessfully.
85+
return Ok(1);
7286
}
7387

74-
Ok(())
88+
info!("Successfully linted all commits.");
89+
Ok(0)
7590
}

src/output.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
use clap::ValueEnum;
2+
3+
#[derive(Clone, Debug, PartialEq, ValueEnum)]
4+
pub enum Output {
5+
Quiet,
6+
Default,
7+
Pretty,
8+
}

0 commit comments

Comments
 (0)