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        extract_head_hash(&json)
19    }
20
21    /// Fetch blame data for specific lines in a file
22    pub async fn get_blame_for_lines(
23        &self,
24        path: &str,
25        lines: &[usize],
26    ) -> Result<HashMap<usize, BlameInfo>> {
27        // Fetch the HTML page for the file
28        let url = format!("https://searchfox.org/{}/source/{}", self.repo, path);
29        let html = self.get_html(&url).await?;
30
31        // Parse blame data from HTML
32        let blame_map = Self::parse_blame_from_html(&html)?;
33
34        // Filter to only the requested lines
35        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        // Collect unique commit hashes
45        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        // Batch fetch commit info
56        let commit_infos = self.get_commit_info(&unique_commits).await?;
57
58        // Build commit hash -> CommitInfo map
59        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        // Build final result
66        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    /// Fetch commit info for commit hashes (batched to avoid 414 URI Too Long)
84    async fn get_commit_info(&self, revs: &[&str]) -> Result<Vec<CommitInfo>> {
85        if revs.is_empty() {
86            return Ok(Vec::new());
87        }
88
89        // Batch requests to avoid hitting URL length limits (414 errors)
90        // Each hash is 40 chars + 1 comma, so ~50 hashes should be safe
91        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    /// Parse blame data from HTML, returns map of line -> (commit_hash, path, original_line)
111    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    /// Parse data-blame attribute format: "hash#path#lineno"
133    /// % in path means "same as current file"
134    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
148/// Parse commit header HTML to extract structured information
149pub fn parse_commit_header(header: &str) -> ParsedCommitInfo {
150    // Remove HTML tags for parsing
151    let text = strip_html_tags(header);
152
153    // Try to extract bug number
154    let bug_number = extract_bug_number(&text);
155
156    // Split by newline or <br> to separate message from author/date
157    let parts: Vec<&str> = text.split('\n').collect();
158
159    let message = if let Some(first_part) = parts.first() {
160        // Remove "Bug XXXXXX: " prefix if present
161        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    // Second part should contain author and date
171    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    // Decode common HTML entities
190    without_tags
191        .replace("&lt;", "<")
192        .replace("&gt;", ">")
193        .replace("&amp;", "&")
194        .replace("&quot;", "\"")
195        .replace("&#39;", "'")
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    // Format is typically "Author, Date"
208    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}