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