use mdbook_preprocessor::errors::Error;
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::path::Path;
use std::process::{Command, Stdio};
pub fn get_git_output<I, S>(args: I, dir: &Path) -> Result<String, Error>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = Command::new("git")
.args(args)
.current_dir(dir)
.stdout(Stdio::piped())
.output()
.map_err(|e| Error::msg(format!("Git command failed: {e}")))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
Err(Error::msg("Git command returned non-zero exit code"))
}
}
pub fn verify_branch(branch: &str, dir: &Path) -> bool {
get_git_output(["rev-parse", "--verify", branch], dir).is_ok()
}
pub fn latest_tag_for_branch(branch: &str, dir: &std::path::Path) -> String {
if let Ok(t) = get_git_output(["describe", "--tags", "--abbrev=0", branch], dir) {
if !t.trim().is_empty() {
return t;
}
}
match get_git_output(["tag", "--sort=-creatordate"], dir) {
Ok(list) => {
if let Some(first) = list.lines().find(|l| !l.trim().is_empty()) {
return first.trim().to_string();
}
}
Err(_) => {}
}
"No tags found".to_string()
}
fn github_username_from_email(email: &str) -> Option<String> {
const SUFFIX: &str = "@users.noreply.github.com";
if !email.ends_with(SUFFIX) {
return None;
}
let local = &email[..email.len() - SUFFIX.len()];
let local = local.trim();
if local.is_empty() {
return None;
}
let username = match local.split_once('+') {
Some((_id, u)) if !u.trim().is_empty() => u.trim(),
_ => local,
};
if username.is_empty() {
None
} else {
Some(username.to_string())
}
}
fn is_plausible_github_username(u: &str) -> bool {
let len = u.len();
if len == 0 || len > 39 {
return false;
}
if u.starts_with('-') || u.ends_with('-') {
return false;
}
u.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
pub fn get_contributor_usernames_from_shortlog(dir: &Path) -> Result<Vec<String>, Error> {
let raw = get_git_output(["shortlog", "-sne", "--all"], dir)
.map_err(|e| Error::msg(format!("unable to get contributors: {e}")))?;
let mut set = BTreeSet::<String>::new();
for line in raw.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let mut parts = line.splitn(2, char::is_whitespace);
let _count_str = parts.next().unwrap_or("");
let rest = parts.next().unwrap_or("").trim();
if rest.is_empty() {
continue;
}
let (name, email) = if let Some((n, e)) = rest.rsplit_once('<') {
let email = e.trim_end_matches('>').trim();
(n.trim(), Some(email))
} else {
(rest, None)
};
if !name.is_empty() && is_plausible_github_username(name) {
set.insert(name.to_string());
continue;
}
if let Some(email) = email {
if let Some(u) = github_username_from_email(email) {
if is_plausible_github_username(&u) {
set.insert(u);
}
}
}
}
Ok(set.into_iter().collect())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn returns_error_on_invalid_git_command() {
let result = get_git_output(["non-existent-command"], &PathBuf::from("."));
assert!(result.is_err());
}
}