use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorIdentity {
pub name: String,
pub email: Option<String>,
}
impl std::fmt::Display for AuthorIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.email {
Some(email) => write!(f, "{} <{email}>", self.name),
None => write!(f, "{}", self.name),
}
}
}
pub fn git_identity() -> Option<AuthorIdentity> {
git_identity_for("git")
}
pub(crate) fn git_identity_for(program: &str) -> Option<AuthorIdentity> {
let name = git_config_get(program, "user.name")?;
let email = git_config_get(program, "user.email");
Some(AuthorIdentity { name, email })
}
fn git_config_get(program: &str, key: &str) -> Option<String> {
let stdout = run_git(program, &["config", "--get", key])?;
let value = stdout.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
const SPAWN_ATTEMPTS: usize = 3;
fn is_transient_spawn_failure(kind: std::io::ErrorKind) -> bool {
matches!(
kind,
std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::OutOfMemory
| std::io::ErrorKind::Interrupted
| std::io::ErrorKind::ResourceBusy
)
}
fn run_git(program: &str, args: &[&str]) -> Option<String> {
let mut last: Option<std::io::Error> = None;
for _ in 0..SPAWN_ATTEMPTS {
match Command::new(program).args(args).output() {
Ok(output) => {
if !output.status.success() {
return None;
}
return Some(String::from_utf8_lossy(&output.stdout).into_owned());
}
Err(e) => {
if !is_transient_spawn_failure(e.kind()) {
return None;
}
last = Some(e);
}
}
}
let _ = last;
None
}
pub fn parse_assignee(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if let Some((name, email)) = parse_full_identity(trimmed) {
if name.is_empty() {
return email_local_part(&email);
}
return Some(trimmed.to_string());
}
let handle = trimmed.strip_prefix('@').unwrap_or(trimmed).trim();
if handle.is_empty() {
return None;
}
Some(handle.to_string())
}
pub fn resolve_me(raw: &str, identity: Option<&AuthorIdentity>) -> Option<String> {
let parsed = parse_assignee(raw)?;
if parsed.eq_ignore_ascii_case("me")
&& let Some(id) = identity
{
return Some(id.to_string());
}
Some(parsed)
}
pub fn assignee_display(assignee: &str) -> String {
if let Some((name, _)) = parse_full_identity(assignee)
&& !name.is_empty()
{
return name;
}
let stripped = assignee.strip_prefix('@').unwrap_or(assignee);
format!("@{stripped}")
}
pub fn derive_segment(assignee: Option<&str>) -> String {
let raw = assignee.map(str::trim).unwrap_or("");
if raw.is_empty() {
return "unassigned".to_string();
}
if let Some((name, email)) = parse_full_identity(raw) {
if name.is_empty() {
return email_local_part(&email)
.map(|local| crate::slug::slugify(&local))
.unwrap_or_else(|| "unassigned".to_string());
}
return crate::slug::slugify(&name);
}
let bare = raw.strip_prefix('@').unwrap_or(raw).trim();
if bare.is_empty() {
return "unassigned".to_string();
}
let component = if bare.contains('@') {
email_local_part(bare)
} else {
Some(bare.to_string())
};
component
.map(|part| crate::slug::slugify(&part))
.unwrap_or_else(|| "unassigned".to_string())
}
fn parse_full_identity(raw: &str) -> Option<(String, String)> {
let open = raw.find('<')?;
let close = raw.rfind('>')?;
if close < open {
return None;
}
let name = raw[..open].trim();
let email = raw[open + 1..close].trim();
if email.is_empty() {
return None;
}
Some((name.to_string(), email.to_string()))
}
fn email_local_part(email: &str) -> Option<String> {
let local = email.split('@').next().unwrap_or("").trim();
if local.is_empty() {
None
} else {
Some(local.to_string())
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use std::os::unix::fs::PermissionsExt;
use tempfile::TempDir;
fn fake_git(dir: &TempDir, script: &str) -> String {
let path = dir.path().join("fake-git");
std::fs::write(&path, script).unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
path.display().to_string()
}
fn fake_script(cases: &[(&str, Option<&str>)]) -> String {
let mut body = String::from("#!/bin/sh\ncase \"$3\" in\n");
for (key, value) in cases {
match value {
Some(value) => body.push_str(&format!("\"{key}\") printf '%s\\n' '{value}';;\n")),
None => body.push_str(&format!("\"{key}\") exit 1;;\n")),
}
}
body.push_str("*) exit 1;;\nesac\n");
body
}
#[test]
fn configured_identity_reads_name_and_email() {
let tmp = TempDir::new().unwrap();
let script = fake_script(&[
("user.name", Some("Herbert Maschke")),
("user.email", Some("thyseus@pm.me")),
]);
let id = git_identity_for(&fake_git(&tmp, &script)).expect("identity expected");
assert_eq!(id.name, "Herbert Maschke");
assert_eq!(id.email.as_deref(), Some("thyseus@pm.me"));
assert_eq!(id.to_string(), "Herbert Maschke <thyseus@pm.me>");
assert_eq!(format!("{id}"), "Herbert Maschke <thyseus@pm.me>");
}
#[test]
fn name_only_survives_unconfigured_email() {
let tmp = TempDir::new().unwrap();
let script = fake_script(&[("user.name", Some("Herbert Maschke")), ("user.email", None)]);
let git = fake_git(&tmp, &script);
let name = git_config_get(&git, "user.name");
assert!(
name.is_some(),
"the fake git did not answer `user.name`; it is at {git}"
);
assert_eq!(name.as_deref(), Some("Herbert Maschke"));
assert_eq!(
git_config_get(&git, "user.email"),
None,
"an unconfigured key must read as None, which is the behaviour under test"
);
let id = git_identity_for(&git).expect("identity expected");
assert_eq!(id.name, "Herbert Maschke");
assert_eq!(id.email, None);
assert_eq!(id.to_string(), "Herbert Maschke");
}
#[test]
fn a_transient_spawn_failure_is_retried_rather_than_reported_as_absent() {
for (label, transient) in [
("WouldBlock", std::io::ErrorKind::WouldBlock),
("OutOfMemory", std::io::ErrorKind::OutOfMemory),
("Interrupted", std::io::ErrorKind::Interrupted),
("ResourceBusy", std::io::ErrorKind::ResourceBusy),
] {
assert!(
is_transient_spawn_failure(transient),
"{label} is resource exhaustion and must be retried"
);
}
for (label, permanent) in [
("NotFound", std::io::ErrorKind::NotFound),
("PermissionDenied", std::io::ErrorKind::PermissionDenied),
] {
assert!(
!is_transient_spawn_failure(permanent),
"{label} will not fix itself and must not be retried"
);
}
const { assert!(SPAWN_ATTEMPTS >= 2, "a single attempt is no retry at all") };
}
#[test]
fn an_absent_git_is_still_none_after_the_retry_policy() {
let tmp = TempDir::new().unwrap();
let absent = tmp.path().join("no-such-git").display().to_string();
let started = std::time::Instant::now();
assert_eq!(git_identity_for(&absent), None);
assert!(
started.elapsed() < std::time::Duration::from_secs(1),
"a missing binary must fail fast, not sit through the retry policy"
);
}
#[test]
fn exit_code_1_on_unconfigured_user_name_is_none() {
let tmp = TempDir::new().unwrap();
let script = fake_script(&[("user.name", None)]);
assert_eq!(git_identity_for(&fake_git(&tmp, &script)), None);
}
#[test]
fn missing_binary_is_none() {
let tmp = TempDir::new().unwrap();
let absent = tmp.path().join("no-such-git").display().to_string();
assert_eq!(git_identity_for(&absent), None);
assert_eq!(git_identity_for("/nonexistent/git-binary"), None);
}
#[test]
fn stdout_is_trimmed_of_stray_whitespace() {
let tmp = TempDir::new().unwrap();
let script = "#!/bin/sh\ncase \"$3\" in\n\"user.name\") printf ' Herbert Maschke \\n';;\n*) exit 1;;\nesac\n";
let id = git_identity_for(&fake_git(&tmp, script)).expect("identity expected");
assert_eq!(id.name, "Herbert Maschke");
}
#[test]
fn resolve_me_with_identity_returns_full_identity() {
let identity = AuthorIdentity {
name: "Herbert Maschke".into(),
email: Some("thyseus@pm.me".into()),
};
assert_eq!(
resolve_me("@me", Some(&identity)),
Some("Herbert Maschke <thyseus@pm.me>".into())
);
assert_eq!(
resolve_me("me", Some(&identity)),
Some("Herbert Maschke <thyseus@pm.me>".into())
);
assert_eq!(
resolve_me("Me", Some(&identity)),
Some("Herbert Maschke <thyseus@pm.me>".into())
);
assert_eq!(
resolve_me("@ME", Some(&identity)),
Some("Herbert Maschke <thyseus@pm.me>".into())
);
}
#[test]
fn resolve_me_without_identity_returns_handle() {
assert_eq!(resolve_me("@me", None), Some("me".into()));
assert_eq!(resolve_me("me", None), Some("me".into()));
}
#[test]
fn resolve_me_non_me_handles_unchanged() {
let identity = AuthorIdentity {
name: "Herb".into(),
email: Some("herb@example.com".into()),
};
assert_eq!(resolve_me("@alice", Some(&identity)), Some("alice".into()));
assert_eq!(
resolve_me("Herbert Maschke <thyseus@pm.me>", Some(&identity)),
Some("Herbert Maschke <thyseus@pm.me>".into())
);
}
#[test]
fn resolve_me_empty_input() {
assert_eq!(resolve_me("", None), None);
assert_eq!(resolve_me(" ", None), None);
}
#[test]
fn assignee_display_shows_name_for_identities() {
assert_eq!(
assignee_display("Herbert Maschke <thyseus@pm.me>"),
"Herbert Maschke"
);
assert_eq!(assignee_display(" <thyseus@pm.me>"), "@ <thyseus@pm.me>");
}
#[test]
fn assignee_display_prefixes_handles() {
assert_eq!(assignee_display("alice"), "@alice");
assert_eq!(assignee_display("@alice"), "@alice");
}
#[test]
fn parse_assignee_normalizes_handles_and_identities() {
assert_eq!(parse_assignee("@alice"), Some("alice".to_string()));
assert_eq!(parse_assignee("alice"), Some("alice".to_string()));
assert_eq!(parse_assignee(" alice "), Some("alice".to_string()));
assert_eq!(
parse_assignee("Herbert Maschke <thyseus@pm.me>"),
Some("Herbert Maschke <thyseus@pm.me>".to_string())
);
assert_eq!(
parse_assignee(" <thyseus@pm.me>"),
Some("thyseus".to_string())
);
assert_eq!(parse_assignee(""), None);
assert_eq!(parse_assignee(" "), None);
assert_eq!(parse_assignee("@"), None);
}
#[test]
fn segment_matrix_is_deterministic_and_safe() {
let cases: &[(Option<&str>, &str)] = &[
(None, "unassigned"),
(Some(""), "unassigned"),
(Some(" "), "unassigned"),
(Some("Herbert Maschke <thyseus@pm.me>"), "herbert-maschke"),
(Some("herbert.maschke@pm.me"), "herbert-maschke"),
(Some(" <thyseus@pm.me>"), "thyseus"),
(Some("alice"), "alice"),
(Some("@alice"), "alice"),
(Some("André Möller"), "andr-m-ller"),
(Some("A_B_C"), "a-b-c"),
(Some("!!Leading##"), "leading"),
(Some("Alice: the Great!"), "alice-the-great"),
(Some("Ünïcødé <weird@example.com>"), "n-c-d"),
];
for (input, expected) in cases {
assert_eq!(derive_segment(*input), *expected, "input: {input:?}");
}
}
#[test]
fn every_segment_is_a_single_safe_path_component() {
let inputs: &[Option<&str>] = &[
None,
Some(""),
Some("Herbert Maschke <thyseus@pm.me>"),
Some(" <thyseus@pm.me>"),
Some("../../etc/passwd"),
Some("@../x"),
Some(".."),
Some("a b<c>d"),
Some("control\u{7}char"),
Some("back\\slash/name"),
];
for input in inputs {
let segment = derive_segment(*input);
assert!(!segment.is_empty(), "input {input:?} gave empty");
assert!(
!segment.contains('/') && !segment.contains('\\') && !segment.contains(' '),
"unsafe segment {segment:?} from {input:?}"
);
assert!(
!segment.chars().any(char::is_control),
"control char in {segment:?}"
);
assert_ne!(segment, ".");
assert_ne!(segment, "..");
assert!(
segment
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
"unexpected characters in {segment:?}"
);
}
}
#[test]
fn derivation_is_stable_across_repeated_calls() {
let inputs: &[Option<&str>] = &[
Some("Herbert Maschke <thyseus@pm.me>"),
Some(" <thyseus@pm.me>"),
Some("@alice"),
Some("André Möller"),
Some("herbert.maschke@pm.me"),
None,
];
for input in inputs {
let first = derive_segment(*input);
for _ in 0..100 {
assert_eq!(derive_segment(*input), first, "nondeterministic: {input:?}");
}
}
}
#[test]
fn full_identity_splits_name_and_email() {
assert_eq!(
parse_full_identity("Herbert Maschke <thyseus@pm.me>"),
Some(("Herbert Maschke".to_string(), "thyseus@pm.me".to_string()))
);
assert_eq!(parse_full_identity("alice"), None);
assert_eq!(parse_full_identity("a<>"), None);
}
#[test]
fn email_local_part_splits_on_at() {
assert_eq!(
email_local_part("thyseus@pm.me"),
Some("thyseus".to_string())
);
assert_eq!(email_local_part("@pm.me"), None);
assert_eq!(email_local_part("a@b@c"), Some("a".to_string()));
}
}