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,
}
impl ParsedLink {
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(_))
}
}
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 = extract_pv_from_subobject(rest)?;
if key == "ca" {
Some(ParsedLink::Ca(pv))
} else {
Some(ParsedLink::Pva(pv))
}
}
"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_from_subobject(body: &str) -> Option<String> {
let body = body.trim_start_matches('{').trim_end_matches('}').trim();
for entry in body.split(',') {
let entry = entry.trim();
let (k, v) = entry.split_once(':')?;
let k = k.trim().trim_matches('"').trim_matches('\'');
if k.eq_ignore_ascii_case("pv") {
let v = v
.trim()
.trim_matches(',')
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string();
if v.is_empty() {
return None;
}
return Some(v);
}
}
None
}
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 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;
}
link_part = trimmed;
break;
}
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_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 json_unknown_key_falls_through_to_legacy() {
let result = parse_link_v2("{unknown: 42}");
assert!(matches!(
result,
ParsedLink::None | ParsedLink::Db(_) | ParsedLink::Constant(_)
));
}
}