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 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 fn refs(&self, repo: &RepoId) -> ProviderResult<RepoRefs> {
112 let (owner, name) = split_repo(repo)?;
113 self.client.refs(owner, name)
114 }
115
116 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 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 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 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 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 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 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 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 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
312fn 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 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 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 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 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 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 #[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 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}