Skip to main content

opcda_bridge/
compatibility.rs

1//! Protocol compatibility discovery and evaluation.
2
3use crate::{Capabilities, Error, Result};
4use opcda_bridge_proto::bridge as proto;
5use serde::Serialize;
6use std::fmt;
7
8/// A protocol surface whose versions are negotiated independently.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
10#[serde(rename_all = "snake_case")]
11pub enum CompatibilityFeature {
12    Core,
13    Namespace,
14    IndexedSearch,
15}
16
17impl CompatibilityFeature {
18    fn from_proto(value: i32) -> Result<Self> {
19        match proto::ProtocolFeatureKind::try_from(value).map_err(|_| {
20            Error::Protocol(format!("gateway returned unknown protocol feature {value}"))
21        })? {
22            proto::ProtocolFeatureKind::Core => Ok(Self::Core),
23            proto::ProtocolFeatureKind::Namespace => Ok(Self::Namespace),
24            proto::ProtocolFeatureKind::IndexedSearch => Ok(Self::IndexedSearch),
25            proto::ProtocolFeatureKind::Unspecified => Err(Error::Protocol(
26                "gateway returned an unspecified protocol feature".into(),
27            )),
28        }
29    }
30}
31
32impl fmt::Display for CompatibilityFeature {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.write_str(match self {
35            Self::Core => "core",
36            Self::Namespace => "namespace",
37            Self::IndexedSearch => "indexed-search",
38        })
39    }
40}
41
42/// Inclusive protocol version range supported by one component.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
44pub struct ProtocolVersionRange {
45    pub min: u32,
46    pub max: u32,
47}
48
49impl ProtocolVersionRange {
50    /// Construct a range, rejecting reversed bounds.
51    pub const fn new(min: u32, max: u32) -> Option<Self> {
52        if min > max {
53            None
54        } else {
55            Some(Self { min, max })
56        }
57    }
58
59    /// Construct a range containing exactly one protocol version.
60    pub const fn exact(version: u32) -> Self {
61        Self {
62            min: version,
63            max: version,
64        }
65    }
66
67    /// Return whether two ranges share at least one version.
68    pub const fn overlaps(self, other: Self) -> bool {
69        self.min <= other.max && other.min <= self.max
70    }
71
72    const fn negotiated_version(self, other: Self) -> Option<u32> {
73        if self.overlaps(other) {
74            Some(if self.min > other.min {
75                self.min
76            } else {
77                other.min
78            })
79        } else {
80            None
81        }
82    }
83}
84
85/// One feature and the versions supported by a gateway.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87pub struct ProtocolFeatureSupport {
88    pub feature: CompatibilityFeature,
89    pub versions: ProtocolVersionRange,
90}
91
92/// Gateway-wide protocol information returned without contacting an OPC server.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
94pub struct GatewayInfo {
95    pub application_version: String,
96    pub compatibility_schema_version: u32,
97    pub features: Vec<ProtocolFeatureSupport>,
98}
99
100impl TryFrom<proto::GetGatewayInfoResponse> for GatewayInfo {
101    type Error = Error;
102
103    fn try_from(value: proto::GetGatewayInfoResponse) -> Result<Self> {
104        let features = value
105            .features
106            .into_iter()
107            .map(|feature| {
108                let feature_kind = CompatibilityFeature::from_proto(feature.kind)?;
109                let versions = ProtocolVersionRange::new(feature.min_version, feature.max_version)
110                    .ok_or_else(|| {
111                        Error::Protocol(format!(
112                            "gateway returned reversed {feature_kind} protocol version range"
113                        ))
114                    })?;
115                Ok(ProtocolFeatureSupport {
116                    feature: feature_kind,
117                    versions,
118                })
119            })
120            .collect::<Result<Vec<_>>>()?;
121        Ok(Self {
122            application_version: value.application_version,
123            compatibility_schema_version: value.compatibility_schema_version,
124            features,
125        })
126    }
127}
128
129/// Where a gateway compatibility profile came from.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
131#[serde(rename_all = "snake_case")]
132pub enum CompatibilitySource {
133    GatewayInfo,
134    LegacyCapabilities,
135    Unknown,
136}
137
138impl fmt::Display for CompatibilitySource {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        f.write_str(match self {
141            Self::GatewayInfo => "gateway-info",
142            Self::LegacyCapabilities => "legacy-capabilities",
143            Self::Unknown => "unknown",
144        })
145    }
146}
147
148/// A component's advertised protocol profile.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
150pub struct ProtocolProfile {
151    pub application_version: Option<String>,
152    pub source: CompatibilitySource,
153    pub features: Vec<ProtocolFeatureSupport>,
154}
155
156impl ProtocolProfile {
157    /// Build a profile from the gateway-wide handshake.
158    pub fn from_gateway_info(info: &GatewayInfo) -> Self {
159        Self {
160            application_version: Some(info.application_version.clone()),
161            source: CompatibilitySource::GatewayInfo,
162            features: info.features.clone(),
163        }
164    }
165
166    fn feature(&self, feature: CompatibilityFeature) -> Option<ProtocolVersionRange> {
167        self.features
168            .iter()
169            .find(|support| support.feature == feature)
170            .map(|support| support.versions)
171    }
172}
173
174/// The current reusable client's protocol profile.
175pub fn current_client_profile(application_version: impl Into<String>) -> ProtocolProfile {
176    let application_version = application_version.into();
177    let release_line =
178        opcda_bridge_proto::compatibility::release_line_for(env!("CARGO_PKG_VERSION"))
179            .expect("reusable client package version must be in the compatibility catalog");
180    ProtocolProfile {
181        application_version: Some(application_version),
182        source: CompatibilitySource::GatewayInfo,
183        features: vec![
184            ProtocolFeatureSupport {
185                feature: CompatibilityFeature::Core,
186                versions: ProtocolVersionRange::exact(release_line.core_protocol),
187            },
188            ProtocolFeatureSupport {
189                feature: CompatibilityFeature::Namespace,
190                versions: ProtocolVersionRange::exact(release_line.namespace_protocol),
191            },
192            ProtocolFeatureSupport {
193                feature: CompatibilityFeature::IndexedSearch,
194                versions: ProtocolVersionRange::exact(release_line.indexed_search_protocol),
195            },
196        ],
197    }
198}
199
200/// Convert legacy per-server capabilities into a gateway profile.
201pub fn legacy_gateway_profile(capabilities: &Capabilities) -> ProtocolProfile {
202    let mut features = Vec::new();
203    let release_line =
204        opcda_bridge_proto::compatibility::release_line_for(&capabilities.application_version);
205    if let Some(namespace_version) = parse_namespace_protocol(&capabilities.protocol_version) {
206        features.push(ProtocolFeatureSupport {
207            feature: CompatibilityFeature::Core,
208            versions: ProtocolVersionRange::exact(release_line.map_or(
209                opcda_bridge_proto::compatibility::CORE_PROTOCOL_VERSION,
210                |line| line.core_protocol,
211            )),
212        });
213        features.push(ProtocolFeatureSupport {
214            feature: CompatibilityFeature::Namespace,
215            versions: ProtocolVersionRange::exact(namespace_version),
216        });
217    }
218    if capabilities.supports_indexed_search
219        && let Some(index_protocol) =
220            parse_index_protocol(&capabilities.indexed_search_protocol_version)
221    {
222        features.push(ProtocolFeatureSupport {
223            feature: CompatibilityFeature::IndexedSearch,
224            versions: ProtocolVersionRange::exact(index_protocol),
225        });
226    }
227    ProtocolProfile {
228        application_version: Some(capabilities.application_version.clone()),
229        source: CompatibilitySource::LegacyCapabilities,
230        features,
231    }
232}
233
234fn catalog_line(version: &str) -> Option<&'static str> {
235    opcda_bridge_proto::compatibility::release_line_for(version).map(|line| line.name)
236}
237
238fn evidence_status(value: &str) -> Option<CompatibilityEvidence> {
239    match value {
240        "contract-boundary-tested" => Some(CompatibilityEvidence::ContractBoundaryTested),
241        "exact-pair-tested" => Some(CompatibilityEvidence::ExactPairTested),
242        "unverified" => Some(CompatibilityEvidence::Unverified),
243        _ => None,
244    }
245}
246
247fn catalog_evidence(
248    client_version: Option<&str>,
249    gateway_version: Option<&str>,
250) -> CompatibilityEvidence {
251    let (Some(client_version), Some(gateway_version)) = (client_version, gateway_version) else {
252        return CompatibilityEvidence::Unverified;
253    };
254    let (Some(client_line), Some(gateway_line)) =
255        (catalog_line(client_version), catalog_line(gateway_version))
256    else {
257        return CompatibilityEvidence::Unverified;
258    };
259
260    for &(catalog_client_line, catalog_gateway_line, status, exact_client, exact_gateway) in
261        opcda_bridge_proto::compatibility::EVIDENCE
262    {
263        if catalog_client_line == client_line
264            && catalog_gateway_line == gateway_line
265            && !exact_client.is_empty()
266            && exact_client == client_version
267            && exact_gateway == gateway_version
268            && let Some(status) = evidence_status(status)
269        {
270            return status;
271        }
272    }
273    for &(catalog_client_line, catalog_gateway_line, status, exact_client, exact_gateway) in
274        opcda_bridge_proto::compatibility::EVIDENCE
275    {
276        if catalog_client_line == client_line
277            && catalog_gateway_line == gateway_line
278            && exact_client.is_empty()
279            && exact_gateway.is_empty()
280            && let Some(status) = evidence_status(status)
281        {
282            return status;
283        }
284    }
285    CompatibilityEvidence::Unverified
286}
287
288fn parse_namespace_protocol(value: &str) -> Option<u32> {
289    match value.trim() {
290        "1" | "1.0" => Some(1),
291        "2" | "2.0" => Some(2),
292        "0.3" | "0.3.0" => Some(2),
293        _ => None,
294    }
295}
296
297fn parse_index_protocol(value: &str) -> Option<u32> {
298    match value.trim() {
299        "1" | "1.0" => Some(1),
300        _ => None,
301    }
302}
303
304/// Result for one feature comparison.
305#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
306pub struct FeatureCompatibility {
307    pub feature: CompatibilityFeature,
308    pub status: FeatureCompatibilityStatus,
309    pub client_versions: ProtocolVersionRange,
310    pub gateway_versions: Option<ProtocolVersionRange>,
311    pub negotiated_version: Option<u32>,
312    pub reason: String,
313}
314
315/// Result status for one protocol feature.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
317#[serde(rename_all = "snake_case")]
318pub enum FeatureCompatibilityStatus {
319    Compatible,
320    Unsupported,
321    Incompatible,
322    Unknown,
323}
324
325impl fmt::Display for FeatureCompatibilityStatus {
326    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327        f.write_str(match self {
328            Self::Compatible => "compatible",
329            Self::Unsupported => "unsupported",
330            Self::Incompatible => "incompatible",
331            Self::Unknown => "unknown",
332        })
333    }
334}
335
336/// Overall result of comparing a client and gateway profile.
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
338#[serde(rename_all = "snake_case")]
339pub enum CompatibilityStatus {
340    Full,
341    Partial,
342    Incompatible,
343    Unknown,
344}
345
346impl fmt::Display for CompatibilityStatus {
347    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
348        f.write_str(match self {
349            Self::Full => "full",
350            Self::Partial => "partial",
351            Self::Incompatible => "incompatible",
352            Self::Unknown => "unknown",
353        })
354    }
355}
356
357/// Evidence status for an otherwise protocol-compatible pairing.
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
359#[serde(rename_all = "snake_case")]
360pub enum CompatibilityEvidence {
361    ContractBoundaryTested,
362    ExactPairTested,
363    Unverified,
364}
365
366impl fmt::Display for CompatibilityEvidence {
367    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368        f.write_str(match self {
369            Self::ContractBoundaryTested => "contract-boundary-tested",
370            Self::ExactPairTested => "exact-pair-tested",
371            Self::Unverified => "unverified",
372        })
373    }
374}
375
376/// Full compatibility report for one connected gateway.
377#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
378pub struct CompatibilityReport {
379    pub client_version: String,
380    pub library_version: String,
381    pub gateway_version: Option<String>,
382    pub source: CompatibilitySource,
383    pub status: CompatibilityStatus,
384    pub evidence: CompatibilityEvidence,
385    pub features: Vec<FeatureCompatibility>,
386}
387
388impl CompatibilityReport {
389    /// Return the result for one feature, if the report contains it.
390    pub fn feature(&self, feature: CompatibilityFeature) -> Option<&FeatureCompatibility> {
391        self.features
392            .iter()
393            .find(|result| result.feature == feature)
394    }
395
396    /// Return whether every requested feature negotiated successfully.
397    pub fn satisfies(&self, required: &[CompatibilityFeature]) -> bool {
398        required.iter().all(|feature| {
399            self.feature(*feature)
400                .is_some_and(|result| result.status == FeatureCompatibilityStatus::Compatible)
401        })
402    }
403}
404
405/// Compare one client profile against one gateway profile.
406pub fn evaluate_compatibility(
407    client: &ProtocolProfile,
408    gateway: &ProtocolProfile,
409) -> CompatibilityReport {
410    let mut features = Vec::new();
411    for client_support in &client.features {
412        let gateway_versions = gateway.feature(client_support.feature);
413        let (status, negotiated_version, reason) = match gateway_versions {
414            Some(gateway_versions) if client_support.versions.overlaps(gateway_versions) => (
415                FeatureCompatibilityStatus::Compatible,
416                client_support.versions.negotiated_version(gateway_versions),
417                format!(
418                    "{feature} protocol ranges overlap",
419                    feature = client_support.feature
420                ),
421            ),
422            Some(gateway_versions) => (
423                FeatureCompatibilityStatus::Incompatible,
424                None,
425                format!(
426                    "{feature} protocol ranges do not overlap: client {}-{}, gateway {}-{}",
427                    client_support.versions.min,
428                    client_support.versions.max,
429                    gateway_versions.min,
430                    gateway_versions.max,
431                    feature = client_support.feature
432                ),
433            ),
434            None if client_support.feature == CompatibilityFeature::IndexedSearch => (
435                FeatureCompatibilityStatus::Unsupported,
436                None,
437                "gateway does not advertise indexed search".into(),
438            ),
439            None => (
440                FeatureCompatibilityStatus::Unknown,
441                None,
442                format!(
443                    "gateway did not advertise the {feature} protocol",
444                    feature = client_support.feature
445                ),
446            ),
447        };
448        features.push(FeatureCompatibility {
449            feature: client_support.feature,
450            status,
451            client_versions: client_support.versions,
452            gateway_versions,
453            negotiated_version,
454            reason,
455        });
456    }
457
458    let core_status = features
459        .iter()
460        .find(|result| result.feature == CompatibilityFeature::Core)
461        .map(|result| result.status);
462    let status = match core_status {
463        Some(FeatureCompatibilityStatus::Compatible) => {
464            if features
465                .iter()
466                .all(|result| result.status == FeatureCompatibilityStatus::Compatible)
467            {
468                CompatibilityStatus::Full
469            } else {
470                CompatibilityStatus::Partial
471            }
472        }
473        Some(FeatureCompatibilityStatus::Incompatible)
474        | Some(FeatureCompatibilityStatus::Unsupported) => CompatibilityStatus::Incompatible,
475        Some(FeatureCompatibilityStatus::Unknown) | None => CompatibilityStatus::Unknown,
476    };
477
478    CompatibilityReport {
479        client_version: client
480            .application_version
481            .clone()
482            .unwrap_or_else(|| "unknown".into()),
483        library_version: env!("CARGO_PKG_VERSION").into(),
484        gateway_version: gateway.application_version.clone(),
485        source: gateway.source,
486        status,
487        evidence: catalog_evidence(
488            client.application_version.as_deref(),
489            gateway.application_version.as_deref(),
490        ),
491        features,
492    }
493}
494
495/// Build an unknown report when an old gateway cannot answer either handshake.
496pub fn unknown_compatibility_report(client_version: impl Into<String>) -> CompatibilityReport {
497    CompatibilityReport {
498        client_version: client_version.into(),
499        library_version: env!("CARGO_PKG_VERSION").into(),
500        gateway_version: None,
501        source: CompatibilitySource::Unknown,
502        status: CompatibilityStatus::Unknown,
503        evidence: CompatibilityEvidence::Unverified,
504        features: Vec::new(),
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    fn capabilities(protocol: &str, indexed: bool) -> Capabilities {
513        Capabilities {
514            application_version: "0.4.3".into(),
515            protocol_version: protocol.into(),
516            max_page_size: 200,
517            supports_browse_sessions: true,
518            supports_search: true,
519            organization: crate::NamespaceOrganization::Hierarchical,
520            source: crate::BrowseSource::Da2,
521            supports_indexed_search: indexed,
522            indexed_search_protocol_version: if indexed { "1" } else { "" }.into(),
523            max_indexed_search_results: 50,
524            search_index_state: crate::SearchIndexState::Ready,
525            search_index_promoting: false,
526        }
527    }
528
529    #[test]
530    fn ranges_validate_and_negotiate() {
531        assert!(ProtocolVersionRange::new(2, 1).is_none());
532        let left = ProtocolVersionRange::new(1, 3).unwrap();
533        let right = ProtocolVersionRange::new(3, 4).unwrap();
534        assert!(left.overlaps(right));
535        assert_eq!(left.negotiated_version(right), Some(3));
536        let higher_left = ProtocolVersionRange::new(4, 6).unwrap();
537        let lower_right = ProtocolVersionRange::new(1, 5).unwrap();
538        assert_eq!(higher_left.negotiated_version(lower_right), Some(4));
539        assert!(!ProtocolVersionRange::exact(1).overlaps(ProtocolVersionRange::exact(2)));
540    }
541
542    #[test]
543    fn feature_display_and_status_display_are_stable() {
544        assert_eq!(
545            CompatibilityFeature::IndexedSearch.to_string(),
546            "indexed-search"
547        );
548        assert_eq!(
549            FeatureCompatibilityStatus::Unsupported.to_string(),
550            "unsupported"
551        );
552        assert_eq!(CompatibilityStatus::Partial.to_string(), "partial");
553        assert_eq!(
554            CompatibilityEvidence::ContractBoundaryTested.to_string(),
555            "contract-boundary-tested"
556        );
557        assert_eq!(CompatibilitySource::GatewayInfo.to_string(), "gateway-info");
558        assert_eq!(
559            CompatibilitySource::LegacyCapabilities.to_string(),
560            "legacy-capabilities"
561        );
562        assert_eq!(CompatibilitySource::Unknown.to_string(), "unknown");
563    }
564
565    #[test]
566    fn current_profile_uses_generated_contract_versions() {
567        let profile = current_client_profile("0.4.3");
568        assert_eq!(
569            profile.feature(CompatibilityFeature::Core),
570            Some(ProtocolVersionRange::exact(1))
571        );
572        assert_eq!(
573            profile.feature(CompatibilityFeature::Namespace),
574            Some(ProtocolVersionRange::exact(2))
575        );
576        assert_eq!(
577            profile.feature(CompatibilityFeature::IndexedSearch),
578            Some(ProtocolVersionRange::exact(1))
579        );
580        assert_eq!(profile.application_version.as_deref(), Some("0.4.3"));
581
582        let relabeled = current_client_profile("application-build");
583        assert_eq!(
584            relabeled.feature(CompatibilityFeature::IndexedSearch),
585            Some(ProtocolVersionRange::exact(1))
586        );
587    }
588
589    #[test]
590    fn legacy_profiles_map_supported_and_unknown_protocol_strings() {
591        let profile = legacy_gateway_profile(&capabilities("2", true));
592        assert_eq!(profile.source, CompatibilitySource::LegacyCapabilities);
593        assert_eq!(
594            profile.feature(CompatibilityFeature::Namespace),
595            Some(ProtocolVersionRange::exact(2))
596        );
597        assert!(
598            profile
599                .feature(CompatibilityFeature::IndexedSearch)
600                .is_some()
601        );
602
603        let old = legacy_gateway_profile(&capabilities("1.0", false));
604        assert_eq!(
605            old.feature(CompatibilityFeature::Namespace),
606            Some(ProtocolVersionRange::exact(1))
607        );
608        assert!(old.feature(CompatibilityFeature::IndexedSearch).is_none());
609
610        let shorthand = legacy_gateway_profile(&capabilities("0.3", false));
611        assert_eq!(
612            shorthand.feature(CompatibilityFeature::Namespace),
613            Some(ProtocolVersionRange::exact(2))
614        );
615
616        let unknown = legacy_gateway_profile(&capabilities("future", false));
617        assert!(unknown.features.is_empty());
618
619        let mut unknown_version = capabilities("2", true);
620        unknown_version.application_version = "future".into();
621        let unknown_version_profile = legacy_gateway_profile(&unknown_version);
622        assert_eq!(
623            unknown_version_profile.feature(CompatibilityFeature::Core),
624            Some(ProtocolVersionRange::exact(1))
625        );
626        assert_eq!(
627            unknown_version_profile.feature(CompatibilityFeature::IndexedSearch),
628            Some(ProtocolVersionRange::exact(1))
629        );
630        let mut invalid_index = capabilities("2", true);
631        invalid_index.indexed_search_protocol_version = "future".into();
632        assert!(
633            legacy_gateway_profile(&invalid_index)
634                .feature(CompatibilityFeature::IndexedSearch)
635                .is_none()
636        );
637    }
638
639    #[test]
640    fn evaluates_full_partial_and_incompatible_profiles() {
641        let client = current_client_profile("0.4.3");
642        let full = evaluate_compatibility(
643            &client,
644            &ProtocolProfile {
645                application_version: Some("0.4.3".into()),
646                source: CompatibilitySource::GatewayInfo,
647                features: client.features.clone(),
648            },
649        );
650        assert_eq!(full.status, CompatibilityStatus::Full);
651        assert_eq!(full.evidence, CompatibilityEvidence::ExactPairTested);
652        assert_eq!(full.library_version, env!("CARGO_PKG_VERSION"));
653        assert!(full.satisfies(&[CompatibilityFeature::Core]));
654
655        let partial = evaluate_compatibility(
656            &client,
657            &ProtocolProfile {
658                application_version: Some("0.3.2".into()),
659                source: CompatibilitySource::LegacyCapabilities,
660                features: vec![
661                    ProtocolFeatureSupport {
662                        feature: CompatibilityFeature::Core,
663                        versions: ProtocolVersionRange::exact(1),
664                    },
665                    ProtocolFeatureSupport {
666                        feature: CompatibilityFeature::Namespace,
667                        versions: ProtocolVersionRange::exact(2),
668                    },
669                ],
670            },
671        );
672        assert_eq!(partial.status, CompatibilityStatus::Partial);
673        assert_eq!(
674            partial
675                .feature(CompatibilityFeature::IndexedSearch)
676                .unwrap()
677                .status,
678            FeatureCompatibilityStatus::Unsupported
679        );
680        assert_eq!(
681            partial.evidence,
682            CompatibilityEvidence::ContractBoundaryTested
683        );
684        assert!(!partial.satisfies(&[CompatibilityFeature::IndexedSearch]));
685
686        let incompatible = evaluate_compatibility(
687            &client,
688            &ProtocolProfile {
689                application_version: Some("future".into()),
690                source: CompatibilitySource::GatewayInfo,
691                features: vec![ProtocolFeatureSupport {
692                    feature: CompatibilityFeature::Core,
693                    versions: ProtocolVersionRange::exact(2),
694                }],
695            },
696        );
697        assert_eq!(incompatible.status, CompatibilityStatus::Incompatible);
698        assert_eq!(
699            incompatible
700                .feature(CompatibilityFeature::Core)
701                .unwrap()
702                .status,
703            FeatureCompatibilityStatus::Incompatible
704        );
705
706        let unknown = evaluate_compatibility(
707            &client,
708            &ProtocolProfile {
709                application_version: Some("0.4.3".into()),
710                source: CompatibilitySource::GatewayInfo,
711                features: Vec::new(),
712            },
713        );
714        assert_eq!(unknown.status, CompatibilityStatus::Unknown);
715        assert!(!unknown.satisfies(&[CompatibilityFeature::Core]));
716    }
717
718    #[test]
719    fn evaluates_unknown_and_converts_gateway_info() {
720        let unknown = unknown_compatibility_report("0.4.3");
721        assert_eq!(unknown.status, CompatibilityStatus::Unknown);
722        assert!(!unknown.satisfies(&[CompatibilityFeature::Core]));
723
724        let info = GatewayInfo::try_from(proto::GetGatewayInfoResponse {
725            application_version: "0.4.3".into(),
726            compatibility_schema_version: 1,
727            features: vec![proto::ProtocolFeature {
728                kind: proto::ProtocolFeatureKind::Core as i32,
729                min_version: 1,
730                max_version: 2,
731            }],
732        })
733        .unwrap();
734        assert_eq!(info.features[0].versions.max, 2);
735    }
736
737    #[test]
738    fn rejects_invalid_gateway_feature_data() {
739        let error = GatewayInfo::try_from(proto::GetGatewayInfoResponse {
740            features: vec![proto::ProtocolFeature {
741                kind: 99,
742                min_version: 1,
743                max_version: 1,
744            }],
745            ..Default::default()
746        })
747        .unwrap_err();
748        assert!(error.to_string().contains("unknown protocol feature"));
749
750        let error = GatewayInfo::try_from(proto::GetGatewayInfoResponse {
751            features: vec![proto::ProtocolFeature {
752                kind: proto::ProtocolFeatureKind::Core as i32,
753                min_version: 2,
754                max_version: 1,
755            }],
756            ..Default::default()
757        })
758        .unwrap_err();
759        assert!(error.to_string().contains("reversed core"));
760
761        let error = GatewayInfo::try_from(proto::GetGatewayInfoResponse {
762            features: vec![proto::ProtocolFeature {
763                kind: proto::ProtocolFeatureKind::Unspecified as i32,
764                min_version: 1,
765                max_version: 1,
766            }],
767            ..Default::default()
768        })
769        .unwrap_err();
770        assert!(error.to_string().contains("unspecified protocol feature"));
771    }
772
773    #[test]
774    fn catalog_evidence_handles_exact_boundary_and_unknown_versions() {
775        assert_eq!(catalog_line("0.3.1"), Some("legacy"));
776        assert_eq!(catalog_line("0.3.2"), Some("paged"));
777        assert_eq!(catalog_line("0.4.0"), Some("indexed"));
778        assert_eq!(catalog_line("1.0.0"), None);
779        assert_eq!(
780            catalog_evidence(Some("0.4.0"), Some("0.4.3")),
781            CompatibilityEvidence::ExactPairTested
782        );
783        assert_eq!(
784            catalog_evidence(Some("0.4.3"), Some("0.3.2")),
785            CompatibilityEvidence::ContractBoundaryTested
786        );
787        assert_eq!(
788            catalog_evidence(Some("0.3.2"), Some("0.4.3")),
789            CompatibilityEvidence::ContractBoundaryTested
790        );
791        assert_eq!(
792            catalog_evidence(Some("0.3.1"), Some("0.3.1")),
793            CompatibilityEvidence::Unverified
794        );
795        assert_eq!(
796            catalog_evidence(Some("future"), Some("0.4.3")),
797            CompatibilityEvidence::Unverified
798        );
799        assert_eq!(
800            catalog_evidence(None, Some("0.4.3")),
801            CompatibilityEvidence::Unverified
802        );
803        assert_eq!(
804            evidence_status("unverified"),
805            Some(CompatibilityEvidence::Unverified)
806        );
807        assert!(catalog_line("0.4").is_none());
808        assert!(catalog_line("0.x.3").is_none());
809        assert_eq!(evidence_status("invalid"), None);
810    }
811}