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        }
526    }
527
528    #[test]
529    fn ranges_validate_and_negotiate() {
530        assert!(ProtocolVersionRange::new(2, 1).is_none());
531        let left = ProtocolVersionRange::new(1, 3).unwrap();
532        let right = ProtocolVersionRange::new(3, 4).unwrap();
533        assert!(left.overlaps(right));
534        assert_eq!(left.negotiated_version(right), Some(3));
535        assert!(!ProtocolVersionRange::exact(1).overlaps(ProtocolVersionRange::exact(2)));
536    }
537
538    #[test]
539    fn feature_display_and_status_display_are_stable() {
540        assert_eq!(
541            CompatibilityFeature::IndexedSearch.to_string(),
542            "indexed-search"
543        );
544        assert_eq!(
545            FeatureCompatibilityStatus::Unsupported.to_string(),
546            "unsupported"
547        );
548        assert_eq!(CompatibilityStatus::Partial.to_string(), "partial");
549        assert_eq!(
550            CompatibilityEvidence::ContractBoundaryTested.to_string(),
551            "contract-boundary-tested"
552        );
553    }
554
555    #[test]
556    fn current_profile_uses_generated_contract_versions() {
557        let profile = current_client_profile("0.4.3");
558        assert_eq!(
559            profile.feature(CompatibilityFeature::Core),
560            Some(ProtocolVersionRange::exact(1))
561        );
562        assert_eq!(
563            profile.feature(CompatibilityFeature::Namespace),
564            Some(ProtocolVersionRange::exact(2))
565        );
566        assert_eq!(
567            profile.feature(CompatibilityFeature::IndexedSearch),
568            Some(ProtocolVersionRange::exact(1))
569        );
570        assert_eq!(profile.application_version.as_deref(), Some("0.4.3"));
571
572        let relabeled = current_client_profile("application-build");
573        assert_eq!(
574            relabeled.feature(CompatibilityFeature::IndexedSearch),
575            Some(ProtocolVersionRange::exact(1))
576        );
577    }
578
579    #[test]
580    fn legacy_profiles_map_supported_and_unknown_protocol_strings() {
581        let profile = legacy_gateway_profile(&capabilities("2", true));
582        assert_eq!(profile.source, CompatibilitySource::LegacyCapabilities);
583        assert_eq!(
584            profile.feature(CompatibilityFeature::Namespace),
585            Some(ProtocolVersionRange::exact(2))
586        );
587        assert!(
588            profile
589                .feature(CompatibilityFeature::IndexedSearch)
590                .is_some()
591        );
592
593        let old = legacy_gateway_profile(&capabilities("1.0", false));
594        assert_eq!(
595            old.feature(CompatibilityFeature::Namespace),
596            Some(ProtocolVersionRange::exact(1))
597        );
598        assert!(old.feature(CompatibilityFeature::IndexedSearch).is_none());
599
600        let shorthand = legacy_gateway_profile(&capabilities("0.3", false));
601        assert_eq!(
602            shorthand.feature(CompatibilityFeature::Namespace),
603            Some(ProtocolVersionRange::exact(2))
604        );
605
606        let unknown = legacy_gateway_profile(&capabilities("future", false));
607        assert!(unknown.features.is_empty());
608
609        let mut unknown_version = capabilities("2", true);
610        unknown_version.application_version = "future".into();
611        let unknown_version_profile = legacy_gateway_profile(&unknown_version);
612        assert_eq!(
613            unknown_version_profile.feature(CompatibilityFeature::Core),
614            Some(ProtocolVersionRange::exact(1))
615        );
616        assert_eq!(
617            unknown_version_profile.feature(CompatibilityFeature::IndexedSearch),
618            Some(ProtocolVersionRange::exact(1))
619        );
620        let mut invalid_index = capabilities("2", true);
621        invalid_index.indexed_search_protocol_version = "future".into();
622        assert!(
623            legacy_gateway_profile(&invalid_index)
624                .feature(CompatibilityFeature::IndexedSearch)
625                .is_none()
626        );
627    }
628
629    #[test]
630    fn evaluates_full_partial_and_incompatible_profiles() {
631        let client = current_client_profile("0.4.3");
632        let full = evaluate_compatibility(
633            &client,
634            &ProtocolProfile {
635                application_version: Some("0.4.3".into()),
636                source: CompatibilitySource::GatewayInfo,
637                features: client.features.clone(),
638            },
639        );
640        assert_eq!(full.status, CompatibilityStatus::Full);
641        assert_eq!(full.evidence, CompatibilityEvidence::ExactPairTested);
642        assert_eq!(full.library_version, env!("CARGO_PKG_VERSION"));
643        assert!(full.satisfies(&[CompatibilityFeature::Core]));
644
645        let partial = evaluate_compatibility(
646            &client,
647            &ProtocolProfile {
648                application_version: Some("0.3.2".into()),
649                source: CompatibilitySource::LegacyCapabilities,
650                features: vec![
651                    ProtocolFeatureSupport {
652                        feature: CompatibilityFeature::Core,
653                        versions: ProtocolVersionRange::exact(1),
654                    },
655                    ProtocolFeatureSupport {
656                        feature: CompatibilityFeature::Namespace,
657                        versions: ProtocolVersionRange::exact(2),
658                    },
659                ],
660            },
661        );
662        assert_eq!(partial.status, CompatibilityStatus::Partial);
663        assert_eq!(
664            partial.evidence,
665            CompatibilityEvidence::ContractBoundaryTested
666        );
667        assert!(!partial.satisfies(&[CompatibilityFeature::IndexedSearch]));
668
669        let incompatible = evaluate_compatibility(
670            &client,
671            &ProtocolProfile {
672                application_version: Some("future".into()),
673                source: CompatibilitySource::GatewayInfo,
674                features: vec![ProtocolFeatureSupport {
675                    feature: CompatibilityFeature::Core,
676                    versions: ProtocolVersionRange::exact(2),
677                }],
678            },
679        );
680        assert_eq!(incompatible.status, CompatibilityStatus::Incompatible);
681        assert_eq!(
682            incompatible
683                .feature(CompatibilityFeature::Core)
684                .unwrap()
685                .status,
686            FeatureCompatibilityStatus::Incompatible
687        );
688
689        let unknown = evaluate_compatibility(
690            &client,
691            &ProtocolProfile {
692                application_version: Some("0.4.3".into()),
693                source: CompatibilitySource::GatewayInfo,
694                features: Vec::new(),
695            },
696        );
697        assert_eq!(unknown.status, CompatibilityStatus::Unknown);
698        assert!(!unknown.satisfies(&[CompatibilityFeature::Core]));
699    }
700
701    #[test]
702    fn evaluates_unknown_and_converts_gateway_info() {
703        let unknown = unknown_compatibility_report("0.4.3");
704        assert_eq!(unknown.status, CompatibilityStatus::Unknown);
705        assert!(!unknown.satisfies(&[CompatibilityFeature::Core]));
706
707        let info = GatewayInfo::try_from(proto::GetGatewayInfoResponse {
708            application_version: "0.4.3".into(),
709            compatibility_schema_version: 1,
710            features: vec![proto::ProtocolFeature {
711                kind: proto::ProtocolFeatureKind::Core as i32,
712                min_version: 1,
713                max_version: 2,
714            }],
715        })
716        .unwrap();
717        assert_eq!(info.features[0].versions.max, 2);
718    }
719
720    #[test]
721    fn rejects_invalid_gateway_feature_data() {
722        let error = GatewayInfo::try_from(proto::GetGatewayInfoResponse {
723            features: vec![proto::ProtocolFeature {
724                kind: 99,
725                min_version: 1,
726                max_version: 1,
727            }],
728            ..Default::default()
729        })
730        .unwrap_err();
731        assert!(error.to_string().contains("unknown protocol feature"));
732
733        let error = GatewayInfo::try_from(proto::GetGatewayInfoResponse {
734            features: vec![proto::ProtocolFeature {
735                kind: proto::ProtocolFeatureKind::Core as i32,
736                min_version: 2,
737                max_version: 1,
738            }],
739            ..Default::default()
740        })
741        .unwrap_err();
742        assert!(error.to_string().contains("reversed core"));
743
744        let error = GatewayInfo::try_from(proto::GetGatewayInfoResponse {
745            features: vec![proto::ProtocolFeature {
746                kind: proto::ProtocolFeatureKind::Unspecified as i32,
747                min_version: 1,
748                max_version: 1,
749            }],
750            ..Default::default()
751        })
752        .unwrap_err();
753        assert!(error.to_string().contains("unspecified protocol feature"));
754    }
755
756    #[test]
757    fn catalog_evidence_handles_exact_boundary_and_unknown_versions() {
758        assert_eq!(catalog_line("0.3.1"), Some("legacy"));
759        assert_eq!(catalog_line("0.3.2"), Some("paged"));
760        assert_eq!(catalog_line("0.4.0"), Some("indexed"));
761        assert_eq!(catalog_line("1.0.0"), None);
762        assert_eq!(
763            catalog_evidence(Some("0.4.0"), Some("0.4.3")),
764            CompatibilityEvidence::ExactPairTested
765        );
766        assert_eq!(
767            catalog_evidence(Some("0.4.3"), Some("0.3.2")),
768            CompatibilityEvidence::ContractBoundaryTested
769        );
770        assert_eq!(
771            catalog_evidence(Some("0.3.2"), Some("0.4.3")),
772            CompatibilityEvidence::ContractBoundaryTested
773        );
774        assert_eq!(
775            catalog_evidence(Some("0.3.1"), Some("0.3.1")),
776            CompatibilityEvidence::Unverified
777        );
778        assert_eq!(
779            catalog_evidence(Some("future"), Some("0.4.3")),
780            CompatibilityEvidence::Unverified
781        );
782        assert_eq!(
783            catalog_evidence(None, Some("0.4.3")),
784            CompatibilityEvidence::Unverified
785        );
786        assert!(catalog_line("0.4").is_none());
787        assert!(catalog_line("0.x.3").is_none());
788        assert_eq!(evidence_status("invalid"), None);
789    }
790}