Skip to main content

searchfox_lib/
blame.rs

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        // For repos with HG history (e.g. firefox-main), fulldiff contains an HG hash.
25        // Searchfox's hgrev endpoint (web-server.rs) redirects to /rev/<git-hash>/ via
26        // `git cinnabar hg2git`, giving the git hash Searchfox uses for permalinks.
27        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        // For pure git repos, parent is HEAD's parent git hash — use as fallback.
51        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    /// Fetch blame data for specific lines in a file
63    pub async fn get_blame_for_lines(
64        &self,
65        path: &str,
66        lines: &[usize],
67    ) -> Result<HashMap<usize, BlameInfo>> {
68        // Fetch the HTML page for the file
69        let url = format!("https://searchfox.org/{}/source/{}", self.repo, path);
70        let html = self.get_html(&url).await?;
71
72        // Parse blame data from HTML
73        let blame_map = Self::parse_blame_from_html(&html)?;
74
75        // Filter to only the requested lines
76        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        // Collect unique commit hashes
86        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        // Batch fetch commit info
97        let commit_infos = self.get_commit_info(&unique_commits).await?;
98
99        // Build commit hash -> CommitInfo map
100        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        // Build final result
107        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    /// Fetch commit info for commit hashes (batched to avoid 414 URI Too Long)
125    async fn get_commit_info(&self, revs: &[&str]) -> Result<Vec<CommitInfo>> {
126        if revs.is_empty() {
127            return Ok(Vec::new());
128        }
129
130        // Batch requests to avoid hitting URL length limits (414 errors)
131        // Each hash is 40 chars + 1 comma, so ~50 hashes should be safe
132        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    /// Parse blame data from HTML, returns map of line -> (commit_hash, path, original_line)
152    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    /// Parse data-blame attribute format: "hash#path#lineno"
174    /// % in path means "same as current file"
175    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
189/// Parse commit header HTML to extract structured information
190pub fn parse_commit_header(header: &str) -> ParsedCommitInfo {
191    // Remove HTML tags for parsing
192    let text = strip_html_tags(header);
193
194    // Try to extract bug number
195    let bug_number = extract_bug_number(&text);
196
197    // Split by newline or <br> to separate message from author/date
198    let parts: Vec<&str> = text.split('\n').collect();
199
200    let message = if let Some(first_part) = parts.first() {
201        // Remove "Bug XXXXXX: " prefix if present
202        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    // Second part should contain author and date
212    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    // Decode common HTML entities
231    without_tags
232        .replace("&lt;", "<")
233        .replace("&gt;", ">")
234        .replace("&amp;", "&")
235        .replace("&quot;", "\"")
236        .replace("&#39;", "'")
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    // Format is typically "Author, Date"
249    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}