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