Skip to main content

ipfrs_network/
peer_capability_negotiator.rs

1//! Peer Capability Negotiation
2//!
3//! This module implements [`PeerCapabilityNegotiator`], a production-grade system
4//! for discovering, advertising, and negotiating compatible protocol capabilities
5//! between peers during connection establishment.
6//!
7//! # Overview
8//!
9//! When two IPFRS nodes connect, they exchange [`NegotiationOffer`] messages describing
10//! their supported capabilities (e.g. "bitswap/2.0.0", "graphsync/1.1.0"). The
11//! negotiator evaluates each offer against its local [`NegotiatorConfig`] and returns
12//! a [`NegotiationResult`] indicating which capabilities were agreed upon or why the
13//! negotiation was rejected.
14//!
15//! # Policies
16//!
17//! Three [`CapabilityPolicy`] variants control strictness:
18//!
19//! - **`RequireAll`** – Every required local capability must appear in the peer's offer;
20//!   any mismatch yields an immediate [`NegotiationResult::Rejected`].
21//! - **`RequireSubset(names)`** – Only the named subset of capabilities must match.
22//! - **`BestEffort`** – Always produces [`NegotiationResult::Accepted`]; unmatched
23//!   required capabilities are listed in `rejected_optional` for caller visibility.
24//!
25//! # Example
26//!
27//! ```rust
28//! use ipfrs_network::peer_capability_negotiator::{
29//!     CapabilityPolicy, CapabilityVersion, NegotiationOffer, NegotiatorConfig,
30//!     PeerCapabilityNegotiator, PeerCapability,
31//! };
32//! use std::collections::HashMap;
33//!
34//! let local_cap = PeerCapability {
35//!     name: "bitswap".to_string(),
36//!     version: CapabilityVersion { major: 2, minor: 0, patch: 0 },
37//!     required: true,
38//!     metadata: HashMap::new(),
39//! };
40//!
41//! let config = NegotiatorConfig {
42//!     local_capabilities: vec![local_cap],
43//!     policy: CapabilityPolicy::RequireAll,
44//!     min_protocol_version: 1,
45//!     negotiation_timeout_ms: 5_000,
46//! };
47//!
48//! let mut negotiator = PeerCapabilityNegotiator::new(config, 128);
49//!
50//! let offer = NegotiationOffer {
51//!     peer_id: "QmPeer".to_string(),
52//!     capabilities: vec![PeerCapability {
53//!         name: "bitswap".to_string(),
54//!         version: CapabilityVersion { major: 2, minor: 1, patch: 0 },
55//!         required: false,
56//!         metadata: HashMap::new(),
57//!     }],
58//!     protocol_version: 2,
59//!     timestamp: 1_000,
60//! };
61//!
62//! let result = negotiator.negotiate("QmPeer".to_string(), offer, 1_000, 1_050);
63//! // "bitswap" v2.1.0 is compatible with required v2.0.0 → Accepted.
64//! assert!(matches!(result, ipfrs_network::peer_capability_negotiator::NegotiationResult::Accepted { .. }));
65//! ```
66
67use std::collections::{HashMap, VecDeque};
68use std::fmt;
69
70// ─────────────────────────────────────────────────────────────────────────────
71//  CapabilityVersion
72// ─────────────────────────────────────────────────────────────────────────────
73
74/// Semantic version triple for a single capability.
75///
76/// Compatibility rule: `self` is compatible with `required` when
77/// `self.major == required.major && self.minor >= required.minor`.
78#[derive(Clone, Debug, PartialEq, Eq, Hash)]
79pub struct CapabilityVersion {
80    /// Breaking-change epoch. Must match exactly.
81    pub major: u16,
82    /// Backwards-compatible feature additions. Must be ≥ required.
83    pub minor: u16,
84    /// Bug-fix increments. Ignored for compatibility checks.
85    pub patch: u16,
86}
87
88impl CapabilityVersion {
89    /// Returns `true` when `self` satisfies the `required` version constraint.
90    ///
91    /// The rule is: same major, and `self.minor >= required.minor`.
92    #[inline]
93    pub fn is_compatible_with(&self, required: &CapabilityVersion) -> bool {
94        self.major == required.major && self.minor >= required.minor
95    }
96}
97
98impl fmt::Display for CapabilityVersion {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
101    }
102}
103
104impl Default for CapabilityVersion {
105    fn default() -> Self {
106        Self {
107            major: 1,
108            minor: 0,
109            patch: 0,
110        }
111    }
112}
113
114// ─────────────────────────────────────────────────────────────────────────────
115//  PeerCapability
116// ─────────────────────────────────────────────────────────────────────────────
117
118/// A single named capability with version and optional metadata key-value pairs.
119///
120/// When `required` is `true`, the local peer considers this capability mandatory
121/// for a successful negotiation (subject to the active [`CapabilityPolicy`]).
122#[derive(Clone, Debug, PartialEq)]
123pub struct PeerCapability {
124    /// Protocol identifier, e.g. `"bitswap"` or `"graphsync"`.
125    pub name: String,
126    /// Version triple describing the capability's feature level.
127    pub version: CapabilityVersion,
128    /// Whether the local node considers this capability mandatory.
129    pub required: bool,
130    /// Arbitrary string key-value pairs (e.g. `"max_block_size" → "1048576"`).
131    pub metadata: HashMap<String, String>,
132}
133
134impl PeerCapability {
135    /// Construct a required capability with no metadata.
136    pub fn required(name: impl Into<String>, version: CapabilityVersion) -> Self {
137        Self {
138            name: name.into(),
139            version,
140            required: true,
141            metadata: HashMap::new(),
142        }
143    }
144
145    /// Construct an optional capability with no metadata.
146    pub fn optional(name: impl Into<String>, version: CapabilityVersion) -> Self {
147        Self {
148            name: name.into(),
149            version,
150            required: false,
151            metadata: HashMap::new(),
152        }
153    }
154
155    /// Construct a capability with metadata.
156    pub fn with_metadata(
157        name: impl Into<String>,
158        version: CapabilityVersion,
159        required: bool,
160        metadata: HashMap<String, String>,
161    ) -> Self {
162        Self {
163            name: name.into(),
164            version,
165            required,
166            metadata,
167        }
168    }
169}
170
171// ─────────────────────────────────────────────────────────────────────────────
172//  NegotiationOffer
173// ─────────────────────────────────────────────────────────────────────────────
174
175/// A capability advertisement sent by a remote peer at connection time.
176#[derive(Clone, Debug, PartialEq)]
177pub struct NegotiationOffer {
178    /// Identifier of the remote peer.
179    pub peer_id: String,
180    /// Full list of capabilities the remote peer supports.
181    pub capabilities: Vec<PeerCapability>,
182    /// Wire-level protocol version of the remote peer.
183    pub protocol_version: u16,
184    /// Unix-millisecond timestamp when this offer was created.
185    pub timestamp: u64,
186}
187
188impl NegotiationOffer {
189    /// Build a map from capability name → capability for efficient lookup.
190    fn capability_map(&self) -> HashMap<&str, &PeerCapability> {
191        self.capabilities
192            .iter()
193            .map(|c| (c.name.as_str(), c))
194            .collect()
195    }
196}
197
198// ─────────────────────────────────────────────────────────────────────────────
199//  NegotiationResult
200// ─────────────────────────────────────────────────────────────────────────────
201
202/// Outcome of a capability negotiation attempt.
203#[derive(Clone, Debug, PartialEq)]
204pub enum NegotiationResult {
205    /// Negotiation succeeded; the listed capability names are mutually supported.
206    Accepted {
207        /// Names of capabilities agreed upon by both sides.
208        agreed_capabilities: Vec<String>,
209        /// Names of optional local capabilities absent from the peer's offer.
210        rejected_optional: Vec<String>,
211    },
212    /// Negotiation failed; the connection should be closed.
213    Rejected {
214        /// Human-readable rejection reason.
215        reason: String,
216        /// Capability names that were required but absent from the peer's offer.
217        missing_required: Vec<String>,
218    },
219}
220
221impl NegotiationResult {
222    /// Returns `true` if the result is [`NegotiationResult::Accepted`].
223    #[inline]
224    pub fn is_accepted(&self) -> bool {
225        matches!(self, NegotiationResult::Accepted { .. })
226    }
227
228    /// Returns `true` if the result is [`NegotiationResult::Rejected`].
229    #[inline]
230    pub fn is_rejected(&self) -> bool {
231        matches!(self, NegotiationResult::Rejected { .. })
232    }
233
234    /// Returns the agreed capability names, or an empty slice if rejected.
235    pub fn agreed_capabilities(&self) -> &[String] {
236        match self {
237            NegotiationResult::Accepted {
238                agreed_capabilities,
239                ..
240            } => agreed_capabilities,
241            NegotiationResult::Rejected { .. } => &[],
242        }
243    }
244
245    /// Returns the rejection reason, or `None` if accepted.
246    pub fn rejection_reason(&self) -> Option<&str> {
247        match self {
248            NegotiationResult::Rejected { reason, .. } => Some(reason.as_str()),
249            NegotiationResult::Accepted { .. } => None,
250        }
251    }
252}
253
254// ─────────────────────────────────────────────────────────────────────────────
255//  CapabilityPolicy
256// ─────────────────────────────────────────────────────────────────────────────
257
258/// Governs how strictly the negotiator validates a peer's offer.
259#[derive(Clone, Debug, Default, PartialEq)]
260pub enum CapabilityPolicy {
261    /// All capabilities marked `required` in the local config must be present
262    /// in the peer's offer with a compatible version. Any mismatch → Rejected.
263    RequireAll,
264
265    /// Only the named capabilities must appear in the peer's offer. Names not
266    /// listed here are treated as optional even if locally `required`.
267    RequireSubset(Vec<String>),
268
269    /// Never reject: return Accepted regardless of missing required capabilities.
270    /// Missing required capabilities are surfaced in `rejected_optional`.
271    #[default]
272    BestEffort,
273}
274
275// ─────────────────────────────────────────────────────────────────────────────
276//  NegotiatorConfig
277// ─────────────────────────────────────────────────────────────────────────────
278
279/// Configuration for a [`PeerCapabilityNegotiator`].
280#[derive(Clone, Debug)]
281pub struct NegotiatorConfig {
282    /// Capabilities this node advertises (and potentially requires).
283    pub local_capabilities: Vec<PeerCapability>,
284    /// Policy controlling acceptance/rejection strictness.
285    pub policy: CapabilityPolicy,
286    /// Minimum acceptable wire-protocol version from remote peers.
287    pub min_protocol_version: u16,
288    /// Timeout budget for a single negotiation round (informational; callers
289    /// supply elapsed time via `negotiate`'s `start_ms`/`end_ms` parameters).
290    pub negotiation_timeout_ms: u64,
291}
292
293impl Default for NegotiatorConfig {
294    fn default() -> Self {
295        Self {
296            local_capabilities: Vec::new(),
297            policy: CapabilityPolicy::BestEffort,
298            min_protocol_version: 1,
299            negotiation_timeout_ms: 5_000,
300        }
301    }
302}
303
304impl NegotiatorConfig {
305    /// Build a config with a single policy and capability set.
306    pub fn new(
307        local_capabilities: Vec<PeerCapability>,
308        policy: CapabilityPolicy,
309        min_protocol_version: u16,
310        negotiation_timeout_ms: u64,
311    ) -> Self {
312        Self {
313            local_capabilities,
314            policy,
315            min_protocol_version,
316            negotiation_timeout_ms,
317        }
318    }
319}
320
321// ─────────────────────────────────────────────────────────────────────────────
322//  NegotiationRecord
323// ─────────────────────────────────────────────────────────────────────────────
324
325/// A historical record of a completed negotiation with a specific peer.
326#[derive(Clone, Debug)]
327pub struct NegotiationRecord {
328    /// Remote peer identifier.
329    pub peer_id: String,
330    /// Outcome of the negotiation.
331    pub result: NegotiationResult,
332    /// Unix-millisecond timestamp when the negotiation completed.
333    pub negotiated_at: u64,
334    /// Round-trip duration of the negotiation in milliseconds.
335    pub latency_ms: u64,
336}
337
338// ─────────────────────────────────────────────────────────────────────────────
339//  NegotiatorStats
340// ─────────────────────────────────────────────────────────────────────────────
341
342/// Aggregate statistics for a [`PeerCapabilityNegotiator`].
343#[derive(Clone, Debug, PartialEq)]
344pub struct NegotiatorStats {
345    /// Total number of `negotiate()` calls made.
346    pub total_negotiations: u64,
347    /// Number of negotiations that produced `Accepted`.
348    pub total_accepted: u64,
349    /// Number of negotiations that produced `Rejected`.
350    pub total_rejected: u64,
351    /// Fraction of negotiations that were accepted (0.0–1.0).
352    /// Returns 0.0 when `total_negotiations` is zero.
353    pub accept_rate: f64,
354    /// Number of capabilities advertised by this node.
355    pub capabilities_advertised: usize,
356}
357
358// ─────────────────────────────────────────────────────────────────────────────
359//  PeerCapabilityNegotiator
360// ─────────────────────────────────────────────────────────────────────────────
361
362/// Production-grade peer capability negotiator.
363///
364/// `PeerCapabilityNegotiator` handles the full lifecycle of capability negotiation:
365/// 1. Building [`NegotiationOffer`]s from local config for outbound connections.
366/// 2. Evaluating incoming offers and producing [`NegotiationResult`]s.
367/// 3. Recording negotiation outcomes in a bounded history ring.
368/// 4. Exposing aggregate statistics for monitoring dashboards.
369///
370/// # Thread Safety
371///
372/// `PeerCapabilityNegotiator` is `Send + Sync` (all fields are `Send + Sync`).
373/// For concurrent access, wrap in `Arc<Mutex<_>>` or `Arc<RwLock<_>>`.
374#[derive(Debug)]
375pub struct PeerCapabilityNegotiator {
376    /// Local negotiation configuration (policy, capabilities, limits).
377    pub config: NegotiatorConfig,
378    /// Bounded ring of historical negotiation records (newest last).
379    pub negotiation_history: VecDeque<NegotiationRecord>,
380    /// Maximum number of historical records retained.
381    pub max_history: usize,
382    /// Total number of `negotiate()` calls.
383    pub total_negotiations: u64,
384    /// Negotiations that returned `Accepted`.
385    pub total_accepted: u64,
386    /// Negotiations that returned `Rejected`.
387    pub total_rejected: u64,
388}
389
390impl PeerCapabilityNegotiator {
391    // ── Construction ─────────────────────────────────────────────────────────
392
393    /// Create a new negotiator with the given config and maximum history size.
394    ///
395    /// # Panics
396    ///
397    /// Does not panic; a `max_history` of 0 is valid (no history retained).
398    pub fn new(config: NegotiatorConfig, max_history: usize) -> Self {
399        Self {
400            config,
401            negotiation_history: VecDeque::new(),
402            max_history,
403            total_negotiations: 0,
404            total_accepted: 0,
405            total_rejected: 0,
406        }
407    }
408
409    // ── Offer Construction ───────────────────────────────────────────────────
410
411    /// Build a [`NegotiationOffer`] from this node's local capabilities.
412    ///
413    /// `peer_id` is the identifier we place in the offer (usually our own peer ID),
414    /// and `now` is the current Unix-millisecond timestamp.
415    pub fn build_offer(&self, peer_id: String, now: u64) -> NegotiationOffer {
416        NegotiationOffer {
417            peer_id,
418            capabilities: self.config.local_capabilities.clone(),
419            protocol_version: self.config.min_protocol_version,
420            timestamp: now,
421        }
422    }
423
424    // ── Offer Evaluation ─────────────────────────────────────────────────────
425
426    /// Evaluate an incoming peer offer and return the negotiation outcome.
427    ///
428    /// The evaluation proceeds in three phases:
429    ///
430    /// 1. **Protocol version gate** — if `offer.protocol_version < min_protocol_version`,
431    ///    immediately return `Rejected { reason: "protocol version too low", ... }`.
432    /// 2. **Required-capability scan** — identify which locally `required` capabilities
433    ///    are absent from (or version-incompatible with) the offer.
434    /// 3. **Policy decision** — apply [`CapabilityPolicy`] to decide Accepted/Rejected.
435    pub fn evaluate_offer(&self, offer: &NegotiationOffer) -> NegotiationResult {
436        // ── Phase 1: protocol version gate ───────────────────────────────────
437        if offer.protocol_version < self.config.min_protocol_version {
438            return NegotiationResult::Rejected {
439                reason: "protocol version too low".to_string(),
440                missing_required: Vec::new(),
441            };
442        }
443
444        let offer_map = offer.capability_map();
445
446        // ── Phase 2: identify missing required capabilities ───────────────────
447        let missing_required: Vec<String> = self
448            .config
449            .local_capabilities
450            .iter()
451            .filter(|cap| cap.required)
452            .filter(|cap| {
453                offer_map
454                    .get(cap.name.as_str())
455                    .map(|peer_cap| !peer_cap.version.is_compatible_with(&cap.version))
456                    .unwrap_or(true) // absent from offer → missing
457            })
458            .map(|cap| cap.name.clone())
459            .collect();
460
461        // ── Phase 3: policy decision ──────────────────────────────────────────
462        match &self.config.policy {
463            CapabilityPolicy::RequireAll => {
464                if !missing_required.is_empty() {
465                    return NegotiationResult::Rejected {
466                        reason: "missing required capabilities".to_string(),
467                        missing_required,
468                    };
469                }
470                let agreed = self.compute_agreed(&offer_map);
471                let rejected_optional = self.compute_rejected_optional(&offer_map);
472                NegotiationResult::Accepted {
473                    agreed_capabilities: agreed,
474                    rejected_optional,
475                }
476            }
477
478            CapabilityPolicy::RequireSubset(required_names) => {
479                let missing_subset: Vec<String> = required_names
480                    .iter()
481                    .filter(|name| {
482                        // Find the local capability with this name
483                        let local_cap = self
484                            .config
485                            .local_capabilities
486                            .iter()
487                            .find(|c| &c.name == *name);
488                        match local_cap {
489                            None => false, // unknown name in subset → skip
490                            Some(local) => offer_map
491                                .get(local.name.as_str())
492                                .map(|peer_cap| {
493                                    !peer_cap.version.is_compatible_with(&local.version)
494                                })
495                                .unwrap_or(true),
496                        }
497                    })
498                    .cloned()
499                    .collect();
500
501                if !missing_subset.is_empty() {
502                    return NegotiationResult::Rejected {
503                        reason: "missing required subset capabilities".to_string(),
504                        missing_required: missing_subset,
505                    };
506                }
507                let agreed = self.compute_agreed(&offer_map);
508                let rejected_optional = self.compute_rejected_optional(&offer_map);
509                NegotiationResult::Accepted {
510                    agreed_capabilities: agreed,
511                    rejected_optional,
512                }
513            }
514
515            CapabilityPolicy::BestEffort => {
516                // Always accept; surface missing_required as rejected_optional.
517                let agreed = self.compute_agreed(&offer_map);
518                // rejected_optional = optional caps not in offer PLUS missing required caps
519                let mut rejected_optional = self.compute_rejected_optional(&offer_map);
520                for name in &missing_required {
521                    if !rejected_optional.contains(name) {
522                        rejected_optional.push(name.clone());
523                    }
524                }
525                NegotiationResult::Accepted {
526                    agreed_capabilities: agreed,
527                    rejected_optional,
528                }
529            }
530        }
531    }
532
533    // ── Negotiation (stateful) ────────────────────────────────────────────────
534
535    /// Evaluate an offer, record it in history, update counters, and return the result.
536    ///
537    /// * `peer_id` — identifier of the remote peer.
538    /// * `offer` — the remote peer's capability offer.
539    /// * `start_ms` — Unix-millisecond timestamp when the negotiation began.
540    /// * `end_ms` — Unix-millisecond timestamp when evaluation completed.
541    ///
542    /// Returns a reference to the stored [`NegotiationResult`] in the history ring.
543    ///
544    /// # History Eviction
545    ///
546    /// When the history ring is full (`len() == max_history`), the oldest record
547    /// is evicted before the new one is appended. If `max_history` is 0, the
548    /// record is computed and counters are updated but nothing is stored.
549    pub fn negotiate(
550        &mut self,
551        peer_id: String,
552        offer: NegotiationOffer,
553        start_ms: u64,
554        end_ms: u64,
555    ) -> &NegotiationResult {
556        let result = self.evaluate_offer(&offer);
557        let latency_ms = end_ms.saturating_sub(start_ms);
558
559        // Update aggregate counters.
560        self.total_negotiations += 1;
561        if result.is_accepted() {
562            self.total_accepted += 1;
563        } else {
564            self.total_rejected += 1;
565        }
566
567        // Record in history (evict oldest if at capacity).
568        let record = NegotiationRecord {
569            peer_id,
570            result,
571            negotiated_at: end_ms,
572            latency_ms,
573        };
574
575        if self.max_history > 0 {
576            if self.negotiation_history.len() >= self.max_history {
577                self.negotiation_history.pop_front();
578            }
579            self.negotiation_history.push_back(record);
580            // SAFETY: we just pushed, len ≥ 1.
581            &self.negotiation_history.back().expect("just pushed").result
582        } else {
583            // max_history == 0: push temporarily, return ref, then pop.
584            // We must still return a reference with lifetime tied to self.
585            // Store in a 1-slot buffer and leak the pointer—actually we
586            // cannot return a dangling ref. Instead we keep a 1-element
587            // scratch deque that we reuse.
588            //
589            // To avoid unsafe, we store the record in a slot-0 of the deque
590            // and return &deque[0].result. This leaks one slot but stays safe.
591            self.negotiation_history.clear();
592            self.negotiation_history.push_back(record);
593            &self.negotiation_history[0].result
594        }
595    }
596
597    // ── Capability Queries ────────────────────────────────────────────────────
598
599    /// Returns `true` if the local config contains a capability with `name` whose
600    /// version is compatible with `version`.
601    pub fn is_capability_supported(&self, name: &str, version: &CapabilityVersion) -> bool {
602        self.config
603            .local_capabilities
604            .iter()
605            .any(|cap| cap.name == name && cap.version.is_compatible_with(version))
606    }
607
608    /// Returns the names of capabilities present in both the local config and the
609    /// offer, with a compatible version.
610    pub fn common_capabilities(&self, offer: &NegotiationOffer) -> Vec<String> {
611        let offer_map = offer.capability_map();
612        self.compute_agreed(&offer_map)
613    }
614
615    // ── History Queries ───────────────────────────────────────────────────────
616
617    /// Returns all historical records for the specified peer.
618    pub fn history_for_peer(&self, peer_id: &str) -> Vec<&NegotiationRecord> {
619        self.negotiation_history
620            .iter()
621            .filter(|r| r.peer_id == peer_id)
622            .collect()
623    }
624
625    /// Snapshot of aggregate negotiation statistics.
626    pub fn negotiator_stats(&self) -> NegotiatorStats {
627        let accept_rate = if self.total_negotiations == 0 {
628            0.0
629        } else {
630            self.total_accepted as f64 / self.total_negotiations as f64
631        };
632        NegotiatorStats {
633            total_negotiations: self.total_negotiations,
634            total_accepted: self.total_accepted,
635            total_rejected: self.total_rejected,
636            accept_rate,
637            capabilities_advertised: self.config.local_capabilities.len(),
638        }
639    }
640
641    // ── Private Helpers ───────────────────────────────────────────────────────
642
643    /// Compute the intersection of local and offer capabilities (version-compatible).
644    fn compute_agreed<'a>(&self, offer_map: &HashMap<&'a str, &'a PeerCapability>) -> Vec<String> {
645        self.config
646            .local_capabilities
647            .iter()
648            .filter(|local_cap| {
649                offer_map
650                    .get(local_cap.name.as_str())
651                    .map(|peer_cap| peer_cap.version.is_compatible_with(&local_cap.version))
652                    .unwrap_or(false)
653            })
654            .map(|cap| cap.name.clone())
655            .collect()
656    }
657
658    /// Compute optional local capabilities absent from the offer (or version-incompatible).
659    fn compute_rejected_optional<'a>(
660        &self,
661        offer_map: &HashMap<&'a str, &'a PeerCapability>,
662    ) -> Vec<String> {
663        self.config
664            .local_capabilities
665            .iter()
666            .filter(|cap| !cap.required)
667            .filter(|cap| {
668                offer_map
669                    .get(cap.name.as_str())
670                    .map(|peer_cap| !peer_cap.version.is_compatible_with(&cap.version))
671                    .unwrap_or(true)
672            })
673            .map(|cap| cap.name.clone())
674            .collect()
675    }
676}
677
678// ─────────────────────────────────────────────────────────────────────────────
679//  Tests
680// ─────────────────────────────────────────────────────────────────────────────
681
682#[cfg(test)]
683mod tests {
684    use crate::peer_capability_negotiator::{
685        CapabilityPolicy, CapabilityVersion, NegotiationOffer, NegotiationResult, NegotiatorConfig,
686        NegotiatorStats, PeerCapability, PeerCapabilityNegotiator,
687    };
688    use std::collections::HashMap;
689
690    // ── Helpers ───────────────────────────────────────────────────────────────
691
692    fn ver(major: u16, minor: u16, patch: u16) -> CapabilityVersion {
693        CapabilityVersion {
694            major,
695            minor,
696            patch,
697        }
698    }
699
700    fn cap_req(name: &str, major: u16, minor: u16) -> PeerCapability {
701        PeerCapability::required(name, ver(major, minor, 0))
702    }
703
704    fn cap_opt(name: &str, major: u16, minor: u16) -> PeerCapability {
705        PeerCapability::optional(name, ver(major, minor, 0))
706    }
707
708    fn offer(peer_id: &str, caps: Vec<PeerCapability>, proto: u16) -> NegotiationOffer {
709        NegotiationOffer {
710            peer_id: peer_id.to_string(),
711            capabilities: caps,
712            protocol_version: proto,
713            timestamp: 1_000,
714        }
715    }
716
717    fn make_negotiator(
718        caps: Vec<PeerCapability>,
719        policy: CapabilityPolicy,
720        min_proto: u16,
721    ) -> PeerCapabilityNegotiator {
722        PeerCapabilityNegotiator::new(NegotiatorConfig::new(caps, policy, min_proto, 5_000), 64)
723    }
724
725    // ── CapabilityVersion tests ───────────────────────────────────────────────
726
727    #[test]
728    fn test_version_compatible_same_major_higher_minor() {
729        let v = ver(2, 3, 0);
730        let req = ver(2, 1, 0);
731        assert!(v.is_compatible_with(&req));
732    }
733
734    #[test]
735    fn test_version_compatible_same_exact() {
736        let v = ver(1, 0, 0);
737        assert!(v.is_compatible_with(&v));
738    }
739
740    #[test]
741    fn test_version_incompatible_major_mismatch() {
742        let v = ver(2, 5, 0);
743        let req = ver(1, 5, 0);
744        assert!(!v.is_compatible_with(&req));
745    }
746
747    #[test]
748    fn test_version_incompatible_lower_minor() {
749        let v = ver(1, 0, 0);
750        let req = ver(1, 1, 0);
751        assert!(!v.is_compatible_with(&req));
752    }
753
754    #[test]
755    fn test_version_display() {
756        assert_eq!(ver(3, 14, 159).to_string(), "3.14.159");
757    }
758
759    #[test]
760    fn test_version_default() {
761        let d = CapabilityVersion::default();
762        assert_eq!(d.major, 1);
763        assert_eq!(d.minor, 0);
764        assert_eq!(d.patch, 0);
765    }
766
767    #[test]
768    fn test_version_patch_irrelevant_for_compat() {
769        // patch=99 on required side should not affect compatibility.
770        let v = ver(1, 2, 0);
771        let req = ver(1, 2, 99);
772        assert!(v.is_compatible_with(&req));
773    }
774
775    // ── PeerCapability constructor tests ─────────────────────────────────────
776
777    #[test]
778    fn test_peer_capability_required_constructor() {
779        let cap = PeerCapability::required("graphsync", ver(1, 0, 0));
780        assert!(cap.required);
781        assert_eq!(cap.name, "graphsync");
782        assert!(cap.metadata.is_empty());
783    }
784
785    #[test]
786    fn test_peer_capability_optional_constructor() {
787        let cap = PeerCapability::optional("relay", ver(2, 0, 0));
788        assert!(!cap.required);
789    }
790
791    #[test]
792    fn test_peer_capability_with_metadata() {
793        let mut meta = HashMap::new();
794        meta.insert("max_block_size".to_string(), "1048576".to_string());
795        let cap = PeerCapability::with_metadata("bitswap", ver(2, 0, 0), true, meta.clone());
796        assert_eq!(cap.metadata, meta);
797    }
798
799    // ── NegotiationResult helpers ─────────────────────────────────────────────
800
801    #[test]
802    fn test_result_is_accepted() {
803        let r = NegotiationResult::Accepted {
804            agreed_capabilities: vec!["bitswap".to_string()],
805            rejected_optional: vec![],
806        };
807        assert!(r.is_accepted());
808        assert!(!r.is_rejected());
809    }
810
811    #[test]
812    fn test_result_is_rejected() {
813        let r = NegotiationResult::Rejected {
814            reason: "test".to_string(),
815            missing_required: vec![],
816        };
817        assert!(r.is_rejected());
818        assert!(!r.is_accepted());
819    }
820
821    #[test]
822    fn test_result_agreed_capabilities_accepted() {
823        let names = vec!["a".to_string(), "b".to_string()];
824        let r = NegotiationResult::Accepted {
825            agreed_capabilities: names.clone(),
826            rejected_optional: vec![],
827        };
828        assert_eq!(r.agreed_capabilities(), names.as_slice());
829    }
830
831    #[test]
832    fn test_result_agreed_capabilities_rejected_empty() {
833        let r = NegotiationResult::Rejected {
834            reason: "no".to_string(),
835            missing_required: vec![],
836        };
837        assert!(r.agreed_capabilities().is_empty());
838    }
839
840    #[test]
841    fn test_result_rejection_reason() {
842        let r = NegotiationResult::Rejected {
843            reason: "protocol version too low".to_string(),
844            missing_required: vec![],
845        };
846        assert_eq!(r.rejection_reason(), Some("protocol version too low"));
847    }
848
849    // ── Protocol version gate tests ───────────────────────────────────────────
850
851    #[test]
852    fn test_evaluate_rejects_low_protocol_version() {
853        let neg = make_negotiator(vec![], CapabilityPolicy::RequireAll, 3);
854        let result = neg.evaluate_offer(&offer("peer1", vec![], 2));
855        assert!(result.is_rejected());
856        assert_eq!(result.rejection_reason(), Some("protocol version too low"));
857    }
858
859    #[test]
860    fn test_evaluate_accepts_exact_protocol_version() {
861        let neg = make_negotiator(vec![], CapabilityPolicy::RequireAll, 2);
862        let result = neg.evaluate_offer(&offer("peer1", vec![], 2));
863        assert!(result.is_accepted());
864    }
865
866    #[test]
867    fn test_evaluate_accepts_higher_protocol_version() {
868        let neg = make_negotiator(vec![], CapabilityPolicy::RequireAll, 1);
869        let result = neg.evaluate_offer(&offer("peer1", vec![], 5));
870        assert!(result.is_accepted());
871    }
872
873    // ── RequireAll policy tests ───────────────────────────────────────────────
874
875    #[test]
876    fn test_require_all_rejects_missing_required() {
877        let neg = make_negotiator(
878            vec![cap_req("bitswap", 2, 0)],
879            CapabilityPolicy::RequireAll,
880            1,
881        );
882        let result = neg.evaluate_offer(&offer("peer", vec![], 1));
883        assert!(result.is_rejected());
884        match result {
885            NegotiationResult::Rejected {
886                missing_required, ..
887            } => {
888                assert!(missing_required.contains(&"bitswap".to_string()));
889            }
890            _ => panic!("expected Rejected"),
891        }
892    }
893
894    #[test]
895    fn test_require_all_rejects_version_incompatible() {
896        let neg = make_negotiator(
897            vec![cap_req("bitswap", 2, 3)],
898            CapabilityPolicy::RequireAll,
899            1,
900        );
901        // Offer has bitswap v2.1.0, but we require v2.3.0
902        let result = neg.evaluate_offer(&offer("peer", vec![cap_opt("bitswap", 2, 1)], 1));
903        assert!(result.is_rejected());
904    }
905
906    #[test]
907    fn test_require_all_accepts_compatible_versions() {
908        let neg = make_negotiator(
909            vec![cap_req("bitswap", 2, 0)],
910            CapabilityPolicy::RequireAll,
911            1,
912        );
913        // Offer has bitswap v2.1.0 — minor is higher → compatible
914        let result = neg.evaluate_offer(&offer("peer", vec![cap_opt("bitswap", 2, 1)], 1));
915        assert!(result.is_accepted());
916    }
917
918    #[test]
919    fn test_require_all_agreed_capabilities_correct() {
920        let neg = make_negotiator(
921            vec![cap_req("bitswap", 2, 0), cap_opt("relay", 1, 0)],
922            CapabilityPolicy::RequireAll,
923            1,
924        );
925        let peer_caps = vec![cap_opt("bitswap", 2, 2), cap_opt("relay", 1, 1)];
926        let result = neg.evaluate_offer(&offer("peer", peer_caps, 1));
927        match result {
928            NegotiationResult::Accepted {
929                agreed_capabilities,
930                ..
931            } => {
932                assert!(agreed_capabilities.contains(&"bitswap".to_string()));
933                assert!(agreed_capabilities.contains(&"relay".to_string()));
934            }
935            _ => panic!("expected Accepted"),
936        }
937    }
938
939    #[test]
940    fn test_require_all_rejected_optional_populated() {
941        let neg = make_negotiator(
942            vec![
943                cap_req("bitswap", 2, 0),
944                cap_opt("graphsync", 1, 0), // optional, peer does not have it
945            ],
946            CapabilityPolicy::RequireAll,
947            1,
948        );
949        let result = neg.evaluate_offer(&offer("peer", vec![cap_opt("bitswap", 2, 0)], 1));
950        match result {
951            NegotiationResult::Accepted {
952                rejected_optional, ..
953            } => {
954                assert!(rejected_optional.contains(&"graphsync".to_string()));
955            }
956            _ => panic!("expected Accepted"),
957        }
958    }
959
960    // ── RequireSubset policy tests ────────────────────────────────────────────
961
962    #[test]
963    fn test_require_subset_rejects_missing_subset_member() {
964        let neg = make_negotiator(
965            vec![cap_req("bitswap", 2, 0), cap_req("graphsync", 1, 0)],
966            CapabilityPolicy::RequireSubset(vec!["bitswap".to_string()]),
967            1,
968        );
969        // No bitswap in offer → missing subset member
970        let result = neg.evaluate_offer(&offer("peer", vec![cap_opt("graphsync", 1, 0)], 1));
971        assert!(result.is_rejected());
972    }
973
974    #[test]
975    fn test_require_subset_accepts_when_subset_present() {
976        let neg = make_negotiator(
977            vec![cap_req("bitswap", 2, 0), cap_req("graphsync", 1, 0)],
978            CapabilityPolicy::RequireSubset(vec!["bitswap".to_string()]),
979            1,
980        );
981        // Only need bitswap; graphsync being absent is fine
982        let result = neg.evaluate_offer(&offer("peer", vec![cap_opt("bitswap", 2, 1)], 1));
983        assert!(result.is_accepted());
984    }
985
986    #[test]
987    fn test_require_subset_rejects_version_incompatible() {
988        let neg = make_negotiator(
989            vec![cap_req("bitswap", 2, 5)],
990            CapabilityPolicy::RequireSubset(vec!["bitswap".to_string()]),
991            1,
992        );
993        // v2.3.0 < required v2.5.0 → incompatible
994        let result = neg.evaluate_offer(&offer("peer", vec![cap_opt("bitswap", 2, 3)], 1));
995        assert!(result.is_rejected());
996    }
997
998    #[test]
999    fn test_require_subset_unknown_subset_name_ignored() {
1000        // "unknown" is in the subset but not in local_capabilities → treated as skip
1001        let neg = make_negotiator(
1002            vec![cap_req("bitswap", 2, 0)],
1003            CapabilityPolicy::RequireSubset(vec!["unknown".to_string()]),
1004            1,
1005        );
1006        let result = neg.evaluate_offer(&offer("peer", vec![], 1));
1007        // "unknown" not in local_capabilities → filter passes through without requiring it
1008        assert!(result.is_accepted());
1009    }
1010
1011    // ── BestEffort policy tests ───────────────────────────────────────────────
1012
1013    #[test]
1014    fn test_best_effort_always_accepted() {
1015        let neg = make_negotiator(
1016            vec![cap_req("bitswap", 2, 0)],
1017            CapabilityPolicy::BestEffort,
1018            1,
1019        );
1020        // No capabilities in offer → still Accepted
1021        let result = neg.evaluate_offer(&offer("peer", vec![], 1));
1022        assert!(result.is_accepted());
1023    }
1024
1025    #[test]
1026    fn test_best_effort_missing_required_in_rejected_optional() {
1027        let neg = make_negotiator(
1028            vec![cap_req("bitswap", 2, 0)],
1029            CapabilityPolicy::BestEffort,
1030            1,
1031        );
1032        let result = neg.evaluate_offer(&offer("peer", vec![], 1));
1033        match result {
1034            NegotiationResult::Accepted {
1035                rejected_optional, ..
1036            } => {
1037                assert!(rejected_optional.contains(&"bitswap".to_string()));
1038            }
1039            _ => panic!("expected Accepted"),
1040        }
1041    }
1042
1043    #[test]
1044    fn test_best_effort_agreed_when_present() {
1045        let neg = make_negotiator(
1046            vec![cap_req("bitswap", 2, 0)],
1047            CapabilityPolicy::BestEffort,
1048            1,
1049        );
1050        let result = neg.evaluate_offer(&offer("peer", vec![cap_opt("bitswap", 2, 1)], 1));
1051        match result {
1052            NegotiationResult::Accepted {
1053                agreed_capabilities,
1054                rejected_optional,
1055            } => {
1056                assert!(agreed_capabilities.contains(&"bitswap".to_string()));
1057                assert!(rejected_optional.is_empty());
1058            }
1059            _ => panic!("expected Accepted"),
1060        }
1061    }
1062
1063    // ── build_offer tests ─────────────────────────────────────────────────────
1064
1065    #[test]
1066    fn test_build_offer_contains_local_capabilities() {
1067        let neg = make_negotiator(
1068            vec![cap_req("bitswap", 2, 0), cap_opt("relay", 1, 0)],
1069            CapabilityPolicy::BestEffort,
1070            1,
1071        );
1072        let o = neg.build_offer("local-peer".to_string(), 42_000);
1073        assert_eq!(o.peer_id, "local-peer");
1074        assert_eq!(o.timestamp, 42_000);
1075        assert_eq!(o.capabilities.len(), 2);
1076        assert_eq!(o.protocol_version, 1);
1077    }
1078
1079    #[test]
1080    fn test_build_offer_empty_capabilities() {
1081        let neg = make_negotiator(vec![], CapabilityPolicy::BestEffort, 2);
1082        let o = neg.build_offer("me".to_string(), 0);
1083        assert!(o.capabilities.is_empty());
1084        assert_eq!(o.protocol_version, 2);
1085    }
1086
1087    // ── negotiate (stateful) tests ────────────────────────────────────────────
1088
1089    #[test]
1090    fn test_negotiate_increments_total() {
1091        let mut neg = make_negotiator(vec![], CapabilityPolicy::BestEffort, 1);
1092        neg.negotiate("p1".to_string(), offer("p1", vec![], 1), 0, 10);
1093        assert_eq!(neg.total_negotiations, 1);
1094    }
1095
1096    #[test]
1097    fn test_negotiate_increments_accepted_counter() {
1098        let mut neg = make_negotiator(vec![], CapabilityPolicy::BestEffort, 1);
1099        neg.negotiate("p1".to_string(), offer("p1", vec![], 1), 0, 10);
1100        assert_eq!(neg.total_accepted, 1);
1101        assert_eq!(neg.total_rejected, 0);
1102    }
1103
1104    #[test]
1105    fn test_negotiate_increments_rejected_counter() {
1106        let mut neg = make_negotiator(vec![], CapabilityPolicy::RequireAll, 5);
1107        neg.negotiate("p1".to_string(), offer("p1", vec![], 1), 0, 10);
1108        assert_eq!(neg.total_rejected, 1);
1109        assert_eq!(neg.total_accepted, 0);
1110    }
1111
1112    #[test]
1113    fn test_negotiate_result_stored_in_history() {
1114        let mut neg = make_negotiator(vec![], CapabilityPolicy::BestEffort, 1);
1115        neg.negotiate("p1".to_string(), offer("p1", vec![], 1), 100, 150);
1116        assert_eq!(neg.negotiation_history.len(), 1);
1117        let rec = &neg.negotiation_history[0];
1118        assert_eq!(rec.peer_id, "p1");
1119        assert_eq!(rec.latency_ms, 50);
1120        assert_eq!(rec.negotiated_at, 150);
1121    }
1122
1123    #[test]
1124    fn test_negotiate_evicts_oldest_when_full() {
1125        let config = NegotiatorConfig::new(vec![], CapabilityPolicy::BestEffort, 1, 5_000);
1126        let mut neg = PeerCapabilityNegotiator::new(config, 2);
1127        neg.negotiate("p1".to_string(), offer("p1", vec![], 1), 0, 1);
1128        neg.negotiate("p2".to_string(), offer("p2", vec![], 1), 1, 2);
1129        neg.negotiate("p3".to_string(), offer("p3", vec![], 1), 2, 3);
1130        assert_eq!(neg.negotiation_history.len(), 2);
1131        // p1 should have been evicted
1132        assert_eq!(neg.negotiation_history[0].peer_id, "p2");
1133        assert_eq!(neg.negotiation_history[1].peer_id, "p3");
1134    }
1135
1136    #[test]
1137    fn test_negotiate_returns_correct_result_ref() {
1138        let mut neg = make_negotiator(vec![], CapabilityPolicy::BestEffort, 1);
1139        let result = neg.negotiate("p".to_string(), offer("p", vec![], 1), 0, 5);
1140        assert!(result.is_accepted());
1141    }
1142
1143    // ── is_capability_supported tests ─────────────────────────────────────────
1144
1145    #[test]
1146    fn test_is_capability_supported_true() {
1147        let neg = make_negotiator(
1148            vec![cap_req("bitswap", 2, 3)],
1149            CapabilityPolicy::BestEffort,
1150            1,
1151        );
1152        // We support 2.3; query for 2.1 (we are higher minor) → compatible
1153        assert!(neg.is_capability_supported("bitswap", &ver(2, 1, 0)));
1154    }
1155
1156    #[test]
1157    fn test_is_capability_supported_false_wrong_name() {
1158        let neg = make_negotiator(
1159            vec![cap_req("bitswap", 2, 0)],
1160            CapabilityPolicy::BestEffort,
1161            1,
1162        );
1163        assert!(!neg.is_capability_supported("graphsync", &ver(1, 0, 0)));
1164    }
1165
1166    #[test]
1167    fn test_is_capability_supported_false_incompatible_version() {
1168        let neg = make_negotiator(
1169            vec![cap_req("bitswap", 2, 0)],
1170            CapabilityPolicy::BestEffort,
1171            1,
1172        );
1173        // We support 2.0; query for 2.5 (we are lower) → incompatible
1174        assert!(!neg.is_capability_supported("bitswap", &ver(2, 5, 0)));
1175    }
1176
1177    // ── common_capabilities tests ─────────────────────────────────────────────
1178
1179    #[test]
1180    fn test_common_capabilities_intersection() {
1181        let neg = make_negotiator(
1182            vec![cap_req("bitswap", 2, 0), cap_opt("relay", 1, 0)],
1183            CapabilityPolicy::BestEffort,
1184            1,
1185        );
1186        let peer_caps = vec![cap_opt("bitswap", 2, 1), cap_opt("graphsync", 1, 0)];
1187        let common = neg.common_capabilities(&offer("peer", peer_caps, 1));
1188        assert!(common.contains(&"bitswap".to_string()));
1189        assert!(!common.contains(&"relay".to_string()));
1190        assert!(!common.contains(&"graphsync".to_string()));
1191    }
1192
1193    #[test]
1194    fn test_common_capabilities_empty_when_no_overlap() {
1195        let neg = make_negotiator(
1196            vec![cap_req("bitswap", 2, 0)],
1197            CapabilityPolicy::BestEffort,
1198            1,
1199        );
1200        let common = neg.common_capabilities(&offer("peer", vec![], 1));
1201        assert!(common.is_empty());
1202    }
1203
1204    // ── history_for_peer tests ────────────────────────────────────────────────
1205
1206    #[test]
1207    fn test_history_for_peer_filtered() {
1208        let mut neg = make_negotiator(vec![], CapabilityPolicy::BestEffort, 1);
1209        neg.negotiate("alice".to_string(), offer("alice", vec![], 1), 0, 1);
1210        neg.negotiate("bob".to_string(), offer("bob", vec![], 1), 1, 2);
1211        neg.negotiate("alice".to_string(), offer("alice", vec![], 1), 2, 3);
1212
1213        let alice_history = neg.history_for_peer("alice");
1214        assert_eq!(alice_history.len(), 2);
1215        assert!(alice_history.iter().all(|r| r.peer_id == "alice"));
1216    }
1217
1218    #[test]
1219    fn test_history_for_peer_empty_for_unknown() {
1220        let neg = make_negotiator(vec![], CapabilityPolicy::BestEffort, 1);
1221        assert!(neg.history_for_peer("nobody").is_empty());
1222    }
1223
1224    // ── negotiator_stats tests ────────────────────────────────────────────────
1225
1226    #[test]
1227    fn test_stats_zero_accept_rate_when_no_negotiations() {
1228        let neg = make_negotiator(vec![], CapabilityPolicy::BestEffort, 1);
1229        let stats = neg.negotiator_stats();
1230        assert_eq!(stats.total_negotiations, 0);
1231        assert_eq!(stats.accept_rate, 0.0);
1232    }
1233
1234    #[test]
1235    fn test_stats_accept_rate_calculation() {
1236        let mut neg = make_negotiator(vec![], CapabilityPolicy::BestEffort, 1);
1237        // 2 accepted
1238        neg.negotiate("p1".to_string(), offer("p1", vec![], 1), 0, 1);
1239        neg.negotiate("p2".to_string(), offer("p2", vec![], 1), 1, 2);
1240        // 1 rejected (protocol too low)
1241        neg.negotiate("p3".to_string(), offer("p3", vec![], 0), 2, 3);
1242        let stats = neg.negotiator_stats();
1243        assert_eq!(stats.total_negotiations, 3);
1244        assert_eq!(stats.total_accepted, 2);
1245        assert_eq!(stats.total_rejected, 1);
1246        let expected = 2.0 / 3.0;
1247        assert!((stats.accept_rate - expected).abs() < 1e-10);
1248    }
1249
1250    #[test]
1251    fn test_stats_capabilities_advertised_count() {
1252        let neg = make_negotiator(
1253            vec![cap_req("a", 1, 0), cap_opt("b", 1, 0), cap_req("c", 1, 0)],
1254            CapabilityPolicy::BestEffort,
1255            1,
1256        );
1257        assert_eq!(neg.negotiator_stats().capabilities_advertised, 3);
1258    }
1259
1260    #[test]
1261    fn test_stats_is_clone() {
1262        let neg = make_negotiator(vec![], CapabilityPolicy::BestEffort, 1);
1263        let stats: NegotiatorStats = neg.negotiator_stats();
1264        let _cloned = stats.clone();
1265    }
1266
1267    // ── NegotiatorConfig default test ─────────────────────────────────────────
1268
1269    #[test]
1270    fn test_config_default_values() {
1271        let cfg = NegotiatorConfig::default();
1272        assert!(cfg.local_capabilities.is_empty());
1273        assert_eq!(cfg.policy, CapabilityPolicy::BestEffort);
1274        assert_eq!(cfg.min_protocol_version, 1);
1275        assert_eq!(cfg.negotiation_timeout_ms, 5_000);
1276    }
1277
1278    // ── CapabilityPolicy default test ─────────────────────────────────────────
1279
1280    #[test]
1281    fn test_policy_default_is_best_effort() {
1282        assert_eq!(CapabilityPolicy::default(), CapabilityPolicy::BestEffort);
1283    }
1284
1285    // ── Edge cases ────────────────────────────────────────────────────────────
1286
1287    #[test]
1288    fn test_negotiate_max_history_zero() {
1289        let config = NegotiatorConfig::new(vec![], CapabilityPolicy::BestEffort, 1, 5_000);
1290        let mut neg = PeerCapabilityNegotiator::new(config, 0);
1291        // Should not panic; scratch slot is used.
1292        let result = neg.negotiate("peer".to_string(), offer("peer", vec![], 1), 0, 1);
1293        assert!(result.is_accepted());
1294        assert_eq!(neg.total_negotiations, 1);
1295        // History cleared between calls; only the last scratch is there.
1296        neg.negotiate("peer".to_string(), offer("peer", vec![], 1), 1, 2);
1297        assert_eq!(neg.negotiation_history.len(), 1);
1298    }
1299
1300    #[test]
1301    fn test_multiple_agreed_capabilities_deduplication_not_needed() {
1302        // Offer with two entries with the same name (edge case): only the last
1303        // wins via the HashMap; no panic should occur.
1304        let neg = make_negotiator(
1305            vec![cap_req("bitswap", 2, 0)],
1306            CapabilityPolicy::RequireAll,
1307            1,
1308        );
1309        let peer_caps = vec![
1310            cap_opt("bitswap", 2, 1),
1311            cap_opt("bitswap", 2, 2), // duplicate name
1312        ];
1313        let result = neg.evaluate_offer(&offer("peer", peer_caps, 1));
1314        // Should still be Accepted (last one wins in capability_map).
1315        assert!(result.is_accepted());
1316    }
1317
1318    #[test]
1319    fn test_latency_stored_in_record() {
1320        let mut neg = make_negotiator(vec![], CapabilityPolicy::BestEffort, 1);
1321        neg.negotiate("p".to_string(), offer("p", vec![], 1), 1_000, 1_075);
1322        assert_eq!(neg.negotiation_history[0].latency_ms, 75);
1323    }
1324
1325    #[test]
1326    fn test_saturation_on_end_before_start() {
1327        // end_ms < start_ms → saturating_sub clamps to 0.
1328        let mut neg = make_negotiator(vec![], CapabilityPolicy::BestEffort, 1);
1329        neg.negotiate("p".to_string(), offer("p", vec![], 1), 1_000, 500);
1330        assert_eq!(neg.negotiation_history[0].latency_ms, 0);
1331    }
1332
1333    #[test]
1334    fn test_mixed_required_optional_require_all() {
1335        // Required cap present, optional absent → Accepted with optional in rejected_optional.
1336        let neg = make_negotiator(
1337            vec![
1338                cap_req("bitswap", 2, 0),
1339                cap_opt("graphsync", 1, 0),
1340                cap_opt("relay", 1, 0),
1341            ],
1342            CapabilityPolicy::RequireAll,
1343            1,
1344        );
1345        let peer_caps = vec![cap_opt("bitswap", 2, 0), cap_opt("graphsync", 1, 1)];
1346        let result = neg.evaluate_offer(&offer("peer", peer_caps, 1));
1347        match result {
1348            NegotiationResult::Accepted {
1349                agreed_capabilities,
1350                rejected_optional,
1351            } => {
1352                assert!(agreed_capabilities.contains(&"bitswap".to_string()));
1353                assert!(agreed_capabilities.contains(&"graphsync".to_string()));
1354                assert!(rejected_optional.contains(&"relay".to_string()));
1355            }
1356            _ => panic!("expected Accepted"),
1357        }
1358    }
1359}