1use crate::error::Error;
8use crate::objects::ObjectKind;
9use crate::repo::Repository;
10use crate::rev_parse::resolve_revision;
11use std::borrow::Cow;
12use std::collections::BTreeMap;
13use std::fs;
14#[cfg(unix)]
15use std::io::Read;
16use std::path::{Path, PathBuf};
17
18type Result<T> = std::result::Result<T, Error>;
19
20#[derive(Debug, Clone)]
22pub struct MailmapEntry {
23 pub canonical_name: Option<String>,
25 pub canonical_email: Option<String>,
27 pub match_name: Option<String>,
29 pub match_email: String,
31}
32
33#[derive(Debug, Default, Clone)]
34struct MailmapInfo {
35 name: Option<String>,
36 email: Option<String>,
37}
38
39#[derive(Debug, Default, Clone)]
40struct MailmapBucket {
41 simple: MailmapInfo,
43 by_name: BTreeMap<String, MailmapInfo>,
45}
46
47#[derive(Debug, Default, Clone)]
49pub struct MailmapTable {
50 buckets: BTreeMap<String, MailmapBucket>,
52}
53
54impl MailmapTable {
55 #[must_use]
57 pub fn is_empty(&self) -> bool {
58 self.buckets.is_empty()
59 }
60
61 #[must_use]
65 pub fn map_user(&self, name: String, email: String) -> (String, String) {
66 self.map_user_str(name.as_str(), email.as_str())
67 }
68
69 #[must_use]
71 pub fn map_user_str(&self, name: &str, email: &str) -> (String, String) {
72 let (name, email) = self.map_user_cow(name, email);
73 (name.into_owned(), email.into_owned())
74 }
75
76 #[must_use]
78 pub fn map_user_cow<'a>(&self, name: &'a str, email: &'a str) -> (Cow<'a, str>, Cow<'a, str>) {
79 let key = email_lookup_key(email);
80 let Some(bucket) = self.buckets.get(key.as_ref()) else {
81 return (Cow::Borrowed(name), Cow::Borrowed(email));
82 };
83
84 let info = if !bucket.by_name.is_empty() {
85 let nk = name.to_ascii_lowercase();
86 bucket.by_name.get(&nk).or_else(|| {
87 if bucket.simple.name.is_some() || bucket.simple.email.is_some() {
88 Some(&bucket.simple)
89 } else {
90 None
91 }
92 })
93 } else if bucket.simple.name.is_some() || bucket.simple.email.is_some() {
94 Some(&bucket.simple)
95 } else {
96 None
97 };
98
99 let Some(info) = info else {
100 return (Cow::Borrowed(name), Cow::Borrowed(email));
101 };
102 if info.name.is_none() && info.email.is_none() {
103 return (Cow::Borrowed(name), Cow::Borrowed(email));
104 }
105 let mut out_name = name.to_owned();
106 let mut out_email = email.to_owned();
107 if let Some(ref e) = info.email {
108 out_email.clone_from(e);
109 }
110 if let Some(ref n) = info.name {
111 out_name.clone_from(n);
112 }
113 (Cow::Owned(out_name), Cow::Owned(out_email))
114 }
115}
116
117fn email_lookup_key(email: &str) -> Cow<'_, str> {
118 if email.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
119 Cow::Owned(email.to_ascii_lowercase())
120 } else {
121 Cow::Borrowed(email)
122 }
123}
124
125fn ascii_lowercase_owned(s: &str) -> String {
126 s.chars().map(|c| c.to_ascii_lowercase()).collect()
127}
128
129fn add_mapping(
130 table: &mut MailmapTable,
131 new_name: Option<String>,
132 new_email: Option<String>,
133 old_name: Option<String>,
134 old_email: Option<String>,
135) {
136 let (old_email, new_email) = match (old_email, new_email) {
139 (None, Some(e)) => (e, None),
140 (Some(old), new) => (old, new),
141 (None, None) => return,
142 };
143
144 let key = ascii_lowercase_owned(&old_email);
145 let bucket = table.buckets.entry(key).or_default();
146
147 if let Some(old_n) = old_name {
148 let nk = ascii_lowercase_owned(&old_n);
149 let mi = MailmapInfo {
150 name: new_name,
151 email: new_email,
152 };
153 bucket.by_name.insert(nk, mi);
154 } else {
155 if let Some(n) = new_name {
156 bucket.simple.name = Some(n);
157 }
158 if let Some(e) = new_email {
159 bucket.simple.email = Some(e);
160 }
161 }
162}
163
164fn parse_name_and_email(
166 buffer: &str,
167 allow_empty_email: bool,
168) -> Option<(Option<String>, Option<String>, &str)> {
169 let left = buffer.find('<')?;
170 let rest = &buffer[left + 1..];
171 let right_rel = rest.find('>')?;
172 if !allow_empty_email && right_rel == 0 {
173 return None;
174 }
175 let email = rest[..right_rel].to_string();
178 let right = left + 1 + right_rel;
179 let name_part = buffer[..left].trim_end_matches(|c: char| c.is_ascii_whitespace());
180 let name = if name_part.is_empty() {
181 None
182 } else {
183 Some(name_part.to_string())
184 };
185 let after = buffer.get(right + 1..).unwrap_or("");
186 Some((name, Some(email), after))
187}
188
189fn read_mailmap_line_into(table: &mut MailmapTable, line: &str) {
190 let line = line.trim_end_matches(['\r', '\n']);
191 let line = line.trim_start();
192 if line.is_empty() || line.starts_with('#') {
193 return;
194 }
195
196 let (name1, email1, rest1) = match parse_name_and_email(line, false) {
200 Some(x) => x,
201 None => return,
202 };
203
204 let (name2, email2) = if rest1.trim().is_empty() {
205 (None, None)
206 } else {
207 match parse_name_and_email(rest1.trim_start(), true) {
208 Some((n, e, tail)) if tail.trim().is_empty() => (n, e),
209 _ => return,
210 }
211 };
212
213 add_mapping(table, name1, email1, name2, email2);
214}
215
216pub fn read_mailmap_string(table: &mut MailmapTable, buf: &str) {
218 let mut start = 0usize;
219 for (i, ch) in buf.char_indices() {
220 if ch == '\n' {
221 read_mailmap_line_into(table, &buf[start..i]);
222 start = i + 1;
223 }
224 }
225 if start < buf.len() {
226 read_mailmap_line_into(table, &buf[start..]);
227 }
228}
229
230#[must_use]
232pub fn table_from_entries(entries: &[MailmapEntry]) -> MailmapTable {
233 let mut table = MailmapTable::default();
234 for e in entries {
235 add_mapping(
236 &mut table,
237 e.canonical_name.clone(),
238 e.canonical_email.clone(),
239 e.match_name.clone(),
240 Some(e.match_email.clone()),
241 );
242 }
243 table
244}
245
246#[must_use]
248pub fn parse_mailmap(content: &str) -> Vec<MailmapEntry> {
249 table_to_entries(&build_mailmap_table_from_str(content))
250}
251
252fn build_mailmap_table_from_str(content: &str) -> MailmapTable {
253 let mut table = MailmapTable::default();
254 read_mailmap_string(&mut table, content);
255 table
256}
257
258fn table_to_entries(table: &MailmapTable) -> Vec<MailmapEntry> {
259 let mut out = Vec::new();
260 for (email_lc, bucket) in &table.buckets {
261 if bucket.simple.name.is_some() || bucket.simple.email.is_some() {
262 out.push(MailmapEntry {
263 canonical_name: bucket.simple.name.clone(),
264 canonical_email: bucket.simple.email.clone(),
265 match_name: None,
266 match_email: email_lc.clone(),
267 });
268 }
269 for (name_lc, mi) in &bucket.by_name {
270 out.push(MailmapEntry {
271 canonical_name: mi.name.clone(),
272 canonical_email: mi.email.clone(),
273 match_name: Some(name_lc.clone()),
274 match_email: email_lc.clone(),
275 });
276 }
277 }
278 out
279}
280
281#[must_use]
283pub fn parse_contact(contact: &str) -> (Option<String>, Option<String>) {
284 let contact = contact.trim();
285 if let Some(lt) = contact.find('<') {
286 if let Some(gt) = contact.find('>') {
287 let name = contact[..lt].trim();
288 let email = contact[lt + 1..gt].trim();
289 return (
290 if name.is_empty() {
291 None
292 } else {
293 Some(name.to_string())
294 },
295 if email.is_empty() {
296 None
297 } else {
298 Some(email.to_string())
299 },
300 );
301 }
302 }
303 if contact.contains('@') && !contact.chars().any(char::is_whitespace) {
304 return (None, Some(contact.to_string()));
305 }
306
307 (Some(contact.to_string()), None)
308}
309
310#[must_use]
312pub fn map_contact(
313 name: Option<&str>,
314 email: Option<&str>,
315 mailmap: &[MailmapEntry],
316) -> (String, String) {
317 let mut table = MailmapTable::default();
318 for e in mailmap {
319 add_mapping(
320 &mut table,
321 e.canonical_name.clone(),
322 e.canonical_email.clone(),
323 e.match_name.clone(),
324 Some(e.match_email.clone()),
325 );
326 }
327 let n = name.unwrap_or("").to_string();
328 let e = email.unwrap_or("").to_string();
329 table.map_user(n, e)
330}
331
332#[must_use]
334pub fn map_contact_table(
335 name: Option<&str>,
336 email: Option<&str>,
337 table: &MailmapTable,
338) -> (String, String) {
339 let n = name.unwrap_or("").to_string();
340 let e = email.unwrap_or("").to_string();
341 table.map_user(n, e)
342}
343
344#[must_use]
346pub fn render_contact(name: &str, email: &str) -> String {
347 if email.is_empty() {
348 return name.to_string();
349 }
350 if name.is_empty() {
351 return format!("<{email}>");
352 }
353 format!("{name} <{email}>")
354}
355
356fn resolve_mailmap_path(base: &Path, value: &str) -> PathBuf {
357 let candidate = Path::new(value);
358 if candidate.is_absolute() {
359 candidate.to_path_buf()
360 } else {
361 base.join(candidate)
362 }
363}
364
365fn read_mailmap_file_nofollow(path: &Path) -> Result<String> {
366 #[cfg(unix)]
367 {
368 use std::os::unix::fs::OpenOptionsExt;
369
370 let mut file = fs::OpenOptions::new()
372 .read(true)
373 .custom_flags(libc::O_NOFOLLOW)
374 .open(path)
375 .map_err(|_| {
376 Error::PathError(format!("unable to open mailmap at {}", path.display()))
377 })?;
378 let mut s = String::new();
379 file.read_to_string(&mut s)
380 .map_err(|e| Error::PathError(format!("reading {}: {e}", path.display())))?;
381 Ok(s)
382 }
383 #[cfg(not(unix))]
384 {
385 fs::read_to_string(path)
386 .map_err(|e| Error::PathError(format!("reading {}: {e}", path.display())))
387 }
388}
389
390fn read_optional_mailmap_file(path: &Path, nofollow: bool) -> Result<String> {
391 if !path.exists() {
392 return Ok(String::new());
393 }
394 if nofollow {
395 read_mailmap_file_nofollow(path)
396 } else {
397 fs::read_to_string(path)
398 .map_err(|e| Error::PathError(format!("reading {}: {e}", path.display())))
399 }
400}
401
402pub fn read_mailmap_blob(repo: &Repository, spec: &str) -> Result<String> {
409 let oid = resolve_revision(repo, spec)
410 .map_err(|e| Error::PathError(format!("resolving mailmap blob '{spec}': {e}")))?;
411 match read_blob_text(repo, &oid, spec)? {
412 BlobText::Text(text) => Ok(text),
413 BlobText::NotABlob(kind) => Err(Error::PathError(format!(
414 "mailmap blob '{spec}' is a {kind}, not a blob"
415 ))),
416 }
417}
418
419enum BlobText {
421 Text(String),
422 NotABlob(ObjectKind),
423}
424
425fn read_blob_text(
426 repo: &Repository,
427 oid: &crate::objects::ObjectId,
428 spec: &str,
429) -> Result<BlobText> {
430 let obj = repo
431 .odb()
432 .read(oid)
433 .map_err(|e| Error::PathError(format!("reading mailmap blob '{spec}': {e}")))?;
434 if obj.kind != ObjectKind::Blob {
435 return Ok(BlobText::NotABlob(obj.kind));
436 }
437 Ok(BlobText::Text(
438 String::from_utf8_lossy(&obj.data).into_owned(),
439 ))
440}
441
442fn read_configured_mailmap_blob(repo: &Repository, spec: &str) -> Result<Option<String>> {
445 let Ok(oid) = resolve_revision(repo, spec) else {
446 return Ok(None);
447 };
448 match read_blob_text(repo, &oid, spec)? {
449 BlobText::Text(text) => Ok(Some(text)),
450 BlobText::NotABlob(kind) => {
451 repo.warn(crate::diagnostics::Warning::MailmapUnreadable {
452 spec: spec.to_owned(),
453 kind,
454 });
455 Ok(None)
456 }
457 }
458}
459
460pub fn load_mailmap_table(repo: &Repository) -> Result<MailmapTable> {
462 let mut table = MailmapTable::default();
463 load_mailmap_into(repo, &mut table)?;
464 Ok(table)
465}
466
467pub fn load_mailmap_into(repo: &Repository, table: &mut MailmapTable) -> Result<()> {
469 let config = repo.config()?;
470 let mut mailmap_blob = config.get("mailmap.blob");
471 let is_bare = repo.work_tree().is_none();
472 if mailmap_blob.is_none() && is_bare {
473 mailmap_blob = Some("HEAD:.mailmap".to_string());
474 }
475
476 let base_dir = repo.work_tree().unwrap_or(repo.git_dir()).to_path_buf();
477
478 if let Some(wt) = repo.work_tree() {
479 let in_tree = wt.join(".mailmap");
480 let body = read_optional_mailmap_file(&in_tree, true)?;
481 read_mailmap_string(table, &body);
482 }
483
484 if let Some(ref blob) = mailmap_blob {
485 if let Some(content) = read_configured_mailmap_blob(repo, blob)? {
486 read_mailmap_string(table, &content);
487 }
488 }
489
490 if let Some(file) = config.get("mailmap.file") {
491 read_mailmap_string(
492 table,
493 &read_optional_mailmap_file(&resolve_mailmap_path(&base_dir, &file), false)?,
494 );
495 }
496
497 Ok(())
498}
499
500pub fn load_mailmap(repo: &Repository) -> Result<Vec<MailmapEntry>> {
502 let table = load_mailmap_table(repo)?;
503 Ok(table_to_entries(&table))
504}
505
506#[must_use]
510pub fn apply_mailmap_to_commit_or_tag_bytes(data: &[u8], mailmap: &MailmapTable) -> Vec<u8> {
511 if mailmap.is_empty() {
512 return data.to_vec();
513 }
514 let Some(pos) = data.windows(2).position(|w| w == b"\n\n") else {
515 return data.to_vec();
516 };
517 let (headers, rest) = data.split_at(pos + 1);
518 let header_text = String::from_utf8_lossy(headers);
519 let mut out = String::with_capacity(data.len() + 64);
520 for line in header_text.lines() {
521 let rewritten = rewrite_identity_header_line(line, mailmap);
522 out.push_str(&rewritten);
523 out.push('\n');
524 }
525 out.push('\n');
526 out.push_str(&String::from_utf8_lossy(&rest[1..]));
527 out.into_bytes()
528}
529
530fn rewrite_identity_header_line(line: &str, mailmap: &MailmapTable) -> String {
531 for pref in ["author ", "committer ", "tagger "] {
532 if let Some(rest) = line.strip_prefix(pref) {
533 let rest = rest.trim_end_matches('\r');
534 let Some(gt) = rest.rfind('>') else {
535 return line.to_string();
536 };
537 let ident = &rest[..=gt];
538 let tail = rest[gt + 1..].trim_start();
539 let (name, email) = parse_contact(ident);
540 let (n, e) = map_contact_table(name.as_deref(), email.as_deref(), mailmap);
541 let new_ident = render_contact(&n, &e);
542 if tail.is_empty() {
543 return format!("{pref}{new_ident}");
544 }
545 return format!("{pref}{new_ident} {tail}");
546 }
547 }
548 line.to_string()
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 #[test]
556 fn name_entry_after_email_merges() {
557 let mut t = MailmapTable::default();
558 read_mailmap_string(
559 &mut t,
560 "<bugs@company.xy> <bugs@company.xx>\nInternal Guy <bugs@company.xx>\n",
561 );
562 let (n, e) = t.map_user("nick1".into(), "bugs@company.xx".into());
563 assert_eq!(n, "Internal Guy");
564 assert_eq!(e, "bugs@company.xy");
565 }
566
567 #[test]
568 fn single_pair_line_maps_name_only() {
569 let mut t = MailmapTable::default();
570 read_mailmap_string(&mut t, "Committed <committer@example.com>\n");
571 let (n, e) = t.map_user("C O Mitter".into(), "committer@example.com".into());
572 assert_eq!(n, "Committed");
573 assert_eq!(e, "committer@example.com");
574 }
575
576 #[test]
577 fn whitespace_inside_angle_brackets_is_part_of_map_key() {
578 let mut t = MailmapTable::default();
579 read_mailmap_string(&mut t, "Ah <ah@example.com> < a@example.com >\n");
580 let (n, e) = t.map_user("A".into(), "a@example.com".into());
581 assert_eq!(n, "A");
582 assert_eq!(e, "a@example.com");
583 let (n2, e2) = t.map_user("A".into(), " a@example.com ".into());
584 assert_eq!(n2, "Ah");
585 assert_eq!(e2, "ah@example.com");
586 }
587}