rcp-tools-common 0.40.0

Internal library for RCP file operation tools - shared utilities and core operations (not intended for direct use)
Documentation
// version information for protocol compatibility checking

use serde::{Deserialize, Serialize};

/// Protocol version information
///
/// Contains version information for compatibility checking between rcp and rcpd.
/// The semantic version is used for compatibility checks, while git information
/// provides additional debugging context.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProtocolVersion {
    /// Semantic version from Cargo.toml (e.g., "0.22.0")
    ///
    /// This is the primary version used for compatibility checking.
    pub semantic: String,

    /// Git describe output (e.g., "v0.21.1-7-g644da27")
    ///
    /// Optional. Provides detailed version information including:
    /// - Most recent tag
    /// - Number of commits since tag
    /// - Short commit hash
    /// - "-dirty" suffix if working tree has uncommitted changes
    #[serde(skip_serializing_if = "Option::is_none")]
    pub git_describe: Option<String>,

    /// Full git commit hash
    ///
    /// Optional. Useful for exact build identification and debugging.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub git_hash: Option<String>,
}

/// The remote protocol/process-contract revision, carried INSIDE [`ProtocolVersion::semantic`] as
/// semver build metadata (`0.38.0+w1`) rather than as a separate field — deliberately, for symmetry:
/// the compatibility check is a string equality on `semantic`, so a build predating this constant
/// rejects a current one exactly as a current build rejects it ("0.38.0" != "0.38.0+w1"), no
/// matter which side was upgraded first. A separate field would protect only the new side — an old
/// parser ignores unknown JSON fields and could accept a changed serialized or spawn contract.
///
/// Bump this on any wire-visible change to `remote/src/protocol` OR a version-sensitive rcpd spawn
/// argument change whenever the crate version does not also change. The crate version alone cannot
/// tell two 0.38.0-dev builds apart, so a stale same-version `rcpd` (on `PATH` or in the deploy
/// cache) would otherwise pass compatibility and then fail — or misbehave — mid-copy. Revision 1
/// covers the 0.38.0-dev `WireAcls` reshape; revision 2 the 0.39.0-dev `ExtendedMetadataCapture`
/// gaining `root_acl_notice`; revision 3 covers the first max-files-in-flight rcpd spawn contract;
/// revision 4 covers source-owned concurrency, its internal overrides, and readiness fields;
/// revision 5 covers the final daemon CLI contract: removing its unreachable explicit-unlimited
/// override and requiring a positive remote-copy connection timeout; revision 6 makes the public
/// `--max-files-in-flight=unlimited` spelling part of the daemon spawn contract.
pub const WIRE_REVISION: u32 = 6;

impl ProtocolVersion {
    /// Get the current protocol version
    ///
    /// Reads version information from environment variables set at compile time
    /// by build.rs. The semantic version is always available, while git information
    /// may be absent if the build was done without git available.
    pub fn current() -> Self {
        Self {
            semantic: format!("{}+w{}", env!("CARGO_PKG_VERSION"), WIRE_REVISION),
            git_describe: option_env!("RCP_GIT_DESCRIBE").map(String::from),
            git_hash: option_env!("RCP_GIT_HASH").map(String::from),
        }
    }

    /// The crate version alone — `semantic` with the `+w<revision>` build metadata stripped.
    /// This is what user-facing remedies quote (`cargo install --version <this>`); the wire
    /// revision is not a cargo version.
    pub fn crate_version(&self) -> &str {
        self.semantic.split('+').next().unwrap_or(&self.semantic)
    }

    /// Check if this version is compatible with another version
    ///
    /// Currently implements exact version matching: versions are compatible
    /// only if their semantic versions match exactly.
    ///
    /// # Examples
    ///
    /// ```
    /// use common::version::ProtocolVersion;
    ///
    /// let v1 = ProtocolVersion {
    ///     semantic: "0.22.0".to_string(),
    ///     git_describe: None,
    ///     git_hash: None,
    /// };
    ///
    /// let v2 = ProtocolVersion {
    ///     semantic: "0.22.0".to_string(),
    ///     git_describe: Some("v0.21.1-7-g644da27".to_string()),
    ///     git_hash: None,
    /// };
    ///
    /// assert!(v1.is_compatible_with(&v2));
    /// ```
    pub fn is_compatible_with(&self, other: &Self) -> bool {
        // exact version match for now
        // in the future, we might allow minor version skew (e.g., 0.22.x compatible with 0.22.y).
        // `semantic` embeds the remote compatibility revision as build metadata (see
        // wire revision), so this one string equality also rejects two same-release builds
        // straddling a protocol/process-contract change — in BOTH directions, including a peer
        // built before the revision existed.
        self.semantic == other.semantic
    }

    /// The deployed-binary cache filename component for this version: the semantic string with
    /// its `+` made filename-friendly (`0.38.0+w1` → `0.38.0-w1`).
    ///
    /// One string for BOTH the discovery probe and the deploy target, so an incompatible
    /// same-release cached binary is never found (its filename carries the old revision) and a
    /// fresh deploy never overwrites a binary another compatibility revision still resolves to.
    pub fn cache_tag(&self) -> String {
        self.semantic.replace('+', "-")
    }

