Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 69 additions & 8 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,23 @@ use mailmap::{Author, Mailmap};
use regex::{Regex, RegexBuilder};
use semver::Version;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Read;
use std::io::{BufWriter, Read};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::{cmp, fmt, str};

use std::str::FromStr;
use config::Config;
use reviewers::Reviewers;

mod config;
mod error;
mod reviewers;
mod site;
mod score;

use error::ErrorContext;
use crate::score::{author_map_to_scores, AuthorScore};

/// Convert a commit signature to an `Author`.
///
Expand Down Expand Up @@ -93,6 +95,21 @@ impl AuthorMap {
}
}

pub struct AuthorsWithScores {
pub authors: AuthorMap,
pub scores: Vec<AuthorScore>
}

impl AuthorsWithScores {
fn new(authors: AuthorMap) -> Self {
let scores = author_map_to_scores(&authors);
Self {
authors,
scores
}
}
}

/// Run a `git` command with the given arguments.
///
/// # Panics
Expand Down Expand Up @@ -667,21 +684,65 @@ fn generate_thanks() -> Result<BTreeMap<VersionTag, AuthorMap>, Box<dyn std::err
Ok(version_map)
}

fn run() -> Result<(), Box<dyn std::error::Error>> {
enum OutputMode {
Html,
Csv
}

impl FromStr for OutputMode {
type Err = String;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"html" => Ok(Self::Html),
"csv" => Ok(Self::Csv),
_ => Err(format!("Invalid output mode {s}. Possible values: `html` or `csv`."))
}
}
}

