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
119/// C's dbStatic `dbfType` code — the `DBF_*` token a `.dbd` declares and
120/// the number `dbStaticLib` stores, exports and prints (`dbFldTypes.h:24-43`
121/// @R7.0.10).
122///
123/// A SECOND fact about a field, not a renumbering of [`DbFieldType`]:
124/// that enum's discriminants are the CA **wire** codes and are load-bearing
125/// on the protocol, whereas these are dbStatic's own indices, and the two
126/// orders disagree from the second entry on (`DBF_CHAR` is 1 here and
127/// `DBR_SHORT` is 1 there). Anything that prints C's `%d` type number —
128/// `dba`'s `Field Type` row, dbStatic's `dbDumpField` — needs this one.
129///
130/// It also has six codes [`DbFieldType`] cannot express at all: a served
131/// type is one of twelve scalars, while a *declaration* can additionally be
132/// `MENU`, `DEVICE`, the three link kinds, or `NOACCESS`. That is why
133/// [`super::super::server::record::FieldDesc::declared_dbf`] carries this
134/// type rather than deriving it — the collapse is one-way.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
136#[repr(i16)]
137pub enum DbfCode {
138    /// `DBF_STRING` (0).
139    String = 0,
140    /// `DBF_CHAR` (1) — `epicsInt8`.
141    Char = 1,
142    /// `DBF_UCHAR` (2) — `epicsUInt8`.
143    UChar = 2,
144    /// `DBF_SHORT` (3).
145    Short = 3,
146    /// `DBF_USHORT` (4).
147    UShort = 4,
148    /// `DBF_LONG` (5).
149    Long = 5,
150    /// `DBF_ULONG` (6).
151    ULong = 6,
152    /// `DBF_INT64` (7).
153    Int64 = 7,
154    /// `DBF_UINT64` (8).
155    UInt64 = 8,
156    /// `DBF_FLOAT` (9).
157    Float = 9,
158    /// `DBF_DOUBLE` (10).
159    Double = 10,
160    /// `DBF_ENUM` (11).
161    Enum = 11,
162    /// `DBF_MENU` (12) — served as an enum, declared as a menu.
163    Menu = 12,
164    /// `DBF_DEVICE` (13) — the `DTYP` field, served as an enum.
165    Device = 13,
166    /// `DBF_INLINK` (14).
167    Inlink = 14,
168    /// `DBF_OUTLINK` (15).
169    Outlink = 15,
170    /// `DBF_FWDLINK` (16).
171    Fwdlink = 16,
172    /// `DBF_NOACCESS` (17) — a C-internal with no dbStatic representation.
173    NoAccess = 17,
174}
175
176impl DbfCode {
177    /// The token without its `DBF_` prefix — C's `dbf[]` (`dbTest.c:76-81`),
178    /// which is `pamapdbfType`'s `strvalue` (`dbFldTypes.h:51-70`) with that
179    /// prefix stripped. `dba` prints `DBF_%s` around it and dbStatic prints
180    /// the full name, so one table serves both.
181    pub const fn name(self) -> &'static str {
182        match self {
183            DbfCode::String => "STRING",
184            DbfCode::Char => "CHAR",
185            DbfCode::UChar => "UCHAR",
186            DbfCode::Short => "SHORT",
187            DbfCode::UShort => "USHORT",
188            DbfCode::Long => "LONG",
189            DbfCode::ULong => "ULONG",
190            DbfCode::Int64 => "INT64",
191            DbfCode::UInt64 => "UINT64",
192            DbfCode::Float => "FLOAT",
193            DbfCode::Double => "DOUBLE",
194            DbfCode::Enum => "ENUM",
195            DbfCode::Menu => "MENU",
196            DbfCode::Device => "DEVICE",
197            DbfCode::Inlink => "INLINK",
198            DbfCode::Outlink => "OUTLINK",
199            DbfCode::Fwdlink => "FWDLINK",
200            DbfCode::NoAccess => "NOACCESS",
201        }
202    }
203
204    /// C's `mapDBFToDBR` (`dbAccess.c:76-95`) — the DBR code `dbEntryToAddr`
205    /// stores in `paddr->dbr_field_type` (`:639`), which is what a client
206    /// asking for this field's *native* type is answered with.
207    ///
208    /// `DBR_*` here is `dbFldTypes.h:75-90`'s numbering, where every
209    /// `DBR_x` is `#define`d to its `DBF_x`, so the map is a projection of
210    /// this enum onto itself and needs no second type: it collapses `MENU`
211    /// and `DEVICE` onto `ENUM` and the three link types onto `STRING`, and
212    /// is the identity on the other thirteen.
213    ///
214    /// NOT the CA wire type. That numbering is the older, narrower
215    /// `db_access.h` one carried by [`DbFieldType`]'s own discriminants and
216    /// reached through [`DbFieldType::ca_wire_type`]; the two disagree on
217    /// every code above `DBR_SHORT`.
218    pub const fn dbr_code(self) -> DbfCode {
219        match self {
220            DbfCode::Menu | DbfCode::Device => DbfCode::Enum,
221            DbfCode::Inlink | DbfCode::Outlink | DbfCode::Fwdlink => DbfCode::String,
222            other => other,
223        }
224    }
225}
226
227impl DbFieldType {
228    /// The dbStatic code for the type this field is **served** as.
229    ///
230    /// The mapping only ever answers one of the twelve scalar codes,
231    /// because that is all a served type can be. It is therefore NOT a way
232    /// to recover a declaration: `ai.INP` and `dbCommon.FLNK` are both
233    /// served as [`DbFieldType::String`] and this returns
234    /// [`DbfCode::String`] for both, while their declarations are
235    /// `DBF_INLINK` and `DBF_FWDLINK`. Read
236    /// [`crate::server::record::FieldDesc::declared_dbf`] when the
237    /// question is what the `.dbd` said.
238    pub const fn dbf_code(self) -> DbfCode {
239        match self {
240            DbFieldType::String => DbfCode::String,
241            DbFieldType::Char => DbfCode::Char,
242            DbFieldType::UChar => DbfCode::UChar,
243            DbFieldType::Short => DbfCode::Short,
244            DbFieldType::UShort => DbfCode::UShort,
245            DbFieldType::Long => DbfCode::Long,
246            DbFieldType::ULong => DbfCode::ULong,
247            DbFieldType::Int64 => DbfCode::Int64,
248            DbFieldType::UInt64 => DbfCode::UInt64,
249            DbFieldType::Float => DbfCode::Float,
250            DbFieldType::Double => DbfCode::Double,
251            DbFieldType::Enum => DbfCode::Enum,
252        }
253    }
254
255    /// This type as the CA **wire** carries its value.
256    ///
257    /// The single owner of the one row where the wire and the database
258    /// disagree. `db_access.h:40` is `typedef epicsUInt8 dbr_char_t;`, so a
259    /// `DBR_CHAR` element off the network is UNSIGNED; the `DBF_CHAR` it
260    /// shares a name with is `epicsInt8` (`epicsTypes.h:44`). Every other
261    /// row names the same type twice, and `DBF_UCHAR` has no wire code of
262    /// its own — it promotes to `DBR_CHAR` (`db_convert.h`
263    /// `dbDBRnewToDBRold`), which is why one carrier serves both.
264    ///
265    /// [`Self::from_u16`] and [`crate::types::native_type_for_dbr`] answer
266    /// the DATABASE question: which field type does this code name. Neither
267    /// is the wire's answer, so every site that turns *received* CA bytes
268    /// into a value composes one of them with this. The naive answer costs
269    /// a sign: byte `0xC8` is 200 to C and -56 without this.
270    ///
271    /// The signed reading is not lost, it is just not the carrier's. C
272    /// re-creates it at the DISPLAY step — `val2str` assigns the
273    /// `dbr_char_t` into a plain `char` before `sprintf("%d")`
274    /// (`ca/src/tools/tool_lib.c:114`, `:160-161`) — which is why C's own
275    /// `caget` prints -56 for a byte the wire called 200.
276    pub fn wire_carrier(self) -> Self {
277        match self {
278            Self::Char => Self::UChar,
279            other => other,
280        }
281    }
282
283    /// The DATABASE field type a `DBF_` index names. **Not** the carrier of
284    /// a CA wire payload — compose with [`Self::wire_carrier`] for that.
285    pub fn from_u16(v: u16) -> CaResult<Self> {
286        match v {
287            0 => Ok(Self::String),
288            1 => Ok(Self::Short),
289            2 => Ok(Self::Float),
290            3 => Ok(Self::Enum),
291            4 => Ok(Self::Char),
292            5 => Ok(Self::Long),
293            6 => Ok(Self::Double),
294            _ => Err(CaError::UnsupportedType(v)),
295        }
296    }
297
298    /// Size in bytes for a single element of this type's native carrier.
299    ///
300    /// This is the carrier width (`UShort` = 2, `ULong` = 4), not the
301    /// CA-wire-promoted width: the CA value path always promotes via
302    /// [`crate::types::EpicsValue::dbr_type`] first and sizes buffers off
303    /// the promoted type (`UShort`→`Long`=4, `ULong`→`Double`=8), so this
304    /// width is never used to size a CA value array for the unsigned types.
305    pub fn element_size(&self) -> usize {
306        match self {
307            Self::String => 40, // MAX_STRING_SIZE
308            Self::Short | Self::Enum | Self::UShort => 2,
309            Self::Float | Self::Long | Self::ULong => 4,
310            Self::Char | Self::UChar => 1,
311            Self::Double | Self::Int64 | Self::UInt64 => 8,
312        }
313    }
314
315    /// Return the wire type code as a `u16`. The internal-only types have
316    /// no CA wire code, so they report the signed CA type the IOC promotes
317    /// them to (C `dbDBRnewToDBRold`, `db_convert.h`): `Int64`/`UInt64`/
318    /// `ULong` → `DBR_DOUBLE` (6), `UShort` → `DBR_LONG` (5, the smallest
319    /// signed CA type that holds the full `0..=65535` range), `UChar` →
320    /// `DBR_CHAR` (4, same 1-byte wire type — the bytes are identical, only
321    /// the interpretation is unsigned).
322    pub fn ca_wire_type(&self) -> u16 {
323        match self {
324            Self::Int64 | Self::UInt64 | Self::ULong => Self::Double as u16,
325            Self::UShort => Self::Long as u16,
326            Self::UChar => Self::Char as u16,
327            other => *other as u16,
328        }
329    }
330
331    /// Return the `DBR_STS_xxx` type code for this native type
332    /// (Int64 maps to `DBR_STS_DOUBLE`).
333    pub fn sts_dbr_type(&self) -> u16 {
334        self.ca_wire_type() + 7
335    }
336
337    /// Return the `DBR_TIME_xxx` type code for this native type
338    /// (Int64 maps to `DBR_TIME_DOUBLE`).
339    pub fn time_dbr_type(&self) -> u16 {
340        self.ca_wire_type() + 14
341    }
342
343    /// Return the `DBR_GR_xxx` type code for this native type
344    /// (Int64 maps to `DBR_GR_DOUBLE`).
345    pub fn gr_dbr_type(&self) -> u16 {
346        self.ca_wire_type() + 21
347    }
348
349    /// Return the `DBR_CTRL_xxx` type code for this native type
350    /// (Int64 maps to `DBR_CTRL_DOUBLE`).
351    pub fn ctrl_dbr_type(&self) -> u16 {
352        self.ca_wire_type() + 28
353    }
354
355    /// Calculate total buffer size for N elements of this type.
356    /// Equivalent to C EPICS dbValueSize(type) * count.
357    pub fn buffer_size(&self, count: usize) -> usize {
358        self.element_size() * count
359    }
360
361    /// Map field type to request type (C EPICS mapDBFToDBR).
362    /// DBF_MENU and DBF_DEVICE map to DBR_ENUM in C EPICS.
363    /// In Rust these are already represented as DbFieldType::Enum,
364    /// so this is an identity mapping for documentation/completeness.
365    pub fn to_dbr_type(&self) -> DbFieldType {
366        *self
367    }
368}
369
370/// dbStatic link-field classes — the three `dbfType` values that mark a
371/// record field as a *link* rather than a value
372/// (`dbFldTypes.h`: `DBF_INLINK`=14, `DBF_OUTLINK`=15, `DBF_FWDLINK`=16).
373///
374/// pvxs rejects a QSRV group PUT to any field whose
375/// `dbChannelFinalFieldType` falls in `DBF_INLINK..=DBF_FWDLINK`
376/// (`ioc/groupsource.cpp:596-606`). [`dbf_link_class`] answers the same
377/// question from the port's dbStatic field table — the generated
378/// `FieldDesc::declared_dbf`. Consumers gate "is this field a link" on
379/// `dbf_link_class(..).is_some()` (or [`is_link_dbf_type`] when they
380/// already hold a dbStatic code), rather than maintaining their own
381/// partial spelling lists.
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383#[repr(u8)]
384pub enum DbfLinkClass {
385    /// `DBF_INLINK` (14) — an input link (`INP`, `DOL`, `SIML`, …).
386    InLink = DBF_INLINK,
387    /// `DBF_OUTLINK` (15) — an output link (`OUT`, `LNKn`, …).
388    OutLink = DBF_OUTLINK,
389    /// `DBF_FWDLINK` (16) — a forward link (`FLNK`).
390    FwdLink = DBF_FWDLINK,
391}
392
393/// `dbFldTypes.h` `DBF_INLINK`.
394pub const DBF_INLINK: u8 = 14;
395/// `dbFldTypes.h` `DBF_OUTLINK`.
396pub const DBF_OUTLINK: u8 = 15;
397/// `dbFldTypes.h` `DBF_FWDLINK`.
398pub const DBF_FWDLINK: u8 = 16;
399
400impl DbfLinkClass {
401    /// The dbStatic `dbfType` numeric code (`dbFldTypes.h`).
402    pub fn dbf_type(self) -> u8 {
403        self as u8
404    }
405}
406
407/// True iff `dbf_type` is a link class — the exact
408/// `DBF_INLINK <= t <= DBF_FWDLINK` range check pvxs applies in
409/// `ioc/groupsource.cpp:596-606`. Use when a caller already holds a
410/// dbStatic field-type code; [`dbf_link_class`] is the entry point that
411/// resolves the code from the record type and field name first.
412pub fn is_link_dbf_type(dbf_type: u8) -> bool {
413    (DBF_INLINK..=DBF_FWDLINK).contains(&dbf_type)
414}
415
416/// Classify a record field by its dbStatic link class, or `None` when it
417/// is not a link field. The single canonical owner of the "is this field a
418/// link" rule for the Rust port.
419///
420/// The answer is the `.dbd` declaration, read through
421/// [`declared_field`](crate::server::record::declared_field) — the by-name
422/// half of the same lookup a caller holding a record does with
423/// `field_desc_of`. Nothing here reasons about the field's spelling.
424///
425/// It used to. `FieldDesc` carried only the type a field is SERVED as, which
426/// is `DBF_STRING` for all three link classes, so the class had to be
427/// reconstructed from the name: an exact list, plus `INP`/`DOL`/`OUT`/`LNK`
428/// followed by exactly one alphanumeric, plus per-record-type exceptions for
429/// `SIOL` and `LNK*`. Measured against the declarations `declared_dbf` now
430/// carries, that rule was wrong at 86 fields — 24 `INAA..INLL` inputs on
431/// `acalcout`/`scalcout` and 52 more on `motor`, `table`, `scaler`, `epid` and
432/// `throttle` that it called non-links, and 10 non-links (`*.OUTV`,
433/// `calcout.INPV`, `compress.INPN`, five `swait` fields) that it called links.
434/// A one-character-suffix rule cannot express a two-character suffix, and no
435/// exception list catches a family it has never been told about.
436///
437/// `record_type` no longer resolves ambiguity — it selects the declaration
438/// table. A type `dbd_generated` does not cover (`motor`, `table`, …) must
439/// have registered its factory for its own fields to resolve; until then only
440/// `dbCommon` answers. That is the same condition under which the record could
441/// be instantiated at all.
442pub fn dbf_link_class(record_type: &str, field: &str) -> Option<DbfLinkClass> {
443    let desc = crate::server::record::declared_field(record_type, field.trim())?;
444    match desc.declared_dbf {
445        DbfCode::Inlink => Some(DbfLinkClass::InLink),
446        DbfCode::Outlink => Some(DbfLinkClass::OutLink),
447        DbfCode::Fwdlink => Some(DbfLinkClass::FwdLink),
448        _ => None,
449    }
450}
451
452/// Calculate buffer size for a DBR type including metadata, matching C
453/// `dbr_size_n(TYPE, COUNT) = dbr_size[TYPE] + (COUNT-1)*dbr_value_size[TYPE]`.
454///
455/// the metadata length is taken from
456/// `crate::types::codec::dbr_meta_size` — the single owner that the
457/// serializers (`serialize_dbr` / `encode_dbr`) emit against — so the
458/// explicit-count pad/truncate and no-read-access frame paths size
459/// TIME / GR / CTRL bodies exactly as the encoder writes them. A
460/// `metadata_matches_encoded_length` test pins `encoded_len ==
461/// dbr_buffer_size` across the whole (dbr_type, native) matrix, so the
462/// sizer can no longer drift from the encoder.
463pub fn dbr_buffer_size(dbr_type: u16, native_type: DbFieldType, count: usize) -> usize {
464    // DBR_CLASS_NAME (38) is always one MAX_STRING_SIZE (40) string,
465    // regardless of `count` or `native_type` — it carries no value[]
466    // array, so the generic meta+value formula does not apply.
467    if dbr_type == DBR_CLASS_NAME {
468        return 40;
469    }
470    let value_size = native_type.element_size() * count;
471    crate::types::codec::dbr_meta_size(dbr_type, native_type) + value_size
472}
473
474/// Extract the native DBF type index (0-6) from any DBR type code.
475fn dbr_native_index(dbr_type: u16) -> Option<u16> {
476    match dbr_type {
477        0..=6 => Some(dbr_type),
478        7..=13 => Some(dbr_type - 7),
479        14..=20 => Some(dbr_type - 14),
480        21..=27 => Some(dbr_type - 21),
481        28..=34 => Some(dbr_type - 28),
482        // Alarm-acknowledge writes carry a single u16, so map them to
483        // Short for codec purposes. STSACK_STRING returns a string body
484        // so it maps to String.
485        35 | 36 => Some(1), // DBR_PUT_ACKT / DBR_PUT_ACKS — u16
486        37 => Some(0),      // DBR_STSACK_STRING — value is a string
487        // DBR_CLASS_NAME is a single fixed 40-byte string carrying the
488        // record's recordType. Treat as String for codec purposes.
489        38 => Some(0),
490        _ => None,
491    }
492}
493
494/// The DATABASE field type a CA DBR code is named after.
495///
496/// **Not** the carrier of a payload that arrived over the wire: compose
497/// with [`DbFieldType::wire_carrier`] for that. The two answers differ for
498/// the CHAR row only, and that one row is the whole of CA's signedness
499/// mismatch.
500pub fn native_type_for_dbr(dbr_type: u16) -> CaResult<DbFieldType> {
501    match dbr_native_index(dbr_type) {
502        Some(idx) => DbFieldType::from_u16(idx),
503        None => Err(CaError::UnsupportedType(dbr_type)),
504    }
505}
506
507/// DBR request-type names indexed by type code, mirroring the C
508/// `dbr_text[]` table (`ca/src/client/access.cpp`). Index 0 =
509/// `DBR_STRING` … index 38 = `DBR_CLASS_NAME`.
510const DBR_TEXT: [&str; (LAST_BUFFER_TYPE + 1) as usize] = [
511    "DBR_STRING",
512    "DBR_SHORT",
513    "DBR_FLOAT",
514    "DBR_ENUM",
515    "DBR_CHAR",
516    "DBR_LONG",
517    "DBR_DOUBLE",
518    "DBR_STS_STRING",
519    "DBR_STS_SHORT",
520    "DBR_STS_FLOAT",
521    "DBR_STS_ENUM",
522    "DBR_STS_CHAR",
523    "DBR_STS_LONG",
524    "DBR_STS_DOUBLE",
525    "DBR_TIME_STRING",
526    "DBR_TIME_SHORT",
527    "DBR_TIME_FLOAT",
528    "DBR_TIME_ENUM",
529    "DBR_TIME_CHAR",
530    "DBR_TIME_LONG",
531    "DBR_TIME_DOUBLE",
532    "DBR_GR_STRING",
533    "DBR_GR_SHORT",
534    "DBR_GR_FLOAT",
535    "DBR_GR_ENUM",
536    "DBR_GR_CHAR",
537    "DBR_GR_LONG",
538    "DBR_GR_DOUBLE",
539    "DBR_CTRL_STRING",
540    "DBR_CTRL_SHORT",
541    "DBR_CTRL_FLOAT",
542    "DBR_CTRL_ENUM",
543    "DBR_CTRL_CHAR",
544    "DBR_CTRL_LONG",
545    "DBR_CTRL_DOUBLE",
546    "DBR_PUT_ACKT",
547    "DBR_PUT_ACKS",
548    "DBR_STSACK_STRING",
549    "DBR_CLASS_NAME",
550];
551
552/// Resolve a DBR request-type name to its type code, mirroring the C
553/// `dbr_text_to_type` macro (`db_access.h`): an exact, **case-sensitive**
554/// `strcmp` search of the `dbr_text[]` table. Returns the matching code
555/// (`0..=38`) or `None` when no name matches.
556///
557/// The case sensitivity is faithful to C — the `caget`/`caput` tools
558/// feed `-d <type>` straight through this search, so `-d DBR_TIME_FLOAT`
559/// resolves while `-d dbr_time_float` does not (the C tool then reverts
560/// to its plain/native request). Callers that accept the bare family
561/// (`caget -d TIME_FLOAT`) retry with a `DBR_` prefix, exactly as
562/// `caget.c` does.
563pub fn dbr_text_to_type(text: &str) -> Option<u16> {
564    DBR_TEXT.iter().position(|&n| n == text).map(|i| i as u16)
565}
566
567/// Resolve a DBR type code to its name, mirroring the C
568/// `dbr_type_to_text` macro (`db_access.h`): an index into the same
569/// `dbr_text[]` table, with C's `"DBR_invalid"` for anything outside
570/// `0..=38`. Inverse of [`dbr_text_to_type`], and the single owner of
571/// that direction — the CA client's exception block
572/// (`CA.Client.Exception ... type=%s`) and `caget -d`'s "Request type:"
573/// line both read the names from here.
574pub fn dbr_type_to_text(code: u16) -> &'static str {
575    DBR_TEXT
576        .get(code as usize)
577        .copied()
578        .unwrap_or("DBR_invalid")
579}
580
581#[cfg(test)]
582mod buffer_size_tests {
583    use super::*;
584
585    /// STS meta size is per-type. `dbr_sts_double` carries a
586    /// 4-byte `dbr_long_t` RISC_pad (db_access.h:233-238) → meta 8.
587    #[test]
588    fn sts_double_meta_is_8() {
589        // scalar: 8 (meta) + 8 (value) = 16
590        assert_eq!(dbr_buffer_size(DBR_STS_DOUBLE, DbFieldType::Double, 1), 16);
591        // n elements: 8 + 8*n
592        assert_eq!(
593            dbr_buffer_size(DBR_STS_DOUBLE, DbFieldType::Double, 5),
594            8 + 8 * 5
595        );
596    }
597
598    /// `dbr_sts_char` carries a 1-byte RISC_pad
599    /// (db_access.h:218-223) → meta 5.
600    #[test]
601    fn sts_char_meta_is_5() {
602        assert_eq!(dbr_buffer_size(DBR_STS_CHAR, DbFieldType::Char, 1), 6);
603        assert_eq!(dbr_buffer_size(DBR_STS_CHAR, DbFieldType::Char, 10), 5 + 10);
604    }
605
606    /// types with no STS RISC pad keep the flat 4-byte meta.
607    #[test]
608    fn sts_short_meta_is_4() {
609        assert_eq!(dbr_buffer_size(DBR_STS_SHORT, DbFieldType::Short, 1), 6);
610        assert_eq!(dbr_buffer_size(DBR_STS_LONG, DbFieldType::Long, 1), 8);
611        assert_eq!(dbr_buffer_size(DBR_STS_FLOAT, DbFieldType::Float, 1), 8);
612    }
613
614    /// Plain values carry no metadata.
615    #[test]
616    fn plain_value_size_only() {
617        assert_eq!(dbr_buffer_size(DBR_DOUBLE, DbFieldType::Double, 3), 24);
618    }
619
620    /// TIME structs carry a per-type RISC pad before `value[0]`
621    /// (C `dbr_time_*`, db_access.h:250-300). The pre-fix flat 12-byte
622    /// TIME meta truncated double/short/enum/char bodies.
623    #[test]
624    fn time_meta_includes_risc_pad() {
625        // double: 12 + RISC_pad(4) + value(8) = 24 (was wrongly 20).
626        assert_eq!(dbr_buffer_size(DBR_TIME_DOUBLE, DbFieldType::Double, 1), 24);
627        // short/enum: 12 + pad(2) + value(2) = 16.
628        assert_eq!(dbr_buffer_size(DBR_TIME_SHORT, DbFieldType::Short, 1), 16);
629        assert_eq!(dbr_buffer_size(DBR_TIME_ENUM, DbFieldType::Enum, 1), 16);
630        // char: 12 + pad(3) + value(1) = 16.
631        assert_eq!(dbr_buffer_size(DBR_TIME_CHAR, DbFieldType::Char, 1), 16);
632        // float/long: no pad (value already 4-aligned at offset 12).
633        assert_eq!(dbr_buffer_size(DBR_TIME_FLOAT, DbFieldType::Float, 1), 16);
634        assert_eq!(dbr_buffer_size(DBR_TIME_LONG, DbFieldType::Long, 1), 16);
635        // Explicit count scales the value array after the pad.
636        assert_eq!(
637            dbr_buffer_size(DBR_TIME_DOUBLE, DbFieldType::Double, 4),
638            16 + 8 * 4
639        );
640    }
641
642    /// GR/CTRL metadata is per native type (the pre-fix single
643    /// broad formula over-padded short/char/float/long and dropped the
644    /// enum `no_str` word).
645    #[test]
646    fn gr_ctrl_meta_is_per_type() {
647        // GR (6 limits): head(4) + layout.
648        assert_eq!(dbr_buffer_size(DBR_GR_SHORT, DbFieldType::Short, 1), 24 + 2);
649        assert_eq!(dbr_buffer_size(DBR_GR_FLOAT, DbFieldType::Float, 1), 40 + 4);
650        assert_eq!(
651            dbr_buffer_size(DBR_GR_DOUBLE, DbFieldType::Double, 1),
652            64 + 8
653        );
654        assert_eq!(dbr_buffer_size(DBR_GR_CHAR, DbFieldType::Char, 1), 19 + 1);
655        assert_eq!(dbr_buffer_size(DBR_GR_LONG, DbFieldType::Long, 1), 36 + 4);
656        // Enum: head(4) + no_str(2) + 16*26 strings = 422, value(2).
657        assert_eq!(dbr_buffer_size(DBR_GR_ENUM, DbFieldType::Enum, 1), 422 + 2);
658        // CTRL adds two control limits.
659        assert_eq!(
660            dbr_buffer_size(DBR_CTRL_DOUBLE, DbFieldType::Double, 1),
661            80 + 8
662        );
663        assert_eq!(
664            dbr_buffer_size(DBR_CTRL_SHORT, DbFieldType::Short, 1),
665            28 + 2
666        );
667        assert_eq!(dbr_buffer_size(DBR_CTRL_CHAR, DbFieldType::Char, 1), 21 + 1);
668    }
669}
670
671#[cfg(test)]
672mod dbf_link_class_tests {
673    use super::*;
674
675    #[test]
676    fn dbcommon_links_classified_uniformly() {
677        // Present on every record (dbCommon.dbd).
678        assert_eq!(dbf_link_class("ai", "FLNK"), Some(DbfLinkClass::FwdLink));
679        assert_eq!(dbf_link_class("ao", "SDIS"), Some(DbfLinkClass::InLink));
680        assert_eq!(dbf_link_class("calc", "TSEL"), Some(DbfLinkClass::InLink));
681    }
682
683    #[test]
684    fn record_specific_link_families_the_old_name_list_missed() {
685        // The families the reviewed partial spelling list omitted, each a
686        // DBF_INLINK/OUTLINK in EPICS Base `*.dbd.pod`:
687        //   seqRecord DOL0 (INLINK) / LNK0 (OUTLINK) / DOLA / DOLF / LNKF
688        assert_eq!(dbf_link_class("seq", "DOL0"), Some(DbfLinkClass::InLink));
689        assert_eq!(dbf_link_class("seq", "LNK0"), Some(DbfLinkClass::OutLink));
690        assert_eq!(dbf_link_class("seq", "DOLA"), Some(DbfLinkClass::InLink));
691        assert_eq!(dbf_link_class("seq", "DOLF"), Some(DbfLinkClass::InLink));
692        assert_eq!(dbf_link_class("seq", "LNKF"), Some(DbfLinkClass::OutLink));
693        //   selRecord NVL (INLINK); histogramRecord SVL (INLINK)
694        assert_eq!(dbf_link_class("sel", "NVL"), Some(DbfLinkClass::InLink));
695        assert_eq!(
696            dbf_link_class("histogram", "SVL"),
697            Some(DbfLinkClass::InLink)
698        );
699        //   calc/aSub INPA..INPU (INLINK); dfanout/aSub OUTA (OUTLINK)
700        assert_eq!(dbf_link_class("calc", "INPA"), Some(DbfLinkClass::InLink));
701        assert_eq!(dbf_link_class("aSub", "INPU"), Some(DbfLinkClass::InLink));
702        assert_eq!(
703            dbf_link_class("dfanout", "OUTA"),
704            Some(DbfLinkClass::OutLink)
705        );
706        // `fanout` declares LNK0..LNKF and no OUT family at all; the name
707        // rule this replaced answered `OutLink` for a field that does not
708        // exist on the type.
709        assert_eq!(dbf_link_class("fanout", "OUTA"), None);
710        //   printf INP0..INP9 (INLINK)
711        assert_eq!(dbf_link_class("printf", "INP0"), Some(DbfLinkClass::InLink));
712    }
713
714    /// **The invariant this function exists to hold**: for every record type
715    /// and every field, `dbf_link_class` agrees with the `.dbd` declaration.
716    ///
717    /// One case per declaration, not one per link family — a per-family test
718    /// is what let 86 fields drift, because a family nobody wrote a case for
719    /// is invisible to it. This walks the table instead, so a new record type
720    /// or a new link family is covered the day it is generated.
721    #[test]
722    fn every_declared_field_classifies_as_its_dbd_says() {
723        use crate::server::record::dbd_generated::{DB_COMMON_FIELDS, RECORD_TYPES, record_fields};
724        let mut wrong = Vec::new();
725        for rt in RECORD_TYPES {
726            let own = record_fields(rt).unwrap_or(&[]);
727            for d in DB_COMMON_FIELDS.iter().chain(own.iter()) {
728                let declared = match d.declared_dbf {
729                    DbfCode::Inlink => Some(DbfLinkClass::InLink),
730                    DbfCode::Outlink => Some(DbfLinkClass::OutLink),
731                    DbfCode::Fwdlink => Some(DbfLinkClass::FwdLink),
732                    _ => None,
733                };
734                let got = dbf_link_class(rt, d.name);
735                if got != declared {
736                    wrong.push(format!("{rt}.{} declared={declared:?} got={got:?}", d.name));
737                }
738            }
739        }
740        assert!(
741            wrong.is_empty(),
742            "{} fields disagree with their declaration:\n{}",
743            wrong.len(),
744            wrong.join("\n")
745        );
746    }
747
748    /// The two shapes the name rule could not express, pinned by name so a
749    /// regression names itself rather than appearing as a count.
750    #[test]
751    fn the_two_shapes_the_name_rule_could_not_express() {
752        // A two-character suffix. `INAA..INLL` are 12 declared `DBF_INLINK`
753        // inputs on each of `acalcout` and `scalcout`; the one-alphanumeric
754        // suffix rule called all 24 non-links.
755        for rt in ["acalcout", "scalcout"] {
756            for f in ["INAA", "INBB", "INLL"] {
757                assert_eq!(
758                    dbf_link_class(rt, f),
759                    Some(DbfLinkClass::InLink),
760                    "{rt}.{f}"
761                );
762            }
763        }
764        // A non-link that merely spells like one. `OUTV` is the output-value
765        // field, `INPV` the input-value one; neither is a link.
766        for (rt, f) in [
767            ("acalcout", "OUTV"),
768            ("calcout", "OUTV"),
769            ("scalcout", "OUTV"),
770            ("calcout", "INPV"),
771            ("compress", "INPN"),
772            ("swait", "DOLN"),
773            ("swait", "OUTN"),
774            ("swait", "DOLV"),
775            ("swait", "OUTV"),
776            ("swait", "DOLD"),
777        ] {
778            assert_eq!(dbf_link_class(rt, f), None, "{rt}.{f} is not a link field");
779        }
780    }
781
782    /// A record type outside `dbd_generated` resolves through the registry a
783    /// factory registration fills — the by-name mirror of `field_list`.
784    #[test]
785    fn a_registered_downstream_type_resolves_its_own_declarations() {
786        use crate::server::record::{FieldDesc, register_declared_fields};
787        static FIELDS: &[FieldDesc] = &[FieldDesc {
788            name: "RDBL",
789            dbf_type: DbFieldType::String,
790            declared_dbf: DbfCode::Inlink,
791            runtime_typed: false,
792            read_only: false,
793            special: crate::server::record::Special::None,
794            declared_special: crate::server::record::Special::None,
795            pp: false,
796            asl: crate::server::record::Asl::Asl1,
797            size: 0,
798            extra: None,
799            menu: None,
800            initial: None,
801            interest: 0,
802            prop: false,
803            prompt: None,
804            promptgroup: None,
805            base: crate::server::record::Base::Decimal,
806        }];
807
808        // Before registration only `dbCommon` answers for an unknown type.
809        assert_eq!(dbf_link_class("zzTestRec", "RDBL"), None);
810        assert_eq!(
811            dbf_link_class("zzTestRec", "FLNK"),
812            Some(DbfLinkClass::FwdLink)
813        );
814
815        register_declared_fields("zzTestRec", FIELDS);
816        assert_eq!(
817            dbf_link_class("zzTestRec", "RDBL"),
818            Some(DbfLinkClass::InLink),
819            "the name rule this replaced answered None for motor's RDBL"
820        );
821    }
822
823    #[test]
824    fn siol_class_depends_on_record_direction() {
825        // boRecord.dbd.pod:318 SIOL=DBF_OUTLINK; ai/bi SIOL=DBF_INLINK.
826        assert_eq!(dbf_link_class("bo", "SIOL"), Some(DbfLinkClass::OutLink));
827        assert_eq!(dbf_link_class("ao", "SIOL"), Some(DbfLinkClass::OutLink));
828        assert_eq!(dbf_link_class("ai", "SIOL"), Some(DbfLinkClass::InLink));
829        assert_eq!(dbf_link_class("bi", "SIOL"), Some(DbfLinkClass::InLink));
830        // SIML is always DBF_INLINK regardless of direction.
831        assert_eq!(dbf_link_class("bo", "SIML"), Some(DbfLinkClass::InLink));
832        assert_eq!(dbf_link_class("ai", "SIML"), Some(DbfLinkClass::InLink));
833    }
834
835    /// Every `field(SIOL,DBF_*)` in the record types this workspace ports,
836    /// read out of the C dbds. synApps `busy` is the one non-Base output
837    /// record — it derives from `bo` and declares `field(SIOL,DBF_OUTLINK)`
838    /// (`busyRecord.dbd`) — while synApps `swait` (`swaitRecord.dbd`) and
839    /// `mca` (`mcaRecord.dbd`) declare `DBF_INLINK`. A missing output entry is
840    /// silent: the classifier defaults to `InLink`, and the CP/CPP mask C
841    /// applies to an output link (`dbStaticLib.c:2380-2391`) is then skipped.
842    #[test]
843    fn siol_direction_matches_every_c_dbd_this_workspace_ports() {
844        for rtype in [
845            "ao",
846            "bo",
847            "busy",
848            "longout",
849            "int64out",
850            "mbbo",
851            "mbboDirect",
852            "stringout",
853            "lso",
854            "aao",
855        ] {
856            assert_eq!(
857                dbf_link_class(rtype, "SIOL"),
858                Some(DbfLinkClass::OutLink),
859                "{rtype} declares field(SIOL,DBF_OUTLINK)"
860            );
861        }
862        for rtype in [
863            "ai",
864            "bi",
865            "mbbi",
866            "mbbiDirect",
867            "longin",
868            "int64in",
869            "stringin",
870            "lsi",
871            "event",
872            "waveform",
873            "aai",
874            "histogram",
875            "swait",
876            // `mca` declares `field(SIOL,DBF_INLINK)` too, but it lives in
877            // `mca-rs` and reaches the by-name lookup only once that crate
878            // registers its factory — which no `epics-base-rs` unit test
879            // does. Asserting it here only ever tested the name rule.
880        ] {
881            assert_eq!(
882                dbf_link_class(rtype, "SIOL"),
883                Some(DbfLinkClass::InLink),
884                "{rtype} declares field(SIOL,DBF_INLINK)"
885            );
886        }
887    }
888
889    /// The consequence a misclassified SIOL actually has: `check_link_assignment`
890    /// turns the class into a [`LinkFieldType`], and only the `Out` arm applies
891    /// C's `modifiers &= ~(pvlOptCPP|pvlOptCP)`. Classified as an input, a
892    /// `busy` SIOL would keep a CPP that C strips.
893    #[test]
894    fn a_busy_siol_discards_cp_the_way_an_output_link_must() {
895        use crate::server::record::{
896            LinkFieldType, LinkProcessPolicy, ParsedLink, parse_link_field,
897        };
898
899        let ftype = match dbf_link_class("busy", "SIOL").unwrap() {
900            DbfLinkClass::InLink => LinkFieldType::In,
901            DbfLinkClass::OutLink => LinkFieldType::Out,
902            DbfLinkClass::FwdLink => LinkFieldType::Fwd,
903        };
904        match parse_link_field("TGT.VAL CPP", ftype) {
905            ParsedLink::Db(db) => assert_eq!(db.policy, LinkProcessPolicy::NoProcess),
906            other => panic!("expected a db link, got {other:?}"),
907        }
908    }
909
910    /// `LNK0..LNKF` shares
911    /// its spelling across two DBF classes — `fanoutRecord` declares the
912    /// family `DBF_FWDLINK`, while `seqRecord`/`sseqRecord` declare it
913    /// `DBF_OUTLINK`. The classifier must resolve by `record_type`, not
914    /// collapse every `LNK*` to OutLink by prefix.
915    #[test]
916    fn lnk_class_depends_on_record_type() {
917        // fanoutRecord.dbd.pod field(LNK0,DBF_FWDLINK) … field(LNKF,…).
918        assert_eq!(
919            dbf_link_class("fanout", "LNK0"),
920            Some(DbfLinkClass::FwdLink),
921            "fanout LNK0 is DBF_FWDLINK (16), not OUTLINK"
922        );
923        assert_eq!(
924            dbf_link_class("fanout", "LNKF"),
925            Some(DbfLinkClass::FwdLink),
926            "fanout LNKF is DBF_FWDLINK (16), not OUTLINK"
927        );
928        // seqRecord.dbd.pod field(LNK0,DBF_OUTLINK): the default direction
929        // for the shared spelling.
930        assert_eq!(dbf_link_class("seq", "LNK0"), Some(DbfLinkClass::OutLink));
931        assert_eq!(dbf_link_class("seq", "LNKF"), Some(DbfLinkClass::OutLink));
932        // synApps sseqRecord LNK1..LNK9 are also DBF_OUTLINK (LNK10 is the
933        // two-char spelling the one-alnum suffix rule deliberately rejects).
934        assert_eq!(dbf_link_class("sseq", "LNK1"), Some(DbfLinkClass::OutLink));
935        // fanout's dbCommon FLNK is still a forward link (unchanged).
936        assert_eq!(
937            dbf_link_class("fanout", "FLNK"),
938            Some(DbfLinkClass::FwdLink)
939        );
940    }
941
942    #[test]
943    fn plain_value_fields_are_not_links() {
944        for f in [
945            "VAL", "EGU", "PREC", "HOPR", "RVAL", "DESC", "A", "B", "OVAL",
946        ] {
947            assert_eq!(dbf_link_class("ai", f), None, "{f} must not be a link");
948        }
949    }
950
951    #[test]
952    fn case_insensitive_and_trims() {
953        assert_eq!(dbf_link_class("ai", "inp"), Some(DbfLinkClass::InLink));
954        assert_eq!(dbf_link_class("ao", " OUT "), Some(DbfLinkClass::OutLink));
955    }
956
957    #[test]
958    fn dbf_codes_and_range_match_pvxs() {
959        assert_eq!(DbfLinkClass::InLink.dbf_type(), 14);
960        assert_eq!(DbfLinkClass::OutLink.dbf_type(), 15);
961        assert_eq!(DbfLinkClass::FwdLink.dbf_type(), 16);
962        // pvxs groupsource.cpp:596-606 range check.
963        assert!(is_link_dbf_type(DBF_INLINK));
964        assert!(is_link_dbf_type(DBF_OUTLINK));
965        assert!(is_link_dbf_type(DBF_FWDLINK));
966        assert!(!is_link_dbf_type(13)); // DBF_DEVICE
967        assert!(!is_link_dbf_type(17)); // DBF_NOACCESS
968        assert!(!is_link_dbf_type(0)); // DBF_STRING
969    }
970}
971
972#[cfg(test)]
973mod dbr_text_tests {
974    use super::*;
975
976    #[test]
977    fn resolves_every_type_name_to_its_code() {
978        // Round-trip the whole table: each name resolves to its index.
979        for (code, name) in DBR_TEXT.iter().enumerate() {
980            assert_eq!(
981                dbr_text_to_type(name),
982                Some(code as u16),
983                "{name} should resolve to {code}"
984            );
985        }
986        // Boundary names spot-check (the cited High-finding values).
987        assert_eq!(dbr_text_to_type("DBR_STRING"), Some(DBR_STRING));
988        assert_eq!(dbr_text_to_type("DBR_TIME_FLOAT"), Some(DBR_TIME_FLOAT));
989        assert_eq!(
990            dbr_text_to_type("DBR_STSACK_STRING"),
991            Some(DBR_STSACK_STRING)
992        );
993        assert_eq!(dbr_text_to_type("DBR_CLASS_NAME"), Some(DBR_CLASS_NAME));
994    }
995
996    #[test]
997    fn is_case_sensitive_like_c_strcmp() {
998        // C `dbr_text_to_type` uses `strcmp`, so lowercase does not
999        // match — the C tools then revert to their plain request.
1000        assert_eq!(dbr_text_to_type("dbr_time_float"), None);
1001        assert_eq!(dbr_text_to_type("DBR_Time_Float"), None);
1002    }
1003
1004    #[test]
1005    fn unknown_and_bare_family_names_do_not_match() {
1006        // Bare family names need the caller's `DBR_` retry — the raw
1007        // search rejects them, matching C's first-pass `strcmp`.
1008        assert_eq!(dbr_text_to_type("TIME_FLOAT"), None);
1009        assert_eq!(dbr_text_to_type("DOUBLE"), None);
1010        assert_eq!(dbr_text_to_type("DBR_NONSENSE"), None);
1011        assert_eq!(dbr_text_to_type(""), None);
1012    }
1013}
1014
1015#[cfg(test)]
1016mod dbf_code_tests {
1017    use super::*;
1018
1019    /// C's `dbfType` in declaration order (`dbFldTypes.h:24-43` @R7.0.10),
1020    /// paired with `pamapdbfType`'s strings (`:51-70`) as `dbTest.c:76-81`
1021    /// carries them, prefix-stripped.
1022    ///
1023    /// The WHOLE table, not a sample. A nineteenth type inserted in the
1024    /// middle has to fail on its own row here rather than silently shift
1025    /// every code after it — the codes are what `dbDumpField` prints and
1026    /// what a `.dbd` interchange file means by `DBF_MENU`.
1027    const C_DBF_TYPE: [(DbfCode, i16, &str); 18] = [
1028        (DbfCode::String, 0, "STRING"),
1029        (DbfCode::Char, 1, "CHAR"),
1030        (DbfCode::UChar, 2, "UCHAR"),
1031        (DbfCode::Short, 3, "SHORT"),
1032        (DbfCode::UShort, 4, "USHORT"),
1033        (DbfCode::Long, 5, "LONG"),
1034        (DbfCode::ULong, 6, "ULONG"),
1035        (DbfCode::Int64, 7, "INT64"),
1036        (DbfCode::UInt64, 8, "UINT64"),
1037        (DbfCode::Float, 9, "FLOAT"),
1038        (DbfCode::Double, 10, "DOUBLE"),
1039        (DbfCode::Enum, 11, "ENUM"),
1040        (DbfCode::Menu, 12, "MENU"),
1041        (DbfCode::Device, 13, "DEVICE"),
1042        (DbfCode::Inlink, 14, "INLINK"),
1043        (DbfCode::Outlink, 15, "OUTLINK"),
1044        (DbfCode::Fwdlink, 16, "FWDLINK"),
1045        (DbfCode::NoAccess, 17, "NOACCESS"),
1046    ];
1047
1048    /// The table above lists every variant: this match is exhaustive, so a
1049    /// nineteenth variant stops the build, and the pairwise-distinct check
1050    /// below then makes 18 rows a proof of coverage rather than of length.
1051    fn _the_enum_has_no_variant_outside_the_table(c: DbfCode) {
1052        match c {
1053            DbfCode::String
1054            | DbfCode::Char
1055            | DbfCode::UChar
1056            | DbfCode::Short
1057            | DbfCode::UShort
1058            | DbfCode::Long
1059            | DbfCode::ULong
1060            | DbfCode::Int64
1061            | DbfCode::UInt64
1062            | DbfCode::Float
1063            | DbfCode::Double
1064            | DbfCode::Enum
1065            | DbfCode::Menu
1066            | DbfCode::Device
1067            | DbfCode::Inlink
1068            | DbfCode::Outlink
1069            | DbfCode::Fwdlink
1070            | DbfCode::NoAccess => (),
1071        }
1072    }
1073
1074    #[test]
1075    fn every_dbf_code_carries_cs_number_and_name() {
1076        for (i, (code, n, name)) in C_DBF_TYPE.iter().enumerate() {
1077            assert_eq!(*code as i16, *n, "DBF_{name}: wrong dbfType code");
1078            assert_eq!(code.name(), *name, "DBF_{name}: wrong name");
1079            // C's dbfType is dense 0..DBF_NTYPES-1 and `pamapdbfType` is
1080            // indexed by it, so position and code are the same fact.
1081            assert_eq!(i as i16, *n, "DBF_{name}: table position != code");
1082        }
1083        // DBF_NTYPES (`dbFldTypes.h:45`).
1084        assert_eq!(C_DBF_TYPE.len(), 18);
1085        for (a, (x, ..)) in C_DBF_TYPE.iter().enumerate() {
1086            for (y, ..) in C_DBF_TYPE.iter().skip(a + 1) {
1087                assert_ne!(x, y, "{x:?} listed twice");
1088            }
1089        }
1090    }
1091
1092    #[test]
1093    fn every_served_type_maps_to_its_dbstatic_code() {
1094        // All twelve CA wire types, not a sample. The mapping only ever
1095        // answers a scalar code, because that is all a SERVED type can be —
1096        // `DbfCode::Menu`, `Device` and the three link codes are declarations
1097        // and are unreachable from here by construction.
1098        let table = [
1099            (DbFieldType::String, DbfCode::String),
1100            (DbFieldType::Short, DbfCode::Short),
1101            (DbFieldType::Float, DbfCode::Float),
1102            (DbFieldType::Enum, DbfCode::Enum),
1103            (DbFieldType::Char, DbfCode::Char),
1104            (DbFieldType::Long, DbfCode::Long),
1105            (DbFieldType::Double, DbfCode::Double),
1106            (DbFieldType::Int64, DbfCode::Int64),
1107            (DbFieldType::UInt64, DbfCode::UInt64),
1108            (DbFieldType::UShort, DbfCode::UShort),
1109            (DbFieldType::ULong, DbfCode::ULong),
1110            (DbFieldType::UChar, DbfCode::UChar),
1111        ];
1112        for (ft, code) in table {
1113            assert_eq!(ft.dbf_code(), code, "{ft:?}");
1114        }
1115        assert_eq!(table.len(), 12);
1116        // The CA wire codes are the OTHER numbering and must not have moved:
1117        // they are `DbFieldType`'s discriminants and are load-bearing on the
1118        // protocol, so the two tables disagreeing from the second entry on is
1119        // the expected state, not a defect.
1120        for (i, (ft, _)) in table.iter().enumerate() {
1121            assert_eq!(*ft as u16, i as u16, "{ft:?}: CA wire code moved");
1122        }
1123    }
1124
1125    /// C's `mapDBFToDBR` (`dbAccess.c:76-95`), all eighteen rows in C's
1126    /// order, written as the DBR *names* C's initialiser comments give so a
1127    /// silent renumbering of either enum shows up as a name mismatch.
1128    #[test]
1129    fn the_dbr_map_is_cs_whole_table() {
1130        const C_MAP: [(DbfCode, DbfCode); 18] = [
1131            (DbfCode::String, DbfCode::String),
1132            (DbfCode::Char, DbfCode::Char),
1133            (DbfCode::UChar, DbfCode::UChar),
1134            (DbfCode::Short, DbfCode::Short),
1135            (DbfCode::UShort, DbfCode::UShort),
1136            (DbfCode::Long, DbfCode::Long),
1137            (DbfCode::ULong, DbfCode::ULong),
1138            (DbfCode::Int64, DbfCode::Int64),
1139            (DbfCode::UInt64, DbfCode::UInt64),
1140            (DbfCode::Float, DbfCode::Float),
1141            (DbfCode::Double, DbfCode::Double),
1142            (DbfCode::Enum, DbfCode::Enum),
1143            (DbfCode::Menu, DbfCode::Enum),
1144            (DbfCode::Device, DbfCode::Enum),
1145            (DbfCode::Inlink, DbfCode::String),
1146            (DbfCode::Outlink, DbfCode::String),
1147            (DbfCode::Fwdlink, DbfCode::String),
1148            (DbfCode::NoAccess, DbfCode::NoAccess),
1149        ];
1150        for (dbf, dbr) in C_MAP {
1151            assert_eq!(dbf.dbr_code(), dbr, "mapDBFToDBR[DBF_{}]", dbf.name());
1152        }
1153        assert_eq!(C_MAP.len(), 18);
1154        // C indexes `mapDBFToDBR` by the dbfType, so the table's position
1155        // and the code it maps are one fact — the same density check the
1156        // name table gets.
1157        for (i, (dbf, _)) in C_MAP.iter().enumerate() {
1158            assert_eq!(
1159                *dbf as i16,
1160                i as i16,
1161                "DBF_{}: row out of order",
1162                dbf.name()
1163            );
1164        }
1165        // `DBR_NOACCESS` is `DBF_NOACCESS` = 17 (`dbFldTypes.h:90`), NOT the
1166        // 12 that `printDbAddr`'s `dbr[]` index suggests: C remaps the index
1167        // to `DBR_ENUM+1` only to reach its shorter name array, and still
1168        // prints the unremapped 17 as the number (`dbTest.c:812-817`). Both
1169        // halves fall out of one code here, so the port cannot print the
1170        // pair inconsistently.
1171        assert_eq!(DbfCode::NoAccess.dbr_code() as i16, 17);
1172        assert_eq!(DbfCode::NoAccess.dbr_code().name(), "NOACCESS");
1173    }
1174}