use std::collections::HashMap;
use async_trait::async_trait;
use gix::revision::walk::Sorting;
use gix::traverse::commit::simple::CommitTimeOrder;
use crate::payload::{
Bar, BarsData, Body, EntriesData, Entry, MarkdownTextBlockData, Payload, TextBlockData,
};
use crate::render::Shape;
use crate::samples;
use super::super::{FetchContext, FetchError, Fetcher, Safety};
use super::{fail, open_repo, payload, repo_cache_key, text_body};
const SHAPES: &[Shape] = &[
Shape::Bars,
Shape::Entries,
Shape::TextBlock,
Shape::MarkdownTextBlock,
Shape::Text,
];
const DEFAULT_DAYS: u64 = 30;
const MAX_ENTRIES: usize = 10;
pub struct GitContributors;
#[async_trait]
impl Fetcher for GitContributors {
fn name(&self) -> &str {
"git_contributors"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Top commit authors over the last N days (default 30, override with `format = \"N\"`), ranked by commit count and capped at ten. Bars/Entries/TextBlock/MarkdownTextBlock all carry the ranking; Text is the headline summary."
}
fn refresh_interval(&self) -> u64 {
60 * 30
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn default_shape(&self) -> Shape {
Shape::Bars
}
fn cache_key(&self, ctx: &FetchContext) -> String {
repo_cache_key(self.name(), ctx)
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
Some(match shape {
Shape::Bars => {
samples::bars(&[("alice", 42), ("bob", 28), ("charlie", 17), ("dave", 9)])
}
Shape::Entries => samples::entries(&[
("alice", "42"),
("bob", "28"),
("charlie", "17"),
("dave", "9"),
]),
Shape::TextBlock => {
samples::text_block(&["alice 42", "bob 28", "charlie 17", "dave 9"])
}
Shape::MarkdownTextBlock => samples::markdown(
"1. **alice** — 42\n2. **bob** — 28\n3. **charlie** — 17\n4. **dave** — 9",
),
Shape::Text => samples::text("alice (42), bob (28), charlie (17), dave (9)"),
_ => return None,
})
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let repo = open_repo()?;
let days = parse_days(ctx.format.as_deref());
let ranked = contributors(&repo, days, ctx.timezone.as_deref())?;
Ok(payload(render_body(
ranked,
ctx.shape.unwrap_or(Shape::Bars),
)))
}
}
fn contributors(
repo: &gix::Repository,
days: u64,
timezone: Option<&str>,
) -> Result<Vec<(String, u64)>, FetchError> {
let Ok(head_id) = repo.head_id() else {
return Ok(Vec::new());
};
let cutoff = crate::time::now_in(timezone).timestamp() - (days as i64) * 86_400;
let walker = repo
.rev_walk([head_id.detach()])
.sorting(Sorting::ByCommitTimeCutoff {
seconds: cutoff,
order: CommitTimeOrder::NewestFirst,
})
.all()
.map_err(fail)?;
let mut counts: HashMap<String, u64> = HashMap::new();
for info in walker {
let Ok(info) = info else { continue };
let Ok(commit) = repo.find_commit(info.id) else {
continue;
};
let Ok(author) = commit.author() else {
continue;
};
let name = author.name.to_string();
*counts.entry(name).or_insert(0) += 1;
}
let mut ranked: Vec<_> = counts.into_iter().collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
ranked.truncate(MAX_ENTRIES);
Ok(ranked)
}
fn parse_days(format: Option<&str>) -> u64 {
format
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_DAYS)
}
fn render_body(ranked: Vec<(String, u64)>, shape: Shape) -> Body {
match shape {
Shape::Entries => Body::Entries(EntriesData {
items: ranked
.into_iter()
.map(|(name, count)| Entry {
key: name,
value: Some(count.to_string()),
status: None,
})
.collect(),
}),
Shape::TextBlock => Body::TextBlock(TextBlockData {
lines: ranked
.into_iter()
.map(|(name, count)| format!("{name} {count}"))
.collect(),
}),
Shape::MarkdownTextBlock => Body::MarkdownTextBlock(MarkdownTextBlockData {
value: ranked
.into_iter()
.enumerate()
.map(|(i, (name, count))| format!("{}. **{name}** — {count}", i + 1))
.collect::<Vec<_>>()
.join("\n"),
}),
Shape::Text => {
if ranked.is_empty() {
text_body("")
} else {
let line = ranked
.into_iter()
.map(|(name, count)| format!("{name} ({count})"))
.collect::<Vec<_>>()
.join(", ");
text_body(line)
}
}
_ => Body::Bars(BarsData {
bars: ranked
.into_iter()
.map(|(name, count)| Bar {
label: name,
value: count,
value_label: None,
})
.collect(),
}),
}
}
#[cfg(test)]
mod tests {
use std::future::Future;
use super::super::test_support::{commit_as, make_repo};
use super::*;
fn run_async<T>(future: impl Future<Output = T>) -> T {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(future)
}
fn ctx(shape: Option<Shape>, format: Option<&str>) -> FetchContext {
FetchContext {
shape,
format: format.map(str::to_string),
..FetchContext::default()
}
}
#[test]
fn fetcher_contract_and_samples_cover_supported_shapes() {
let fetcher = GitContributors;
let text_key = fetcher.cache_key(&ctx(Some(Shape::Text), Some("7")));
let bars_key = fetcher.cache_key(&ctx(Some(Shape::Bars), Some("30")));
assert_eq!(fetcher.name(), "git_contributors");
assert_eq!(fetcher.safety(), Safety::Safe);
assert!(fetcher.description().contains("Top commit authors"));
assert_eq!(fetcher.shapes(), SHAPES);
assert_eq!(fetcher.default_shape(), Shape::Bars);
assert!(text_key.starts_with("git_contributors-"));
assert_ne!(text_key, bars_key);
assert_eq!(
fetcher.sample_body(Shape::Bars),
Some(samples::bars(&[
("alice", 42),
("bob", 28),
("charlie", 17),
("dave", 9),
]))
);
assert_eq!(
fetcher.sample_body(Shape::Entries),
Some(samples::entries(&[
("alice", "42"),
("bob", "28"),
("charlie", "17"),
("dave", "9"),
]))
);
assert_eq!(
fetcher.sample_body(Shape::TextBlock),
Some(samples::text_block(&[
"alice 42",
"bob 28",
"charlie 17",
"dave 9",
]))
);
assert_eq!(
fetcher.sample_body(Shape::MarkdownTextBlock),
Some(samples::markdown(
"1. **alice** — 42\n2. **bob** — 28\n3. **charlie** — 17\n4. **dave** — 9",
))
);
assert_eq!(
fetcher.sample_body(Shape::Text),
Some(samples::text(
"alice (42), bob (28), charlie (17), dave (9)"
))
);
assert!(fetcher.sample_body(Shape::Badge).is_none());
}
#[test]
fn empty_repo_returns_empty() {
let (_tmp, repo) = make_repo();
assert!(contributors(&repo, 30, None).unwrap().is_empty());
}
#[test]
fn tallies_authors_in_rank_order() {
let (_tmp, repo) = make_repo();
commit_as(&repo, "a1", "alice", "a@example.com");
commit_as(&repo, "b1", "bob", "b@example.com");
commit_as(&repo, "a2", "alice", "a@example.com");
commit_as(&repo, "a3", "alice", "a@example.com");
let ranked = contributors(&repo, 30, None).unwrap();
assert_eq!(ranked, vec![("alice".into(), 3u64), ("bob".into(), 1u64)]);
}
#[test]
fn bars_shape_from_ranking() {
assert_eq!(
render_body(vec![("alice".into(), 3), ("bob".into(), 1)], Shape::Bars),
Body::Bars(BarsData {
bars: vec![
Bar {
label: "alice".into(),
value: 3,
value_label: None
},
Bar {
label: "bob".into(),
value: 1,
value_label: None
},
],
})
);
}
#[test]
fn entries_shape_uses_name_count_rows() {
assert_eq!(
render_body(vec![("alice".into(), 3), ("bob".into(), 1)], Shape::Entries),
Body::Entries(EntriesData {
items: vec![
Entry {
key: "alice".into(),
value: Some("3".into()),
status: None,
},
Entry {
key: "bob".into(),
value: Some("1".into()),
status: None,
},
],
})
);
}
#[test]
fn text_shape_empty_when_empty() {
assert_eq!(render_body(Vec::new(), Shape::Text), text_body(""));
}
#[test]
fn text_shape_joins_with_counts() {
assert_eq!(
render_body(vec![("alice".into(), 3), ("bob".into(), 1)], Shape::Text),
text_body("alice (3), bob (1)")
);
}
#[test]
fn text_block_lists_one_row_per_contributor() {
assert_eq!(
render_body(
vec![("alice".into(), 3), ("bob".into(), 1)],
Shape::TextBlock
),
Body::TextBlock(TextBlockData {
lines: vec!["alice 3".into(), "bob 1".into()],
})
);
}
#[test]
fn markdown_text_block_emits_numbered_ranking() {
assert_eq!(
render_body(
vec![("alice".into(), 3), ("bob".into(), 1)],
Shape::MarkdownTextBlock,
),
Body::MarkdownTextBlock(MarkdownTextBlockData {
value: "1. **alice** — 3\n2. **bob** — 1".into(),
})
);
}
#[test]
fn ranking_tie_breaks_alphabetically_and_truncates() {
let (_tmp, repo) = make_repo();
[
"zoe", "amy", "luz", "bob", "kai", "eve", "mia", "ian", "ned", "uma", "zzz",
]
.into_iter()
.for_each(|name| commit_as(&repo, name, name, &format!("{name}@example.com")));
let ranked = contributors(&repo, 30, None).unwrap();
let names: Vec<_> = ranked.into_iter().map(|(name, _)| name).collect();
assert_eq!(names.len(), MAX_ENTRIES);
assert_eq!(
names,
vec![
"amy", "bob", "eve", "ian", "kai", "luz", "mia", "ned", "uma", "zoe",
]
);
}
#[test]
fn fetch_reads_cwd_repo_for_default_and_requested_shapes() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (_tmp, repo) = make_repo();
commit_as(&repo, "first", "alice", "a@example.com");
commit_as(&repo, "second", "bob", "b@example.com");
let workdir = repo.workdir().unwrap().to_path_buf();
let prev_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&workdir).unwrap();
let bars = run_async(GitContributors.fetch(&ctx(None, Some("3650"))));
let entries = run_async(GitContributors.fetch(&ctx(Some(Shape::Entries), Some("3650"))));
std::env::set_current_dir(prev_cwd).unwrap();
assert!(matches!(
bars.unwrap().body,
Body::Bars(BarsData { bars }) if bars.len() == 2
));
assert!(matches!(
entries.unwrap().body,
Body::Entries(EntriesData { items })
if items.len() == 2 && items.iter().all(|item| item.value.is_some())
));
}
#[test]
fn parse_days_defaults_and_parses() {
assert_eq!(parse_days(None), DEFAULT_DAYS);
assert_eq!(parse_days(Some("0")), DEFAULT_DAYS);
assert_eq!(parse_days(Some(" 14 ")), 14);
assert_eq!(parse_days(Some("7")), 7);
}
}