fn run(mode: OutputMode) -> Result<(), Box<dyn std::error::Error>> {
let by_version = generate_thanks()?;
let by_version: BTreeMap<_, _> = by_version
.into_iter()
.map(|(k, v)| (k, AuthorsWithScores::new(v)))
.collect();

let mut all_time = by_version.values().next().unwrap().clone();
for map in by_version.values().skip(1) {
all_time.extend(map.clone());
let mut all_time = by_version.values().next().unwrap().authors.clone();
for authors in by_version.values().skip(1) {
all_time.extend(authors.authors.clone());
}

site::render(by_version, all_time)?;
match mode {
OutputMode::Html => {
site::render(by_version, AuthorsWithScores::new(all_time))?;
}
OutputMode::Csv => {
use std::io::Write;

let directory = PathBuf::from("output/csv");
std::fs::create_dir_all(&directory)?;
for (version, authors) in by_version {
let mut file = BufWriter::new(std::fs::File::create(directory.join(format!("{version}.csv")))?);
for score in authors.scores {
let AuthorScore { rank, author, email, commits } = score;
writeln!(file, "{rank},{author},{email},{commits}")?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the email is always going to be reported for CSV mode, then under DEBUG_EMAILS it will be output twice

@Kobzol Kobzol Aug 11, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's fine, it's just a debugging thing, it shouldn't be enabled on CI or when re-blessing the file (but good catch!).

}
}
}
}

Ok(())
}

fn main() {
if let Err(err) = run() {
let mode = std::env::args().skip(1).next();
let mode = match mode {
None => OutputMode::Html,
Some(mode) => mode.parse().unwrap()
};

if let Err(err) = run(mode) {
eprintln!("Error: {}", err);
let mut cur = &*err;
while let Some(cause) = cur.source() {
Expand Down
74 changes: 74 additions & 0 deletions src/score.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
use std::collections::HashMap;
use unicase::UniCase;
use crate::AuthorMap;

#[derive(serde::Serialize, Ord, PartialOrd, Eq, PartialEq)]
pub struct AuthorScore {
pub rank: u32,
pub author: String,
pub email: String,
pub commits: usize,
}

pub fn author_map_to_scores(map: &AuthorMap) -> Vec<AuthorScore> {
let debug_emails = std::env::var("DEBUG_EMAILS").is_ok_and(|value| value == "1");

let scores = map
.iter()
.map(|(author, commits)| {
let name = UniCase::into_inner(author.name.clone());

AuthorScore {
rank: 0,
author: if debug_emails {
format!("{name} ({})", UniCase::into_inner(author.email.clone()))
} else {
name
},
email: UniCase::into_inner(author.email.clone()),
commits,
}
})
.collect::<Vec<_>>();
let mut scores = deduplicate_scores(scores);
scores.sort_by_key(|e| (std::cmp::Reverse(e.commits), e.author.clone()));

let mut last_rank = 1;
let mut ranked_at_current = 0;
let mut last_commits = usize::MAX;
for entry in &mut scores {
if entry.commits < last_commits {
last_commits = entry.commits;
last_rank += ranked_at_current;
ranked_at_current = 1;
} else {
ranked_at_current += 1;
}
entry.rank = last_rank;
}
scores
}

/// Deduplicate scores based on the assumption that an e-mail uniquely identifies a given
/// person. If there are multiple entries with the same email, their commit counts will be
/// merged into a single entry, with the canonical name being chosen based on the entry with
/// the most commits.
fn deduplicate_scores(entries: Vec<AuthorScore>) -> Vec<AuthorScore> {
let mut entry_map: HashMap<String, Vec<AuthorScore>> = HashMap::with_capacity(entries.len());
for entry in entries {
entry_map.entry(entry.email.clone()).or_default().push(entry);
}

entry_map.into_values().map(|mut entry| {
// If there are multiple entries with the same maximum commit count, ensure that
// the ordering is stable, by sorting based on the whole entry.
entry.sort();
let canonical_entry = entry.iter().max_by_key(|entry| entry.commits).unwrap();
AuthorScore {
rank: 0,
author: canonical_entry.author.clone(),
email: canonical_entry.email.clone(),
commits: entry.iter().map(|e| e.commits).sum(),
}
}).collect()
}
107 changes: 15 additions & 92 deletions src/site.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
use crate::{AuthorMap, VersionTag};
use crate::score::AuthorScore;
use crate::{AuthorMap, AuthorsWithScores, VersionTag};
use handlebars::Handlebars;
use std::collections::{BTreeMap, HashMap};
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use unicase::UniCase;

pub fn render(
by_version: BTreeMap<VersionTag, AuthorMap>,
all_time_map: AuthorMap,
by_version: BTreeMap<VersionTag, AuthorsWithScores>,
all_time_map: AuthorsWithScores,
) -> Result<(), Box<dyn std::error::Error>> {
copy_public()?;
index(&all_time_map, &by_version)?;
index(&all_time_map.authors, &by_version)?;
about()?;
releases(&by_version, &all_time_map)?;

Expand Down Expand Up @@ -72,7 +72,7 @@ fn copy_public() -> Result<(), Box<dyn std::error::Error>> {

fn index(
all_time: &AuthorMap,
by_version: &BTreeMap<VersionTag, AuthorMap>,
by_version: &BTreeMap<VersionTag, AuthorsWithScores>,
) -> Result<(), Box<dyn std::error::Error>> {
#[derive(serde::Serialize)]
struct Release {
Expand All @@ -99,8 +99,8 @@ fn index(
releases.push(Release {
name: version.name.clone(),
url: format!("/rust/{}/", version.version),
people: stats.iter().count(),
commits: stats.iter().map(|(_, count)| count).sum(),
people: stats.authors.iter().count(),
commits: stats.authors.iter().map(|(_, count)| count).sum(),
});
}

Expand Down Expand Up @@ -135,99 +135,22 @@ fn about() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}

#[derive(serde::Serialize, Ord, PartialOrd, Eq, PartialEq)]
struct Entry {
rank: u32,
author: String,
email: String,
commits: usize,
}

fn author_map_to_scores(map: &AuthorMap) -> Vec<Entry> {
let debug_emails = std::env::var("DEBUG_EMAILS").is_ok_and(|value| value == "1");

let scores = map
.iter()
.map(|(author, commits)| {
let name = UniCase::into_inner(author.name.clone());

Entry {
rank: 0,
author: if debug_emails {
format!("{name} ({})", UniCase::into_inner(author.email.clone()))
} else {
name
},
email: UniCase::into_inner(author.email.clone()),
commits,
}
})
.collect::<Vec<_>>();
let mut scores = deduplicate_scores(scores);
scores.sort_by_key(|e| (std::cmp::Reverse(e.commits), e.author.clone()));

let mut last_rank = 1;
let mut ranked_at_current = 0;
let mut last_commits = usize::MAX;
for entry in &mut scores {
if entry.commits < last_commits {
last_commits = entry.commits;
last_rank += ranked_at_current;
ranked_at_current = 1;
} else {
ranked_at_current += 1;
}
entry.rank = last_rank;
}
scores
}

/// Deduplicate scores based on the assumption that an e-mail uniquely identifies a given
/// person. If there are multiple entries with the same email, their commit counts will be
/// merged into a single entry, with the canonical name being chosen based on the entry with
/// the most commits.
fn deduplicate_scores(entries: Vec<Entry>) -> Vec<Entry> {
let mut entry_map: HashMap<String, Vec<Entry>> = HashMap::with_capacity(entries.len());
for entry in entries {
entry_map
.entry(entry.email.clone())
.or_default()
.push(entry);
}

entry_map
.into_values()
.map(|mut entry| {
// If there are multiple entries with the same maximum commit count, ensure that
// the ordering is stable, by sorting based on the whole entry.
entry.sort();
let canonical_entry = entry.iter().max_by_key(|entry| entry.commits).unwrap();
Entry {
rank: 0,
author: canonical_entry.author.clone(),
email: canonical_entry.email.clone(),
commits: entry.iter().map(|e| e.commits).sum(),
}
})
.collect()
}

fn releases(
by_version: &BTreeMap<VersionTag, AuthorMap>,
all_time: &AuthorMap,
by_version: &BTreeMap<VersionTag, AuthorsWithScores>,
all_time: &AuthorsWithScores,
) -> Result<(), Box<dyn std::error::Error>> {
#[derive(serde::Serialize)]
struct Release {
struct Release<'a> {
common: CommonData,
release_title: String,
release: String,
count: usize,
scores: Vec<Entry>,
scores: &'a [AuthorScore],
in_progress: bool,
}
let hb = hb()?;
let scores = author_map_to_scores(all_time);

let scores = &all_time.scores;
let res = hb.render(
"stats",
&Release {
Expand All @@ -244,7 +167,7 @@ fn releases(
fs::write("output/rust/all-time/index.html", res)?;

for (version, map) in by_version {
let scores = author_map_to_scores(map);
let scores = &map.scores;
let res = hb.render(
"stats",
&Release {
Expand Down