use std::borrow::Cow;
use crate::types::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,
}
#[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,
}
impl ParsedLink {
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 Ok(v) = s.parse::<f64>() {
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 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 stripped = v.trim_matches('"').trim_matches('\'');
if stripped.is_empty() {
Some(ParsedLink::None)
} else {
Some(ParsedLink::Constant(stripped.to_string()))
}
}
"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 = name.trim();
if name.is_empty() {
return None;
}
if key == "ca" {
Some(ParsedLink::Ca(CaLink::new(name.to_string())))
} else {
Some(ParsedLink::Pva(name.to_string()))
}
}
}
}
"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 = v_trimmed.trim_matches('"').trim_matches('\'');
if !name.is_empty() {
pv = Some(name.to_string());
}
} 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 t.len() >= 2
&& ((t.starts_with('"') && t.ends_with('"')) || (t.starts_with('\'') && t.ends_with('\'')))
{
return Some(JlinkValue::Str(t[1..t.len() - 1].to_string()));
}
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
}
fn strip_link_modifiers(s: &str) -> (&str, LinkProcessPolicy, MonitorSwitch, bool) {
let mut policy = LinkProcessPolicy::NoProcess;
let mut ms = MonitorSwitch::NoMaximize;
let mut force_ca = false;
let mut link_part = s;
loop {
let trimmed = link_part.trim_end();
if let Some(rest) = trimmed.strip_suffix(" NMS") {
ms = MonitorSwitch::NoMaximize;
link_part = rest;
continue;
}
if let Some(rest) = trimmed.strip_suffix(" MSI") {
ms = MonitorSwitch::MaximizeIfInvalid;
link_part = rest;
continue;
}
if let Some(rest) = trimmed.strip_suffix(" MSS") {
ms = MonitorSwitch::MaximizeStatus;
link_part = rest;
continue;
}
if let Some(rest) = trimmed.strip_suffix(" MS") {
ms = MonitorSwitch::Maximize;
link_part = rest;
continue;
}
if let Some(rest) = trimmed.strip_suffix(" NPP") {
policy = LinkProcessPolicy::NoProcess;
link_part = rest;
continue;
}
if let Some(rest) = trimmed.strip_suffix(" CPP") {
policy = LinkProcessPolicy::ChannelProcessPassive;
link_part = rest;
continue;
}
if let Some(rest) = trimmed.strip_suffix(" CP") {
policy = LinkProcessPolicy::ChannelProcess;
link_part = rest;
continue;
}
if let Some(rest) = trimmed.strip_suffix(" PP") {
policy = LinkProcessPolicy::ProcessPassive;
link_part = rest;
continue;
}
if let Some(rest) = trimmed.strip_suffix(" CA") {
force_ca = true;
link_part = rest;
continue;
}
link_part = trimmed;
break;
}
(link_part, policy, ms, force_ca)
}
pub fn parse_link_v2(s: &str) -> 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) = strip_link_modifiers(rest);
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());
}
let (link_part, policy, ms, force_ca) = strip_link_modifiers(s);
if force_ca {
return ParsedLink::Ca(CaLink {
pv: link_part.to_string(),
monitor_switch: ms,
policy,
});
}
if link_part.parse::<f64>().is_ok() {
return ParsedLink::Constant(link_part.to_string());
}
if link_part.starts_with('"') && link_part.ends_with('"') && link_part.len() >= 2 {
let inner = &link_part[1..link_part.len() - 1];
if inner.is_empty() {
return ParsedLink::None;
}
return ParsedLink::Constant(inner.to_string());
}
if let Some((rec, field)) = link_part.rsplit_once('.') {
let field_upper = field.to_ascii_uppercase();
let is_valid_field = !field_upper.is_empty()
&& field_upper.len() <= 4
&& field_upper.chars().all(|c| c.is_ascii_uppercase());
if is_valid_field {
return ParsedLink::Db(DbLink {
record: rec.to_string(),
field: field_upper,
policy,
monitor_switch: ms,
});
}
}
ParsedLink::Db(DbLink {
record: link_part.to_string(),
field: "VAL".to_string(),
policy,
monitor_switch: ms,
})
}
pub fn parse_output_link_v2(s: &str) -> ParsedLink {
parse_link_v2(s)
}
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_constant_quoted_string() {
assert_eq!(link_field_type(r#""hello""#), LinkType::Constant);
}
#[test]
fn link_type_empty_is_empty() {
assert_eq!(link_field_type(""), LinkType::Empty);
assert_eq!(link_field_type(" "), LinkType::Empty);
assert_eq!(link_field_type(r#""""#), 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_pp_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 PP CA"),
ParsedLink::Ca(CaLink {
pv: "REC.VAL".to_string(),
monitor_switch: MonitorSwitch::NoMaximize,
policy: LinkProcessPolicy::ProcessPassive,
})
);
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 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);
}
}