diff --git a/src/analyse.rs b/src/analyse.rs index 2166970f..9b2c3e45 100644 --- a/src/analyse.rs +++ b/src/analyse.rs @@ -12,17 +12,50 @@ use std::path::PathBuf; use std::sync::{Arc, LazyLock, Mutex}; use std::time::Instant; +pub struct ProjectDisplayConfig { + /// Name of the project, displayed on the website. + name: &'static str, + /// Path under which the project will be available on the web. + /// If the `url` is e.g. `rust`, it will be available under `/rust/`. + url_path: &'static str, + /// Should this project be displayed as the main homepage project? + is_homepage: bool, + /// Returns true if the project does not track versions explicitly. + /// It will be rendered as a single page with all-time contributions. + is_versionless: bool, +} + +impl ProjectDisplayConfig { + /// Name of the project, displayed on the website. + pub fn name(&self) -> &'static str { + self.name + } + /// Path under which the project will be available on the web. + /// If the `url` is e.g. `rust`, it will be available under `/rust/`. + pub fn url_path(&self) -> &'static str { + self.url_path + } + /// Should this project be displayed as the main homepage project? + pub fn is_homepage(&self) -> bool { + self.is_homepage + } + /// Returns true if the project does not track versions explicitly. + /// It will be rendered as a single page with all-time contributions. + pub fn is_versionless(&self) -> bool { + self.is_versionless + } +} + pub struct ProjectData { - pub project: Box, + pub display_config: ProjectDisplayConfig, pub by_version: BTreeMap, pub all_time: AuthorsWithScores, } -pub fn compute_data( - project: Box, +pub fn compute_data( mailmap_path: Option, ) -> Result> { - let by_version = generate_thanks(project.as_ref(), mailmap_path)?; + let by_version = generate_thanks::

(mailmap_path)?; let by_version: BTreeMap<_, _> = by_version .into_iter() .map(|(k, v)| (k, AuthorsWithScores::new(v))) @@ -34,7 +67,12 @@ pub fn compute_data( } let all_time = AuthorsWithScores::new(all_time); Ok(ProjectData { - project, + display_config: ProjectDisplayConfig { + name: P::NAME, + url_path: P::URL_PATH, + is_homepage: P::IS_HOMEPAGE, + is_versionless: P::IS_VERSIONLESS, + }, by_version, all_time, }) diff --git a/src/git.rs b/src/git.rs index bc48f49d..de04bcb5 100644 --- a/src/git.rs +++ b/src/git.rs @@ -281,3 +281,12 @@ impl fmt::Debug for VersionTag { write!(f, "{}", self.version) } } + +/// Get the Oid of the latest commit to the given revision name +pub fn revision_latest_commit(repo: &Repository, revision: &str) -> Oid { + repo.revparse_single(revision) + .unwrap() + .peel_to_commit() + .unwrap() + .id() +} diff --git a/src/main.rs b/src/main.rs index 0555577a..d0648ee5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,19 +19,18 @@ mod score; mod site; use crate::analyse::{ - AuthorMap, AuthorsWithScores, build_author_map, compute_data, gather_all_commits, + AuthorMap, AuthorsWithScores, ProjectData, build_author_map, compute_data, gather_all_commits, }; use crate::git::{VersionTag, mailmap_from_repo, update_repo}; use crate::projects::{CratesIo, DocsRs, Project, Rust, Rustup}; use crate::score::AuthorScore; -fn generate_thanks( - project: &dyn Project, +fn generate_thanks( mailmap_path: Option, ) -> Result, Box> { - eprintln!("Generating {}", project.name()); + eprintln!("Generating {}", P::NAME); - let path = update_repo(project.repo_url())?; + let path = update_repo(P::REPO_URL)?; let repo = git2::Repository::open(&path)?; let mailmap = match mailmap_path { @@ -44,7 +43,7 @@ fn generate_thanks( }; let reviewers = Reviewers::new()?; - let versions = project.get_versions(&repo)?; + let versions = P::get_versions(&repo)?; let start = Instant::now(); @@ -57,8 +56,7 @@ fn generate_thanks( start.elapsed().as_secs_f64() ); - let ignored_emails: HashSet> = project - .ignored_emails() + let ignored_emails: HashSet> = P::IGNORED_EMAILS .iter() .map(|email| UniCase::new(email.to_string())) .collect(); @@ -87,33 +85,32 @@ fn generate_thanks( Ok(version_map) } -/// Return all projects for which we currently generate contribution statistics. -fn get_all_projects() -> Vec> { - vec![ - Box::new(Rust), - Box::new(Rustup), - Box::new(CratesIo), - Box::new(DocsRs), - ] -} - fn run( mode: OutputMode, mailmap_path: Option, selected_project: Option<&str>, ) -> Result<(), Box> { - let mut projects = get_all_projects(); - if let Some(selected) = selected_project { - projects.retain(|p| p.name().to_lowercase() == selected); - if projects.is_empty() { - panic!("No projects found with name {selected}"); + macro_rules! known_projects { + ( $( $name:literal => $project:ty, )+ ) => { + match selected_project { + $( + Some($name) => vec![compute_data::<$project>(mailmap_path.clone())?], + )+ + Some(selected) => panic!("No projects found with name {selected}"), + None => vec![ + $( + compute_data::<$project>(mailmap_path.clone())? + ),+ + ] + } } } - - let mut data = vec![]; - for project in projects { - data.push(compute_data(project, mailmap_path.clone())?); - } + let data: Vec = known_projects! { + "rust" => Rust, + "rustup" => Rustup, + "crates.io" => CratesIo, + "docs.rs" => DocsRs, + }; match mode { OutputMode::Html => { @@ -140,7 +137,7 @@ fn run( let csv_dir = PathBuf::from("output/csv"); for data in data { - let directory = csv_dir.join(data.project.name().to_lowercase()); + let directory = csv_dir.join(data.display_config.name().to_lowercase()); std::fs::create_dir_all(&directory)?; for (version, authors) in data.by_version { write(&directory.join(format!("{version}.csv")), authors)?; diff --git a/src/projects.rs b/src/projects.rs index e34d50bc..8b5866f8 100644 --- a/src/projects.rs +++ b/src/projects.rs @@ -1,67 +1,44 @@ -use crate::git::{VersionTag, get_versions}; +use crate::git::{VersionTag, get_versions, revision_latest_commit}; use git2::Repository; use semver::Version; pub trait Project { /// Name of the project, displayed on the website. - fn name(&self) -> &'static str; + const NAME: &'static str; /// Path under which the project will be available on the web. /// If the `url` is e.g. `rust`, it will be available under `/rust/`. - fn url_path(&self) -> &'static str; + const URL_PATH: &'static str; /// Should this project be displayed as the main homepage project? - fn is_homepage(&self) -> bool { - false - } + const IS_HOMEPAGE: bool = false; /// URL of its GitHub repository. - fn repo_url(&self) -> &'static str; + const REPO_URL: &'static str; /// Identify the versions that have been tagged in the given repo, including /// any project-specific additional versions to add. - fn get_versions( - &self, - repo: &Repository, - ) -> Result, Box>; + fn get_versions(repo: &Repository) -> Result, Box>; - /// Returns true if the project does not track versions explicitly. + /// true if the project does not track versions explicitly. /// It will be rendered as a single page with all-time contributions. - fn is_versionless(&self) -> bool { - false - } + const IS_VERSIONLESS: bool = false; /// Contributions from users with these e-mail addresses will be ignored. /// The addresses will be compared in a case-insensitive manner. - fn ignored_emails(&self) -> &'static [&'static str] { - &[] - } + const IGNORED_EMAILS: &'static [&'static str] = &[]; } pub struct Rust; impl Project for Rust { - fn name(&self) -> &'static str { - "Rust" - } - - fn url_path(&self) -> &'static str { - "rust" - } - - fn is_homepage(&self) -> bool { - true - } - - fn repo_url(&self) -> &'static str { - "https://github.com/rust-lang/rust.git" - } + const NAME: &'static str = "Rust"; + const URL_PATH: &'static str = "rust"; + const IS_HOMEPAGE: bool = true; + const REPO_URL: &'static str = "https://github.com/rust-lang/rust.git"; - fn get_versions( - &self, - repo: &Repository, - ) -> Result, Box> { - let mut versions = get_versions(repo, self.name())?; + fn get_versions(repo: &Repository) -> Result, Box> { + let mut versions = get_versions(repo, Self::NAME)?; let last_full_stable = versions .iter() .rfind(|v| v.raw_tag.ends_with(".0")) @@ -69,16 +46,6 @@ impl Project for Rust { .version .clone(); - // The nightly branch is the default one, fall back to "main" if it cannot - // be read - let nightly_branch = match repo.head() { - Ok(reference) => match reference.shorthand() { - Some(name) => name.to_string(), - None => "main".to_string(), - }, - Err(_) => "main".to_string(), - }; - versions.push(VersionTag { name: String::from("Beta"), version: { @@ -87,12 +54,7 @@ impl Project for Rust { last }, raw_tag: String::from("beta"), - commit: repo - .revparse_single("beta") - .unwrap() - .peel_to_commit() - .unwrap() - .id(), + commit: revision_latest_commit(repo, "beta"), in_progress: true, }); versions.push(VersionTag { @@ -103,13 +65,8 @@ impl Project for Rust { last.minor += 2; last }, - raw_tag: nightly_branch, - commit: repo - .revparse_single("HEAD") - .unwrap() - .peel_to_commit() - .unwrap() - .id(), + raw_tag: String::from("main"), + commit: revision_latest_commit(repo, "HEAD"), in_progress: true, }); @@ -120,23 +77,12 @@ impl Project for Rust { pub struct Rustup; impl Project for Rustup { - fn name(&self) -> &'static str { - "Rustup" - } - - fn url_path(&self) -> &'static str { - "rustup" - } - - fn repo_url(&self) -> &'static str { - "https://github.com/rust-lang/rustup.git" - } + const NAME: &'static str = "Rustup"; + const URL_PATH: &'static str = "rustup"; + const REPO_URL: &'static str = "https://github.com/rust-lang/rustup.git"; - fn get_versions( - &self, - repo: &Repository, - ) -> Result, Box> { - let mut versions = get_versions(repo, self.name())?; + fn get_versions(repo: &Repository) -> Result, Box> { + let mut versions = get_versions(repo, Self::NAME)?; let last_full_stable = versions .iter() .rfind(|v| v.raw_tag.ends_with(".0")) @@ -152,12 +98,7 @@ impl Project for Rustup { last }, raw_tag: String::from("main"), - commit: repo - .revparse_single("HEAD") - .unwrap() - .peel_to_commit() - .unwrap() - .id(), + commit: revision_latest_commit(repo, "HEAD"), in_progress: true, }); @@ -168,86 +109,45 @@ impl Project for Rustup { pub struct CratesIo; impl Project for CratesIo { - fn name(&self) -> &'static str { - "crates.io" - } - - fn url_path(&self) -> &'static str { - "crates.io" - } - - fn repo_url(&self) -> &'static str { - "https://github.com/rust-lang/crates.io.git" - } + const NAME: &'static str = "crates.io"; + const URL_PATH: &'static str = "crates.io"; + const IS_VERSIONLESS: bool = true; + const REPO_URL: &'static str = "https://github.com/rust-lang/crates.io.git"; - fn get_versions( - &self, - repo: &Repository, - ) -> Result, Box> { + fn get_versions(repo: &Repository) -> Result, Box> { Ok(vec![VersionTag { name: String::from("Nightly"), version: Version::new(1, 0, 0), raw_tag: String::from("main"), - commit: repo - .revparse_single("HEAD") - .unwrap() - .peel_to_commit() - .unwrap() - .id(), + commit: revision_latest_commit(repo, "HEAD"), in_progress: true, }]) } - - fn is_versionless(&self) -> bool { - true - } } pub struct DocsRs; impl Project for DocsRs { - fn name(&self) -> &'static str { - "Docs.rs" - } - - fn url_path(&self) -> &'static str { - "docs.rs" - } - - fn repo_url(&self) -> &'static str { - "https://github.com/rust-lang/docs.rs.git" - } - - fn get_versions( - &self, - repo: &Repository, - ) -> Result, Box> { + const NAME: &'static str = "Docs.rs"; + const URL_PATH: &'static str = "docs.rs"; + const IS_VERSIONLESS: bool = true; + const REPO_URL: &'static str = "https://github.com/rust-lang/docs.rs.git"; + const IGNORED_EMAILS: &'static [&'static str] = &[ + // CI bot + "docs.rs@users.noreply.github.com", + // Renovatebot + "29139614+renovate[bot]@users.noreply.github.com", + // Dependabot + "49699333+dependabot[bot]@users.noreply.github.com", + ]; + + fn get_versions(repo: &Repository) -> Result, Box> { Ok(vec![VersionTag { name: String::from("Nightly"), version: Version::new(1, 0, 0), raw_tag: String::from("main"), - commit: repo - .revparse_single("HEAD") - .unwrap() - .peel_to_commit() - .unwrap() - .id(), + commit: revision_latest_commit(repo, "HEAD"), in_progress: true, }]) } - - fn ignored_emails(&self) -> &'static [&'static str] { - &[ - // CI bot - "docs.rs@users.noreply.github.com", - // Renovatebot - "29139614+renovate[bot]@users.noreply.github.com", - // Dependabot - "49699333+dependabot[bot]@users.noreply.github.com", - ] - } - - fn is_versionless(&self) -> bool { - true - } } diff --git a/src/site.rs b/src/site.rs index cb24f077..5fb75ad6 100644 --- a/src/site.rs +++ b/src/site.rs @@ -24,15 +24,15 @@ pub fn render_projects( let mut combined_all_time = AuthorMap::new(); for data in projects { - let project_out_dir = root_dir.join(data.project.url_path()); + let project_out_dir = root_dir.join(data.display_config.url_path()); create_dir(&project_out_dir)?; - if data.project.is_homepage() { - assert!(!data.project.is_versionless()); + if data.display_config.is_homepage() { + assert!(!data.display_config.is_versionless()); render_project_index_page(&hb, data, root_dir)?; } - if data.project.is_versionless() { + if data.display_config.is_versionless() { render_all_time_page(&hb, data, &project_out_dir)?; } else { render_project_index_page(&hb, data, &project_out_dir)?; @@ -69,14 +69,14 @@ pub fn render_projects( fn validate_homepage(projects: &[ProjectData]) { let mut homepage_project = None; for data in projects { - if data.project.is_homepage() { + if data.display_config.is_homepage() { if let Some(other) = homepage_project { panic!( "Multiple projects that are marked as a homepage project: {} and {other}", - data.project.name() + data.display_config.name() ); } - homepage_project = Some(data.project.name().to_string()); + homepage_project = Some(data.display_config.name().to_string()); } } if homepage_project.is_none() { @@ -160,14 +160,14 @@ fn render_project_index_page( let mut releases = Vec::new(); releases.push(Release { name: "All time".into(), - url: format!("/{}/all-time/", data.project.url_path()), + url: format!("/{}/all-time/", data.display_config.url_path()), people: data.all_time.authors.iter().count(), commits: data.all_time.authors.iter().map(|(_, count)| count).sum(), }); for (version, stats) in data.by_version.iter().rev() { releases.push(Release { name: version.name.clone(), - url: format!("/{}/{}/", data.project.url_path(), version.version), + url: format!("/{}/{}/", data.display_config.url_path(), version.version), people: stats.authors.iter().count(), commits: stats.authors.iter().map(|(_, count)| count).sum(), }); @@ -176,9 +176,9 @@ fn render_project_index_page( let res = hb.render( "index", &Index { - common: CommonData::new(format!("{} Contributors", data.project.name())) + common: CommonData::new(format!("{} Contributors", data.display_config.name())) .without_thanks_in_logo(), - name: data.project.name(), + name: data.display_config.name(), releases, }, )?; @@ -234,8 +234,8 @@ fn render_projects_page( let projects: Vec = projects .iter() .map(|data| ProjectInfo { - name: data.project.name().to_string(), - link: data.project.url_path().to_string(), + name: data.display_config.name().to_string(), + link: data.display_config.url_path().to_string(), people: data.all_time.authors.iter().count(), commits: data.all_time.authors.iter().map(|(_, count)| count).sum(), }) @@ -275,13 +275,16 @@ fn render_all_time_page( let res = hb.render( "stats", &Release { - common: CommonData::new(format!("All-time {} Contributors", data.project.name())), + common: CommonData::new(format!( + "All-time {} Contributors", + data.display_config.name() + )), release_title: String::from("All-time"), - release: data.project.name().to_string(), + release: data.display_config.name().to_string(), count: scores.len(), scores, in_progress: true, - is_homepage_project: data.project.is_homepage(), + is_homepage_project: data.display_config.is_homepage(), }, )?; @@ -300,13 +303,16 @@ fn render_release_pages( let res = hb.render( "stats", &Release { - common: CommonData::new(format!("{} {version} Contributors", data.project.name())), + common: CommonData::new(format!( + "{} {version} Contributors", + data.display_config.name() + )), release_title: version.name.clone(), release: version.to_string(), count: scores.len(), scores, in_progress: version.in_progress, - is_homepage_project: data.project.is_homepage(), + is_homepage_project: data.display_config.is_homepage(), }, )?;