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