Skip to main content

rootle_github/
lib.rs

1//! GitHub provider (reference implementation, plans/0005): wraps the
2//! REST `Client` — auth resolution, sha-keyed disk cache, ETag
3//! revalidation all live inside it (PLAN.md §7/§8).
4
5use rootle_provider::{
6    BlameRange, Capabilities, CodeMatch, CommitDetail, CommitFile, FileStatus, GitRef, LogEntry,
7    Provider, ProviderResult, RepoId, RepoInfo, RepoRefs, SearchItem, Sha, TreeNode, TreeResult,
8};
9pub mod cache;
10pub mod client;
11pub mod types;
12
13use crate::client::GitHubClient;
14
15pub struct GitHubProvider {
16    client: GitHubClient,
17}
18
19impl GitHubProvider {
20    pub fn new(max_mb: u64) -> Self {
21        // Self-hardening: orphan sweep + LRU eviction of the content
22        // store, off-thread — the TUI never knows this exists.
23        let max_bytes = max_mb * 1024 * 1024;
24        std::thread::spawn(move || crate::cache::harden(max_bytes));
25        GitHubProvider {
26            client: GitHubClient::new(),
27        }
28    }
29
30    /// Token-less provider (tests, offline defaults).
31    /// Token-less, no hardening (tests and offline defaults).
32    pub fn anonymous() -> Self {
33        GitHubProvider {
34            client: GitHubClient::anonymous(),
35        }
36    }
37}
38
39fn split_repo(repo: &RepoId) -> Result<(&str, &str), String> {
40    repo.as_str()
41        .split_once('/')
42        .ok_or_else(|| format!("bad repo id: {repo} (expected owner/name)"))
43}
44
45impl From<&crate::types::TreeEntry> for TreeNode {
46    fn from(e: &crate::types::TreeEntry) -> Self {
47        TreeNode {
48            path: e.path.clone(),
49            is_dir: e.kind == "tree",
50            sha: e.sha.clone(),
51            size: e.size,
52        }
53    }
54}
55
56impl Provider for GitHubProvider {
57    fn name(&self) -> &str {
58        "github"
59    }
60
61    fn icon(&self) -> Option<String> {
62        Some("github".into())
63    }
64
65    fn capabilities(&self) -> Capabilities {
66        Capabilities {
67            orgs: true,
68            code_search: true,
69            file_search: true,
70            // v1.5: branches/tags, log, blame. v1.6: commit detail.
71            refs: true,
72            log: true,
73            blame: true,
74            commit: true,
75        }
76    }
77
78    fn default_orgs(&self) -> Vec<String> {
79        ["ratatui", "tokio-rs", "helix-editor"]
80            .iter()
81            .map(|s| s.to_string())
82            .collect()
83    }
84
85    fn search(&self, query: &str) -> ProviderResult<Vec<SearchItem>> {
86        self.client.search(query)
87    }
88
89    fn org_repos(&self, org: &str) -> ProviderResult<Vec<RepoInfo>> {
90        self.client.org_repos(org)
91    }
92
93    fn fetch_tree(&self, repo: &RepoId, ref_: Option<&GitRef>) -> ProviderResult<TreeResult> {
94        let (owner, name) = split_repo(repo)?;
95        let (tree, truncated, branch) =
96            self.client
97                .fetch_tree(owner, name, ref_.map(|r| r.as_str()))?;
98        Ok(TreeResult {
99            entries: tree.tree.iter().map(Into::into).collect(),
100            truncated,
101            branch,
102        })
103    }
104
105    fn fetch_blob(&self, repo: &RepoId, sha: &Sha) -> ProviderResult<Vec<u8>> {
106        let (owner, name) = split_repo(repo)?;
107        self.client.fetch_blob(owner, name, sha.as_str())
108    }
109
110    /// v1.5 (plans/0016 M1): branches + tags.
111    fn refs(&self, repo: &RepoId) -> ProviderResult<RepoRefs> {
112        let (owner, name) = split_repo(repo)?;
113        self.client.refs(owner, name)
114    }
115
116    /// v1.5: commit log, newest first.
117    fn log(
118        &self,
119        repo: &RepoId,
120        path: Option<&str>,
121        ref_: Option<&GitRef>,
122        limit: Option<usize>,
123    ) -> ProviderResult<(Vec<LogEntry>, bool)> {
124        let (owner, name) = split_repo(repo)?;
125        self.client
126            .log(owner, name, path, ref_.map(|r| r.as_str()), limit)
127    }
128
129    /// v1.5: open-at-commit.
130    fn blob_at(
131        &self,
132        repo: &RepoId,
133        path: &str,
134        ref_: Option<&GitRef>,
135    ) -> ProviderResult<(Vec<u8>, Sha)> {
136        let (owner, name) = split_repo(repo)?;
137        self.client
138            .blob_at(owner, name, path, ref_.map(|r| r.as_str()))
139            .map(|(bytes, sha)| (bytes, Sha::from(sha)))
140    }
141
142    /// v1.5: blame via GraphQL.
143    fn blame(
144        &self,
145        repo: &RepoId,
146        path: &str,
147        ref_: Option<&GitRef>,
148    ) -> ProviderResult<Vec<BlameRange>> {
149        let (owner, name) = split_repo(repo)?;
150        self.client
151            .blame(owner, name, path, ref_.map(|r| r.as_str()))
152    }
153
154    /// v1.6 (plans/0028): one commit's detail.
155    fn commit(&self, repo: &RepoId, sha: &Sha) -> ProviderResult<CommitDetail> {
156        let (owner, name) = split_repo(repo)?;
157        Ok(self.client.commit(owner, name, sha.as_str())?.into())
158    }
159
160    fn clone_url(&self, repo: &RepoId) -> ProviderResult<String> {
161        split_repo(repo)?;
162        Ok(format!("https://github.com/{repo}.git"))
163    }
164
165    fn web_url(
166        &self,
167        repo: &RepoId,
168        path: &str,
169        branch: Option<&GitRef>,
170        line: Option<u32>,
171        end: Option<u32>,
172        is_file: bool,
173    ) -> ProviderResult<String> {
174        split_repo(repo)?;
175        if path.is_empty() {
176            return Ok(format!("https://github.com/{repo}"));
177        }
178        // Blob vs tree grammar; the branch is cheap to resolve — the
179        // tree is disk-cached whenever the repo has been browsed.
180        let branch = match branch {
181            None => self.fetch_tree(repo, None).map(|t| t.branch)?,
182            Some(b) => b.as_str().to_string(),
183        };
184        let kind = if is_file { "blob" } else { "tree" };
185        // Range anchors (v1.5): `#L3-L7` when a selection's end rides
186        // along (GitHub's fragment grammar).
187        let fragment = match (is_file, line, end) {
188            (true, Some(line), Some(end)) if end > line => format!("#L{line}-L{end}"),
189            (true, Some(line), _) => format!("#L{line}"),
190            _ => String::new(),
191        };
192        Ok(format!(
193            "https://github.com/{repo}/{kind}/{branch}/{path}{fragment}"
194        ))
195    }
196
197    fn org_url(&self, org: &str) -> ProviderResult<String> {
198        Ok(format!("https://github.com/{org}"))
199    }
200
201    fn search_code(&self, q: &str) -> ProviderResult<rootle_provider::SearchCodeResult> {
202        let (items, truncated) = self.client.search_code(q)?;
203        Ok(rootle_provider::SearchCodeResult {
204            hits: items.iter().map(CodeMatch::from).collect(),
205            truncated,
206            index_as_of: None,
207        })
208    }
209
210    fn source_tarball(&self, repo: &RepoId) -> ProviderResult<Vec<u8>> {
211        split_repo(repo)?;
212        self.client.source_tarball(repo.as_str())
213    }
214
215    /// v1.3 progressive (plans/0011): stream `search/code` pages as
216    /// they arrive — the first 100 render while later pages fetch.
217    /// Budget: 3 pages × 100 (rate-conscious); GitHub caps code search
218    /// at 1000 anyway, and `truncated` says whether more exists.
219    fn search_code_progressive(
220        &self,
221        q: &str,
222        on_hits: &(dyn Fn(&[CodeMatch]) + Send + Sync),
223    ) -> ProviderResult<rootle_provider::SearchCodeResult> {
224        const PAGES: u32 = 3;
225        const PER_PAGE: usize = 100;
226        let mut fetched = 0usize;
227        let mut total = 0u64;
228        for page in 1..=PAGES {
229            let (items, page_total) = self.client.search_code_page(q, page)?;
230            total = page_total;
231            fetched += items.len();
232            let empty = items.is_empty();
233            let matches: Vec<CodeMatch> = items.iter().map(CodeMatch::from).collect();
234            on_hits(&matches);
235            if empty || items.len() < PER_PAGE {
236                break;
237            }
238        }
239        Ok(rootle_provider::SearchCodeResult {
240            hits: Vec::new(),
241            truncated: (total as usize) > fetched,
242            // GitHub's index freshness isn't exposed — no badge.
243            index_as_of: None,
244        })
245    }
246}
247
248impl From<&crate::types::CodeItem> for CodeMatch {
249    fn from(item: &crate::types::CodeItem) -> Self {
250        CodeMatch {
251            repo: item.repository.full_name.clone(),
252            path: item.path.clone(),
253            sha: item.sha.clone(),
254            branch: item
255                .repository
256                .default_branch
257                .clone()
258                .unwrap_or_else(|| "main".into()),
259            matches: item
260                .text_matches
261                .iter()
262                .flat_map(|tm| tm.matches.iter().map(|m| m.text.clone()))
263                .collect(),
264            located: true,
265            // GitHub text-match fragments carry no absolute line
266            // numbers — locating fills them.
267            line: None,
268        }
269    }
270}
271
272impl From<crate::types::CommitResponse> for CommitDetail {
273    fn from(response: crate::types::CommitResponse) -> Self {
274        let author = response.commit.author;
275        let (author, date) = author
276            .map(|author| {
277                (
278                    author.name.unwrap_or_default(),
279                    author.date.unwrap_or_default(),
280                )
281            })
282            .unwrap_or_default();
283        CommitDetail {
284            sha: response.sha.into(),
285            author,
286            date,
287            message: response.commit.message,
288            parents: response
289                .parents
290                .into_iter()
291                .map(|parent| parent.sha.into())
292                .collect(),
293            files: response
294                .files
295                .into_iter()
296                .map(|file| CommitFile {
297                    path: file.filename.into(),
298                    status: file_status(file.status.as_deref()),
299                    additions: file.additions,
300                    deletions: file.deletions,
301                    patch: file.patch,
302                    previous_path: file.previous_filename.map(Into::into),
303                    binary: false,
304                })
305                .collect(),
306            truncated: response.truncated,
307            web_url: response.html_url,
308        }
309    }
310}
311
312/// Wire `status` (lowercase) → `FileStatus`. GitHub also emits
313/// "changed"/"unchanged"; anything the seam doesn't name degrades to
314/// `Modified` (the v1.6 reader-tolerance rule).
315fn file_status(s: Option<&str>) -> FileStatus {
316    match s {
317        Some("added") => FileStatus::Added,
318        Some("removed") => FileStatus::Removed,
319        Some("renamed") => FileStatus::Renamed,
320        _ => FileStatus::Modified,
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn web_url_grammar() {
330        let p = GitHubProvider::anonymous();
331        // Repo root.
332        assert_eq!(
333            p.web_url(
334                &RepoId::from("ratatui/ratatui"),
335                "",
336                None,
337                None,
338                None,
339                false
340            )
341            .unwrap(),
342            "https://github.com/ratatui/ratatui"
343        );
344        // File with a line: blob + fragment (known branch, no I/O).
345        assert_eq!(
346            p.web_url(
347                &RepoId::from("ratatui/ratatui"),
348                "src/lib.rs",
349                Some(&GitRef::from("main")),
350                Some(42),
351                None,
352                true
353            )
354            .unwrap(),
355            "https://github.com/ratatui/ratatui/blob/main/src/lib.rs#L42"
356        );
357        // A visual range anchors `#L3-L7` (v1.5).
358        assert_eq!(
359            p.web_url(
360                &RepoId::from("ratatui/ratatui"),
361                "src/lib.rs",
362                Some(&GitRef::from("main")),
363                Some(3),
364                Some(7),
365                true
366            )
367            .unwrap(),
368            "https://github.com/ratatui/ratatui/blob/main/src/lib.rs#L3-L7"
369        );
370        // File without a line: blob, no fragment.
371        assert_eq!(
372            p.web_url(
373                &RepoId::from("ratatui/ratatui"),
374                "src/lib.rs",
375                Some(&GitRef::from("main")),
376                None,
377                None,
378                true
379            )
380            .unwrap(),
381            "https://github.com/ratatui/ratatui/blob/main/src/lib.rs"
382        );
383        // Directory: tree.
384        assert_eq!(
385            p.web_url(
386                &RepoId::from("ratatui/ratatui"),
387                "src",
388                Some(&GitRef::from("master")),
389                None,
390                None,
391                false
392            )
393            .unwrap(),
394            "https://github.com/ratatui/ratatui/tree/master/src"
395        );
396        assert_eq!(p.org_url("ratatui").unwrap(), "https://github.com/ratatui");
397    }
398
399    #[test]
400    fn clone_url_grammar() {
401        let p = GitHubProvider::anonymous();
402        assert_eq!(
403            p.clone_url(&RepoId::from("ratatui/ratatui")).unwrap(),
404            "https://github.com/ratatui/ratatui.git"
405        );
406        assert!(p.clone_url(&RepoId::from("no-slash")).is_err());
407    }
408
409    /// The v1.6 commit mapping: statuses degrade, renames carry
410    /// their old path, absent counts stay `None`.
411    #[test]
412    fn commit_detail_maps_from_wire() {
413        let json = r#"{
414            "sha": "6dcb09b5",
415            "commit": {
416                "message": "Fix the race\n\nBody line.",
417                "author": {"name": "octocat", "date": "2026-09-06T10:00:00Z"}
418            },
419            "parents": [{"sha": "aaa111"}, {"sha": "bbb222"}],
420            "files": [
421                {"filename": "src/lib.rs", "status": "modified", "additions": 2,
422                 "deletions": 1, "patch": "@@ -1 +1,2 @@\n-old\n+new\n+line"},
423                {"filename": "src/moved.rs", "status": "renamed",
424                 "previous_filename": "src/orig.rs", "additions": 1, "deletions": 1},
425                {"filename": "img/logo.png", "status": "changed", "additions": 3, "deletions": 0}
426            ]
427        }"#;
428        let wire: crate::types::CommitResponse = serde_json::from_str(json).unwrap();
429        let d = CommitDetail::from(wire);
430        assert_eq!(d.files[0].status, FileStatus::Modified);
431        assert_eq!(d.files[1].status, FileStatus::Renamed);
432        assert_eq!(
433            d.files[1].previous_path.as_ref().map(|path| path.as_str()),
434            Some("src/orig.rs")
435        );
436        // "changed" is not one of the seam's four → modified.
437        assert_eq!(d.files[2].status, FileStatus::Modified);
438        assert_eq!(d.files[2].patch, None);
439        assert_eq!(
440            d.line_stats(),
441            rootle_provider::CommitStatistics {
442                additions: Some(6),
443                deletions: Some(2)
444            }
445        );
446    }
447}