master
John Lauer rust: netlist, lint, libraries/settings/design rules, model_check/pcm/updates groups (33 verbs); net feature gate; serv… 2a09607 2mo ago
//! kicad_check_for_updates: the read-only half of handlers/upgrade.py.
//!
//! Installed KiCad versus the latest STABLE release, resolved from KiCad's GitLab tags
//! (baked fallback on a network blip), plus the "your KiCad predates the IPC API,
//! upgrade" nudge and the official downloads.kicad.org installer URL. The installer
//! itself (kicad_upgrade: download, silent NSIS install, elevation facts) is a later
//! phase; `remote_installer_size` is here already because it is the anti-truncation
//! ground truth that phase needs.
//!
//! TLS is rustls with bundled roots, so the CA-store problem the Python bridge had on
//! ab's portable interpreter cannot recur.

use std::time::Duration;

use serde_json::{json, Value};

/// Baked fallback if the GitLab tag query fails. Bump on new stable.
pub const FALLBACK_LATEST: &str = "10.0.6";
pub const TAGS_URL: &str = "https://gitlab.com/api/v4/projects/kicad%2Fcode%2Fkicad/repository/tags?per_page=30";
const DL_URL: &str = "https://downloads.kicad.org/kicad/windows/explore/stable/download/kicad-{v}-x86_64.exe";
const UA: &str = "kicad-bridge";
/// The oldest KiCad whose IPC API our routing verbs drive (docs/rust-port-plan.md).
const IPC_MIN: [u32; 3] = [10, 0, 1];

pub type Fetch<'a> = &'a dyn Fn(&str, Duration) -> Result<Vec<u8>, String>;

/// GET a URL with the bridge user agent (rustls, bundled roots).
#[cfg(not(feature = "net"))]
pub fn http_fetch(_url: &str, _timeout: Duration) -> Result<Vec<u8>, String> {
    Err("the net feature is off in this build: no HTTPS client".into())
}

#[cfg(feature = "net")]
pub fn http_fetch(url: &str, timeout: Duration) -> Result<Vec<u8>, String> {
    let mut resp = ureq::get(url)
        .header("User-Agent", UA)
        .config()
        .timeout_global(Some(timeout))
        .build()
        .call()
        .map_err(|e| e.to_string())?;
    resp.body_mut().with_config().limit(8 * 1024 * 1024).read_to_vec().map_err(|e| e.to_string())
}

/// Parse "10.0" / "9.0.9.1" / "8.0.4-rc1" into a sortable vector (kicad_detect._version_tuple):
/// pieces split on `.`, `-`, `_`; leading digits or 0.
pub fn version_tuple(v: &str) -> Vec<u32> {
    let parts: Vec<u32> = v
        .split(['.', '-', '_'])
        .map(|piece| {
            let digits: String = piece.chars().take_while(|c| c.is_ascii_digit()).collect();
            digits.parse::<u32>().unwrap_or(0)
        })
        .collect();
    if parts.is_empty() {
        vec![0]
    } else {
        parts
    }
}

/// True for a real stable tag like 10.0.4 / 9.0.9.1. Drops `-rcN` prereleases and
/// KiCad's `X.99.*` nightly tags (minor component == 99).
pub fn is_stable(tag: &str) -> bool {
    if tag.is_empty() {
        return false;
    }
    let parts: Vec<&str> = tag.split('.').collect();
    if parts.len() < 2 || parts.iter().any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit())) {
        return false;
    }
    parts[1] != "99"
}

/// The newest stable tag in a GitLab tags API response body, if any.
pub fn latest_stable_from_tags(body: &[u8]) -> Option<String> {
    let tags: Value = serde_json::from_slice(body).ok()?;
    let mut stables: Vec<String> = tags
        .as_array()?
        .iter()
        .filter_map(|t| t.get("name").and_then(Value::as_str))
        .filter(|t| is_stable(t))
        .map(String::from)
        .collect();
    stables.sort_by(|a, b| version_tuple(b).cmp(&version_tuple(a)));
    stables.into_iter().next()
}

/// (version, source) where source is "gitlab" or "fallback".
pub fn latest_stable_version(fetch: Fetch) -> (String, &'static str) {
    if let Ok(body) = fetch(TAGS_URL, Duration::from_secs(10)) {
        if let Some(v) = latest_stable_from_tags(&body) {
            return (v, "gitlab");
        }
    }
    (FALLBACK_LATEST.to_string(), "fallback")
}

