cartog 0.34.0

Code graph indexer for LLM coding agents. Map your codebase, navigate by graph.
Documentation
//! Tests for version reporting and update-availability checks.

use std::ffi::OsStr;
use std::path::{Path, PathBuf};

use crate::commands::self_cmd::*;

#[test]
fn render_human_shows_describe_value_separately_from_version() {
    let info = VersionInfo {
        version: "0.29.1".into(),
        build_version: "v0.29.1-2-g3e2822c".into(),
        target: "x".into(),
        install_source: "dev".into(),
        last_update_check: None,
        pending_update: None,
    };
    let out = info.render_human();
    // The version line carries the bare semver, the describe line its own value
    // — assert on the values, not the column padding.
    assert!(out.lines().any(|l| l == "cartog 0.29.1"));
    assert!(out
        .lines()
        .any(|l| l.trim_start().starts_with("describe:") && l.contains("v0.29.1-2-g3e2822c")));
}

// ── resolve_install_source ────────────────────────────────────────

#[test]
fn resolve_release_tarball_short_circuits_runtime_detection() {
    // A release-built binary stays "release-tarball" no matter where
    // it sits on disk.
    assert_eq!(
        resolve_install_source(
            "release-tarball",
            Some(Path::new("/home/user/.cargo/bin/cartog")),
            None,
        ),
        "release-tarball",
    );
}

#[test]
fn resolve_dev_binary_in_cargo_home_classified_as_cargo() {
    let cargo_home = Path::new("/home/user/.cargo");
    let bin = Path::new("/home/user/.cargo/bin/cartog");
    assert_eq!(
        resolve_install_source("dev", Some(bin), Some(cargo_home)),
        "cargo",
    );
}

#[test]
fn resolve_dev_binary_outside_cargo_classified_as_dev() {
    assert_eq!(
        resolve_install_source(
            "dev",
            Some(Path::new("/usr/local/bin/cartog")),
            Some(Path::new("/home/user/.cargo")),
        ),
        "dev",
    );
}

#[test]
fn resolve_dev_falls_back_to_path_heuristic_when_cargo_home_unset() {
    // Catches `~/.cargo/bin` even without CARGO_HOME (common on macOS).
    assert_eq!(
        resolve_install_source(
            "dev",
            Some(Path::new("/Users/alice/.cargo/bin/cartog")),
            None,
        ),
        "cargo",
    );
}

#[test]
fn resolve_dev_no_binary_path_stays_dev() {
    assert_eq!(resolve_install_source("dev", None, None), "dev");
}

// ── parse_release_tag / is_stable_semver ──────────────────────────

