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