Skip to main content

spvirit_server/
record_fields.rs

1//! IOC/QSRV-style record field access.
2//!
3//! Serves `<pvname>.<FIELD>` (and the obsolete `<pvname>.<FIELD>$` long-string
4//! form) as independent read-only channels, mimicking IOC field-access
5//! semantics so tools such as the EPICS Archiver Appliance can fetch record
6//! metadata (`RTYP`, `NAME`, `DESC`, dbCommon fields, ...).
7//!
8//! Ported from the p4pillon `RecordProvider` / `DynamicRecordFields` design
9//! (branch `50-access-to-ioc-fields`).
10
11use std::future::Future;
12use std::pin::Pin;
13use std::sync::Arc;
14
15use tokio::sync::{Mutex, mpsc};
16
17use spvirit_codec::spvd_decode::DecodedValue;
18use spvirit_types::{NtPayload, NtScalar, NtScalarArray, ScalarArrayValue};
19
20use crate::field_provider::{RecordFieldProvider, resolve_field_info, resolve_field_payload};
21use crate::pvstore::{PvInfo, Source};
22use crate::types::{RecordInstance, RecordType, ScalarValue};
23
24/// A parsed `<base>.<FIELD>[$]` channel-name reference.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct FieldRef {
27    pub base: String,
28    pub field: String,
29    /// `true` when the obsolete `$` long-string suffix was present.
30    pub long_string: bool,
31}
32
33/// Scalar type of a record field, per dbCommon.dbd semantics.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum FieldKind {
36    Str,
37    Int,
38    Double,
39}
40
41/// Split a channel name into a [`FieldRef`] on its last `.`.
42///
43/// Returns `None` for names that cannot be an IOC field reference: no dot,
44/// empty base or field, or a field part that is not all ASCII
45/// uppercase/digits (record field names are uppercase by convention, so
46/// `a.record.like.this` is never misclaimed).
47pub fn parse_field_ref(name: &str) -> Option<FieldRef> {
48    let (base, field_part) = name.rsplit_once('.')?;
49    if base.is_empty() {
50        return None;
51    }
52    let (field, long_string) = match field_part.strip_suffix('$') {
53        Some(stripped) => (stripped, true),
54        None => (field_part, false),
55    };
56    if field.is_empty()
57        || !field
58            .bytes()
59            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
60    {
61        return None;
62    }
63    Some(FieldRef {
64        base: base.to_string(),
65        field: field.to_string(),
66        long_string,
67    })
68}
69
70/// dbCommon field defaults: `(name, kind, default-as-string)`.
71///
72/// The default strings are parsed per `kind` when served. Table follows
73/// dbCommon.dbd (the fields every EPICS record carries), as p4pillon's
74/// `fields.py` encodes.
75const DBCOMMON_DEFAULTS: &[(&str, FieldKind, &str)] = &[
76    ("DESC", FieldKind::Str, ""),
77    ("SCAN", FieldKind::Str, "Passive"),
78    ("PINI", FieldKind::Str, "NO"),
79    ("PHAS", FieldKind::Int, "0"),
80    ("EVNT", FieldKind::Str, ""),
81    ("PRIO", FieldKind::Str, "LOW"),
82    ("DISV", FieldKind::Int, "1"),
83    ("DISA", FieldKind::Int, "0"),
84    ("SDIS", FieldKind::Str, ""),
85    ("DISS", FieldKind::Str, "NO_ALARM"),
86    ("PROC", FieldKind::Int, "0"),
87    ("STAT", FieldKind::Str, "UDF"),
88    ("SEVR", FieldKind::Str, "INVALID"),
89    ("UDF", FieldKind::Int, "1"),
90    ("TPRO", FieldKind::Int, "0"),
91    ("FLNK", FieldKind::Str, ""),
92    ("ADEL", FieldKind::Double, "0"),
93    ("MDEL", FieldKind::Double, "0"),
94    ("TSE", FieldKind::Int, "0"),
95    ("DISP", FieldKind::Int, "0"),
96    ("ACKS", FieldKind::Str, "NO_ALARM"),
97    ("ACKT", FieldKind::Str, "YES"),
98    ("ASG", FieldKind::Str, ""),
99];
100
101/// Look up the dbCommon default for `field`, if it is a common field.
102pub fn dbcommon_default(field: &str) -> Option<(FieldKind, &'static str)> {
103    DBCOMMON_DEFAULTS
104        .iter()
105        .find(|(name, _, _)| *name == field)
106        .map(|(_, kind, default)| (*kind, *default))
107}
108
109/// The dbCommon default for `field`, already parsed to its declared type.
110///
111/// The fallback both stores use for fields their own model does not carry —
112/// the analogue of `dbCommon.dbd` being included in every record type.
113pub fn dbcommon_default_value(field: &str) -> Option<ScalarValue> {
114    dbcommon_default(field).map(|(kind, default)| typed_value(kind, default))
115}
116
117/// The record fields whose value is a link, and which therefore render
118/// through [`render_link_text`] rather than as the raw `.db` text.
119///
120/// Shared so that a store holding raw `.db` strings and a store holding a
121/// parsed link model cannot disagree about which fields are links.
122pub const LINK_FIELDS: &[&str] = &["INP", "OUT", "DOL", "SDIS", "FLNK"];
123
124/// Whether `field` is one of [`LINK_FIELDS`].
125pub fn is_link_field(field: &str) -> bool {
126    LINK_FIELDS.contains(&field)
127}
128
129/// Render a link the way EPICS Base prints one: the target, an optional
130/// `.FIELD`, then both modifiers, however terse the `.db` was.
131///
132/// Base stores a link's modifiers as a bit mask and re-renders them from it
133/// (`dbGetString`'s `DBF_INLINK` arm), so `field(INP, "PV:B PP")` reads back
134/// as `PV:B PP NMS` — the modifiers are always both present and always
135/// spelled out. The target, by contrast, Base prints verbatim from
136/// `pv_link.pvname`: it never *adds* a `.VAL` the `.db` did not write.
137/// `field` is therefore `None` for a link that addresses the record itself.
138///
139/// Every tier renders links through this one function, so a client cannot
140/// tell tier 1 (`SimplePvStore`, raw `.db` strings) from tier 2
141/// (`spvirit_ioc::IocSource`, a parsed link model) by reading `.INP`.
142pub fn render_link_text(
143    target: &str,
144    field: Option<&str>,
145    process_passive: bool,
146    maximize_severity: bool,
147) -> String {
148    let mut s = target.to_string();
149    if let Some(field) = field {
150        s.push('.');
151        s.push_str(field);
152    }
153    s.push(' ');
154    s.push_str(if process_passive { "PP" } else { "NPP" });
155    s.push(' ');
156    s.push_str(if maximize_severity { "MS" } else { "NMS" });
157    s
158}
159
160/// Re-render the raw `.db` text of a link field in [`render_link_text`]'s
161/// canonical form.
162///
163/// `forward` marks a forward link (`FLNK`), whose target field is dropped:
164/// Base's bare-`FLNK` semantics are "process the target record", not "read
165/// one of its fields", and the engine's `forward_link` discards the field
166/// too.
167///
168/// `None` when `raw` is not a database link — a constant, an empty field, or
169/// a link carrying a modifier this codebase does not model (`CP`/`CPP`,
170/// which `spvirit-ioc`'s loader rejects outright). The caller then serves
171/// the raw text unchanged, which is all it can honestly say.
172pub fn canonical_link_text(raw: &str, forward: bool) -> Option<String> {
173    let raw = raw.trim();
174    if raw.is_empty() || raw.parse::<f64>().is_ok() {
175        return None;
176    }
177    let mut parts = raw.split_whitespace();
178    let target_spec = parts.next()?;
179    let mut process_passive = false;
180    let mut maximize_severity = false;
181    for modifier in parts {
182        // MSS/MSI are severity refinements the engine folds into MS; this
183        // mirrors `spvirit_ioc::build::link` so the two agree on the mask.
184        match modifier.to_ascii_uppercase().as_str() {
185            "PP" => process_passive = true,
186            "NPP" => process_passive = false,
187            "MS" | "MSS" | "MSI" => maximize_severity = true,
188            "NMS" => maximize_severity = false,
189            _ => return None,
190        }
191    }
192    // Split on the *first* dot, as `spvirit_ioc::build::link` does.
193    let (target, field) = match target_spec.split_once('.') {
194        Some((t, f)) if !t.is_empty() && !f.is_empty() => (t, Some(f)),
195        _ => (target_spec, None),
196    };
197    // `.VAL` is the implied field and is never printed: the parsed model on
198    // the other tier cannot tell "PV:B" from "PV:B.VAL", so neither may this.
199    let field = field.filter(|f| !forward && !f.eq_ignore_ascii_case("VAL"));
200    Some(render_link_text(
201        target,
202        field,
203        process_passive,
204        maximize_severity,
205    ))
206}
207
208/// The `.db` record-type name for a [`RecordType`] (what `RTYP` reports).
209pub fn record_type_name(rt: &RecordType) -> &'static str {
210    match rt {
211        RecordType::Ai => "ai",
212        RecordType::Ao => "ao",
213        RecordType::Bi => "bi",
214        RecordType::Bo => "bo",
215        RecordType::StringIn => "stringin",
216        RecordType::StringOut => "stringout",
217        RecordType::Waveform => "waveform",
218        RecordType::Aai => "aai",
219        RecordType::Aao => "aao",
220        RecordType::SubArray => "subArray",
221        RecordType::NtTable => "ntTable",
222        RecordType::NtNdArray => "ntNDArray",
223        RecordType::Mbbi => "mbbi",
224        RecordType::Mbbo => "mbbo",
225        RecordType::Generic => "generic",
226        RecordType::LongIn => "longin",
227        RecordType::LongOut => "longout",
228    }
229}
230
231/// The record's MDEL monitor deadband (0.0 when absent or unparsable).
232pub fn mdel_of(record: &RecordInstance) -> f64 {
233    record
234        .raw_fields
235        .get("MDEL")
236        .and_then(|s| s.trim().parse::<f64>().ok())
237        .unwrap_or(0.0)
238}
239
240/// Parse a raw field string as `kind`, falling back to `Str` on parse failure.
241fn typed_value(kind: FieldKind, raw: &str) -> ScalarValue {
242    match kind {
243        FieldKind::Int => raw
244            .trim()
245            .parse::<i32>()
246            .map(ScalarValue::I32)
247            .unwrap_or_else(|_| ScalarValue::Str(raw.to_string())),
248        FieldKind::Double => raw
249            .trim()
250            .parse::<f64>()
251            .map(ScalarValue::F64)
252            .unwrap_or_else(|_| ScalarValue::Str(raw.to_string())),
253        FieldKind::Str => ScalarValue::Str(raw.to_string()),
254    }
255}
256
257/// Resolve the value of `field` for `record`.
258///
259/// Lookup order: computed fields (`RTYP`, `NAME`, `DTYP`), then any field
260/// literally present in the parsed `.db` (`raw_fields`), then dbCommon
261/// defaults. Returns `None` for fields an IOC would not serve either.
262pub fn field_value(record: &RecordInstance, field: &str) -> Option<ScalarValue> {
263    match field {
264        "RTYP" => {
265            return Some(ScalarValue::Str(
266                record_type_name(&record.record_type).to_string(),
267            ));
268        }
269        "NAME" => return Some(ScalarValue::Str(record.name.clone())),
270        "VAL" => return Some(record.current_value()),
271        "DTYP" => {
272            let dtyp = record
273                .raw_fields
274                .get("DTYP")
275                .cloned()
276                .unwrap_or_else(|| "Soft Channel".to_string());
277            return Some(ScalarValue::Str(dtyp));
278        }
279        _ => {}
280    }
281
282    let kind = dbcommon_default(field).map(|(kind, _)| kind);
283    if let Some(raw) = record.raw_fields.get(field) {
284        // Link fields are canonicalised rather than echoed, so that this
285        // store and a store holding a parsed link model serve the same text
286        // for the same `.db` — see `canonical_link_text`.
287        if is_link_field(field)
288            && let Some(rendered) = canonical_link_text(raw, field == "FLNK")
289        {
290            return Some(ScalarValue::Str(rendered));
291        }
292        return Some(typed_value(kind.unwrap_or(FieldKind::Str), raw));
293    }
294    if field == "DESC" {
295        return Some(ScalarValue::Str(record.common.desc.clone()));
296    }
297    dbcommon_default(field).map(|(kind, default)| typed_value(kind, default))
298}
299
300/// Wrap a resolved field value as a wire payload.
301///
302/// Regular fields are served as an NTScalar; the `$` long-string form is
303/// served as an NTScalarArray of Int8 holding the UTF-8 bytes (QSRV
304/// long-string semantics). `$` on a non-string value resolves to `None`.
305///
306/// Both the record-level [`payload_for`] and the provider-level
307/// `resolve_field_payload` go through here, so the two stores cannot drift
308/// in how they wrap.
309pub fn payload_for_value(value: ScalarValue, desc: &str, long_string: bool) -> Option<NtPayload> {
310    if long_string {
311        let ScalarValue::Str(s) = value else {
312            return None;
313        };
314        let bytes: Vec<i8> = s.into_bytes().into_iter().map(|b| b as i8).collect();
315        return Some(NtPayload::ScalarArray(NtScalarArray::from_value(
316            ScalarArrayValue::I8(bytes),
317        )));
318    }
319    let mut nt = NtScalar::from_value(value);
320    nt.display_description = desc.to_string();
321    Some(NtPayload::Scalar(nt))
322}
323
324/// Build the wire payload for a resolved field reference on a record.
325pub fn payload_for(record: &RecordInstance, field_ref: &FieldRef) -> Option<NtPayload> {
326    let value = field_value(record, &field_ref.field)?;
327    payload_for_value(value, &record.common.desc, field_ref.long_string)
328}
329
330/// A read-only [`Source`] serving `<pvname>.<FIELD>` channels for any
331/// [`RecordFieldProvider`].
332///
333/// Registered by `PvaServer::run` after the builtin store; the builtin only
334/// claims exact record names, so the two never compete.
335pub struct RecordFieldSource {
336    provider: Arc<dyn RecordFieldProvider>,
337    /// Senders for open field-PV subscriptions. Field values are static in
338    /// A2 (field writes are B's), so each channel only ever carries the
339    /// initial snapshot; the senders are retained here purely to keep the
340    /// channels open.
341    open_subs: Mutex<Vec<mpsc::Sender<NtPayload>>>,
342}
343
344impl RecordFieldSource {
345    pub fn new(provider: Arc<dyn RecordFieldProvider>) -> Self {
346        Self {
347            provider,
348            open_subs: Mutex::new(Vec::new()),
349        }
350    }
351}
352
353impl Source for RecordFieldSource {
354    fn claim(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<PvInfo>> + Send + '_>> {
355        let name = name.to_string();
356        Box::pin(async move { resolve_field_info(self.provider.as_ref(), &name).await })
357    }
358
359    fn get(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<NtPayload>> + Send + '_>> {
360        let name = name.to_string();
361        Box::pin(async move { resolve_field_payload(self.provider.as_ref(), &name).await })
362    }
363
364    fn put(
365        &self,
366        name: &str,
367        _value: &DecodedValue,
368    ) -> Pin<Box<dyn Future<Output = Result<Vec<(String, NtPayload)>, String>> + Send + '_>> {
369        let name = name.to_string();
370        Box::pin(async move { Err(format!("field PV '{}' is read-only", name)) })
371    }
372
373    fn subscribe(
374        &self,
375        name: &str,
376    ) -> Pin<Box<dyn Future<Output = Option<mpsc::Receiver<NtPayload>>> + Send + '_>> {
377        let name = name.to_string();
378        Box::pin(async move {
379            let initial = resolve_field_payload(self.provider.as_ref(), &name).await?;
380            let (tx, rx) = mpsc::channel(4);
381            let _ = tx.try_send(initial);
382            self.open_subs.lock().await.push(tx);
383            Some(rx)
384        })
385    }
386
387    fn names(&self) -> Pin<Box<dyn Future<Output = Vec<String>> + Send + '_>> {
388        // Field PVs are derived on demand; enumerating every possible
389        // <record>.<FIELD> combination would flood name listings.
390        Box::pin(async move { Vec::new() })
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn parses_simple_field_ref() {
400        let r = parse_field_ref("SIM:AO.RTYP").unwrap();
401        assert_eq!(
402            (r.base.as_str(), r.field.as_str(), r.long_string),
403            ("SIM:AO", "RTYP", false)
404        );
405    }
406
407    #[test]
408    fn parses_long_string_suffix() {
409        let r = parse_field_ref("SIM:AO.DESC$").unwrap();
410        assert_eq!((r.field.as_str(), r.long_string), ("DESC", true));
411    }
412
413    #[test]
414    fn rejects_non_field_names() {
415        assert!(parse_field_ref("SIM:AO").is_none()); // no dot
416        assert!(parse_field_ref("SIM:AO.").is_none()); // empty field
417        assert!(parse_field_ref("SIM:AO.rtyp").is_none()); // lowercase = not a field
418        assert!(parse_field_ref("SIM:AO.$").is_none()); // bare $
419        assert!(parse_field_ref(".RTYP").is_none()); // empty base
420    }
421
422    #[test]
423    fn dbcommon_defaults_cover_key_fields() {
424        assert!(matches!(
425            dbcommon_default("SCAN"),
426            Some((FieldKind::Str, "Passive"))
427        ));
428        assert!(matches!(
429            dbcommon_default("PINI"),
430            Some((FieldKind::Str, "NO"))
431        ));
432        assert!(matches!(
433            dbcommon_default("PHAS"),
434            Some((FieldKind::Int, "0"))
435        ));
436        assert!(matches!(
437            dbcommon_default("MDEL"),
438            Some((FieldKind::Double, "0"))
439        ));
440        assert!(dbcommon_default("NOTAFIELD").is_none());
441    }
442
443    #[test]
444    fn record_type_names_match_db_names() {
445        assert_eq!(record_type_name(&RecordType::Ao), "ao");
446        assert_eq!(record_type_name(&RecordType::StringIn), "stringin");
447        assert_eq!(record_type_name(&RecordType::LongIn), "longin");
448    }
449
450    fn test_record() -> RecordInstance {
451        let recs = crate::db::parse_db(
452            r#"
453record(ao, "SIM:AO") {
454    field(VAL, "2.34")
455    field(DESC, "A test output")
456    field(EGU, "V")
457    field(MDEL, "0.5")
458}"#,
459        )
460        .expect("parse");
461        recs.get("SIM:AO").expect("record present").clone()
462    }
463
464    #[test]
465    fn computed_fields_resolve() {
466        let r = test_record();
467        assert_eq!(field_value(&r, "RTYP"), Some(ScalarValue::Str("ao".into())));
468        assert_eq!(
469            field_value(&r, "NAME"),
470            Some(ScalarValue::Str("SIM:AO".into()))
471        );
472        assert_eq!(
473            field_value(&r, "DTYP"),
474            Some(ScalarValue::Str("Soft Channel".into()))
475        );
476        assert_eq!(field_value(&r, "VAL"), Some(ScalarValue::F64(2.34)));
477    }
478
479    #[test]
480    fn raw_db_fields_take_precedence_over_defaults() {
481        let r = test_record();
482        assert_eq!(
483            field_value(&r, "DESC"),
484            Some(ScalarValue::Str("A test output".into()))
485        );
486        assert_eq!(field_value(&r, "EGU"), Some(ScalarValue::Str("V".into())));
487        assert_eq!(field_value(&r, "MDEL"), Some(ScalarValue::F64(0.5)));
488    }
489
490    #[test]
491    fn dbcommon_defaults_fill_absent_fields() {
492        let r = test_record();
493        assert_eq!(
494            field_value(&r, "SCAN"),
495            Some(ScalarValue::Str("Passive".into()))
496        );
497        assert_eq!(field_value(&r, "PHAS"), Some(ScalarValue::I32(0)));
498        assert_eq!(field_value(&r, "ADEL"), Some(ScalarValue::F64(0.0)));
499        assert_eq!(field_value(&r, "NOTAFIELD"), None);
500    }
501
502    fn test_source() -> RecordFieldSource {
503        let recs = crate::db::parse_db(
504            r#"
505record(ao, "SIM:AO") {
506    field(VAL, "2.34")
507    field(DESC, "A test output")
508    field(MDEL, "0.5")
509}"#,
510        )
511        .expect("parse");
512        let store = crate::simple_store::SimplePvStore::new(
513            recs,
514            std::collections::HashMap::new(),
515            Vec::new(),
516            false,
517        );
518        let provider: Arc<dyn RecordFieldProvider> = Arc::new(store);
519        RecordFieldSource::new(provider)
520    }
521
522    #[tokio::test]
523    async fn claims_field_pvs_read_only() {
524        let src = test_source();
525        let info = src.claim("SIM:AO.RTYP").await.expect("claimed");
526        assert!(!info.writable);
527        match src.get("SIM:AO.RTYP").await.expect("payload") {
528            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::Str("ao".into())),
529            other => panic!("expected scalar, got {other:?}"),
530        }
531    }
532
533    #[tokio::test]
534    async fn does_not_claim_non_field_names() {
535        let src = test_source();
536        assert!(src.claim("SIM:AO").await.is_none()); // base PV: builtin's job
537        assert!(src.claim("SIM:AO.NOTAFIELD").await.is_none());
538        assert!(src.claim("SIM:MISSING.RTYP").await.is_none());
539        assert!(src.claim("SIM:AO.MDEL$").await.is_none()); // $ on non-string
540    }
541
542    #[tokio::test]
543    async fn put_is_rejected() {
544        let src = test_source();
545        let err = src
546            .put("SIM:AO.DESC", &DecodedValue::Int32(1))
547            .await
548            .expect_err("put must fail");
549        assert!(err.contains("read-only"), "unexpected error: {err}");
550    }
551
552    #[tokio::test]
553    async fn long_string_serves_utf8_bytes() {
554        let src = test_source();
555        match src.get("SIM:AO.DESC$").await.expect("payload") {
556            NtPayload::ScalarArray(arr) => {
557                let ScalarArrayValue::I8(bytes) = arr.value else {
558                    panic!("expected Int8 array, got {:?}", arr.value);
559                };
560                let expected: Vec<i8> = "A test output".bytes().map(|b| b as i8).collect();
561                assert_eq!(bytes, expected);
562            }
563            other => panic!("expected scalar array, got {other:?}"),
564        }
565    }
566
567    #[tokio::test]
568    async fn subscribe_delivers_initial_snapshot() {
569        let src = test_source();
570        let mut rx = src.subscribe("SIM:AO.RTYP").await.expect("subscribed");
571        match rx.recv().await.expect("initial value") {
572            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::Str("ao".into())),
573            other => panic!("expected scalar, got {other:?}"),
574        }
575        // Channel stays open (sender retained) — no immediate close.
576        assert!(rx.try_recv().is_err());
577    }
578
579    #[test]
580    fn payload_for_value_wraps_a_scalar_with_its_description() {
581        let p = payload_for_value(ScalarValue::F64(2.34), "A test output", false)
582            .expect("scalars always wrap");
583        match p {
584            NtPayload::Scalar(nt) => {
585                assert_eq!(nt.value, ScalarValue::F64(2.34));
586                assert_eq!(nt.display_description, "A test output");
587            }
588            other => panic!("expected scalar, got {other:?}"),
589        }
590    }
591
592    #[test]
593    fn payload_for_value_long_string_needs_a_string() {
594        assert!(payload_for_value(ScalarValue::F64(1.0), "", true).is_none());
595        let p = payload_for_value(ScalarValue::Str("hi".into()), "", true).expect("string wraps");
596        match p {
597            NtPayload::ScalarArray(arr) => {
598                assert_eq!(arr.value, ScalarArrayValue::I8(vec![104, 105]));
599            }
600            other => panic!("expected scalar array, got {other:?}"),
601        }
602    }
603
604    #[test]
605    fn a_terse_link_renders_with_both_modifiers_spelled_out() {
606        assert_eq!(
607            canonical_link_text("PV:B PP", false).as_deref(),
608            Some("PV:B PP NMS")
609        );
610        assert_eq!(
611            canonical_link_text("  PV:B   MS  ", false).as_deref(),
612            Some("PV:B NPP MS")
613        );
614        assert_eq!(
615            canonical_link_text("PV:B", false).as_deref(),
616            Some("PV:B NPP NMS")
617        );
618    }
619
620    /// The implied `.VAL` is never printed, and the explicit one is dropped
621    /// to match: the parsed link model on the IOC tier cannot tell the two
622    /// apart, so neither may this one.
623    #[test]
624    fn the_implied_val_field_is_never_printed() {
625        assert_eq!(
626            canonical_link_text("PV:B.VAL PP", false).as_deref(),
627            Some("PV:B PP NMS")
628        );
629        assert_eq!(
630            canonical_link_text("PV:B.SEVR", false).as_deref(),
631            Some("PV:B.SEVR NPP NMS")
632        );
633    }
634
635    /// A forward link addresses a record, not a field.
636    #[test]
637    fn a_forward_link_drops_its_field() {
638        assert_eq!(
639            canonical_link_text("PV:B.PROC", true).as_deref(),
640            Some("PV:B NPP NMS")
641        );
642    }
643
644    /// Anything that is not a database link is left to the caller to serve
645    /// as-is: there is nothing honest to canonicalise it into.
646    #[test]
647    fn constants_and_unmodelled_modifiers_do_not_canonicalise() {
648        assert_eq!(canonical_link_text("7", false), None);
649        assert_eq!(canonical_link_text("-1.5", false), None);
650        assert_eq!(canonical_link_text("   ", false), None);
651        assert_eq!(canonical_link_text("PV:B CPP", false), None);
652    }
653
654    #[test]
655    fn link_fields_render_canonically_out_of_the_raw_db() {
656        let recs = crate::db::parse_db(
657            r#"
658record(ai, "PV:A") {
659    field(INP, "PV:B PP")
660    field(FLNK, "PV:B")
661    field(EGU, "mm")
662}"#,
663        )
664        .expect("parse");
665        let record = recs.get("PV:A").expect("record present");
666        assert_eq!(
667            field_value(record, "INP"),
668            Some(ScalarValue::Str("PV:B PP NMS".into()))
669        );
670        assert_eq!(
671            field_value(record, "FLNK"),
672            Some(ScalarValue::Str("PV:B NPP NMS".into()))
673        );
674        // A non-link field is still served verbatim.
675        assert_eq!(
676            field_value(record, "EGU"),
677            Some(ScalarValue::Str("mm".into()))
678        );
679    }
680}