1use 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#[derive(Debug, Clone)]
15pub struct MailmapEntry {
16 pub canonical_name: Option<String>,
18 pub canonical_email: Option<String>,
20 pub match_name: Option<String>,
22 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#[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#[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#[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#[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
212pub 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
228pub 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
264pub fn load_mailmap(repo: &Repository) -> Result<Vec<MailmapEntry>> {
266 Ok(parse_mailmap(&load_mailmap_raw(repo)?))
267}