1use 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 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 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 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 fn refs(&self, repo: &RepoId) -> ProviderResult<RepoRefs> {
105 let (owner, name) = split_repo(repo)?;
106 self.client.refs(owner, name)
107 }
108
109 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 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 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 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 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 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 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 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 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
310fn 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 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 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 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 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 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 #[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 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}