pub fn download_url(version: &str) -> String {
    DL_URL.replace("{v}", version)
}

/// The server's size for the installer, redirects followed. The installer phase
/// compares a cached or downloaded file against this so a dropped connection cannot
/// pass as a complete download. HEAD first; downloads.kicad.org answers 405 to HEAD,
/// so the fallback is a one-byte ranged GET and the total in Content-Range (the
/// s3.cern.ch mirror honours ranges). None when both probes fail.
#[cfg(not(feature = "net"))]
pub fn remote_installer_size(_url: &str, _timeout: Duration) -> Option<u64> {
    None
}

#[cfg(feature = "net")]
pub fn remote_installer_size(url: &str, timeout: Duration) -> Option<u64> {
    let head = ureq::head(url).header("User-Agent", UA).config().timeout_global(Some(timeout)).build().call();
    if let Ok(resp) = head {
        if let Some(n) = resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|s| s.trim().parse::<u64>().ok()) {
            return Some(n);
        }
    }
    let resp = ureq::get(url)
        .header("User-Agent", UA)
        .header("Range", "bytes=0-0")
        .config()
        .timeout_global(Some(timeout))
        .build()
        .call()
        .ok()?;
    if resp.status().as_u16() == 206 {
        return content_range_total(resp.headers().get("content-range")?.to_str().ok()?);
    }
    // A server that ignores Range answers 200 with the whole body; its Content-Length is the size.
    resp.headers().get("content-length")?.to_str().ok()?.trim().parse().ok()
}

/// "bytes 0-0/967765696" -> 967765696.
pub fn content_range_total(value: &str) -> Option<u64> {
    value.trim().rsplit('/').next()?.trim().parse().ok().filter(|n: &u64| *n > 0)
}

/// Does this KiCad version carry the IPC API our routing verbs need?
pub fn ipc_api_available(version: &str) -> bool {
    version_tuple(version) >= IPC_MIN.to_vec()
}

pub const HINT: &str = "Run kicad_upgrade '{}' to install the latest (downloads the OFFICIAL downloads.kicad.org installer + silent /S install, NOT winget, which lags). Pass {\"version\":\"X.Y.Z\"} to pin a specific build.";

