Skip to main content

epics_base_rs/server/database/
link_set.rs

1//! [`LinkSet`] — pluggable backend for `pva://` / `ca://` link
2//! resolution.
3//!
4//! Mirrors the C EPICS `lset` (link set) abstraction used by libdbCore
5//! to delegate link operations to a pluggable backend. We expose a
6//! pure-Rust trait so the bridge crate can wire up `pvalink` /
7//! `calink` without epics-base-rs having to know about either
8//! protocol.
9//!
10//! At runtime [`super::PvDatabase`] holds a registry keyed by URL
11//! scheme (`"pva"`, `"ca"`); each entry is an `Arc<dyn LinkSet>`.
12//! Record-link reads dispatch through the matching lset before
13//! falling back to the legacy `ExternalPvResolver` closure.
14//!
15//! The trait is **split by thread**, mirroring the C `dbCa` split
16//! between the record-processing thread and the `dbCaTask`
17//! (`dbCa.c:1191-1248`):
18//!
19//! * **Synchronous methods** are the ones record processing calls while
20//!   it holds the record's advisory write gate (C `dbScanLock`). They
21//!   answer from cached, monitor-fed state and MUST NOT perform I/O —
22//!   C `dbCaGetLink` copies out of `pca->pgetNative` and never touches
23//!   the wire (`dbCa.c:448-535`). Their signature is what enforces
24//!   that: a `fn` cannot await, so it cannot suspend the record thread.
25//! * **Async methods** ([`LinkSet::get_value`],
26//!   [`LinkSet::connect_link`], [`LinkSet::put_value`],
27//!   [`LinkSet::flush_puts`]) are the `dbCaTask` half. They run on the
28//!   database's link work owner, never on a record-processing thread,
29//!   and MAY block on the network.
30//!
31//! Before this split the "MUST NOT perform I/O" rule was a doc comment
32//! on an `async fn`, so nothing stopped a new lset from suspending
33//! record processing inside the gate. It is now a type-level property.
34//!
35//! # Adding a new lset
36//!
37//! ```ignore
38//! struct MyLset { /* ... */ }
39//! #[epics_base_rs::async_trait]
40//! impl LinkSet for MyLset {
41//!     fn is_connected(&self, name: &str) -> bool { /* cached */ }
42//!     fn get_cached_value(&self, name: &str) -> Option<EpicsValue> { /* cached */ }
43//!     async fn get_value(&self, name: &str) -> Option<EpicsValue> { /* may do I/O */ }
44//!     /* etc. */
45//! }
46//! db.register_link_set("pva", Arc::new(MyLset { ... })).await;
47//! ```
48
49use std::sync::Arc;
50
51use crate::types::EpicsValue;
52
53/// DBF field type a link's value maps to — the Rust counterpart of
54/// the C `DBF_*` codes pvxs `pvaGetDBFtype` returns.
55///
56/// Mirrors `pvxs/ioc/pvalink_lset.cpp:199` (`pvaGetDBFtype`), which
57/// maps the cached NT value's `TypeCode` to a `DBF_*` constant; an
58/// NT `enum_t` structure maps to `DBF_ENUM`.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum LinkDbfType {
61    Char,
62    UChar,
63    Short,
64    UShort,
65    Long,
66    ULong,
67    Int64,
68    UInt64,
69    Float,
70    Double,
71    String,
72    Enum,
73}
74
75/// How an external OUT-link write should be delivered to the lset.
76///
77/// Mirrors the C dbCore split between a plain link put and a
78/// put-notify-aware put: `dbPutLink` (synchronous, no completion
79/// callback) vs `dbPutLinkAsync` (issued from `dbNotify`, where the
80/// source record's processing is held until the downstream put
81/// completes). pvxs's pvalink lset realises the same split as
82/// `pvaPutValue` (plain, `wait=false`) vs `pvaPutValueAsync`
83/// (`wait=true`, which sets `record._options.block` so the PUT
84/// request carries the block option and the source record is parked
85/// in `after_put` until the server acknowledges completion) —
86/// `pvxs/ioc/pvalink_lset.cpp` `putValue` / `putValueAsync`.
87///
88/// The database selects the op from the write context: a write that
89/// originates inside a put-notify / blocking-put chain (the source
90/// record carries a completion wait-set) uses [`Async`]; a plain
91/// record-processing OUT write uses [`Plain`].
92///
93/// [`Async`]: LinkPutOp::Async
94/// [`Plain`]: LinkPutOp::Plain
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
96pub enum LinkPutOp {
97    /// Plain put — fire-and-forget from the lset's perspective. Maps to
98    /// pvxs `pvaPutValue` (`wait=false`) / C `dbPutLink`.
99    #[default]
100    Plain,
101    /// Completion-aware put — the originating record is part of a
102    /// put-notify / blocking-put chain. Maps to pvxs `pvaPutValueAsync`
103    /// (`wait=true`, `record._options.block`) / C `dbPutLinkAsync`.
104    Async,
105}
106
107/// Whether an external OUT link will accept a staged write right now —
108/// the answer to C `dbCaPutLinkCallback`'s first gate:
109///
110/// ```c
111/// if (!pca->isConnected || !pca->hasWriteAccess) {
112///     epicsMutexUnlock(pca->lock);
113///     return -1;
114/// }
115/// ```
116/// (`dbCa.c:558-561`).
117///
118/// Answered from cached state only: it runs on a record-processing thread
119/// inside the record's advisory write gate, which is exactly where C never
120/// touches the network.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum PutAdmission {
123    /// The lset tracks this link and its channel is up — stage the write.
124    /// C's fall-through to `addAction` (`dbCa.c:622`).
125    Connected,
126    /// The lset tracks this link and the channel is DOWN. C refuses the put
127    /// with `-1` and stages nothing (`dbCa.c:558-561`); the caller raises the
128    /// owning record's LINK/INVALID through `dbPutLink`'s `setLinkAlarm`
129    /// (`dbLink.c:434-448`).
130    Disconnected,
131    /// The lset has never opened this link, so it cannot answer. C cannot
132    /// reach this state: `dbCaAddLink` opens every CA link at record-init
133    /// time (`dbCa.c` `addAction(pca, CA_CONNECT)`), so by the first
134    /// `dbCaPutLink` the `caLink` always exists. Our lsets open lazily on
135    /// first use instead, so the write is staged and the lset's own
136    /// `put_value` performs the open — dropping it would mean an OUT link
137    /// that never opens and therefore never connects.
138    Unopened,
139}
140
141/// Remote display / control / valueAlarm metadata snapshot for a
142/// link, as exposed by pvxs's pvalink lset metadata getters.
143///
144/// Mirrors the pvxs `pvalink_lset.cpp` metadata getter set installed
145/// at `pvxs/ioc/pvalink_lset.cpp:700`:
146/// `pvaGetDBFtype`, `pvaGetElements`, `pvaGetControlLimits`,
147/// `pvaGetGraphicLimits`, `pvaGetAlarmLimits`, `pvaGetPrecision`,
148/// `pvaGetUnits`.
149///
150/// Every field is optional: pvxs's getters read the cached NT
151/// structure with `Value::as`, which leaves the caller's buffer
152/// unchanged when the sub-field is absent. `None` here means the
153/// remote NT value carried no such metadata — the record support
154/// then keeps its local/default metadata, exactly as the C path does.
155#[derive(Clone, Debug, Default, PartialEq)]
156pub struct LinkMetadata {
157    /// DBF type the remote value maps to (`pvaGetDBFtype`). A connected
158    /// link always reports a type — an unmappable value shape falls back
159    /// to `Long`, the `default:` arm of pvxs `pvaGetDBFtype`
160    /// (`pvalink_lset.cpp:199-236`). `None` therefore means "not
161    /// connected" (no cached value), never "connected but unmappable".
162    pub dbf_type: Option<LinkDbfType>,
163    /// Element count: array length, or `1` for a scalar / any connected
164    /// non-array shape (`pvaGetElements`, `pvalink_lset.cpp:242-254`).
165    /// As with `dbf_type`, `None` means "not connected".
166    pub element_count: Option<i64>,
167    /// `display.limitLow` / `display.limitHigh` (`pvaGetGraphicLimits`).
168    pub graphic_limits: Option<(f64, f64)>,
169    /// `control.limitLow` / `control.limitHigh` (`pvaGetControlLimits`).
170    pub control_limits: Option<(f64, f64)>,
171    /// `valueAlarm.{lowAlarmLimit,lowWarningLimit,highWarningLimit,
172    /// highAlarmLimit}` as `(lolo, lo, hi, hihi)` (`pvaGetAlarmLimits`).
173    pub alarm_limits: Option<(f64, f64, f64, f64)>,
174    /// `display.precision` (`pvaGetPrecision`).
175    pub precision: Option<i16>,
176    /// `display.units` (`pvaGetUnits`).
177    pub units: Option<String>,
178    /// `display.description` — carried so a link snapshot is complete;
179    /// pvxs exposes it through the same `fld_meta` cache.
180    pub description: Option<String>,
181}
182
183/// Ungated remote alarm snapshot for a link — the remote
184/// `(severity, status, message)` the upstream PV carried at the last
185/// successful value read, WITHOUT the maximize-severity
186/// (`MS`/`NMS`/`MSI`) gate that [`LinkSet::alarm_severity`] applies for
187/// owning-record propagation.
188///
189/// This is the DB-link inspection counterpart pvxs exposes through
190/// `dbGetAlarm` / `dbGetAlarmMsg` — `pvaGetAlarmMsg` returns the cached
191/// `snap_severity` / `snap_message` directly and never consults the
192/// link's `sevr` mode (`pvxs/ioc/pvalink_lset.cpp:542-575`). A default
193/// `NMS` link must still report its remote severity here even though it
194/// does not maximize the owning record's severity.
195#[derive(Clone, Debug, Default, PartialEq)]
196pub struct RemoteAlarm {
197    /// Remote alarm severity (`0 = NO_ALARM` … `3 = INVALID`), the raw
198    /// cached `alarm.severity` — never gated by the link's `sevr` mode.
199    pub severity: i32,
200    /// Remote alarm status code, derived from `severity` exactly as
201    /// pvxs `pvaGetAlarmMsg` does (`LINK_ALARM` when severity is
202    /// non-`NO_ALARM`, else `NO_ALARM` — `pvalink_lset.cpp:551`). See
203    /// [`RemoteAlarm::from_severity_message`].
204    pub status: i32,
205    /// Remote `alarm.message`. Empty when the remote carried none or
206    /// the severity is `NO_ALARM` (pvxs clears `snap_message` unless
207    /// `snap_severity != 0` — `pvalink_lset.cpp:418-422`).
208    pub message: String,
209}
210
211impl RemoteAlarm {
212    /// Build a snapshot whose `status` is derived from `severity`
213    /// exactly as pvxs `pvaGetAlarmMsg` (`pvalink_lset.cpp:551`):
214    /// `LINK_ALARM` when the remote severity is non-`NO_ALARM`, else
215    /// `NO_ALARM`. status and severity cannot disagree by construction.
216    pub fn from_severity_message(severity: i32, message: String) -> Self {
217        let status = if severity != 0 {
218            crate::server::recgbl::alarm_status::LINK_ALARM as i32
219        } else {
220            crate::server::recgbl::alarm_status::NO_ALARM as i32
221        };
222        Self {
223            severity,
224            status,
225            message,
226        }
227    }
228}
229
230/// Pluggable backend for one URL scheme's link operations.
231///
232/// All methods take `&self` so the implementation must use interior
233/// mutability for any cached state. None / false is the
234/// "unavailable" sentinel — the database falls back to a generic
235/// LINK/INVALID alarm when an lset returns None.
236#[async_trait::async_trait]
237pub trait LinkSet: Send + Sync {
238    /// True iff a fresh value is available for `name` without
239    /// blocking. Used by the record processing loop to decide
240    /// whether to mark the record's STAT as LINK_ALARM.
241    ///
242    /// Synchronous: asked on the record-processing thread inside the
243    /// record's advisory write gate. MUST NOT perform I/O.
244    fn is_connected(&self, name: &str) -> bool;
245
246    /// Read the current value of `name`. Returns None when the
247    /// upstream isn't yet connected or the lset has no cache for
248    /// this name.
249    ///
250    /// MAY perform I/O (open the channel, issue a one-shot GET). It is
251    /// therefore called only from the database's link work owner task —
252    /// the record-processing path uses [`Self::get_cached_value`].
253    async fn get_value(&self, name: &str) -> Option<EpicsValue>;
254
255    /// Read `name` from cached, monitor-fed state ONLY — the
256    /// record-processing read. C `dbCaGetLink` (`dbCa.c:448-535`) copies
257    /// out of `pca->pgetNative`, the buffer the CA monitor callback
258    /// (`eventCallback`, `dbCa.c:925`) keeps fresh on the `dbCaTask`; it
259    /// never opens a channel and never waits on the wire. Returns None
260    /// when the link has no cached value yet, which is C returning -1 for
261    /// `!pca->isConnected` (`dbCa.c:459-464`) — the reading record takes
262    /// LINK/INVALID for that cycle.
263    ///
264    /// MUST NOT perform I/O — which is why this is a `fn` and
265    /// [`Self::get_value`] is an `async fn`.
266    ///
267    /// Default: `None`, i.e. "this lset keeps no cache". That is C's
268    /// `!pca->isConnected` arm verbatim: the reading record takes
269    /// LINK/INVALID for the cycle and the database stages the link's
270    /// open on the link work owner ([`Self::connect_link`]), which is
271    /// what warms the cache for the next cycle. An lset that CAN answer
272    /// from memory MUST override, or its links never read.
273    fn get_cached_value(&self, name: &str) -> Option<EpicsValue> {
274        let _ = name;
275        None
276    }
277
278    /// Open (subscribe / connect) `name` so later
279    /// [`Self::get_cached_value`] reads have a cache to serve — C
280    /// `dbCaAddLink` (`dbCa.c:735-800`), which stages a `CA_CONNECT`
281    /// action whose `ca_create_channel` + `ca_add_array_event` run on the
282    /// `dbCaTask`, not on the caller.
283    ///
284    /// **Called from the database's link work owner task**, so it MAY
285    /// block on the network. Idempotent: the owner may call it again for
286    /// a link that is already open or still connecting.
287    ///
288    /// Default: drive the lset's own lazy open by reading through
289    /// [`Self::get_value`] and discarding the result — correct for every
290    /// existing lset, and it runs off the record-processing thread.
291    async fn connect_link(&self, name: &str) {
292        let _ = self.get_value(name).await;
293    }
294
295    /// Non-blocking admission gate for an OUT-link write, asked on the
296    /// record-processing thread *before* the write is staged onto the
297    /// database's link-put queue — C `dbCaPutLinkCallback`'s
298    /// `if (!pca->isConnected || !pca->hasWriteAccess) return -1;`
299    /// (`dbCa.c:558-561`).
300    ///
301    /// MUST NOT perform I/O. It is the one lset call left inside the
302    /// record's advisory write gate, and the whole point of the queue is
303    /// that nothing there touches the network.
304    ///
305    /// Default: derive from [`Self::is_connected`], which the trait already
306    /// documents as answerable "without blocking". An lset whose OUT links
307    /// live in a different cache than its INP links (pvalink keys its
308    /// registry on direction) MUST override, or every OUT write to a
309    /// perfectly healthy channel is refused.
310    fn put_admission(&self, name: &str) -> PutAdmission {
311        if self.is_connected(name) {
312            PutAdmission::Connected
313        } else {
314            PutAdmission::Disconnected
315        }
316    }
317
318    /// Write `value` to `name` with the delivery semantics named by
319    /// `op` ([`LinkPutOp::Plain`] for a fire-and-forget put,
320    /// [`LinkPutOp::Async`] for a put that is part of a put-notify /
321    /// blocking-put chain). Returns Err with a human-readable reason
322    /// on failure (denied, type-mismatch, no-such-pv, etc.). Default
323    /// impl rejects all writes — read-only lsets keep the default.
324    ///
325    /// **Called from the database's link-put owner task, never from a
326    /// record-processing thread** — this is the `dbCaTask` half of the
327    /// split (`dbCa.c:1226-1248`), so it may block on the network.
328    async fn put_value(&self, name: &str, value: EpicsValue, op: LinkPutOp) -> Result<(), String> {
329        let _ = (name, value, op);
330        Err("link set is read-only".into())
331    }
332
333    /// Fire `name`'s forward link (FLNK): trigger the remote target to
334    /// process, transferring no value.
335    ///
336    /// The lset counterpart of C `dbScanFwdLink` → `lset->scanForward`
337    /// (`dbLink.c:475`), realised by the pvalink lset as `pvaScanForward`
338    /// (`pvxs/ioc/pvalink_lset.cpp:672-688`). A forward link is never
339    /// deferred ("FWD_LINK is never deferred, and always results in a
340    /// Put") and carries no staged value: it forces the remote record to
341    /// process when the source record fires its FLNK.
342    ///
343    /// The lset applies the same non-retry validity gate pvxs does
344    /// (`pvalink_lset.cpp:677`): on a non-retry link that is not currently
345    /// connected it performs NO trigger and returns `Err`, so the caller
346    /// raises LINK/INVALID on the owning record — pvxs calls
347    /// `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "Disconn")` there.
348    ///
349    /// Default impl: `Ok(())` no-op. A read-only or DB-local lset
350    /// forwards nothing through this hook — a DB FLNK target is processed
351    /// directly by the database's `scanOnce` path (the DB lset's
352    /// `scanForward`), not through an external link set.
353    fn scan_forward(&self, name: &str) -> Result<(), String> {
354        let _ = name;
355        Ok(())
356    }
357
358    /// Flush any OUT-link writes the lset has queued but not yet sent —
359    /// the production drain trigger for an async OUT channel owner.
360    ///
361    /// Two queued states this drains: a write deferred for sibling
362    /// coalescing, and a write that failed mid-disconnect and is held
363    /// for replay once the upstream reconnects (`retry`). The database
364    /// calls this after every external OUT-link write so the
365    /// "retry on connect" path has a production caller from record
366    /// processing — not only test code. Default no-op: a synchronous
367    /// lset (DB links, a read-only lset) queues nothing.
368    ///
369    /// Mirrors the role of pvxs's shared `pvaLinkChannel::put()` being
370    /// driven from record processing rather than left to manual calls
371    /// (`pvxs/ioc/pvalink_lset.cpp:647`, `pvalink_channel.cpp:220-263`).
372    async fn flush_puts(&self) {}
373
374    /// Most recent alarm message string from the upstream PV, when
375    /// available. None means no alarm or no cache.
376    fn alarm_message(&self, _name: &str) -> Option<String> {
377        None
378    }
379
380    /// Alarm severity (`0 = NO_ALARM` … `3 = INVALID`) to fold into
381    /// the owning record's `LINK_ALARM`, when the link should
382    /// propagate one.
383    ///
384    /// `None` means "do not propagate" — either the upstream has no
385    /// alarm, the lset has no cache, or the link's maximize-severity
386    /// mode (`NMS`/`MS`/`MSI`) suppresses it. The lset is expected to
387    /// apply that mode gate itself (the `pva://X?sevr=MS` modifier is
388    /// stripped before epics-base-rs sees the link, so only the lset
389    /// retains it). A returned `Some(sev)` is therefore already
390    /// gated and the record processing loop propagates it verbatim
391    /// as a maximize-severity contribution. Mirrors pvxs
392    /// `pvalink_lset.cpp` `pvaGetAlarm` feeding `recGblSetSevr`.
393    fn alarm_severity(&self, _name: &str) -> Option<i32> {
394        None
395    }
396
397    /// Remote alarm *status* code (the EPICS `alarm_status` enum:
398    /// `0 = NO_ALARM`, `1 = READ`, … `17 = COMM`, …) from the upstream
399    /// PV, when available.
400    ///
401    /// used to honour the `MSS` (maximize-severity-and-
402    /// status) link modifier — the owning record then adopts the remote
403    /// STAT instead of the generic `LINK_ALARM`. `None` means the lset
404    /// cannot report a remote status (no cache, or the link set does not
405    /// track it); the caller falls back to `LINK_ALARM`, which is the
406    /// behaviour for every non-`MSS` modifier and for lsets that leave
407    /// this default. Mirrors pvxs `pvalink_lset.cpp` `pvaGetAlarm`
408    /// surfacing the remote `alarm.status` to `recGblSetSevrMsg`.
409    fn alarm_status(&self, _name: &str) -> Option<i32> {
410        None
411    }
412
413    /// Ungated remote alarm snapshot — the remote `(severity, status,
414    /// message)` after a successful value read, WITHOUT the
415    /// maximize-severity (`MS`/`NMS`/`MSI`) gate that
416    /// [`LinkSet::alarm_severity`] applies.
417    ///
418    /// This is the split pvxs draws between two operations: `pvaGetValue`
419    /// applies the `sevr` gate only when raising the *owning record's*
420    /// `LINK_ALARM` (`pvxs/ioc/pvalink_lset.cpp:424-431` — surfaced here
421    /// through [`LinkSet::alarm_severity`]), whereas `pvaGetAlarmMsg`
422    /// returns the cached `snap_severity` / `snap_message` snapshot
423    /// directly and never consults `sevr`
424    /// (`pvxs/ioc/pvalink_lset.cpp:542-575` — surfaced here). A caller
425    /// inspecting the DB link's alarm (`dbGetAlarm` / `dbGetAlarmMsg`)
426    /// therefore sees the remote severity even on a default `NMS` link
427    /// that leaves the owning record unraised.
428    ///
429    /// `None` means the lset cannot report a snapshot: no cache, the
430    /// link is not connected (pvxs `CHECK_VALID` — `pvalink_lset.cpp:545`),
431    /// or the link set does not track remote alarms. Default: none.
432    fn remote_alarm(&self, _name: &str) -> Option<RemoteAlarm> {
433        None
434    }
435
436    /// `(seconds_past_epoch, nanoseconds, userTag)` from the upstream
437    /// PV's timestamp slot, when available. The `userTag` is the remote
438    /// `timeStamp.userTag` widened to the 64-bit `epicsUTag` tag without
439    /// sign extension, or `0` when the source carries none (CA links, or
440    /// a PVA source whose timeStamp omits the field).
441    fn time_stamp(&self, _name: &str) -> Option<(i64, i32, u64)> {
442        None
443    }
444
445    /// Remote display / control / valueAlarm metadata for `name`, as
446    /// a single snapshot.
447    ///
448    /// The Rust counterpart of pvxs's pvalink lset metadata getter
449    /// set (`pvaGetDBFtype`, `pvaGetElements`, `pvaGetControlLimits`,
450    /// `pvaGetGraphicLimits`, `pvaGetAlarmLimits`, `pvaGetPrecision`,
451    /// `pvaGetUnits` — installed at `pvxs/ioc/pvalink_lset.cpp:700`).
452    /// A structured snapshot is used instead of seven separate trait
453    /// methods so the lset reads its cache once and record support
454    /// gets every linked-metadata field together.
455    ///
456    /// `None` means the lset has no cached value for `name` (not yet
457    /// connected); a `Some(LinkMetadata)` with individual `None`
458    /// fields means the remote NT value simply did not carry that
459    /// piece of metadata — the record then keeps its local default,
460    /// matching the C getters that leave the caller's buffer
461    /// untouched on a missing sub-field. Default impl: no metadata.
462    fn link_metadata(&self, _name: &str) -> Option<LinkMetadata> {
463        None
464    }
465
466    /// Enumerate every PV name this lset has *opened* (i.e., is
467    /// actively tracking). Used by `dbpvxr` to dump per-record
468    /// link state without forcing the caller to know the full
469    /// name list up-front.
470    fn link_names(&self) -> Vec<String> {
471        Vec::new()
472    }
473}
474
475/// Type-erased lset reference held by the [`LinkSetRegistry`].
476pub type DynLinkSet = Arc<dyn LinkSet>;
477
478/// Per-scheme registry. Held in a snapshot cell inside
479/// [`super::PvDatabase`]: readers take an `Arc` of the whole registry with no
480/// lock, and `register` rebuilds and republishes under the cell's writer gate
481/// (`doc/rtems-priority-locks-design.md` §3 row L8i). `Clone` is what makes
482/// that rebuild possible; it is a per-scheme `Arc` clone, not a deep copy.
483#[derive(Clone, Default)]
484pub struct LinkSetRegistry {
485    inner: std::collections::HashMap<String, DynLinkSet>,
486}
487
488impl LinkSetRegistry {
489    pub fn new() -> Self {
490        Self {
491            inner: std::collections::HashMap::new(),
492        }
493    }
494
495    /// Register `lset` under `scheme`. Subsequent calls for the same
496    /// scheme replace the previous binding.
497    pub fn register(&mut self, scheme: &str, lset: DynLinkSet) {
498        self.inner.insert(scheme.to_string(), lset);
499    }
500
501    /// Look up the lset for `scheme`. Returns `None` when nothing is
502    /// registered under that scheme.
503    pub fn get(&self, scheme: &str) -> Option<DynLinkSet> {
504        self.inner.get(scheme).cloned()
505    }
506
507    /// Names of every registered scheme (`["pva", "ca", ...]`).
508    pub fn schemes(&self) -> Vec<String> {
509        self.inner.keys().cloned().collect()
510    }
511
512    /// Number of registered schemes.
513    pub fn len(&self) -> usize {
514        self.inner.len()
515    }
516
517    pub fn is_empty(&self) -> bool {
518        self.inner.is_empty()
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    struct StubLset;
527    #[async_trait::async_trait]
528    impl LinkSet for StubLset {
529        fn is_connected(&self, _: &str) -> bool {
530            true
531        }
532        fn get_cached_value(&self, _: &str) -> Option<EpicsValue> {
533            Some(EpicsValue::Long(42))
534        }
535        async fn get_value(&self, name: &str) -> Option<EpicsValue> {
536            self.get_cached_value(name)
537        }
538    }
539
540    #[epics_macros_rs::epics_test]
541    async fn register_and_lookup() {
542        let mut reg = LinkSetRegistry::new();
543        assert!(reg.is_empty());
544        reg.register("pva", Arc::new(StubLset));
545        assert_eq!(reg.len(), 1);
546        let lset = reg.get("pva").expect("registered");
547        assert!(lset.is_connected("anything"));
548        assert_eq!(lset.get_value("anything").await, Some(EpicsValue::Long(42)));
549    }
550
551    #[test]
552    fn unknown_scheme_returns_none() {
553        let reg = LinkSetRegistry::new();
554        assert!(reg.get("missing").is_none());
555    }
556}