#[test]
fn parse_release_tag_strips_v_prefix() {
    assert_eq!(
        parse_release_tag(r#"{"tag_name":"v0.14.0"}"#),
        Some("0.14.0".to_string()),
    );
    assert_eq!(
        parse_release_tag(r#"{"tag_name":"0.14.0"}"#),
        Some("0.14.0".to_string()),
        "leading v is optional",
    );
}

#[test]
fn parse_release_tag_rejects_prereleases() {
    for tag in [
        r#"{"tag_name":"v0.14.0-rc.1"}"#,
        r#"{"tag_name":"v0.14.0-alpha"}"#,
        r#"{"tag_name":"v0.14.0-nightly.42"}"#,
    ] {
        assert_eq!(parse_release_tag(tag), None, "must reject {tag}");
    }
}

#[test]
fn parse_release_tag_rejects_malformed() {
    assert_eq!(parse_release_tag("not json at all"), None);
    assert_eq!(parse_release_tag(r#"{}"#), None, "missing tag_name");
    assert_eq!(
        parse_release_tag(r#"{"tag_name":"v0.14"}"#),
        None,
        "two-part is not stable semver",
    );
    assert_eq!(
        parse_release_tag(r#"{"tag_name":"v0.14.0.0"}"#),
        None,
        "four-part is not stable semver",
    );
    assert_eq!(
        parse_release_tag(r#"{"tag_name":"vfoo.bar.baz"}"#),
        None,
        "non-numeric components rejected",
    );
}

#[test]
fn is_stable_semver_accepts_canonical_triples() {
    assert!(is_stable_semver("0.0.0"));
    assert!(is_stable_semver("1.2.3"));
    assert!(is_stable_semver("99.0.0"));
}

#[test]
fn is_stable_semver_rejects_anything_else() {
    assert!(!is_stable_semver(""));
    assert!(!is_stable_semver("1.2"));
    assert!(!is_stable_semver("1.2.3.4"));
    assert!(!is_stable_semver("1.2.x"));
    assert!(!is_stable_semver("1..3"));
}

// ── compare_stable_versions ───────────────────────────────────────

#[test]
fn compare_stable_versions_orders_lexicographically() {
    use std::cmp::Ordering::*;
    assert_eq!(compare_stable_versions("0.13.2", "0.14.0"), Less);
    assert_eq!(compare_stable_versions("0.14.0", "0.13.2"), Greater);
    assert_eq!(compare_stable_versions("0.14.0", "0.14.0"), Equal);
    assert_eq!(compare_stable_versions("1.0.0", "0.99.99"), Greater);
    assert_eq!(compare_stable_versions("0.13.10", "0.13.2"), Greater);
}

#[test]
fn compare_stable_versions_garbage_treated_as_zero() {
    // Documented degradation: non-numeric parts become 0 instead of panicking.
    use std::cmp::Ordering::*;
    assert_eq!(compare_stable_versions("0.0.0", "abc.def.ghi"), Equal);
    assert_eq!(compare_stable_versions("0.1.0", "abc.def.ghi"), Greater);
}

// ── CheckOutcome ──────────────────────────────────────────────────

#[test]
fn check_outcome_ok_marks_outdated_when_latest_is_newer() {
    let outcome = CheckOutcome::ok("0.13.2", "0.14.0");
    assert_eq!(outcome.outdated, Some(true));
    assert_eq!(outcome.latest.as_deref(), Some("0.14.0"));
    assert_eq!(outcome.current, "0.13.2");
    assert_eq!(outcome.error, None);
}

#[test]
fn check_outcome_ok_not_outdated_when_versions_match() {
    let outcome = CheckOutcome::ok("0.14.0", "0.14.0");
    assert_eq!(outcome.outdated, Some(false));
}

#[test]
fn check_outcome_ok_not_outdated_when_local_is_ahead() {
    // Pre-release dev builds (e.g. 0.15.0 mid-development) must not be
    // told to "downgrade" to a published 0.14.0.
    let outcome = CheckOutcome::ok("0.15.0", "0.14.0");
    assert_eq!(outcome.outdated, Some(false));
}

#[test]
fn check_outcome_failed_reports_error_with_null_latest() {
    let outcome = CheckOutcome::failed("0.13.2", "connection refused");
    assert_eq!(outcome.latest, None);
    assert_eq!(outcome.outdated, None);
    assert_eq!(outcome.error.as_deref(), Some("connection refused"));
}

#[test]
fn check_outcome_to_human_outdated() {
    let s = CheckOutcome::ok("0.13.2", "0.14.0").to_human();
    assert!(s.contains("update available"), "got: {s}");
    assert!(s.contains("0.13.2"));
    assert!(s.contains("0.14.0"));
}

#[test]
fn check_outcome_to_human_up_to_date() {
    let s = CheckOutcome::ok("0.14.0", "0.14.0").to_human();
    assert!(s.contains("up to date"), "got: {s}");
    assert!(s.contains("0.14.0"));
}

#[test]
fn check_outcome_to_human_failed() {
    let s = CheckOutcome::failed("0.13.2", "DNS lookup failed").to_human();
    assert!(s.contains("update check failed"), "got: {s}");
    assert!(s.contains("DNS lookup failed"));
}

#[test]
fn check_outcome_serialises_with_skip_none_keys() {
    // Failure shape must omit `latest` and `outdated`, never serialise as null.
    let outcome = CheckOutcome::failed("0.13.2", "boom");
    let json = serde_json::to_string(&outcome).unwrap();
    assert!(json.contains(r#""error":"boom""#));
    assert!(json.contains(r#""current":"0.13.2""#));
    assert!(
        !json.contains("latest"),
        "latest must be skipped, got: {json}"
    );
    assert!(
        !json.contains("outdated"),
        "outdated must be skipped, got: {json}"
    );
}

#[test]
fn check_outcome_serialises_success_with_all_fields() {
    let outcome = CheckOutcome::ok("0.13.2", "0.14.0");
    let json = serde_json::to_string(&outcome).unwrap();
    assert!(json.contains(r#""current":"0.13.2""#));
    assert!(json.contains(r#""latest":"0.14.0""#));
    assert!(json.contains(r#""outdated":true"#));
    assert!(!json.contains("error"), "error must be skipped on success");
}

// ── looks_like_cargo_install ─────────────────────────────────────

#[test]
fn cargo_install_detected_via_cargo_home_prefix() {
    assert!(looks_like_cargo_install(
        Path::new("/home/u/.cargo/bin/cartog"),
        Some(Path::new("/home/u/.cargo")),
    ));
}

#[test]
fn cargo_install_detected_via_dotcargo_path_segment() {
    assert!(looks_like_cargo_install(
        Path::new("/Users/alice/.cargo/bin/cartog"),
        None,
    ));
}

#[test]
fn cargo_install_not_detected_for_unrelated_paths() {
    assert!(!looks_like_cargo_install(
        Path::new("/usr/local/bin/cartog"),
        None,
    ));
    assert!(!looks_like_cargo_install(
        Path::new("/home/u/.cargo-tools/bin/cartog"),
        None,
    ));
}

// ── plugin pin resolution for `cartog serve` ──

#[test]
fn plugin_manifest_path_prefers_explicit_over_root() {
    let explicit = OsStr::new("/tmp/explicit.json");
    let root = OsStr::new("/plugins/cartog");
    assert_eq!(
        plugin_manifest_path(Some(explicit), Some(root)),
        Some(PathBuf::from("/tmp/explicit.json"))
    );
    assert_eq!(
        plugin_manifest_path(None, Some(root)),
        Some(PathBuf::from("/plugins/cartog/.claude-plugin/plugin.json"))
    );
}

#[test]
fn plugin_manifest_path_treats_empty_as_unset() {
    let empty = OsStr::new("");
    assert_eq!(plugin_manifest_path(Some(empty), Some(empty)), None);
    assert_eq!(plugin_manifest_path(None, None), None);
    assert_eq!(
        plugin_manifest_path(Some(empty), Some(OsStr::new("/r"))),
        Some(PathBuf::from("/r/.claude-plugin/plugin.json"))
    );
}

#[test]
fn describe_plugin_pin_flags_only_an_older_binary() {
    assert!(describe_plugin_pin("0.34.0", "0.33.0", "release-tarball", false).behind);
    assert!(!describe_plugin_pin("0.33.0", "0.33.0", "release-tarball", false).behind);
    // Ahead of the pin (deliberate manual install) is not drift.
    assert!(!describe_plugin_pin("0.33.0", "0.34.0", "release-tarball", false).behind);
}

#[test]
fn describe_plugin_pin_gives_cargo_users_the_cargo_command() {
    assert_eq!(
        describe_plugin_pin("0.34.0", "0.33.0", "cargo", false).update_command,
        "cargo install cartog --force"
    );
    for source in ["release-tarball", "dev"] {
        assert_eq!(
            describe_plugin_pin("0.34.0", "0.33.0", source, false).update_command,
            "/cartog-install",
            "{source}"
        );
    }
}

#[test]
fn describe_plugin_pin_suppresses_the_notice_when_update_checks_are_disabled() {
    // CARTOG_NO_UPDATE_CHECK silences the two MCP notice surfaces, exactly as it
    // silences `doctor`'s version row and the SessionStart notice.
    let quiet = describe_plugin_pin("0.34.0", "0.33.0", "release-tarball", true);
    assert!(
        !quiet.behind,
        "a disabled update check must not report drift"
    );
    // The pin itself survives: `cartog_update` still arms `--to` the pin when the
    // user explicitly asks for an update.
    assert_eq!(quiet.version, "0.34.0");
    assert_eq!(quiet.update_command, "/cartog-install");
}