use std::borrow::Cow;
use crate::error::{CaError, CaResult};
use crate::runtime::json_string::{decode_json_string, decode_json_string_token};
use crate::types::{DbfLinkClass, EpicsValue};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LinkProcessPolicy {
NoProcess,
#[default]
ProcessPassive,
ChannelProcess,
ChannelProcessPassive,
}
impl LinkProcessPolicy {
pub fn cp_passive_only(self) -> Option<bool> {
match self {
LinkProcessPolicy::ChannelProcess => Some(false),
LinkProcessPolicy::ChannelProcessPassive => Some(true),
_ => None,
}
}
}
#[derive(Clone, Debug)]
pub struct LinkAddress {
pub record: String,
pub field: String,
pub policy: LinkProcessPolicy,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HwLinkKind {
InstIo,
VmeIo,
Other,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HwLink {
pub kind: HwLinkKind,
pub args: Vec<String>,
pub raw: String,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ParsedLink {
None,
Constant(String),
Db(DbLink),
Ca(CaLink),
Pva(String),
PvaJson(PvaJsonLink),
Hw(HwLink),
Calc(CalcLink),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum JlinkValue {
Null,
Bool(bool),
Int(i64),
Str(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PvaJsonLink {
pub pv: String,
pub options: Vec<(String, JlinkValue)>,
}
impl PvaJsonLink {
pub fn link_identity_key(&self) -> String {
pvajson_identity_key(&self.pv, &self.options)
}
}
pub const PVAJSON_IDENTITY_SEP: char = '\u{1f}';
pub fn pvajson_identity_key(pv: &str, options: &[(String, JlinkValue)]) -> String {
if options.is_empty() {
return pv.to_string();
}
let mut pairs: Vec<String> = options
.iter()
.map(|(k, v)| match v {
JlinkValue::Null => format!("{k}=n"),
JlinkValue::Bool(b) => format!("{k}=b:{b}"),
JlinkValue::Int(n) => format!("{k}=i:{n}"),
JlinkValue::Str(s) => format!("{k}=s:{s}"),
})
.collect();
pairs.sort();
let mut key =
String::with_capacity(pv.len() + 1 + pairs.iter().map(|p| p.len() + 1).sum::<usize>());
key.push_str(pv);
for p in &pairs {
key.push(PVAJSON_IDENTITY_SEP);
key.push_str(p);
}
key
}
#[derive(Clone, Debug, PartialEq)]
pub struct CalcLink {
pub expr: String,
pub args: Vec<String>,
pub time_source: Option<char>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MonitorSwitch {
#[default]
NoMaximize,
Maximize,
MaximizeStatus,
MaximizeIfInvalid,
}
#[derive(Clone, Debug, PartialEq)]
pub struct DbLink {
pub record: String,
pub field: String,
pub policy: LinkProcessPolicy,
pub monitor_switch: MonitorSwitch,
}
impl DbLink {
pub fn channel_name(&self) -> String {
if self.field == "VAL" {
self.record.clone()
} else {
format!("{}.{}", self.record, self.field)
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CaLink {
pub pv: String,
pub monitor_switch: MonitorSwitch,
pub policy: LinkProcessPolicy,
}
impl CaLink {
pub fn new(pv: impl Into<String>) -> Self {
Self {
pv: pv.into(),
monitor_switch: MonitorSwitch::NoMaximize,
policy: LinkProcessPolicy::NoProcess,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinkType {
Empty,
Constant,
Db,
Ca,
Other,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DbLinkType {
Constant,
PvLink,
JsonLink,
VmeIo,
CamacIo,
AbIo,
GpibIo,
BitbusIo,
InstIo,
BbgpibIo,
RfIo,
VxiIo,
}
impl DbLinkType {
pub fn c_name(self) -> &'static str {
match self {
DbLinkType::Constant => "CONSTANT",
DbLinkType::PvLink => "PV_LINK",
DbLinkType::JsonLink => "JSON_LINK",
DbLinkType::VmeIo => "VME_IO",
DbLinkType::CamacIo => "CAMAC_IO",
DbLinkType::AbIo => "AB_IO",
DbLinkType::GpibIo => "GPIB_IO",
DbLinkType::BitbusIo => "BITBUS_IO",
DbLinkType::InstIo => "INST_IO",
DbLinkType::BbgpibIo => "BBGPIB_IO",
DbLinkType::RfIo => "RF_IO",
DbLinkType::VxiIo => "VXI_IO",
}
}
pub fn accepts(self, parsed: DbLinkType) -> bool {
if self == parsed {
return true;
}
let soft = |t: DbLinkType| {
matches!(
t,
DbLinkType::Constant | DbLinkType::JsonLink | DbLinkType::PvLink
)
};
soft(self) && soft(parsed)
}
}
pub fn declared_link_type(
record_type: &str,
dtyp: Option<&str>,
upper_field: &str,
) -> Option<DbLinkType> {
if !matches!(upper_field, "INP" | "OUT") {
return Some(DbLinkType::Constant);
}
let menu = super::dbd_generated::device_menu(record_type)?;
let dtyp = dtyp
.filter(|d| !d.is_empty())
.or_else(|| menu.first().copied())?;
super::dbd_generated::device_link_type(record_type, dtyp)
}
pub fn check_link_assignment(
record_type: &str,
dtyp: Option<&str>,
upper_field: &str,
text: &str,
) -> CaResult<()> {
let Some(class) = crate::types::dbf_link_class(record_type, upper_field) else {
return Ok(());
};
let Some(expected) = declared_link_type(record_type, dtyp, upper_field) else {
return Ok(());
};
let ftype = match class {
DbfLinkClass::InLink => LinkFieldType::In,
DbfLinkClass::OutLink => LinkFieldType::Out,
DbfLinkClass::FwdLink => LinkFieldType::Fwd,
};
let Some(parsed) = parse_link_field(text, ftype).db_link_type() else {
return Ok(());
};
if expected.accepts(parsed) {
return Ok(());
}
Err(CaError::InvalidValue(format!(
"{record_type}.{upper_field}: can't initialize link type {} with \"{text}\" (type {})",
expected.c_name(),
parsed.c_name(),
)))
}
impl ParsedLink {
pub fn db_link_type(&self) -> Option<DbLinkType> {
Some(match self {
ParsedLink::None | ParsedLink::Constant(_) => DbLinkType::Constant,
ParsedLink::Db(_) | ParsedLink::Ca(_) | ParsedLink::Pva(_) => DbLinkType::PvLink,
ParsedLink::PvaJson(_) | ParsedLink::Calc(_) => DbLinkType::JsonLink,
ParsedLink::Hw(hw) => match hw.kind {
HwLinkKind::InstIo => DbLinkType::InstIo,
HwLinkKind::VmeIo => DbLinkType::VmeIo,
HwLinkKind::Other => return None,
},
})
}
pub fn link_type(&self) -> LinkType {
match self {
ParsedLink::None => LinkType::Empty,
ParsedLink::Constant(_) => LinkType::Constant,
ParsedLink::Db(_) => LinkType::Db,
ParsedLink::Ca(_) | ParsedLink::Pva(_) | ParsedLink::PvaJson(_) => LinkType::Ca,
ParsedLink::Hw(_) | ParsedLink::Calc(_) => LinkType::Other,
}
}
pub fn constant_value(&self) -> Option<EpicsValue> {
if let ParsedLink::Constant(s) = self {
if let Some(inner) = s
.trim()
.strip_prefix('[')
.and_then(|body| body.strip_suffix(']'))
{
return Some(constant_array_value(inner));
}
if let Some(v) = parse_c_double(s) {
Some(EpicsValue::Double(v))
} else {
Some(EpicsValue::String(s.clone().into()))
}
} else {
None
}
}
pub fn is_db(&self) -> bool {
matches!(self, ParsedLink::Db(_))
}
pub fn is_hw(&self) -> bool {
matches!(self, ParsedLink::Hw(_))
}
pub fn is_writable_out_link(&self) -> bool {
matches!(
self,
ParsedLink::Db(_) | ParsedLink::Ca(_) | ParsedLink::Pva(_) | ParsedLink::PvaJson(_)
)
}
pub fn external_pv_name(&self) -> Option<Cow<'_, str>> {
match self {
ParsedLink::Ca(ca) => Some(Cow::Borrowed(ca.pv.as_str())),
ParsedLink::Pva(name) => Some(Cow::Borrowed(name.as_str())),
ParsedLink::PvaJson(j) => Some(Cow::Owned(j.link_identity_key())),
_ => None,
}
}
pub fn monitor_switch(&self) -> Option<MonitorSwitch> {
match self {
ParsedLink::Db(db) => Some(db.monitor_switch),
ParsedLink::Ca(ca) => Some(ca.monitor_switch),
_ => None,
}
}
}
enum PvaRootValue<'a> {
Object(&'a str),
StringName(&'a str),
}
fn classify_pva_root_value(value: &str) -> Option<PvaRootValue<'_>> {
let v = value.trim();
if v.starts_with('{') {
return Some(PvaRootValue::Object(v));
}
for quote in ['"', '\''] {
if let Some(rest) = v.strip_prefix(quote) {
if let Some(inner) = rest.strip_suffix(quote) {
return Some(PvaRootValue::StringName(inner));
}
}
}
None
}
fn constant_array_value(inner: &str) -> EpicsValue {
let body = inner.trim();
if body.is_empty() {
return EpicsValue::DoubleArray(Vec::new());
}
let elements: Vec<String> = body
.split(',')
.map(|e| {
let e = e.trim();
decode_json_string_token(e).unwrap_or_else(|| e.to_string())
})
.collect();
let numbers: Option<Vec<f64>> = elements.iter().map(|e| e.parse::<f64>().ok()).collect();
match numbers {
Some(nums) => EpicsValue::DoubleArray(nums),
None => EpicsValue::StringArray(elements.into_iter().map(|e| e.into()).collect()),
}
}
pub fn parse_c_double(s: &str) -> Option<f64> {
crate::runtime::stdlib::epics_parse_double(s).ok()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LsLoad {
Text(String),
LenOnly,
}
pub fn load_link_ls(text: &str) -> Option<LsLoad> {
let s = text.trim();
if s.is_empty() {
return None;
}
if s.starts_with('{') && s.ends_with('}') {
let value = json_const_value(s)?;
return json_string_value(value).map(LsLoad::Text);
}
parse_c_double(s).map(|_| LsLoad::LenOnly)
}
fn json_const_value(s: &str) -> Option<&str> {
let inner = s[1..s.len() - 1].trim_start();
let (key, rest) = inner.split_once(':')?;
let key = key.trim().trim_matches('"').trim_matches('\'');
if !key.eq_ignore_ascii_case("const") {
return None;
}
Some(rest.trim().trim_end_matches(',').trim())
}
fn json_string_value(value: &str) -> Option<String> {
let v = value.trim();
let first = if let Some(rest) = v.strip_prefix('[') {
rest.trim_start().split(',').next()?.trim()
} else {
v
};
decode_json_string_token(first)
}
fn try_parse_json_link(s: &str) -> Option<ParsedLink> {
let s = s.trim();
if !s.starts_with('{') || !s.ends_with('}') {
return None;
}
let inner = &s[1..s.len() - 1];
let inner_trim = inner.trim_start();
let (key_raw, rest) = match inner_trim.split_once(':') {
Some((k, r)) => (k.trim(), r.trim()),
None => return None,
};
let key = key_raw
.trim_matches('"')
.trim_matches('\'')
.to_ascii_lowercase();
match key.as_str() {
"const" => {
let v = rest.trim_end_matches(',').trim();
let decoded = match decode_json_string_token(v) {
Some(s) => s,
None => v.to_string(),
};
if decoded.is_empty() {
Some(ParsedLink::None)
} else {
Some(ParsedLink::Constant(decoded))
}
}
"ca" | "pva" => {
let value = rest.trim_end_matches(',').trim();
match classify_pva_root_value(value)? {
PvaRootValue::Object(obj) => {
if key == "ca" {
Some(ParsedLink::Ca(CaLink::new(
extract_pv_and_opts_from_subobject(obj)?.0,
)))
} else {
let (pv, options) = extract_pv_and_opts_from_subobject(obj)?;
if options.is_empty() {
Some(ParsedLink::Pva(pv))
} else {
Some(ParsedLink::PvaJson(PvaJsonLink { pv, options }))
}
}
}
PvaRootValue::StringName(name) => {
let name = decode_json_string(name.trim());
if name.is_empty() {
return None;
}
if key == "ca" {
Some(ParsedLink::Ca(CaLink::new(name)))
} else {
Some(ParsedLink::Pva(name))
}
}
}
}
"calc" => {
let body = rest.trim();
let body_obj = if body.ends_with('}') {
body
} else {
return None;
};
let val: serde_json::Value = serde_json::from_str(body_obj).ok()?;
let obj = val.as_object()?;
let expr = obj.get("expr").and_then(|v| v.as_str())?.to_string();
let args: Vec<String> = obj
.get("args")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
if args.len() > 12 {
return None;
}
let time_source = obj
.get("time")
.and_then(|v| v.as_str())
.and_then(|s| s.chars().next())
.filter(|c| ('A'..='L').contains(c));
Some(ParsedLink::Calc(CalcLink {
expr,
args,
time_source,
}))
}
_ => None,
}
}
fn extract_pv_and_opts_from_subobject(body: &str) -> Option<(String, Vec<(String, JlinkValue)>)> {
let body = body.trim_start_matches('{').trim_end_matches('}').trim();
let mut pv: Option<String> = None;
let mut opts: Vec<(String, JlinkValue)> = Vec::new();
for entry in body.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let (k, v) = entry.split_once(':')?;
let k_raw = k.trim().trim_matches('"').trim_matches('\'');
let v_trimmed = v.trim().trim_matches(',').trim();
if v_trimmed.is_empty() {
continue;
}
if k_raw.eq_ignore_ascii_case("pv") {
let name = decode_json_string_token(v_trimmed).unwrap_or_else(|| v_trimmed.to_string());
if !name.is_empty() {
pv = Some(name);
}
} else if let Some(val) = classify_jlink_value(v_trimmed) {
opts.push((k_raw.to_string(), val));
}
}
Some((pv?, opts))
}
fn classify_jlink_value(raw: &str) -> Option<JlinkValue> {
let t = raw.trim();
if let Some(decoded) = decode_json_string_token(t) {
return Some(JlinkValue::Str(decoded));
}
match t {
"" => None,
"true" => Some(JlinkValue::Bool(true)),
"false" => Some(JlinkValue::Bool(false)),
"null" => Some(JlinkValue::Null),
_ => match t.parse::<i64>() {
Ok(n) => Some(JlinkValue::Int(n)),
Err(_) => Some(JlinkValue::Str(t.to_string())),
},
}
}
fn try_parse_hw_link(s: &str) -> Option<ParsedLink> {
if s.is_empty() {
return None;
}
let first = s.as_bytes()[0];
if first == b'@' {
let raw = s[1..].trim().to_string();
let args: Vec<String> = raw.split_whitespace().map(|t| t.to_string()).collect();
return Some(ParsedLink::Hw(HwLink {
kind: HwLinkKind::InstIo,
args,
raw,
}));
}
if first == b'#' {
let raw = s[1..].trim().to_string();
let args: Vec<String> = raw.split_whitespace().map(|t| t.to_string()).collect();
return Some(ParsedLink::Hw(HwLink {
kind: HwLinkKind::VmeIo,
args,
raw,
}));
}
None
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
enum ProcessClass {
#[default]
Npp,
Cpp,
Pp,
Ca,
Cp,
}
impl ProcessClass {
fn resolve(self) -> (LinkProcessPolicy, bool) {
match self {
Self::Npp => (LinkProcessPolicy::NoProcess, false),
Self::Cpp => (LinkProcessPolicy::ChannelProcessPassive, false),
Self::Pp => (LinkProcessPolicy::ProcessPassive, false),
Self::Ca => (LinkProcessPolicy::NoProcess, true),
Self::Cp => (LinkProcessPolicy::ChannelProcess, false),
}
}
fn is_cp_or_cpp(self) -> bool {
matches!(self, Self::Cp | Self::Cpp)
}
fn from_modifier_text(mods: &str) -> Self {
if mods.contains("NPP") {
Self::Npp
} else if mods.contains("CPP") {
Self::Cpp
} else if mods.contains("PP") {
Self::Pp
} else if mods.contains("CA") {
Self::Ca
} else if mods.contains("CP") {
Self::Cp
} else {
Self::Npp
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinkFieldType {
In,
Out,
Fwd,
}
impl LinkFieldType {
fn mask(self, class: ProcessClass, ms: MonitorSwitch) -> (ProcessClass, MonitorSwitch) {
match self {
Self::In => (class, ms),
Self::Out => {
let class = if class.is_cp_or_cpp() {
ProcessClass::Npp
} else {
class
};
(class, ms)
}
Self::Fwd => {
let class = if class == ProcessClass::Ca {
ProcessClass::Ca
} else {
ProcessClass::Npp
};
(class, MonitorSwitch::NoMaximize)
}
}
}
}
pub fn out_link_discards_cp(s: &str) -> bool {
let s = s.trim();
if s.is_empty() || s.starts_with(['{', '@', '#', '"']) {
return false;
}
let Some((_, mods)) = s.split_once(' ') else {
return false;
};
ProcessClass::from_modifier_text(mods).is_cp_or_cpp()
}
fn split_link_modifiers(
s: &str,
ftype: LinkFieldType,
) -> (&str, LinkProcessPolicy, MonitorSwitch, bool) {
let Some((target, mods)) = s.split_once(' ') else {
let (policy, force_ca) = ProcessClass::default().resolve();
return (s, policy, MonitorSwitch::NoMaximize, force_ca);
};
let class = ProcessClass::from_modifier_text(mods);
let ms = if mods.contains("NMS") {
MonitorSwitch::NoMaximize
} else if mods.contains("MSI") {
MonitorSwitch::MaximizeIfInvalid
} else if mods.contains("MSS") {
MonitorSwitch::MaximizeStatus
} else if mods.contains("MS") {
MonitorSwitch::Maximize
} else {
MonitorSwitch::NoMaximize
};
let (class, ms) = ftype.mask(class, ms);
let (policy, force_ca) = class.resolve();
(target, policy, ms, force_ca)
}
pub fn parse_link_v2(s: &str) -> ParsedLink {
parse_link_field(s, LinkFieldType::In)
}
pub fn parse_link_field(s: &str, ftype: LinkFieldType) -> ParsedLink {
let s = s.trim();
if let Some(parsed) = try_parse_json_link(s) {
return parsed;
}
if let Some(parsed) = try_parse_hw_link(s) {
return parsed;
}
if s.is_empty() {
return ParsedLink::None;
}
if let Some(rest) = s.strip_prefix("ca://") {
let (pv, policy, ms, _force_ca) = split_link_modifiers(rest, ftype);
return ParsedLink::Ca(CaLink {
pv: pv.to_string(),
monitor_switch: ms,
policy,
});
}
if let Some(rest) = s.strip_prefix("pva://") {
return ParsedLink::Pva(rest.to_string());
}
if parse_c_double(s).is_some() {
return ParsedLink::Constant(s.to_string());
}
if s.starts_with('[') && s.ends_with(']') && s.len() >= 2 {
return ParsedLink::Constant(s.to_string());
}
let (link_part, policy, ms, force_ca) = split_link_modifiers(s, ftype);
if force_ca {
return ParsedLink::Ca(CaLink {
pv: link_part.to_string(),
monitor_switch: ms,
policy,
});
}
if let Some((rec, field)) = split_record_field(link_part) {
return ParsedLink::Db(DbLink {
record: rec.to_string(),
field,
policy,
monitor_switch: ms,
});
}
ParsedLink::Db(DbLink {
record: link_part.to_string(),
field: "VAL".to_string(),
policy,
monitor_switch: ms,
})
}
fn split_record_field(link_part: &str) -> Option<(&str, String)> {
let (rec, field) = link_part.split_once('.')?;
let mut chars = field.chars();
let first = chars.next()?;
if !(first.is_ascii_alphabetic() || first == '_') {
return None;
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
return None;
}
Some((rec, field.to_ascii_uppercase()))
}
pub fn parse_output_link_v2(s: &str) -> ParsedLink {
parse_link_field(s, LinkFieldType::Out)
}
pub fn parse_forward_link_v2(s: &str) -> ParsedLink {
parse_link_field(s, LinkFieldType::Fwd)
}
pub fn link_field_type(s: &str) -> LinkType {
parse_link_v2(s).link_type()
}
pub fn parse_link(s: &str) -> Option<LinkAddress> {
match parse_link_v2(s) {
ParsedLink::Db(db) => Some(LinkAddress {
record: db.record,
field: db.field,
policy: db.policy,
}),
_ => None,
}
}
#[cfg(test)]
mod json_link_tests {
use super::*;
#[test]
fn json_const_numeric() {
assert_eq!(
parse_link_v2("{const: 1.5}"),
ParsedLink::Constant("1.5".to_string())
);
}
#[test]
fn json_const_quoted_string() {
assert_eq!(
parse_link_v2(r#"{const: "hello"}"#),
ParsedLink::Constant("hello".to_string())
);
}
#[test]
fn json_const_empty_is_none() {
assert_eq!(parse_link_v2(r#"{const: ""}"#), ParsedLink::None);
}
#[test]
fn json_ca_link() {
assert_eq!(
parse_link_v2(r#"{ca: { pv: "FOO" }}"#),
ParsedLink::Ca(CaLink::new("FOO"))
);
}
#[test]
fn json_pva_link() {
assert_eq!(
parse_link_v2(r#"{pva: { pv: "FOO:bar" }}"#),
ParsedLink::Pva("FOO:bar".to_string())
);
}
#[test]
fn json_ca_link_unquoted_key() {
assert_eq!(
parse_link_v2(r#"{ca: { pv: 'BAR' }}"#),
ParsedLink::Ca(CaLink::new("BAR"))
);
}
#[test]
fn json_pva_link_string_shorthand() {
assert_eq!(
parse_link_v2(r#"{pva: "TARGET:AI"}"#),
ParsedLink::Pva("TARGET:AI".to_string())
);
}
#[test]
fn json_ca_link_string_shorthand() {
assert_eq!(
parse_link_v2(r#"{ca: "TARGET:AI"}"#),
ParsedLink::Ca(CaLink::new("TARGET:AI"))
);
}
#[test]
fn json_pva_link_string_shorthand_single_quotes() {
assert_eq!(
parse_link_v2(r#"{pva: 'invalid:pv:name'}"#),
ParsedLink::Pva("invalid:pv:name".to_string())
);
}
#[test]
fn json_pva_link_longhand_preserves_options_structurally() {
assert_eq!(
parse_link_v2(r#"{pva: { pv: "FOO:bar", Q: "4" }}"#),
ParsedLink::PvaJson(PvaJsonLink {
pv: "FOO:bar".to_string(),
options: vec![("Q".to_string(), JlinkValue::Str("4".to_string()))],
})
);
}
#[test]
fn json_pva_link_longhand_preserves_value_kind() {
let link = parse_link_v2(
r#"{pva: {pv: "X:AI", pipeline: true, retry: false, proc: "CP", Q: 4, sevr: true}}"#,
);
let j = match link {
ParsedLink::PvaJson(j) => j,
other => panic!("expected PvaJson, got {other:?}"),
};
assert_eq!(
j.options,
vec![
("pipeline".to_string(), JlinkValue::Bool(true)),
("retry".to_string(), JlinkValue::Bool(false)),
("proc".to_string(), JlinkValue::Str("CP".to_string())),
("Q".to_string(), JlinkValue::Int(4)),
("sevr".to_string(), JlinkValue::Bool(true)),
]
);
}
#[test]
fn json_pva_link_root_nonstring_rejected() {
assert_eq!(
parse_link_v2(r#"{pva: "TARGET"}"#),
ParsedLink::Pva("TARGET".to_string())
);
assert_eq!(
parse_link_v2(r#"{pva: { pv: "TARGET" }}"#),
ParsedLink::Pva("TARGET".to_string())
);
for src in [
r#"{pva: true}"#,
r#"{pva: false}"#,
r#"{pva: 5}"#,
r#"{pva: null}"#,
r#"{pva: [1,2]}"#,
] {
assert!(
!matches!(
parse_link_v2(src),
ParsedLink::Pva(_) | ParsedLink::PvaJson(_)
),
"non-string pva root must not become a PVA link: {src}"
);
}
for src in [
r#"{ca: true}"#,
r#"{ca: 5}"#,
r#"{ca: null}"#,
r#"{ca: [1,2]}"#,
] {
assert!(
!matches!(parse_link_v2(src), ParsedLink::Ca(_)),
"non-string ca root must not become a CA link: {src}"
);
}
}
#[test]
fn hw_link_inst_io() {
let parsed = parse_link_v2("@simDriver 0 INPUT");
match parsed {
ParsedLink::Hw(hw) => {
assert_eq!(hw.kind, HwLinkKind::InstIo);
assert_eq!(hw.args, vec!["simDriver", "0", "INPUT"]);
assert_eq!(hw.raw, "simDriver 0 INPUT");
}
other => panic!("expected Hw, got {other:?}"),
}
}
#[test]
fn hw_link_inst_io_with_hex() {
let parsed = parse_link_v2("@dev 0xFF mask=0x1A");
match parsed {
ParsedLink::Hw(hw) => {
assert_eq!(hw.kind, HwLinkKind::InstIo);
assert_eq!(hw.args, vec!["dev", "0xFF", "mask=0x1A"]);
}
other => panic!("expected Hw, got {other:?}"),
}
}
#[test]
fn hw_link_vme_io() {
let parsed = parse_link_v2("#C0 S2");
match parsed {
ParsedLink::Hw(hw) => {
assert_eq!(hw.kind, HwLinkKind::VmeIo);
assert_eq!(hw.args, vec!["C0", "S2"]);
}
other => panic!("expected Hw, got {other:?}"),
}
}
#[test]
fn hw_link_inst_io_empty_args() {
let parsed = parse_link_v2("@");
match parsed {
ParsedLink::Hw(hw) => {
assert_eq!(hw.kind, HwLinkKind::InstIo);
assert!(hw.args.is_empty());
assert!(hw.raw.is_empty());
}
other => panic!("expected Hw, got {other:?}"),
}
}
#[test]
fn link_type_constant_numeric() {
assert_eq!(link_field_type("3.14"), LinkType::Constant);
assert_eq!(link_field_type("{const: 7}"), LinkType::Constant);
}
#[test]
fn link_type_quoted_string_is_a_pv_link() {
assert_eq!(link_field_type(r#""hello""#), LinkType::Db);
assert_eq!(link_field_type(r#""""#), LinkType::Db);
}
#[test]
fn link_type_empty_is_empty() {
assert_eq!(link_field_type(""), LinkType::Empty);
assert_eq!(link_field_type(" "), LinkType::Empty);
}
#[test]
fn link_type_db_link() {
assert_eq!(link_field_type("REC.VAL"), LinkType::Db);
assert_eq!(link_field_type("REC"), LinkType::Db);
assert_eq!(link_field_type("REC.VAL PP"), LinkType::Db);
}
#[test]
fn link_type_ca_link() {
assert_eq!(link_field_type("ca://REMOTE:PV"), LinkType::Ca);
assert_eq!(link_field_type("pva://REMOTE:PV"), LinkType::Ca);
assert_eq!(link_field_type(r#"{ca: { pv: "REMOTE" }}"#), LinkType::Ca);
}
#[test]
fn link_type_hw_and_calc_are_other() {
assert_eq!(link_field_type("@dev 0 IN"), LinkType::Other);
assert_eq!(link_field_type("#C0 S2"), LinkType::Other);
assert_eq!(
link_field_type(r#"{calc: {"expr": "A+1", "args": ["pv1"]}}"#),
LinkType::Other
);
}
#[test]
fn ca_modifier_classifies_as_ca() {
assert_eq!(
parse_link_v2("REC.FIELD CA"),
ParsedLink::Ca(CaLink::new("REC.FIELD"))
);
assert_eq!(link_field_type("REC.FIELD CA"), LinkType::Ca);
}
#[test]
fn ca_modifier_bare_pv_name() {
assert_eq!(
parse_link_v2("localPv CA"),
ParsedLink::Ca(CaLink::new("localPv"))
);
assert_eq!(link_field_type("localPv CA"), LinkType::Ca);
}
#[test]
fn ca_modifier_combined_with_ms() {
assert_eq!(
parse_link_v2("REC.VAL CA MS"),
ParsedLink::Ca(CaLink {
pv: "REC.VAL".to_string(),
monitor_switch: MonitorSwitch::Maximize,
policy: LinkProcessPolicy::NoProcess,
})
);
assert_eq!(
parse_link_v2("REC.VAL CA NMS"),
ParsedLink::Ca(CaLink {
pv: "REC.VAL".to_string(),
monitor_switch: MonitorSwitch::NoMaximize,
policy: LinkProcessPolicy::NoProcess,
})
);
assert_eq!(link_field_type("REC.VAL CA NMS"), LinkType::Ca);
}
#[test]
fn process_class_precedence_is_chain_order_not_token_order() {
assert_eq!(
parse_link_v2("REC CP NPP"),
ParsedLink::Db(DbLink {
record: "REC".to_string(),
field: "VAL".to_string(),
policy: LinkProcessPolicy::NoProcess,
monitor_switch: MonitorSwitch::NoMaximize,
})
);
assert_eq!(
parse_link_v2("OTHER:PV CP CA"),
ParsedLink::Ca(CaLink {
pv: "OTHER:PV".to_string(),
monitor_switch: MonitorSwitch::NoMaximize,
policy: LinkProcessPolicy::NoProcess,
})
);
assert_eq!(
parse_link_v2("REC PP CA"),
ParsedLink::Db(DbLink {
record: "REC".to_string(),
field: "VAL".to_string(),
policy: LinkProcessPolicy::ProcessPassive,
monitor_switch: MonitorSwitch::NoMaximize,
})
);
assert!(matches!(
parse_link_v2("REC CPP"),
ParsedLink::Db(DbLink {
policy: LinkProcessPolicy::ChannelProcessPassive,
..
})
));
assert!(matches!(
parse_link_v2("REC CP"),
ParsedLink::Db(DbLink {
policy: LinkProcessPolicy::ChannelProcess,
..
})
));
assert!(matches!(
parse_link_v2("REC"),
ParsedLink::Db(DbLink {
policy: LinkProcessPolicy::NoProcess,
..
})
));
}
#[test]
fn modifiers_are_substring_matched_not_space_delimited() {
assert_eq!(
parse_link_v2("REC QQCPPXMSITT"),
ParsedLink::Db(DbLink {
record: "REC".to_string(),
field: "VAL".to_string(),
policy: LinkProcessPolicy::ChannelProcessPassive,
monitor_switch: MonitorSwitch::MaximizeIfInvalid,
})
);
}
#[test]
fn maximize_switch_precedence() {
for (link, expect) in [
("REC NMS", MonitorSwitch::NoMaximize),
("REC MSI", MonitorSwitch::MaximizeIfInvalid),
("REC MSS", MonitorSwitch::MaximizeStatus),
("REC MS", MonitorSwitch::Maximize),
("REC MS NMS", MonitorSwitch::NoMaximize),
] {
match parse_link_v2(link) {
ParsedLink::Db(db) => assert_eq!(db.monitor_switch, expect, "link {link}"),
other => panic!("link {link}: expected Db, got {other:?}"),
}
}
}
#[test]
fn ca_scheme_link_parses_ms_modifier() {
assert_eq!(
parse_link_v2("ca://SR:DCCT MS"),
ParsedLink::Ca(CaLink {
pv: "SR:DCCT".to_string(),
monitor_switch: MonitorSwitch::Maximize,
policy: LinkProcessPolicy::NoProcess,
})
);
assert_eq!(
parse_link_v2("ca://SR:DCCT MSI"),
ParsedLink::Ca(CaLink {
pv: "SR:DCCT".to_string(),
monitor_switch: MonitorSwitch::MaximizeIfInvalid,
policy: LinkProcessPolicy::NoProcess,
})
);
assert_eq!(
parse_link_v2("ca://SR:DCCT MSS"),
ParsedLink::Ca(CaLink {
pv: "SR:DCCT".to_string(),
monitor_switch: MonitorSwitch::MaximizeStatus,
policy: LinkProcessPolicy::NoProcess,
})
);
assert_eq!(
parse_link_v2("ca://SR:DCCT"),
ParsedLink::Ca(CaLink::new("SR:DCCT"))
);
}
#[test]
fn ca_modifier_does_not_affect_plain_db_link() {
assert_eq!(link_field_type("camera.VAL"), LinkType::Db);
assert_eq!(link_field_type("REC.VAL PP"), LinkType::Db);
}
#[test]
fn br_r10_json_pva_options_preserved_in_parsed_link() {
let link = parse_link_v2(
r#"{pva: {pv: "TARGET:AI", field: "display.precision", proc: "CPP", sevr: "MS", Q: 8}}"#,
);
let j = match link {
ParsedLink::PvaJson(j) => j,
other => panic!("expected PvaJson, got {other:?}"),
};
assert_eq!(j.pv, "TARGET:AI", "pv must be the bare channel name");
assert!(
!j.pv.contains('?'),
"pv must not carry a `?` query: {}",
j.pv
);
assert_eq!(
j.options,
vec![
(
"field".to_string(),
JlinkValue::Str("display.precision".to_string())
),
("proc".to_string(), JlinkValue::Str("CPP".to_string())),
("sevr".to_string(), JlinkValue::Str("MS".to_string())),
("Q".to_string(), JlinkValue::Int(8)),
]
);
}
#[test]
fn json_pva_string_shorthand_keeps_query_chars_verbatim() {
assert_eq!(
parse_link_v2(r#"{pva: "TARGET:AI?field=x"}"#),
ParsedLink::Pva("TARGET:AI?field=x".to_string())
);
}
#[test]
fn pva_scheme_keeps_query_chars_verbatim() {
assert_eq!(
parse_link_v2("pva://TARGET:AI?field=x"),
ParsedLink::Pva("TARGET:AI?field=x".to_string())
);
}
#[test]
fn pva_json_external_pv_name() {
let link = parse_link_v2(r#"{pva: {pv: "TARGET:AI", proc: "CP"}}"#);
let key = link.external_pv_name().expect("PvaJson carries a key");
assert_eq!(key.as_ref(), "TARGET:AI\u{1f}proc=s:CP");
assert_eq!(link.link_type(), LinkType::Ca);
assert!(link.is_writable_out_link());
}
#[test]
fn pva_json_identity_key_separates_same_pv_links() {
let a = match parse_link_v2(r#"{pva: {pv: "SRC", field: "value", Q: 64}}"#) {
ParsedLink::PvaJson(j) => j,
other => panic!("expected PvaJson, got {other:?}"),
};
let b = match parse_link_v2(r#"{pva: {pv: "SRC", field: "alarm.severity", Q: 1}}"#) {
ParsedLink::PvaJson(j) => j,
other => panic!("expected PvaJson, got {other:?}"),
};
assert_ne!(
a.link_identity_key(),
b.link_identity_key(),
"same-PV links with different options must not collide"
);
assert_eq!(a.link_identity_key().split('\u{1f}').next(), Some("SRC"));
assert_eq!(b.link_identity_key().split('\u{1f}').next(), Some("SRC"));
let c = match parse_link_v2(r#"{pva: {pv: "SRC", Q: 64, field: "value"}}"#) {
ParsedLink::PvaJson(j) => j,
other => panic!("expected PvaJson, got {other:?}"),
};
assert_eq!(a.link_identity_key(), c.link_identity_key());
}
#[test]
fn br_r10_json_pva_bare_pv_unchanged() {
assert_eq!(
parse_link_v2(r#"{pva: { pv: "FOO:bar" }}"#),
ParsedLink::Pva("FOO:bar".to_string())
);
}
#[test]
fn json_unknown_key_falls_through_to_legacy() {
let result = parse_link_v2("{unknown: 42}");
assert!(matches!(
result,
ParsedLink::None | ParsedLink::Db(_) | ParsedLink::Constant(_)
));
}
#[test]
fn parse_output_link_bare_is_noprocess() {
match parse_output_link_v2("TARGET.VAL") {
ParsedLink::Db(db) => {
assert_eq!(db.policy, LinkProcessPolicy::NoProcess);
assert_eq!(db.record, "TARGET");
assert_eq!(db.field, "VAL");
}
other => panic!("expected Db link, got {other:?}"),
}
}
#[test]
fn parse_output_link_explicit_pp_processes() {
match parse_output_link_v2("TARGET.VAL PP") {
ParsedLink::Db(db) => {
assert_eq!(db.policy, LinkProcessPolicy::ProcessPassive);
}
other => panic!("expected Db link, got {other:?}"),
}
}
#[test]
fn parse_output_link_proc_field_is_noprocess() {
match parse_output_link_v2("TARGET.PROC") {
ParsedLink::Db(db) => {
assert_eq!(db.field, "PROC");
assert_eq!(db.policy, LinkProcessPolicy::NoProcess);
}
other => panic!("expected Db link, got {other:?}"),
}
}
#[test]
fn parse_input_link_bare_is_noprocess() {
match parse_link_v2("SRC.VAL") {
ParsedLink::Db(db) => {
assert_eq!(db.policy, LinkProcessPolicy::NoProcess);
assert_eq!(db.record, "SRC");
assert_eq!(db.field, "VAL");
}
other => panic!("expected Db link, got {other:?}"),
}
match parse_link_v2("SRC.VAL PP") {
ParsedLink::Db(db) => assert_eq!(db.policy, LinkProcessPolicy::ProcessPassive),
other => panic!("expected Db link, got {other:?}"),
}
}
#[test]
fn parse_output_link_explicit_npp_is_noprocess() {
match parse_output_link_v2("TARGET.VAL NPP") {
ParsedLink::Db(db) => {
assert_eq!(db.policy, LinkProcessPolicy::NoProcess);
}
other => panic!("expected Db link, got {other:?}"),
}
}
#[test]
fn parse_cp_and_cpp_are_distinct_policies() {
match parse_link_v2("SRC.VAL CP") {
ParsedLink::Db(db) => assert_eq!(db.policy, LinkProcessPolicy::ChannelProcess),
other => panic!("expected Db link for CP, got {other:?}"),
}
match parse_link_v2("SRC.VAL CPP") {
ParsedLink::Db(db) => {
assert_eq!(db.policy, LinkProcessPolicy::ChannelProcessPassive)
}
other => panic!("expected Db link for CPP, got {other:?}"),
}
}
#[test]
fn cp_passive_only_maps_cp_and_cpp() {
assert_eq!(
LinkProcessPolicy::ChannelProcess.cp_passive_only(),
Some(false)
);
assert_eq!(
LinkProcessPolicy::ChannelProcessPassive.cp_passive_only(),
Some(true)
);
assert_eq!(LinkProcessPolicy::ProcessPassive.cp_passive_only(), None);
assert_eq!(LinkProcessPolicy::NoProcess.cp_passive_only(), None);
}
#[test]
fn out_link_discards_cp_and_cpp_but_keeps_the_other_modifiers() {
for text in ["TARGET.VAL CP", "TARGET.VAL CPP", "TARGET CP MS"] {
match parse_output_link_v2(text) {
ParsedLink::Db(db) => {
assert_eq!(
db.policy,
LinkProcessPolicy::NoProcess,
"{text} must lose its CP/CPP class"
);
assert_eq!(
db.policy.cp_passive_only(),
None,
"{text} must not be a CP holder"
);
}
other => panic!("expected Db link for {text}, got {other:?}"),
}
}
match parse_output_link_v2("TARGET CP MS") {
ParsedLink::Db(db) => assert_eq!(db.monitor_switch, MonitorSwitch::Maximize),
other => panic!("expected Db link, got {other:?}"),
}
match parse_output_link_v2("TARGET PP CP") {
ParsedLink::Db(db) => assert_eq!(db.policy, LinkProcessPolicy::ProcessPassive),
other => panic!("expected Db link, got {other:?}"),
}
assert!(matches!(
parse_output_link_v2("OTHER:PV CA"),
ParsedLink::Ca(_)
));
}
#[test]
fn out_link_discards_cp_predicate_tracks_the_mask() {
for text in ["TARGET CP", "TARGET CPP", "TARGET CP MS", "TARGET MSI CPP"] {
assert!(out_link_discards_cp(text), "{text} loses its CP/CPP");
}
for text in [
"TARGET", "TARGET PP", "TARGET CA", "TARGET NPP", "TARGET PP CP", "TARGETCP", "\"CP\"", "{ca: {pv: \"X\"}}", ] {
assert!(!out_link_discards_cp(text), "{text} discards nothing");
}
}
#[test]
fn fwd_link_masks_everything_except_ca() {
for text in ["NEXT PP", "NEXT CP", "NEXT CPP", "NEXT NPP"] {
match parse_forward_link_v2(text) {
ParsedLink::Db(db) => {
assert_eq!(db.policy, LinkProcessPolicy::NoProcess, "{text}");
assert_eq!(db.monitor_switch, MonitorSwitch::NoMaximize, "{text}");
}
other => panic!("expected Db link for {text}, got {other:?}"),
}
}
match parse_forward_link_v2("NEXT MS") {
ParsedLink::Db(db) => assert_eq!(db.monitor_switch, MonitorSwitch::NoMaximize),
other => panic!("expected Db link, got {other:?}"),
}
match parse_forward_link_v2("OTHER:REC CA MS") {
ParsedLink::Ca(ca) => {
assert_eq!(ca.pv, "OTHER:REC");
assert_eq!(ca.monitor_switch, MonitorSwitch::NoMaximize);
}
other => panic!("expected Ca link, got {other:?}"),
}
}
}