1use std::process::Command;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct AuthorIdentity {
21 pub name: String,
22 pub email: Option<String>,
23}
24
25impl std::fmt::Display for AuthorIdentity {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 match &self.email {
28 Some(email) => write!(f, "{} <{email}>", self.name),
29 None => write!(f, "{}", self.name),
30 }
31 }
32}
33
34pub fn git_identity() -> Option<AuthorIdentity> {
42 git_identity_for("git")
43}
44
45#[doc(hidden)]
56pub fn git_identity_for(program: &str) -> Option<AuthorIdentity> {
57 let name = git_config_get(program, "user.name")?;
58 let email = git_config_get(program, "user.email");
59 Some(AuthorIdentity { name, email })
60}
61
62fn git_config_get(program: &str, key: &str) -> Option<String> {
64 let stdout = run_git(program, &["config", "--get", key])?;
65 let value = stdout.trim();
66 if value.is_empty() {
67 None
68 } else {
69 Some(value.to_string())
70 }
71}
72
73const SPAWN_ATTEMPTS: usize = 3;
84
85fn is_transient_spawn_failure(kind: std::io::ErrorKind) -> bool {
99 matches!(
100 kind,
101 std::io::ErrorKind::WouldBlock
102 | std::io::ErrorKind::OutOfMemory
103 | std::io::ErrorKind::Interrupted
104 | std::io::ErrorKind::ResourceBusy
105 )
106}
107
108fn run_git(program: &str, args: &[&str]) -> Option<String> {
120 let mut last: Option<std::io::Error> = None;
121 for _ in 0..SPAWN_ATTEMPTS {
122 match Command::new(program).args(args).output() {
123 Ok(output) => {
124 if !output.status.success() {
125 return None;
126 }
127 return Some(String::from_utf8_lossy(&output.stdout).into_owned());
128 }
129 Err(e) => {
130 if !is_transient_spawn_failure(e.kind()) {
131 return None;
132 }
133 last = Some(e);
134 }
135 }
136 }
137 let _ = last;
142 None
143}
144
145pub fn parse_assignee(raw: &str) -> Option<String> {
152 let trimmed = raw.trim();
153 if trimmed.is_empty() {
154 return None;
155 }
156 if let Some((name, email)) = parse_full_identity(trimmed) {
157 if name.is_empty() {
158 return email_local_part(&email);
159 }
160 return Some(trimmed.to_string());
161 }
162 let handle = trimmed.strip_prefix('@').unwrap_or(trimmed).trim();
163 if handle.is_empty() {
164 return None;
165 }
166 Some(handle.to_string())
167}
168
169pub fn resolve_me(raw: &str, identity: Option<&AuthorIdentity>) -> Option<String> {
176 let parsed = parse_assignee(raw)?;
177 if parsed.eq_ignore_ascii_case("me")
178 && let Some(id) = identity
179 {
180 return Some(id.to_string());
181 }
182 Some(parsed)
183}
184
185pub fn assignee_display(assignee: &str) -> String {
189 if let Some((name, _)) = parse_full_identity(assignee)
190 && !name.is_empty()
191 {
192 return name;
193 }
194 let stripped = assignee.strip_prefix('@').unwrap_or(assignee);
195 format!("@{stripped}")
196}
197
198pub fn derive_segment(assignee: Option<&str>) -> String {
210 let raw = assignee.map(str::trim).unwrap_or("");
211 if raw.is_empty() {
212 return "unassigned".to_string();
213 }
214 if let Some((name, email)) = parse_full_identity(raw) {
215 if name.is_empty() {
216 return email_local_part(&email)
217 .map(|local| crate::slug::slugify(&local))
218 .unwrap_or_else(|| "unassigned".to_string());
219 }
220 return crate::slug::slugify(&name);
221 }
222 let bare = raw.strip_prefix('@').unwrap_or(raw).trim();
223 if bare.is_empty() {
224 return "unassigned".to_string();
225 }
226 let component = if bare.contains('@') {
227 email_local_part(bare)
228 } else {
229 Some(bare.to_string())
230 };
231 component
232 .map(|part| crate::slug::slugify(&part))
233 .unwrap_or_else(|| "unassigned".to_string())
234}
235
236fn parse_full_identity(raw: &str) -> Option<(String, String)> {
239 let open = raw.find('<')?;
240 let close = raw.rfind('>')?;
241 if close < open {
242 return None;
243 }
244 let name = raw[..open].trim();
245 let email = raw[open + 1..close].trim();
246 if email.is_empty() {
247 return None;
248 }
249 Some((name.to_string(), email.to_string()))
250}
251
252fn email_local_part(email: &str) -> Option<String> {
254 let local = email.split('@').next().unwrap_or("").trim();
255 if local.is_empty() {
256 None
257 } else {
258 Some(local.to_string())
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 #[allow(clippy::wildcard_imports)]
265 use super::*;
266 use std::os::unix::fs::PermissionsExt;
267 use tempfile::TempDir;
268
269 fn fake_git(dir: &TempDir, script: &str) -> String {
271 let path = dir.path().join("fake-git");
272 std::fs::write(&path, script).unwrap();
273 let mut perms = std::fs::metadata(&path).unwrap().permissions();
274 perms.set_mode(0o755);
275 std::fs::set_permissions(&path, perms).unwrap();
276 path.display().to_string()
277 }
278
279 fn fake_script(cases: &[(&str, Option<&str>)]) -> String {
282 let mut body = String::from("#!/bin/sh\ncase \"$3\" in\n");
283 for (key, value) in cases {
284 match value {
285 Some(value) => body.push_str(&format!("\"{key}\") printf '%s\\n' '{value}';;\n")),
286 None => body.push_str(&format!("\"{key}\") exit 1;;\n")),
287 }
288 }
289 body.push_str("*) exit 1;;\nesac\n");
290 body
291 }
292
293 #[test]
294 fn configured_identity_reads_name_and_email() {
295 let tmp = TempDir::new().unwrap();
296 let script = fake_script(&[
297 ("user.name", Some("Herbert Maschke")),
298 ("user.email", Some("thyseus@pm.me")),
299 ]);
300 let id = git_identity_for(&fake_git(&tmp, &script)).expect("identity expected");
301 assert_eq!(id.name, "Herbert Maschke");
302 assert_eq!(id.email.as_deref(), Some("thyseus@pm.me"));
303 assert_eq!(id.to_string(), "Herbert Maschke <thyseus@pm.me>");
304 assert_eq!(format!("{id}"), "Herbert Maschke <thyseus@pm.me>");
305 }
306
307 #[test]
331 fn name_only_survives_unconfigured_email() {
332 let tmp = TempDir::new().unwrap();
333 let script = fake_script(&[("user.name", Some("Herbert Maschke")), ("user.email", None)]);
334 let git = fake_git(&tmp, &script);
335
336 let name = git_config_get(&git, "user.name");
341 assert!(
342 name.is_some(),
343 "the fake git did not answer `user.name`; it is at {git}"
344 );
345 assert_eq!(name.as_deref(), Some("Herbert Maschke"));
346 assert_eq!(
348 git_config_get(&git, "user.email"),
349 None,
350 "an unconfigured key must read as None, which is the behaviour under test"
351 );
352
353 let id = git_identity_for(&git).expect("identity expected");
354 assert_eq!(id.name, "Herbert Maschke");
355 assert_eq!(id.email, None);
356 assert_eq!(id.to_string(), "Herbert Maschke");
357 }
358
359 #[test]
371 fn a_transient_spawn_failure_is_retried_rather_than_reported_as_absent() {
372 for (label, transient) in [
373 ("WouldBlock", std::io::ErrorKind::WouldBlock),
374 ("OutOfMemory", std::io::ErrorKind::OutOfMemory),
375 ("Interrupted", std::io::ErrorKind::Interrupted),
376 ("ResourceBusy", std::io::ErrorKind::ResourceBusy),
377 ] {
378 assert!(
379 is_transient_spawn_failure(transient),
380 "{label} is resource exhaustion and must be retried"
381 );
382 }
383 for (label, permanent) in [
387 ("NotFound", std::io::ErrorKind::NotFound),
388 ("PermissionDenied", std::io::ErrorKind::PermissionDenied),
389 ] {
390 assert!(
391 !is_transient_spawn_failure(permanent),
392 "{label} will not fix itself and must not be retried"
393 );
394 }
395 const { assert!(SPAWN_ATTEMPTS >= 2, "a single attempt is no retry at all") };
400 }
401
402 #[test]
405 fn an_absent_git_is_still_none_after_the_retry_policy() {
406 let tmp = TempDir::new().unwrap();
407 let absent = tmp.path().join("no-such-git").display().to_string();
408 let started = std::time::Instant::now();
409 assert_eq!(git_identity_for(&absent), None);
410 assert!(
411 started.elapsed() < std::time::Duration::from_secs(1),
412 "a missing binary must fail fast, not sit through the retry policy"
413 );
414 }
415
416 #[test]
417 fn exit_code_1_on_unconfigured_user_name_is_none() {
418 let tmp = TempDir::new().unwrap();
419 let script = fake_script(&[("user.name", None)]);
420 assert_eq!(git_identity_for(&fake_git(&tmp, &script)), None);
421 }
422
423 #[test]
424 fn missing_binary_is_none() {
425 let tmp = TempDir::new().unwrap();
426 let absent = tmp.path().join("no-such-git").display().to_string();
427 assert_eq!(git_identity_for(&absent), None);
428 assert_eq!(git_identity_for("/nonexistent/git-binary"), None);
429 }
430
431 #[test]
432 fn stdout_is_trimmed_of_stray_whitespace() {
433 let tmp = TempDir::new().unwrap();
434 let script = "#!/bin/sh\ncase \"$3\" in\n\"user.name\") printf ' Herbert Maschke \\n';;\n*) exit 1;;\nesac\n";
435 let id = git_identity_for(&fake_git(&tmp, script)).expect("identity expected");
436 assert_eq!(id.name, "Herbert Maschke");
437 }
438
439 #[test]
440 fn resolve_me_with_identity_returns_full_identity() {
441 let identity = AuthorIdentity {
442 name: "Herbert Maschke".into(),
443 email: Some("thyseus@pm.me".into()),
444 };
445 assert_eq!(
446 resolve_me("@me", Some(&identity)),
447 Some("Herbert Maschke <thyseus@pm.me>".into())
448 );
449 assert_eq!(
450 resolve_me("me", Some(&identity)),
451 Some("Herbert Maschke <thyseus@pm.me>".into())
452 );
453 assert_eq!(
454 resolve_me("Me", Some(&identity)),
455 Some("Herbert Maschke <thyseus@pm.me>".into())
456 );
457 assert_eq!(
458 resolve_me("@ME", Some(&identity)),
459 Some("Herbert Maschke <thyseus@pm.me>".into())
460 );
461 }
462
463 #[test]
464 fn resolve_me_without_identity_returns_handle() {
465 assert_eq!(resolve_me("@me", None), Some("me".into()));
466 assert_eq!(resolve_me("me", None), Some("me".into()));
467 }
468
469 #[test]
470 fn resolve_me_non_me_handles_unchanged() {
471 let identity = AuthorIdentity {
472 name: "Herb".into(),
473 email: Some("herb@example.com".into()),
474 };
475 assert_eq!(resolve_me("@alice", Some(&identity)), Some("alice".into()));
476 assert_eq!(
477 resolve_me("Herbert Maschke <thyseus@pm.me>", Some(&identity)),
478 Some("Herbert Maschke <thyseus@pm.me>".into())
479 );
480 }
481
482 #[test]
483 fn resolve_me_empty_input() {
484 assert_eq!(resolve_me("", None), None);
485 assert_eq!(resolve_me(" ", None), None);
486 }
487
488 #[test]
489 fn assignee_display_shows_name_for_identities() {
490 assert_eq!(
491 assignee_display("Herbert Maschke <thyseus@pm.me>"),
492 "Herbert Maschke"
493 );
494 assert_eq!(assignee_display(" <thyseus@pm.me>"), "@ <thyseus@pm.me>");
495 }
496
497 #[test]
498 fn assignee_display_prefixes_handles() {
499 assert_eq!(assignee_display("alice"), "@alice");
500 assert_eq!(assignee_display("@alice"), "@alice");
501 }
502
503 #[test]
504 fn parse_assignee_normalizes_handles_and_identities() {
505 assert_eq!(parse_assignee("@alice"), Some("alice".to_string()));
506 assert_eq!(parse_assignee("alice"), Some("alice".to_string()));
507 assert_eq!(parse_assignee(" alice "), Some("alice".to_string()));
508 assert_eq!(
509 parse_assignee("Herbert Maschke <thyseus@pm.me>"),
510 Some("Herbert Maschke <thyseus@pm.me>".to_string())
511 );
512 assert_eq!(
513 parse_assignee(" <thyseus@pm.me>"),
514 Some("thyseus".to_string())
515 );
516 assert_eq!(parse_assignee(""), None);
517 assert_eq!(parse_assignee(" "), None);
518 assert_eq!(parse_assignee("@"), None);
519 }
520
521 #[test]
522 fn segment_matrix_is_deterministic_and_safe() {
523 let cases: &[(Option<&str>, &str)] = &[
524 (None, "unassigned"),
525 (Some(""), "unassigned"),
526 (Some(" "), "unassigned"),
527 (Some("Herbert Maschke <thyseus@pm.me>"), "herbert-maschke"),
528 (Some("herbert.maschke@pm.me"), "herbert-maschke"),
529 (Some(" <thyseus@pm.me>"), "thyseus"),
530 (Some("alice"), "alice"),
531 (Some("@alice"), "alice"),
532 (Some("André Möller"), "andr-m-ller"),
533 (Some("A_B_C"), "a-b-c"),
534 (Some("!!Leading##"), "leading"),
535 (Some("Alice: the Great!"), "alice-the-great"),
536 (Some("Ünïcødé <weird@example.com>"), "n-c-d"),
537 ];
538 for (input, expected) in cases {
539 assert_eq!(derive_segment(*input), *expected, "input: {input:?}");
540 }
541 }
542
543 #[test]
544 fn every_segment_is_a_single_safe_path_component() {
545 let inputs: &[Option<&str>] = &[
546 None,
547 Some(""),
548 Some("Herbert Maschke <thyseus@pm.me>"),
549 Some(" <thyseus@pm.me>"),
550 Some("../../etc/passwd"),
551 Some("@../x"),
552 Some(".."),
553 Some("a b<c>d"),
554 Some("control\u{7}char"),
555 Some("back\\slash/name"),
556 ];
557 for input in inputs {
558 let segment = derive_segment(*input);
559 assert!(!segment.is_empty(), "input {input:?} gave empty");
560 assert!(
561 !segment.contains('/') && !segment.contains('\\') && !segment.contains(' '),
562 "unsafe segment {segment:?} from {input:?}"
563 );
564 assert!(
565 !segment.chars().any(char::is_control),
566 "control char in {segment:?}"
567 );
568 assert_ne!(segment, ".");
569 assert_ne!(segment, "..");
570 assert!(
571 segment
572 .chars()
573 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
574 "unexpected characters in {segment:?}"
575 );
576 }
577 }
578
579 #[test]
580 fn derivation_is_stable_across_repeated_calls() {
581 let inputs: &[Option<&str>] = &[
582 Some("Herbert Maschke <thyseus@pm.me>"),
583 Some(" <thyseus@pm.me>"),
584 Some("@alice"),
585 Some("André Möller"),
586 Some("herbert.maschke@pm.me"),
587 None,
588 ];
589 for input in inputs {
590 let first = derive_segment(*input);
591 for _ in 0..100 {
592 assert_eq!(derive_segment(*input), first, "nondeterministic: {input:?}");
593 }
594 }
595 }
596
597 #[test]
598 fn full_identity_splits_name_and_email() {
599 assert_eq!(
600 parse_full_identity("Herbert Maschke <thyseus@pm.me>"),
601 Some(("Herbert Maschke".to_string(), "thyseus@pm.me".to_string()))
602 );
603 assert_eq!(parse_full_identity("alice"), None);
604 assert_eq!(parse_full_identity("a<>"), None);
605 }
606
607 #[test]
608 fn email_local_part_splits_on_at() {
609 assert_eq!(
610 email_local_part("thyseus@pm.me"),
611 Some("thyseus".to_string())
612 );
613 assert_eq!(email_local_part("@pm.me"), None);
614 assert_eq!(email_local_part("a@b@c"), Some("a".to_string()));
615 }
616}