1use std::io::Read;
8use std::path::Path;
9
10use dre_protocol::{
11 Kind, PluginId, package_executable_name, parse_executable_name, parse_package_executable_name,
12};
13use semver::{Version, VersionReq};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17use crate::lock::{Checksums, Lock, Locked};
18use crate::plugins::{Manifest, version_dir};
19use crate::project::{PluginRequirement, PluginSource, Project};
20
21pub const DEFAULT_REGISTRY: &str = "https://github.com/get-dre/dre/releases/download/registry/packages.json";
22
23pub const INDEX_SCHEMA: u32 = 2;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Index {
29 pub schema: u32,
30 pub plugins: Vec<IndexPackage>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(from = "RawPackage")]
36pub struct IndexPackage {
37 pub name: String,
38 #[serde(default)]
39 pub description: String,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
43 pub provides: Vec<PluginId>,
44 pub versions: Vec<IndexVersion>,
45}
46
47#[derive(Deserialize)]
48struct RawPackage {
49 name: String,
50 #[serde(default)]
52 kind: Option<Kind>,
53 #[serde(default)]
54 description: String,
55 #[serde(default)]
56 provides: Vec<PluginId>,
57 versions: Vec<IndexVersion>,
58}
59
60impl From<RawPackage> for IndexPackage {
61 fn from(r: RawPackage) -> IndexPackage {
62 let mut provides = r.provides;
63 if provides.is_empty()
64 && let Some(kind) = r.kind
65 {
66 provides.push(PluginId::new(kind, r.name.clone()));
67 }
68 IndexPackage {
69 name: r.name,
70 description: r.description,
71 provides,
72 versions: r.versions,
73 }
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct IndexVersion {
79 pub version: Version,
80 #[serde(default)]
82 pub protocol: u32,
83 pub artifacts: std::collections::BTreeMap<String, Artifact>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Artifact {
89 pub url: String,
90 #[serde(default)]
93 pub sha256: String,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub sha256_url: Option<String>,
96}
97
98pub fn explain_undeclared(diags: &mut crate::Diagnostics) {
102 if !diags.iter_mut().any(|d| d.plugin.is_some()) {
103 return;
104 }
105 let Ok(index) = Index::load() else { return };
106 for d in diags.iter_mut() {
107 let Some((kind, name)) = &d.plugin else { continue };
108 let id = PluginId::new(*kind, name.clone());
109 let what = d.message.split(" — ").next().unwrap_or_default().to_string();
110 let providers: Vec<&str> = index.providers(&id).iter().map(|p| p.name.as_str()).collect();
111 let others: Vec<String> = index
112 .plugins
113 .iter()
114 .flat_map(|p| &p.provides)
115 .filter(|p| &p.name == name && p.kind != *kind)
116 .map(|p| p.kind.to_string())
117 .collect();
118 d.message = match (providers.as_slice(), others.is_empty()) {
119 ([p], _) if *p == name => {
120 format!("{what} — add `{p}` under `plugins:` in dependencies.yml, then run `dre deps`")
121 }
122 ([p], _) => format!(
123 "{what} — the `{name}` {kind} is in the `{p}` package: add `{p}` under `plugins:` in dependencies.yml, then run `dre deps`"
124 ),
125 ([], true) => {
126 format!(
127 "{what} — DRE's plugin registry has no {kind} plugin called `{name}`; check the spelling"
128 )
129 }
130 ([], false) => format!(
131 "{what} — `{name}` in DRE's plugin registry is a {} plugin, not a {kind}",
132 others.join(" and ")
133 ),
134 (many, _) => format!(
135 "{what} — add one of the packages that provide it under `plugins:` in dependencies.yml ({}), then run `dre deps`",
136 many.iter()
137 .map(|p| format!("`{p}`"))
138 .collect::<Vec<_>>()
139 .join(", ")
140 ),
141 };
142 }
143}
144
145pub fn platform() -> String {
147 format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH)
148}
149
150pub fn registry_url() -> String {
151 std::env::var("DRE_REGISTRY_URL")
152 .ok()
153 .filter(|s| !s.is_empty())
154 .unwrap_or_else(|| DEFAULT_REGISTRY.to_string())
155}
156
157pub fn github_api() -> String {
159 std::env::var("DRE_GITHUB_API_URL")
160 .ok()
161 .filter(|s| !s.is_empty())
162 .unwrap_or_else(|| "https://api.github.com".into())
163 .trim_end_matches('/')
164 .to_string()
165}
166
167fn is_github(url: &str) -> bool {
169 url.starts_with("https://github.com/") || url.starts_with(&format!("{}/", github_api()))
170}
171
172pub fn fetch(url: &str) -> Result<Vec<u8>, String> {
173 if url.starts_with("http://") || url.starts_with("https://") {
174 let mut req = ureq::get(url).header("User-Agent", "dre");
175 if is_github(url) {
176 let accept = if url.contains("/releases/assets/") {
178 "application/octet-stream"
179 } else {
180 "application/vnd.github+json"
181 };
182 req = req.header("Accept", accept);
183 if let Ok(t) = std::env::var("GITHUB_TOKEN")
184 && !t.is_empty()
185 {
186 req = req.header("Authorization", &format!("Bearer {t}"));
187 }
188 }
189 let mut resp = req.call().map_err(|e| format!("can't download {url}: {e}"))?;
190 let mut body = Vec::new();
191 resp.body_mut()
192 .as_reader()
193 .read_to_end(&mut body)
194 .map_err(|e| format!("can't download {url}: {e}"))?;
195 return Ok(body);
196 }
197 let path = url.strip_prefix("file://").unwrap_or(url);
198 std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))
199}
200
201impl Index {
202 pub fn load() -> Result<Index, String> {
204 Index::load_from(®istry_url())
205 }
206
207 pub fn load_from(url: &str) -> Result<Index, String> {
208 let body = fetch(url)?;
209 serde_json::from_slice(&body)
210 .map_err(|e| format!("the plugin registry at {url} isn't a valid index: {e}"))
211 }
212
213 pub fn for_source(source: &PluginSource, name: &str) -> Result<Index, String> {
215 match source {
216 PluginSource::Default => Index::load(),
217 PluginSource::Registry(u) => Index::load_from(u),
218 PluginSource::Github(repo) => github_index(repo, name),
219 PluginSource::Local(p) => Err(format!(
220 "plugin package `{name}` is used from {p}; there's nothing to install"
221 )),
222 }
223 }
224
225 pub fn package(&self, name: &str) -> Option<&IndexPackage> {
226 self.plugins.iter().find(|p| p.name == name)
227 }
228
229 pub fn providers(&self, id: &PluginId) -> Vec<&IndexPackage> {
231 self.plugins.iter().filter(|p| p.provides.contains(id)).collect()
232 }
233
234 pub fn providers_of_name(&self, name: &str) -> Vec<&IndexPackage> {
236 self.plugins
237 .iter()
238 .filter(|p| p.provides.iter().any(|i| i.name == name))
239 .collect()
240 }
241}
242
243impl IndexPackage {
244 pub fn best(&self, req: &VersionReq) -> Option<&IndexVersion> {
248 let plat = platform();
249 let usable: Vec<&IndexVersion> = self
250 .versions
251 .iter()
252 .filter(|v| (dre_protocol::MIN_VERSION..=dre_protocol::MAX_VERSION).contains(&v.protocol))
253 .filter(|v| v.artifacts.contains_key(&plat))
254 .collect();
255 prefer_stable(usable, req, |v| &v.version)
256 }
257
258 pub fn exact(&self, v: &Version) -> Option<&IndexVersion> {
259 self.versions.iter().find(|x| &x.version == v)
260 }
261}
262
263pub fn prefer_stable<T>(items: Vec<T>, req: &VersionReq, version: impl Fn(&T) -> &Version) -> Option<T> {
267 let highest = |ok: &dyn Fn(&Version) -> bool, items: Vec<T>| {
268 items
269 .into_iter()
270 .filter(|i| ok(version(i)))
271 .max_by(|a, b| version_order(version(a), version(b)))
272 };
273 let exact = |v: &Version| req.matches(v);
274 if items.iter().any(|i| exact(version(i))) {
275 return highest(&exact, items);
276 }
277 highest(
278 &|v: &Version| !v.pre.is_empty() && req.matches(&Version::new(v.major, v.minor, v.patch)),
279 items,
280 )
281}
282
283pub fn version_order(a: &Version, b: &Version) -> std::cmp::Ordering {
287 use std::cmp::Ordering::*;
288 (a.major, a.minor, a.patch)
289 .cmp(&(b.major, b.minor, b.patch))
290 .then_with(|| match (a.pre.is_empty(), b.pre.is_empty()) {
291 (true, true) => Equal,
292 (true, false) => Greater,
293 (false, true) => Less,
294 (false, false) => {
295 let (mut x, mut y) = (a.pre.split('.'), b.pre.split('.'));
296 loop {
297 match (x.next(), y.next()) {
298 (None, None) => break Equal,
299 (None, Some(_)) => break Less,
300 (Some(_), None) => break Greater,
301 (Some(p), Some(q)) => match natural_order(p, q) {
302 Equal => {}
303 o => break o,
304 },
305 }
306 }
307 }
308 })
309 .then_with(|| a.cmp(b))
310}
311
312fn natural_order(a: &str, b: &str) -> std::cmp::Ordering {
315 fn runs(s: &str) -> Vec<&str> {
316 let mut out = Vec::new();
317 let mut start = 0;
318 let bytes = s.as_bytes();
319 for i in 1..=bytes.len() {
320 if i == bytes.len() || bytes[i].is_ascii_digit() != bytes[start].is_ascii_digit() {
321 out.push(&s[start..i]);
322 start = i;
323 }
324 }
325 out
326 }
327 let (ra, rb) = (runs(a), runs(b));
328 for (p, q) in ra.iter().zip(&rb) {
329 let digits = |s: &str| s.bytes().all(|c| c.is_ascii_digit());
330 let o = match (digits(p), digits(q)) {
331 (true, true) => {
332 let (p, q) = (p.trim_start_matches('0'), q.trim_start_matches('0'));
333 p.len().cmp(&q.len()).then_with(|| p.cmp(q))
334 }
335 (true, false) => std::cmp::Ordering::Less,
336 (false, true) => std::cmp::Ordering::Greater,
337 (false, false) => p.cmp(q),
338 };
339 if o != std::cmp::Ordering::Equal {
340 return o;
341 }
342 }
343 ra.len().cmp(&rb.len())
344}
345
346#[derive(Debug, Clone)]
348pub struct Release {
349 pub tag: String,
350 pub version: Version,
351 pub assets: Vec<(String, String)>,
353}
354
355pub fn github_releases(repo: &str) -> Result<Vec<Release>, String> {
359 let mut out: Vec<Release> = list_github_releases(repo)?
360 .into_iter()
361 .filter(|r| !r.draft)
362 .filter_map(|r| {
363 let version = Version::parse(r.tag_name.trim_start_matches('v')).ok()?;
364 Some(Release {
365 tag: r.tag_name,
366 version,
367 assets: r.assets.into_iter().map(|a| (a.name, a.url)).collect(),
368 })
369 })
370 .collect();
371 out.sort_by(|a, b| version_order(&b.version, &a.version));
372 Ok(out)
373}
374
375fn list_github_releases(repo: &str) -> Result<Vec<GithubRelease>, String> {
377 const PER_PAGE: usize = 100;
378 let mut releases = Vec::new();
379 for page in 1.. {
380 let url = format!(
381 "{}/repos/{repo}/releases?per_page={PER_PAGE}&page={page}",
382 github_api()
383 );
384 let body = fetch(&url)?;
385 let batch: Vec<GithubRelease> =
386 serde_json::from_slice(&body).map_err(|e| format!("{url}: unexpected reply from GitHub: {e}"))?;
387 let last = batch.len() < PER_PAGE;
388 releases.extend(batch);
389 if last {
390 break;
391 }
392 }
393 Ok(releases)
394}
395
396#[derive(Deserialize)]
397struct GithubRelease {
398 tag_name: String,
399 #[serde(default)]
400 draft: bool,
401 #[serde(default)]
402 assets: Vec<GithubAsset>,
403}
404
405#[derive(Deserialize)]
406struct GithubAsset {
407 name: String,
408 url: String,
411}
412
413fn github_index(repo: &str, name: &str) -> Result<Index, String> {
419 let releases = list_github_releases(repo)?;
420 let package_stem = format!("dre-plugin-{name}");
421 let own_tag = format!("{name}-v");
422 let mut provides: Vec<PluginId> = Vec::new();
423 let mut versions = Vec::new();
424 for r in releases.iter().filter(|r| !r.draft) {
425 let tag = r.tag_name.strip_prefix(&own_tag).unwrap_or(&r.tag_name);
426 let Ok(version) = Version::parse(tag.trim_start_matches('v')) else {
427 continue;
428 };
429 let suffix = format!("-{version}-");
430 let mut artifacts = std::collections::BTreeMap::new();
431 for a in &r.assets {
432 let Some((stem, rest)) = a.name.split_once(&suffix) else {
433 continue;
434 };
435 if stem != package_stem {
436 match parse_executable_name(&format!("{stem}{}", std::env::consts::EXE_SUFFIX)) {
437 Some((kind, n)) if n == name => {
438 let id = PluginId::new(kind, n);
439 if !provides.contains(&id) {
440 provides.push(id);
441 }
442 }
443 _ => continue,
444 }
445 }
446 let plat = rest
447 .trim_end_matches(".tar.gz")
448 .trim_end_matches(".tgz")
449 .trim_end_matches(".exe");
450 if plat.contains('.') || plat.is_empty() {
451 continue; }
453 let sha256_url = r
454 .assets
455 .iter()
456 .find(|s| s.name == format!("{}.sha256", a.name))
457 .map(|s| s.url.clone());
458 artifacts.insert(
459 plat.to_string(),
460 Artifact {
461 url: a.url.clone(),
462 sha256: String::new(),
463 sha256_url,
464 },
465 );
466 }
467 if !artifacts.is_empty() {
468 versions.push(IndexVersion {
469 version,
470 protocol: dre_protocol::MAX_VERSION,
471 artifacts,
472 });
473 }
474 }
475 if versions.is_empty() {
476 return Err(format!(
477 "no release of github.com/{repo} has a `{package_stem}-<version>-<platform>` asset for any platform"
478 ));
479 }
480 if provides.len() > 1 {
482 provides.clear();
483 }
484 Ok(Index {
485 schema: INDEX_SCHEMA,
486 plugins: vec![IndexPackage {
487 name: name.to_string(),
488 description: format!("from github.com/{repo}"),
489 provides,
490 versions,
491 }],
492 })
493}
494
495pub fn install(
498 dir: &Path,
499 package: &IndexPackage,
500 v: &IndexVersion,
501 pin: Option<&Checksums>,
502) -> Result<Locked, String> {
503 let plat = platform();
504 let what = format!("plugin package `{}` {}", package.name, v.version);
505 let art = v
506 .artifacts
507 .get(&plat)
508 .ok_or_else(|| format!("{what} has no build for {plat}"))?;
509 let published = if !art.sha256.is_empty() {
511 Some(art.sha256.clone())
512 } else if let Some(u) = &art.sha256_url {
513 let text = String::from_utf8_lossy(&fetch(u)?).to_string();
514 Some(text.split_whitespace().next().unwrap_or_default().to_string())
515 } else {
516 None
517 };
518 let refuse = || format!("{what}: the registry's checksum doesn't match dre.lock; refusing to install");
519 let mut want = pin.and_then(|c| c.get(&plat));
523 if let (Some(want), Some(published)) = (want, &published)
524 && !want.eq_ignore_ascii_case(published)
525 {
526 return Err(refuse());
527 }
528 if want.is_none()
529 && let Some(legacy) = pin.and_then(Checksums::legacy)
530 {
531 let all = published_checksums(v);
532 if all.is_empty() {
533 want = Some(legacy);
534 } else if !all.contains(legacy) {
535 return Err(refuse());
536 }
537 }
538 let bytes = fetch(&art.url)?;
539 let got = hex(&Sha256::digest(&bytes));
540 let expected = published.as_deref().or(want);
542 if let Some(want) = expected
543 && !got.eq_ignore_ascii_case(want)
544 {
545 return Err(format!(
546 "checksum mismatch for {what} (expected {want}, got {got}); the download was discarded"
547 ));
548 }
549 let (exe_name, exe) = if art.url.ends_with(".tar.gz") || art.url.ends_with(".tgz") {
550 extract_tar_gz(&bytes, &package.name)?
551 } else {
552 let name = crate::plugins::legacy_executable(&package.provides)
553 .unwrap_or_else(|| package_executable_name(&package.name));
554 (name, bytes)
555 };
556 let vdir = version_dir(dir, &package.name, &v.version);
557 std::fs::create_dir_all(&vdir).map_err(|e| format!("can't create {}: {e}", vdir.display()))?;
558 let dst = vdir.join(&exe_name);
559 let tmp = vdir.join(format!(".download-{}", std::process::id()));
560 std::fs::write(&tmp, &exe).map_err(|e| format!("can't write {}: {e}", tmp.display()))?;
561 #[cfg(unix)]
562 {
563 use std::os::unix::fs::PermissionsExt;
564 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?;
565 }
566 std::fs::rename(&tmp, &dst).map_err(|e| format!("can't install {}: {e}", dst.display()))?;
567 let provides = if package.provides.is_empty() {
569 crate::plugins::probe(&dst)?
570 } else {
571 package.provides.clone()
572 };
573 crate::plugins::write_manifest(
574 &vdir,
575 &Manifest {
576 executable: exe_name,
577 provides: provides.clone(),
578 },
579 )?;
580 let mut sha256 = published_checksums(v);
581 if let Some(p) = pin {
582 sha256.merge(p);
583 }
584 sha256.insert(&plat, &got);
585 Ok(Locked {
586 version: v.version.clone(),
587 sha256,
588 from: None,
589 provides,
590 })
591}
592
593fn published_checksums(v: &IndexVersion) -> Checksums {
595 let mut c = Checksums::new();
596 for (p, a) in &v.artifacts {
597 if !a.sha256.is_empty() {
598 c.insert(p, &a.sha256);
599 }
600 }
601 c
602}
603
604fn extract_tar_gz(bytes: &[u8], package: &str) -> Result<(String, Vec<u8>), String> {
607 let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(bytes));
608 for entry in archive.entries().map_err(|e| e.to_string())? {
609 let mut entry = entry.map_err(|e| e.to_string())?;
610 let path = entry.path().map_err(|e| e.to_string())?.to_path_buf();
611 let Some(file) = path.file_name().map(|f| f.to_string_lossy().to_string()) else {
612 continue;
613 };
614 let ours = parse_package_executable_name(&file).is_some_and(|n| n == package)
615 || parse_executable_name(&file).is_some_and(|(_, n)| n == package);
616 if ours {
617 let mut out = Vec::new();
618 entry.read_to_end(&mut out).map_err(|e| e.to_string())?;
619 return Ok((file, out));
620 }
621 }
622 Err(format!(
623 "the archive doesn't contain `{}`",
624 package_executable_name(package)
625 ))
626}
627
628fn hex(b: &[u8]) -> String {
629 b.iter().map(|x| format!("{x:02x}")).collect()
630}
631
632#[derive(Debug, Clone, PartialEq, Eq)]
634pub enum Synced {
635 AlreadyInstalled { name: String, version: Option<Version> },
636 Installed { name: String, version: Version },
637}
638
639pub fn sync(
647 project: &Project,
648 install_missing: bool,
649 resolve: bool,
650 mut log: impl FnMut(&str),
651) -> Result<Vec<Synced>, Vec<String>> {
652 let dir = crate::plugins::plugins_dir(Some(&project.root));
653 let mut lock = Lock::load(&project.root).map_err(|e| vec![e])?;
654 let mut indexes: std::collections::BTreeMap<String, Index> = std::collections::BTreeMap::new();
655 let mut out = Vec::new();
656 let mut errors = Vec::new();
657 let mut lock_changed = false;
658 for req in &project.plugins {
659 let name = &req.name;
660 if let PluginSource::Local(p) = &req.source {
661 if !project.root.join(p).is_file() {
662 errors.push(format!(
663 "the plugin package `{name}` is declared `local: {p}`, but there's no file there"
664 ));
665 continue;
666 }
667 if lock.local.get(name) != Some(p) {
668 lock.local.insert(name.clone(), p.clone());
669 lock_changed = true;
670 }
671 lock_changed |= lock.plugins.remove(name).is_some();
672 out.push(Synced::AlreadyInstalled {
673 name: name.clone(),
674 version: None,
675 });
676 continue;
677 }
678 lock_changed |= lock.local.remove(name).is_some();
679 let moved = lock.get(name).is_some_and(|l| l.from != req.source.lock_key());
682 let pin = lock
683 .get(name)
684 .filter(|l| l.from == req.source.lock_key())
685 .cloned();
686 if !moved
687 && let Some(p) =
688 crate::plugins::find(&dir, name, Some(&req.req()), pin.as_ref().map(|l| &l.version))
689 .into_iter()
690 .next()
691 {
692 let ok = match (&pin, &p.version) {
695 (Some(l), Some(v)) => &l.version == v,
696 (None, Some(_)) => !resolve,
697 _ => true,
698 };
699 if ok {
700 if let (Some(l), Some(_)) = (lock.plugins.get_mut(name), &p.version)
702 && l.provides.is_empty()
703 && !p.provides.is_empty()
704 {
705 l.provides = p.provides.clone();
706 lock_changed = true;
707 }
708 out.push(Synced::AlreadyInstalled {
709 name: name.clone(),
710 version: p.version,
711 });
712 continue;
713 }
714 }
715 if let Some(l) = &pin
718 && req.source.is_default()
719 && dir != crate::plugins::cache_dir()
720 {
721 let cached = version_dir(&crate::plugins::cache_dir(), name, &l.version);
722 if crate::plugins::read_manifest(&cached).is_some()
723 && crate::plugins::link_version(&cached, &version_dir(&dir, name, &l.version)).is_ok()
724 {
725 out.push(Synced::AlreadyInstalled {
726 name: name.clone(),
727 version: Some(l.version.clone()),
728 });
729 continue;
730 }
731 }
732 if !install_missing {
733 errors.push(format!(
734 "the plugin package `{name}` ({}) isn't installed and auto-install is off; run `dre deps`",
735 pin.as_ref()
736 .map(|l| format!("locked at {}", l.version))
737 .unwrap_or_else(|| req.version.clone())
738 ));
739 continue;
740 }
741 let ikey = match &req.source {
743 PluginSource::Github(_) => format!("{}#{name}", req.source.lock_key().unwrap_or_default()),
744 s => s.lock_key().unwrap_or_default(),
745 };
746 if !indexes.contains_key(&ikey) {
747 match Index::for_source(&req.source, name) {
748 Ok(i) => {
749 indexes.insert(ikey.clone(), i);
750 }
751 Err(e) if req.source.is_default() => return Err(vec![e]),
752 Err(e) => {
753 errors.push(e);
754 continue;
755 }
756 }
757 }
758 match install_one(&dir, &indexes[&ikey], req, pin.as_ref()).map(|(mut l, fresh)| {
759 l.from = req.source.lock_key();
760 (l, fresh)
761 }) {
762 Ok((locked, false)) => {
763 lock.plugins.insert(name.clone(), locked.clone());
764 lock_changed = true;
765 out.push(Synced::AlreadyInstalled {
766 name: name.clone(),
767 version: Some(locked.version),
768 });
769 }
770 Ok((locked, true)) => {
771 log(&format!("installed plugin package `{name}` {}", locked.version));
772 if lock.get(name) != Some(&locked) {
773 lock.plugins.insert(name.clone(), locked.clone());
774 lock_changed = true;
775 }
776 out.push(Synced::Installed {
777 name: name.clone(),
778 version: locked.version,
779 });
780 }
781 Err(e) => errors.push(e),
782 }
783 }
784 if lock_changed && let Err(e) = lock.save(&project.root) {
785 errors.push(e);
786 }
787 if errors.is_empty() { Ok(out) } else { Err(errors) }
788}
789
790fn install_one(
794 dir: &Path,
795 index: &Index,
796 req: &PluginRequirement,
797 pin: Option<&Locked>,
798) -> Result<(Locked, bool), String> {
799 let name = &req.name;
800 let package = index.package(name).ok_or_else(|| {
801 let inside: Vec<String> = index
802 .plugins
803 .iter()
804 .filter(|p| p.provides.iter().any(|i| &i.name == name))
805 .map(|p| format!("`{}`", p.name))
806 .collect();
807 if inside.is_empty() {
808 format!("the registry has no plugin package `{name}`")
809 } else {
810 format!(
811 "the registry has no plugin package `{name}`; `{name}` is a plugin in {}: declare that under `plugins:` instead",
812 inside.join(", ")
813 )
814 }
815 })?;
816 let v = match pin {
817 Some(l) => package.exact(&l.version).ok_or_else(|| {
818 format!(
819 "dre.lock pins plugin package `{name}` {}, which the registry doesn't list",
820 l.version
821 )
822 })?,
823 None => package.best(&req.req()).ok_or_else(|| {
824 format!(
825 "no version of the plugin package `{name}` matches `{}` for {}",
826 req.version,
827 platform()
828 )
829 })?,
830 };
831 let vdir = version_dir(dir, name, &v.version);
836 if pin.is_none()
837 && let Some(art) = v.artifacts.get(&platform())
838 && !(art.url.ends_with(".tar.gz") || art.url.ends_with(".tgz"))
839 && let Some(m) = crate::plugins::read_manifest(&vdir)
840 && std::fs::read(vdir.join(&m.executable))
841 .is_ok_and(|b| hex(&Sha256::digest(&b)).eq_ignore_ascii_case(&art.sha256))
842 {
843 let locked = Locked {
844 version: v.version.clone(),
845 sha256: published_checksums(v),
846 from: None,
847 provides: m.provides,
848 };
849 return Ok((locked, false));
850 }
851 let expect = pin.map(|l| &l.sha256);
852 if req.source.is_default() {
853 install_linked(dir, package, v, expect).map(|l| (l, true))
854 } else {
855 install(dir, package, v, expect).map(|l| (l, true))
856 }
857}
858
859pub fn install_linked(
861 dir: &Path,
862 package: &IndexPackage,
863 v: &IndexVersion,
864 pin: Option<&Checksums>,
865) -> Result<Locked, String> {
866 let cache = crate::plugins::cache_dir();
867 let locked = install(&cache, package, v, pin)?;
868 if dir != cache {
869 crate::plugins::link_version(
870 &version_dir(&cache, &package.name, &v.version),
871 &version_dir(dir, &package.name, &v.version),
872 )?;
873 }
874 Ok(locked)
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880
881 #[test]
882 fn default_registry_lives_under_get_dre() {
883 assert_eq!(
884 DEFAULT_REGISTRY,
885 "https://github.com/get-dre/dre/releases/download/registry/packages.json"
886 );
887 }
888
889 fn pick(versions: &[&str], req: &str) -> Option<String> {
890 let vs: Vec<Version> = versions.iter().map(|v| Version::parse(v).unwrap()).collect();
891 prefer_stable(vs.iter().collect(), &VersionReq::parse(req).unwrap(), |v| v).map(|v| v.to_string())
892 }
893
894 #[test]
895 fn pre_releases_only_when_nothing_stable_matches() {
896 assert_eq!(pick(&["0.0.1-alpha"], "*").as_deref(), Some("0.0.1-alpha"));
897 assert_eq!(
898 pick(&["0.0.1-alpha", "0.0.2-beta"], "*").as_deref(),
899 Some("0.0.2-beta")
900 );
901 assert_eq!(pick(&["0.1.0", "0.2.0-rc.1"], "*").as_deref(), Some("0.1.0"));
902 assert_eq!(
903 pick(&["0.0.1-alpha"], "^0.0.1-alpha").as_deref(),
904 Some("0.0.1-alpha")
905 );
906 assert_eq!(pick(&["0.0.1-alpha"], "^1").as_deref(), None);
907 assert_eq!(pick(&[], "*").as_deref(), None);
908 }
909
910 #[test]
911 fn alpha_10_is_newer_than_alpha_9() {
912 assert_eq!(
913 pick(&["0.0.1-alpha-8", "0.0.1-alpha-10", "0.0.1-alpha-9"], "*").as_deref(),
914 Some("0.0.1-alpha-10")
915 );
916 let v = |s: &str| Version::parse(s).unwrap();
917 let mut vs: Vec<Version> = [
918 "0.0.1",
919 "0.0.1-alpha-10",
920 "0.0.1-beta-1",
921 "0.0.1-alpha",
922 "0.0.1-alpha-9",
923 "0.0.1-alpha-2",
924 "0.0.1-rc.10",
925 "0.0.1-rc.9",
926 "0.0.2-alpha-1",
927 ]
928 .iter()
929 .map(|s| v(s))
930 .collect();
931 vs.sort_by(version_order);
932 let got: Vec<String> = vs.iter().map(ToString::to_string).collect();
933 assert_eq!(
934 got,
935 [
936 "0.0.1-alpha",
937 "0.0.1-alpha-2",
938 "0.0.1-alpha-9",
939 "0.0.1-alpha-10",
940 "0.0.1-beta-1",
941 "0.0.1-rc.9",
942 "0.0.1-rc.10",
943 "0.0.1",
944 "0.0.2-alpha-1"
945 ]
946 );
947 }
948}