use super::StdioProvider;
use super::transport::{cancel_notification, de};
use rootle_provider::{
BlameRange, Capabilities, CodeMatch, CommitDetail, ErrorKind, GitRef, LogEntry, Provider,
ProviderError, ProviderResult, RefInfo, RepoId, RepoInfo, RepoRefs, SearchCodeResult,
SearchItem, Sha, TreeNode, TreeResult,
};
use serde_json::json;
use std::io::Write;
use std::sync::atomic::Ordering;
impl Provider for StdioProvider {
fn name(&self) -> &str {
&self.name
}
fn icon(&self) -> Option<String> {
self.icon.clone()
}
fn capabilities(&self) -> Capabilities {
self.capabilities
}
fn search(&self, query: &str) -> ProviderResult<Vec<SearchItem>> {
#[derive(serde::Deserialize)]
struct RepositorySearchReply {
#[serde(default)]
items: Vec<Item>,
}
#[derive(serde::Deserialize)]
struct Item {
full_name: Option<String>,
org: Option<String>,
}
let reply: RepositorySearchReply =
de(self.request("search/repos", json!({ "query": query }))?)?;
Ok(reply
.items
.into_iter()
.filter_map(|i| match (i.full_name, i.org) {
(Some(r), _) => Some(SearchItem::Repo(r)),
(None, Some(o)) => Some(SearchItem::Org(o)),
_ => None,
})
.collect())
}
fn org_repos(&self, org: &str) -> ProviderResult<Vec<RepoInfo>> {
#[derive(serde::Deserialize)]
struct OrganizationRepositoriesReply {
#[serde(default)]
repos: Vec<RepoEntry>,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum RepoEntry {
Name(String),
Meta {
name: String,
description: Option<String>,
#[serde(default)]
private: bool,
#[serde(default)]
archived: bool,
pushed_at: Option<String>,
},
}
let reply: OrganizationRepositoriesReply =
de(self.request("org/repos", json!({ "org": org }))?)?;
Ok(reply
.repos
.into_iter()
.map(|e| match e {
RepoEntry::Name(name) => RepoInfo::bare(name),
RepoEntry::Meta {
name,
description,
private,
archived,
pushed_at,
} => RepoInfo {
name,
description,
private,
archived,
pushed_at,
},
})
.collect())
}
fn fetch_tree(&self, repo: &RepoId, ref_: Option<&GitRef>) -> ProviderResult<TreeResult> {
#[derive(serde::Deserialize)]
struct RepositoryTreeReply {
#[serde(default)]
entries: Vec<Entry>,
#[serde(default)]
truncated: bool,
#[serde(default = "main")]
branch: String,
}
#[derive(serde::Deserialize)]
struct Entry {
path: String,
#[serde(rename = "type")]
kind: String, sha: String,
size: Option<u64>,
}
fn main() -> String {
"main".into()
}
let reply: RepositoryTreeReply =
de(self.request("repo/tree", json!({ "repo": repo, "ref": ref_ }))?)?;
Ok(TreeResult {
entries: reply
.entries
.into_iter()
.map(|e| TreeNode {
path: e.path,
is_dir: e.kind == "tree",
sha: e.sha,
size: e.size,
})
.collect(),
truncated: reply.truncated,
branch: reply.branch,
})
}
fn fetch_blob(&self, repo: &RepoId, sha: &Sha) -> ProviderResult<Vec<u8>> {
#[derive(serde::Deserialize)]
struct BlobReply {
bytes_b64: String,
}
let reply: BlobReply = de(self.request("repo/blob", json!({ "repo": repo, "sha": sha }))?)?;
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(reply.bytes_b64)
.map_err(|e| ProviderError::other(format!("provider blob base64: {e}")))
}
fn web_url(
&self,
repo: &RepoId,
path: &str,
branch: Option<&GitRef>,
line: Option<u32>,
end: Option<u32>,
is_file: bool,
) -> ProviderResult<String> {
#[derive(serde::Deserialize)]
struct RepositoryUrlReply {
url: String,
}
let reply: RepositoryUrlReply = de(self.request(
"repo/web_url",
json!({ "repo": repo, "path": path, "branch": branch.map(GitRef::as_str).unwrap_or(""), "line": line, "end_line": end, "is_file": is_file }),
)?)?;
Ok(reply.url)
}
fn org_url(&self, org: &str) -> ProviderResult<String> {
#[derive(serde::Deserialize)]
struct OrganizationUrlReply {
url: String,
}
let reply: OrganizationUrlReply = de(self.request("org/url", json!({ "org": org }))?)?;
Ok(reply.url)
}
fn clone_url(&self, repo: &RepoId) -> ProviderResult<String> {
#[derive(serde::Deserialize)]
struct CloneUrlReply {
clone_url: String,
}
let reply: CloneUrlReply = de(self.request("repo/clone_url", json!({ "repo": repo }))?)?;
Ok(reply.clone_url)
}
fn search_code(&self, q: &str) -> ProviderResult<SearchCodeResult> {
let r: CodeReply = de(self.request(
"search/code",
json!({ "q": q, "limit": rootle_provider::RENDER_BUDGET }),
)?)?;
Ok(SearchCodeResult {
hits: code_matches(&r.items),
truncated: r.truncated,
index_as_of: r.index.as_of,
})
}
fn search_code_progressive(
&self,
q: &str,
on_hits: &(dyn Fn(&[CodeMatch]) + Send + Sync),
) -> ProviderResult<SearchCodeResult> {
let sink = |params: &serde_json::Value| {
let items: Vec<WireItem> = params
.get("items")
.cloned()
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_default();
on_hits(&code_matches(&items));
};
let reply: CodeReply = de(self.exchange_with_partials(
"search/code",
json!({ "q": q, "partial": true, "limit": rootle_provider::RENDER_BUDGET }),
&sink,
)?)?;
Ok(SearchCodeResult {
hits: Vec::new(),
truncated: reply.truncated,
index_as_of: reply.index.as_of,
})
}
fn refs(&self, repo: &RepoId) -> ProviderResult<RepoRefs> {
#[derive(serde::Deserialize)]
struct RepositoryRefsReply {
#[serde(default)]
branches: Vec<RefEntry>,
#[serde(default)]
tags: Vec<RefEntry>,
}
#[derive(serde::Deserialize)]
struct RefEntry {
name: String,
sha: String,
#[serde(default)]
default: bool,
}
let reply: RepositoryRefsReply = de(self.request("repo/refs", json!({ "repo": repo }))?)?;
let map = |e: RefEntry| RefInfo {
name: e.name,
sha: e.sha,
is_default: e.default,
};
Ok(RepoRefs {
branches: reply.branches.into_iter().map(map).collect(),
tags: reply.tags.into_iter().map(map).collect(),
})
}
fn log(
&self,
repo: &RepoId,
path: Option<&str>,
ref_: Option<&GitRef>,
limit: Option<usize>,
) -> ProviderResult<(Vec<LogEntry>, bool)> {
#[derive(serde::Deserialize)]
struct CommitLogReply {
#[serde(default)]
items: Vec<LogEntry>,
#[serde(default)]
truncated: bool,
}
let reply: CommitLogReply = de(self.request(
"repo/log",
json!({ "repo": repo, "path": path, "ref": ref_, "limit": limit }),
)?)?;
Ok((reply.items, reply.truncated))
}
fn blob_at(
&self,
repo: &RepoId,
path: &str,
ref_: Option<&GitRef>,
) -> ProviderResult<(Vec<u8>, Sha)> {
#[derive(serde::Deserialize)]
struct BlobAtRevisionReply {
bytes_b64: String,
sha: String,
}
let reply: BlobAtRevisionReply = de(self.request(
"repo/blob_at",
json!({ "repo": repo, "path": path, "ref": ref_ }),
)?)?;
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(reply.bytes_b64)
.map_err(|e| ProviderError::new(ErrorKind::Provider, format!("bad base64: {e}")))?;
Ok((bytes, Sha::from(reply.sha)))
}
fn blame(
&self,
repo: &RepoId,
path: &str,
ref_: Option<&GitRef>,
) -> ProviderResult<Vec<BlameRange>> {
#[derive(serde::Deserialize)]
struct BlameReply {
#[serde(default)]
ranges: Vec<BlameRange>,
}
let reply: BlameReply = de(self.request(
"repo/blame",
json!({ "repo": repo, "path": path, "ref": ref_ }),
)?)?;
Ok(reply.ranges)
}
fn commit(&self, repo: &RepoId, sha: &Sha) -> ProviderResult<CommitDetail> {
de(self.request("repo/commit", json!({ "repo": repo, "sha": sha }))?)
}
fn advise_cancel(&self) {
let id = self.current_id.load(Ordering::Acquire);
if id == 0 {
return;
}
let line = cancel_notification(id);
let mut process = self.process.lock();
let _ = writeln!(process.stdin, "{line}");
let _ = process.stdin.flush();
}
fn take_notice(&self) -> Option<String> {
self.notice.lock().take()
}
fn cache_usage(&self) -> Option<u64> {
*self.cache_used.lock()
}
}
#[derive(serde::Deserialize)]
struct CodeReply {
#[serde(default)]
items: Vec<WireItem>,
#[serde(default)]
truncated: bool,
#[serde(default)]
index: IndexInfo,
}
#[derive(serde::Deserialize, Default)]
struct IndexInfo {
#[serde(default)]
as_of: Option<String>,
}
#[derive(serde::Deserialize)]
struct WireItem {
repo: String,
path: String,
#[serde(default)]
sha: String,
#[serde(default = "main_branch")]
branch: String,
#[serde(default)]
matches: Vec<String>,
#[serde(default = "located")]
located: bool,
#[serde(default)]
line: Option<u32>,
}
fn main_branch() -> String {
"main".into()
}
fn located() -> bool {
true
}
fn code_matches(items: &[WireItem]) -> Vec<CodeMatch> {
items
.iter()
.map(|i| CodeMatch {
repo: i.repo.clone(),
path: i.path.clone(),
sha: i.sha.clone(),
branch: i.branch.clone(),
matches: i.matches.clone(),
located: i.located,
line: i.line,
})
.collect()
}
#[cfg(test)]
mod tests {
use rootle_provider::CommitDetail;
#[test]
fn located_defaults_true_and_parses_false() {
#[derive(serde::Deserialize)]
struct Item {
#[serde(default = "located")]
located: bool,
}
fn located() -> bool {
true
}
let absent: Item = serde_json::from_str("{}").unwrap();
assert!(absent.located);
let stale: Item = serde_json::from_str(r#"{"located":false}"#).unwrap();
assert!(!stale.located);
}
#[test]
fn commit_unknown_status_and_missing_counts_remain_honest() {
let detail: CommitDetail = super::de(serde_json::json!({
"sha": "id", "author": "author", "date": "2026-09-06", "message": "message",
"files": [
{"path": "one", "status": "future-status", "additions": 4294967295u64, "deletions": 0},
{"path": "two", "status": "added", "additions": 4294967295u64}
]
})).unwrap();
assert_eq!(
detail.files[0].status,
rootle_provider::FileStatus::Modified
);
assert_eq!(detail.line_stats().additions, Some(8589934590));
assert_eq!(detail.line_stats().deletions, None);
}
}