Skip to main content

rootle_stdio/
wire.rs

1//! Wire mapping: each `trait Provider` method as one NDJSON-RPC round
2//! trip, with its reply shape. Wire structs live next to the method
3//! that reads them — the shapes are the provider-protocol contract
4//! (doc/provider-protocol.md), versioned in lockstep.
5
6use 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        /// v1.4: entries are the bare name or an object riding reader
61        /// tolerance — `{name, description?, private?, archived?,
62        /// pushed_at?}` (doc/provider-protocol.md).
63        #[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, // "blob" | "tree"
116            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    /// v1.3 progressive search (plans/0011): `partial: true` opts into
203    /// `$/partial` batches for this request's id; the reply is
204    /// metadata-only when the provider streamed (items empty,
205    /// `truncated` authoritative).
206    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    /// v1.5 (plans/0016 M1): branches + tags.
232    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    /// v1.5: commit log, newest first.
260    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    /// v1.5: file bytes + content id at path@ref (open-at-commit).
319    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    /// v1.5: blame ranges.
342    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    /// v1.6 (plans/0028): one commit's detail — the reply
361    /// deserializes straight into the seam type (it already carries
362    /// the wire defaults).
363    fn commit(&self, repo: &RepoId, sha: &Sha) -> ProviderResult<CommitDetail> {
364        de(self.request("repo/commit", json!({ "repo": repo, "sha": sha }))?)
365    }
366
367    /// v1.1 advisory cancel: name a request currently in flight, if
368    /// any. Best-effort — a racing cancel for an id that just
369    /// completed is ignored by the provider by contract.
370    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    /// One-shot restart notice for the status line (plans/0008 §5).
385    fn take_notice(&self) -> Option<String> {
386        self.notice.lock().take()
387    }
388
389    /// Cache usage reported at the initialize handshake, when the
390    /// provider participates in the advisory budget (v1.2).
391    fn cache_usage(&self) -> Option<u64> {
392        *self.cache_used.lock()
393    }
394}
395
396/// `search/code` reply (items present only when the provider did not
397/// stream) — the protocol v1.3 contract.
398#[derive(serde::Deserialize)]
399struct CodeReply {
400    #[serde(default)]
401    items: Vec<WireItem>,
402    /// v1.2 (plans/0008 §4): provider capped its result set.
403    #[serde(default)]
404    truncated: bool,
405    /// v1.3: index-wide freshness for indexed backends.
406    #[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    /// v1.1: absent = located (verified placement).
427    #[serde(default = "located")]
428    located: bool,
429    /// v1.3: provider-known line number; absent = unknown.
430    #[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    /// The v1.1 `located` default: absent means located (verified
461    /// placement); only an explicit false flags a stale hit.
462    #[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}