Skip to main content

rootle_github/client/
blobs.rs

1//! Immutable blobs, path-at-revision content and source archives.
2
3use super::transport::{classify_send, classify_status};
4use super::{API, GitHubClient, urlencoding};
5use rootle_provider::{ProviderError, ProviderResult};
6
7impl GitHubClient {
8    /// File at a ref via the contents API: raw bytes + the git blob
9    /// sha (the provider's content id).
10    pub fn blob_at(
11        &self,
12        owner: &str,
13        repo: &str,
14        path: &str,
15        ref_: Option<&str>,
16    ) -> ProviderResult<(Vec<u8>, String)> {
17        #[derive(serde::Deserialize)]
18        struct Contents {
19            content: String, // base64 with embedded newlines
20            sha: String,
21        }
22        let mut url = format!("{API}/repos/{owner}/{repo}/contents/{path}");
23        if let Some(r) = ref_ {
24            url.push_str(&format!("?ref={}", urlencoding(r)));
25        }
26        let item: Contents = self.get(&url)?;
27        use base64::Engine;
28        let clean: String = item
29            .content
30            .chars()
31            .filter(|c| !c.is_whitespace())
32            .collect();
33        let bytes = base64::engine::general_purpose::STANDARD
34            .decode(clean)
35            .map_err(|e| ProviderError::other(format!("contents base64: {e}")))?;
36        Ok((bytes, item.sha))
37    }
38
39    /// The default branch's source as a gzip tarball (api.github.com
40    /// 302s to codeload). Capped — a repo past this size is not a
41    /// fallback candidate.
42    pub fn source_tarball(&self, repo: &str) -> ProviderResult<Vec<u8>> {
43        const CAP: u64 = 64 * 1024 * 1024;
44        let url = format!("{API}/repos/{repo}/tarball");
45        let mut req = self.http.get(&url);
46        if let Some(token) = &self.token {
47            req = req.bearer_auth(token);
48        }
49        let mut resp = req.send().map_err(classify_send)?;
50        if !resp.status().is_success() {
51            return Err(classify_status(resp));
52        }
53        if let Some(len) = resp.content_length()
54            && len > CAP
55        {
56            return Err(ProviderError::other(format!(
57                "tarball too large for local grep ({len} bytes)"
58            )));
59        }
60        let mut bytes = Vec::new();
61        let mut capped = std::io::Read::take(&mut resp, CAP);
62        std::io::Read::read_to_end(&mut capped, &mut bytes)
63            .map_err(|e| ProviderError::other(e.to_string()))?;
64        Ok(bytes)
65    }
66
67    /// Fetch a blob by git sha, cache-first (blobs are immutable).
68    /// Files over 1 MiB are rejected — too heavy for a preview pane.
69    pub fn fetch_blob(&self, owner: &str, repo: &str, sha: &str) -> ProviderResult<Vec<u8>> {
70        if let Some(bytes) = crate::cache::read_blob(sha) {
71            return Ok(bytes);
72        }
73        #[derive(serde::Deserialize)]
74        struct BlobResponse {
75            content: String,
76            size: u64,
77        }
78        let url = format!("{API}/repos/{owner}/{repo}/git/blobs/{sha}");
79        let blob: BlobResponse = self.get(&url)?;
80        if blob.size > 1024 * 1024 {
81            return Err(ProviderError::other(format!(
82                "file too large to preview ({} bytes)",
83                blob.size
84            )));
85        }
86        use base64::Engine;
87        let clean: String = blob
88            .content
89            .chars()
90            .filter(|c| !c.is_whitespace())
91            .collect();
92        let bytes = base64::engine::general_purpose::STANDARD
93            .decode(clean)
94            .map_err(|e| ProviderError::other(e.to_string()))?;
95        let _ = crate::cache::write_blob(sha, &bytes);
96        Ok(bytes)
97    }
98}