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