use std::path::{Path, PathBuf};
use anyhow::Result;
use serde::Serialize;
use crate::state::{self, State};
const COMPILE_TIME_INSTALL_SOURCE: &str = env!("CARTOG_INSTALL_SOURCE");
pub(crate) const TARGET_TRIPLE: &str = env!("CARTOG_TARGET_TRIPLE");
const BUILD_VERSION: &str = env!("CARTOG_BUILD_VERSION");
const TEST_INSTALL_SOURCE_ENV: &str = "CARTOG_TEST_INSTALL_SOURCE";
pub(crate) fn effective_install_source() -> &'static str {
if let Ok(forced) = std::env::var(TEST_INSTALL_SOURCE_ENV) {
match forced.as_str() {
"release-tarball" => return "release-tarball",
"cargo" => return "cargo",
"dev" => return "dev",
_ => {} }
}
let cargo_home = std::env::var_os("CARGO_HOME").map(PathBuf::from);
let binary_path = std::env::current_exe().ok();
resolve_install_source(
COMPILE_TIME_INSTALL_SOURCE,
binary_path.as_deref(),
cargo_home.as_deref(),
)
}
pub(crate) fn resolve_install_source(
compile_time: &str,
binary_path: Option<&Path>,
cargo_home: Option<&Path>,
) -> &'static str {
if compile_time == "release-tarball" {
return "release-tarball";
}
if let Some(bin) = binary_path {
if looks_like_cargo_install(bin, cargo_home) {
return "cargo";
}
}
"dev"
}
pub(crate) fn looks_like_cargo_install(binary_path: &Path, cargo_home: Option<&Path>) -> bool {
if let Some(home) = cargo_home {
let bin_dir = home.join("bin");
if binary_path.starts_with(&bin_dir) {
return true;
}
}
let mut prev: Option<&std::ffi::OsStr> = None;
for component in binary_path.components() {
let cur = component.as_os_str();
if prev == Some(std::ffi::OsStr::new(".cargo")) && cur == std::ffi::OsStr::new("bin") {
return true;
}
prev = Some(cur);
}
false
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct VersionInfo {
pub version: String,
#[serde(rename = "describe")]
pub build_version: String,
pub target: String,
pub install_source: String,
pub last_update_check: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pending_update: Option<crate::state::PendingUpdate>,
}
impl VersionInfo {
pub(crate) fn build(state: &State) -> Self {
Self {
version: env!("CARGO_PKG_VERSION").to_string(),
build_version: BUILD_VERSION.to_string(),
target: TARGET_TRIPLE.to_string(),
install_source: effective_install_source().to_string(),
last_update_check: state.last_update_check.clone(),
pending_update: state.pending_update.clone(),
}
}
pub(crate) fn render_human(&self) -> String {
let last = self.last_update_check.as_deref().unwrap_or("never");
format!(
"cartog {version}\n describe: {build}\n target: {target}\n install source: {source}\n last update check: {last}\n",
version = self.version,
build = self.build_version,
target = self.target,
source = self.install_source,
last = last,
)
}
}
pub fn cmd_self_version(json: bool) -> Result<()> {
let state = match state::default_state_file() {
Some(p) => State::load_from(&p),
None => State::default(),
};
let info = VersionInfo::build(&state);
if json {
println!("{}", serde_json::to_string_pretty(&info)?);
} else {
print!("{}", info.render_human());
}
Ok(())
}
const DEFAULT_GITHUB_LATEST_URL: &str =
"https://api.github.com/repos/jrollin/cartog/releases/latest";
pub(crate) fn github_latest_url() -> String {
std::env::var("CARTOG_GITHUB_API_URL").unwrap_or_else(|_| DEFAULT_GITHUB_LATEST_URL.to_string())
}
pub(crate) fn fetch_latest_version(url: &str) -> Result<String> {
let client = reqwest::blocking::Client::builder()
.user_agent(concat!("cartog/", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(5))
.build()?;
let response = client
.get(url)
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("GitHub API returned status {status}");
}
let body = response.text()?;
parse_release_tag(&body).ok_or_else(|| {
anyhow::anyhow!("could not extract a stable release tag from GitHub response")
})
}
pub(crate) fn parse_release_tag(json: &str) -> Option<String> {
let parsed: serde_json::Value = serde_json::from_str(json).ok()?;
let tag = parsed.get("tag_name")?.as_str()?;
let trimmed = tag.strip_prefix('v').unwrap_or(tag);
if trimmed.contains('-') {
return None;
}
if !is_stable_semver(trimmed) {
return None;
}
Some(trimmed.to_string())
}
pub(crate) fn is_stable_semver(s: &str) -> bool {
let parts: Vec<&str> = s.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub(crate) struct CheckOutcome {
pub(crate) current: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) latest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) outdated: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) error: Option<String>,
}
impl CheckOutcome {
pub(crate) fn ok(current: &str, latest: &str) -> Self {
let outdated = compare_stable_versions(current, latest) == std::cmp::Ordering::Less;
Self {
current: current.to_string(),
latest: Some(latest.to_string()),
outdated: Some(outdated),
error: None,
}
}
pub(crate) fn failed(current: &str, error: &str) -> Self {
Self {
current: current.to_string(),
latest: None,
outdated: None,
error: Some(error.to_string()),
}
}
pub(crate) fn to_human(&self) -> String {
match (&self.latest, self.outdated, &self.error) {
(Some(latest), Some(true), _) => {
format!(
"cartog: update available: {current} -> {latest}",
current = self.current,
latest = latest,
)
}
(_, Some(false), _) => format!("cartog: up to date ({})", self.current),
(_, _, Some(err)) => format!("cartog: update check failed: {err}"),
_ => "cartog: update check produced an empty outcome".to_string(),
}
}
}
pub(crate) fn compare_stable_versions(a: &str, b: &str) -> std::cmp::Ordering {
let parse = |s: &str| -> [u64; 3] {
let mut parts = s.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
[
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
]
};
parse(a).cmp(&parse(b))
}