rootle_provider/lib.rs
1//! Provider seam (plans/0005): the TUI talks to source-control backends
2//! through this trait — never to a concrete API. `github` is the
3//! in-tree reference implementation; external providers are child
4//! processes speaking NDJSON-RPC over stdio (`stdio.rs`), so any
5//! internal system can be wrapped with a small script.
6//!
7//! Contract rules that matter:
8//! - Repos are opaque "group/project" strings; the UI never parses them.
9//! - `sha` is an opaque *content id*: it MUST change when content
10//! changes (the cache design is content-keyed and immutable).
11//! - URL building (yank) and cloning use provider-supplied fields —
12//! no GitHub URL grammar outside the GitHub impl.
13
14/// The client's search render budget (protocol v1.4 advisory,
15/// doc/provider-protocol.md): sent as `limit` on every `search/code`
16/// so the provider stops scanning at ~N and sets `truncated: true`
17/// instead of computing hits the view would clip. The view's render
18/// cap is this same number.
19pub const RENDER_BUDGET: usize = 500;
20
21pub mod id;
22
23pub use id::{GitRef, OwnerId, RepoId, RepoPath, Sha};
24
25mod commit;
26pub use commit::{CommitDetail, CommitFile, CommitStatistics, FileStatus};
27
28/// One repo in an org listing (protocol v1.4): the name plus whatever
29/// metadata the backend reports. Everything past `name` is optional —
30/// a provider with only names sends the string form on the wire and
31/// every field here stays default.
32#[derive(Debug, Clone, Default, PartialEq, Eq)]
33pub struct RepoInfo {
34 pub name: String,
35 pub description: Option<String>,
36 pub private: bool,
37 pub archived: bool,
38 /// ISO-8601 last-push timestamp when the backend knows it.
39 pub pushed_at: Option<String>,
40}
41
42impl RepoInfo {
43 /// A bare name — selection-driven flows where no listing metadata
44 /// exists.
45 pub fn bare(name: impl Into<String>) -> Self {
46 RepoInfo {
47 name: name.into(),
48 ..Default::default()
49 }
50 }
51}
52
53impl From<String> for RepoInfo {
54 fn from(name: String) -> Self {
55 RepoInfo::bare(name)
56 }
57}
58
59impl From<&str> for RepoInfo {
60 fn from(name: &str) -> Self {
61 RepoInfo::bare(name)
62 }
63}
64
65/// Structured provider error (plans/0008 §2): the protocol v1.1
66/// `data.kind` taxonomy carried from the wire to the UI instead of a
67/// bare string. Unknown or absent kinds degrade to `Other`, which
68/// renders exactly like the old unstructured toast.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ProviderError {
71 pub kind: ErrorKind,
72 pub message: String,
73 /// `rate_limited`: the provider's advertised backoff, if any.
74 pub retry_after: Option<std::time::Duration>,
75}
76
77/// The v1.1 `data.kind` open enum. Wire-unknown kinds map to `Other`.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
79#[serde(rename_all = "snake_case")]
80pub enum ErrorKind {
81 Auth,
82 RateLimited,
83 NotFound,
84 Network,
85 Timeout,
86 Provider,
87 Other,
88}
89
90impl ErrorKind {
91 pub fn as_str(self) -> &'static str {
92 match self {
93 Self::Auth => "auth",
94 Self::RateLimited => "rate_limited",
95 Self::NotFound => "not_found",
96 Self::Network => "network",
97 Self::Timeout => "timeout",
98 Self::Provider => "provider",
99 Self::Other => "other",
100 }
101 }
102}
103
104impl ProviderError {
105 pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
106 ProviderError {
107 kind,
108 message: message.into(),
109 retry_after: None,
110 }
111 }
112
113 pub fn other(message: impl Into<String>) -> Self {
114 Self::new(ErrorKind::Other, message)
115 }
116
117 pub fn with_retry_after(mut self, retry: std::time::Duration) -> Self {
118 self.retry_after = Some(retry);
119 self
120 }
121}
122
123impl std::fmt::Display for ProviderError {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 f.write_str(&self.message)
126 }
127}
128
129impl std::error::Error for ProviderError {}
130
131impl From<String> for ProviderError {
132 fn from(message: String) -> Self {
133 Self::other(message)
134 }
135}
136
137impl From<&str> for ProviderError {
138 fn from(message: &str) -> Self {
139 Self::other(message)
140 }
141}
142
143pub type ProviderResult<T> = std::result::Result<T, ProviderError>;
144
145/// What a provider supports; the UI degrades on `false`. `file_search`
146/// (v1.3) covers path-only search (the `path:` grammar); absent on the
147/// wire it inherits `code_search` — a forge with filename search but
148/// no global content index (Bitbucket Cloud, GitLab without Advanced
149/// Search) says `code_search: false, file_search: true`.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub struct Capabilities {
152 pub orgs: bool,
153 pub code_search: bool,
154 pub file_search: bool,
155 /// v1.5 (plans/0016 M1): revision awareness — all default false;
156 /// absent means default-branch-only, the honest answer for
157 /// backends that can't answer (Bitbucket has no blame API).
158 pub refs: bool,
159 pub log: bool,
160 pub blame: bool,
161 /// v1.6 (plans/0028): commit detail — full message, changed files,
162 /// per-file unified hunks. Default false, same family as the v1.5
163 /// trio.
164 pub commit: bool,
165}
166
167/// One ref (branch or tag) — `repo/refs` item.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct RefInfo {
170 pub name: String,
171 pub sha: String,
172 pub is_default: bool,
173}
174
175/// `repo/refs` reply.
176#[derive(Debug, Clone, Default, PartialEq, Eq)]
177pub struct RepoRefs {
178 pub branches: Vec<RefInfo>,
179 pub tags: Vec<RefInfo>,
180}
181
182/// `repo/log` item — newest first on the wire.
183#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
184pub struct LogEntry {
185 pub sha: String,
186 pub subject: String,
187 pub author: String,
188 /// ISO-8601.
189 pub date: String,
190}
191
192/// `repo/blame` range — 1-based inclusive lines, coalesced by sha.
193#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
194pub struct BlameRange {
195 pub start_line: u32,
196 pub end_line: u32,
197 pub sha: String,
198 pub author: String,
199 pub date: String,
200}
201
202/// Repo/org search result for the launch popup.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub enum SearchItem {
205 /// "group/project"
206 Repo(String),
207 /// org/group name
208 Org(String),
209}
210
211/// UI-facing tree node (path relative to repo root).
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct TreeNode {
214 pub path: String,
215 pub is_dir: bool,
216 pub sha: String,
217 pub size: Option<u64>,
218}
219
220/// A repo's recursive tree plus routing metadata.
221#[derive(Debug, Clone)]
222pub struct TreeResult {
223 pub entries: Vec<TreeNode>,
224 pub truncated: bool,
225 pub branch: String,
226}
227
228/// One code-search hit. `matches` are the matched substrings — an
229/// empty vec is a legal **path-only hit** ("this file matched"). When
230/// `matches` is non-empty the UI locates them in the blob for real
231/// line numbers; `line`, when the provider knows it, is the anchor
232/// used as-is (the first occurrence of a substring is often not the
233/// occurrence that matched).
234#[derive(Debug, Clone)]
235pub struct CodeMatch {
236 pub repo: String,
237 pub path: String,
238 pub sha: String,
239 pub branch: String,
240 pub matches: Vec<String>,
241 /// v1.1: provider knows its index is stale for this hit (the UI
242 /// shows a stale chip until client-side locating self-heals).
243 pub located: bool,
244 /// v1.3: provider-known line number (1-based); `None` = unknown,
245 /// the UI locates or anchors at 1.
246 pub line: Option<u32>,
247}
248
249/// Code-search outcome metadata: the provider's own truncation signal
250/// (plans/0008 §4) and, for indexed backends, when the index was
251/// built (`located: false` covers the per-hit case; this is the
252/// index-wide one — a lagging index is worth a badge next to the
253/// results).
254#[derive(Debug, Clone)]
255pub struct SearchCodeResult {
256 pub hits: Vec<CodeMatch>,
257 pub truncated: bool,
258 /// v1.3: e.g. "2026-08-20T14:00:00Z"; `None` = live or unknown.
259 pub index_as_of: Option<String>,
260}
261
262/// The backend contract. Blocking; calls run on worker threads.
263pub trait Provider: Send + Sync {
264 fn name(&self) -> &str;
265 fn capabilities(&self) -> Capabilities;
266 /// Repo + org search for the launch popup (orgs first).
267 fn search(&self, query: &str) -> ProviderResult<Vec<SearchItem>>;
268 /// Repo names of an org/group, with listing metadata when the
269 /// backend reports it (v1.4).
270 fn org_repos(&self, org: &str) -> ProviderResult<Vec<RepoInfo>>;
271 /// Full recursive tree of a repo — at `ref` (branch/tag/sha) when
272 /// given (v1.5), else the default branch.
273 fn fetch_tree(&self, repo: &RepoId, ref_: Option<&GitRef>) -> ProviderResult<TreeResult>;
274 /// Blob bytes by content id.
275 fn fetch_blob(&self, repo: &RepoId, sha: &Sha) -> ProviderResult<Vec<u8>>;
276
277 /// The repo's default-branch source as a gzip tarball — fuel for
278 /// the local-grep fallback when a repo-scoped code search returns
279 /// nothing (GitHub's index does not cover young/low-activity
280 /// repos; the tree can't lie, the index can). Optional: the
281 /// default refuses and the fallback is simply unavailable —
282 /// external providers grow it when the wire protocol does.
283 fn source_tarball(&self, repo: &RepoId) -> ProviderResult<Vec<u8>> {
284 let _ = repo;
285 Err(ProviderError::other(
286 "source tarball not supported by this provider",
287 ))
288 }
289 fn refs(&self, repo: &RepoId) -> ProviderResult<RepoRefs> {
290 let _ = repo;
291 Err(ProviderError::new(
292 ErrorKind::Provider,
293 "provider has no revision listing",
294 ))
295 }
296 /// Commit history, newest first, delivered as owned append-only batches.
297 /// The result is the authoritative truncation flag; an error leaves already
298 /// delivered entries with the caller. Providers honor the bounded `limit`.
299 fn log(
300 &self,
301 repo: &RepoId,
302 path: Option<&str>,
303 ref_: Option<&GitRef>,
304 limit: Option<usize>,
305 on_entries: &mut dyn FnMut(Vec<LogEntry>),
306 ) -> ProviderResult<bool> {
307 let _ = (repo, path, ref_, limit, on_entries);
308 Err(ProviderError::new(
309 ErrorKind::Provider,
310 "provider has no commit log",
311 ))
312 }
313 /// v1.5: file bytes + content id at a path and ref — the
314 /// open-at-commit call (capability `log`'s companion).
315 fn blob_at(
316 &self,
317 repo: &RepoId,
318 path: &str,
319 ref_: Option<&GitRef>,
320 ) -> ProviderResult<(Vec<u8>, Sha)> {
321 let _ = (repo, path, ref_);
322 Err(ProviderError::new(
323 ErrorKind::Provider,
324 "provider cannot serve blobs at a ref",
325 ))
326 }
327 /// v1.5: blame ranges, 1-based inclusive, coalesced (capability
328 /// `blame`).
329 fn blame(
330 &self,
331 repo: &RepoId,
332 path: &str,
333 ref_: Option<&GitRef>,
334 ) -> ProviderResult<Vec<BlameRange>> {
335 let _ = (repo, path, ref_);
336 Err(ProviderError::new(
337 ErrorKind::Provider,
338 "provider has no blame",
339 ))
340 }
341 /// v1.6 (plans/0028): one commit's detail — message, changed
342 /// files, unified hunks (capability `commit`).
343 fn commit(&self, repo: &RepoId, sha: &Sha) -> ProviderResult<CommitDetail> {
344 let _ = (repo, sha);
345 Err(ProviderError::new(
346 ErrorKind::Provider,
347 "provider has no commit detail",
348 ))
349 }
350
351 /// Code search; `q` is the full query string with qualifiers.
352 fn search_code(&self, q: &str) -> ProviderResult<SearchCodeResult>;
353
354 /// Modeline icon: a builtin name ("github", "gitlab", "bitbucket",
355 /// "folder" — rendered as its Nerd Font glyph when nerd_font is
356 /// on) or a single literal glyph the terminal can render. The
357 /// provider declares its own (handshake `icon`, protocol v1.3);
358 /// the in-tree github provider owns the one rootle hardcodes.
359 fn icon(&self) -> Option<String> {
360 None
361 }
362
363 /// Progressive code search (protocol v1.3, plans/0011): `on_hits`
364 /// may fire from any thread, any number of times, strictly before
365 /// this call returns. When the provider streamed, the result is
366 /// metadata-only — `hits` empty, `truncated` authoritative.
367 /// Default: one `search_code` call, one `on_hits` batch — every
368 /// provider streams; page-shaped backends stream page-by-page.
369 fn search_code_progressive(
370 &self,
371 q: &str,
372 on_hits: &(dyn Fn(&[CodeMatch]) + Send + Sync),
373 ) -> ProviderResult<SearchCodeResult> {
374 let result = self.search_code(q)?;
375 on_hits(&result.hits);
376 Ok(SearchCodeResult {
377 hits: Vec::new(),
378 truncated: result.truncated,
379 index_as_of: result.index_as_of,
380 })
381 }
382
383 /// Advisory cancellation (protocol v1.1): tells the backend the
384 /// caller no longer needs the in-flight request. Best-effort —
385 /// replies may still arrive and are always handled. Default: nothing
386 /// to cancel (in-process providers drop work via generations).
387 fn advise_cancel(&self) {}
388
389 /// One-shot UI notice for the status line (plans/0008 §5) — e.g.
390 /// a stdio child's successful restart. Drained once per route.
391 /// Default: nothing to say.
392 fn take_notice(&self) -> Option<String> {
393 None
394 }
395
396 /// Cache usage the provider reported at initialize (bytes), when
397 /// it participates in the advisory cache budget (protocol v1.2) —
398 /// surfaced in :settings next to the provider row.
399 fn cache_usage(&self) -> Option<u64> {
400 None
401 }
402
403 /// URL `git clone` accepts for a repo (clone wizard, plans/0004).
404 fn clone_url(&self, repo: &RepoId) -> ProviderResult<String>;
405 /// Browser URL for yank (␣ y): repo root, or a path inside it.
406 /// `is_file` picks the grammar (GitHub: blob vs tree); `line`
407 /// adds a fragment when Some. `branch` is `None` for the default
408 /// branch (the provider resolves it).
409 fn web_url(
410 &self,
411 repo: &RepoId,
412 path: &str,
413 branch: Option<&GitRef>,
414 line: Option<u32>,
415 end: Option<u32>,
416 is_file: bool,
417 ) -> ProviderResult<String>;
418
419 /// Browser URL for an org/group page.
420 fn org_url(&self, org: &str) -> ProviderResult<String>;
421}
422pub mod paths;