    /// Get a human-readable version string
    ///
    /// Returns the semantic version, optionally including git describe information
    /// if available.
    ///
    /// # Examples
    ///
    /// ```
    /// use common::version::ProtocolVersion;
    ///
    /// let v = ProtocolVersion {
    ///     semantic: "0.22.0".to_string(),
    ///     git_describe: Some("v0.21.1-7-g644da27".to_string()),
    ///     git_hash: None,
    /// };
    ///
    /// assert_eq!(v.display(), "0.22.0 (v0.21.1-7-g644da27)");
    /// ```
    pub fn display(&self) -> String {
        if let Some(ref git_describe) = self.git_describe {
            format!("{} ({})", self.semantic, git_describe)
        } else {
            self.semantic.clone()
        }
    }

    /// Serialize to JSON string
    ///
    /// # Errors
    ///
    /// Returns an error if JSON serialization fails.
    pub fn to_json(&self) -> anyhow::Result<String> {
        serde_json::to_string(self)
            .map_err(|e| anyhow::anyhow!("failed to serialize version: {:#}", e))
    }

    /// Deserialize from JSON string
    ///
    /// # Errors
    ///
    /// Returns an error if JSON deserialization fails or the format is invalid.
    pub fn from_json(json: &str) -> anyhow::Result<Self> {
        serde_json::from_str(json)
            .map_err(|e| anyhow::anyhow!("failed to parse version JSON: {:#}", e))
    }
}

impl std::fmt::Display for ProtocolVersion {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.display())
    }
}

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

    #[cfg_attr(rcp_nix_sandbox, ignore = "Nix source build has no Git metadata")]
    #[test]
    fn test_current_version() {
        let version = ProtocolVersion::current();
        // semantic version should always be available
        assert!(!version.semantic.is_empty());
        // git info should be available when building from git repository
        // this test will catch if build.rs is not activated in Cargo.toml
        assert!(
            version.git_describe.is_some(),
            "git_describe should be populated by build.rs (check that build.rs is activated in Cargo.toml)"
        );
        assert!(
            version.git_hash.is_some(),
            "git_hash should be populated by build.rs (check that build.rs is activated in Cargo.toml)"
        );
    }

    #[test]
    fn test_exact_version_compatibility() {
        let v1 = ProtocolVersion {
            semantic: "0.22.0".to_string(),
            git_describe: None,
            git_hash: None,
        };

        let v2 = ProtocolVersion {
            semantic: "0.22.0".to_string(),
            git_describe: Some("v0.21.1-7-g644da27".to_string()),
            git_hash: Some("644da27".to_string()),
        };

        let v3 = ProtocolVersion {
            semantic: "0.21.0".to_string(),
            git_describe: None,
            git_hash: None,
        };

        // same semantic version should be compatible
        assert!(v1.is_compatible_with(&v2));
        assert!(v2.is_compatible_with(&v1));

        // different semantic versions should not be compatible
        assert!(!v1.is_compatible_with(&v3));
        assert!(!v3.is_compatible_with(&v1));
    }

    #[test]
    fn test_display() {
        let v1 = ProtocolVersion {
            semantic: "0.22.0".to_string(),
            git_describe: None,
            git_hash: None,
        };
        assert_eq!(v1.display(), "0.22.0");

        let v2 = ProtocolVersion {
            semantic: "0.22.0".to_string(),
            git_describe: Some("v0.21.1-7-g644da27".to_string()),
            git_hash: None,
        };
        assert_eq!(v2.display(), "0.22.0 (v0.21.1-7-g644da27)");
    }

    #[test]
    fn wire_revision_mismatch_is_incompatible_in_both_directions() {
        let a = ProtocolVersion::current();
        assert!(a.is_compatible_with(&a.clone()));
        assert!(
            a.semantic.contains("+w"),
            "the compatibility revision must ride inside `semantic`, where even a pre-revision peer's string comparison sees it"
        );
        let mut b = a.clone();
        b.semantic = format!("{}+w{}", a.crate_version(), WIRE_REVISION + 1);
        assert!(
            !a.is_compatible_with(&b),
            "same release with a different remote protocol/process contract must be rejected"
        );
        // a build predating the revision reports the bare crate version; the SAME string
        // equality both sides have always used rejects it — symmetry is the point
        let legacy = ProtocolVersion {
            semantic: a.crate_version().to_string(),
            git_describe: None,
            git_hash: None,
        };
        assert!(!a.is_compatible_with(&legacy));
        assert!(!legacy.is_compatible_with(&a));
        assert_eq!(a.cache_tag(), a.semantic.replace('+', "-"));
    }

    #[test]
    fn current_process_contract_rejects_revision_five_daemons() {
        let current = ProtocolVersion::current();
        let revision_five = ProtocolVersion {
            semantic: format!("{}+w5", current.crate_version()),
            git_describe: None,
            git_hash: None,
        };
        assert!(!current.is_compatible_with(&revision_five));
        assert!(!revision_five.is_compatible_with(&current));
        assert_eq!(
            current.cache_tag(),
            format!("{}-w6", current.crate_version())
        );
        assert_ne!(current.cache_tag(), revision_five.cache_tag());
    }

    #[test]
    fn test_json_serialization() {
        let v = ProtocolVersion {
            semantic: "0.22.0".to_string(),
            git_describe: Some("v0.21.1-7-g644da27".to_string()),
            git_hash: Some("644da27abc".to_string()),
        };

        let json = v.to_json().unwrap();
        let parsed = ProtocolVersion::from_json(&json).unwrap();

        assert_eq!(v, parsed);
    }

    #[test]
    fn test_json_deserialization_without_git() {
        let json = r#"{"semantic":"0.22.0"}"#;
        let v = ProtocolVersion::from_json(json).unwrap();

        assert_eq!(v.semantic, "0.22.0");
        assert!(v.git_describe.is_none());
        assert!(v.git_hash.is_none());
    }
}