Skip to main content

grit_lib/
mailmap.rs

1//! Parse `.mailmap` and resolve author/committer identities (Git-compatible).
2
3use crate::config::ConfigSet;
4use crate::error::Error as GustError;
5use crate::objects::ObjectKind;
6use crate::repo::Repository;
7use crate::rev_parse::resolve_revision;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11type Result<T> = std::result::Result<T, GustError>;
12
13/// One line from a `.mailmap` file after parsing.
14#[derive(Debug, Clone)]
15pub struct MailmapEntry {
16    /// Canonical name (`None` = keep original).
17    pub canonical_name: Option<String>,
18    /// Canonical email (`None` = keep original).
19    pub canonical_email: Option<String>,
20    /// Match on this name (`None` = any name with the email).
21    pub match_name: Option<String>,
22    /// Match on this email.
23    pub match_email: String,
24}
25
26struct EmailSpan {
27    value: String,
28    start: usize,
29    end: usize,
30}
31
32fn extract_emails(line: &str) -> Vec<EmailSpan> {
33    let mut emails = Vec::new();
34    let mut search_from = 0;
35
36    while let Some(start) = line[search_from..].find('<') {
37        let abs_start = search_from + start;
38        if let Some(end) = line[abs_start..].find('>') {
39            let abs_end = abs_start + end + 1;
40            let email = line[abs_start + 1..abs_end - 1].to_string();
41            emails.push(EmailSpan {
42                value: email,
43                start: abs_start,
44                end: abs_end,
45            });
46            search_from = abs_end;
47        } else {
48            break;
49        }
50    }
51
52    emails
53}
54
55fn parse_mailmap_line(line: &str) -> Option<MailmapEntry> {
56    let emails = extract_emails(line);
57
58    match emails.len() {
59        1 => {
60            let email = &emails[0];
61            let before = line[..email.start].trim();
62            let canonical_name = if before.is_empty() {
63                None
64            } else {
65                Some(before.to_string())
66            };
67            Some(MailmapEntry {
68                canonical_name,
69                canonical_email: Some(email.value.clone()),
70                match_name: None,
71                match_email: email.value.clone(),
72            })
73        }
74        2 => {
75            let canonical_email = &emails[0];
76            let match_email = &emails[1];
77
78            let before_first = line[..canonical_email.start].trim();
79            let between = line[canonical_email.end..match_email.start].trim();
80
81            let canonical_name = if before_first.is_empty() {
82                None
83            } else {
84                Some(before_first.to_string())
85            };
86
87            let match_name = if between.is_empty() {
88                None
89            } else {
90                Some(between.to_string())
91            };
92
93            Some(MailmapEntry {
94                canonical_name,
95                canonical_email: Some(canonical_email.value.clone()),
96                match_name,
97                match_email: match_email.value.clone(),
98            })
99        }
100        _ => None,
101    }
102}
103
104/// Parse a `.mailmap` file body into entries (comments and blanks skipped).
105#[must_use]
106pub fn parse_mailmap(content: &str) -> Vec<MailmapEntry> {
107    let mut entries = Vec::new();
108
109    for line in content.lines() {
110        let line = line.trim();
111        if line.is_empty() || line.starts_with('#') {
112            continue;
113        }
114
115        if let Some(entry) = parse_mailmap_line(line) {
116            entries.push(entry);
117        }
118    }
119
120    entries
121}
122
123/// Parse a contact string `Name <email>` or `<email>`.
124#[must_use]
125pub fn parse_contact(contact: &str) -> (Option<String>, Option<String>) {
126    let contact = contact.trim();
127    if let Some(lt) = contact.find('<') {
128        if let Some(gt) = contact.find('>') {
129            let name = contact[..lt].trim();
130            let email = contact[lt + 1..gt].trim();
131            return (
132                if name.is_empty() {
133                    None
134                } else {
135                    Some(name.to_string())
136                },
137                if email.is_empty() {
138                    None
139                } else {
140                    Some(email.to_string())
141                },
142            );
143        }
144    }
145    if contact.contains('@') && !contact.chars().any(char::is_whitespace) {
146        return (None, Some(contact.to_string()));
147    }
148
149    (Some(contact.to_string()), None)
150}
151
152/// Map `(name, email)` through the mailmap; last matching rule wins (Git order).
153#[must_use]
154pub fn map_contact(
155    name: Option<&str>,
156    email: Option<&str>,
157    mailmap: &[MailmapEntry],
158) -> (String, String) {
159    let orig_name = name.unwrap_or("");
160    let orig_email = email.unwrap_or("");
161
162    for entry in mailmap.iter().rev() {
163        if !entry.match_email.eq_ignore_ascii_case(orig_email) {
164            continue;
165        }
166
167        if let Some(ref match_name) = entry.match_name {
168            if !match_name.eq_ignore_ascii_case(orig_name) {
169                continue;
170            }
171        }
172
173        let result_name = entry.canonical_name.as_deref().unwrap_or(orig_name);
174        let result_email = entry.canonical_email.as_deref().unwrap_or(orig_email);
175
176        return (result_name.to_string(), result_email.to_string());
177    }
178
179    (orig_name.to_string(), orig_email.to_string())
180}
181
182/// Format a contact for display (`check-mailmap` style).
183#[must_use]
184pub fn render_contact(name: &str, email: &str) -> String {
185    if email.is_empty() {
186        return name.to_string();
187    }
188    if name.is_empty() {
189        return format!("<{email}>");
190    }
191    format!("{name} <{email}>")
192}
193
194fn resolve_mailmap_path(base: &Path, value: &str) -> PathBuf {
195    let candidate = Path::new(value);
196    if candidate.is_absolute() {
197        candidate.to_path_buf()
198    } else {
199        base.join(candidate)
200    }
201}
202
203fn read_optional_mailmap_file(path: &Path) -> Result<String> {
204    if path.exists() {
205        fs::read_to_string(path)
206            .map_err(|e| GustError::PathError(format!("reading {}: {e}", path.display())))
207    } else {
208        Ok(String::new())
209    }
210}
211
212/// Read mailmap text from a blob revision (for `mailmap.blob` / CLI `--mailmap-blob`).
213pub fn read_mailmap_blob(repo: &Repository, spec: &str) -> Result<String> {
214    let oid = resolve_revision(repo, spec)
215        .map_err(|e| GustError::PathError(format!("resolving mailmap blob '{spec}': {e}")))?;
216    let obj = repo
217        .odb
218        .read(&oid)
219        .map_err(|e| GustError::PathError(format!("reading mailmap blob '{spec}': {e}")))?;
220    if obj.kind != ObjectKind::Blob {
221        return Err(GustError::PathError(format!(
222            "mailmap.blob '{spec}' does not resolve to a blob object"
223        )));
224    }
225    Ok(String::from_utf8_lossy(&obj.data).into_owned())
226}
227
228/// Load and concatenate all configured mailmap sources for a repository.
229pub fn load_mailmap_raw(repo: &Repository) -> Result<String> {
230    let mut mailmap_content = String::new();
231
232    if let Some(ref wt) = repo.work_tree {
233        mailmap_content.push_str(&read_optional_mailmap_file(&wt.join(".mailmap"))?);
234        if !mailmap_content.ends_with('\n') && !mailmap_content.is_empty() {
235            mailmap_content.push('\n');
236        }
237    }
238
239    let config = ConfigSet::load(Some(&repo.git_dir), true)?;
240    let base_dir = repo
241        .work_tree
242        .as_deref()
243        .unwrap_or(repo.git_dir.as_path())
244        .to_path_buf();
245
246    if let Some(file) = config.get("mailmap.file") {
247        mailmap_content.push_str(&read_optional_mailmap_file(&resolve_mailmap_path(
248            &base_dir, &file,
249        ))?);
250        if !mailmap_content.ends_with('\n') && !mailmap_content.is_empty() {
251            mailmap_content.push('\n');
252        }
253    }
254    if let Some(blob) = config.get("mailmap.blob") {
255        mailmap_content.push_str(&read_mailmap_blob(repo, &blob)?);
256        if !mailmap_content.ends_with('\n') && !mailmap_content.is_empty() {
257            mailmap_content.push('\n');
258        }
259    }
260
261    Ok(mailmap_content)
262}
263
264/// Parsed mailmap for the repository (default `.mailmap` + config).
265pub fn load_mailmap(repo: &Repository) -> Result<Vec<MailmapEntry>> {
266    Ok(parse_mailmap(&load_mailmap_raw(repo)?))
267}