Skip to main content

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::{Generation, GitRef, 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)]
79pub enum ErrorKind {
80    Auth,
81    RateLimited,
82    NotFound,
83    Network,
84    Timeout,
85    Provider,
86    Other,
87}
88
89impl ProviderError {
90    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
91        ProviderError {
92            kind,
93            message: message.into(),
94            retry_after: None,
95        }
96    }
97
98    pub fn other(message: impl Into<String>) -> Self {
99        Self::new(ErrorKind::Other, message)
100    }
101
102    pub fn with_retry_after(mut self, retry: std::time::Duration) -> Self {
103        self.retry_after = Some(retry);
104        self
105    }
106}
107
108impl std::fmt::Display for ProviderError {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.write_str(&self.message)
111    }
112}
113
114impl std::error::Error for ProviderError {}
115
116impl From<String> for ProviderError {
117    fn from(message: String) -> Self {
118        Self::other(message)
119    }
120}
121
122impl From<&str> for ProviderError {
123    fn from(message: &str) -> Self {
124        Self::other(message)
125    }
126}
127
128pub type ProviderResult<T> = std::result::Result<T, ProviderError>;
129
130/// What a provider supports; the UI degrades on `false`. `file_search`
131/// (v1.3) covers path-only search (the `path:` grammar); absent on the
132/// wire it inherits `code_search` — a forge with filename search but
133/// no global content index (Bitbucket Cloud, GitLab without Advanced
134/// Search) says `code_search: false, file_search: true`.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct Capabilities {
137    pub orgs: bool,
138    pub code_search: bool,
139    pub file_search: bool,
140    /// v1.5 (plans/0016 M1): revision awareness — all default false;
141    /// absent means default-branch-only, the honest answer for
142    /// backends that can't answer (Bitbucket has no blame API).
143    pub refs: bool,
144    pub log: bool,
145    pub blame: bool,
146    /// v1.6 (plans/0028): commit detail — full message, changed files,
147    /// per-file unified hunks. Default false, same family as the v1.5
148    /// trio.
149    pub commit: bool,
150}
151
152/// One ref (branch or tag) — `repo/refs` item.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct RefInfo {
155    pub name: String,
156    pub sha: String,
157    pub is_default: bool,
158}
159
160/// `repo/refs` reply.
161#[derive(Debug, Clone, Default, PartialEq, Eq)]
162pub struct RepoRefs {
163    pub branches: Vec<RefInfo>,
164    pub tags: Vec<RefInfo>,
165}
166
167/// `repo/log` item — newest first on the wire.
168#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
169pub struct LogEntry {
170    pub sha: String,
171    pub subject: String,
172    pub author: String,
173    /// ISO-8601.
174    pub date: String,
175}
176
177/// `repo/blame` range — 1-based inclusive lines, coalesced by sha.
178#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
179pub struct BlameRange {
180    pub start_line: u32,
181    pub end_line: u32,
182    pub sha: String,
183    pub author: String,
184    pub date: String,
185}
186
187/// Repo/org search result for the launch popup.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum SearchItem {
190    /// "group/project"
191    Repo(String),
192    /// org/group name
193    Org(String),
194}
195
196/// UI-facing tree node (path relative to repo root).
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct TreeNode {
199    pub path: String,
200    pub is_dir: bool,
201    pub sha: String,
202    pub size: Option<u64>,
203}
204
205/// A repo's recursive tree plus routing metadata.
206#[derive(Debug, Clone)]
207pub struct TreeResult {
208    pub entries: Vec<TreeNode>,
209    pub truncated: bool,
210    pub branch: String,
211}
212
213/// One code-search hit. `matches` are the matched substrings — an
214/// empty vec is a legal **path-only hit** ("this file matched"). When
215/// `matches` is non-empty the UI locates them in the blob for real
216/// line numbers; `line`, when the provider knows it, is the anchor
217/// used as-is (the first occurrence of a substring is often not the
218/// occurrence that matched).
219#[derive(Debug, Clone)]
220pub struct CodeMatch {
221    pub repo: String,
222    pub path: String,
223    pub sha: String,
224    pub branch: String,
225    pub matches: Vec<String>,
226    /// v1.1: provider knows its index is stale for this hit (the UI
227    /// shows a stale chip until client-side locating self-heals).
228    pub located: bool,
229    /// v1.3: provider-known line number (1-based); `None` = unknown,
230    /// the UI locates or anchors at 1.
231    pub line: Option<u32>,
232}
233
234/// Code-search outcome metadata: the provider's own truncation signal
235/// (plans/0008 §4) and, for indexed backends, when the index was
236/// built (`located: false` covers the per-hit case; this is the
237/// index-wide one — a lagging index is worth a badge next to the
238/// results).
239#[derive(Debug, Clone)]
240pub struct SearchCodeResult {
241    pub hits: Vec<CodeMatch>,
242    pub truncated: bool,
243    /// v1.3: e.g. "2026-08-20T14:00:00Z"; `None` = live or unknown.
244    pub index_as_of: Option<String>,
245}
246
247/// The backend contract. Blocking; calls run on worker threads.
248pub trait Provider: Send + Sync {
249    fn name(&self) -> &str;
250    fn capabilities(&self) -> Capabilities;
251    /// Suggested orgs for a cold start (no state); GitHub ships its
252    /// defaults, other providers return nothing.
253    fn default_orgs(&self) -> Vec<String> {
254        Vec::new()
255    }
256
257    /// Repo + org search for the launch popup (orgs first).
258    fn search(&self, query: &str) -> ProviderResult<Vec<SearchItem>>;
259    /// Repo names of an org/group, with listing metadata when the
260    /// backend reports it (v1.4).
261    fn org_repos(&self, org: &str) -> ProviderResult<Vec<RepoInfo>>;
262    /// Full recursive tree of a repo — at `ref` (branch/tag/sha) when
263    /// given (v1.5), else the default branch.
264    fn fetch_tree(&self, repo: &RepoId, ref_: Option<&GitRef>) -> ProviderResult<TreeResult>;
265    /// Blob bytes by content id.
266    fn fetch_blob(&self, repo: &RepoId, sha: &Sha) -> ProviderResult<Vec<u8>>;
267
268    /// The repo's default-branch source as a gzip tarball — fuel for
269    /// the local-grep fallback when a repo-scoped code search returns
270    /// nothing (GitHub's index does not cover young/low-activity
271    /// repos; the tree can't lie, the index can). Optional: the
272    /// default refuses and the fallback is simply unavailable —
273    /// external providers grow it when the wire protocol does.
274    fn source_tarball(&self, repo: &RepoId) -> ProviderResult<Vec<u8>> {
275        let _ = repo;
276        Err(ProviderError::other(
277            "source tarball not supported by this provider",
278        ))
279    }
280    fn refs(&self, repo: &RepoId) -> ProviderResult<RepoRefs> {
281        let _ = repo;
282        Err(ProviderError::new(
283            ErrorKind::Provider,
284            "provider has no revision listing",
285        ))
286    }
287    /// v1.5: commit log, newest first; `limit` rides the bounded-
288    /// compute contract — stop at ~N, the bool is `truncated`
289    /// (capability `log`).
290    fn log(
291        &self,
292        repo: &RepoId,
293        path: Option<&str>,
294        ref_: Option<&GitRef>,
295        limit: Option<usize>,
296    ) -> ProviderResult<(Vec<LogEntry>, bool)> {
297        let _ = (repo, path, ref_, limit);
298        Err(ProviderError::new(
299            ErrorKind::Provider,
300            "provider has no commit log",
301        ))
302    }
303    /// v1.5: file bytes + content id at a path and ref — the
304    /// open-at-commit call (capability `log`'s companion).
305    fn blob_at(
306        &self,
307        repo: &RepoId,
308        path: &str,
309        ref_: Option<&GitRef>,
310    ) -> ProviderResult<(Vec<u8>, Sha)> {
311        let _ = (repo, path, ref_);
312        Err(ProviderError::new(
313            ErrorKind::Provider,
314            "provider cannot serve blobs at a ref",
315        ))
316    }
317    /// v1.5: blame ranges, 1-based inclusive, coalesced (capability
318    /// `blame`).
319    fn blame(
320        &self,
321        repo: &RepoId,
322        path: &str,
323        ref_: Option<&GitRef>,
324    ) -> ProviderResult<Vec<BlameRange>> {
325        let _ = (repo, path, ref_);
326        Err(ProviderError::new(
327            ErrorKind::Provider,
328            "provider has no blame",
329        ))
330    }
331    /// v1.6 (plans/0028): one commit's detail — message, changed
332    /// files, unified hunks (capability `commit`).
333    fn commit(&self, repo: &RepoId, sha: &Sha) -> ProviderResult<CommitDetail> {
334        let _ = (repo, sha);
335        Err(ProviderError::new(
336            ErrorKind::Provider,
337            "provider has no commit detail",
338        ))
339    }
340
341    /// Code search; `q` is the full query string with qualifiers.
342    fn search_code(&self, q: &str) -> ProviderResult<SearchCodeResult>;
343
344    /// Modeline icon: a builtin name ("github", "gitlab", "bitbucket",
345    /// "folder" — rendered as its Nerd Font glyph when nerd_font is
346    /// on) or a single literal glyph the terminal can render. The
347    /// provider declares its own (handshake `icon`, protocol v1.3);
348    /// the in-tree github provider owns the one rootle hardcodes.
349    fn icon(&self) -> Option<String> {
350        None
351    }
352
353    /// Progressive code search (protocol v1.3, plans/0011): `on_hits`
354    /// may fire from any thread, any number of times, strictly before
355    /// this call returns. When the provider streamed, the result is
356    /// metadata-only — `hits` empty, `truncated` authoritative.
357    /// Default: one `search_code` call, one `on_hits` batch — every
358    /// provider streams; page-shaped backends stream page-by-page.
359    fn search_code_progressive(
360        &self,
361        q: &str,
362        on_hits: &(dyn Fn(&[CodeMatch]) + Send + Sync),
363    ) -> ProviderResult<SearchCodeResult> {
364        let result = self.search_code(q)?;
365        on_hits(&result.hits);
366        Ok(SearchCodeResult {
367            hits: Vec::new(),
368            truncated: result.truncated,
369            index_as_of: result.index_as_of,
370        })
371    }
372
373    /// Advisory cancellation (protocol v1.1): tells the backend the
374    /// caller no longer needs the in-flight request. Best-effort —
375    /// replies may still arrive and are always handled. Default: nothing
376    /// to cancel (in-process providers drop work via generations).
377    fn advise_cancel(&self) {}
378
379    /// One-shot UI notice for the status line (plans/0008 §5) — e.g.
380    /// a stdio child's successful restart. Drained once per route.
381    /// Default: nothing to say.
382    fn take_notice(&self) -> Option<String> {
383        None
384    }
385
386    /// Cache usage the provider reported at initialize (bytes), when
387    /// it participates in the advisory cache budget (protocol v1.2) —
388    /// surfaced in :settings next to the provider row.
389    fn cache_usage(&self) -> Option<u64> {
390        None
391    }
392
393    /// URL `git clone` accepts for a repo (clone wizard, plans/0004).
394    fn clone_url(&self, repo: &RepoId) -> ProviderResult<String>;
395    /// Browser URL for yank (␣ y): repo root, or a path inside it.
396    /// `is_file` picks the grammar (GitHub: blob vs tree); `line`
397    /// adds a fragment when Some. `branch` is `None` for the default
398    /// branch (the provider resolves it).
399    fn web_url(
400        &self,
401        repo: &RepoId,
402        path: &str,
403        branch: Option<&GitRef>,
404        line: Option<u32>,
405        end: Option<u32>,
406        is_file: bool,
407    ) -> ProviderResult<String>;
408
409    /// Browser URL for an org/group page.
410    fn org_url(&self, org: &str) -> ProviderResult<String>;
411}
412pub mod paths;
413
414/// Env-gated trace sink (`ROOTLE_TRACE=<path>`): timestamped appends,
415/// never a behavior change. Shared by the app and the provider
416/// crates (github client, transports) so one knob traces everything.
417pub fn trace(msg: &str) {
418    if let Ok(path) = std::env::var("ROOTLE_TRACE") {
419        use std::io::Write;
420        if let Ok(mut f) = std::fs::OpenOptions::new()
421            .create(true)
422            .append(true)
423            .open(path)
424        {
425            let _ = writeln!(f, "{:?} {msg}", std::time::SystemTime::now());
426        }
427    }
428}