1use super::reference::{ProviderReference, binary_name_of};
5use super::release::{
6 checksum_sidecar, download_bytes, extract_binary, latest_release, latest_release_at,
7 pick_asset, platform_target, release_by_tag_at, sha256_hex, verify_checksum,
8};
9use super::store::now_iso;
10use super::{Manager, ManagerError, Receipt, Result, SweepOutcome};
11use std::path::Path;
12
13impl Manager {
14 pub fn install(&self, r: &ProviderReference, force: bool) -> Result<Receipt> {
20 if let Some(url) = &r.tarball {
21 return self.install_tarball(r, url, force);
22 }
23 self.install_inner(r, force, &crate::progress::ProgressOutput::new(), None)
24 }
25
26 pub fn install_inner(
31 &self,
32 r: &ProviderReference,
33 force: bool,
34 ui: &crate::progress::ProgressOutput,
35 expect_sha: Option<&str>,
36 ) -> Result<Receipt> {
37 if let Some(existing) = self.receipt(&r.name)
38 && existing.tag == r.tag.clone().unwrap_or_default()
39 && !force
40 && existing.source == r.repo
41 {
42 return Err(ManagerError::User(format!(
43 "{} {} is already installed (use --force to reinstall)",
44 r.name, existing.tag
45 )));
46 }
47 let timer = crate::progress::Timer::start();
48 let release = match &r.tag {
49 Some(tag) => release_by_tag_at(&self.api, &r.repo, tag)?,
50 None => latest_release_at(&self.api, &r.repo)?,
51 };
52 ui.done(
53 "Resolved",
54 &format!("{repo} @ {tag}", repo = r.repo, tag = release.tag_name),
55 );
56 let target = platform_target();
57 let asset = pick_asset(&release, target)?;
58 let sidecar = checksum_sidecar(&release, asset)?;
59
60 let spinner = ui.spinner(&format!("Downloading {}", asset.name));
61 let tarball = download_bytes(&asset.browser_download_url)?;
62 drop(spinner);
63 ui.step("Verifying", "sha256 checksum");
64 verify_checksum(&tarball, &sidecar.browser_download_url)?;
65 if let Some(want) = expect_sha {
68 let got = sha256_hex(&tarball);
69 if got != want {
70 return Err(ManagerError::User(format!(
71 "sha256 pin mismatch: config pins {want}, release serves {got}"
72 )));
73 }
74 }
75 ui.done("Verified", &format!("sha256 ok ({})", asset.name));
76
77 ui.step("Extracting", &binary_name_of(r));
78 let binary_name = format!("rootle-{}", r.name);
79 let bytes = extract_binary(&tarball, &binary_name)?;
80 ui.done("Extracted", &binary_name);
81
82 let vdir = self.version_dir(&r.name, &release.tag_name);
84 std::fs::create_dir_all(&vdir)?;
85 let bin = vdir.join(&binary_name);
86 std::fs::write(&bin, bytes)?;
87 #[cfg(unix)]
88 {
89 use std::os::unix::fs::PermissionsExt;
90 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))?;
91 }
92
93 let receipt = Receipt {
94 name: r.name.clone(),
95 source: r.repo.clone(),
96 tag: release.tag_name.clone(),
97 sha256: sha256_hex(&tarball),
98 pinned: r.tag.is_some(),
99 installed_at: now_iso(),
100 latest_tag: Some(release.tag_name.clone()),
101 };
102 self.write_receipt(&receipt)?;
103 self.point_current(&r.name, &release.tag_name)?;
104
105 ui.summary("Installed", &r.name, &release.tag_name, timer.elapsed());
106 ui.note(&format!(
107 "you are trusting {repo} — run `rootle provider use {name}` to activate",
108 repo = r.repo,
109 name = r.name
110 ));
111 Ok(receipt)
112 }
113
114 fn install_tarball(&self, r: &ProviderReference, url: &str, force: bool) -> Result<Receipt> {
120 let file = url.rsplit('/').next().unwrap_or(url);
121 if let Some(existing) = self.receipt(&r.name)
122 && existing.source == url
123 && !force
124 {
125 return Err(ManagerError::User(format!(
126 "{} is already installed from this URL (use --force to reinstall)",
127 r.name
128 )));
129 }
130 let timer = crate::progress::Timer::start();
131 let ui = crate::progress::ProgressOutput::new();
132
133 let spinner = ui.spinner(&format!("Downloading {file}"));
134 let tarball = download_bytes(url)?;
135 drop(spinner);
136 ui.step("Verifying", "sha256 checksum");
137 verify_checksum(&tarball, &format!("{url}.sha256"))?;
138 ui.done("Verified", &format!("sha256 ok ({file})"));
139
140 ui.step("Extracting", &binary_name_of(r));
141 let binary_name = format!("rootle-{}", r.name);
142 let bytes = extract_binary(&tarball, &binary_name)?;
143 ui.done("Extracted", &binary_name);
144
145 let tag = r
148 .tag
149 .clone()
150 .unwrap_or_else(|| sha256_hex(&tarball)[..12].to_string());
151 let vdir = self.version_dir(&r.name, &tag);
152 std::fs::create_dir_all(&vdir)?;
153 let bin = vdir.join(&binary_name);
154 std::fs::write(&bin, bytes)?;
155 #[cfg(unix)]
156 {
157 use std::os::unix::fs::PermissionsExt;
158 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))?;
159 }
160
161 let receipt = Receipt {
162 name: r.name.clone(),
163 source: url.to_string(),
164 tag: tag.clone(),
165 sha256: sha256_hex(&tarball),
166 pinned: true, installed_at: now_iso(),
168 latest_tag: None,
169 };
170 self.write_receipt(&receipt)?;
171 self.point_current(&r.name, &tag)?;
172
173 ui.summary("Installed", &r.name, &tag, timer.elapsed());
174 ui.note(&format!(
175 "you are trusting {url} — install-and-pin: `update`/`upgrade` do not track \
176 plain-HTTP sources; upgrades come from whatever deployed it"
177 ));
178 Ok(receipt)
179 }
180
181 pub fn install_path(&self, name: &str, path: &Path) -> Result<Receipt> {
184 if !path.is_file() {
185 return Err(ManagerError::User(format!(
186 "{} is not a file",
187 path.display()
188 )));
189 }
190 #[cfg(unix)]
191 {
192 use std::os::unix::fs::PermissionsExt;
193 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?;
194 }
195 let vdir = self.version_dir(name, "local");
196 std::fs::create_dir_all(&vdir)?;
197 let bin = vdir.join(format!("rootle-{name}"));
198 #[cfg(unix)]
199 std::os::unix::fs::symlink(path, &bin)
200 .or_else(|_| std::fs::copy(path, &bin).map(|_| ()))
201 .map_err(ManagerError::Io)?;
202 #[cfg(not(unix))]
203 std::fs::copy(path, &bin)?;
204 let receipt = Receipt {
205 name: name.into(),
206 source: path.display().to_string(),
207 tag: "local".into(),
208 sha256: String::new(),
209 pinned: true, installed_at: now_iso(),
211 latest_tag: None,
212 };
213 self.write_receipt(&receipt)?;
214 self.point_current(name, "local")?;
215 println!("{name} installed (local: {path})", path = path.display());
216 Ok(receipt)
217 }
218
219 pub fn update(&self, name: Option<&str>) -> Result<Vec<(String, String, String)>> {
223 let mut stale = Vec::new();
224 for receipt in self.receipts() {
225 if let Some(want) = name
226 && receipt.name != want
227 {
228 continue;
229 }
230 if receipt.pinned || !tracks_releases(&receipt.source) {
231 continue;
232 }
233 match latest_release(&receipt.source) {
234 Ok(rel) => {
235 let latest = rel.tag_name;
236 if latest != receipt.tag {
237 stale.push((receipt.name.clone(), receipt.tag.clone(), latest.clone()));
238 }
239 let mut updated = receipt.clone();
240 updated.latest_tag = Some(latest);
241 self.write_receipt(&updated)?;
242 }
243 Err(e) => eprintln!("{}: {e}", receipt.name),
244 }
245 }
246 Ok(stale)
247 }
248
249 pub fn upgrade(&self, name: Option<&str>, dry_run: bool, force: bool) -> Result<()> {
252 for receipt in self.receipts() {
253 if let Some(want) = name
254 && receipt.name != want
255 {
256 continue;
257 }
258 if !tracks_releases(&receipt.source) {
259 println!(
263 "{}: {} source is install-and-pin, not tracked by upgrade",
264 receipt.name, receipt.source
265 );
266 continue;
267 }
268 if receipt.pinned && !force {
269 println!("{}: pinned at {} (use --force)", receipt.name, receipt.tag);
270 continue;
271 }
272 let latest = match &receipt.latest_tag {
273 Some(t) => t.clone(),
274 None => latest_release(&receipt.source)?.tag_name,
275 };
276 if latest == receipt.tag {
277 println!("{}: {} is current", receipt.name, receipt.tag);
278 continue;
279 }
280 if dry_run {
281 println!("{}: {} → {}", receipt.name, receipt.tag, latest);
282 continue;
283 }
284 let r = ProviderReference {
285 repo: receipt.source.clone(),
286 name: receipt.name.clone(),
287 tag: None,
288 tarball: None,
289 };
290 self.install(&r, true)?;
292 }
293 Ok(())
294 }
295
296 pub fn sweep(&self, dry_run: bool, ui: &crate::progress::ProgressOutput) -> Vec<SweepOutcome> {
303 let mut out = Vec::new();
304 for receipt in self.receipts() {
305 if !tracks_releases(&receipt.source) {
306 out.push(SweepOutcome::Untracked {
307 name: receipt.name.clone(),
308 source: receipt.source.clone(),
309 });
310 continue;
311 }
312 if receipt.pinned {
313 out.push(SweepOutcome::Pinned {
314 name: receipt.name.clone(),
315 tag: receipt.tag.clone(),
316 });
317 }
318 let latest = match &receipt.latest_tag {
319 Some(t) => t.clone(),
320 None => match latest_release_at(&self.api, &receipt.source) {
321 Ok(rel) => rel.tag_name,
322 Err(e) => {
323 out.push(SweepOutcome::Failed {
324 name: receipt.name,
325 error: e.to_string(),
326 });
327 continue;
328 }
329 },
330 };
331 if latest == receipt.tag {
332 out.push(SweepOutcome::Current {
333 name: receipt.name,
334 tag: receipt.tag,
335 });
336 continue;
337 }
338 if dry_run {
339 out.push(SweepOutcome::Stale {
340 name: receipt.name,
341 from: receipt.tag,
342 to: latest,
343 });
344 continue;
345 }
346 let r = ProviderReference {
347 repo: receipt.source.clone(),
348 name: receipt.name.clone(),
349 tag: None,
350 tarball: None,
351 };
352 match self.install_inner(&r, true, ui, None) {
355 Ok(done) => out.push(SweepOutcome::Upgraded {
356 name: receipt.name,
357 from: receipt.tag,
358 to: done.tag,
359 }),
360 Err(e) => out.push(SweepOutcome::Failed {
361 name: receipt.name,
362 error: e.to_string(),
363 }),
364 }
365 }
366 out
367 }
368}
369
370fn tracks_releases(source: &str) -> bool {
374 !source.contains("://") && source.matches('/').count() == 1
375}
376
377#[cfg(test)]
378mod tests;