rootle_manager/lib.rs
1//! Provider manager (plans/0010): install, list, update, upgrade,
2//! pin, remove, use — for stdio provider binaries distributed as
3//! GitHub release tarballs (the 4-target matrix rootle and its
4//! providers ship: linux+macOS × x86_64+aarch64).
5//!
6//! Layout (XDG tiers, one purpose each — see the plan's survey):
7//!
8//! ```text
9//! ~/.local/share/rootle/providers/<name>/<version>/rootle-<name> binary
10//! ~/.local/share/rootle/providers/<name>/current -> <version>/ pointer
11//! ~/.local/state/rootle/providers/<name>.toml receipt
12//! ```
13//!
14//! Atomicity (krew model): download to temp staging → verify sha256
15//! (mandatory) → extract into the versioned dir → write the receipt
16//! LAST → atomically re-point `current`. A failed step leaves the
17//! previous version intact. The running TUI is never affected —
18//! provider children die with the app, the next spawn picks up the
19//! new `current`.
20//!
21//! Config declares, the store installs (mise pattern): `use` writes
22//! the existing `[provider]` block; the runtime `build()` is
23//! untouched and hand-edited configs keep working.
24//!
25//! Submodules by concern: `refs` (the install-reference grammar),
26//! `release` (GitHub API, tarball, checksum), `install` (the
27//! install/update/upgrade flows), `store` (local state on disk),
28//! `bookkeeping` (pin/remove/use/list).
29
30mod install;
31pub mod progress;
32mod reference;
33mod release;
34mod store;
35
36pub use reference::ProviderReference;
37
38/// The verified-download machinery, shared with `rootle update`
39/// (0017 M2) — same integrity model as provider installs.
40pub use release::latest_release_at;
41pub use release::{
42 checksum_sidecar, download_bytes, extract_binary, latest_release, pick_asset, platform_target,
43 verify_checksum,
44};
45
46use serde::{Deserialize, Serialize};
47use std::path::PathBuf;
48
49/// The receipt: provenance recorded beside the binary so list/upgrade
50/// read local state, never the network (gh manifest.yml pattern).
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Receipt {
53 pub name: String,
54 /// "owner/repo"
55 pub source: String,
56 pub tag: String,
57 pub sha256: String,
58 pub pinned: bool,
59 #[serde(default)]
60 pub installed_at: Option<String>,
61 /// Latest tag known from `update` (krew's non-mutating refresh).
62 #[serde(default)]
63 pub latest_tag: Option<String>,
64}
65
66pub type Result<T, E = ManagerError> = std::result::Result<T, E>;
67
68#[derive(Debug, thiserror::Error)]
69pub enum ManagerError {
70 #[error("{0}")]
71 User(String),
72 #[error("network: {0}")]
73 Network(String),
74 #[error("io: {0}")]
75 Io(#[from] std::io::Error),
76}
77
78pub struct Manager {
79 store: PathBuf,
80 state: PathBuf,
81 /// Releases-API base — github.com in every real path; tests point
82 /// it at a loopback host (the `latest_release_at` seam).
83 api: String,
84}
85
86impl Manager {
87 pub fn new() -> Result<Manager> {
88 Ok(Manager {
89 store: store::store_root().ok_or_else(|| ManagerError::User("no data dir".into()))?,
90 state: store::state_root().ok_or_else(|| ManagerError::User("no state dir".into()))?,
91 api: "https://api.github.com".into(),
92 })
93 }
94
95 /// A manager rooted at throwaway dirs — tests never touch the
96 /// user's real XDG store.
97 #[cfg(test)]
98 pub(crate) fn rooted_at(store: PathBuf, state: PathBuf) -> Manager {
99 Manager {
100 store,
101 state,
102 api: "https://api.github.com".into(),
103 }
104 }
105
106 /// Point the releases API at a loopback host (tests).
107 #[cfg(test)]
108 pub(crate) fn with_api(mut self, api: &str) -> Manager {
109 self.api = api.into();
110 self
111 }
112}
113
114/// An installed provider, as `list` reports.
115#[derive(Debug, Clone)]
116pub struct Installed {
117 pub receipt: Receipt,
118 pub active: bool,
119 pub current: Option<String>,
120}
121
122/// One provider's result in `rootle update`'s sweep (0019 M1) —
123/// rendered by the caller, never printed here.
124#[derive(Debug, Clone)]
125pub enum SweepOutcome {
126 Upgraded {
127 name: String,
128 from: String,
129 to: String,
130 },
131 /// Would upgrade (`--check`): nothing was swapped.
132 Stale {
133 name: String,
134 from: String,
135 to: String,
136 },
137 Current {
138 name: String,
139 tag: String,
140 },
141 Pinned {
142 name: String,
143 tag: String,
144 },
145 /// plain-HTTP / `--path` install: install-and-pin, untouched
146 /// (plans/0014 #1b) — reported, not silently skipped.
147 Untracked {
148 name: String,
149 source: String,
150 },
151 Failed {
152 name: String,
153 error: String,
154 },
155}