use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use contributor_graphs::{analyze, html, model, svg, Analysis, Config, Contributor, Sort};
use model::{format_month_year, thousands};
use std::path::PathBuf;
#[derive(Parser)]
#[command(version, about, arg_required_else_help = true)]
struct Args {
repo: String,
#[arg(short, long, default_value = ".")]
output_dir: PathBuf,
#[arg(long)]
basename: Option<String>,
#[arg(long)]
title: Option<String>,
#[arg(short, long)]
branch: Option<String>,
#[arg(long)]
since: Option<String>,
#[arg(long)]
until: Option<String>,
#[arg(long)]
no_merges: bool,
#[arg(long, default_value_t = 1)]
min_commits: u32,
#[arg(long, default_value_t = 0)]
min_span_days: i64,
#[arg(long, default_value_t = 40)]
max_contributors: usize,
#[arg(long)]
include_bots: bool,
#[arg(long)]
exclude: Vec<String>,
#[arg(long)]
groups: Option<PathBuf>,
#[arg(long)]
identities: Option<PathBuf>,
#[arg(long)]
no_github: bool,
#[arg(long)]
no_affiliation: bool,
#[arg(long)]
no_name_merge: bool,
#[arg(long)]
no_embed_avatars: bool,
#[arg(long, default_value_t = 1100.0)]
width: f64,
#[arg(long)]
by_affiliation: bool,
#[arg(long, default_value = "Unaffiliated")]
unaffiliated_label: String,
#[arg(long, value_enum, default_value = "first")]
sort: SortKey,
#[arg(long, value_enum, default_value = "both")]
format: Format,
#[arg(long, default_value = "#2f6feb")]
accent: String,
#[arg(long, value_enum, default_value = "light")]
theme: SvgTheme,
#[arg(long)]
open: bool,
}
#[derive(Copy, Clone, PartialEq, ValueEnum)]
enum SortKey {
First,
Last,
Commits,
Duration,
Name,
}
impl From<SortKey> for Sort {
fn from(k: SortKey) -> Sort {
match k {
SortKey::First => Sort::First,
SortKey::Last => Sort::Last,
SortKey::Commits => Sort::Commits,
SortKey::Duration => Sort::Duration,
SortKey::Name => Sort::Name,
}
}
}
#[derive(Copy, Clone, PartialEq, ValueEnum)]
enum Format {
Svg,
Html,
Both,
}
#[derive(Copy, Clone, PartialEq, ValueEnum)]
enum SvgTheme {
Light,
Dark,
}
fn read_tsv(path: &PathBuf) -> Result<Vec<Vec<String>>> {
let text =
std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
Ok(text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| l.split('\t').map(|f| f.trim().to_string()).collect())
.collect())
}
fn main() -> Result<()> {
let args = Args::parse();
let started = std::time::Instant::now();
eprintln!("contributor-graphs");
if args.accent.contains(['"', '<', '>', '&']) {
anyhow::bail!("invalid --accent colour: {:?}", args.accent);
}
let groups = match &args.groups {
Some(path) => read_tsv(path)?
.into_iter()
.filter(|r| r.len() >= 2)
.map(|r| (r[0].clone(), r[1].clone()))
.collect(),
None => Vec::new(),
};
let identities = match &args.identities {
Some(path) => read_tsv(path)?,
None => Vec::new(),
};
let cfg = Config {
branch: args.branch.clone(),
since: args.since.clone(),
until: args.until.clone(),
no_merges: args.no_merges,
title: args.title.clone(),
exclude: args.exclude.clone(),
groups,
identities,
use_github: !args.no_github,
detect_affiliation: !args.no_affiliation,
merge_names: !args.no_name_merge,
embed_avatars: !args.no_embed_avatars,
avatar_size: 64,
verbose: true,
};
let Analysis { contributors, meta } = analyze(&args.repo, &cfg)?;
std::fs::create_dir_all(&args.output_dir)?;
let basename = args
.basename
.clone()
.unwrap_or_else(|| contributor_graphs::repo::sanitize(&meta.name));
if matches!(args.format, Format::Svg | Format::Both) {
let base: Vec<Contributor> = contributors
.iter()
.filter(|c| args.include_bots || !c.bot)
.cloned()
.collect();
let mut rows: Vec<Contributor> = if args.by_affiliation {
model::aggregate_by_group(&base, &args.unaffiliated_label)
} else {
base
};
let min_span = args.min_span_days * 86400;
rows.retain(|c| c.commits >= args.min_commits && (c.last - c.first) >= min_span);
if rows.is_empty() {
eprintln!(" warning: no contributors matched the filters; SVG will be empty");
}
let eligible = rows.len();
if rows.len() > args.max_contributors {
rows.sort_by_key(|c| std::cmp::Reverse(c.commits));
rows.truncate(args.max_contributors);
}
contributor_graphs::sort(&mut rows, args.sort.into());
let unit = if args.by_affiliation {
"affiliations"
} else {
"contributors"
};
let mut notes = vec![
if args.by_affiliation {
format!("{} affiliations", eligible)
} else {
format!("{} contributors", meta.total_contributors)
},
format!("{} commits", thousands(meta.total_commits)),
format!(
"{} – {}",
format_month_year(meta.first),
format_month_year(meta.last)
),
];
if rows.len() < eligible {
notes.push(format!("showing top {} {unit} by commits", rows.len()));
} else if args.min_commits > 1 {
notes.push(format!("≥{} commits", args.min_commits));
}
let opts = svg::SvgOptions {
width: args.width,
title: meta.name.clone(),
subtitle: notes.join(" · "),
footer_left: meta
.url
.clone()
.map(|u| u.trim_start_matches("https://").to_string())
.unwrap_or_else(|| format!("branch {}", meta.branch)),
footer_right: format!("{} · Generated by ewels/contributor-graphs", meta.generated),
accent: args.accent.clone(),
by_affiliation: args.by_affiliation,
dark: args.theme == SvgTheme::Dark,
};
let svg_str = svg::render_svg(&rows, &opts);
let path = args.output_dir.join(format!("{basename}.svg"));
std::fs::write(&path, &svg_str)?;
eprintln!(
"→ wrote {} ({} rows, {} KB)",
path.display(),
rows.len(),
svg_str.len() / 1024
);
}
if matches!(args.format, Format::Html | Format::Both) {
let mut all = contributors.clone();
contributor_graphs::sort(&mut all, Sort::First);
let html_opts = html::HtmlOptions {
accent: args.accent.clone(),
by_affiliation: args.by_affiliation,
unaffiliated_label: args.unaffiliated_label.clone(),
};
let html_str = html::render_html(&meta, &all, &html_opts);
let path = args.output_dir.join(format!("{basename}.html"));
std::fs::write(&path, &html_str)?;
eprintln!(
"→ wrote {} ({} contributors, {} KB)",
path.display(),
all.len(),
html_str.len() / 1024
);
if args.open {
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(&path).status();
#[cfg(not(target_os = "macos"))]
let _ = std::process::Command::new("xdg-open").arg(&path).status();
}
}
eprintln!("✓ done in {:.1}s", started.elapsed().as_secs_f64());
Ok(())
}