pub trait ReleaseSource {
fn latest_release_json(&self, owner: &str, repo: &str) -> anyhow::Result<String>;
}
pub struct GitHubReleaseSource;
impl ReleaseSource for GitHubReleaseSource {
fn latest_release_json(&self, owner: &str, repo: &str) -> anyhow::Result<String> {
let url = format!("https://api.github.com/repos/{owner}/{repo}/releases/latest");
let body = ureq::get(&url)
.header("User-Agent", "claude-profile-update-check")
.call()
.map_err(|e| anyhow::anyhow!("checking for updates: {e}"))?
.body_mut()
.read_to_string()
.map_err(|e| anyhow::anyhow!("reading release response: {e}"))?;
Ok(body)
}
}
pub struct VersionCheck {
pub current: String,
pub latest: String,
pub up_to_date: bool,
}
fn latest_tag(release_json: &str) -> anyhow::Result<String> {
let v: serde_json::Value = serde_json::from_str(release_json)?;
let tag = v.get("tag_name").and_then(|t| t.as_str())
.ok_or_else(|| anyhow::anyhow!("release response has no `tag_name`"))?;
Ok(tag.trim_start_matches('v').to_string())
}
pub fn check(source: &dyn ReleaseSource, owner: &str, repo: &str, current: &str) -> anyhow::Result<VersionCheck> {
let json = source.latest_release_json(owner, repo)?;
let latest = latest_tag(&json)?;
Ok(VersionCheck { current: current.to_string(), up_to_date: latest == current, latest })
}
fn upgrade_hint(current_exe: &std::path::Path) -> &'static str {
let path = current_exe.to_string_lossy();
if path.contains("/.cargo/bin/") {
"cargo install claude-profiles --force"
} else if path.contains("Cellar") || path.contains("homebrew") {
"brew upgrade claude-profiles"
} else {
"see https://github.com/fuzzyalej/claude-profile#installing for your install method"
}
}
pub fn run(current_exe: &std::path::Path) -> anyhow::Result<()> {
let current = env!("CARGO_PKG_VERSION");
crate::progress::step("checking for updates");
let result = check(&GitHubReleaseSource, "fuzzyalej", "claude-profile", current);
crate::progress::clear();
match result {
Ok(v) if v.up_to_date => {
println!("claude-profile {} is up to date", v.current);
}
Ok(v) => {
println!("claude-profile {} is available (you have {})", v.latest, v.current);
println!("upgrade with: {}", upgrade_hint(current_exe));
}
Err(e) => {
println!("could not check for updates: {e}");
}
}
println!();
println!("to update installed profile repos and marketplaces instead, run: claude-profile update profiles");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
struct FakeSource(String);
impl ReleaseSource for FakeSource {
fn latest_release_json(&self, _owner: &str, _repo: &str) -> anyhow::Result<String> {
Ok(self.0.clone())
}
}
#[test]
fn reports_up_to_date_when_tags_match() {
let src = FakeSource(r#"{"tag_name":"v0.3.0"}"#.to_string());
let v = check(&src, "o", "r", "0.3.0").unwrap();
assert!(v.up_to_date);
assert_eq!(v.latest, "0.3.0");
}
#[test]
fn reports_newer_release_available() {
let src = FakeSource(r#"{"tag_name":"v0.4.0"}"#.to_string());
let v = check(&src, "o", "r", "0.3.0").unwrap();
assert!(!v.up_to_date);
assert_eq!(v.latest, "0.4.0");
}
#[test]
fn errors_on_missing_tag_name() {
let src = FakeSource(r#"{}"#.to_string());
assert!(check(&src, "o", "r", "0.3.0").is_err());
}
#[test]
fn upgrade_hint_detects_cargo_install() {
assert!(upgrade_hint(&PathBuf::from("/Users/x/.cargo/bin/claude-profile")).contains("cargo install"));
}
#[test]
fn upgrade_hint_detects_homebrew() {
assert!(upgrade_hint(&PathBuf::from("/opt/homebrew/Cellar/claude-profile/0.3.0/bin/claude-profile"))
.contains("brew upgrade"));
}
#[test]
fn upgrade_hint_falls_back_to_readme() {
assert!(upgrade_hint(&PathBuf::from("/usr/local/bin/claude-profile")).contains("github.com"));
}
}