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).map_err(|source| Error::Io {
150 context: format!("clearing {}", versioned_dir.display()),
151 source,
152 })?;
153 }
154 fs::create_dir_all(versioned_dir).map_err(|source| Error::Io {
155 context: format!("creating {}", versioned_dir.display()),
156 source,
157 })?;
158
159 let url = build_download_url(release, platform);
160 let tmp = tempfile::tempdir().map_err(|source| Error::Io {
161 context: "creating temp dir".to_string(),
162 source,
163 })?;
164 let zip_path = tmp.path().join("hyperapi-cxx.zip");
165 download_and_verify(&url, release.sha256_for(platform), &zip_path)?;
166 extract_hyperd(&zip_path, versioned_dir)?;
167 Ok(())
168}
169
170fn refresh_current(current: &Path, source: &Path, version_tag: &str) -> Result<(), Error> {
171 if current.exists() {
174 fs::remove_dir_all(current).map_err(|source| Error::Io {
175 context: format!("clearing {}", current.display()),
176 source,
177 })?;
178 }
179 fs::create_dir_all(current).map_err(|source| Error::Io {
180 context: format!("creating {}", current.display()),
181 source,
182 })?;
183 copy_dir_contents(source, current)?;
184 fs::write(current.join("VERSION"), version_tag).map_err(|source| Error::Io {
185 context: format!("writing {}/VERSION", current.display()),
186 source,
187 })?;
188 Ok(())
189}
190
191fn copy_dir_contents(from: &Path, to: &Path) -> Result<(), Error> {
192 for entry in fs::read_dir(from).map_err(|source| Error::Io {
193 context: format!("reading {}", from.display()),
194 source,
195 })? {
196 let entry = entry.map_err(|source| Error::Io {
197 context: format!("reading {}", from.display()),
198 source,
199 })?;
200 let src = entry.path();
201 let dst = to.join(entry.file_name());
202 let ty = entry.file_type().map_err(|source| Error::Io {
203 context: format!("stat {}", src.display()),
204 source,
205 })?;
206 if ty.is_dir() {
207 fs::create_dir_all(&dst).map_err(|source| Error::Io {
208 context: format!("creating {}", dst.display()),
209 source,
210 })?;
211 copy_dir_contents(&src, &dst)?;
212 } else {
213 fs::copy(&src, &dst).map_err(|source| Error::Io {
214 context: format!("copying {} -> {}", src.display(), dst.display()),
215 source,
216 })?;
217 #[cfg(unix)]
218 {
219 use std::os::unix::fs::PermissionsExt;
220 if let Ok(meta) = fs::metadata(&src) {
221 let mode = meta.permissions().mode();
222 let _ = fs::set_permissions(&dst, fs::Permissions::from_mode(mode));
223 }
224 }
225 }
226 }
227 Ok(())
228}