use std::fmt;
use super::error::CassError;
pub const REQUIRED_CONTRACT_VERSION: &str = "1";
pub const REQUIRED_API_VERSION: u32 = 1;
pub const REQUIRED_CAPABILITIES: &[&str] = &[
"api_version_command",
"expand_command",
"field_selection",
"introspect_command",
"json_output",
"request_id",
"robot_meta",
"status_command",
"timeout",
"view_command",
];
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CassContract {
crate_version: String,
api_version: u32,
contract_version: String,
capabilities: Vec<String>,
}
impl CassContract {
#[must_use]
pub fn new(
crate_version: impl Into<String>,
api_version: u32,
contract_version: impl Into<String>,
capabilities: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
let mut capabilities: Vec<String> = capabilities
.into_iter()
.map(Into::into)
.map(|cap| cap.trim().to_owned())
.filter(|cap| !cap.is_empty())
.collect();
capabilities.sort();
capabilities.dedup();
Self {
crate_version: crate_version.into(),
api_version,
contract_version: contract_version.into(),
capabilities,
}
}
#[must_use]
pub fn crate_version(&self) -> &str {
self.crate_version.as_str()
}
#[must_use]
pub const fn api_version(&self) -> u32 {
self.api_version
}
#[must_use]
pub fn contract_version(&self) -> &str {
self.contract_version.as_str()
}
#[must_use]
pub fn capabilities(&self) -> &[String] {
self.capabilities.as_slice()
}
#[must_use]
pub fn has_capability(&self, name: &str) -> bool {
self.capabilities.iter().any(|cap| cap == name)
}
#[must_use]
pub fn missing_required_capabilities(&self) -> Vec<&'static str> {
REQUIRED_CAPABILITIES
.iter()
.copied()
.filter(|name| !self.has_capability(name))
.collect()
}
pub fn ensure_compatible(&self) -> Result<(), CassError> {
if self.api_version != REQUIRED_API_VERSION {
return Err(CassError::ContractMismatch {
required: format!("api_version={REQUIRED_API_VERSION}"),
observed: format!("api_version={}", self.api_version),
});
}
if self.contract_version != REQUIRED_CONTRACT_VERSION {
return Err(CassError::ContractMismatch {
required: format!("contract_version={REQUIRED_CONTRACT_VERSION}"),
observed: format!("contract_version={}", self.contract_version),
});
}
Ok(())
}
}
impl fmt::Display for CassContract {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"cass crate={} api={} contract={} capabilities={}",
self.crate_version,
self.api_version,
self.contract_version,
self.capabilities.join(",")
)
}
}
#[cfg(test)]
mod tests {
use super::{
CassContract, REQUIRED_API_VERSION, REQUIRED_CAPABILITIES, REQUIRED_CONTRACT_VERSION,
};
const API_VERSION_FIXTURE: &str = include_str!("../../tests/fixtures/cass/api_version.v1.json");
const CAPABILITIES_FIXTURE: &str =
include_str!("../../tests/fixtures/cass/capabilities.v1.json");
type TestResult = Result<(), String>;
fn good_contract() -> CassContract {
CassContract::new(
"0.3.0",
REQUIRED_API_VERSION,
REQUIRED_CONTRACT_VERSION,
REQUIRED_CAPABILITIES.iter().copied(),
)
}
fn parse_fixture(source: &str, fixture_name: &str) -> Result<serde_json::Value, String> {
serde_json::from_str(source)
.map_err(|error| format!("failed to parse {fixture_name}: {error}"))
}
fn fixture_features(value: &serde_json::Value) -> Result<Vec<&str>, String> {
let features = value
.get("features")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "capabilities fixture must contain a features array".to_string())?;
let mut result = Vec::with_capacity(features.len());
for feature in features {
match feature.as_str() {
Some(feature) => result.push(feature),
None => return Err("capabilities fixture feature must be a string".to_string()),
}
}
Ok(result)
}
#[test]
fn capability_list_is_sorted_and_deduped() {
let contract = CassContract::new(
"0.3.0",
1,
"1",
["search", "json_output", "search", "robot_meta"],
);
assert_eq!(
contract.capabilities(),
&[
"json_output".to_owned(),
"robot_meta".to_owned(),
"search".to_owned()
]
);
}
#[test]
fn capability_list_drops_blanks_and_trims() {
let contract = CassContract::new("0.3.0", 1, "1", ["", " ", " search "]);
assert_eq!(contract.capabilities(), &["search".to_owned()]);
}
#[test]
fn ensure_compatible_accepts_pinned_versions() -> TestResult {
good_contract()
.ensure_compatible()
.map_err(|error| error.to_string())
}
#[test]
fn ensure_compatible_rejects_api_mismatch() -> TestResult {
let contract = CassContract::new("0.3.0", 2, REQUIRED_CONTRACT_VERSION, ["json_output"]);
let error = match contract.ensure_compatible() {
Ok(()) => return Err("api mismatch must fail".to_string()),
Err(error) => error,
};
assert_eq!(error.kind_str(), "external_adapter_schema_mismatch");
assert!(error.to_string().contains("api_version=1"));
assert!(error.to_string().contains("api_version=2"));
Ok(())
}
#[test]
fn ensure_compatible_rejects_contract_version_mismatch() -> TestResult {
let contract = CassContract::new("0.3.0", REQUIRED_API_VERSION, "2", ["json_output"]);
let error = match contract.ensure_compatible() {
Ok(()) => return Err("contract version mismatch must fail".to_string()),
Err(error) => error,
};
assert_eq!(error.kind_str(), "external_adapter_schema_mismatch");
assert!(error.to_string().contains("contract_version=1"));
assert!(error.to_string().contains("contract_version=2"));
Ok(())
}
#[test]
fn missing_required_capabilities_reports_gaps_in_stable_order() {
let contract = CassContract::new("0.3.0", 1, "1", ["json_output", "request_id"]);
let missing = contract.missing_required_capabilities();
assert_eq!(
missing,
vec![
"api_version_command",
"expand_command",
"field_selection",
"introspect_command",
"robot_meta",
"status_command",
"timeout",
"view_command",
],
);
}
#[test]
fn missing_required_capabilities_is_empty_when_all_present() {
let contract = good_contract();
assert!(contract.missing_required_capabilities().is_empty());
}
#[test]
fn has_capability_is_a_membership_check() {
let contract = good_contract();
assert!(contract.has_capability("json_output"));
assert!(!contract.has_capability("not_a_real_capability"));
}
#[test]
fn display_is_round_trip_friendly_for_logs() {
let contract = CassContract::new("0.3.0", 1, "1", ["json_output", "request_id"]);
let rendered = contract.to_string();
assert!(rendered.contains("crate=0.3.0"));
assert!(rendered.contains("api=1"));
assert!(rendered.contains("contract=1"));
assert!(rendered.contains("json_output"));
assert!(rendered.contains("request_id"));
}
#[test]
fn pinned_api_version_fixture_matches_required_versions() -> TestResult {
let value = parse_fixture(API_VERSION_FIXTURE, "api_version.v1.json")?;
assert_eq!(
value.get("api_version").and_then(serde_json::Value::as_u64),
Some(u64::from(REQUIRED_API_VERSION)),
);
assert_eq!(
value
.get("contract_version")
.and_then(serde_json::Value::as_str),
Some(REQUIRED_CONTRACT_VERSION),
);
Ok(())
}
#[test]
fn pinned_capabilities_fixture_satisfies_required_features() -> TestResult {
let value = parse_fixture(CAPABILITIES_FIXTURE, "capabilities.v1.json")?;
let crate_version = value
.get("crate_version")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "capabilities fixture must contain crate_version".to_string())?;
let api_version = value
.get("api_version")
.and_then(serde_json::Value::as_u64)
.and_then(|version| u32::try_from(version).ok())
.ok_or_else(|| "capabilities fixture must contain a u32 api_version".to_string())?;
let contract_version = value
.get("contract_version")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "capabilities fixture must contain contract_version".to_string())?;
let features = fixture_features(&value)?;
let contract = CassContract::new(crate_version, api_version, contract_version, features);
contract
.ensure_compatible()
.map_err(|error| error.to_string())?;
assert!(
contract.missing_required_capabilities().is_empty(),
"fixture missing required CASS features: {:?}",
contract.missing_required_capabilities()
);
Ok(())
}
}