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