1use std::fs;
11use std::path::{Path, PathBuf};
12
13use crate::download::download_and_verify;
14use crate::extract::extract_hyperd;
15use crate::platform::Platform;
16use crate::release::PinnedRelease;
17use crate::scrape::scrape_latest;
18use crate::url::build_download_url;
19use crate::Error;
20
21pub const DEFAULT_DEST_ROOT: &str = ".hyperd";
25
26#[derive(Debug, Clone, Default)]
31pub enum VersionSource {
32 #[default]
34 Builtin,
35 TomlFile(PathBuf),
37 Explicit(PinnedRelease),
39 ScrapeLatest,
41}
42
43#[derive(Debug, Clone)]
45pub struct InstallOptions {
46 pub dest_root: PathBuf,
48 pub version_source: VersionSource,
50 pub platform: Option<Platform>,
53 pub force: bool,
55}
56
57impl Default for InstallOptions {
58 fn default() -> Self {
59 Self {
60 dest_root: PathBuf::from(DEFAULT_DEST_ROOT),
61 version_source: VersionSource::Builtin,
62 platform: None,
63 force: false,
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct InstalledHyperd {
71 pub binary_path: PathBuf,
74 pub version: String,
76 pub build_id: String,
78 pub platform: Platform,
80 pub cache_hit: bool,
83}
84
85#[expect(
86 clippy::needless_pass_by_value,
87 reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
88)]
89pub fn install(opts: InstallOptions) -> Result<InstalledHyperd, Error> {
103 let platform = match opts.platform {
104 Some(p) => p,
105 None => Platform::current()?,
106 };
107 let release = resolve_release(&opts.version_source, platform)?;
108 let version_tag = release.version_tag();
109 let versioned_dir = opts.dest_root.join(&version_tag);
110 let current_dir = opts.dest_root.join("current");
111 let exe_name = platform.executable_name();
112
113 let cache_hit = versioned_dir.join(exe_name).exists() && !opts.force;
114 if cache_hit {
115 tracing::info!(
116 dir = %versioned_dir.display(),
117 "cache hit, skipping download"
118 );
119 } else {
120 download_and_extract(&release, platform, &versioned_dir)?;
121 }
122
123 refresh_current(¤t_dir, &versioned_dir, &version_tag)?;
124 let binary_path = current_dir.join(exe_name);
125 Ok(InstalledHyperd {
126 binary_path,
127 version: release.version,
128 build_id: release.build_id,
129 platform,
130 cache_hit,
131 })
132}
133
134fn resolve_release(source: &VersionSource, platform: Platform) -> Result<PinnedRelease, Error> {
135 match source {
136 VersionSource::Builtin => Ok(PinnedRelease::builtin()),
137 VersionSource::TomlFile(path) => PinnedRelease::from_toml_file(path),
138 VersionSource::Explicit(r) => Ok(r.clone()),
139 VersionSource::ScrapeLatest => scrape_latest(platform),
140 }
141}
142
143fn download_and_extract(
144 release: &PinnedRelease,
145 platform: Platform,
146 versioned_dir: &Path,
147) -> Result<(), Error> {
148 if versioned_dir.exists() {
149 fs::remove_dir_all(versioned_dir)
150 .map_err(|source| Error::io(format!("clearing {}", versioned_dir.display()), source))?;
151 }
152 fs::create_dir_all(versioned_dir)
153 .map_err(|source| Error::io(format!("creating {}", versioned_dir.display()), source))?;
154
155 let url = build_download_url(release, platform);
156 let tmp = tempfile::tempdir().map_err(|source| Error::io("creating temp dir", source))?;
157 let zip_path = tmp.path().join("hyperapi-java.zip");
158 download_and_verify(&url, release.sha256_for(platform), &zip_path)?;
159 extract_hyperd(&zip_path, versioned_dir)?;
160 Ok(())
161}
162
163fn refresh_current(current: &Path, source: &Path, version_tag: &str) -> Result<(), Error> {
164 if current.exists() {
167 fs::remove_dir_all(current)
168 .map_err(|source| Error::io(format!("clearing {}", current.display()), source))?;
169 }
170 fs::create_dir_all(current)
171 .map_err(|source| Error::io(format!("creating {}", current.display()), source))?;
172 copy_dir_contents(source, current)?;
173 fs::write(current.join("VERSION"), version_tag)
174 .map_err(|source| Error::io(format!("writing {}/VERSION", current.display()), source))?;
175 Ok(())
176}
177
178fn copy_dir_contents(from: &Path, to: &Path) -> Result<(), Error> {
179 for entry in fs::read_dir(from)
180 .map_err(|source| Error::io(format!("reading {}", from.display()), source))?
181 {
182 let entry =
183 entry.map_err(|source| Error::io(format!("reading {}", from.display()), source))?;
184 let src = entry.path();
185 let dst = to.join(entry.file_name());
186 let ty = entry
187 .file_type()
188 .map_err(|source| Error::io(format!("stat {}", src.display()), source))?;
189 if ty.is_dir() {
190 fs::create_dir_all(&dst)
191 .map_err(|source| Error::io(format!("creating {}", dst.display()), source))?;
192 copy_dir_contents(&src, &dst)?;
193 } else {
194 fs::copy(&src, &dst).map_err(|source| {
195 Error::io(
196 format!("copying {} -> {}", src.display(), dst.display()),
197 source,
198 )
199 })?;
200 #[cfg(unix)]
201 {
202 use std::os::unix::fs::PermissionsExt;
203 if let Ok(meta) = fs::metadata(&src) {
204 let mode = meta.permissions().mode();
205 let _ = fs::set_permissions(&dst, fs::Permissions::from_mode(mode));
206 }
207 }
208 }
209 }
210 Ok(())
211}