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