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 ) -> 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 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 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 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 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 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 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 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 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
305fn 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 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 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 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 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 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 #[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 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}