use crate::types::EpicsValue;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LinkProcessPolicy {
NoProcess,
#[default]
ProcessPassive,
ChannelProcess,
}
#[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(String),
Pva(String),
Hw(HwLink),
Calc(CalcLink),
}
#[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, 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(_) => 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()))
}
} 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(_)
)
}
}
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 (pv, query) = extract_pv_and_opts_from_subobject(rest)?;
if key == "ca" {
Some(ParsedLink::Ca(pv))
} else if query.is_empty() {
Some(ParsedLink::Pva(pv))
} else {
Some(ParsedLink::Pva(format!("{pv}?{query}")))
}
}
"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, String)> {
let body = body.trim_start_matches('{').trim_end_matches('}').trim();
let mut pv: Option<String> = None;
let mut opts: Vec<String> = 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_raw = v
.trim()
.trim_matches(',')
.trim()
.trim_matches('"')
.trim_matches('\'');
if v_raw.is_empty() {
continue;
}
if k_raw.eq_ignore_ascii_case("pv") {
pv = Some(v_raw.to_string());
} else {
opts.push(format!("{k_raw}={v_raw}"));
}
}
Some((pv?, opts.join("&")))
}
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
}
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://") {
return ParsedLink::Ca(rest.to_string());
}
if let Some(rest) = s.strip_prefix("pva://") {
return ParsedLink::Pva(rest.to_string());
}
let mut policy = LinkProcessPolicy::ProcessPassive;
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(" CP")
.or_else(|| trimmed.strip_suffix(" CPP"))
{
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;
}
if force_ca {
return ParsedLink::Ca(link_part.to_string());
}
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,
})
}
fn link_has_explicit_pp(raw: &str) -> bool {
raw.split_whitespace()
.any(|tok| tok == "PP" || tok == "CP" || tok == "CPP")
}
pub fn parse_output_link_v2(s: &str) -> ParsedLink {
let parsed = parse_link_v2(s);
if let ParsedLink::Db(ref db) = parsed {
if db.policy == LinkProcessPolicy::ProcessPassive
&& db.field != "PROC"
&& !link_has_explicit_pp(s)
{
let mut db = db.clone();
db.policy = LinkProcessPolicy::NoProcess;
return ParsedLink::Db(db);
}
}
parsed
}
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("FOO".to_string())
);
}
#[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("BAR".to_string())
);
}
#[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("REC.FIELD".to_string())
);
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("localPv".to_string())
);
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("REC.VAL".to_string())
);
assert_eq!(
parse_link_v2("REC.VAL PP CA"),
ParsedLink::Ca("REC.VAL".to_string())
);
assert_eq!(link_field_type("REC.VAL CA NMS"), LinkType::Ca);
}
#[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 stored = match link {
ParsedLink::Pva(ref s) => s.as_str(),
other => panic!("expected Pva, got {other:?}"),
};
assert!(
stored.starts_with("TARGET:AI?"),
"options must be encoded as query: {stored}"
);
assert!(
stored.contains("field=display.precision"),
"field option lost: {stored}"
);
assert!(stored.contains("proc=CPP"), "proc option lost: {stored}");
assert!(stored.contains("sevr=MS"), "sevr option lost: {stored}");
assert!(stored.contains("Q=8"), "Q option lost: {stored}");
}
#[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_keeps_process_passive() {
match parse_output_link_v2("TARGET.PROC") {
ParsedLink::Db(db) => {
assert_eq!(db.field, "PROC");
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:?}"),
}
}
}