use git_stats::app;
use git_stats::model::{Author, CommitMeta, DiffStat, Options, SortBy, Trailer};
use git_stats::repo::{NulledCommit, Repo};
fn commit(name: &str, email: &str, time: i64, diff: DiffStat) -> NulledCommit {
NulledCommit {
meta: CommitMeta {
author: Author {
name: name.to_string(),
email: email.to_string(),
},
time_seconds: time,
trailers: Vec::new(),
},
diff,
is_merge: false,
}
}
fn diff(insertions: u64, deletions: u64, files: u64) -> DiffStat {
DiffStat {
insertions,
deletions,
files,
}
}
fn options() -> Options {
Options {
range: "HEAD".to_string(),
email: false,
reviews: false,
sort: SortBy::Commits,
reverse: false,
authors: Vec::new(),
since: None,
until: None,
}
}
fn report(repo: &Repo, opts: &Options) -> String {
yansi::disable();
app::run(repo, opts).unwrap()
}
fn row<'a>(out: &'a str, label: &str) -> &'a str {
out.lines()
.find(|l| l.trim_start().starts_with(label))
.unwrap_or_else(|| panic!("no row starting with {label:?} in:\n{out}"))
}
fn two_author_repo() -> Repo {
Repo::create_null(vec![
commit("Ada Lovelace", "ada@x", 100, diff(10, 2, 3)),
commit("Ada Lovelace", "ada@x", 200, diff(5, 5, 1)),
commit("Bob Ross", "bob@x", 300, diff(1, 0, 1)),
])
}
#[test]
fn sums_per_author_insertions_deletions_and_net() {
let out = report(&two_author_repo(), &options());
let ada = row(&out, "Ada Lovelace");
assert!(
ada.contains("+15") && ada.contains("-7") && ada.contains("+8"),
"ada row should show +15 / -7 / +8: {ada}"
);
}
#[test]
fn appends_a_total_row_over_all_authors() {
let out = report(&two_author_repo(), &options());
let total = row(&out, "Total");
assert!(
total.contains("+16") && total.contains("+9"),
"total row should show +16 insertions and +9 net: {total}"
);
}
#[test]
fn since_filters_out_older_commits() {
let repo = Repo::create_null(vec![
commit("Olda", "olda@x", 1_577_836_800, diff(1, 0, 1)),
commit("Newman", "newman@x", 1_704_067_200, diff(1, 0, 1)),
]);
let mut opts = options();
opts.since = Some("2022-01-01".to_string());
let out = report(&repo, &opts);
assert!(out.contains("Newman"), "newer commit should remain:\n{out}");
assert!(
!out.contains("Olda"),
"older commit should be filtered:\n{out}"
);
}
#[test]
fn author_filter_keeps_only_matching_authors() {
let repo = Repo::create_null(vec![
commit("Alice", "alice@x", 100, diff(1, 0, 1)),
commit("Bob", "bob@x", 200, diff(1, 0, 1)),
]);
let mut opts = options();
opts.authors = vec!["Alice".to_string()];
let out = report(&repo, &opts);
assert!(
out.contains("Alice"),
"matching author should remain:\n{out}"
);
assert!(
!out.contains("Bob"),
"non-matching author should be gone:\n{out}"
);
}
#[test]
fn empty_repo_renders_nothing() {
let repo = Repo::create_null(vec![]);
assert_eq!(report(&repo, &options()), "");
}
#[test]
fn merge_commits_count_but_contribute_no_lines() {
let mut merge = commit("Ada", "ada@x", 100, diff(999, 999, 9));
merge.is_merge = true;
let repo = Repo::create_null(vec![merge, commit("Ada", "ada@x", 200, diff(4, 1, 2))]);
let out = report(&repo, &options());
let ada = row(&out, "Ada");
assert!(ada.contains("+4"), "ada row: {ada}");
assert!(ada.contains("-1"), "ada row: {ada}");
assert!(ada.contains("+3"), "ada row: {ada}");
assert!(
!out.contains("999"),
"merge stats leaked into output:\n{out}"
);
}
#[test]
fn email_flag_splits_same_name_distinct_emails() {
let commits = vec![
commit("Luke", "luke@a", 100, diff(1, 0, 1)),
commit("Luke", "luke@b", 200, diff(2, 0, 1)),
];
let merged = report(&Repo::create_null(commits.clone()), &options());
assert!(row(&merged, "Luke").contains("+3"), "merged: {merged}");
assert!(
!merged.contains("luke@a"),
"email leaked without flag: {merged}"
);
let mut with_email = options();
with_email.email = true;
let split = report(&Repo::create_null(commits), &with_email);
assert!(split.contains("luke@a"), "split: {split}");
assert!(split.contains("luke@b"), "split: {split}");
}
#[test]
fn reviews_credit_a_reviewer_once_per_commit() {
let mut reviewed = commit("Ada", "ada@x", 100, diff(1, 0, 1));
reviewed.meta.trailers = vec![
Trailer {
token: "Reviewed-by".to_string(),
value: "Rev Iewer <rev@x>".to_string(),
},
Trailer {
token: "Tested-by".to_string(),
value: "Rev Iewer <rev@x>".to_string(),
},
Trailer {
token: "Signed-off-by".to_string(),
value: "Sole Author <sole@x>".to_string(),
},
];
let mut with_reviews = options();
with_reviews.reviews = true;
let out = report(&Repo::create_null(vec![reviewed]), &with_reviews);
assert!(out.contains("Reviewer/Tester"), "no reviews table:\n{out}");
let rev = row(&out, "Rev Iewer");
assert_eq!(rev.split_whitespace().last(), Some("1"), "rev row: {rev}");
assert!(
!out.contains("Sole Author"),
"non-review trailer leaked:\n{out}"
);
}
#[test]
fn reverse_flips_sort_order() {
let repo = Repo::create_null(vec![
commit("Ada", "ada@x", 100, diff(1, 0, 1)),
commit("Ada", "ada@x", 200, diff(1, 0, 1)),
commit("Bob", "bob@x", 300, diff(1, 0, 1)),
]);
let desc = report(&repo, &options());
assert!(
desc.find("Ada").unwrap() < desc.find("Bob").unwrap(),
"descending order wrong:\n{desc}"
);
let mut reversed = options();
reversed.reverse = true;
let asc = report(&repo, &reversed);
assert!(
asc.find("Bob").unwrap() < asc.find("Ada").unwrap(),
"reverse failed:\n{asc}"
);
}