searchfox-lib 0.20.5

Library for searchfox.org API access
Documentation
use crate::client::SearchfoxClient;
use crate::utils::searchfox_url_repo;
use anyhow::Result;
use log::debug;
use scraper::{Html, Selector};

impl SearchfoxClient {
    pub async fn get_file(&self, path: &str) -> Result<String> {
        let url = format!(
            "{}/{}/source/{}",
            self.base_url,
            searchfox_url_repo(&self.repo),
            path
        );

        let cache_key = format!("source:{url}");

        if let Some(entry) = self.cache_get(&cache_key) {
            if entry.is_fresh() {
                debug!("Cache hit (fresh) for: {}", url);
                return Ok(entry.content);
            }

            debug!("Cache stale, revalidating: {}", url);
            match self
                .get_html_with_meta(&url, entry.etag.as_deref(), entry.last_modified.as_deref())
                .await?
            {
                None => {
                    debug!("Cache revalidated (304) for: {}", url);
                    self.cache_set(
                        &cache_key,
                        &entry.content,
                        entry.etag.as_deref(),
                        entry.last_modified.as_deref(),
                    );
                    return Ok(entry.content);
                }
                Some((html, etag, last_modified)) => {
                    let content = parse_source_lines(&html, &url)?;
                    self.cache_set(
                        &cache_key,
                        &content,
                        etag.as_deref(),
                        last_modified.as_deref(),
                    );
                    return Ok(content);
                }
            }
        }

        if self.force_refetch() {
            debug!("Force refetch for: {}", url);
        }

        let (html, etag, last_modified) = self
            .get_html_with_meta(&url, None, None)
            .await?
            .ok_or_else(|| anyhow::anyhow!("Unexpected 304 on cold cache miss"))?;

        let content = parse_source_lines(&html, &url)?;
        self.cache_set(
            &cache_key,
            &content,
            etag.as_deref(),
            last_modified.as_deref(),
        );

        Ok(content)
    }

    pub async fn get_file_at_revision(&self, path: &str, revision: &str) -> Result<String> {
        let url = format!(
            "{}/{}/rev/{}/{}",
            self.base_url,
            searchfox_url_repo(&self.repo),
            revision,
            path
        );

        let cache_key = format!("source-rev:{revision}:{url}");

        if let Some(entry) = self.cache_get(&cache_key) {
            if entry.is_fresh() {
                debug!("Cache hit (fresh) for: {}", url);
                return Ok(entry.content);
            }
        }

        let response = self.get(url.parse()?).await?;
        let html = response.text().await?;
        let content = parse_source_lines(&html, &url)?;
        // Revision-pinned URLs are immutable — cache indefinitely.
        self.cache_set(&cache_key, &content, None, None);

        Ok(content)
    }
}

fn parse_source_lines(html: &str, url: &str) -> Result<String> {
    let document = Html::parse_document(html);
    let selector = Selector::parse("code.source-line").expect("valid selector");
    let lines: Vec<String> = document
        .select(&selector)
        .map(|el| el.text().collect::<String>())
        .collect();
    if lines.is_empty() {
        anyhow::bail!("Could not find file content at {}", url);
    }
    Ok(lines.join(""))
}

#[cfg(test)]
mod tests {
    use super::*;
    use wiremock::matchers::{header, method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    const HTML_V1: &str = r#"<html><body>
        <code class="source-line">fn foo() {</code>
        <code class="source-line">    42</code>
        <code class="source-line">}</code>
    </body></html>"#;

    const HTML_V2: &str = r#"<html><body>
        <code class="source-line">fn foo() {</code>
        <code class="source-line">    99</code>
        <code class="source-line">}</code>
    </body></html>"#;

    #[tokio::test]
    async fn cold_miss_fetches_and_returns_parsed_content() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/firefox-main/source/some/file.js"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_string(HTML_V1)
                    .insert_header("etag", "\"v1\""),
            )
            .expect(1)
            .mount(&server)
            .await;

        let client = SearchfoxClient::new_for_test("mozilla-central".into(), server.uri()).unwrap();
        let content = client.get_file("some/file.js").await.unwrap();

        assert!(content.contains("fn foo()"));
        assert!(!content.contains("<code"));
    }

    #[tokio::test]
    async fn fresh_hit_skips_network() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/firefox-main/source/some/file.js"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_string(HTML_V1)
                    .insert_header("etag", "\"v1\""),
            )
            .expect(1)
            .mount(&server)
            .await;

        let client = SearchfoxClient::new_for_test("mozilla-central".into(), server.uri()).unwrap();
        let first = client.get_file("some/file.js").await.unwrap();
        let second = client.get_file("some/file.js").await.unwrap();

        assert_eq!(first, second);
    }

    #[tokio::test]
    async fn stale_hit_304_refreshes_without_reparse() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/firefox-main/source/some/file.js"))
            .and(header("If-None-Match", "\"v1\""))
            .respond_with(ResponseTemplate::new(304))
            .expect(1)
            .mount(&server)
            .await;

        let client = SearchfoxClient::new_for_test("mozilla-central".into(), server.uri()).unwrap();
        let url = format!("{}/firefox-main/source/some/file.js", server.uri());
        let cache_key = format!("source:{url}");
        client.cache_set_stale(&cache_key, "fn foo() {\n    42\n}\n", Some("\"v1\""), None);

        let content = client.get_file("some/file.js").await.unwrap();
        assert!(content.contains("fn foo()"));
    }

    #[tokio::test]
    async fn server_error_returns_error() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/firefox-main/source/some/file.js"))
            .respond_with(ResponseTemplate::new(404))
            .mount(&server)
            .await;

        let client = SearchfoxClient::new_for_test("mozilla-central".into(), server.uri()).unwrap();
        assert!(client.get_file("some/file.js").await.is_err());
    }

    #[tokio::test]
    async fn html_without_source_lines_returns_error() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/firefox-main/source/some/file.js"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_string("<html><body>no lines here</body></html>"),
            )
            .mount(&server)
            .await;

        let client = SearchfoxClient::new_for_test("mozilla-central".into(), server.uri()).unwrap();
        assert!(client.get_file("some/file.js").await.is_err());
    }

    #[tokio::test]
    async fn stale_without_etag_does_unconditional_get() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/firefox-main/source/some/file.js"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_string(HTML_V2)
                    .insert_header("etag", "\"v2\""),
            )
            .expect(1)
            .mount(&server)
            .await;

        let client = SearchfoxClient::new_for_test("mozilla-central".into(), server.uri()).unwrap();
        let url = format!("{}/firefox-main/source/some/file.js", server.uri());
        let cache_key = format!("source:{url}");
        client.cache_set_stale(&cache_key, "fn foo() {\n    42\n}\n", None, None);

        let content = client.get_file("some/file.js").await.unwrap();
        assert!(content.contains("99"));
    }

    #[tokio::test]
    async fn stale_hit_200_fetches_and_reparses() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/firefox-main/source/some/file.js"))
            .and(header("If-None-Match", "\"v1\""))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_string(HTML_V2)
                    .insert_header("etag", "\"v2\""),
            )
            .expect(1)
            .mount(&server)
            .await;

        let client = SearchfoxClient::new_for_test("mozilla-central".into(), server.uri()).unwrap();
        let url = format!("{}/firefox-main/source/some/file.js", server.uri());
        let cache_key = format!("source:{url}");
        client.cache_set_stale(&cache_key, "fn foo() {\n    42\n}\n", Some("\"v1\""), None);

        let content = client.get_file("some/file.js").await.unwrap();
        assert!(content.contains("99"));
        assert!(!content.contains("42"));
    }
}