kaizen/shell/upgrade/
plan.rs1use anyhow::{Result, bail};
3use serde::Deserialize;
4use std::path::Path;
5
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum UpgradeAction {
8 Homebrew,
9 ReleaseBinary,
10 SourceCargo,
11}
12
13#[derive(Debug, Deserialize)]
14pub struct GithubAsset {
15 pub name: String,
16 pub browser_download_url: String,
17}
18
19#[derive(Debug, Deserialize)]
20pub struct GithubRelease {
21 pub tag_name: String,
22 pub assets: Vec<GithubAsset>,
23}
24
25#[derive(Debug, Eq, PartialEq)]
26pub struct ReleaseAssetPlan {
27 pub version: String,
28 pub target: String,
29 pub archive_name: String,
30 pub checksum_name: String,
31 pub archive_url: String,
32 pub checksum_url: String,
33}
34
35pub fn upgrade_action_for(exe: &Path, from_source: bool) -> UpgradeAction {
36 if from_source {
37 UpgradeAction::SourceCargo
38 } else if super::is_homebrew_install(exe) {
39 UpgradeAction::Homebrew
40 } else {
41 UpgradeAction::ReleaseBinary
42 }
43}
44
45pub fn release_asset_plan(
46 release: &GithubRelease,
47 os: &str,
48 arch: &str,
49) -> Result<ReleaseAssetPlan> {
50 let target = target_triple(os, arch)?;
51 let version = release.tag_name.trim_start_matches('v').to_string();
52 let archive_name = format!("kaizen-v{version}-{target}.tar.gz");
53 let checksum_name = format!("{archive_name}.sha256");
54 Ok(ReleaseAssetPlan {
55 archive_url: asset_url(release, &archive_name)?,
56 checksum_url: asset_url(release, &checksum_name)?,
57 version,
58 target,
59 archive_name,
60 checksum_name,
61 })
62}
63
64pub fn target_triple(os: &str, arch: &str) -> Result<String> {
65 match (os, arch) {
66 ("macos", "aarch64") => Ok("aarch64-apple-darwin".into()),
67 ("macos", "x86_64") => Ok("x86_64-apple-darwin".into()),
68 ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu".into()),
69 ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu".into()),
70 _ => bail!("no binary release for {os}/{arch}; run `kaizen upgrade --from-source`"),
71 }
72}
73
74pub fn parse_sha256(text: &str) -> Result<String> {
75 let hash = text.split_whitespace().next().unwrap_or_default();
76 if hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
77 Ok(hash.to_ascii_lowercase())
78 } else {
79 bail!("release checksum asset did not contain a SHA-256 hash")
80 }
81}
82
83pub fn verify_sha256(bytes: &[u8], expected: &str) -> Result<()> {
84 let actual = sha256_hex(bytes);
85 if actual == expected.to_ascii_lowercase() {
86 Ok(())
87 } else {
88 bail!("checksum mismatch for release binary")
89 }
90}
91
92fn asset_url(release: &GithubRelease, name: &str) -> Result<String> {
93 release
94 .assets
95 .iter()
96 .find(|asset| asset.name == name)
97 .map(|asset| asset.browser_download_url.clone())
98 .ok_or_else(|| anyhow::anyhow!("release asset missing: {name}"))
99}
100
101fn sha256_hex(bytes: &[u8]) -> String {
102 use sha2::{Digest, Sha256};
103 hex::encode(Sha256::digest(bytes))
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use std::path::Path;
110
111 #[test]
112 fn cargo_home_defaults_to_release_binary() {
113 let path = Path::new("/Users/me/.cargo/bin/kaizen");
114 assert_eq!(
115 upgrade_action_for(path, false),
116 UpgradeAction::ReleaseBinary
117 );
118 }
119
120 #[test]
121 fn from_source_uses_cargo_install() {
122 let path = Path::new("/Users/me/.cargo/bin/kaizen");
123 assert_eq!(upgrade_action_for(path, true), UpgradeAction::SourceCargo);
124 }
125
126 #[test]
127 fn homebrew_uses_brew_upgrade() {
128 let path = Path::new("/opt/homebrew/bin/kaizen");
129 assert_eq!(upgrade_action_for(path, false), UpgradeAction::Homebrew);
130 }
131
132 #[test]
133 fn maps_supported_targets() {
134 assert_eq!(
135 target_triple("linux", "x86_64").unwrap(),
136 "x86_64-unknown-linux-gnu"
137 );
138 assert_eq!(
139 target_triple("macos", "aarch64").unwrap(),
140 "aarch64-apple-darwin"
141 );
142 }
143
144 #[test]
145 fn rejects_unsupported_targets() {
146 assert!(target_triple("windows", "x86_64").is_err());
147 }
148
149 #[test]
150 fn parses_checksum_line() {
151 let hash = "0".repeat(64);
152 assert_eq!(
153 parse_sha256(&format!("{hash} kaizen.tar.gz")).unwrap(),
154 hash
155 );
156 }
157
158 #[test]
159 fn detects_checksum_mismatch() {
160 assert!(verify_sha256(b"abc", &"0".repeat(64)).is_err());
161 }
162
163 #[test]
164 fn finds_release_assets() {
165 let release = GithubRelease {
166 tag_name: "v1.2.3".into(),
167 assets: vec![
168 asset("kaizen-v1.2.3-x86_64-unknown-linux-gnu.tar.gz", "archive"),
169 asset(
170 "kaizen-v1.2.3-x86_64-unknown-linux-gnu.tar.gz.sha256",
171 "sha",
172 ),
173 ],
174 };
175 let plan = release_asset_plan(&release, "linux", "x86_64").unwrap();
176 assert_eq!(plan.version, "1.2.3");
177 assert_eq!(plan.archive_url, "archive");
178 assert_eq!(plan.checksum_url, "sha");
179 }
180
181 fn asset(name: &str, url: &str) -> GithubAsset {
182 GithubAsset {
183 name: name.into(),
184 browser_download_url: url.into(),
185 }
186 }
187}