1use super::StdioProvider;
7use super::transport::{cancel_notification, de};
8use rootle_provider::{
9 BlameRange, Capabilities, CodeMatch, CommitDetail, ErrorKind, GitRef, LogEntry, Provider,
10 ProviderError, ProviderResult, RefInfo, RepoId, RepoInfo, RepoRefs, SearchCodeResult,
11 SearchItem, Sha, TreeNode, TreeResult,
12};
13use serde_json::json;
14use std::io::Write;
15use std::sync::atomic::Ordering;
16
17impl Provider for StdioProvider {
18 fn name(&self) -> &str {
19 &self.name
20 }
21
22 fn icon(&self) -> Option<String> {
23 self.icon.clone()
24 }
25
26 fn capabilities(&self) -> Capabilities {
27 self.capabilities
28 }
29
30 fn search(&self, query: &str) -> ProviderResult<Vec<SearchItem>> {
31 #[derive(serde::Deserialize)]
32 struct RepositorySearchReply {
33 #[serde(default)]
34 items: Vec<Item>,
35 }
36 #[derive(serde::Deserialize)]
37 struct Item {
38 full_name: Option<String>,
39 org: Option<String>,
40 }
41 let reply: RepositorySearchReply =
42 de(self.request("search/repos", json!({ "query": query }))?)?;
43 Ok(reply
44 .items
45 .into_iter()
46 .filter_map(|i| match (i.full_name, i.org) {
47 (Some(r), _) => Some(SearchItem::Repo(r)),
48 (None, Some(o)) => Some(SearchItem::Org(o)),
49 _ => None,
50 })
51 .collect())
52 }
53
54 fn org_repos(&self, org: &str) -> ProviderResult<Vec<RepoInfo>> {
55 #[derive(serde::Deserialize)]
56 struct OrganizationRepositoriesReply {
57 #[serde(default)]
58 repos: Vec<RepoEntry>,
59 }
60 #[derive(serde::Deserialize)]
64 #[serde(untagged)]
65 enum RepoEntry {
66 Name(String),
67 Meta {
68 name: String,
69 description: Option<String>,
70 #[serde(default)]
71 private: bool,
72 #[serde(default)]
73 archived: bool,
74 pushed_at: Option<String>,
75 },
76 }
77 let reply: OrganizationRepositoriesReply =
78 de(self.request("org/repos", json!({ "org": org }))?)?;
79 Ok(reply
80 .repos
81 .into_iter()
82 .map(|e| match e {
83 RepoEntry::Name(name) => RepoInfo::bare(name),
84 RepoEntry::Meta {
85 name,
86 description,
87 private,
88 archived,
89 pushed_at,
90 } => RepoInfo {
91 name,
92 description,
93 private,
94 archived,
95 pushed_at,
96 },
97 })
98 .collect())
99 }
100
101 fn fetch_tree(&self, repo: &RepoId, ref_: Option<&GitRef>) -> ProviderResult<TreeResult> {
102 #[derive(serde::Deserialize)]
103 struct RepositoryTreeReply {
104 #[serde(default)]
105 entries: Vec<Entry>,
106 #[serde(default)]
107 truncated: bool,
108 #[serde(default = "main")]
109 branch: String,
110 }
111 #[derive(serde::Deserialize)]
112 struct Entry {
113 path: String,
114 #[serde(rename = "type")]
115 kind: String, sha: String,
117 size: Option<u64>,
118 }
119 fn main() -> String {
120 "main".into()
121 }
122 let reply: RepositoryTreeReply =
123 de(self.request("repo/tree", json!({ "repo": repo, "ref": ref_ }))?)?;
124 Ok(TreeResult {
125 entries: reply
126 .entries
127 .into_iter()
128 .map(|e| TreeNode {
129 path: e.path,
130 is_dir: e.kind == "tree",
131 sha: e.sha,
132 size: e.size,
133 })
134 .collect(),
135 truncated: reply.truncated,
136 branch: reply.branch,
137 })
138 }
139
140 fn fetch_blob(&self, repo: &RepoId, sha: &Sha) -> ProviderResult<Vec<u8>> {
141 #[derive(serde::Deserialize)]
142 struct BlobReply {
143 bytes_b64: String,
144 }
145 let reply: BlobReply = de(self.request("repo/blob", json!({ "repo": repo, "sha": sha }))?)?;
146 use base64::Engine;
147 base64::engine::general_purpose::STANDARD
148 .decode(reply.bytes_b64)
149 .map_err(|e| ProviderError::other(format!("provider blob base64: {e}")))
150 }
151
152 fn web_url(
153 &self,
154 repo: &RepoId,
155 path: &str,
156 branch: Option<&GitRef>,
157 line: Option<u32>,
158 end: Option<u32>,
159 is_file: bool,
160 ) -> ProviderResult<String> {
161 #[derive(serde::Deserialize)]
162 struct RepositoryUrlReply {
163 url: String,
164 }
165 let reply: RepositoryUrlReply = de(self.request(
166 "repo/web_url",
167 json!({ "repo": repo, "path": path, "branch": branch.map(GitRef::as_str).unwrap_or(""), "line": line, "end_line": end, "is_file": is_file }),
168 )?)?;
169 Ok(reply.url)
170 }
171
172 fn org_url(&self, org: &str) -> ProviderResult<String> {
173 #[derive(serde::Deserialize)]
174 struct OrganizationUrlReply {
175 url: String,
176 }
177 let reply: OrganizationUrlReply = de(self.request("org/url", json!({ "org": org }))?)?;
178 Ok(reply.url)
179 }
180
181 fn clone_url(&self, repo: &RepoId) -> ProviderResult<String> {
182 #[derive(serde::Deserialize)]
183 struct CloneUrlReply {
184 clone_url: String,
185 }
186 let reply: CloneUrlReply = de(self.request("repo/clone_url", json!({ "repo": repo }))?)?;
187 Ok(reply.clone_url)
188 }
189
190 fn search_code(&self, q: &str) -> ProviderResult<SearchCodeResult> {
191 let r: CodeReply = de(self.request(
192 "search/code",
193 json!({ "q": q, "limit": rootle_provider::RENDER_BUDGET }),
194 )?)?;
195 Ok(SearchCodeResult {
196 hits: code_matches(&r.items),
197 truncated: r.truncated,
198 index_as_of: r.index.as_of,
199 })
200 }
201
202 fn search_code_progressive(
207 &self,
208 q: &str,
209 on_hits: &(dyn Fn(&[CodeMatch]) + Send + Sync),
210 ) -> ProviderResult<SearchCodeResult> {
211 let mut sink = |mut params: serde_json::Value| {
212 let items: Vec<WireItem> = match params.get_mut("items") {
213 Some(items) => de(items.take())?,
214 None => Vec::new(),
215 };
216 on_hits(&code_matches(&items));
217 Ok(())
218 };
219 let reply: CodeReply = de(self.exchange_with_partials(
220 "search/code",
221 json!({ "q": q, "partial": true, "limit": rootle_provider::RENDER_BUDGET }),
222 &mut sink,
223 )?)?;
224 Ok(SearchCodeResult {
225 hits: Vec::new(),
226 truncated: reply.truncated,
227 index_as_of: reply.index.as_of,
228 })
229 }
230
231 fn refs(&self, repo: &RepoId) -> ProviderResult<RepoRefs> {
233 #[derive(serde::Deserialize)]
234 struct RepositoryRefsReply {
235 #[serde(default)]
236 branches: Vec<RefEntry>,
237 #[serde(default)]
238 tags: Vec<RefEntry>,
239 }
240 #[derive(serde::Deserialize)]
241 struct RefEntry {
242 name: String,
243 sha: String,
244 #[serde(default)]
245 default: bool,
246 }
247 let reply: RepositoryRefsReply = de(self.request("repo/refs", json!({ "repo": repo }))?)?;
248 let map = |e: RefEntry| RefInfo {
249 name: e.name,
250 sha: e.sha,
251 is_default: e.default,
252 };
253 Ok(RepoRefs {
254 branches: reply.branches.into_iter().map(map).collect(),
255 tags: reply.tags.into_iter().map(map).collect(),
256 })
257 }
258
259 fn log(
261 &self,
262 repo: &RepoId,
263 path: Option<&str>,
264 ref_: Option<&GitRef>,
265 limit: Option<usize>,
266 on_entries: &mut dyn FnMut(Vec<LogEntry>),
267 ) -> ProviderResult<bool> {
268 #[derive(serde::Deserialize)]
269 struct CommitLogReply {
270 #[serde(default)]
271 items: Vec<LogEntry>,
272 #[serde(default)]
273 truncated: bool,
274 }
275 let limit = limit.unwrap_or(50).min(rootle_provider::RENDER_BUDGET);
276 let mut remaining = limit;
277 let mut clipped = false;
278 let mut stream_received = false;
279 let mut revisions = std::collections::HashSet::new();
280 let mut deliver = |items: Vec<LogEntry>| {
281 let mut items = items;
282 clipped |= items.len() > remaining;
283 items.truncate(remaining);
284 remaining -= items.len();
285 for entry in &items {
286 if !revisions.insert(entry.sha.clone()) {
287 return Err(ProviderError::new(
288 ErrorKind::Provider,
289 "history stream repeated a commit",
290 ));
291 }
292 }
293 if !items.is_empty() {
294 on_entries(items);
295 }
296 Ok(())
297 };
298 let mut sink = |params: serde_json::Value| {
299 let batch: CommitLogReply = de(params)?;
300 stream_received = true;
301 deliver(batch.items)
302 };
303 let reply: CommitLogReply = de(self.exchange_with_partials(
304 "repo/log",
305 json!({ "repo": repo, "path": path, "ref": ref_, "limit": limit, "partial": true }),
306 &mut sink,
307 )?)?;
308 if stream_received && !reply.items.is_empty() {
309 return Err(ProviderError::new(
310 ErrorKind::Provider,
311 "streamed history final must contain metadata only",
312 ));
313 }
314 deliver(reply.items)?;
315 Ok(reply.truncated || clipped)
316 }
317
318 fn blob_at(
320 &self,
321 repo: &RepoId,
322 path: &str,
323 ref_: Option<&GitRef>,
324 ) -> ProviderResult<(Vec<u8>, Sha)> {
325 #[derive(serde::Deserialize)]
326 struct BlobAtRevisionReply {
327 bytes_b64: String,
328 sha: String,
329 }
330 let reply: BlobAtRevisionReply = de(self.request(
331 "repo/blob_at",
332 json!({ "repo": repo, "path": path, "ref": ref_ }),
333 )?)?;
334 use base64::Engine;
335 let bytes = base64::engine::general_purpose::STANDARD
336 .decode(reply.bytes_b64)
337 .map_err(|e| ProviderError::new(ErrorKind::Provider, format!("bad base64: {e}")))?;
338 Ok((bytes, Sha::from(reply.sha)))
339 }
340
341 fn blame(
343 &self,
344 repo: &RepoId,
345 path: &str,
346 ref_: Option<&GitRef>,
347 ) -> ProviderResult<Vec<BlameRange>> {
348 #[derive(serde::Deserialize)]
349 struct BlameReply {
350 #[serde(default)]
351 ranges: Vec<BlameRange>,
352 }
353 let reply: BlameReply = de(self.request(
354 "repo/blame",
355 json!({ "repo": repo, "path": path, "ref": ref_ }),
356 )?)?;
357 Ok(reply.ranges)
358 }
359
360 fn commit(&self, repo: &RepoId, sha: &Sha) -> ProviderResult<CommitDetail> {
364 de(self.request("repo/commit", json!({ "repo": repo, "sha": sha }))?)
365 }
366
367 fn advise_cancel(&self) {
371 let id = self.current_id.load(Ordering::Acquire);
372 if id == 0 {
373 return;
374 }
375 let line = cancel_notification(id);
376 let session = self.shared.routing.lock().session;
377 let mut process = self.process.lock();
378 let written = writeln!(process.stdin, "{line}")
379 .and_then(|()| process.stdin.flush())
380 .is_ok();
381 crate::trace::tx_cancel(session, id, line.len() as u64 + 1, written);
382 }
383
384 fn take_notice(&self) -> Option<String> {
386 self.notice.lock().take()
387 }
388
389 fn cache_usage(&self) -> Option<u64> {
392 *self.cache_used.lock()
393 }
394}
395
396#[derive(serde::Deserialize)]
399struct CodeReply {
400 #[serde(default)]
401 items: Vec<WireItem>,
402 #[serde(default)]
404 truncated: bool,
405 #[serde(default)]
407 index: IndexInfo,
408}
409
410#[derive(serde::Deserialize, Default)]
411struct IndexInfo {
412 #[serde(default)]
413 as_of: Option<String>,
414}
415
416#[derive(serde::Deserialize)]
417struct WireItem {
418 repo: String,
419 path: String,
420 #[serde(default)]
421 sha: String,
422 #[serde(default = "main_branch")]
423 branch: String,
424 #[serde(default)]
425 matches: Vec<String>,
426 #[serde(default = "located")]
428 located: bool,
429 #[serde(default)]
431 line: Option<u32>,
432}
433
434fn main_branch() -> String {
435 "main".into()
436}
437
438fn located() -> bool {
439 true
440}
441
442fn code_matches(items: &[WireItem]) -> Vec<CodeMatch> {
443 items
444 .iter()
445 .map(|i| CodeMatch {
446 repo: i.repo.clone(),
447 path: i.path.clone(),
448 sha: i.sha.clone(),
449 branch: i.branch.clone(),
450 matches: i.matches.clone(),
451 located: i.located,
452 line: i.line,
453 })
454 .collect()
455}
456#[cfg(test)]
457mod tests {
458 use rootle_provider::CommitDetail;
459
460 #[test]
463 fn located_defaults_true_and_parses_false() {
464 #[derive(serde::Deserialize)]
465 struct Item {
466 #[serde(default = "located")]
467 located: bool,
468 }
469 fn located() -> bool {
470 true
471 }
472 let absent: Item = serde_json::from_str("{}").unwrap();
473 assert!(absent.located);
474 let stale: Item = serde_json::from_str(r#"{"located":false}"#).unwrap();
475 assert!(!stale.located);
476 }
477
478 #[test]
479 fn commit_unknown_status_and_missing_counts_remain_honest() {
480 let detail: CommitDetail = super::de(serde_json::json!({
481 "sha": "id", "author": "author", "date": "2026-09-06", "message": "message",
482 "files": [
483 {"path": "one", "status": "future-status", "additions": 4294967295u64, "deletions": 0},
484 {"path": "two", "status": "added", "additions": 4294967295u64}
485 ]
486 })).unwrap();
487 assert_eq!(
488 detail.files[0].status,
489 rootle_provider::FileStatus::Modified
490 );
491 assert_eq!(detail.line_stats().additions, Some(8589934590));
492 assert_eq!(detail.line_stats().deletions, None);
493 }
494}