use std::path::{Path, PathBuf};
use std::process::Command;
const REPO_API_LATEST: &str = "https://api.github.com/repos/zakuro-ai/zc/releases/latest";
const REPO_CLONE_HOST: &str = "github.com/zakuro-ai/zc";
fn asset_name() -> &'static str {
match (cfg!(target_os = "macos"), cfg!(target_arch = "aarch64")) {
(true, true) => "zc-darwin-arm64",
(true, false) => "zc-darwin-x86_64",
(false, true) => "zc-linux-arm64",
(false, false) => "zc-linux-x86_64",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
PrebuiltRelease,
FromSource,
}
pub fn parse_mode(args: &[String]) -> Mode {
if args.iter().any(|a| a == "--from-source") {
Mode::FromSource
} else {
Mode::PrebuiltRelease
}
}
pub fn resolve_token<F1, F2>(try_gh: F1, get_env: F2) -> Option<String>
where
F1: FnOnce() -> Option<String>,
F2: Fn(&str) -> Option<String>,
{
if let Some(t) = try_gh() {
if !t.trim().is_empty() {
return Some(t.trim().to_string());
}
}
if let Some(t) = get_env("GH_PAT") {
if !t.trim().is_empty() {
return Some(t.trim().to_string());
}
}
if let Some(t) = get_env("GITHUB_TOKEN") {
if !t.trim().is_empty() {
return Some(t.trim().to_string());
}
}
None
}
fn gh_auth_token() -> Option<String> {
let out = Command::new("gh").args(["auth", "token"]).output().ok()?;
if !out.status.success() {
return None;
}
let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
if token.is_empty() {
None
} else {
Some(token)
}
}
fn real_env(key: &str) -> Option<String> {
std::env::var(key).ok()
}
pub fn run_cli(args: &[String]) {
match parse_mode(args) {
Mode::PrebuiltRelease => update_from_release(),
Mode::FromSource => update_from_source(),
}
}
fn replace_binary_atomic(exe_path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let dir = exe_path.parent().unwrap_or_else(|| Path::new("."));
let tmp_path: PathBuf = dir.join(format!(".zc-update-{}.tmp", std::process::id()));
std::fs::write(&tmp_path, bytes)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
}
std::fs::rename(&tmp_path, exe_path)?;
Ok(())
}
fn current_exe_or_bail() -> Option<PathBuf> {
match std::env::current_exe() {
Ok(p) => Some(p),
Err(e) => {
eprintln!(
"Error: could not determine the current executable path: {}",
e
);
None
}
}
}
fn dir_writable(dir: &Path) -> bool {
let probe = dir.join(format!(".zc-write-probe-{}", std::process::id()));
match std::fs::File::create(&probe) {
Ok(_) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
fn local_bin_zc() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".local").join("bin").join("zc")
}
fn resolve_install_target() -> (PathBuf, bool) {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
if dir_writable(dir) {
return (exe, false);
}
}
}
(local_bin_zc(), true)
}
fn append_line_once(path: &Path, line: &str) -> std::io::Result<bool> {
let existing = std::fs::read_to_string(path).unwrap_or_default();
if existing.lines().any(|l| l.trim() == line.trim()) {
return Ok(false);
}
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
let sep = if existing.is_empty() || existing.ends_with('\n') {
""
} else {
"\n"
};
writeln!(
f,
"{}# added by `zc update` — put ~/.local/bin on PATH\n{}",
sep, line
)?;
Ok(true)
}
fn shell_rc_path() -> Option<PathBuf> {
let home = std::env::var("HOME").ok()?;
let shell = std::env::var("SHELL").unwrap_or_default();
let file = if shell.contains("zsh") {
".zshrc"
} else if shell.contains("bash") {
".bashrc"
} else {
".profile"
};
Some(PathBuf::from(home).join(file))
}
fn ensure_local_bin_on_path(installed: &Path) {
let bin_dir = installed
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default();
let on_path = std::env::var("PATH")
.map(|p| p.split(':').any(|d| d == bin_dir))
.unwrap_or(false);
if on_path {
println!("Installed to {}.", installed.display());
println!("Run `hash -r` (or open a new terminal) so your shell uses the new binary.");
return;
}
let export = "export PATH=\"$HOME/.local/bin:$PATH\"".to_string();
let appended = shell_rc_path().and_then(|rc| {
let ok = append_line_once(&rc, &export).unwrap_or(false);
ok.then_some(rc)
});
println!("Installed to {}.", installed.display());
match appended {
Some(rc) => {
println!("Added ~/.local/bin to your PATH in {}.", rc.display());
println!(
"Open a new terminal (or `source {}`) and run `zc`.",
rc.display()
);
}
None => {
println!("Add ~/.local/bin to your PATH so `zc` is found:");
println!(" {}", export);
}
}
}
fn update_from_release() {
use std::time::Duration;
println!("Checking latest zc release...");
let agent = ureq::Agent::new_with_config(
ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(10)))
.timeout_global(Some(Duration::from_secs(30)))
.build(),
);
let token = resolve_token(gh_auth_token, real_env);
let mut req = agent
.get(REPO_API_LATEST)
.header("User-Agent", "zc-update")
.header("Accept", "application/vnd.github+json");
if let Some(t) = &token {
req = req.header("Authorization", format!("Bearer {}", t));
}
let resp = match req.call() {
Ok(r) => r,
Err(e) => {
eprintln!("Failed to reach GitHub releases API: {}", e);
eprintln!("Fallback: try building from source instead — `zc update --from-source`.");
return;
}
};
if resp.status().as_u16() != 200 {
eprintln!("GitHub releases API returned HTTP {}", resp.status());
eprintln!("Fallback: try building from source instead — `zc update --from-source`.");
return;
}
let body = match resp.into_body().read_to_string() {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to read releases API response: {}", e);
return;
}
};
let data: serde_json::Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(e) => {
eprintln!("Failed to parse releases API response: {}", e);
return;
}
};
let version = data
.get("tag_name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let asset_url = data
.get("assets")
.and_then(|v| v.as_array())
.and_then(|assets| {
assets
.iter()
.find(|a| a.get("name").and_then(|n| n.as_str()) == Some(asset_name()))
})
.and_then(|a| a.get("browser_download_url"))
.and_then(|u| u.as_str());
let asset_url = match asset_url {
Some(u) => u.to_string(),
None => {
eprintln!("No {} asset found on release {}.", asset_name(), version);
eprintln!("Fallback: try building from source instead — `zc update --from-source`.");
return;
}
};
println!("Downloading {} ({})...", asset_name(), version);
let mut dl_req = agent.get(&asset_url).header("User-Agent", "zc-update");
if let Some(t) = &token {
dl_req = dl_req.header("Authorization", format!("Bearer {}", t));
}
let dl_resp = match dl_req.call() {
Ok(r) => r,
Err(e) => {
eprintln!("Failed to download release asset: {}", e);
eprintln!("Fallback: try building from source instead — `zc update --from-source`.");
return;
}
};
if dl_resp.status().as_u16() != 200 {
eprintln!("Download failed: HTTP {}", dl_resp.status());
eprintln!("Fallback: try building from source instead — `zc update --from-source`.");
return;
}
let bytes = match dl_resp.into_body().read_to_vec() {
Ok(b) => b,
Err(e) => {
eprintln!("Failed to read downloaded binary: {}", e);
return;
}
};
let (exe_path, to_local_bin) = resolve_install_target();
if let Some(parent) = exe_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
println!("Installing to {}...", exe_path.display());
if let Err(e) = replace_binary_atomic(&exe_path, &bytes) {
eprintln!("Failed to install the new binary: {}", e);
return;
}
if to_local_bin {
ensure_local_bin_on_path(&exe_path);
}
println!("updated to {}", version);
}
fn update_from_source() {
let token = match resolve_token(gh_auth_token, real_env) {
Some(t) => t,
None => {
eprintln!("no GitHub token found; run `gh auth login` or set GH_PAT/GITHUB_TOKEN");
return;
}
};
let (exe_path, to_local_bin) = resolve_install_target();
if let Some(parent) = exe_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp_dir = std::env::temp_dir().join(format!("zc-update-src-{}", std::process::id()));
if let Err(e) = std::fs::create_dir_all(&tmp_dir) {
eprintln!("Failed to create temp dir {}: {}", tmp_dir.display(), e);
return;
}
println!("Cloning zakuro-ai/zc (source build)...");
let clone_url = format!("https://x-access-token:{}@{}", token, REPO_CLONE_HOST);
let clone_status = Command::new("git")
.args(["clone", "--depth", "1", &clone_url, "."])
.current_dir(&tmp_dir)
.status();
match clone_status {
Ok(s) if s.success() => {}
Ok(s) => {
eprintln!("git clone failed (exit {}).", s);
let _ = std::fs::remove_dir_all(&tmp_dir);
return;
}
Err(e) => {
eprintln!("Failed to run git clone: {}", e);
let _ = std::fs::remove_dir_all(&tmp_dir);
return;
}
}
println!("Building (cargo build --release)... this can take a few minutes.");
let (prog, pre_args) = cargo_invocation();
let build_output = Command::new(&prog)
.args(pre_args.iter().map(String::as_str))
.args(["build", "--release"])
.current_dir(&tmp_dir)
.output();
let build_output = match build_output {
Ok(o) => o,
Err(e) => {
eprintln!("Failed to run cargo build: {}", e);
let _ = std::fs::remove_dir_all(&tmp_dir);
return;
}
};
if !build_output.status.success() {
let stderr = String::from_utf8_lossy(&build_output.stderr);
let tail: String = stderr
.lines()
.rev()
.take(30)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n");
eprintln!("cargo build --release failed. Build output (tail):");
eprintln!("{}", tail);
if stderr.contains("lock file version") {
eprintln!();
eprintln!(
"This is a toolchain problem, not a code problem: the cargo that ran is\n\
too old to read this repo's Cargo.lock (v4 needs Rust >= 1.78).\n\
Install rustup so the pinned `stable` toolchain is used, or run the\n\
build yourself with a newer cargo:\n\
\n\
\trustup run stable cargo build --release"
);
}
let _ = std::fs::remove_dir_all(&tmp_dir);
return;
}
let mut built_bin: Option<PathBuf> = None;
for c in ["zc", "zc2"] {
let p = tmp_dir.join("target/release").join(c);
if p.is_file() {
built_bin = Some(p);
break;
}
}
let built_bin = match built_bin {
Some(p) => p,
None => {
eprintln!("Build finished but no zc binary found under target/release/.");
let _ = std::fs::remove_dir_all(&tmp_dir);
return;
}
};
let bytes = match std::fs::read(&built_bin) {
Ok(b) => b,
Err(e) => {
eprintln!("Failed to read built binary: {}", e);
let _ = std::fs::remove_dir_all(&tmp_dir);
return;
}
};
println!("Installing to {}...", exe_path.display());
if let Err(e) = replace_binary_atomic(&exe_path, &bytes) {
eprintln!("Failed to install the new binary: {}", e);
let _ = std::fs::remove_dir_all(&tmp_dir);
return;
}
if to_local_bin {
ensure_local_bin_on_path(&exe_path);
}
let version = Command::new(&exe_path)
.arg("--version")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "source build".to_string());
let _ = std::fs::remove_dir_all(&tmp_dir);
println!("updated to {}", version);
}
fn cargo_invocation() -> (String, Vec<String>) {
let rustup_ok = Command::new("rustup")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if rustup_ok {
(
"rustup".to_string(),
["run", "stable", "cargo"]
.iter()
.map(|s| s.to_string())
.collect(),
)
} else {
("cargo".to_string(), Vec::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn append_line_once_is_idempotent() {
let dir = std::env::temp_dir().join(format!("zc-rc-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let rc = dir.join("rc");
let line = "export PATH=\"$HOME/.local/bin:$PATH\"";
assert!(super::append_line_once(&rc, line).unwrap()); assert!(!super::append_line_once(&rc, line).unwrap()); let body = std::fs::read_to_string(&rc).unwrap();
assert_eq!(body.matches(line).count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn parse_mode_default_is_prebuilt_release() {
let args: Vec<String> = vec![];
assert_eq!(parse_mode(&args), Mode::PrebuiltRelease);
}
#[test]
fn parse_mode_other_flags_still_prebuilt_release() {
let args: Vec<String> = vec!["--verbose".to_string()];
assert_eq!(parse_mode(&args), Mode::PrebuiltRelease);
}
#[test]
fn parse_mode_from_source_flag_selects_source_build() {
let args: Vec<String> = vec!["--from-source".to_string()];
assert_eq!(parse_mode(&args), Mode::FromSource);
}
#[test]
fn parse_mode_from_source_among_other_args() {
let args: Vec<String> = vec!["--verbose".to_string(), "--from-source".to_string()];
assert_eq!(parse_mode(&args), Mode::FromSource);
}
#[test]
fn resolve_token_prefers_gh() {
let t = resolve_token(
|| Some("gh-token".to_string()),
|k| match k {
"GH_PAT" => Some("pat-token".to_string()),
"GITHUB_TOKEN" => Some("gh-env-token".to_string()),
_ => None,
},
);
assert_eq!(t.as_deref(), Some("gh-token"));
}
#[test]
fn resolve_token_falls_back_to_gh_pat() {
let t = resolve_token(
|| None,
|k| match k {
"GH_PAT" => Some("pat-token".to_string()),
"GITHUB_TOKEN" => Some("gh-env-token".to_string()),
_ => None,
},
);
assert_eq!(t.as_deref(), Some("pat-token"));
}
#[test]
fn resolve_token_falls_back_to_github_token() {
let t = resolve_token(
|| None,
|k| match k {
"GH_PAT" => None,
"GITHUB_TOKEN" => Some("gh-env-token".to_string()),
_ => None,
},
);
assert_eq!(t.as_deref(), Some("gh-env-token"));
}
#[test]
fn resolve_token_none_found() {
let t = resolve_token(|| None, |_k| None);
assert_eq!(t, None);
}
#[test]
fn resolve_token_skips_blank_values() {
let t = resolve_token(
|| Some(" ".to_string()),
|k| match k {
"GH_PAT" => Some("".to_string()),
"GITHUB_TOKEN" => Some("real-token".to_string()),
_ => None,
},
);
assert_eq!(t.as_deref(), Some("real-token"));
}
}
#[cfg(test)]
mod cargo_invocation_tests {
#[test]
fn rustup_invocation_puts_cargo_after_run_stable() {
let (prog, pre) = super::cargo_invocation();
if prog == "rustup" {
assert_eq!(pre, vec!["run", "stable", "cargo"]);
} else {
assert_eq!(prog, "cargo");
assert!(pre.is_empty(), "plain cargo takes no leading args");
}
}
}