Skip to main content

epics_base_rs/types/
dbr.rs

1use crate::error::{CaError, CaResult};
2
3// DBR type ranges (matches db_access.h):
4//   native (0..=6), STS (7..=13), TIME (14..=20), GR (21..=27),
5//   CTRL (28..=34), special PUT_ACKT (35), PUT_ACKS (36),
6//   STSACK_STRING (37), CLASS_NAME (38).
7
8// Native scalar types (also exposed via DbFieldType enum)
9pub const DBR_STRING: u16 = 0;
10pub const DBR_SHORT: u16 = 1;
11pub const DBR_FLOAT: u16 = 2;
12pub const DBR_ENUM: u16 = 3;
13pub const DBR_CHAR: u16 = 4;
14pub const DBR_LONG: u16 = 5;
15pub const DBR_DOUBLE: u16 = 6;
16// `DBR_INT` is the libca alias for `DBR_SHORT`.
17pub const DBR_INT: u16 = DBR_SHORT;
18
19// Status-only metadata layer (CA-261)
20pub const DBR_STS_STRING: u16 = 7;
21pub const DBR_STS_SHORT: u16 = 8;
22pub const DBR_STS_FLOAT: u16 = 9;
23pub const DBR_STS_ENUM: u16 = 10;
24pub const DBR_STS_CHAR: u16 = 11;
25pub const DBR_STS_LONG: u16 = 12;
26pub const DBR_STS_DOUBLE: u16 = 13;
27pub const DBR_STS_INT: u16 = DBR_STS_SHORT;
28
29// Status + timestamp layer (CA-262)
30pub const DBR_TIME_STRING: u16 = 14;
31pub const DBR_TIME_SHORT: u16 = 15;
32pub const DBR_TIME_FLOAT: u16 = 16;
33pub const DBR_TIME_ENUM: u16 = 17;
34pub const DBR_TIME_CHAR: u16 = 18;
35pub const DBR_TIME_LONG: u16 = 19;
36pub const DBR_TIME_DOUBLE: u16 = 20;
37pub const DBR_TIME_INT: u16 = DBR_TIME_SHORT;
38
39// Status + graphic (display limits / units / precision) layer (CA-263)
40pub const DBR_GR_STRING: u16 = 21;
41pub const DBR_GR_SHORT: u16 = 22;
42pub const DBR_GR_FLOAT: u16 = 23;
43pub const DBR_GR_ENUM: u16 = 24;
44pub const DBR_GR_CHAR: u16 = 25;
45pub const DBR_GR_LONG: u16 = 26;
46pub const DBR_GR_DOUBLE: u16 = 27;
47pub const DBR_GR_INT: u16 = DBR_GR_SHORT;
48
49// Status + graphic + control limits layer (CA-264)
50pub const DBR_CTRL_STRING: u16 = 28;
51pub const DBR_CTRL_SHORT: u16 = 29;
52pub const DBR_CTRL_FLOAT: u16 = 30;
53pub const DBR_CTRL_ENUM: u16 = 31;
54pub const DBR_CTRL_CHAR: u16 = 32;
55pub const DBR_CTRL_LONG: u16 = 33;
56pub const DBR_CTRL_DOUBLE: u16 = 34;
57pub const DBR_CTRL_INT: u16 = DBR_CTRL_SHORT;
58
59// Special alarm-acknowledgement / introspection types
60pub const DBR_PUT_ACKT: u16 = 35;
61pub const DBR_PUT_ACKS: u16 = 36;
62pub const DBR_STSACK_STRING: u16 = 37;
63/// Returns the IOC's record-type class name as a 40-byte string
64/// (CA-268, db_access.h: `DBR_CLASS_NAME`).
65pub const DBR_CLASS_NAME: u16 = 38;
66
67/// Last allocated DBR type code, matching the C `LAST_BUFFER_TYPE` macro.
68pub const LAST_BUFFER_TYPE: u16 = DBR_CLASS_NAME;
69
70/// EPICS DBR field types (native types only)
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72#[repr(u16)]
73pub enum DbFieldType {
74    String = 0,
75    Short = 1, // aka Int16
76    Float = 2,
77    Enum = 3,
78    Char = 4, // aka UInt8
79    Long = 5, // aka Int32
80    Double = 6,
81    /// Internal-only type for int64in/int64out records.
82    /// No CA wire type 7 exists; over CA these PVs appear as Double (type 6).
83    Int64 = 7,
84    /// Internal-only type for unsigned 64-bit EPICS fields (C `DBF_UINT64`,
85    /// dbStatic `dbfType` index 8). The CA wire protocol has no 64-bit
86    /// type, so over CA these PVs appear as Double (type 6); over PVA they
87    /// are served natively as `ulong`. Mirrors `Int64`'s CA handling.
88    UInt64 = 8,
89    /// Internal-only type for unsigned 16-bit EPICS fields (C `DBF_USHORT`,
90    /// dbStatic `dbfType`). The CA wire protocol has no unsigned types, so
91    /// the IOC promotes `DBF_USHORT` to the next signed type that holds its
92    /// full `0..=65535` range — `DBR_LONG` (the C `dbDBRnewToDBRold` table,
93    /// `db_convert.h`: `5, /*DBR_USHORT to DBR_LONG*/`). Over PVA pvxs
94    /// serves it natively as `ushort` (`ioc/typeutils.cpp:38-40`:
95    /// `DBR_USHORT -> TypeCode::UInt16`). The discriminant is an internal
96    /// marker (not a CA wire code); see [`Self::ca_wire_type`].
97    UShort = 9,
98    /// Internal-only type for unsigned 32-bit EPICS fields (C `DBF_ULONG`,
99    /// dbStatic `dbfType`). `0..=4294967295` does not fit in `i32`, so the
100    /// IOC promotes `DBF_ULONG` to `DBR_DOUBLE` over CA exactly like
101    /// `UInt64`/`Int64` (`db_convert.h`: `6, /*DBR_ULONG to DBR_DOUBLE*/`).
102    /// Over PVA pvxs serves it natively as `uint` (`ioc/typeutils.cpp:43-44`:
103    /// `DBR_ULONG -> TypeCode::UInt32`).
104    ULong = 10,
105    /// Internal-only type for unsigned 8-bit EPICS fields (C `DBF_UCHAR`,
106    /// dbStatic `dbfType` index 2 / `waveform` `FTVL=UCHAR`). Unlike the
107    /// signed `Char` (epicsInt8), this is epicsUInt8. The CA wire protocol
108    /// has no unsigned types, so the IOC promotes `DBF_UCHAR` to `DBR_CHAR`
109    /// — the same 1-byte wire type as `Char` (the C `dbDBRnewToDBRold` table,
110    /// `db_convert.h`: `4, /*DBR_UCHAR to DBR_CHAR*/`); the raw bytes are
111    /// identical, only the signedness of the interpretation differs. Over PVA
112    /// pvxs serves it natively as `ubyte` (`ioc/typeutils.cpp:34-35`:
113    /// `DBR_UCHAR -> TypeCode::UInt8`), distinct from `Char`'s signed `byte`.
114    /// The discriminant is an internal marker (not a CA wire code); see
115    /// [`Self::ca_wire_type`].
116    UChar = 11,
117}
118
119impl DbFieldType {
120    /// This type as the CA **wire** carries its value.
121    ///
122    /// The single owner of the one row where the wire and the database
123    /// disagree. `db_access.h:40` is `typedef epicsUInt8 dbr_char_t;`, so a
124    /// `DBR_CHAR` element off the network is UNSIGNED; the `DBF_CHAR` it
125    /// shares a name with is `epicsInt8` (`epicsTypes.h:44`). Every other
126    /// row names the same type twice, and `DBF_UCHAR` has no wire code of
127    /// its own — it promotes to `DBR_CHAR` (`db_convert.h`
128    /// `dbDBRnewToDBRold`), which is why one carrier serves both.
129    ///
130    /// [`Self::from_u16`] and [`crate::types::native_type_for_dbr`] answer
131    /// the DATABASE question: which field type does this code name. Neither
132    /// is the wire's answer, so every site that turns *received* CA bytes
133    /// into a value composes one of them with this. The naive answer costs
134    /// a sign: byte `0xC8` is 200 to C and -56 without this.
135    ///
136    /// The signed reading is not lost, it is just not the carrier's. C
137    /// re-creates it at the DISPLAY step — `val2str` assigns the
138    /// `dbr_char_t` into a plain `char` before `sprintf("%d")`
139    /// (`ca/src/tools/tool_lib.c:114`, `:160-161`) — which is why C's own
140    /// `caget` prints -56 for a byte the wire called 200.
141    pub fn wire_carrier(self) -> Self {
142        match self {
143            Self::Char => Self::UChar,
144            other => other,
145        }
146    }
147
148    /// The DATABASE field type a `DBF_` index names. **Not** the carrier of
149    /// a CA wire payload — compose with [`Self::wire_carrier`] for that.
150    pub fn from_u16(v: u16) -> CaResult<Self> {
151        match v {
152            0 => Ok(Self::String),
153            1 => Ok(Self::Short),
154            2 => Ok(Self::Float),
155            3 => Ok(Self::Enum),
156            4 => Ok(Self::Char),
157            5 => Ok(Self::Long),
158            6 => Ok(Self::Double),
159            _ => Err(CaError::UnsupportedType(v)),
160        }
161    }
162
163    /// Size in bytes for a single element of this type's native carrier.
164    ///
165    /// This is the carrier width (`UShort` = 2, `ULong` = 4), not the
166    /// CA-wire-promoted width: the CA value path always promotes via
167    /// [`crate::types::EpicsValue::dbr_type`] first and sizes buffers off
168    /// the promoted type (`UShort`→`Long`=4, `ULong`→`Double`=8), so this
169    /// width is never used to size a CA value array for the unsigned types.
170    pub fn element_size(&self) -> usize {
171        match self {
172            Self::String => 40, // MAX_STRING_SIZE
173            Self::Short | Self::Enum | Self::UShort => 2,
174            Self::Float | Self::Long | Self::ULong => 4,
175            Self::Char | Self::UChar => 1,
176            Self::Double | Self::Int64 | Self::UInt64 => 8,
177        }
178    }
179
180    /// Return the wire type code as a `u16`. The internal-only types have
181    /// no CA wire code, so they report the signed CA type the IOC promotes
182    /// them to (C `dbDBRnewToDBRold`, `db_convert.h`): `Int64`/`UInt64`/
183    /// `ULong` → `DBR_DOUBLE` (6), `UShort` → `DBR_LONG` (5, the smallest
184    /// signed CA type that holds the full `0..=65535` range), `UChar` →
185    /// `DBR_CHAR` (4, same 1-byte wire type — the bytes are identical, only
186    /// the interpretation is unsigned).
187    pub fn ca_wire_type(&self) -> u16 {
188        match self {
189            Self::Int64 | Self::UInt64 | Self::ULong => Self::Double as u16,
190            Self::UShort => Self::Long as u16,
191            Self::UChar => Self::Char as u16,
192            other => *other as u16,
193        }
194    }
195
196    /// Return the `DBR_STS_xxx` type code for this native type
197    /// (Int64 maps to `DBR_STS_DOUBLE`).
198    pub fn sts_dbr_type(&self) -> u16 {
199        self.ca_wire_type() + 7
200    }
201
202    /// Return the `DBR_TIME_xxx` type code for this native type
203    /// (Int64 maps to `DBR_TIME_DOUBLE`).
204    pub fn time_dbr_type(&self) -> u16 {
205        self.ca_wire_type() + 14
206    }
207
208    /// Return the `DBR_GR_xxx` type code for this native type
209    /// (Int64 maps to `DBR_GR_DOUBLE`).
210    pub fn gr_dbr_type(&self) -> u16 {
211        self.ca_wire_type() + 21
212    }
213
214    /// Return the `DBR_CTRL_xxx` type code for this native type
215    /// (Int64 maps to `DBR_CTRL_DOUBLE`).
216    pub fn ctrl_dbr_type(&self) -> u16 {
217        self.ca_wire_type() + 28
218    }
219
220    /// Calculate total buffer size for N elements of this type.
221    /// Equivalent to C EPICS dbValueSize(type) * count.
222    pub fn buffer_size(&self, count: usize) -> usize {
223        self.element_size() * count
224    }
225
226    /// Map field type to request type (C EPICS mapDBFToDBR).
227    /// DBF_MENU and DBF_DEVICE map to DBR_ENUM in C EPICS.
228    /// In Rust these are already represented as DbFieldType::Enum,
229    /// so this is an identity mapping for documentation/completeness.
230    pub fn to_dbr_type(&self) -> DbFieldType {
231        *self
232    }
233}
234
235/// dbStatic link-field classes — the three `dbfType` values that mark a
236/// record field as a *link* rather than a value
237/// (`dbFldTypes.h`: `DBF_INLINK`=14, `DBF_OUTLINK`=15, `DBF_FWDLINK`=16).
238///
239/// pvxs rejects a QSRV group PUT to any field whose
240/// `dbChannelFinalFieldType` falls in `DBF_INLINK..=DBF_FWDLINK`
241/// (`ioc/groupsource.cpp:596-606`). The Rust port has no dbStatic field
242/// table, so [`dbf_link_class`] reconstructs the same classification from
243/// the EPICS Base / synApps `*.dbd(.pod)` link-field families and returns
244/// the matching class. Consumers gate "is this field a link" on
245/// `dbf_link_class(..).is_some()` (or [`is_link_dbf_type`] when they
246/// already hold a dbStatic code), rather than maintaining their own
247/// partial spelling lists.
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249#[repr(u8)]
250pub enum DbfLinkClass {
251    /// `DBF_INLINK` (14) — an input link (`INP`, `DOL`, `SIML`, …).
252    InLink = DBF_INLINK,
253    /// `DBF_OUTLINK` (15) — an output link (`OUT`, `LNKn`, …).
254    OutLink = DBF_OUTLINK,
255    /// `DBF_FWDLINK` (16) — a forward link (`FLNK`).
256    FwdLink = DBF_FWDLINK,
257}
258
259/// `dbFldTypes.h` `DBF_INLINK`.
260pub const DBF_INLINK: u8 = 14;
261/// `dbFldTypes.h` `DBF_OUTLINK`.
262pub const DBF_OUTLINK: u8 = 15;
263/// `dbFldTypes.h` `DBF_FWDLINK`.
264pub const DBF_FWDLINK: u8 = 16;
265
266impl DbfLinkClass {
267    /// The dbStatic `dbfType` numeric code (`dbFldTypes.h`).
268    pub fn dbf_type(self) -> u8 {
269        self as u8
270    }
271}
272
273/// True iff `dbf_type` is a link class — the exact
274/// `DBF_INLINK <= t <= DBF_FWDLINK` range check pvxs applies in
275/// `ioc/groupsource.cpp:596-606`. Use when a caller already holds a
276/// dbStatic field-type code; [`dbf_link_class`] is the name-keyed entry
277/// point for the Rust port, which carries no dbStatic table.
278pub fn is_link_dbf_type(dbf_type: u8) -> bool {
279    (DBF_INLINK..=DBF_FWDLINK).contains(&dbf_type)
280}
281
282/// Classify a record field by its dbStatic link class, or `None` when it
283/// is not a link field. This is the single canonical owner of the
284/// "is this field a link" rule for the Rust port — the structural
285/// replacement for the partial, per-consumer name lists that the
286/// `groupsource.cpp:596-606` review flagged (a record-type-blind
287/// spelling list re-opens the bypass for every record that names a link
288/// field outside the list).
289///
290/// `record_type` is the record's `recordType` (`ai`, `bo`, `seq`, …); it
291/// is consulted to resolve the two direction-ambiguous fields that share a
292/// spelling across record families:
293///   - `SIOL` is `DBF_OUTLINK` on output records and `DBF_INLINK` on input
294///     records (compare `boRecord.dbd.pod:318` `SIOL,DBF_OUTLINK` vs
295///     `aiRecord`/`biRecord` `SIOL,DBF_INLINK`).
296///   - `LNK0..LNKF` is `DBF_FWDLINK` on `fanoutRecord` (the multi-forward
297///     fan-out family) and `DBF_OUTLINK` on `seqRecord` / synApps
298///     `sseqRecord` (compare `fanoutRecord.dbd.pod` `field(LNK0,DBF_FWDLINK)`
299///     vs `seqRecord.dbd.pod` `field(LNK0,DBF_OUTLINK)`).
300///
301/// Every other family has a fixed class across all record types.
302///
303/// Names and classes are taken verbatim from EPICS Base / synApps
304/// `*.dbd(.pod)`:
305///   - dbCommon: `FLNK`→Fwd, `SDIS`/`TSEL`→In.
306///   - `INP`/`DOL`/`SIML`/`NVL`/`SVL`/`SUBL`/`SELL`→In, `OUT`→Out.
307///   - `SIOL`→Out on output records, else In.
308///   - `INPA..INPU`/`INP0..INP9`→In; `OUTA..OUTU`→Out.
309///   - `DOL0..DOL9`/`DOLA..DOLF`→In.
310///   - `LNK0..LNK9`/`LNKA..LNKF`→Fwd on `fanout`, else Out.
311pub fn dbf_link_class(record_type: &str, field: &str) -> Option<DbfLinkClass> {
312    use DbfLinkClass::*;
313    let f = field.trim().to_ascii_uppercase();
314
315    // Exact dbCommon + record-specific named link fields.
316    match f.as_str() {
317        "FLNK" => return Some(FwdLink),
318        "SDIS" | "TSEL" | "INP" | "DOL" | "SIML" | "NVL" | "SVL" | "SUBL" | "SELL" => {
319            return Some(InLink);
320        }
321        "OUT" => return Some(OutLink),
322        "SIOL" => {
323            return Some(if is_output_record_type(record_type) {
324                OutLink
325            } else {
326                InLink
327            });
328        }
329        _ => {}
330    }
331
332    // Indexed / lettered link families: a known prefix plus exactly one
333    // alphanumeric suffix character. The DBDs use `A..U` / `0..9` / `A..F`,
334    // but any single-alnum suffix on these prefixes is a link in every
335    // base record that defines the family, so a one-char-suffix rule is
336    // both complete and on the safe (reject) side for custom records.
337    let one_alnum = |rest: &str| rest.len() == 1 && rest.as_bytes()[0].is_ascii_alphanumeric();
338    // `LNK0..LNKF` is direction-ambiguous by record type the same way
339    // `SIOL` is: `fanoutRecord` declares the family as `DBF_FWDLINK`
340    // (multi-forward fan-out), every other family that uses the spelling
341    // (`seqRecord`, synApps `sseqRecord`) declares it `DBF_OUTLINK`.
342    // Resolve it once here so the prefix table below carries a single
343    // class per spelling rather than collapsing fanout's forward links to
344    // output links by prefix.
345    let lnk_class = if is_fanout_link_record(record_type) {
346        FwdLink
347    } else {
348        OutLink
349    };
350    for (prefix, class) in [
351        ("INP", InLink),
352        ("DOL", InLink),
353        ("OUT", OutLink),
354        ("LNK", lnk_class),
355    ] {
356        if let Some(rest) = f.strip_prefix(prefix) {
357            if one_alnum(rest) {
358                return Some(class);
359            }
360        }
361    }
362    None
363}
364
365/// Record types whose `SIOL` simulation-output link is `DBF_OUTLINK`
366/// (the output records). Input records declare `SIOL` as `DBF_INLINK`.
367/// Same output-record set as the device-write records — compare
368/// `crate::server::record::Record::can_device_write`.
369///
370/// The population is every `field(SIOL,DBF_OUTLINK)` in the record types this
371/// workspace ports: the nine Base output records plus synApps `busy`
372/// (`busyRecord.dbd`), a `bo` derivative whose SIOL is an output like its
373/// parent's. Every other SIOL-bearing type — `ai` `bi` `mbbi` `mbbiDirect`
374/// `longin` `int64in` `stringin` `lsi` `event` `waveform` `aai` `histogram`,
375/// synApps `swait` and `mca` — declares `DBF_INLINK`.
376fn is_output_record_type(record_type: &str) -> bool {
377    matches!(
378        record_type,
379        "ao" | "bo"
380            | "busy"
381            | "longout"
382            | "int64out"
383            | "mbbo"
384            | "mbboDirect"
385            | "stringout"
386            | "lso"
387            | "aao"
388    )
389}
390
391/// `fanoutRecord` is the one Base family whose `LNK0..LNKF` fields are
392/// `DBF_FWDLINK` (forward links fired in numerical order). Every other
393/// family that uses the same `LNK*` spelling — `seqRecord`, synApps
394/// `sseqRecord` — declares them `DBF_OUTLINK`. Compare
395/// `fanoutRecord.dbd.pod` `field(LNK0,DBF_FWDLINK)` vs `seqRecord.dbd.pod`
396/// `field(LNK0,DBF_OUTLINK)`.
397fn is_fanout_link_record(record_type: &str) -> bool {
398    record_type == "fanout"
399}
400
401/// Calculate buffer size for a DBR type including metadata, matching C
402/// `dbr_size_n(TYPE, COUNT) = dbr_size[TYPE] + (COUNT-1)*dbr_value_size[TYPE]`.
403///
404/// the metadata length is taken from
405/// `crate::types::codec::dbr_meta_size` — the single owner that the
406/// serializers (`serialize_dbr` / `encode_dbr`) emit against — so the
407/// explicit-count pad/truncate and no-read-access frame paths size
408/// TIME / GR / CTRL bodies exactly as the encoder writes them. A
409/// `metadata_matches_encoded_length` test pins `encoded_len ==
410/// dbr_buffer_size` across the whole (dbr_type, native) matrix, so the
411/// sizer can no longer drift from the encoder.
412pub fn dbr_buffer_size(dbr_type: u16, native_type: DbFieldType, count: usize) -> usize {
413    // DBR_CLASS_NAME (38) is always one MAX_STRING_SIZE (40) string,
414    // regardless of `count` or `native_type` — it carries no value[]
415    // array, so the generic meta+value formula does not apply.
416    if dbr_type == DBR_CLASS_NAME {
417        return 40;
418    }
419    let value_size = native_type.element_size() * count;
420    crate::types::codec::dbr_meta_size(dbr_type, native_type) + value_size
421}
422
423/// Extract the native DBF type index (0-6) from any DBR type code.
424fn dbr_native_index(dbr_type: u16) -> Option<u16> {
425    match dbr_type {
426        0..=6 => Some(dbr_type),
427        7..=13 => Some(dbr_type - 7),
428        14..=20 => Some(dbr_type - 14),
429        21..=27 => Some(dbr_type - 21),
430        28..=34 => Some(dbr_type - 28),
431        // Alarm-acknowledge writes carry a single u16, so map them to
432        // Short for codec purposes. STSACK_STRING returns a string body
433        // so it maps to String.
434        35 | 36 => Some(1), // DBR_PUT_ACKT / DBR_PUT_ACKS — u16
435        37 => Some(0),      // DBR_STSACK_STRING — value is a string
436        // DBR_CLASS_NAME is a single fixed 40-byte string carrying the
437        // record's recordType. Treat as String for codec purposes.
438        38 => Some(0),
439        _ => None,
440    }
441}
442
443/// The DATABASE field type a CA DBR code is named after.
444///
445/// **Not** the carrier of a payload that arrived over the wire: compose
446/// with [`DbFieldType::wire_carrier`] for that. The two answers differ for
447/// the CHAR row only, and that one row is the whole of CA's signedness
448/// mismatch.
449pub fn native_type_for_dbr(dbr_type: u16) -> CaResult<DbFieldType> {
450    match dbr_native_index(dbr_type) {
451        Some(idx) => DbFieldType::from_u16(idx),
452        None => Err(CaError::UnsupportedType(dbr_type)),
453    }
454}
455
456/// DBR request-type names indexed by type code, mirroring the C
457/// `dbr_text[]` table (`ca/src/client/access.cpp`). Index 0 =
458/// `DBR_STRING` … index 38 = `DBR_CLASS_NAME`.
459const DBR_TEXT: [&str; (LAST_BUFFER_TYPE + 1) as usize] = [
460    "DBR_STRING",
461    "DBR_SHORT",
462    "DBR_FLOAT",
463    "DBR_ENUM",
464    "DBR_CHAR",
465    "DBR_LONG",
466    "DBR_DOUBLE",
467    "DBR_STS_STRING",
468    "DBR_STS_SHORT",
469    "DBR_STS_FLOAT",
470    "DBR_STS_ENUM",
471    "DBR_STS_CHAR",
472    "DBR_STS_LONG",
473    "DBR_STS_DOUBLE",
474    "DBR_TIME_STRING",
475    "DBR_TIME_SHORT",
476    "DBR_TIME_FLOAT",
477    "DBR_TIME_ENUM",
478    "DBR_TIME_CHAR",
479    "DBR_TIME_LONG",
480    "DBR_TIME_DOUBLE",
481    "DBR_GR_STRING",
482    "DBR_GR_SHORT",
483    "DBR_GR_FLOAT",
484    "DBR_GR_ENUM",
485    "DBR_GR_CHAR",
486    "DBR_GR_LONG",
487    "DBR_GR_DOUBLE",
488    "DBR_CTRL_STRING",
489    "DBR_CTRL_SHORT",
490    "DBR_CTRL_FLOAT",
491    "DBR_CTRL_ENUM",
492    "DBR_CTRL_CHAR",
493    "DBR_CTRL_LONG",
494    "DBR_CTRL_DOUBLE",
495    "DBR_PUT_ACKT",
496    "DBR_PUT_ACKS",
497    "DBR_STSACK_STRING",
498    "DBR_CLASS_NAME",
499];
500
501/// Resolve a DBR request-type name to its type code, mirroring the C
502/// `dbr_text_to_type` macro (`db_access.h`): an exact, **case-sensitive**
503/// `strcmp` search of the `dbr_text[]` table. Returns the matching code
504/// (`0..=38`) or `None` when no name matches.
505///
506/// The case sensitivity is faithful to C — the `caget`/`caput` tools
507/// feed `-d <type>` straight through this search, so `-d DBR_TIME_FLOAT`
508/// resolves while `-d dbr_time_float` does not (the C tool then reverts
509/// to its plain/native request). Callers that accept the bare family
510/// (`caget -d TIME_FLOAT`) retry with a `DBR_` prefix, exactly as
511/// `caget.c` does.
512pub fn dbr_text_to_type(text: &str) -> Option<u16> {
513    DBR_TEXT.iter().position(|&n| n == text).map(|i| i as u16)
514}
515
516/// Resolve a DBR type code to its name, mirroring the C
517/// `dbr_type_to_text` macro (`db_access.h`): an index into the same
518/// `dbr_text[]` table, with C's `"DBR_invalid"` for anything outside
519/// `0..=38`. Inverse of [`dbr_text_to_type`], and the single owner of
520/// that direction — the CA client's exception block
521/// (`CA.Client.Exception ... type=%s`) and `caget -d`'s "Request type:"
522/// line both read the names from here.
523pub fn dbr_type_to_text(code: u16) -> &'static str {
524    DBR_TEXT
525        .get(code as usize)
526        .copied()
527        .unwrap_or("DBR_invalid")
528}
529
530#[cfg(test)]
531mod buffer_size_tests {
532    use super::*;
533
534    /// STS meta size is per-type. `dbr_sts_double` carries a
535    /// 4-byte `dbr_long_t` RISC_pad (db_access.h:233-238) → meta 8.
536    #[test]
537    fn sts_double_meta_is_8() {
538        // scalar: 8 (meta) + 8 (value) = 16
539        assert_eq!(dbr_buffer_size(DBR_STS_DOUBLE, DbFieldType::Double, 1), 16);
540        // n elements: 8 + 8*n
541        assert_eq!(
542            dbr_buffer_size(DBR_STS_DOUBLE, DbFieldType::Double, 5),
543            8 + 8 * 5
544        );
545    }
546
547    /// `dbr_sts_char` carries a 1-byte RISC_pad
548    /// (db_access.h:218-223) → meta 5.
549    #[test]
550    fn sts_char_meta_is_5() {
551        assert_eq!(dbr_buffer_size(DBR_STS_CHAR, DbFieldType::Char, 1), 6);
552        assert_eq!(dbr_buffer_size(DBR_STS_CHAR, DbFieldType::Char, 10), 5 + 10);
553    }
554
555    /// types with no STS RISC pad keep the flat 4-byte meta.
556    #[test]
557    fn sts_short_meta_is_4() {
558        assert_eq!(dbr_buffer_size(DBR_STS_SHORT, DbFieldType::Short, 1), 6);
559        assert_eq!(dbr_buffer_size(DBR_STS_LONG, DbFieldType::Long, 1), 8);
560        assert_eq!(dbr_buffer_size(DBR_STS_FLOAT, DbFieldType::Float, 1), 8);
561    }
562
563    /// Plain values carry no metadata.
564    #[test]
565    fn plain_value_size_only() {
566        assert_eq!(dbr_buffer_size(DBR_DOUBLE, DbFieldType::Double, 3), 24);
567    }
568
569    /// TIME structs carry a per-type RISC pad before `value[0]`
570    /// (C `dbr_time_*`, db_access.h:250-300). The pre-fix flat 12-byte
571    /// TIME meta truncated double/short/enum/char bodies.
572    #[test]
573    fn time_meta_includes_risc_pad() {
574        // double: 12 + RISC_pad(4) + value(8) = 24 (was wrongly 20).
575        assert_eq!(dbr_buffer_size(DBR_TIME_DOUBLE, DbFieldType::Double, 1), 24);
576        // short/enum: 12 + pad(2) + value(2) = 16.
577        assert_eq!(dbr_buffer_size(DBR_TIME_SHORT, DbFieldType::Short, 1), 16);
578        assert_eq!(dbr_buffer_size(DBR_TIME_ENUM, DbFieldType::Enum, 1), 16);
579        // char: 12 + pad(3) + value(1) = 16.
580        assert_eq!(dbr_buffer_size(DBR_TIME_CHAR, DbFieldType::Char, 1), 16);
581        // float/long: no pad (value already 4-aligned at offset 12).
582        assert_eq!(dbr_buffer_size(DBR_TIME_FLOAT, DbFieldType::Float, 1), 16);
583        assert_eq!(dbr_buffer_size(DBR_TIME_LONG, DbFieldType::Long, 1), 16);
584        // Explicit count scales the value array after the pad.
585        assert_eq!(
586            dbr_buffer_size(DBR_TIME_DOUBLE, DbFieldType::Double, 4),
587            16 + 8 * 4
588        );
589    }
590
591    /// GR/CTRL metadata is per native type (the pre-fix single
592    /// broad formula over-padded short/char/float/long and dropped the
593    /// enum `no_str` word).
594    #[test]
595    fn gr_ctrl_meta_is_per_type() {
596        // GR (6 limits): head(4) + layout.
597        assert_eq!(dbr_buffer_size(DBR_GR_SHORT, DbFieldType::Short, 1), 24 + 2);
598        assert_eq!(dbr_buffer_size(DBR_GR_FLOAT, DbFieldType::Float, 1), 40 + 4);
599        assert_eq!(
600            dbr_buffer_size(DBR_GR_DOUBLE, DbFieldType::Double, 1),
601            64 + 8
602        );
603        assert_eq!(dbr_buffer_size(DBR_GR_CHAR, DbFieldType::Char, 1), 19 + 1);
604        assert_eq!(dbr_buffer_size(DBR_GR_LONG, DbFieldType::Long, 1), 36 + 4);
605        // Enum: head(4) + no_str(2) + 16*26 strings = 422, value(2).
606        assert_eq!(dbr_buffer_size(DBR_GR_ENUM, DbFieldType::Enum, 1), 422 + 2);
607        // CTRL adds two control limits.
608        assert_eq!(
609            dbr_buffer_size(DBR_CTRL_DOUBLE, DbFieldType::Double, 1),
610            80 + 8
611        );
612        assert_eq!(
613            dbr_buffer_size(DBR_CTRL_SHORT, DbFieldType::Short, 1),
614            28 + 2
615        );
616        assert_eq!(dbr_buffer_size(DBR_CTRL_CHAR, DbFieldType::Char, 1), 21 + 1);
617    }
618}
619
620#[cfg(test)]
621mod dbf_link_class_tests {
622    use super::*;
623
624    #[test]
625    fn dbcommon_links_classified_uniformly() {
626        // Present on every record (dbCommon.dbd).
627        assert_eq!(dbf_link_class("ai", "FLNK"), Some(DbfLinkClass::FwdLink));
628        assert_eq!(dbf_link_class("ao", "SDIS"), Some(DbfLinkClass::InLink));
629        assert_eq!(dbf_link_class("calc", "TSEL"), Some(DbfLinkClass::InLink));
630    }
631
632    #[test]
633    fn record_specific_link_families_the_old_name_list_missed() {
634        // The families the reviewed partial spelling list omitted, each a
635        // DBF_INLINK/OUTLINK in EPICS Base `*.dbd.pod`:
636        //   seqRecord DOL0 (INLINK) / LNK0 (OUTLINK) / DOLA / DOLF / LNKF
637        assert_eq!(dbf_link_class("seq", "DOL0"), Some(DbfLinkClass::InLink));
638        assert_eq!(dbf_link_class("seq", "LNK0"), Some(DbfLinkClass::OutLink));
639        assert_eq!(dbf_link_class("seq", "DOLA"), Some(DbfLinkClass::InLink));
640        assert_eq!(dbf_link_class("seq", "DOLF"), Some(DbfLinkClass::InLink));
641        assert_eq!(dbf_link_class("seq", "LNKF"), Some(DbfLinkClass::OutLink));
642        //   selRecord NVL (INLINK); histogramRecord SVL (INLINK)
643        assert_eq!(dbf_link_class("sel", "NVL"), Some(DbfLinkClass::InLink));
644        assert_eq!(
645            dbf_link_class("histogram", "SVL"),
646            Some(DbfLinkClass::InLink)
647        );
648        //   calc/aSub INPA..INPU (INLINK); fanout/aSub OUTA (OUTLINK)
649        assert_eq!(dbf_link_class("calc", "INPA"), Some(DbfLinkClass::InLink));
650        assert_eq!(dbf_link_class("aSub", "INPU"), Some(DbfLinkClass::InLink));
651        assert_eq!(
652            dbf_link_class("fanout", "OUTA"),
653            Some(DbfLinkClass::OutLink)
654        );
655        //   printf INP0..INP9 (INLINK)
656        assert_eq!(dbf_link_class("printf", "INP0"), Some(DbfLinkClass::InLink));
657    }
658
659    #[test]
660    fn siol_class_depends_on_record_direction() {
661        // boRecord.dbd.pod:318 SIOL=DBF_OUTLINK; ai/bi SIOL=DBF_INLINK.
662        assert_eq!(dbf_link_class("bo", "SIOL"), Some(DbfLinkClass::OutLink));
663        assert_eq!(dbf_link_class("ao", "SIOL"), Some(DbfLinkClass::OutLink));
664        assert_eq!(dbf_link_class("ai", "SIOL"), Some(DbfLinkClass::InLink));
665        assert_eq!(dbf_link_class("bi", "SIOL"), Some(DbfLinkClass::InLink));
666        // SIML is always DBF_INLINK regardless of direction.
667        assert_eq!(dbf_link_class("bo", "SIML"), Some(DbfLinkClass::InLink));
668        assert_eq!(dbf_link_class("ai", "SIML"), Some(DbfLinkClass::InLink));
669    }
670
671    /// Every `field(SIOL,DBF_*)` in the record types this workspace ports,
672    /// read out of the C dbds. synApps `busy` is the one non-Base output
673    /// record — it derives from `bo` and declares `field(SIOL,DBF_OUTLINK)`
674    /// (`busyRecord.dbd`) — while synApps `swait` (`swaitRecord.dbd`) and
675    /// `mca` (`mcaRecord.dbd`) declare `DBF_INLINK`. A missing output entry is
676    /// silent: the classifier defaults to `InLink`, and the CP/CPP mask C
677    /// applies to an output link (`dbStaticLib.c:2380-2391`) is then skipped.
678    #[test]
679    fn siol_direction_matches_every_c_dbd_this_workspace_ports() {
680        for rtype in [
681            "ao",
682            "bo",
683            "busy",
684            "longout",
685            "int64out",
686            "mbbo",
687            "mbboDirect",
688            "stringout",
689            "lso",
690            "aao",
691        ] {
692            assert_eq!(
693                dbf_link_class(rtype, "SIOL"),
694                Some(DbfLinkClass::OutLink),
695                "{rtype} declares field(SIOL,DBF_OUTLINK)"
696            );
697        }
698        for rtype in [
699            "ai",
700            "bi",
701            "mbbi",
702            "mbbiDirect",
703            "longin",
704            "int64in",
705            "stringin",
706            "lsi",
707            "event",
708            "waveform",
709            "aai",
710            "histogram",
711            "swait",
712            "mca",
713        ] {
714            assert_eq!(
715                dbf_link_class(rtype, "SIOL"),
716                Some(DbfLinkClass::InLink),
717                "{rtype} declares field(SIOL,DBF_INLINK)"
718            );
719        }
720    }
721
722    /// The consequence a misclassified SIOL actually has: `check_link_assignment`
723    /// turns the class into a [`LinkFieldType`], and only the `Out` arm applies
724    /// C's `modifiers &= ~(pvlOptCPP|pvlOptCP)`. Classified as an input, a
725    /// `busy` SIOL would keep a CPP that C strips.
726    #[test]
727    fn a_busy_siol_discards_cp_the_way_an_output_link_must() {
728        use crate::server::record::{
729            LinkFieldType, LinkProcessPolicy, ParsedLink, parse_link_field,
730        };
731
732        let ftype = match dbf_link_class("busy", "SIOL").unwrap() {
733            DbfLinkClass::InLink => LinkFieldType::In,
734            DbfLinkClass::OutLink => LinkFieldType::Out,
735            DbfLinkClass::FwdLink => LinkFieldType::Fwd,
736        };
737        match parse_link_field("TGT.VAL CPP", ftype) {
738            ParsedLink::Db(db) => assert_eq!(db.policy, LinkProcessPolicy::NoProcess),
739            other => panic!("expected a db link, got {other:?}"),
740        }
741    }
742
743    /// `LNK0..LNKF` shares
744    /// its spelling across two DBF classes — `fanoutRecord` declares the
745    /// family `DBF_FWDLINK`, while `seqRecord`/`sseqRecord` declare it
746    /// `DBF_OUTLINK`. The classifier must resolve by `record_type`, not
747    /// collapse every `LNK*` to OutLink by prefix.
748    #[test]
749    fn lnk_class_depends_on_record_type() {
750        // fanoutRecord.dbd.pod field(LNK0,DBF_FWDLINK) … field(LNKF,…).
751        assert_eq!(
752            dbf_link_class("fanout", "LNK0"),
753            Some(DbfLinkClass::FwdLink),
754            "fanout LNK0 is DBF_FWDLINK (16), not OUTLINK"
755        );
756        assert_eq!(
757            dbf_link_class("fanout", "LNKF"),
758            Some(DbfLinkClass::FwdLink),
759            "fanout LNKF is DBF_FWDLINK (16), not OUTLINK"
760        );
761        // seqRecord.dbd.pod field(LNK0,DBF_OUTLINK): the default direction
762        // for the shared spelling.
763        assert_eq!(dbf_link_class("seq", "LNK0"), Some(DbfLinkClass::OutLink));
764        assert_eq!(dbf_link_class("seq", "LNKF"), Some(DbfLinkClass::OutLink));
765        // synApps sseqRecord LNK1..LNK9 are also DBF_OUTLINK (LNK10 is the
766        // two-char spelling the one-alnum suffix rule deliberately rejects).
767        assert_eq!(dbf_link_class("sseq", "LNK1"), Some(DbfLinkClass::OutLink));
768        // fanout's dbCommon FLNK is still a forward link (unchanged).
769        assert_eq!(
770            dbf_link_class("fanout", "FLNK"),
771            Some(DbfLinkClass::FwdLink)
772        );
773    }
774
775    #[test]
776    fn plain_value_fields_are_not_links() {
777        for f in [
778            "VAL", "EGU", "PREC", "HOPR", "RVAL", "DESC", "A", "B", "OVAL",
779        ] {
780            assert_eq!(dbf_link_class("ai", f), None, "{f} must not be a link");
781        }
782    }
783
784    #[test]
785    fn case_insensitive_and_trims() {
786        assert_eq!(dbf_link_class("ai", "inp"), Some(DbfLinkClass::InLink));
787        assert_eq!(dbf_link_class("ao", " OUT "), Some(DbfLinkClass::OutLink));
788    }
789
790    #[test]
791    fn dbf_codes_and_range_match_pvxs() {
792        assert_eq!(DbfLinkClass::InLink.dbf_type(), 14);
793        assert_eq!(DbfLinkClass::OutLink.dbf_type(), 15);
794        assert_eq!(DbfLinkClass::FwdLink.dbf_type(), 16);
795        // pvxs groupsource.cpp:596-606 range check.
796        assert!(is_link_dbf_type(DBF_INLINK));
797        assert!(is_link_dbf_type(DBF_OUTLINK));
798        assert!(is_link_dbf_type(DBF_FWDLINK));
799        assert!(!is_link_dbf_type(13)); // DBF_DEVICE
800        assert!(!is_link_dbf_type(17)); // DBF_NOACCESS
801        assert!(!is_link_dbf_type(0)); // DBF_STRING
802    }
803}
804
805#[cfg(test)]
806mod dbr_text_tests {
807    use super::*;
808
809    #[test]
810    fn resolves_every_type_name_to_its_code() {
811        // Round-trip the whole table: each name resolves to its index.
812        for (code, name) in DBR_TEXT.iter().enumerate() {
813            assert_eq!(
814                dbr_text_to_type(name),
815                Some(code as u16),
816                "{name} should resolve to {code}"
817            );
818        }
819        // Boundary names spot-check (the cited High-finding values).
820        assert_eq!(dbr_text_to_type("DBR_STRING"), Some(DBR_STRING));
821        assert_eq!(dbr_text_to_type("DBR_TIME_FLOAT"), Some(DBR_TIME_FLOAT));
822        assert_eq!(
823            dbr_text_to_type("DBR_STSACK_STRING"),
824            Some(DBR_STSACK_STRING)
825        );
826        assert_eq!(dbr_text_to_type("DBR_CLASS_NAME"), Some(DBR_CLASS_NAME));
827    }
828
829    #[test]
830    fn is_case_sensitive_like_c_strcmp() {
831        // C `dbr_text_to_type` uses `strcmp`, so lowercase does not
832        // match — the C tools then revert to their plain request.
833        assert_eq!(dbr_text_to_type("dbr_time_float"), None);
834        assert_eq!(dbr_text_to_type("DBR_Time_Float"), None);
835    }
836
837    #[test]
838    fn unknown_and_bare_family_names_do_not_match() {
839        // Bare family names need the caller's `DBR_` retry — the raw
840        // search rejects them, matching C's first-pass `strcmp`.
841        assert_eq!(dbr_text_to_type("TIME_FLOAT"), None);
842        assert_eq!(dbr_text_to_type("DOUBLE"), None);
843        assert_eq!(dbr_text_to_type("DBR_NONSENSE"), None);
844        assert_eq!(dbr_text_to_type(""), None);
845    }
846}