use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use crate::error::{CaError, CaResult};
use crate::server::record::Record;
use crate::types::EpicsValue;
mod include;
mod substitution;
#[cfg(test)]
pub(crate) use include::parse_include_directive;
pub use include::{DbLoadConfig, expand_includes, override_dtyp, parse_db_file};
pub use substitution::{TemplateLoad, load_substitution_file, parse_substitutions};
pub type RecordFactory = Box<dyn Fn() -> Box<dyn Record> + Send + Sync>;
static RECORD_FACTORY_REGISTRY: OnceLock<Mutex<HashMap<String, RecordFactory>>> = OnceLock::new();
fn get_registry() -> &'static Mutex<HashMap<String, RecordFactory>> {
RECORD_FACTORY_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn register_record_type(name: &str, factory: RecordFactory) {
let mut reg = get_registry()
.lock()
.expect("record factory registry mutex poisoned");
reg.insert(name.to_string(), factory);
}
#[derive(Debug, Clone)]
pub struct DbRecordDef {
pub record_type: String,
pub name: String,
pub fields: Vec<(String, String)>,
pub aliases: Vec<String>,
pub info_tags: Vec<(String, String)>,
}
pub(crate) fn validate_record_name(name: &str, line: usize, col: usize) -> CaResult<()> {
if name.is_empty() {
return Err(CaError::DbParseError {
line,
column: col,
message: "record/alias name can't be empty".into(),
});
}
for (i, c) in name.chars().enumerate() {
if i == 0 && matches!(c, '-' | '+' | '[' | '{') {
tracing::warn!(name, "record/alias name should not begin with '{}'", c);
}
if (c as u32) < 0x20 {
tracing::warn!(
name,
"record/alias name should not contain non-printable 0x{:02X}",
c as u32
);
continue;
}
if matches!(c, ' ' | '\t' | '"' | '\'' | '.' | '$') {
return Err(CaError::DbParseError {
line,
column: col,
message: format!("bad character '{c}' in record/alias name \"{name}\""),
});
}
}
Ok(())
}
pub fn parse_db(input: &str, macros: &HashMap<String, String>) -> CaResult<Vec<DbRecordDef>> {
let expanded = substitute_macros(input, macros);
let mut records = Vec::new();
let mut global_aliases: Vec<(String, String)> = Vec::new();
let chars: Vec<char> = expanded.chars().collect();
let mut pos = 0;
let mut line = 1;
let mut col = 1;
while pos < chars.len() {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
if pos >= chars.len() {
break;
}
let word = read_word(&chars, &mut pos, &mut col);
if word.is_empty() {
pos += 1;
col += 1;
continue;
}
if word == "path" || word == "addpath" {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let _dir = read_quoted_string(&chars, &mut pos, &mut line, &mut col)?;
continue;
}
if word == "include" {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let _file = read_quoted_string(&chars, &mut pos, &mut line, &mut col)?;
continue;
}
if word == "alias" {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, '(', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let target = read_quoted_string(&chars, &mut pos, &mut line, &mut col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ',', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let alias_name = read_quoted_string(&chars, &mut pos, &mut line, &mut col)?;
validate_record_name(&alias_name, line, col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ')', line)?;
global_aliases.push((target, alias_name));
continue;
}
if word != "record" && word != "grecord" {
return Err(CaError::DbParseError {
line,
column: col,
message: format!("expected 'record', got '{word}'"),
});
}
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, '(', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let rec_type = read_word(&chars, &mut pos, &mut col);
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ',', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let name = read_quoted_string(&chars, &mut pos, &mut line, &mut col)?;
validate_record_name(&name, line, col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ')', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, '{', line)?;
let mut fields = Vec::new();
let mut aliases: Vec<String> = Vec::new();
let mut info_tags: Vec<(String, String)> = Vec::new();
loop {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
if pos >= chars.len() {
return Err(CaError::DbParseError {
line,
column: col,
message: "unexpected end of file in record body".into(),
});
}
if chars[pos] == '}' {
pos += 1;
col += 1;
break;
}
let kw = read_word(&chars, &mut pos, &mut col);
if kw != "field" && kw != "info" && kw != "alias" {
return Err(CaError::DbParseError {
line,
column: col,
message: format!("expected 'field', got '{kw}'"),
});
}
if kw == "alias" {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, '(', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let alias_name = read_quoted_string(&chars, &mut pos, &mut line, &mut col)?;
validate_record_name(&alias_name, line, col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ')', line)?;
aliases.push(alias_name);
continue;
}
if kw == "info" {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, '(', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let tag = read_field_value(&chars, &mut pos, &mut line, &mut col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ',', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let value = read_field_value(&chars, &mut pos, &mut line, &mut col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ')', line)?;
info_tags.push((tag, value));
continue;
}
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, '(', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let field_name = read_word(&chars, &mut pos, &mut col);
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ',', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let field_value = read_field_value(&chars, &mut pos, &mut line, &mut col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ')', line)?;
fields.push((field_name, field_value));
}
records.push(DbRecordDef {
record_type: rec_type,
name,
fields,
aliases,
info_tags,
});
}
for (target, alias_name) in global_aliases {
match records.iter_mut().find(|r| r.name == target) {
Some(rec) => rec.aliases.push(alias_name),
None => {
return Err(CaError::DbParseError {
line: 0,
column: 0,
message: format!(
"alias \"{alias_name}\" refers to unknown record \"{target}\""
),
});
}
}
}
Ok(records)
}
pub(crate) fn substitute_macros(input: &str, macros: &HashMap<String, String>) -> String {
let chars: Vec<char> = input.chars().collect();
let mut out = String::with_capacity(input.len());
trans(
&chars,
0,
macros,
&mut Vec::new(),
&mut Vec::new(),
&mut out,
);
out
}
fn trans(
chars: &[char],
level: usize,
macros: &HashMap<String, String>,
scopes: &mut Vec<HashMap<String, String>>,
visiting: &mut Vec<String>,
out: &mut String,
) {
let mut quote: Option<char> = None;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if let Some(q) = quote {
if c == q {
quote = None;
}
} else if c == '"' || c == '\'' {
quote = Some(c);
}
if c == '\\' && i + 1 < chars.len() {
out.push('\\');
out.push(chars[i + 1]);
i += 2;
continue;
}
let mac_ref =
c == '$' && i + 1 < chars.len() && (chars[i + 1] == '(' || chars[i + 1] == '{');
if mac_ref && quote != Some('\'') {
if let Some(next) = refer(chars, i, level, macros, scopes, visiting, out) {
i = next;
continue;
}
}
out.push(c);
i += 1;
}
}
fn refer(
chars: &[char],
start: usize,
level: usize,
macros: &HashMap<String, String>,
scopes: &mut Vec<HashMap<String, String>>,
visiting: &mut Vec<String>,
out: &mut String,
) -> Option<usize> {
let close = if chars[start + 1] == '(' { ')' } else { '}' };
let body_start = start + 2;
let mut depth = 1usize;
let mut j = body_start;
while j < chars.len() && depth > 0 {
if j + 1 < chars.len() && chars[j] == '$' && (chars[j + 1] == '(' || chars[j + 1] == '{') {
depth += 1;
j += 2;
continue;
}
if depth == 1 && chars[j] == close || depth > 1 && (chars[j] == ')' || chars[j] == '}') {
depth -= 1;
if depth == 0 {
break;
}
}
j += 1;
}
if depth != 0 {
return None; }
let body = &chars[body_start..j];
let after = j + 1;
let split = top_level_terminator(body);
let (name_chars, rest) = match split {
Some(k) => (&body[..k], &body[k..]),
None => (body, &body[body.len()..]),
};
let mut name = String::new();
trans(name_chars, level + 1, macros, scopes, visiting, &mut name);
let mut default: Option<&[char]> = None;
let mut scoped: Vec<(String, String)> = Vec::new();
if let Some(first) = rest.first() {
if *first == '=' {
let dflt = &rest[1..];
let dsplit = top_level_comma(dflt);
match dsplit {
Some(k) => {
default = Some(&dflt[..k]);
parse_scoped(&dflt[k..], level, macros, scopes, visiting, &mut scoped);
}
None => default = Some(dflt),
}
} else if *first == ',' {
parse_scoped(rest, level, macros, scopes, visiting, &mut scoped);
}
}
let mut frame: HashMap<String, String> = HashMap::new();
for (k, v) in scoped {
frame.insert(k, v);
}
scopes.push(frame);
let resolved = scopes
.iter()
.rev()
.find_map(|s| s.get(&name).cloned())
.or_else(|| macros.get(&name).cloned());
match resolved {
Some(val) => {
if visiting.contains(&name) {
out.push_str(&val);
} else {
visiting.push(name.clone());
let val_chars: Vec<char> = val.chars().collect();
trans(&val_chars, level + 1, macros, scopes, visiting, out);
visiting.pop();
}
}
None => match default {
Some(def_chars) => {
let def = strip_outer_quotes(def_chars);
trans(def, level + 1, macros, scopes, visiting, out);
}
None => {
out.push_str("$(");
out.push_str(&name);
out.push_str(",undefined)");
}
},
}
scopes.pop();
Some(after)
}
fn parse_scoped(
rest: &[char],
level: usize,
macros: &HashMap<String, String>,
scopes: &mut Vec<HashMap<String, String>>,
visiting: &mut Vec<String>,
out: &mut Vec<(String, String)>,
) {
let mut k = 0;
while k < rest.len() {
if rest[k] != ',' {
break;
}
k += 1; let seg = &rest[k..];
let term = top_level_terminator(seg);
let (name_part, tail) = match term {
Some(t) => (&seg[..t], &seg[t..]),
None => (seg, &seg[seg.len()..]),
};
let mut sname = String::new();
trans(name_part, level + 1, macros, scopes, visiting, &mut sname);
k += name_part.len();
if let Some('=') = tail.first() {
let valseg = &tail[1..];
let vterm = top_level_comma(valseg);
let (val_part, _) = match vterm {
Some(t) => (&valseg[..t], &valseg[t..]),
None => (valseg, &valseg[valseg.len()..]),
};
let mut sval = String::new();
trans(val_part, level + 1, macros, scopes, visiting, &mut sval);
out.push((sname, sval));
k += 1 + val_part.len();
}
}
}
fn top_level_terminator(body: &[char]) -> Option<usize> {
let mut depth = 0usize;
let mut i = 0;
while i < body.len() {
let c = body[i];
if c == '$' && i + 1 < body.len() && (body[i + 1] == '(' || body[i + 1] == '{') {
depth += 1;
i += 2;
continue;
}
if (c == ')' || c == '}') && depth > 0 {
depth -= 1;
} else if depth == 0 && (c == '=' || c == ',') {
return Some(i);
}
i += 1;
}
None
}
fn top_level_comma(body: &[char]) -> Option<usize> {
let mut depth = 0usize;
let mut i = 0;
while i < body.len() {
let c = body[i];
if c == '$' && i + 1 < body.len() && (body[i + 1] == '(' || body[i + 1] == '{') {
depth += 1;
i += 2;
continue;
}
if (c == ')' || c == '}') && depth > 0 {
depth -= 1;
} else if depth == 0 && c == ',' {
return Some(i);
}
i += 1;
}
None
}
fn strip_outer_quotes(s: &[char]) -> &[char] {
if s.len() >= 2 && s[0] == '"' && s[s.len() - 1] == '"' {
&s[1..s.len() - 1]
} else {
s
}
}
fn skip_whitespace_and_comments(
chars: &[char],
pos: &mut usize,
line: &mut usize,
col: &mut usize,
) {
while *pos < chars.len() {
match chars[*pos] {
' ' | '\t' | '\r' => {
*pos += 1;
*col += 1;
}
'\n' => {
*pos += 1;
*line += 1;
*col = 1;
}
'#' => {
while *pos < chars.len() && chars[*pos] != '\n' {
*pos += 1;
}
}
_ => break,
}
}
}
fn read_word(chars: &[char], pos: &mut usize, col: &mut usize) -> String {
let mut word = String::new();
while *pos < chars.len() && (chars[*pos].is_ascii_alphanumeric() || chars[*pos] == '_') {
word.push(chars[*pos]);
*pos += 1;
*col += 1;
}
word
}
fn read_quoted_string(
chars: &[char],
pos: &mut usize,
line: &mut usize,
col: &mut usize,
) -> CaResult<String> {
if *pos >= chars.len() || chars[*pos] != '"' {
return Err(CaError::DbParseError {
line: *line,
column: *col,
message: "expected '\"'".into(),
});
}
*pos += 1;
*col += 1;
let mut s = String::new();
while *pos < chars.len() && chars[*pos] != '"' {
if chars[*pos] == '\\' && *pos + 1 < chars.len() {
s.push('\\');
s.push(chars[*pos + 1]);
*pos += 2;
*col += 2;
} else if chars[*pos] == '\n' {
return Err(CaError::DbParseError {
line: *line,
column: *col,
message: "Newline in string, closing quote missing".into(),
});
} else {
s.push(chars[*pos]);
*pos += 1;
*col += 1;
}
}
if *pos >= chars.len() {
return Err(CaError::DbParseError {
line: *line,
column: *col,
message: "unterminated string".into(),
});
}
*pos += 1; *col += 1;
Ok(s)
}
fn read_field_value(
chars: &[char],
pos: &mut usize,
line: &mut usize,
col: &mut usize,
) -> CaResult<String> {
if *pos < chars.len() && chars[*pos] == '"' {
return read_quoted_string(chars, pos, line, col);
}
let is_bareword = |c: char| {
c.is_ascii_alphanumeric()
|| matches!(c, '_' | '-' | '+' | ':' | '.' | '[' | ']' | '<' | '>' | ';')
};
let mut s = String::new();
while *pos < chars.len() && is_bareword(chars[*pos]) {
s.push(chars[*pos]);
*pos += 1;
*col += 1;
}
while *pos < chars.len() && matches!(chars[*pos], ' ' | '\t' | '\r' | '\n') {
if chars[*pos] == '\n' {
*line += 1;
*col = 0;
}
*pos += 1;
*col += 1;
}
if *pos < chars.len() && chars[*pos] != ')' && chars[*pos] != ',' {
return Err(CaError::DbParseError {
line: *line,
column: *col,
message: format!(
"illegal character '{}' in unquoted value (expected a quoted string or bareword)",
chars[*pos]
),
});
}
Ok(s)
}
fn expect_char(
chars: &[char],
pos: &mut usize,
col: &mut usize,
expected: char,
line: usize,
) -> CaResult<()> {
if *pos >= chars.len() || chars[*pos] != expected {
let got = if *pos < chars.len() {
chars[*pos].to_string()
} else {
"EOF".to_string()
};
return Err(CaError::DbParseError {
line,
column: *col,
message: format!("expected '{expected}', got '{got}'"),
});
}
*pos += 1;
*col += 1;
Ok(())
}
pub fn create_record(record_type: &str) -> CaResult<Box<dyn Record>> {
if let Ok(reg) = get_registry().lock() {
if let Some(factory) = reg.get(record_type) {
return Ok(factory());
}
}
use crate::server::records::*;
match record_type {
"ai" => Ok(Box::new(ai::AiRecord::default())),
"ao" => Ok(Box::new(ao::AoRecord::default())),
"bi" => Ok(Box::new(bi::BiRecord::default())),
"bo" => Ok(Box::new(bo::BoRecord::default())),
"busy" => Ok(Box::new(busy::BusyRecord::default())),
"stringin" => Ok(Box::new(stringin::StringinRecord::default())),
"asyn" => Ok(Box::new(asyn_record::AsynRecord::default())),
"stringout" => Ok(Box::new(stringout::StringoutRecord::default())),
"longin" => Ok(Box::new(longin::LonginRecord::default())),
"longout" => Ok(Box::new(longout::LongoutRecord::default())),
"int64in" => Ok(Box::new(int64in::Int64inRecord::default())),
"int64out" => Ok(Box::new(int64out::Int64outRecord::default())),
"lsi" => Ok(Box::new(lsi::LsiRecord::default())),
"lso" => Ok(Box::new(lso::LsoRecord::default())),
"mbbi" => Ok(Box::new(mbbi::MbbiRecord::default())),
"mbbo" => Ok(Box::new(mbbo::MbboRecord::default())),
"mbbiDirect" => Ok(Box::new(mbbi_direct::MbbiDirectRecord::default())),
"mbboDirect" => Ok(Box::new(mbbo_direct::MbboDirectRecord::default())),
"event" => Ok(Box::new(event::EventRecord::default())),
"printf" => Ok(Box::new(printf::PrintfRecord::default())),
"swait" => Ok(Box::new(swait::SwaitRecord::default())),
"waveform" => Ok(Box::new(waveform::WaveformRecord::with_kind(
waveform::ArrayKind::Waveform,
))),
"aai" => Ok(Box::new(waveform::WaveformRecord::with_kind(
waveform::ArrayKind::Aai,
))),
"aao" => Ok(Box::new(waveform::WaveformRecord::with_kind(
waveform::ArrayKind::Aao,
))),
"subArray" => Ok(Box::new(waveform::WaveformRecord::with_kind(
waveform::ArrayKind::SubArray,
))),
"calc" => Ok(Box::new(calc::CalcRecord::default())),
"fanout" => Ok(Box::new(fanout::FanoutRecord::default())),
"seq" => Ok(Box::new(seq::SeqRecord::default())),
"sseq" => Ok(Box::new(sseq::SseqRecord::default())),
"scalcout" => Ok(Box::new(scalcout::ScalcoutRecord::default())),
"transform" => Ok(Box::new(transform::TransformRecord::default())),
"calcout" => Ok(Box::new(calcout::CalcoutRecord::default())),
"dfanout" => Ok(Box::new(dfanout::DfanoutRecord::default())),
"compress" => Ok(Box::new(compress::CompressRecord::default())),
"histogram" => Ok(Box::new(histogram::HistogramRecord::default())),
"sel" => Ok(Box::new(sel::SelRecord::default())),
"sub" => Ok(Box::new(sub_record::SubRecord::default())),
"aSub" => Ok(Box::new(asub_record::ASubRecord::default())),
_ => Err(CaError::DbParseError {
line: 0,
column: 0,
message: format!("unknown record type: '{record_type}'"),
}),
}
}
pub fn create_record_with_factories(
record_type: &str,
extra_factories: &std::collections::HashMap<String, super::RecordFactory>,
) -> CaResult<Box<dyn Record>> {
if let Some(factory) = extra_factories.get(record_type) {
return Ok(factory());
}
create_record(record_type)
}
pub fn apply_fields(
record: &mut Box<dyn Record>,
fields: &[(String, String)],
common_fields: &mut Vec<(String, EpicsValue)>,
) -> CaResult<()> {
for (name, value_str) in fields {
let upper_name = name.to_uppercase();
let field_desc = record
.field_list()
.iter()
.find(|f| f.name == upper_name.as_str());
if let Some(desc) = field_desc {
let value = EpicsValue::parse(desc.dbf_type, value_str).map_err(|e| {
CaError::InvalidValue(format!(
"field {upper_name} (type {:?}): cannot parse '{}': {e}",
desc.dbf_type, value_str
))
})?;
record.put_field(&upper_name, value)?;
} else {
common_fields.push((upper_name, EpicsValue::String(value_str.clone())));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_db() {
let input = r#"
record(ai, "TEMP") {
field(DESC, "Temperature")
field(SCAN, "1 second")
field(HOPR, "100")
field(LOPR, "0")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].record_type, "ai");
assert_eq!(records[0].name, "TEMP");
assert_eq!(records[0].fields.len(), 4);
assert_eq!(records[0].fields[0], ("DESC".into(), "Temperature".into()));
}
#[test]
fn test_macro_substitution() {
let input = r#"
record(ai, "$(P)TEMP") {
field(DESC, "$(D=Default Desc)")
}
"#;
let mut macros = HashMap::new();
macros.insert("P".to_string(), "IOC:".to_string());
let records = parse_db(input, ¯os).unwrap();
assert_eq!(records[0].name, "IOC:TEMP");
assert_eq!(records[0].fields[0].1, "Default Desc");
}
#[test]
fn test_multiple_records() {
let input = r#"
record(ai, "TEMP1") {
field(VAL, "25.0")
}
record(bo, "SWITCH") {
field(VAL, "1")
field(ZNAM, "Off")
field(ONAM, "On")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0].record_type, "ai");
assert_eq!(records[1].record_type, "bo");
}
#[test]
fn test_comments() {
let input = r#"
# This is a comment
record(ai, "TEMP") {
# Another comment
field(VAL, "25.0")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records.len(), 1);
}
#[test]
fn test_unknown_record_type() {
let result = create_record("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_quoted_string_escape() {
let input = r#"
record(stringin, "TEST") {
field(VAL, "hello \"world\"")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records[0].fields[0].1, r#"hello \"world\""#);
}
#[test]
fn test_quoted_string_keeps_escapes_raw() {
let input = r#"
record(stringin, "TEST") {
field(DESC, "line1\nline2")
field(OUT, "a\\b\tc")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records[0].fields[0].1, r"line1\nline2");
assert_eq!(records[0].fields[1].1, r"a\\b\tc");
}
#[test]
fn test_quoted_string_newline_aborts() {
let input = "record(stringin, \"TEST\") {\n field(DESC, \"line1\nline2\")\n}\n";
let res = parse_db(input, &HashMap::new());
assert!(
matches!(res, Err(CaError::DbParseError { ref message, .. })
if message.contains("Newline in string")),
"expected newline-in-string abort, got {res:?}"
);
}
#[test]
fn test_macro_with_quoted_default_in_string() {
let input = r#"
record(longout, "$(P)$(R)PositionXLink") {
field(DOL, "$(XPOS="") CP MS")
}
"#;
let mut macros = HashMap::new();
macros.insert("P".to_string(), "SIM1:".to_string());
macros.insert("R".to_string(), "Over1:1:".to_string());
macros.insert("XPOS".to_string(), "SIM1:ROI1:MinX_RBV".to_string());
let records = parse_db(input, ¯os).unwrap();
assert_eq!(records[0].fields[0].1, "SIM1:ROI1:MinX_RBV CP MS");
}
#[test]
fn test_macro_with_quoted_default_unset() {
let input = r#"
record(longout, "TEST:Link") {
field(DOL, "$(XPOS="") CP MS")
}
"#;
let macros = HashMap::new();
let records = parse_db(input, ¯os).unwrap();
assert!(records[0].fields[0].1.contains("CP MS"));
}
#[test]
fn test_recursive_macro_default() {
let input = r#"
record(stringin, "TEST") {
field(VAL, "$(TS_PORT=$(PORT)_TS)")
}
"#;
let mut macros = HashMap::new();
macros.insert("PORT".to_string(), "ATTR1".to_string());
let records = parse_db(input, ¯os).unwrap();
assert_eq!(records[0].fields[0].1, "ATTR1_TS");
}
#[test]
fn test_substitute_directive_in_expand() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let child = dir.path().join("child.db");
let mut f = std::fs::File::create(&child).unwrap();
writeln!(f, r#"record(ai, "$(P)$(R)Val") {{"#).unwrap();
writeln!(f, r#" field(VAL, "$(ADDR)")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
let parent = dir.path().join("parent.db");
let mut f = std::fs::File::create(&parent).unwrap();
writeln!(f, r#"substitute "R=A:,ADDR=0""#).unwrap();
writeln!(f, r#"include "child.db""#).unwrap();
writeln!(f, r#"substitute "R=B:,ADDR=1""#).unwrap();
writeln!(f, r#"include "child.db""#).unwrap();
let mut macros = HashMap::new();
macros.insert("P".to_string(), "IOC:".to_string());
let config = DbLoadConfig {
include_paths: vec![],
max_include_depth: 10,
};
let records = parse_db_file(&parent, ¯os, &config).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0].name, "IOC:A:Val");
assert_eq!(records[0].fields[0].1, "0");
assert_eq!(records[1].name, "IOC:B:Val");
assert_eq!(records[1].fields[0].1, "1");
}
#[test]
fn test_empty_string_numeric_parse() {
let input = r#"
record(longin, "TEST:Int") {
field(VAL, "")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records.len(), 1);
}
#[test]
fn test_calcout_process() {
use crate::server::record::Record;
use crate::server::records::calcout::CalcoutRecord;
let mut rec = CalcoutRecord::default();
rec.put_field("CALC", EpicsValue::String("A+B".into()))
.unwrap();
rec.put_field("A", EpicsValue::Double(3.0)).unwrap();
rec.put_field("B", EpicsValue::Double(4.0)).unwrap();
rec.process().unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 7.0).abs() < 1e-10),
other => panic!("expected Double(7.0), got {:?}", other),
}
}
#[test]
fn test_calcout_oopt() {
use crate::server::record::Record;
use crate::server::records::calcout::CalcoutRecord;
let mut rec = CalcoutRecord::default();
rec.put_field("CALC", EpicsValue::String("A".into()))
.unwrap();
rec.put_field("OOPT", EpicsValue::Short(1)).unwrap(); rec.put_field("A", EpicsValue::Double(5.0)).unwrap();
rec.process().unwrap();
assert!((rec.oval - 5.0).abs() < 1e-10);
rec.process().unwrap();
}
#[test]
fn test_calcout_dopt() {
use crate::server::record::Record;
use crate::server::records::calcout::CalcoutRecord;
let mut rec = CalcoutRecord::default();
rec.put_field("CALC", EpicsValue::String("A+B".into()))
.unwrap();
rec.put_field("OCAL", EpicsValue::String("A*B".into()))
.unwrap();
rec.put_field("DOPT", EpicsValue::Short(1)).unwrap(); rec.put_field("A", EpicsValue::Double(3.0)).unwrap();
rec.put_field("B", EpicsValue::Double(4.0)).unwrap();
rec.process().unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 7.0).abs() < 1e-10),
other => panic!("expected Double(7.0), got {:?}", other),
}
match rec.get_field("OVAL") {
Some(EpicsValue::Double(v)) => assert!((v - 12.0).abs() < 1e-10),
other => panic!("expected Double(12.0), got {:?}", other),
}
}
#[test]
fn test_dfanout_basic() {
use crate::server::record::Record;
use crate::server::records::dfanout::DfanoutRecord;
let mut rec = DfanoutRecord::default();
rec.put_field("VAL", EpicsValue::Double(42.0)).unwrap();
assert_eq!(rec.record_type(), "dfanout");
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 42.0).abs() < 1e-10),
other => panic!("expected Double(42.0), got {:?}", other),
}
}
#[test]
fn test_dfanout_output_links() {
use crate::server::record::Record;
use crate::server::records::dfanout::DfanoutRecord;
let mut rec = DfanoutRecord::default();
rec.put_field("OUTA", EpicsValue::String("REC_A".into()))
.unwrap();
rec.put_field("OUTB", EpicsValue::String("REC_B".into()))
.unwrap();
let links = rec.output_links();
assert_eq!(links.len(), 2);
}
#[test]
fn test_compress_circular_buffer() {
use crate::server::record::Record;
use crate::server::records::compress::CompressRecord;
let mut rec = CompressRecord::new(5, 4); for i in 0..7 {
rec.push_value(i as f64);
}
match rec.get_field("VAL") {
Some(EpicsValue::DoubleArray(arr)) => {
assert_eq!(arr, vec![2.0, 3.0, 4.0, 5.0, 6.0]);
}
other => panic!("expected DoubleArray, got {:?}", other),
}
}
#[test]
fn test_compress_n_to_1_mean() {
use crate::server::record::Record;
use crate::server::records::compress::CompressRecord;
let mut rec = CompressRecord::new(10, 2); rec.put_field("N", EpicsValue::Long(3)).unwrap();
rec.push_value(3.0);
rec.push_value(6.0);
rec.push_value(9.0); match rec.get_field("VAL") {
Some(EpicsValue::DoubleArray(arr)) => {
assert!((arr[0] - 6.0).abs() < 1e-10);
}
other => panic!("expected DoubleArray, got {:?}", other),
}
}
#[test]
fn test_histogram_bucket_count() {
use crate::server::records::histogram::HistogramRecord;
let mut rec = HistogramRecord::new(10, 0.0, 10.0);
rec.add_sample(2.5); rec.add_sample(2.7); rec.add_sample(7.0); assert_eq!(rec.val[2], 2);
assert_eq!(rec.val[6], 1);
}
#[test]
fn test_histogram_out_of_range() {
use crate::server::records::histogram::HistogramRecord;
let mut rec = HistogramRecord::new(10, 0.0, 10.0);
rec.add_sample(-1.0); rec.add_sample(10.0); rec.add_sample(15.0); let total: i32 = rec.val.iter().sum();
assert_eq!(total, 0);
}
#[test]
fn test_sel_specified() {
use crate::server::record::Record;
use crate::server::records::sel::SelRecord;
let mut rec = SelRecord::default();
rec.put_field("SELM", EpicsValue::Short(0)).unwrap(); rec.put_field("SELN", EpicsValue::Short(2)).unwrap(); rec.put_field("C", EpicsValue::Double(99.0)).unwrap();
rec.process().unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 99.0).abs() < 1e-10),
other => panic!("expected Double(99.0), got {:?}", other),
}
}
#[test]
fn test_sel_high_low_median() {
use crate::server::record::Record;
use crate::server::records::sel::SelRecord;
let mut rec = SelRecord::default();
rec.put_field("A", EpicsValue::Double(10.0)).unwrap();
rec.put_field("B", EpicsValue::Double(30.0)).unwrap();
rec.put_field("C", EpicsValue::Double(20.0)).unwrap();
rec.put_field("SELM", EpicsValue::Short(1)).unwrap();
rec.process().unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 30.0).abs() < 1e-10),
other => panic!("expected Double(30.0), got {:?}", other),
}
rec.put_field("SELM", EpicsValue::Short(2)).unwrap();
rec.process().unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 10.0).abs() < 1e-10), other => panic!("expected near 0.0, got {:?}", other),
}
}
#[test]
fn test_sub_record_register_and_call() {
use crate::server::record::{Record, RecordInstance, SubroutineFn};
use crate::server::records::sub_record::SubRecord;
use std::sync::Arc;
let mut rec = SubRecord::default();
rec.put_field("SNAM", EpicsValue::String("double_val".into()))
.unwrap();
rec.put_field("VAL", EpicsValue::Double(5.0)).unwrap();
let mut instance = RecordInstance::new("TEST_SUB".into(), rec);
let sub_fn: SubroutineFn = Box::new(|record: &mut dyn Record| {
if let Some(EpicsValue::Double(v)) = record.get_field("VAL") {
record.put_field("VAL", EpicsValue::Double(v * 2.0))?;
}
Ok(())
});
instance.subroutine = Some(Arc::new(sub_fn));
instance.process_local().unwrap();
match instance.record.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 10.0).abs() < 1e-10),
other => panic!("expected Double(10.0), got {:?}", other),
}
}
#[test]
fn test_new_record_types_in_db() {
let input = r#"
record(calcout, "TEST_CO") {
field(CALC, "A+1")
}
record(dfanout, "TEST_DF") {
field(VAL, "5.0")
}
record(compress, "TEST_CMP") {
field(DESC, "test compress")
}
record(histogram, "TEST_HIST") {
field(DESC, "test hist")
}
record(sel, "TEST_SEL") {
field(SELM, "0")
}
record(sub, "TEST_SUB") {
field(SNAM, "my_sub")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records.len(), 6);
for def in &records {
create_record(&def.record_type).unwrap();
}
}
#[test]
fn test_parse_include_directive() {
assert_eq!(
parse_include_directive(r#"include "foo.template""#),
Some("foo.template".to_string())
);
assert_eq!(
parse_include_directive(r#" include "bar.db""#),
Some("bar.db".to_string())
);
assert_eq!(
parse_include_directive(r#"include "baz.template" # a comment"#),
Some("baz.template".to_string())
);
assert_eq!(parse_include_directive("include something"), None);
assert_eq!(parse_include_directive(r#"# include "ignored.db""#), None);
assert_eq!(parse_include_directive("record(ai, \"X\") {"), None);
assert_eq!(parse_include_directive(r#"includes "nope.db""#), None);
}
#[test]
fn test_commented_include_ignored() {
assert_eq!(parse_include_directive(r#"# include "file.db""#), None);
assert_eq!(parse_include_directive(r#" # include "file.db""#), None);
}
#[test]
fn test_expand_includes() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let child_path = dir.path().join("child.db");
let mut f = std::fs::File::create(&child_path).unwrap();
writeln!(f, r#"record(ai, "CHILD") {{"#).unwrap();
writeln!(f, r#" field(VAL, "1.0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
let parent_path = dir.path().join("parent.db");
let mut f = std::fs::File::create(&parent_path).unwrap();
writeln!(f, r#"record(ao, "PARENT") {{"#).unwrap();
writeln!(f, r#" field(VAL, "2.0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
writeln!(f, r#"include "child.db""#).unwrap();
let config = DbLoadConfig::default();
let result = expand_includes(&parent_path, &HashMap::new(), &config).unwrap();
assert!(result.contains(r#"record(ao, "PARENT")"#));
assert!(result.contains(r#"record(ai, "CHILD")"#));
let records = parse_db(&result, &HashMap::new()).unwrap();
assert_eq!(records.len(), 2);
}
#[test]
fn test_circular_include_error() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let a_path = dir.path().join("a.template");
let b_path = dir.path().join("b.template");
let mut fa = std::fs::File::create(&a_path).unwrap();
writeln!(fa, r#"include "b.template""#).unwrap();
let mut fb = std::fs::File::create(&b_path).unwrap();
writeln!(fb, r#"include "a.template""#).unwrap();
let config = DbLoadConfig::default();
let result = expand_includes(&a_path, &HashMap::new(), &config);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("circular include"), "error was: {err}");
}
#[test]
fn test_duplicate_include_allowed() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let shared_path = dir.path().join("shared.db");
let mut f = std::fs::File::create(&shared_path).unwrap();
writeln!(f, r#"record(ai, "SHARED") {{"#).unwrap();
writeln!(f, r#" field(VAL, "0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
let main_path = dir.path().join("main.db");
let mut f = std::fs::File::create(&main_path).unwrap();
writeln!(f, r#"include "shared.db""#).unwrap();
writeln!(f, r#"include "shared.db""#).unwrap();
let config = DbLoadConfig::default();
let result = expand_includes(&main_path, &HashMap::new(), &config).unwrap();
assert_eq!(result.matches(r#"record(ai, "SHARED")"#).count(), 2);
}
#[test]
fn test_include_depth_limit() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
for i in 0..34 {
let path = dir.path().join(format!("file{i}.db"));
let mut f = std::fs::File::create(&path).unwrap();
if i < 33 {
writeln!(f, r#"include "file{}.db""#, i + 1).unwrap();
} else {
writeln!(f, r#"record(ai, "DEEP") {{"#).unwrap();
writeln!(f, r#" field(VAL, "0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
}
}
let config = DbLoadConfig {
include_paths: vec![],
max_include_depth: 32,
};
let result = expand_includes(&dir.path().join("file0.db"), &HashMap::new(), &config);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("depth limit"), "error was: {err}");
}
#[test]
fn test_include_not_found_error() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("main.db");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, r#"include "nonexistent.db""#).unwrap();
let config = DbLoadConfig::default();
let result = expand_includes(&path, &HashMap::new(), &config);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("not found"), "error was: {err}");
}
#[test]
fn test_include_with_macro_filename() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let subdir = dir.path().join("sub");
std::fs::create_dir(&subdir).unwrap();
let child_path = subdir.join("child.db");
let mut f = std::fs::File::create(&child_path).unwrap();
writeln!(f, r#"record(ai, "CHILD") {{"#).unwrap();
writeln!(f, r#" field(VAL, "0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
let main_path = dir.path().join("main.db");
let mut f = std::fs::File::create(&main_path).unwrap();
writeln!(f, r#"include "$(DIR)/child.db""#).unwrap();
let mut macros = HashMap::new();
macros.insert("DIR".to_string(), subdir.to_string_lossy().to_string());
let config = DbLoadConfig::default();
let result = expand_includes(&main_path, ¯os, &config).unwrap();
assert!(result.contains(r#"record(ai, "CHILD")"#));
}
#[test]
fn test_include_search_order() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let inc_dir = dir.path().join("inc");
std::fs::create_dir(&inc_dir).unwrap();
let child_path = inc_dir.join("child.db");
let mut f = std::fs::File::create(&child_path).unwrap();
writeln!(f, r#"record(ai, "FROM_INC") {{"#).unwrap();
writeln!(f, r#" field(VAL, "0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
let main_path = dir.path().join("main.db");
let mut f = std::fs::File::create(&main_path).unwrap();
writeln!(f, r#"include "child.db""#).unwrap();
let config = DbLoadConfig {
include_paths: vec![inc_dir.clone()],
max_include_depth: 32,
};
let result = expand_includes(&main_path, &HashMap::new(), &config).unwrap();
assert!(result.contains(r#"record(ai, "FROM_INC")"#));
let local_child = dir.path().join("child.db");
let mut f = std::fs::File::create(&local_child).unwrap();
writeln!(f, r#"record(ai, "FROM_LOCAL") {{"#).unwrap();
writeln!(f, r#" field(VAL, "0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
let result = expand_includes(&main_path, &HashMap::new(), &config).unwrap();
assert!(result.contains(r#"record(ai, "FROM_LOCAL")"#));
}
#[test]
fn test_addpath_directive_resolves_include() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let inc_dir = dir.path().join("extra");
std::fs::create_dir(&inc_dir).unwrap();
let child = inc_dir.join("child.db");
let mut f = std::fs::File::create(&child).unwrap();
writeln!(f, r#"record(ai, "FROM_ADDPATH") {{ field(VAL, "0") }}"#).unwrap();
let main = dir.path().join("main.db");
let mut f = std::fs::File::create(&main).unwrap();
writeln!(f, r#"addpath "{}""#, inc_dir.display()).unwrap();
writeln!(f, r#"include "child.db""#).unwrap();
let config = DbLoadConfig::default();
let result = expand_includes(&main, &HashMap::new(), &config).unwrap();
assert!(result.contains(r#"record(ai, "FROM_ADDPATH")"#));
}
#[test]
fn test_path_directive_replaces_search_path() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let inc_dir = dir.path().join("p");
std::fs::create_dir(&inc_dir).unwrap();
let child = inc_dir.join("c.db");
let mut f = std::fs::File::create(&child).unwrap();
writeln!(f, r#"record(ai, "VIA_PATH") {{ field(VAL, "0") }}"#).unwrap();
let main = dir.path().join("main.db");
let mut f = std::fs::File::create(&main).unwrap();
writeln!(f, r#"path "{}""#, inc_dir.display()).unwrap();
writeln!(f, r#"include "c.db""#).unwrap();
let config = DbLoadConfig::default();
let result = expand_includes(&main, &HashMap::new(), &config).unwrap();
assert!(result.contains(r#"record(ai, "VIA_PATH")"#));
}
#[test]
fn test_dtyp_override_existing_only() {
let mut records = vec![
DbRecordDef {
record_type: "ai".to_string(),
name: "REC_WITH_DTYP".to_string(),
fields: vec![
("DTYP".to_string(), "oldDtyp".to_string()),
("VAL".to_string(), "0".to_string()),
],
aliases: Vec::new(),
info_tags: Vec::new(),
},
DbRecordDef {
record_type: "ao".to_string(),
name: "REC_WITHOUT_DTYP".to_string(),
fields: vec![("VAL".to_string(), "1".to_string())],
aliases: Vec::new(),
info_tags: Vec::new(),
},
];
override_dtyp(&mut records, "newDtyp");
assert_eq!(
records[0].fields[0],
("DTYP".to_string(), "newDtyp".to_string())
);
assert_eq!(records[1].fields.len(), 1);
assert!(!records[1].fields.iter().any(|(n, _)| n == "DTYP"));
}
#[test]
fn test_parse_db_file_no_includes() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("simple.db");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, r#"record(ai, "$(P)TEMP") {{"#).unwrap();
writeln!(f, r#" field(VAL, "25.0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
let mut macros = HashMap::new();
macros.insert("P".to_string(), "IOC:".to_string());
let config = DbLoadConfig::default();
let records = parse_db_file(&path, ¯os, &config).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].name, "IOC:TEMP");
}
#[test]
fn name_validation_accepts_typical_names() {
for n in [
"IOC:TEMP",
"MOTOR-1",
"X[1]",
"scan_5",
"BL3:STD:01",
"abc.xyz_record",
]
.iter()
.copied()
{
if n.contains('.') {
assert!(validate_record_name(n, 1, 1).is_err());
continue;
}
validate_record_name(n, 1, 1).unwrap_or_else(|e| panic!("'{n}' should pass: {e:?}"));
}
}
#[test]
fn name_validation_rejects_empty() {
assert!(validate_record_name("", 1, 1).is_err());
}
#[test]
fn name_validation_rejects_bad_chars() {
for bad in ["spa ce", "do.t", "qu\"ot", "ap'os", "do$llar"] {
assert!(
validate_record_name(bad, 1, 1).is_err(),
"'{bad}' must be rejected"
);
}
}
#[test]
fn name_validation_warns_but_passes_on_nonprintable() {
validate_record_name("ta\tb", 1, 1).expect("TAB is warn-only per base spec");
validate_record_name("hello\x01world", 1, 1).expect("0x01 is warn-only");
}
#[test]
fn name_validation_warns_on_leading_special_but_passes() {
for warn in ["-x", "+y", "[arr", "{obj"] {
validate_record_name(warn, 1, 1).expect("leading special is warn-only");
}
}
#[test]
fn parse_db_propagates_name_validation_error() {
let bad = r#"record(ai, "BAD NAME") { }"#;
let res = parse_db(bad, &HashMap::new());
assert!(matches!(res, Err(CaError::DbParseError { .. })));
}
#[test]
fn parse_db_captures_aliases() {
let src = r#"record(ai, "TARGET") {
alias("ALIAS1")
alias("ALIAS2")
field(VAL, 42)
}"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].name, "TARGET");
assert_eq!(recs[0].aliases, vec!["ALIAS1", "ALIAS2"]);
assert_eq!(recs[0].fields.len(), 1);
}
#[test]
fn parse_db_rejects_alias_with_bad_name() {
let src = r#"record(ai, "TARGET") {
alias("BAD ALIAS")
}"#;
let res = parse_db(src, &HashMap::new());
assert!(matches!(res, Err(CaError::DbParseError { .. })));
}
#[test]
fn parse_db_accepts_path_and_addpath() {
let src = r#"
path "/opt/epics/db"
addpath "/extra/db"
record(ai, "REC") { field(VAL, "1") }
"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].name, "REC");
}
#[test]
fn parse_db_accepts_top_level_include() {
let src = r#"
include "common.db"
record(ai, "REC") { field(VAL, "1") }
"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert_eq!(recs.len(), 1);
}
#[test]
fn parse_db_global_alias_two_arg() {
let src = r#"
record(ai, "TARGET") { field(VAL, "1") }
alias("TARGET", "TARGET_ALIAS")
"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].aliases, vec!["TARGET_ALIAS"]);
}
#[test]
fn parse_db_global_alias_forward_reference() {
let src = r#"
alias("TARGET", "EARLY_ALIAS")
record(ai, "TARGET") { field(VAL, "1") }
"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert_eq!(recs[0].aliases, vec!["EARLY_ALIAS"]);
}
#[test]
fn parse_db_global_alias_unknown_record_errors() {
let src = r#"alias("NOSUCH", "X")"#;
let res = parse_db(src, &HashMap::new());
assert!(matches!(res, Err(CaError::DbParseError { .. })));
}
#[test]
fn parse_db_unquoted_bareword_value_ok() {
let src = r#"record(ai, "REC") { field(VAL, 42) field(EGU, deg-C) }"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert_eq!(recs[0].fields[0].1, "42");
assert_eq!(recs[0].fields[1].1, "deg-C");
}
#[test]
fn parse_db_unquoted_value_with_space_rejected() {
let src = r#"record(ai, "REC") { field(DESC, hello world) }"#;
let res = parse_db(src, &HashMap::new());
assert!(
matches!(res, Err(CaError::DbParseError { .. })),
"unquoted value with space must be rejected, got {res:?}"
);
}
#[test]
fn parse_db_unquoted_value_with_illegal_char_rejected() {
let src = r#"record(ai, "REC") { field(DESC, a*b) }"#;
let res = parse_db(src, &HashMap::new());
assert!(matches!(res, Err(CaError::DbParseError { .. })));
}
#[test]
fn parse_db_record_without_alias_has_empty_aliases() {
let src = r#"record(ai, "PLAIN") { field(VAL, 1) }"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert!(recs[0].aliases.is_empty());
}
#[test]
fn parse_db_info_accepts_unquoted_tag() {
let src = r#"
record(ai, "REC") {
field(VAL, "0")
info(asyn:READBACK, "1")
info("Q:group", "demo")
info(autosaveFields, "VAL DESC")
}
"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert_eq!(recs.len(), 1);
let tags = &recs[0].info_tags;
assert!(
tags.iter().any(|(k, v)| k == "asyn:READBACK" && v == "1"),
"unquoted tag must parse: {tags:?}"
);
assert!(tags.iter().any(|(k, v)| k == "Q:group" && v == "demo"));
assert!(
tags.iter()
.any(|(k, v)| k == "autosaveFields" && v == "VAL DESC"),
"unquoted multi-word value must parse: {tags:?}"
);
}
#[test]
fn substitute_macros_backslash_escapes_dollar() {
let mut macros = HashMap::new();
macros.insert("a".to_string(), "foo".to_string());
macros.insert("b".to_string(), "baz".to_string());
assert_eq!(substitute_macros(r"$(a)\$(b)", ¯os), r"foo\$(b)");
assert_eq!(substitute_macros(r"\${a}", ¯os), r"\${a}");
assert_eq!(substitute_macros("$(a)$(b)", ¯os), "foobaz");
assert_eq!(substitute_macros(r"\\$(a)", ¯os), r"\\foo");
assert_eq!(
substitute_macros(r"path\file $(a)", ¯os),
r"path\file foo"
);
}
#[test]
fn substitute_macros_chained_expansion() {
let mut macros = HashMap::new();
macros.insert("P".to_string(), "$(Q)".to_string());
macros.insert("Q".to_string(), "IOC:".to_string());
assert_eq!(substitute_macros("$(P)TEMP", ¯os), "IOC:TEMP");
}
#[test]
fn substitute_macros_scoped_definitions() {
let mut macros = HashMap::new();
macros.insert("INNER".to_string(), "$(A)-$(B)".to_string());
assert_eq!(substitute_macros("$(INNER,A=1,B=2)", ¯os), "1-2");
}
#[test]
fn substitute_macros_scoped_not_leaking() {
let mut macros = HashMap::new();
macros.insert("INNER".to_string(), "$(A)".to_string());
let out = substitute_macros("$(INNER,A=9)|$(A)", ¯os);
assert_eq!(out, "9|$(A,undefined)");
}
#[test]
fn substitute_macros_suppressed_in_single_quotes() {
let mut macros = HashMap::new();
macros.insert("X".to_string(), "VAL".to_string());
assert_eq!(substitute_macros("'$(X)'", ¯os), "'$(X)'");
assert_eq!(substitute_macros("\"$(X)\"", ¯os), "\"VAL\"");
}
#[test]
fn substitute_macros_indirect_name() {
let mut macros = HashMap::new();
macros.insert("WHICH".to_string(), "SEL".to_string());
macros.insert("SEL".to_string(), "chosen".to_string());
assert_eq!(substitute_macros("$($(WHICH))", ¯os), "chosen");
}
#[test]
fn substitute_macros_undefined_placeholder() {
let macros = HashMap::new();
assert_eq!(
substitute_macros("$(MISSING)", ¯os),
"$(MISSING,undefined)"
);
}
#[test]
fn substitute_macros_default_with_comma_is_c_parity() {
let macros = HashMap::new();
assert_eq!(substitute_macros("$(LIST=a,b,c)", ¯os), "a");
}
#[test]
fn substitute_macros_self_reference_terminates() {
let mut macros = HashMap::new();
macros.insert("A".to_string(), "$(A)".to_string());
let out = substitute_macros("$(A)", ¯os);
assert!(out.contains("A"), "self-ref expansion produced: {out}");
}
#[test]
fn substitute_macros_mutual_reference_terminates() {
let mut macros = HashMap::new();
macros.insert("A".to_string(), "$(B)".to_string());
macros.insert("B".to_string(), "$(A)".to_string());
let _ = substitute_macros("$(A)", ¯os); }
}