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 sink = |params: &serde_json::Value| {
212            let items: Vec<WireItem> = params
213                .get("items")
214                .cloned()
215                .and_then(|v| serde_json::from_value(v).ok())
216                .unwrap_or_default();
217            on_hits(&code_matches(&items));
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            &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    ) -> ProviderResult<(Vec<LogEntry>, bool)> {
267        #[derive(serde::Deserialize)]
268        struct CommitLogReply {
269            #[serde(default)]
270            items: Vec<LogEntry>,
271            #[serde(default)]
272            truncated: bool,
273        }
274        let reply: CommitLogReply = de(self.request(
275            "repo/log",
276            json!({ "repo": repo, "path": path, "ref": ref_, "limit": limit }),
277        )?)?;
278        Ok((reply.items, reply.truncated))
279    }
280
281    /// v1.5: file bytes + content id at path@ref (open-at-commit).
282    fn blob_at(
283        &self,
284        repo: &RepoId,
285        path: &str,
286        ref_: Option<&GitRef>,
287    ) -> ProviderResult<(Vec<u8>, Sha)> {
288        #[derive(serde::Deserialize)]
289        struct BlobAtRevisionReply {
290            bytes_b64: String,
291            sha: String,
292        }
293        let reply: BlobAtRevisionReply = de(self.request(
294            "repo/blob_at",
295            json!({ "repo": repo, "path": path, "ref": ref_ }),
296        )?)?;
297        use base64::Engine;
298        let bytes = base64::engine::general_purpose::STANDARD
299            .decode(reply.bytes_b64)
300            .map_err(|e| ProviderError::new(ErrorKind::Provider, format!("bad base64: {e}")))?;
301        Ok((bytes, Sha::from(reply.sha)))
302    }
303
304    /// v1.5: blame ranges.
305    fn blame(
306        &self,
307        repo: &RepoId,
308        path: &str,
309        ref_: Option<&GitRef>,
310    ) -> ProviderResult<Vec<BlameRange>> {
311        #[derive(serde::Deserialize)]
312        struct BlameReply {
313            #[serde(default)]
314            ranges: Vec<BlameRange>,
315        }
316        let reply: BlameReply = de(self.request(
317            "repo/blame",
318            json!({ "repo": repo, "path": path, "ref": ref_ }),
319        )?)?;
320        Ok(reply.ranges)
321    }
322
323    /// v1.6 (plans/0028): one commit's detail — the reply
324    /// deserializes straight into the seam type (it already carries
325    /// the wire defaults).
326    fn commit(&self, repo: &RepoId, sha: &Sha) -> ProviderResult<CommitDetail> {
327        de(self.request("repo/commit", json!({ "repo": repo, "sha": sha }))?)
328    }
329
330    /// v1.1 advisory cancel: name a request currently in flight, if
331    /// any. Best-effort — a racing cancel for an id that just
332    /// completed is ignored by the provider by contract.
333    fn advise_cancel(&self) {
334        let id = self.current_id.load(Ordering::Acquire);
335        if id == 0 {
336            return;
337        }
338        let line = cancel_notification(id);
339        let session = self.shared.routing.lock().session;
340        let mut process = self.process.lock();
341        let written = writeln!(process.stdin, "{line}")
342            .and_then(|()| process.stdin.flush())
343            .is_ok();
344        crate::trace::tx_cancel(session, id, line.len() as u64 + 1, written);
345    }
346
347    /// One-shot restart notice for the status line (plans/0008 §5).
348    fn take_notice(&self) -> Option<String> {
349        self.notice.lock().take()
350    }
351
352    /// Cache usage reported at the initialize handshake, when the
353    /// provider participates in the advisory budget (v1.2).
354    fn cache_usage(&self) -> Option<u64> {
355        *self.cache_used.lock()
356    }
357}
358
359/// `search/code` reply (items present only when the provider did not
360/// stream) — the protocol v1.3 contract.
361#[derive(serde::Deserialize)]
362struct CodeReply {
363    #[serde(default)]
364    items: Vec<WireItem>,
365    /// v1.2 (plans/0008 §4): provider capped its result set.
366    #[serde(default)]
367    truncated: bool,
368    /// v1.3: index-wide freshness for indexed backends.
369    #[serde(default)]
370    index: IndexInfo,
371}
372
373#[derive(serde::Deserialize, Default)]
374struct IndexInfo {
375    #[serde(default)]
376    as_of: Option<String>,
377}
378
379#[derive(serde::Deserialize)]
380struct WireItem {
381    repo: String,
382    path: String,
383    #[serde(default)]
384    sha: String,
385    #[serde(default = "main_branch")]
386    branch: String,
387    #[serde(default)]
388    matches: Vec<String>,
389    /// v1.1: absent = located (verified placement).
390    #[serde(default = "located")]
391    located: bool,
392    /// v1.3: provider-known line number; absent = unknown.
393    #[serde(default)]
394    line: Option<u32>,
395}
396
397fn main_branch() -> String {
398    "main".into()
399}
400
401fn located() -> bool {
402    true
403}
404
405fn code_matches(items: &[WireItem]) -> Vec<CodeMatch> {
406    items
407        .iter()
408        .map(|i| CodeMatch {
409            repo: i.repo.clone(),
410            path: i.path.clone(),
411            sha: i.sha.clone(),
412            branch: i.branch.clone(),
413            matches: i.matches.clone(),
414            located: i.located,
415            line: i.line,
416        })
417        .collect()
418}
419#[cfg(test)]
420mod tests {
421    use rootle_provider::CommitDetail;
422
423    /// The v1.1 `located` default: absent means located (verified
424    /// placement); only an explicit false flags a stale hit.
425    #[test]
426    fn located_defaults_true_and_parses_false() {
427        #[derive(serde::Deserialize)]
428        struct Item {
429            #[serde(default = "located")]
430            located: bool,
431        }
432        fn located() -> bool {
433            true
434        }
435        let absent: Item = serde_json::from_str("{}").unwrap();
436        assert!(absent.located);
437        let stale: Item = serde_json::from_str(r#"{"located":false}"#).unwrap();
438        assert!(!stale.located);
439    }
440
441    #[test]
442    fn commit_unknown_status_and_missing_counts_remain_honest() {
443        let detail: CommitDetail = super::de(serde_json::json!({
444            "sha": "id", "author": "author", "date": "2026-09-06", "message": "message",
445            "files": [
446                {"path": "one", "status": "future-status", "additions": 4294967295u64, "deletions": 0},
447                {"path": "two", "status": "added", "additions": 4294967295u64}
448            ]
449        })).unwrap();
450        assert_eq!(
451            detail.files[0].status,
452            rootle_provider::FileStatus::Modified
453        );
454        assert_eq!(detail.line_stats().additions, Some(8589934590));
455        assert_eq!(detail.line_stats().deletions, None);
456    }
457}