//! Single source of truth for the running build's version string.
//!
//! Why this module exists: ten different files used to inline
//! `env!("CARGO_PKG_VERSION")` so CI had no way to stamp a richer version
//! (commit sha, dirty bit, workflow timestamp) without hunting every call
//! site. The accessors here read compile-time env vars populated by
//! `build.rs`, and every caller in the workspace routes through them.
//!
//! ## Accessors
//!
//! * [`full`] returns `"0.1.0"` or `"0.1.0+abcd1234"` or
//!   `"0.1.0+abcd1234.dirty"` depending on what the build script saw.
//! * [`semver`] returns the bare Cargo package version. MCP `clientInfo`
//!   uses this because servers parse it as a SemVer.
//! * [`git_sha`], [`build_timestamp`], [`is_dirty`] expose the individual
//!   build facts so JSON reporters can surface them as their own fields.
//! * [`info`] returns the [`Version`] struct that the (future)
//!   `mcptest version --json` subcommand renders.

use serde::{Deserialize, Serialize};

/// Cargo package version, e.g. `"0.1.0"`. Always present.
const SEMVER: &str = env!("CARGO_PKG_VERSION");

/// Full version string. `build.rs` resolves this to `<semver>` when no
/// sha is available, `<semver>+<sha>` when CI stamped a sha, and
/// `<semver>+<sha>.dirty` when the local working tree was modified.
const FULL: &str = env!("MCPTEST_VERSION_FULL");

/// Short git sha if `build.rs` discovered one (CI env var or `git
/// rev-parse HEAD` in a checkout). Empty string means "unknown".
const GIT_SHA: &str = env!("MCPTEST_BUILD_SHA_RESOLVED");

/// RFC 3339 timestamp when CI built the binary. Empty for local builds.
const BUILD_TIMESTAMP: &str = env!("MCPTEST_BUILD_TIMESTAMP_RESOLVED");

/// `"1"` when CI explicitly stamped a dirty bit or the local working
/// tree had unstaged changes at build time; otherwise `"0"`.
const DIRTY_FLAG: &str = env!("MCPTEST_BUILD_DIRTY_RESOLVED");

/// Compile-time target triple (e.g. `x86_64-apple-darwin`).
const BUILD_TARGET: &str = env!("MCPTEST_BUILD_TARGET");

/// The full version string (e.g. `"0.1.0+abcd1234.dirty"`).
///
/// Use this in user-visible output. The MCP `clientInfo.version` field
/// is the one exception, see [`semver`].
pub fn full() -> &'static str {
    if FULL.is_empty() {
        SEMVER
    } else {
        FULL
    }
}

/// Bare Cargo package version (e.g. `"0.1.0"`).
///
/// MCP servers parse `clientInfo.version` as SemVer, so the protocol
/// client uses this form rather than [`full`].
pub fn semver() -> &'static str {
    SEMVER
}

/// Short git sha if `build.rs` resolved one.
pub fn git_sha() -> Option<&'static str> {
    if GIT_SHA.is_empty() {
        None
    } else {
        Some(GIT_SHA)
    }
}

/// RFC 3339 build timestamp if CI stamped one.
pub fn build_timestamp() -> Option<&'static str> {
    if BUILD_TIMESTAMP.is_empty() {
        None
    } else {
        Some(BUILD_TIMESTAMP)
    }
}

/// `true` when the build script saw a dirty working tree (or CI set
/// `MCPTEST_BUILD_DIRTY=1`).
pub fn is_dirty() -> bool {
    DIRTY_FLAG == "1"
}

/// Rust compiler version this binary was built with. Falls back to a
/// constant when the env var is missing (older toolchains do not always
/// set `RUSTC_VERSION`); `rustc -V` plumbing is out of scope.
pub fn rust_version() -> &'static str {
    // `CARGO_PKG_RUST_VERSION` carries the `rust-version` field from
    // Cargo.toml. It is the most stable handle we have without invoking
    // rustc at build time.
    option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("unknown")
}

/// Target triple the binary was compiled for.
pub fn target() -> &'static str {
    if BUILD_TARGET.is_empty() {
        "unknown"
    } else {
        BUILD_TARGET
    }
}

/// Structured envelope returned by [`info`] and rendered by the future
/// `mcptest version --json` subcommand.
///
/// Fields mirror the accessors above so a caller can scrape one stable
/// JSON shape rather than wiring up six env reads of its own. Serializes
/// with snake_case keys.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Version {
    /// Bare Cargo package version.
    pub semver: String,
    /// Full version string, including sha and dirty marker when known.
    pub full: String,
    /// Short git sha or `None`.
    pub git_sha: Option<String>,
    /// RFC 3339 build timestamp or `None`.
    pub build_timestamp: Option<String>,
    /// Dirty working tree flag.
    pub dirty: bool,
    /// `rust-version` from Cargo.toml at compile time.
    pub rust_version: String,
    /// Target triple.
    pub target: String,
}

/// Collect every accessor into one struct.
///
/// The shape is intentionally flat so JSON consumers do not need to walk
/// nested objects.
pub fn info() -> Version {
    Version {
        semver: semver().to_string(),
        full: full().to_string(),
        git_sha: git_sha().map(str::to_string),
        build_timestamp: build_timestamp().map(str::to_string),
        dirty: is_dirty(),
        rust_version: rust_version().to_string(),
        target: target().to_string(),
    }
}

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

    #[test]
    fn semver_is_non_empty() {
        assert!(!semver().is_empty());
    }

    #[test]
    fn full_is_non_empty() {
        assert!(!full().is_empty());
    }

    #[test]
    fn full_starts_with_semver() {
        // `full` is `<semver>` plus optional `+<sha>[.dirty]`. The prefix
        // contract is what every downstream parser relies on.
        assert!(full().starts_with(semver()), "full={}", full());
    }

    #[test]
    fn info_round_trips_through_json() {
        let v = info();
        let s = serde_json::to_string(&v).expect("serialize");
        let back: Version = serde_json::from_str(&s).expect("deserialize");
        assert_eq!(v, back);
    }

    #[test]
    fn rust_version_is_non_empty() {
        assert!(!rust_version().is_empty());
    }

    #[test]
    fn target_is_non_empty() {
        assert!(!target().is_empty());
    }

    #[test]
    fn dirty_flag_matches_full_string() {
        // Invariant: when `is_dirty()` returns true, the full string must
        // carry the `.dirty` suffix so callers can spot the bit visually
        // without scraping a separate field.
        if is_dirty() {
            assert!(full().ends_with(".dirty"), "full={}", full());
        }
    }

    #[test]
    fn git_sha_appears_in_full_when_present() {
        if let Some(sha) = git_sha() {
            assert!(full().contains(sha), "sha={} full={}", sha, full());
        }
    }
}
