1use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use rtb_forge::{ReleaseAsset, ReleaseProvider};
13use tokio::io::AsyncReadExt as _;
14
15use crate::error::{Result, UpdateError};
16use crate::options::{ProgressEvent, ProgressSink, RunOutcome};
17
18pub type SwapFn = Arc<dyn Fn(&Path) -> std::io::Result<()> + Send + Sync + 'static>;
22
23pub type SelfTestFn = Arc<dyn Fn(&Path) -> std::io::Result<String> + Send + Sync + 'static>;
26
27#[must_use]
29pub fn default_swap_fn() -> SwapFn {
30 Arc::new(|src: &Path| self_replace::self_replace(src))
31}
32
33#[must_use]
37pub fn default_self_test_fn() -> SelfTestFn {
38 Arc::new(|binary: &Path| {
39 let output = std::process::Command::new(binary).arg("--version").output()?;
40 if !output.status.success() {
41 return Err(std::io::Error::other(format!(
42 "--version exited with status {}",
43 output.status
44 )));
45 }
46 Ok(String::from_utf8_lossy(&output.stdout).to_string())
47 })
48}
49
50pub async fn download_to_file(
53 provider: &dyn ReleaseProvider,
54 asset: &ReleaseAsset,
55 dest: &Path,
56 progress: Option<&ProgressSink>,
57) -> Result<u64> {
58 let (mut reader, total) = provider.download_asset(asset).await?;
59 let mut file = tokio::fs::File::create(dest).await?;
60 let mut buf = vec![0u8; 64 * 1024];
64 let mut done = 0u64;
65 loop {
66 let n = reader.read(&mut buf).await?;
67 if n == 0 {
68 break;
69 }
70 tokio::io::AsyncWriteExt::write_all(&mut file, &buf[..n]).await?;
71 done += n as u64;
72 if let Some(sink) = progress {
73 sink(ProgressEvent::Downloading { bytes_done: done, bytes_total: total });
74 }
75 }
76 tokio::io::AsyncWriteExt::flush(&mut file).await?;
77 Ok(done)
78}
79
80pub async fn fetch_small_asset(
83 provider: &dyn ReleaseProvider,
84 asset: &ReleaseAsset,
85) -> Result<Vec<u8>> {
86 let (mut reader, _) = provider.download_asset(asset).await?;
87 let mut out = Vec::new();
88 reader.read_to_end(&mut out).await?;
89 Ok(out)
90}
91
92pub fn extract_binary(src: &Path, dest_dir: &Path, tool_name: &str) -> Result<PathBuf> {
98 std::fs::create_dir_all(dest_dir)?;
99 let file_name =
100 src.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_ascii_lowercase();
101
102 #[allow(clippy::case_sensitive_file_extension_comparisons)]
105 let is_tar_gz = file_name.ends_with(".tar.gz") || file_name.ends_with(".tgz");
106 #[allow(clippy::case_sensitive_file_extension_comparisons)]
107 let is_zip = file_name.ends_with(".zip");
108 if is_tar_gz {
109 extract_tar_gz(src, dest_dir)?;
110 } else if is_zip {
111 extract_zip(src, dest_dir)?;
112 } else {
113 return Err(UpdateError::Archive(format!("unsupported archive extension: {file_name}")));
114 }
115
116 let expected_name_unix = tool_name.to_string();
119 let expected_name_windows = format!("{tool_name}.exe");
120 for entry in walk_files(dest_dir) {
121 let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
122 if name == expected_name_unix || name == expected_name_windows {
123 return Ok(entry);
124 }
125 }
126 Err(UpdateError::Archive(format!("extracted archive contained no `{tool_name}` binary")))
127}
128
129fn extract_tar_gz(src: &Path, dest_dir: &Path) -> Result<()> {
130 let file = std::fs::File::open(src)?;
131 let gz = flate2::read::GzDecoder::new(file);
132 let mut archive = tar::Archive::new(gz);
133 archive.unpack(dest_dir).map_err(|e| UpdateError::Archive(e.to_string()))
134}
135
136fn extract_zip(src: &Path, dest_dir: &Path) -> Result<()> {
137 let file = std::fs::File::open(src)?;
138 let mut archive =
139 zip::ZipArchive::new(file).map_err(|e| UpdateError::Archive(e.to_string()))?;
140 for i in 0..archive.len() {
141 let mut entry = archive.by_index(i).map_err(|e| UpdateError::Archive(e.to_string()))?;
142 let Some(rel_path) = entry.enclosed_name() else {
143 continue;
144 };
145 let out_path = dest_dir.join(rel_path);
146 if entry.is_dir() {
147 std::fs::create_dir_all(&out_path)?;
148 continue;
149 }
150 if let Some(parent) = out_path.parent() {
151 std::fs::create_dir_all(parent)?;
152 }
153 let mut out_file = std::fs::File::create(&out_path)?;
154 std::io::copy(&mut entry, &mut out_file)?;
155 }
156 Ok(())
157}
158
159fn walk_files(root: &Path) -> Vec<PathBuf> {
160 let mut stack = vec![root.to_path_buf()];
161 let mut out = Vec::new();
162 while let Some(dir) = stack.pop() {
163 let Ok(read) = std::fs::read_dir(&dir) else {
164 continue;
165 };
166 for entry in read.flatten() {
167 let path = entry.path();
168 if path.is_dir() {
169 stack.push(path);
170 } else {
171 out.push(path);
172 }
173 }
174 }
175 out
176}
177
178pub fn mark_executable(path: &Path) -> Result<()> {
180 #[cfg(unix)]
184 {
185 use std::os::unix::fs::PermissionsExt as _;
186 let mut perm = std::fs::metadata(path)?.permissions();
187 perm.set_mode(0o755);
188 std::fs::set_permissions(path, perm)?;
189 }
190 #[cfg(not(unix))]
191 let _ = path;
192 Ok(())
193}
194
195#[must_use]
197pub const fn dry_run_outcome(
198 from: semver::Version,
199 to: semver::Version,
200 bytes: u64,
201 staged_at: PathBuf,
202) -> RunOutcome {
203 RunOutcome {
204 from_version: from,
205 to_version: to,
206 bytes,
207 swapped: false,
208 staged_at: Some(staged_at),
209 }
210}
211
212#[must_use]
214pub const fn swap_outcome(from: semver::Version, to: semver::Version, bytes: u64) -> RunOutcome {
215 RunOutcome { from_version: from, to_version: to, bytes, swapped: true, staged_at: None }
216}
217
218#[must_use]
221pub fn parse_release_tag(tag: &str) -> Option<semver::Version> {
222 let stripped = tag.strip_prefix(['v', 'V']).unwrap_or(tag);
223 semver::Version::parse(stripped).ok()
224}
225
226pub fn cache_dir_for(tool_name: &str, version: &str) -> PathBuf {
229 let base = directories::ProjectDirs::from("", "", tool_name)
230 .map_or_else(std::env::temp_dir, |p| p.cache_dir().to_path_buf());
231 base.join("update").join(version)
232}