1use crate::client::SearchfoxClient;
2use crate::types::{BlameInfo, CommitInfo, ParsedCommitInfo};
3use crate::utils::searchfox_url_repo;
4use anyhow::Result;
5use regex::Regex;
6use scraper::{Html, Selector};
7use std::collections::HashMap;
8
9impl SearchfoxClient {
10 pub async fn get_head_hash(&self) -> anyhow::Result<String> {
11 let url = format!(
12 "https://searchfox.org/{}/commit-info/HEAD",
13 searchfox_url_repo(&self.repo)
14 );
15 let response = self.get_raw(&url).await?;
16 let json: serde_json::Value = serde_json::from_str(&response)
17 .map_err(|_| anyhow::anyhow!("Failed to parse HEAD commit info"))?;
18 extract_head_hash(&json)
19 }
20
21 pub async fn get_blame_for_lines(
23 &self,
24 path: &str,
25 lines: &[usize],
26 ) -> Result<HashMap<usize, BlameInfo>> {
27 let url = format!("https://searchfox.org/{}/source/{}", self.repo, path);
29 let html = self.get_html(&url).await?;
30
31 let blame_map = Self::parse_blame_from_html(&html)?;
33
34 let filtered_blame: HashMap<usize, (String, String, usize)> = blame_map
36 .into_iter()
37 .filter(|(line_no, _)| lines.contains(line_no))
38 .collect();
39
40 if filtered_blame.is_empty() {
41 return Ok(HashMap::new());
42 }
43
44 let unique_commits: Vec<&str> = {
46 let mut commits: Vec<&str> = filtered_blame
47 .values()
48 .map(|(hash, _, _)| hash.as_str())
49 .collect();
50 commits.sort_unstable();
51 commits.dedup();
52 commits
53 };
54
55 let commit_infos = self.get_commit_info(&unique_commits).await?;
57
58 let commit_map: HashMap<String, CommitInfo> = unique_commits
60 .into_iter()
61 .zip(commit_infos)
62 .map(|(hash, info)| (hash.to_string(), info))
63 .collect();
64
65 let result = filtered_blame
67 .into_iter()
68 .map(|(line_no, (hash, path, orig_line))| {
69 let commit_info = commit_map.get(&hash).cloned();
70 let blame_info = BlameInfo {
71 commit_hash: hash.clone(),
72 original_path: path,
73 original_line: orig_line,
74 commit_info,
75 };
76 (line_no, blame_info)
77 })
78 .collect();
79
80 Ok(result)
81 }
82
83 async fn get_commit_info(&self, revs: &[&str]) -> Result<Vec<CommitInfo>> {
85 if revs.is_empty() {
86 return Ok(Vec::new());
87 }
88
89 const BATCH_SIZE: usize = 50;
92
93 let mut all_infos = Vec::new();
94
95 for chunk in revs.chunks(BATCH_SIZE) {
96 let revs_str = chunk.join(",");
97 let url = format!(
98 "https://searchfox.org/{}/commit-info/{}",
99 self.repo, revs_str
100 );
101
102 let response = self.get_raw(&url).await?;
103 let mut commit_infos: Vec<CommitInfo> = serde_json::from_str(&response)?;
104 all_infos.append(&mut commit_infos);
105 }
106
107 Ok(all_infos)
108 }
109
110 fn parse_blame_from_html(html: &str) -> Result<HashMap<usize, (String, String, usize)>> {
112 let document = Html::parse_document(html);
113 let blame_selector = Selector::parse(".blame-strip").unwrap();
114 let line_selector = Selector::parse("div[role='row']").unwrap();
115
116 let mut result = HashMap::new();
117
118 for (line_number, row) in (1..).zip(document.select(&line_selector)) {
119 if let Some(blame_elem) = row.select(&blame_selector).next() {
120 if let Some(blame_data) = blame_elem.value().attr("data-blame") {
121 if let Some((hash, path, orig_line)) = Self::parse_data_blame(blame_data) {
122 result.insert(line_number, (hash, path, orig_line));
123 }
124 }
125 }
126 }
127
128 log::debug!("Parsed {} blame entries from HTML", result.len());
129 Ok(result)
130 }
131
132 fn parse_data_blame(data: &str) -> Option<(String, String, usize)> {
135 let parts: Vec<&str> = data.split('#').collect();
136 if parts.len() != 3 {
137 return None;
138 }
139
140 let hash = parts[0].to_string();
141 let path = parts[1].to_string();
142 let line_no = parts[2].parse::<usize>().ok()?;
143
144 Some((hash, path, line_no))
145 }
146}
147
148pub fn parse_commit_header(header: &str) -> ParsedCommitInfo {
150 let text = strip_html_tags(header);
152
153 let bug_number = extract_bug_number(&text);
155
156 let parts: Vec<&str> = text.split('\n').collect();
158
159 let message = if let Some(first_part) = parts.first() {
160 if let Some(idx) = first_part.find(':') {
162 first_part[idx + 1..].trim().to_string()
163 } else {
164 first_part.trim().to_string()
165 }
166 } else {
167 String::new()
168 };
169
170 let (author, date) = if parts.len() > 1 {
172 parse_author_date(parts[1])
173 } else {
174 (String::new(), String::new())
175 };
176
177 ParsedCommitInfo {
178 bug_number,
179 message,
180 author,
181 date,
182 }
183}
184
185fn strip_html_tags(html: &str) -> String {
186 let tag_re = Regex::new(r"<[^>]+>").unwrap();
187 let without_tags = tag_re.replace_all(html, "");
188
189 without_tags
191 .replace("<", "<")
192 .replace(">", ">")
193 .replace("&", "&")
194 .replace(""", "\"")
195 .replace("'", "'")
196}
197
198fn extract_bug_number(text: &str) -> Option<u64> {
199 let bug_re = Regex::new(r"[Bb]ug\s+(\d+)").unwrap();
200 bug_re
201 .captures(text)
202 .and_then(|cap| cap.get(1))
203 .and_then(|m| m.as_str().parse::<u64>().ok())
204}
205
206fn parse_author_date(text: &str) -> (String, String) {
207 let parts: Vec<&str> = text.split(',').collect();
209 if parts.len() >= 2 {
210 let author = parts[0].trim().to_string();
211 let date = parts[1..].join(",").trim().to_string();
212 (author, date)
213 } else {
214 (text.trim().to_string(), String::new())
215 }
216}
217
218fn extract_head_hash(json: &serde_json::Value) -> anyhow::Result<String> {
219 let commit = json
220 .as_array()
221 .and_then(|arr| arr.first())
222 .ok_or_else(|| anyhow::anyhow!("Could not find HEAD commit-info entry"))?;
223
224 if let Some(parent) = commit.get("parent").and_then(|p| p.as_str()) {
225 return Ok(parent.to_string());
226 }
227
228 if let Some(fulldiff) = commit.get("fulldiff").and_then(|p| p.as_str()) {
229 if let Some(hash) = fulldiff.rsplit('/').next() {
230 if hash.len() == 40 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
231 return Ok(hash.to_string());
232 }
233 }
234 }
235
236 Err(anyhow::anyhow!(
237 "Could not find HEAD revision hash in commit-info"
238 ))
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn test_parse_data_blame() {
247 let data = "88a286dcec9ba069397bd4c4c35b3e317bf66f4f#%#7";
248 let result = SearchfoxClient::parse_data_blame(data);
249 assert!(result.is_some());
250
251 let (hash, path, line) = result.unwrap();
252 assert_eq!(hash, "88a286dcec9ba069397bd4c4c35b3e317bf66f4f");
253 assert_eq!(path, "%");
254 assert_eq!(line, 7);
255 }
256
257 #[test]
258 fn test_parse_commit_header() {
259 let header =
260 "Bug <a href=\"...\">123456</a>: Fix audio issue\n<br><i>John Doe, 2021-05-15</i>";
261 let result = parse_commit_header(header);
262
263 assert_eq!(result.bug_number, Some(123456));
264 assert_eq!(result.message, "Fix audio issue");
265 assert_eq!(result.author, "John Doe");
266 assert_eq!(result.date, "2021-05-15");
267 }
268
269 #[test]
270 fn test_strip_html_tags() {
271 let html = "Bug <a href=\"url\">123</a>: message";
272 let result = strip_html_tags(html);
273 assert_eq!(result, "Bug 123: message");
274 }
275
276 #[test]
277 fn test_extract_head_hash_from_parent() {
278 let json = serde_json::json!([{
279 "parent": "0123456789abcdef0123456789abcdef01234567"
280 }]);
281 assert_eq!(
282 extract_head_hash(&json).unwrap(),
283 "0123456789abcdef0123456789abcdef01234567"
284 );
285 }
286
287 #[test]
288 fn test_extract_head_hash_from_fulldiff() {
289 let json = serde_json::json!([{
290 "fulldiff": "https://hg.mozilla.org/mozilla-central/rev/e408e7705e7d720080ff2d20b6fa20ba17ca760d"
291 }]);
292 assert_eq!(
293 extract_head_hash(&json).unwrap(),
294 "e408e7705e7d720080ff2d20b6fa20ba17ca760d"
295 );
296 }
297}