/// kicad_check_for_updates: is a newer KiCad available, and does the current one have the IPC API?
/// `current` is the installed kicad-cli X.Y.Z (None when KiCad is not installed);
/// `installer_bytes` is an optional HEAD probe result to report as downloadBytes.
pub fn check_for_updates(current: Option<&str>, fetch: Fetch, installer_bytes: Option<Option<u64>>) -> Value {
    let (latest, src) = latest_stable_version(fetch);
    let cur_t = current.map(version_tuple).unwrap_or_else(|| vec![0]);
    let lat_t = version_tuple(&latest);
    let upgrade_available = current.is_none() || lat_t > cur_t;
    let ipc = current.map(ipc_api_available).unwrap_or(false);
    let rec = match current {
        None => format!("KiCad isn't installed. Run kicad_upgrade to install {latest} from the official downloads.kicad.org installer."),
        Some(c) if upgrade_available && !ipc => {
            format!("Your KiCad {c} predates the modern IPC / kipy automation API: upgrade to {latest} to unlock it (plus the latest fixes). Run kicad_upgrade.")
        }
        Some(c) if upgrade_available => format!("KiCad {latest} is available (you're on {c}). Run kicad_upgrade to update."),
        Some(c) => format!("KiCad {c} is up to date (latest stable is {latest})."),
    };
    let mut out = json!({
        "success": true,
        "currentVersion": current,
        "latestVersion": latest,
        "latestSource": src,
        "upgradeAvailable": upgrade_available,
        "ipcApiAvailable": ipc,
        "downloadUrl": download_url(&latest),
        "recommendation": rec,
        "_hint": HINT,
    });
    if let Some(bytes) = installer_bytes {
        out["downloadBytes"] = json!(bytes);
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    const TAGS: &str = r#"[{"name":"10.99.0-rc1"},{"name":"10.0.6"},{"name":"10.0.6-rc1"},{"name":"9.0.9.1"},{"name":"10.0.10"},{"name":"10.99.5"},{"name":"v1"}]"#;

    #[test]
    fn version_tuples_sort_like_python() {
        assert_eq!(version_tuple("10.0"), vec![10, 0]);
        assert_eq!(version_tuple("9.0.9.1"), vec![9, 0, 9, 1]);
        assert_eq!(version_tuple("8.0.4-rc1"), vec![8, 0, 4, 0]);
        assert_eq!(version_tuple(""), vec![0]);
        assert!(version_tuple("10.0.10") > version_tuple("10.0.6"));
        assert!(version_tuple("10.0") < version_tuple("10.0.5"));
        assert!(ipc_api_available("10.0.6"));
        assert!(ipc_api_available("10.0.1"));
        assert!(!ipc_api_available("10.0.0"));
        assert!(!ipc_api_available("9.0.9"));
    }

    #[test]
    fn stable_tags_only() {
        assert!(is_stable("10.0.4"));
        assert!(is_stable("9.0.9.1"));
        assert!(!is_stable("10.0.4-rc1"));
        assert!(!is_stable("10.99.0"));
        assert!(!is_stable("v10"));
        assert!(!is_stable("10"));
        assert!(!is_stable(""));
        assert_eq!(latest_stable_from_tags(TAGS.as_bytes()), Some("10.0.10".into()));
        assert_eq!(latest_stable_from_tags(b"[]"), None);
        assert_eq!(latest_stable_from_tags(b"not json"), None);
    }

    #[test]
    fn latest_falls_back_when_offline() {
        let offline = |_: &str, _: Duration| Err::<Vec<u8>, String>("no network in tests".into());
        assert_eq!(latest_stable_version(&offline), (FALLBACK_LATEST.to_string(), "fallback"));
        let online = |url: &str, _: Duration| {
            assert_eq!(url, TAGS_URL);
            Ok::<Vec<u8>, String>(TAGS.as_bytes().to_vec())
        };
        assert_eq!(latest_stable_version(&online), ("10.0.10".to_string(), "gitlab"));
        assert_eq!(download_url("10.0.6"), "https://downloads.kicad.org/kicad/windows/explore/stable/download/kicad-10.0.6-x86_64.exe");
    }

    #[test]
    fn content_range_totals() {
        assert_eq!(content_range_total("bytes 0-0/967765696"), Some(967765696));
        assert_eq!(content_range_total(" bytes 0-0/12 "), Some(12));
        assert_eq!(content_range_total("bytes */0"), None);
        assert_eq!(content_range_total("bytes 0-0/*"), None);
        assert_eq!(content_range_total(""), None);
    }

    #[test]
    fn check_for_updates_shapes() {
        let online = |_: &str, _: Duration| Ok::<Vec<u8>, String>(TAGS.as_bytes().to_vec());
        let r = check_for_updates(Some("10.0.6"), &online, None);
        assert_eq!(r["success"], json!(true));
        assert_eq!(r["currentVersion"], json!("10.0.6"));
        assert_eq!(r["latestVersion"], json!("10.0.10"));
        assert_eq!(r["latestSource"], json!("gitlab"));
        assert_eq!(r["upgradeAvailable"], json!(true));
        assert_eq!(r["ipcApiAvailable"], json!(true));
        assert!(r["recommendation"].as_str().unwrap().starts_with("KiCad 10.0.10 is available (you're on 10.0.6)"));
        assert!(r.get("downloadBytes").is_none());

        let r = check_for_updates(Some("10.0.10"), &online, Some(Some(1234)));
        assert_eq!(r["upgradeAvailable"], json!(false));
        assert!(r["recommendation"].as_str().unwrap().contains("up to date"));
        assert_eq!(r["downloadBytes"], json!(1234));

        let r = check_for_updates(Some("9.0.9"), &online, None);
        assert_eq!(r["ipcApiAvailable"], json!(false));
        assert!(r["recommendation"].as_str().unwrap().contains("predates the modern IPC"));

        let offline = |_: &str, _: Duration| Err::<Vec<u8>, String>("offline".into());
        let r = check_for_updates(None, &offline, None);
        assert_eq!(r["currentVersion"], Value::Null);
        assert_eq!(r["latestSource"], json!("fallback"));
        assert_eq!(r["upgradeAvailable"], json!(true));
        assert!(r["recommendation"].as_str().unwrap().starts_with("KiCad isn't installed"));
        assert_eq!(r["downloadUrl"], json!(download_url(FALLBACK_LATEST)));
    }
}