use std::collections::HashMap;
use super::registry::*;
use crate::error::{CaError, CaResult};
use crate::runtime::log::{ERL_ERROR, ERL_WARNING};
use crate::server::database::filters::sync::DbState;
use crate::server::database::{DbNode, RecordLoad, parse_pv_name};
use crate::server::db_loader;
use crate::server::record::{Base, FieldDeclaration, FieldDesc, Special};
use crate::types::{DbFieldType, DbfCode, EpicsValue};
use std::sync::Arc;
static DB_RECORDS_ONCE_ONLY: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
fn db_records_once_only() -> bool {
DB_RECORDS_ONCE_ONLY.load(std::sync::atomic::Ordering::Relaxed) != 0
}
fn db_records_once_only_var() -> super::vars::VarDef {
super::vars::VarDef {
name: "dbRecordsOnceOnly",
access: super::vars::VarAccess::Int {
get: || DB_RECORDS_ONCE_ONLY.load(std::sync::atomic::Ordering::Relaxed),
set: |v| DB_RECORDS_ONCE_ONLY.store(v, std::sync::atomic::Ordering::Relaxed),
},
}
}
fn db_quiet_macro_warnings_var() -> super::vars::VarDef {
super::vars::VarDef {
name: "dbQuietMacroWarnings",
access: super::vars::VarAccess::Int {
get: || i64::from(db_loader::db_quiet_macro_warnings()),
set: |v| db_loader::set_db_quiet_macro_warnings(v != 0),
},
}
}
async fn install_alias(
ctx: &CommandContext,
alias: &str,
target: &str,
faults: &mut db_loader::DbFaults,
) {
let canonical = ctx
.db()
.resolve_alias(target)
.unwrap_or_else(|| target.to_string());
if ctx
.db()
.resolve_alias(alias)
.is_some_and(|already| already == canonical)
{
if db_records_once_only() {
faults.recoverable(format!(
"{ERL_ERROR}: Alias '{alias}' already defined; dbRecordsOnceOnly is set."
));
}
return;
}
if let Err(e) = ctx.db().add_alias(alias, target).await {
faults.recoverable(format!(
"dbLoadRecords: alias '{alias}' for '{target}' rejected: {e}"
));
}
}
pub(crate) fn register_builtins(registry: &mut CommandRegistry) {
registry.register(cmd_help());
registry.register(cmd_dbl());
registry.register(cmd_dba());
registry.register(cmd_dbgf());
registry.register(cmd_dbpf());
registry.register(cmd_dbpr());
registry.register(cmd_dbsr());
registry.register(cmd_dbglob());
registry.register(cmd_dbgrep());
registry.register(cmd_scanppl());
registry.register(cmd_scanpel());
registry.register(cmd_scanpiol());
registry.register(cmd_post_event());
registry.register(cmd_ioc_stats());
registry.register(cmd_db_load_database());
registry.register(cmd_db_load_records());
registry.register(cmd_db_load_template());
registry.register(cmd_db_create_record());
registry.register(cmd_db_delete_record());
registry.register(cmd_dbtgf());
registry.register(cmd_dbtpf());
registry.register(cmd_gft());
registry.register(cmd_pft());
registry.register(cmd_dbtr());
registry.register(cmd_dbjlr());
registry.register(cmd_dbior());
registry.register(cmd_dbel());
registry.register(cmd_dbtpn());
registry.register(cmd_tpn());
registry.register(cmd_dbnr());
registry.register(cmd_dblsr());
registry.register(cmd_db_lock_show_locked());
registry.register(cmd_db_put_attribute());
registry.register(cmd_db_notify_dump());
registry.register(cmd_dbcar());
registry.register(cmd_dbla());
registry.register(cmd_dbli());
registry.register(cmd_db_create_alias());
registry.register(cmd_db_state_create());
registry.register(cmd_db_state_set());
registry.register(cmd_db_state_clear());
registry.register(cmd_db_state_show());
registry.register(cmd_db_state_show_all());
registry.register(cmd_db_dump_breaktable());
registry.register(cmd_db_dump_path());
registry.register(cmd_epics_env_set());
registry.register(cmd_pushd());
registry.register(cmd_popd());
registry.register(cmd_dirs());
registry.register(cmd_ioc_init());
registry.register(cmd_after_ioc_running());
registry.register(cmd_exit());
super::core_commands::register(registry);
super::dbstatic_commands::register(registry);
super::misc_commands::register(registry);
super::queue_commands::register(registry);
super::registry_commands::register(registry);
super::time_commands::register(registry);
super::access_commands::register(registry);
super::breakpoint_commands::register(registry);
super::vars::register_variable(db_records_once_only_var());
super::vars::register_variable(db_quiet_macro_warnings_var());
super::vars::register(registry);
debug_assert!(
registry.displaced().is_empty(),
"two built-in families claim the same iocsh name(s): {:?}",
registry.displaced()
);
}
#[cfg(test)]
const C_DATABASE_COMMANDS: &[&str] = &[
"callbackParallelThreads",
"callbackQueueShow",
"callbackSetQueueSize",
"dbCreateAlias",
"dbDumpBreaktable",
"dbDumpDevice",
"dbDumpDriver",
"dbDumpField",
"dbDumpFunction",
"dbDumpLink",
"dbDumpMenu",
"dbDumpPath",
"dbDumpRecord",
"dbDumpRecordType",
"dbDumpRegistrar",
"dbDumpVariable",
"dbLoadDatabase",
"dbLoadRecords",
"dbLoadTemplate",
"dbLockShowLocked",
"dbNotifyDump",
"dbPutAttribute",
"dbPvdDump",
"dbPvdTableSize",
"dbReportDeviceConfig",
"dbStateClear",
"dbStateCreate",
"dbStateSet",
"dbStateShow",
"dbStateShowAll",
"dba",
"dbap",
"dbb",
"dbc",
"dbcar",
"dbd",
"dbel",
"dbgf",
"dbglob",
"dbgrep",
"dbhcr",
"dbior",
"dbjlr",
"dbl",
"dbla",
"dbli",
"dblsr",
"dbnr",
"dbp",
"dbpf",
"dbpr",
"dbs",
"dbsr",
"dbstat",
"dbtgf",
"dbtpf",
"dbtpn",
"dbtr",
"gft",
"pft",
"postEvent",
"scanOnceQueueShow",
"scanOnceSetQueueSize",
"scanpel",
"scanpiol",
"scanppl",
"tpn",
];
#[cfg(test)]
const ABSENT_DATABASE_COMMANDS: &[&str] = &[];
#[cfg(test)]
const PORT_ONLY_COMMANDS: &[(&str, &str)] = &[
(
"dbDeleteRecord",
"C has the call — `dbDeleteRecord` (`dbStaticLib.h:139`) — but reaches \
it only from the `.db` grammar `record(\"#\",\"name\")` \
(`dbLexRoutines.c:1146-1159`), which this port also accepts. The \
command is that same deletion with an iocsh surface.",
),
(
"dirs",
"C registers `cd` and `pwd` (`libComRegister.c`) and keeps no \
directory stack. See `pushd`.",
),
(
"iocStats",
"Base has no runtime-statistics verb at any spelling; the `devIocStats` \
module publishes the same numbers as records, not as a command.",
),
(
"popd",
"The pop half of the stack `cd` alone cannot express. See `pushd`.",
),
(
"pushd",
"An `st.cmd` that `iocshLoad`s a script which `cd`s has no way back in \
C except to know its own absolute path; the stack makes the return \
local to the caller.",
),
];
fn cmd_after_ioc_running() -> CommandDef {
CommandDef::new(
"afterIocRunning",
vec![ArgDesc {
name: "command",
arg_type: ArgType::String,
}],
"afterIocRunning <command> — schedule a command for post-iocInit execution",
|args: &[ArgValue], ctx: &CommandContext| {
let line = match &args[0] {
ArgValue::String(s) => s.clone(),
_ => {
ctx.println("afterIocRunning: missing command");
return Ok(CommandOutcome::Continue);
}
};
ctx.db().queue_after_ioc_running(line);
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_db_delete_record() -> CommandDef {
CommandDef::new(
"dbDeleteRecord",
vec![ArgDesc {
name: "recordName",
arg_type: ArgType::Record,
}],
"dbDeleteRecord <name> — remove a record from the live database",
|args: &[ArgValue], ctx: &CommandContext| {
let name = match &args[0] {
ArgValue::String(s) => s.clone(),
_ => {
ctx.println("dbDeleteRecord: missing recordName");
return Ok(CommandOutcome::Continue);
}
};
if ctx.block_on(ctx.db().remove_record(&name)) {
ctx.println(&format!("dbDeleteRecord: removed '{name}'"));
} else {
return Err(format!("dbDeleteRecord: no record named '{name}'"));
}
Ok(CommandOutcome::Continue)
},
)
}
fn dir_stack() -> &'static std::sync::Mutex<Vec<std::path::PathBuf>> {
static STACK: std::sync::OnceLock<std::sync::Mutex<Vec<std::path::PathBuf>>> =
std::sync::OnceLock::new();
STACK.get_or_init(|| std::sync::Mutex::new(Vec::new()))
}
fn print_stack(ctx: &CommandContext) {
let cwd = std::env::current_dir().unwrap_or_default();
let stack = dir_stack().lock().unwrap();
let parts: Vec<String> = std::iter::once(cwd.display().to_string())
.chain(stack.iter().rev().map(|p| p.display().to_string()))
.collect();
ctx.println(&parts.join(" "));
}
fn cmd_help() -> CommandDef {
CommandDef::new(
"help",
vec![ArgDesc {
name: "command",
arg_type: ArgType::String,
}],
"help [command] - List commands or show usage for a specific command",
|args: &[ArgValue], _ctx: &CommandContext| {
match &args[0] {
ArgValue::String(name) => {
_ctx.println("Use 'help' without arguments to list all commands, or 'help <command>' for details.");
_ctx.println(&format!("(Looking for help on '{name}')"));
}
ArgValue::Missing => {
_ctx.println("Use 'help' to list all commands.");
}
_ => {}
}
Ok(CommandOutcome::Continue)
},
)
}
fn split_fields_list(fields: &str) -> Vec<String> {
fields
.split(' ')
.filter(|f| !f.is_empty())
.map(str::to_string)
.collect()
}
fn print_fields_list(ctx: &CommandContext, name: &str, fields: &[String]) {
let mut line = String::from(name);
let record = ctx.db().get_record(name);
for field in fields {
let value = record.as_ref().and_then(|rec| {
let inst = rec.read();
if let Some(desc) = inst.field_desc(field) {
if desc.no_access() {
return Some(String::new());
}
return Some(
inst.resolve_field(field)
.map(|v| db_get_string(&inst, desc, &v))
.unwrap_or_default(),
);
}
if inst.resolves_noaccess_name(&field.to_ascii_uppercase()) {
return Some(String::new());
}
(field == "recordType").then(|| inst.record.record_type().to_string())
});
match value {
Some(v) => line.push_str(&format!(", \"{v}\"")),
None => line.push_str(", "),
}
}
ctx.println(&line);
}
fn db_nodes_type_major(ctx: &CommandContext) -> Vec<DbNode> {
use crate::server::record::dbd_generated::RECORD_TYPES;
let mut nodes = ctx.block_on(ctx.db().all_db_nodes());
let rank = |node: &DbNode| {
let record_type = ctx
.db()
.get_record(&node.name)
.map(|rec| rec.read().record.record_type().to_string())
.unwrap_or_default();
match RECORD_TYPES.iter().position(|t| *t == record_type) {
Some(i) => (0usize, i, String::new()),
None => (1usize, 0, record_type),
}
};
nodes.sort_by_cached_key(rank);
nodes
}
fn record_names_type_major(ctx: &CommandContext) -> Vec<String> {
db_nodes_type_major(ctx)
.into_iter()
.filter(|node| node.alias_of.is_none())
.map(|node| node.name)
.collect()
}
fn cmd_dbl() -> CommandDef {
CommandDef::new(
"dbl",
vec![
ArgDesc {
name: "record type",
arg_type: ArgType::String,
},
ArgDesc {
name: "fields",
arg_type: ArgType::String,
},
],
"dbl [record type] [fields] - List record names, optionally filtered by type",
|args: &[ArgValue], ctx: &CommandContext| {
let type_filter = match &args[0] {
ArgValue::String(s) if !s.is_empty() && s != "*" => Some(s.as_str()),
_ => None,
};
let fields = match &args[1] {
ArgValue::String(s) => split_fields_list(s),
_ => Vec::new(),
};
let nodes = db_nodes_type_major(ctx);
if let Some(filter) = type_filter {
let known = nodes.iter().any(|node| {
ctx.db()
.get_record(&node.name)
.is_some_and(|rec| rec.read().record.record_type() == filter)
});
if !known {
ctx.println("No record type");
return Ok(CommandOutcome::Continue);
}
}
for node in &nodes {
if let Some(filter) = type_filter {
let rec = ctx.db().get_record(&node.name);
if let Some(rec) = rec {
let inst = rec.read();
if inst.record.record_type() != filter {
continue;
}
}
}
print_fields_list(ctx, &node.name, &fields);
}
Ok(CommandOutcome::Continue)
},
)
}
struct TabBuffer {
out: String,
tab: usize,
next_tab: usize,
lines: Vec<String>,
}
impl TabBuffer {
const MAXLINE: usize = 80;
fn new(tab: usize) -> Self {
Self {
out: String::new(),
tab,
next_tab: tab,
lines: Vec::new(),
}
}
fn at(&self, i: usize) -> u8 {
self.out.as_bytes().get(i).copied().unwrap_or(0)
}
fn insert(&mut self, msg: &str) {
if self.out.len() + msg.len() > Self::MAXLINE {
self.flush();
}
for b in msg.bytes() {
self.out.push(b as char);
if self.at(self.next_tab - 1) != 0 {
self.next_tab += self.tab;
}
}
while self.at(self.next_tab - 1) != b' ' && self.out.len() < Self::MAXLINE {
self.out.push(' ');
}
}
fn flush(&mut self) {
if !self.out.is_empty() {
self.lines.push(std::mem::take(&mut self.out));
}
self.next_tab = self.tab;
}
fn finish(mut self) -> Vec<String> {
self.flush();
self.lines
}
}
fn field_addr_types(fd: &FieldDesc, value: Option<&EpicsValue>) -> (DbfCode, DbfCode) {
let field_type = match value {
Some(v) if fd.runtime_typed => v.db_field_type().dbf_code(),
_ if fd.declared_special == Special::DbAddr => fd.dbf_type.dbf_code(),
_ => fd.declared_dbf,
};
(field_type, field_type.dbr_code())
}
fn dbr_request(dbr: DbfCode) -> Option<(&'static str, crate::types::DbFieldType)> {
DBTGF_REQUEST_TYPES
.iter()
.find(|(name, _)| *name == dbr.name())
.copied()
}
fn printbuffer_elements(dbr: DbfCode, val: &EpicsValue) -> Vec<String> {
use crate::calc::engine::cvt::fmt_g;
fn quoted(bytes: &[u8]) -> String {
format!("\"{}\"", escape_char_array_for_dbgf(bytes))
}
fn i32_hex(v: i32) -> String {
format!("{v} = 0x{:x}", v as u32)
}
fn i16_hex(v: i16) -> String {
format!("{v} = 0x{:x}", v as u16)
}
fn char_scalar(byte: u8) -> String {
let val = byte as i8 as i32;
if byte.is_ascii_graphic() || byte == b' ' {
format!("{val} = 0x{byte:x} = '{}'", byte as char)
} else {
format!("{val} = 0x{byte:x}")
}
}
fn i64_hex(v: i64) -> String {
match v {
i64::MIN => "-0x8000000000000000".to_string(),
v if v < 0 => format!("-0x{:x}", v.unsigned_abs()),
v => format!("0x{v:x}"),
}
}
match (dbr, val) {
(DbfCode::String, EpicsValue::String(v)) => vec![quoted(v.as_bytes())],
(DbfCode::String, EpicsValue::StringArray(v)) => {
v.iter().map(|s| quoted(s.as_bytes())).collect()
}
(DbfCode::Char, EpicsValue::Char(v)) => vec![char_scalar(*v)],
(DbfCode::Char, EpicsValue::CharArray(v)) if v.len() == 1 => vec![char_scalar(v[0])],
(DbfCode::Char, EpicsValue::CharArray(v)) => {
let len = v.iter().position(|&b| b == 0).unwrap_or(v.len());
match len {
0 => vec![],
len => vec![quoted(&v[..len])],
}
}
(DbfCode::UChar, EpicsValue::UChar(v)) => vec![format!("{v} = 0x{v:x}")],
(DbfCode::UChar, EpicsValue::UCharArray(v)) => {
v.iter().map(|e| format!("{e} = 0x{e:x}")).collect()
}
(DbfCode::Short, EpicsValue::Short(v)) => vec![i16_hex(*v)],
(DbfCode::Short, EpicsValue::ShortArray(v)) => v.iter().map(|e| i16_hex(*e)).collect(),
(DbfCode::UShort, EpicsValue::UShort(v)) => vec![format!("{v} = 0x{v:x}")],
(DbfCode::UShort, EpicsValue::UShortArray(v)) => {
v.iter().map(|e| format!("{e} = 0x{e:x}")).collect()
}
(DbfCode::Long, EpicsValue::Long(v)) => vec![i32_hex(*v)],
(DbfCode::Long, EpicsValue::LongArray(v)) => v.iter().map(|e| i32_hex(*e)).collect(),
(DbfCode::ULong, EpicsValue::ULong(v)) => vec![format!("{v} = 0x{v:x}")],
(DbfCode::ULong, EpicsValue::ULongArray(v)) => {
v.iter().map(|e| format!("{e} = 0x{e:x}")).collect()
}
(DbfCode::Int64, EpicsValue::Int64(v)) => vec![format!("{v} = {}", i64_hex(*v))],
(DbfCode::Int64, EpicsValue::Int64Array(v)) => {
v.iter().map(|e| format!("{e} = {}", i64_hex(*e))).collect()
}
(DbfCode::UInt64, EpicsValue::UInt64(v)) => vec![format!("{v} = 0x{v:x}")],
(DbfCode::UInt64, EpicsValue::UInt64Array(v)) => {
v.iter().map(|e| format!("{e} = 0x{e:x}")).collect()
}
(DbfCode::Float, EpicsValue::Float(v)) => vec![fmt_g(*v as f64, 6, false, false)],
(DbfCode::Float, EpicsValue::FloatArray(v)) => v
.iter()
.map(|e| fmt_g(*e as f64, 6, false, false))
.collect(),
(DbfCode::Double, EpicsValue::Double(v)) => vec![fmt_g(*v, 12, false, false)],
(DbfCode::Double, EpicsValue::DoubleArray(v)) => {
v.iter().map(|e| fmt_g(*e, 12, false, false)).collect()
}
(DbfCode::Enum, EpicsValue::Enum(v)) => vec![v.to_string()],
(DbfCode::Enum, EpicsValue::EnumWithChoices { index, .. }) => vec![index.to_string()],
(DbfCode::Enum, EpicsValue::EnumArray(v)) => v.iter().map(|e| e.to_string()).collect(),
_ => vec![bad_dbr_type_text(dbr)],
}
}
fn bad_dbr_type_text(dbr: DbfCode) -> String {
format!("Bad DBR type {}", dbr as i16)
}
fn native_readback_lines(dbr: DbfCode, val: &EpicsValue) -> Vec<String> {
let Some((label, target)) = dbr_request(dbr) else {
return printbuffer_lines(dbr.name(), 1, Some(&printbuffer_elements(dbr, val)));
};
match val.get_convert(target) {
Ok(v) => printbuffer_block(label, dbr, &v),
Err(_) => printbuffer_lines(label, 1, None),
}
}
fn printbuffer_block(label: &str, dbr: DbfCode, val: &EpicsValue) -> Vec<String> {
printbuffer_lines(
label,
val.count() as usize,
Some(&printbuffer_elements(dbr, val)),
)
}
fn printbuffer_lines(dbr: &str, count: usize, elements: Option<&[String]>) -> Vec<String> {
let mut buf = TabBuffer::new(10);
match count {
1 => buf.insert(&format!("DBF_{dbr}: ")),
0 => buf.insert(&format!("DBF_{dbr}[0]: (empty)")),
n => buf.insert(&format!("DBF_{dbr}[{n}]: ")),
}
match elements {
None => buf.insert("failed."),
Some(values) => {
for v in values {
buf.insert(v);
}
}
}
buf.finish()
}
fn dbgf_lines(ctx: &CommandContext, pname: &str, val: &EpicsValue) -> Vec<String> {
let (rec_name, field) = parse_pv_name(pname);
let field = if field.is_empty() { "VAL" } else { field };
let Some(rec) = ctx.db().get_record(rec_name) else {
return native_readback_lines(DbfCode::String, val);
};
let inst = rec.read();
let Some((_, dbr)) = inst
.field_desc(field)
.map(|fd| field_addr_types(fd, Some(val)))
else {
return native_readback_lines(DbfCode::String, val);
};
if dbr != DbfCode::Enum {
return native_readback_lines(dbr, val);
}
let text = inst.field_as_dbr_string(field).unwrap_or_default();
native_readback_lines(DbfCode::String, &EpicsValue::String(text))
}
fn dbgf_echo(ctx: &CommandContext, pname: &str) {
match ctx.db().get_pv(pname) {
Ok(val) => {
for line in dbgf_lines(ctx, pname, &val) {
ctx.println(&line);
}
}
Err(_) => dbgf_failed(ctx, pname),
}
}
fn print_pv_not_found(ctx: &CommandContext, pname: &str) {
ctx.println(&format!("PV '{pname}' not found"));
}
fn name_to_addr(ctx: &CommandContext, pname: &str) -> Option<DbAddr> {
let (base, field) = parse_pv_name(pname);
let resolved = ctx
.db()
.get_record(base)
.filter(|_| !matches!(ctx.db().get_pv(pname), Err(CaError::ChannelNotFound(_))));
match resolved {
Some(rec) => {
let field = field.to_ascii_uppercase();
let fld_des = rec.read().field_desc(&field);
Some(DbAddr {
rec,
field,
fld_des,
})
}
None => {
print_pv_not_found(ctx, pname);
None
}
}
}
struct DbAddr {
rec: Arc<parking_lot::RwLock<crate::server::record::RecordInstance>>,
field: String,
fld_des: Option<&'static FieldDesc>,
}
impl DbAddr {
fn types(&self, value: Option<&EpicsValue>) -> (DbfCode, DbfCode) {
match self.fld_des {
Some(fd) => field_addr_types(fd, value),
None => (DbfCode::NoAccess, DbfCode::NoAccess),
}
}
}
fn c_field_size(code: DbfCode, declared_size: u16) -> Option<u16> {
Some(match code {
DbfCode::String => declared_size,
DbfCode::NoAccess if declared_size > 0 => declared_size,
DbfCode::Char | DbfCode::UChar => 1,
DbfCode::Short | DbfCode::UShort | DbfCode::Enum | DbfCode::Menu | DbfCode::Device => 2,
DbfCode::Long | DbfCode::ULong | DbfCode::Float => 4,
DbfCode::Double | DbfCode::Int64 | DbfCode::UInt64 => 8,
DbfCode::Inlink | DbfCode::Outlink | DbfCode::Fwdlink | DbfCode::NoAccess => return None,
})
}
fn dbget_bad_dbrtype_message(dbr: DbfCode, field_type: DbfCode) -> String {
let ft_name = format!("DBF_{}", field_type.name());
format!(
"dbGet: dbrType = {}, field_type = {ft_name:.12} ({}).",
dbr as i16, field_type as i16
)
}
fn dbgf_failed(ctx: &CommandContext, pname: &str) {
let (rec_name, field) = parse_pv_name(pname);
let field = if field.is_empty() { "VAL" } else { field };
let (field_type, dbr, field_name) = match ctx.db().get_record(rec_name) {
Some(rec) => {
let inst = rec.read();
match inst.field_desc(field) {
Some(fd) => {
let (ft, dbr) = field_addr_types(fd, None);
(ft, dbr, fd.name)
}
None => (DbfCode::NoAccess, DbfCode::NoAccess, field),
}
}
None => (DbfCode::NoAccess, DbfCode::NoAccess, field),
};
crate::server::recgbl::rec_gbl_dbaddr_error(
"Illegal Database Request Type",
rec_name,
field_name,
&dbget_bad_dbrtype_message(dbr, field_type),
);
for line in printbuffer_lines(dbr.name(), 1, None) {
ctx.println(&line);
}
}
fn print_db_addr(ctx: &CommandContext, addr: &DbAddr) {
let inst = addr.rec.read();
let field = addr.field.as_str();
let value = inst.resolve_field(field);
let (field_type, dbr) = addr.types(value.as_ref());
let elements = inst
.record
.field_native_count(field)
.or_else(|| inst.record.get_field(field).map(|v| v.count()))
.unwrap_or(1);
match addr.fld_des {
Some(fd) => ctx.println(&format!(
"Record Address: {:p} Field Address: (none) Field Description: {:p}",
Arc::as_ptr(&addr.rec),
fd as *const FieldDesc,
)),
None => ctx.println(&format!(
"Record Address: {:p} Field Address: (none) Field Description: (none)",
Arc::as_ptr(&addr.rec),
)),
}
ctx.println(&format!(" No Elements: {elements}"));
ctx.println(&format!(" Record Type: {}", inst.record.record_type()));
ctx.println(&format!(
" Field Type: {} = DBF_{}",
field_type as i16,
field_type.name(),
));
match addr
.fld_des
.and_then(|fd| c_field_size(field_type, fd.size))
{
Some(n) => ctx.println(&format!(" Field Size: {n}")),
None => ctx.println(" Field Size: (none)"),
}
let special = addr
.fld_des
.map_or(Special::NoMod as i16, |fd| fd.special as i16);
ctx.println(&format!(" Special: {special}"));
ctx.println(&format!(
"DBR Field Type: {} = DBR_{}",
dbr as i16,
dbr.name(),
));
}
fn cmd_dba() -> CommandDef {
CommandDef::new(
"dba",
vec![ArgDesc {
name: "record name",
arg_type: ArgType::Record,
}],
"dba record name - Print the dbAddr structure for a field",
|args: &[ArgValue], ctx: &CommandContext| {
let name = match args.first() {
Some(ArgValue::String(s)) if !s.is_empty() => s,
_ => {
ctx.println("Usage: dba \"pv name\"");
return Ok(CommandOutcome::Failed);
}
};
let Some(addr) = name_to_addr(ctx, name) else {
return Ok(CommandOutcome::Failed);
};
print_db_addr(ctx, &addr);
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_dbgf() -> CommandDef {
CommandDef::new(
"dbgf",
vec![ArgDesc {
name: "record name",
arg_type: ArgType::Record,
}],
"dbgf record name - Get field value",
|args: &[ArgValue], ctx: &CommandContext| {
let name = match args.first() {
Some(ArgValue::String(s)) if !s.is_empty() => s,
_ => {
ctx.println("Usage: dbgf \"pv name\"");
return Ok(CommandOutcome::Failed);
}
};
if name_to_addr(ctx, name).is_none() {
return Ok(CommandOutcome::Failed);
}
if !dbt_requires_ioc_init(ctx, "dbgf") {
return Ok(CommandOutcome::Failed);
}
dbgf_echo(ctx, name);
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_dbpf() -> CommandDef {
CommandDef::new(
"dbpf",
vec![
ArgDesc {
name: "pvname",
arg_type: ArgType::Record,
},
ArgDesc {
name: "value",
arg_type: ArgType::String,
},
],
"dbpf pvname value - Put field value",
|args: &[ArgValue], ctx: &CommandContext| {
let (name, value_str) = match (&args[0], &args[1]) {
(ArgValue::String(n), ArgValue::String(v)) if !n.is_empty() => (n, v),
_ => {
ctx.println("Usage: dbpf \"pv name\", \"value\"");
return Ok(CommandOutcome::Failed);
}
};
if name_to_addr(ctx, name).is_none() {
return Ok(CommandOutcome::Failed);
}
if !dbt_requires_ioc_init(ctx, "dbpf") {
return Ok(CommandOutcome::Failed);
}
let (base, field) = parse_pv_name(name);
let field = field.to_ascii_uppercase();
let dbf_type = ctx.block_on(async {
if let Some(rec) = ctx.db().get_record(base) {
let inst = rec.read();
if let Some(t) = crate::server::record::record_instance::declared_field_type_of(
inst.record.as_ref(),
&field,
) {
return Some(t);
}
return common_field_dbf_type(&field);
}
None
});
let array_addr = ctx.block_on(async {
let rec = ctx.db().get_record(base)?;
let inst = rec.read();
let dbr = inst.resolve_field(&field)?.db_field_type();
let capacity = inst
.record
.field_native_count(&field)
.or_else(|| inst.record.get_field(&field).map(|v| v.count()))
.unwrap_or(1);
(capacity > 1).then_some((capacity as usize, dbr))
});
let array_value = match array_addr {
Some((capacity, dbr @ (DbFieldType::Char | DbFieldType::UChar))) => {
let mut bytes = value_str.as_bytes().to_vec();
bytes.truncate(capacity.saturating_sub(1));
bytes.push(0);
Some(match dbr {
DbFieldType::UChar => EpicsValue::UCharArray(bytes),
_ => EpicsValue::CharArray(bytes),
})
}
Some((capacity, dbr)) => {
match crate::server::db_convert_json::db_put_convert_json(
value_str, dbr, capacity,
) {
Ok(v) => Some(v),
Err(e) => {
if let Some(refusal) = &e.refusal {
crate::runtime::log::errlog_printf(refusal);
}
crate::runtime::log::errlog_printf(&e.diagnostic);
return Ok(CommandOutcome::Failed);
}
}
}
None => None,
};
let value = if let Some(v) = array_value {
v
} else if field == "DTYP" {
match value_str.trim().parse::<i64>() {
Ok(i) => EpicsValue::Enum(i as u16),
Err(_) => EpicsValue::String(value_str.trim().into()),
}
} else if let Some(dbf) = dbf_type {
EpicsValue::parse(dbf, value_str)
.unwrap_or_else(|_| EpicsValue::String(value_str.trim().into()))
} else {
EpicsValue::String(value_str.clone().into())
};
let put_result: CaResult<()> = ctx.block_on(async {
let db = ctx.db();
if db.get_record(base).is_some() {
db.put_record_field_from_ca_no_notify(base, &field, value)
.await
} else {
db.put_pv(name, value).await
}
});
let put_failed = put_result.is_err();
dbgf_echo(ctx, name);
if put_failed {
Ok(CommandOutcome::Failed)
} else {
Ok(CommandOutcome::Continue)
}
},
)
}
pub(super) fn db_get_string(
inst: &crate::server::record::RecordInstance,
desc: &crate::server::record::FieldDesc,
value: &EpicsValue,
) -> String {
let text = match desc.declared_dbf {
DbfCode::Menu | DbfCode::Device => {
let index = match value {
EpicsValue::Enum(v) => Some(*v),
EpicsValue::Short(v) => u16::try_from(*v).ok(),
EpicsValue::UShort(v) => Some(*v),
EpicsValue::UChar(v) => Some(u16::from(*v)),
_ => None,
};
index.map(|i| dbpr_choice_text(inst, desc, i).unwrap_or_else(|| "<nil>".to_string()))
}
_ if desc.base == Base::Hex => hex_string(value),
_ => match value {
EpicsValue::Float(v) => Some(real_to_string(f64::from(*v), false)),
EpicsValue::Double(v) => Some(real_to_string(*v, true)),
_ => None,
},
};
text.unwrap_or_else(|| value.to_string())
}
fn hex_string(value: &EpicsValue) -> Option<String> {
fn u32_hex(v: u32) -> String {
format!("0x{v:x}")
}
fn i64_hex(v: i64) -> String {
match v {
0 => "0x0".to_string(),
v if v > 0 => format!("0x{v:x}"),
i64::MIN => "-0x8000000000000000".to_string(),
v => format!("-0x{:x}", v.unsigned_abs()),
}
}
Some(match value {
EpicsValue::Char(v) => u32_hex(i32::from(*v as i8) as u32),
EpicsValue::UChar(v) => u32_hex(u32::from(*v)),
EpicsValue::Short(v) => u32_hex(i32::from(*v) as u32),
EpicsValue::UShort(v) | EpicsValue::Enum(v) => u32_hex(u32::from(*v)),
EpicsValue::Long(v) => u32_hex(*v as u32),
EpicsValue::ULong(v) => u32_hex(*v),
EpicsValue::Int64(v) => i64_hex(*v),
EpicsValue::UInt64(v) => format!("0x{v:x}"),
_ => return None,
})
}
fn real_to_string(value: f64, is_double: bool) -> String {
const DELTA: [f64; 2] = [1e-6, 1e-15];
const PRECISION: [i32; 2] = [6, 14];
let i = usize::from(is_double);
if value == 0.0 {
return "0".to_string();
}
let absvalue = if value < 0.0 { -value } else { value };
if absvalue < f64::from(i32::MAX) {
let intval = value as i32;
let diff = (value - f64::from(intval)).abs();
if diff < absvalue * DELTA[i] {
return intval.to_string();
}
}
let (sign, value) = if value < 0.0 {
("-", -value)
} else {
("", value)
};
let logval: i32 = if value.is_finite() {
value.log10() as i32
} else {
i32::MIN
};
if logval > 6 || logval < -2 {
let prec = PRECISION[i];
let formatted = c_format_e(value, prec as usize);
let Some(epos) = formatted.find('e') else {
return format!("{sign}{formatted}");
};
let exponent = &formatted[epos..];
let mut mantissa = real_trim(formatted.as_bytes()[..epos].to_vec(), prec, PRECISION[i]);
if !mantissa.contains('.') {
mantissa.push_str(".0");
}
format!("{sign}{mantissa}{exponent}")
} else {
let prec = (PRECISION[i] - logval).max(0);
let formatted = format!("{value:.*}", prec as usize);
let trimmed = real_trim(formatted.into_bytes(), prec, PRECISION[i]);
format!("{sign}{trimmed}")
}
}
fn real_trim(mut t: Vec<u8>, prec: i32, precision: i32) -> String {
if prec <= 0 {
return String::from_utf8_lossy(&t).into_owned();
}
let precision = precision as usize;
let mut end = t.len() - 1;
let mut round = false;
while end > 0 {
if t[end] == b'.' {
end -= 1;
break;
}
if t[end] == b'0' {
end -= 1;
continue;
}
if !round && end < precision {
break;
}
if !round && t[end] < b'8' {
break;
}
if t[end - 1] == b'.' {
if round {
end = end.saturating_sub(2);
}
break;
}
if t[end - 1] != b'9' {
break;
}
round = true;
end -= 1;
}
t.truncate(end + 1);
let mut carry = false;
if round {
loop {
if t[end] < b'9' {
t[end] += 1;
break;
}
if end == 0 {
carry = true;
t[end] = b'0';
break;
}
t[end] = b'0';
end -= 1;
}
}
let digits = String::from_utf8_lossy(&t);
if carry {
format!("1{digits}")
} else {
digits.into_owned()
}
}
fn c_format_e(value: f64, prec: usize) -> String {
if value.is_nan() {
return if value.is_sign_negative() {
"-nan"
} else {
"nan"
}
.to_string();
}
if value.is_infinite() {
return if value < 0.0 { "-inf" } else { "inf" }.to_string();
}
let s = format!("{value:.*e}", prec);
match s.split_once('e') {
Some((mantissa, exp)) => {
let (sign, digits) = match exp.strip_prefix('-') {
Some(d) => ('-', d),
None => ('+', exp.trim_start_matches('+')),
};
format!("{mantissa}e{sign}{digits:0>2}")
}
None => s,
}
}
pub(super) fn db_get_string_field(
inst: &crate::server::record::RecordInstance,
field: &str,
) -> Option<String> {
let desc = inst.field_desc(field)?;
let value = inst.resolve_field(field)?;
Some(db_get_string(inst, desc, &value))
}
fn dbpr_choice_text(
inst: &crate::server::record::RecordInstance,
desc: &crate::server::record::FieldDesc,
index: u16,
) -> Option<String> {
let device = desc.declared_dbf == DbfCode::Device;
let Some(form) = inst.enum_string_form_for(desc.name) else {
return device.then(String::new);
};
match form.slots.get(index as usize) {
Some(choice) => Some(choice.as_str_lossy().into_owned()),
None if device => None,
None => Some(index.to_string()),
}
}
fn render_dbpr_link(
ctx: &CommandContext,
class: crate::types::DbfLinkClass,
raw: &str,
rendered: &str,
running: bool,
) -> String {
if !running && !raw.is_empty() {
return format!("LINK {raw}");
}
let ftype = crate::server::record::LinkFieldType::for_class(class);
let parsed = ctx
.db()
.db_init_link_locality(crate::server::record::parse_link_field(raw, ftype));
format!("{} {rendered}", parsed.link_type_after_init(raw).c_name())
}
fn cmd_dbpr() -> CommandDef {
CommandDef::new(
"dbpr",
vec![
ArgDesc {
name: "record",
arg_type: ArgType::Record,
},
ArgDesc {
name: "level",
arg_type: ArgType::Int,
},
],
"dbpr record [level] - Print record fields (level 0-2)",
|args: &[ArgValue], ctx: &CommandContext| {
let name = match &args[0] {
ArgValue::String(s) if !s.is_empty() => s,
_ => {
ctx.println("Usage: dbpr \"pv name\", level");
return Ok(CommandOutcome::Failed);
}
};
let level = match &args[1] {
ArgValue::Int(n) => *n as i32,
ArgValue::Missing => 0,
_ => 0,
};
if !dbpr_report(ctx, name, level) {
return Ok(CommandOutcome::Failed);
}
Ok(CommandOutcome::Continue)
},
)
}
fn dbpr_no_access(
inst: &crate::server::record::RecordInstance,
d: &crate::server::record::FieldDesc,
) -> Option<String> {
if d.name == "TIME" {
return Some(
crate::server::builtin_devices::timestamp::epics_time_to_strftime(
"%Y-%m-%d %H:%M:%S.%09f",
inst.common.time,
),
);
}
let bytes: &[u8] = match d.name {
"BKPT" => std::slice::from_ref(&inst.common.bkpt),
_ => return None,
};
let mut out = String::new();
for b in bytes.iter().take(usize::from(d.size)) {
out.push_str(&format!("{b:02x} "));
}
Some(out)
}
pub(super) fn dbpr_report(ctx: &CommandContext, name: &str, level: i32) -> bool {
let Some(DbAddr { rec, .. }) = name_to_addr(ctx, name) else {
return false;
};
let (fields, extras): (Vec<(String, String)>, Vec<(String, String)>) = ctx.block_on(async {
let rec_name = { rec.read().name.clone() };
let aliases = ctx.db().aliases_for_record(&rec_name);
let inst = rec.read();
let mut descs: Vec<&crate::server::record::FieldDesc> =
crate::server::record::dbd_generated::DB_COMMON_FIELDS
.iter()
.chain(inst.record.field_list())
.collect();
descs.sort_by_key(|d| d.name);
descs.dedup_by_key(|d| d.name);
let rows: Vec<(String, String, Option<(crate::types::DbfLinkClass, String)>)> = descs
.iter()
.filter(|d| i32::from(d.interest) <= level)
.filter_map(|d| {
if d.no_access() {
return dbpr_no_access(&inst, d).map(|t| (d.name.to_string(), t, None));
}
let value = inst.resolve_field(d.name)?;
let link =
crate::types::dbf_link_class(inst.record.record_type(), d.name).map(|class| {
let raw = match inst.resolve_field_stored(d.name) {
Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
_ => String::new(),
};
(class, raw)
});
Some((d.name.to_string(), db_get_string(&inst, d, &value), link))
})
.collect();
let mut extras = Vec::new();
if !aliases.is_empty() {
extras.push(("ALIASES".to_string(), aliases.join(", ")));
}
if level >= 2 {
let mut info_keys: Vec<&String> = inst.info.keys().collect();
info_keys.sort();
for key in info_keys {
let val = inst.info.get(key).cloned().unwrap_or_default();
extras.push((format!("info({key})"), val));
}
}
drop(inst);
let running = ctx.db().ioc_is_running();
let fields = rows
.into_iter()
.map(|(name, text, link)| match link {
None => (name, text),
Some((class, raw)) => {
let text = render_dbpr_link(ctx, class, &raw, &text, running);
(name, text)
}
})
.collect();
(fields, extras)
});
let mut buf = TabBuffer::new(20);
for (name, value) in &fields {
buf.insert(&format!("{name:<4}: {value}"));
}
for line in buf.finish() {
ctx.println(&line);
}
for (name, value) in &extras {
ctx.println(&format!("{name:<4}: {value}"));
}
true
}
const DBTGF_REQUEST_TYPES: [(&str, crate::types::DbFieldType); 12] = {
use crate::types::DbFieldType as T;
[
("STRING", T::String),
("CHAR", T::Char),
("UCHAR", T::UChar),
("SHORT", T::Short),
("USHORT", T::UShort),
("LONG", T::Long),
("ULONG", T::ULong),
("INT64", T::Int64),
("UINT64", T::UInt64),
("FLOAT", T::Float),
("DOUBLE", T::Double),
("ENUM", T::Enum),
]
};
fn dbr_string_row(snap: &crate::server::snapshot::Snapshot, precision: i16) -> Option<Vec<String>> {
use crate::calc::engine::cvt::cvt_double_to_string;
let prec = precision.max(0) as u16;
let text = |v: f64| cvt_double_to_string(v, prec);
let label = |idx: u16| {
snap.enums
.as_ref()
.map(|e| e.string_form.render(idx).as_str_lossy().into_owned())
.unwrap_or_default()
};
match &snap.value {
EpicsValue::Double(v) => Some(vec![text(*v)]),
EpicsValue::Float(v) => Some(vec![text(*v as f64)]),
EpicsValue::DoubleArray(v) => Some(v.iter().map(|e| text(*e)).collect()),
EpicsValue::FloatArray(v) => Some(v.iter().map(|e| text(*e as f64)).collect()),
EpicsValue::Enum(v) => Some(vec![label(*v)]),
EpicsValue::EnumArray(v) => Some(v.iter().map(|e| label(*e)).collect()),
_ => None,
}
}
fn dbtgf_string_elements(
snap: &crate::server::snapshot::Snapshot,
precision: i16,
) -> Option<Vec<String>> {
Some(
dbr_string_row(snap, precision)?
.into_iter()
.map(|t| format!("\"{}\"", escape_char_array_for_dbgf(t.as_bytes())))
.collect(),
)
}
fn dbtgf_time_text(ts: crate::types::WallTime) -> String {
let epics_epoch_unix = crate::runtime::general_time::EPICS_EPOCH_UNIX_SECS;
if ts.unix_secs() == epics_epoch_unix && ts.subsec_nanos() == 0 {
return "<undefined>".to_string();
}
let secs = ts.unix_secs() as i64;
let nanos = ts.subsec_nanos();
match chrono::DateTime::from_timestamp(secs, 0) {
Some(dt) => format!(
"{}.{nanos:09}",
dt.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M:%S")
),
None => "<undefined>".to_string(),
}
}
fn dbtgf_option_lines(
inst: &crate::server::record::RecordInstance,
field_type: DbfCode,
snap: Option<&crate::server::snapshot::Snapshot>,
) -> Vec<String> {
use crate::calc::engine::cvt::fmt_g;
let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
let g = move |v: f64| fmt_g(finite(v), 6, false, false);
let l = move |v: f64| -> i32 { finite(v) as i32 };
let mut out = Vec::new();
out.push(match snap {
Some(snap) => format!(
"status = {}, severity = {}",
snap.alarm.status, snap.alarm.severity
),
None => format!(
"status = {}, severity = {}",
inst.common.stat, inst.common.sevr as u16
),
});
let Some(snap) = snap else {
out.push("units = \"\"".to_string());
out.push("precision not returned".to_string());
out.push(format!(
"time = {}",
dbtgf_time_text(crate::types::WallTime::from(inst.common.time))
));
out.push("enum strings not returned".to_string());
for name in ["gr", "ctrl"] {
out.push(format!("{name}Long: 0 .. 0"));
out.push(format!("{name}Double: 0 .. 0"));
}
out.push("alLong: 0 < 0 .. 0 < 0".to_string());
out.push("alDouble: 0 < 0 .. 0 < 0".to_string());
return out;
};
match snap.units() {
Some(u) => out.push(format!("units = \"{}\"", u.as_str_lossy())),
None => out.push("units not returned".to_string()),
}
match snap.precision() {
Some(p) => out.push(format!("precision = {p}")),
None => out.push("precision not returned".to_string()),
}
out.push(format!("time = {}", dbtgf_time_text(snap.timestamp)));
let enums = matches!(field_type, DbfCode::Enum | DbfCode::Menu | DbfCode::Device)
.then_some(snap.enums.as_ref())
.flatten();
match enums {
Some(e) => {
out.push(format!("no_strs = {}:", e.strings.len()));
for s in &e.strings {
out.push(format!("\t\"{}\"", s.as_str_lossy()));
}
}
None => out.push("enum strings not returned".to_string()),
}
match snap.graphic_limits() {
Some((lo, hi)) => {
out.push(format!("grLong: {} .. {}", l(lo), l(hi)));
out.push(format!("grDouble: {} .. {}", g(lo), g(hi)));
}
None => {
out.push("DBRgrLong not returned".to_string());
out.push("DBRgrDouble not returned".to_string());
}
}
match snap.control_limits() {
Some((lo, hi)) => {
out.push(format!("ctrlLong: {} .. {}", l(lo), l(hi)));
out.push(format!("ctrlDouble: {} .. {}", g(lo), g(hi)));
}
None => {
out.push("DBRctrlLong not returned".to_string());
out.push("DBRctrlDouble not returned".to_string());
}
}
match snap.alarm_limits() {
Some((lolo, low, high, hihi)) => {
out.push(format!(
"alLong: {} < {} .. {} < {}",
l(lolo),
l(low),
l(high),
l(hihi)
));
out.push(format!(
"alDouble: {} < {} .. {} < {}",
g(lolo),
g(low),
g(high),
g(hihi)
));
}
None => {
out.push("DBRalLong not returned".to_string());
out.push("DBRalDouble not returned".to_string());
}
}
out
}
fn dbt_requires_ioc_init(ctx: &CommandContext, cmd: &str) -> bool {
if ctx.db().ioc_is_running() {
return true;
}
ctx.println(&format!("{cmd} only works after iocInit"));
false
}
fn cmd_dbtgf() -> CommandDef {
CommandDef::new(
"dbtgf",
vec![ArgDesc {
name: "record name",
arg_type: ArgType::Record,
}],
"dbtgf record name - Get field with all DBR_* request types",
|args: &[ArgValue], ctx: &CommandContext| {
let name = match args.first() {
Some(ArgValue::String(s)) if !s.is_empty() => s,
_ => {
ctx.println("Usage: dbtgf \"pv name\"");
return Ok(CommandOutcome::Failed);
}
};
let Some(addr) = name_to_addr(ctx, name) else {
return Ok(CommandOutcome::Failed);
};
if !dbt_requires_ioc_init(ctx, "dbtgf") {
return Ok(CommandOutcome::Failed);
}
let inst = addr.rec.read();
let snap = inst.snapshot_for_field(&addr.field);
let (class, native) = addr.types(snap.as_ref().map(|s| &s.value));
for line in dbtgf_option_lines(&inst, class, snap.as_ref()) {
ctx.println(&line);
}
let native_body = match dbr_request(native) {
Some(_) => Vec::new(),
None => vec![bad_dbr_type_text(native)],
};
for line in printbuffer_lines(native.name(), 0, Some(&native_body)) {
ctx.println(&line);
}
let Some(snap) = snap else {
for (dbr, _) in DBTGF_REQUEST_TYPES {
for line in printbuffer_lines(dbr, 1, None) {
ctx.println(&line);
}
}
return Ok(CommandOutcome::Continue);
};
let precision = snap.precision().unwrap_or(6);
for (dbr, target) in DBTGF_REQUEST_TYPES {
let float_string = (target == crate::types::DbFieldType::String)
.then(|| dbtgf_string_elements(&snap, precision))
.flatten();
let lines = match float_string {
Some(elements) => printbuffer_lines(dbr, elements.len(), Some(&elements)),
None => match snap.value.get_convert(target) {
Ok(v) => printbuffer_block(dbr, target.dbf_code(), &v),
Err(_) => printbuffer_lines(dbr, 1, None),
},
};
for line in lines {
ctx.println(&line);
}
}
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_dbtpf() -> CommandDef {
CommandDef::new(
"dbtpf",
vec![
ArgDesc {
name: "record name",
arg_type: ArgType::Record,
},
ArgDesc {
name: "value",
arg_type: ArgType::String,
},
],
"dbtpf record name, value - Put field with all DBR_* request types",
|args: &[ArgValue], ctx: &CommandContext| {
let (name, value_str) = match (&args[0], &args[1]) {
(ArgValue::String(n), ArgValue::String(v)) if !n.is_empty() => (n, v),
_ => {
ctx.println("Usage: dbtpf \"pv name\", \"value\"");
return Ok(CommandOutcome::Failed);
}
};
let (base, _) = parse_pv_name(name);
let Some(DbAddr { field, .. }) = name_to_addr(ctx, name) else {
return Ok(CommandOutcome::Failed);
};
if !dbt_requires_ioc_init(ctx, "dbtpf") {
return Ok(CommandOutcome::Failed);
}
for (dbr, target) in DBTGF_REQUEST_TYPES {
let parsed = match target {
crate::types::DbFieldType::String => {
Some(EpicsValue::String(value_str.as_str().into()))
}
crate::types::DbFieldType::Enum => {
match crate::types::c_parse::parse_base10_units_null(
crate::types::c_parse::NumericField::UShort,
value_str,
) {
Some(EpicsValue::UShort(v)) => Some(EpicsValue::Enum(v)),
_ => None,
}
}
t => crate::types::c_parse::NumericField::of(t).and_then(|nf| {
crate::types::c_parse::parse_base10_units_null(nf, value_str)
}),
};
let Some(value) = parsed else {
ctx.println(&format!("Cvt to DBR_{dbr} failed."));
continue;
};
let put: CaResult<()> = ctx.block_on(
ctx.db()
.put_record_field_from_ca_no_notify(base, &field, value),
);
if put.is_err() {
ctx.println(&format!("Put as DBR_{dbr:<6} Failed."));
continue;
}
let readback = ctx.db().get_record(base).and_then(|r| {
let inst = r.read();
let snap = inst.snapshot_for_field(&field)?;
let dbr = inst.field_desc(&field).map_or(DbfCode::String, |fd| {
field_addr_types(fd, Some(&snap.value)).1
});
Some((snap, dbr))
});
let lines = match &readback {
Some((snap, dbr)) => native_readback_lines(*dbr, &snap.value),
None => Vec::new(),
};
let mut lines = lines.into_iter();
let head = lines.next().unwrap_or_default();
ctx.println(&format!("Put as DBR_{dbr:<6} Ok, result as {head}"));
for line in lines {
ctx.println(&line);
}
}
Ok(CommandOutcome::Continue)
},
)
}
const CA_MAX_UNITS_SIZE: usize = 8;
const CA_MAX_ENUM_STRING_SIZE: usize = 26;
const GFT_MAX_ELEMS: u32 = 10;
fn ca_zero_fill(value: EpicsValue, no_elements: usize) -> EpicsValue {
use EpicsValue as V;
fn pad<T: Default>(mut v: Vec<T>, n: usize) -> Vec<T> {
v.resize_with(n, T::default);
v
}
if no_elements <= 1 || value.count() as usize >= no_elements {
return value;
}
let n = no_elements;
match value {
V::ShortArray(v) => V::ShortArray(pad(v, n)),
V::FloatArray(v) => V::FloatArray(pad(v, n)),
V::EnumArray(v) => V::EnumArray(pad(v, n)),
V::DoubleArray(v) => V::DoubleArray(pad(v, n)),
V::LongArray(v) => V::LongArray(pad(v, n)),
V::CharArray(v) => V::CharArray(pad(v, n)),
V::StringArray(v) => V::StringArray(pad(v, n)),
V::Short(v) => V::ShortArray(pad(vec![v], n)),
V::Float(v) => V::FloatArray(pad(vec![v], n)),
V::Enum(v) => V::EnumArray(pad(vec![v], n)),
V::Double(v) => V::DoubleArray(pad(vec![v], n)),
V::Long(v) => V::LongArray(pad(vec![v], n)),
V::Char(v) => V::CharArray(pad(vec![v], n)),
V::String(v) => V::StringArray(pad(vec![v], n)),
other => other,
}
}
fn ca_dump_elements(count: usize, wrap: usize, mut render: impl FnMut(usize) -> String) -> String {
let mut out = String::new();
for i in 0..count {
if count != 1 && i % wrap == 0 {
out.push('\n');
}
out.push_str(&render(i));
out.push(' ');
}
out
}
fn ca_dump_ints(value: &EpicsValue) -> Vec<i64> {
match value {
EpicsValue::Short(v) => vec![*v as i64],
EpicsValue::ShortArray(v) => v.iter().map(|e| *e as i64).collect(),
EpicsValue::Enum(v) => vec![*v as i64],
EpicsValue::EnumArray(v) => v.iter().map(|e| *e as i64).collect(),
EpicsValue::Char(v) => vec![*v as i64],
EpicsValue::CharArray(v) => v.iter().map(|e| *e as i64).collect(),
EpicsValue::Long(v) => vec![*v as i64],
EpicsValue::LongArray(v) => v.iter().map(|e| *e as i64).collect(),
_ => Vec::new(),
}
}
fn ca_dump_reals(value: &EpicsValue) -> Vec<f64> {
match value {
EpicsValue::Float(v) => vec![*v as f64],
EpicsValue::FloatArray(v) => v.iter().map(|e| *e as f64).collect(),
EpicsValue::Double(v) => vec![*v],
EpicsValue::DoubleArray(v) => v.to_vec(),
_ => Vec::new(),
}
}
fn ca_dump_texts(value: &EpicsValue) -> Vec<String> {
match value {
EpicsValue::String(v) => vec![v.as_str_lossy().into_owned()],
EpicsValue::StringArray(v) => v.iter().map(|e| e.as_str_lossy().into_owned()).collect(),
_ => Vec::new(),
}
}
fn ca_dump_f(x: f64, width: usize, decimals: usize) -> String {
let narrowed = crate::types::c_cast::f64_to_f32(x) as f64;
let text = crate::calc::engine::cvt::fmt_f(narrowed, decimals);
format!("{text:>width$}")
}
fn ca_dump_int_limit(limit: i32, family: u16) -> String {
use crate::types::{DBR_CHAR, DBR_SHORT};
let narrowed = match family {
DBR_SHORT => limit as i16 as i64,
DBR_CHAR => limit as u8 as i64,
_ => limit as i64,
};
format!("{narrowed:8}")
}
fn ca_dump_int_limits(limits: &[i32], family: u16) -> String {
limits
.iter()
.map(|l| ca_dump_int_limit(*l, family))
.collect::<Vec<_>>()
.join(" ")
}
fn ca_dump_real_limits(limits: &[f64]) -> String {
limits
.iter()
.map(|l| ca_dump_f(*l, 8, 3))
.collect::<Vec<_>>()
.join(" ")
}
fn ca_dump_time_stamp(ts: crate::types::WallTime) -> String {
let epics_epoch_unix = crate::runtime::general_time::EPICS_EPOCH_UNIX_SECS;
let nsec = ts.subsec_nanos() as u64;
if ts.unix_secs() == epics_epoch_unix && nsec == 0 {
return "<undefined>".to_string();
}
let micros = (nsec + 500).min(999_999_999) / 1_000;
match chrono::DateTime::from_timestamp(ts.unix_secs() as i64, 0) {
Some(dt) => format!(
"{}.{micros:06}",
dt.with_timezone(&chrono::Local).format("%Y/%m/%d %H:%M:%S")
),
None => "<undefined>".to_string(),
}
}
fn ca_dump_dbr(dbr: u16, snap: &crate::server::snapshot::Snapshot) -> String {
use crate::types::{
DBR_CHAR, DBR_CLASS_NAME, DBR_CTRL_CHAR, DBR_CTRL_DOUBLE, DBR_CTRL_ENUM, DBR_CTRL_FLOAT,
DBR_CTRL_LONG, DBR_CTRL_SHORT, DBR_CTRL_STRING, DBR_DOUBLE, DBR_ENUM, DBR_FLOAT,
DBR_GR_CHAR, DBR_GR_DOUBLE, DBR_GR_ENUM, DBR_GR_FLOAT, DBR_GR_LONG, DBR_GR_SHORT,
DBR_GR_STRING, DBR_LONG, DBR_SHORT, DBR_STRING, DBR_STS_CHAR, DBR_STS_DOUBLE, DBR_STS_ENUM,
DBR_STS_FLOAT, DBR_STS_LONG, DBR_STS_SHORT, DBR_STS_STRING, DBR_STSACK_STRING,
DBR_TIME_CHAR, DBR_TIME_DOUBLE, DBR_TIME_ENUM, DBR_TIME_FLOAT, DBR_TIME_LONG,
DBR_TIME_SHORT, DBR_TIME_STRING, dbr_type_to_text,
};
let value = &snap.value;
let sts = format!("{:2} {:2}", snap.alarm.status, snap.alarm.severity);
let units: String = snap
.units()
.map(|u| u.as_str_lossy().into_owned())
.unwrap_or_default()
.chars()
.take(CA_MAX_UNITS_SIZE - 1)
.collect();
let precision = snap.precision().unwrap_or(0);
let limits = crate::types::codec::get_limits(snap, 8);
let int_limits = crate::types::codec::limits_as_integers(limits);
let (lo_ctrl, hi_ctrl) = (limits[7], limits[6]);
let ints = ca_dump_ints(value);
let reals = ca_dump_reals(value);
let count = value.count() as usize;
let head = if count == 1 { "\tValue: " } else { "" };
let n_ints = ints.len();
let n_reals = reals.len();
let body = match dbr {
DBR_STRING => {
let texts = ca_dump_texts(value);
let stop = texts
.iter()
.position(|s| s.is_empty())
.unwrap_or(texts.len());
ca_dump_elements(stop, 5, |i| texts[i].clone())
}
DBR_SHORT | DBR_ENUM | DBR_CHAR | DBR_LONG => {
ca_dump_elements(n_ints, 10, |i| ints[i].to_string())
}
DBR_FLOAT | DBR_DOUBLE => ca_dump_elements(n_reals, 10, |i| ca_dump_f(reals[i], 6, 4)),
DBR_STS_STRING | DBR_GR_STRING | DBR_CTRL_STRING => format!(
"{sts}\tValue: {}",
ca_dump_texts(value).first().cloned().unwrap_or_default()
),
DBR_STS_ENUM | DBR_STS_CHAR | DBR_STS_LONG => format!(
"{sts}{head}{}",
ca_dump_elements(n_ints, 10, |i| ints[i].to_string())
),
DBR_STS_SHORT => format!(
"{sts}{head}{}",
ca_dump_elements(n_ints, 10, |i| (ints[i] as i32 as u32).to_string())
),
DBR_STS_FLOAT | DBR_STS_DOUBLE => format!(
"{sts}{head}{}",
ca_dump_elements(n_reals, 10, |i| ca_dump_f(reals[i], 6, 4))
),
DBR_TIME_STRING => format!(
"{sts}\tTimeStamp: {}\tValue: {}",
ca_dump_time_stamp(snap.timestamp),
ca_dump_texts(value).first().cloned().unwrap_or_default()
),
DBR_TIME_ENUM | DBR_TIME_SHORT | DBR_TIME_CHAR | DBR_TIME_LONG => format!(
"{sts}\tTimeStamp: {}{head}{}",
ca_dump_time_stamp(snap.timestamp),
ca_dump_elements(n_ints, 10, |i| ints[i].to_string())
),
DBR_TIME_FLOAT | DBR_TIME_DOUBLE => format!(
"{sts}\tTimeStamp: {}{head}{}",
ca_dump_time_stamp(snap.timestamp),
ca_dump_elements(n_reals, 10, |i| ca_dump_f(reals[i], 6, 4))
),
DBR_GR_ENUM | DBR_CTRL_ENUM => {
let mut out = format!("{sts}\tValue: {}", ints.first().copied().unwrap_or(0));
let strings = snap
.enums
.as_ref()
.map(|e| e.strings.as_slice())
.unwrap_or_default();
if !strings.is_empty() {
out.push_str(&format!("\n\t{:3}", strings.len()));
for s in strings {
out.push_str(&format!(
"\n\t{}",
s.as_str_lossy()
.chars()
.take(CA_MAX_ENUM_STRING_SIZE)
.collect::<String>()
));
}
}
out
}
DBR_GR_SHORT | DBR_GR_CHAR | DBR_GR_LONG => format!(
"{sts} {units}\n\t{}{head}{}",
ca_dump_int_limits(&int_limits[..6], dbr - 21),
ca_dump_elements(n_ints, 10, |i| ints[i].to_string())
),
DBR_GR_FLOAT | DBR_GR_DOUBLE => format!(
"{sts} {units} {precision:3}\n\t{}{head}{}",
ca_dump_real_limits(&limits[..6]),
ca_dump_elements(n_reals, 10, |i| ca_dump_f(reals[i], 6, 4))
),
DBR_CTRL_SHORT | DBR_CTRL_CHAR | DBR_CTRL_LONG => {
let family = dbr - 28;
let width = if dbr == DBR_CTRL_CHAR { 4 } else { 0 };
format!(
"{sts} {units}\n\t{} {} {}{head}{}",
ca_dump_int_limits(&int_limits[..6], family),
ca_dump_int_limit(int_limits[6], family),
ca_dump_int_limit(int_limits[7], family),
ca_dump_elements(n_ints, 10, |i| format!("{:width$}", ints[i]))
)
}
DBR_CTRL_FLOAT | DBR_CTRL_DOUBLE => {
let decimals = if dbr == DBR_CTRL_DOUBLE { 6 } else { 4 };
format!(
"{sts} {units} {precision:3}\n\t{} {} {}{head}{}",
ca_dump_real_limits(&limits[..6]),
ca_dump_f(hi_ctrl, 8, 3),
ca_dump_f(lo_ctrl, 8, 3),
ca_dump_elements(n_reals, 10, |i| ca_dump_f(reals[i], 6, decimals))
)
}
DBR_STSACK_STRING => format!(
"{sts} {:2} {:2} {}",
snap.alarm.ackt.unwrap_or(0),
snap.alarm.acks.unwrap_or(0),
ca_dump_texts(value).first().cloned().unwrap_or_default()
),
DBR_CLASS_NAME => ca_dump_texts(value).first().cloned().unwrap_or_default(),
_ => "unsupported by ca_dbrDump()".to_string(),
};
format!("{}\t{body}", dbr_type_to_text(dbr))
}
fn ca_request_family(dbr: u16) -> Option<crate::types::DbFieldType> {
use crate::types::DbFieldType as T;
use crate::types::{DBR_PUT_ACKS, DBR_PUT_ACKT, DBR_STSACK_STRING, LAST_BUFFER_TYPE};
if dbr == DBR_PUT_ACKT || dbr == DBR_PUT_ACKS || dbr > LAST_BUFFER_TYPE {
return None;
}
if dbr == DBR_STSACK_STRING {
return Some(T::String);
}
Some(match dbr % 7 {
0 => T::String,
1 => T::Short,
2 => T::Float,
3 => T::Enum,
4 => T::Char,
5 => T::Long,
_ => T::Double,
})
}
struct CaChannel {
record_name: String,
record_type: String,
field: String,
export_type: u16,
elements: u32,
snap: crate::server::snapshot::Snapshot,
address: usize,
field_size: Option<u16>,
string_precision: i16,
}
impl CaChannel {
fn create(ctx: &CommandContext, pname: &str) -> Option<Self> {
let (base, field) = parse_pv_name(pname);
let field = if field.is_empty() {
"VAL".to_string()
} else {
field.to_ascii_uppercase()
};
let rec = ctx.db().get_record(base)?;
let inst = rec.read();
let fd = inst.field_desc(&field)?;
let mut snap = inst.snapshot_for_field(&field)?;
snap.alarm.ackt = Some(u16::from(inst.common.ackt));
snap.alarm.acks = Some(inst.common.acks as u16);
Some(Self {
record_name: inst.name.clone(),
record_type: inst.record.record_type().to_string(),
field: field.clone(),
export_type: snap.value.db_field_type().ca_wire_type(),
elements: inst
.record
.field_native_count(&field)
.or_else(|| inst.record.get_field(&field).map(|v| v.count()))
.unwrap_or(1),
field_size: c_field_size(snap.value.db_field_type().dbf_code(), fd.size),
string_precision: snap.precision().unwrap_or(6),
address: Arc::as_ptr(&rec) as *const () as usize,
snap,
})
}
fn header(&self) -> Vec<String> {
vec![
format!(" Record Name: {}", self.record_name),
format!("Record Address: 0x{:#x}", self.address),
format!(" Export Type: {}", self.export_type),
" Field Address: (none)".to_string(),
match self.field_size {
Some(n) => format!(" Field Size: {n}"),
None => " Field Size: (none)".to_string(),
},
format!(" No Elements: {}", self.elements),
]
}
fn get(&self, dbr: u16, no_elements: usize) -> Option<crate::server::snapshot::Snapshot> {
use crate::types::{DBR_CLASS_NAME, DbFieldType};
let mut reply = self.snap.clone();
if dbr == DBR_CLASS_NAME {
reply.value = EpicsValue::String(self.record_type.as_str().into());
return Some(reply);
}
let family = ca_request_family(dbr)?;
let precision_row = (family == DbFieldType::String)
.then(|| dbr_string_row(&self.snap, self.string_precision))
.flatten();
let converted = match precision_row {
Some(texts) => {
EpicsValue::StringArray(texts.iter().map(|t| t.as_str().into()).collect())
}
None => self.snap.value.get_convert(family).ok()?,
};
reply.value = ca_zero_fill(converted, no_elements);
Some(reply)
}
fn pv_name(&self) -> String {
format!("{}.{}", self.record_name, self.field)
}
fn put(&self, ctx: &CommandContext, value: EpicsValue) -> bool {
ctx.block_on(async { ctx.db().put_pv(&self.pv_name(), value).await })
.is_ok()
}
fn refresh(&mut self, ctx: &CommandContext) {
if let Some(fresh) = Self::create(ctx, &self.pv_name()) {
self.snap = fresh.snap;
self.string_precision = fresh.string_precision;
}
}
}
fn cmd_gft() -> CommandDef {
CommandDef::new(
"gft",
vec![ArgDesc {
name: "record name",
arg_type: ArgType::Record,
}],
"gft record name - Get Field Test",
|args: &[ArgValue], ctx: &CommandContext| {
use crate::types::{
DBR_CTRL_STRING, DBR_GR_STRING, DBR_STRING, DBR_STS_STRING, DBR_TIME_STRING,
LAST_BUFFER_TYPE, dbr_type_to_text,
};
let name = match args.first() {
Some(ArgValue::String(s)) if !s.is_empty() => s,
_ => {
ctx.println("Usage: gft \"pv_name\"");
return Ok(CommandOutcome::Failed);
}
};
let Some(chan) = CaChannel::create(ctx, name) else {
ctx.println("Channel couldn't be created");
return Ok(CommandOutcome::Failed);
};
for line in chan.header() {
ctx.println(&line);
}
let count = chan.elements.min(GFT_MAX_ELEMS) as usize;
for dbr in 0..=LAST_BUFFER_TYPE {
if chan.export_type == DBR_STRING
&& !matches!(
dbr,
DBR_STRING
| DBR_STS_STRING
| DBR_TIME_STRING
| DBR_GR_STRING
| DBR_CTRL_STRING
)
{
continue;
}
match chan.get(dbr, count) {
None => ctx.println(&format!("\t{} Failed", dbr_type_to_text(dbr))),
Some(reply) => ctx.println(&ca_dump_dbr(dbr, &reply)),
}
}
Ok(CommandOutcome::Continue)
},
)
}
fn c_scan_int(s: &str, fmt: &[u8]) -> Option<i64> {
match crate::calc::engine::scanf::sscanf(s.as_bytes(), fmt) {
Ok(crate::calc::engine::value::StackValue::Double(v)) => Some(v as i64),
_ => None,
}
}
fn print_c_stream(ctx: &CommandContext, stream: &str) {
if stream.is_empty() {
return;
}
for line in stream.strip_suffix('\n').unwrap_or(stream).split('\n') {
ctx.println(line);
}
}
fn cmd_pft() -> CommandDef {
CommandDef::new(
"pft",
vec![
ArgDesc {
name: "record name",
arg_type: ArgType::Record,
},
ArgDesc {
name: "value",
arg_type: ArgType::String,
},
],
"pft record name value - Put Field Test",
|args: &[ArgValue], ctx: &CommandContext| {
use crate::types::c_parse::{NumericField, parse_base10_units_null};
use crate::types::{
DBR_CHAR, DBR_DOUBLE, DBR_ENUM, DBR_FLOAT, DBR_LONG, DBR_SHORT, DBR_STRING,
};
let (Some(ArgValue::String(name)), Some(ArgValue::String(value))) =
(args.first(), args.get(1))
else {
ctx.println("Usage: pft \"pv_name\", \"value\"");
return Ok(CommandOutcome::Failed);
};
if name.is_empty() {
ctx.println("Usage: pft \"pv_name\", \"value\"");
return Ok(CommandOutcome::Failed);
}
let Some(mut chan) = CaChannel::create(ctx, name) else {
ctx.println("Channel couldn't be created");
return Ok(CommandOutcome::Failed);
};
for line in chan.header() {
ctx.println(&line);
}
let mut out = String::new();
let rung = |out: &mut String,
chan: &mut CaChannel,
dbr: u16,
put: EpicsValue,
put_failed: &str,
get_failed: &str| {
if !chan.put(ctx, put) {
out.push_str(put_failed);
}
chan.refresh(ctx);
match chan.get(dbr, 1) {
None => out.push_str(get_failed),
Some(reply) => {
out.push_str(&ca_dump_dbr(dbr, &reply));
out.push('\n');
}
}
};
rung(
&mut out,
&mut chan,
DBR_STRING,
EpicsValue::String(value.as_str().into()),
"\n\t failed ",
"\n\tfailed",
);
if chan.export_type == DBR_STRING || chan.export_type == DBR_ENUM {
print_c_stream(ctx, &out);
return Ok(CommandOutcome::Continue);
}
if let Some(v) = c_scan_int(value, b"%hd") {
rung(
&mut out,
&mut chan,
DBR_SHORT,
EpicsValue::Short(v as i16),
"\n\t SHORT failed ",
"\n\t SHORT GET failed",
);
}
if let Some(v) = c_scan_int(value, b"%ld") {
rung(
&mut out,
&mut chan,
DBR_LONG,
EpicsValue::Long(v as i32),
"\n\t LONG failed ",
"\n\t LONG GET failed",
);
}
let scanned_float = match parse_base10_units_null(NumericField::Float, value) {
Some(EpicsValue::Float(f)) => Some(f),
_ => None,
};
if let Some(f) = scanned_float {
rung(
&mut out,
&mut chan,
DBR_FLOAT,
EpicsValue::Float(f),
"\n\t FLOAT failed ",
"\n\t FLOAT GET failed",
);
rung(
&mut out,
&mut chan,
DBR_DOUBLE,
EpicsValue::Double(f as f64),
"\n\t DOUBLE failed ",
"\n\t DOUBLE GET failed",
);
}
if let Some(v) = c_scan_int(value, b"%hd") {
rung(
&mut out,
&mut chan,
DBR_CHAR,
EpicsValue::UChar(v as i16 as u8),
"\n\t CHAR failed ",
"\n\t CHAR GET failed",
);
rung(
&mut out,
&mut chan,
DBR_ENUM,
EpicsValue::Enum(v as i16 as u16),
"\n\t ENUM failed ",
"\n\t ENUM GET failed",
);
}
out.push('\n');
print_c_stream(ctx, &out);
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_dbtr() -> CommandDef {
CommandDef::new(
"dbtr",
vec![ArgDesc {
name: "record name",
arg_type: ArgType::Record,
}],
"dbtr record name - Process a record then print its fields",
|args: &[ArgValue], ctx: &CommandContext| {
let name = match args.first() {
Some(ArgValue::String(s)) if !s.is_empty() => s,
_ => {
ctx.println("Usage: dbtr \"pv name\"");
return Ok(CommandOutcome::Failed);
}
};
let (base, _) = parse_pv_name(name);
let Some(DbAddr { rec, .. }) = name_to_addr(ctx, name) else {
return Ok(CommandOutcome::Failed);
};
if !dbt_requires_ioc_init(ctx, "dbtr") {
return Ok(CommandOutcome::Failed);
}
if rec.read().is_processing() {
ctx.println("record active");
return Ok(CommandOutcome::Failed);
}
let processed = ctx.block_on(ctx.db().process_record(base));
if let Err(e) = processed {
crate::server::recgbl::rec_gbl_record_error(
&e.to_string(),
base,
"dbtr(dbProcess)",
);
}
dbpr_report(ctx, name, 3);
Ok(CommandOutcome::Continue)
},
)
}
fn const_value_dtype(token: &str) -> &'static str {
let t = token.trim();
if t.starts_with('"') || t.starts_with('\'') {
"string"
} else if t.starts_with('-')
|| t.starts_with('+')
|| t.starts_with(|c: char| c.is_ascii_digit())
{
if t.contains(['.', 'e', 'E']) {
"double"
} else {
"integer"
}
} else {
"bug"
}
}
fn const_link_report_lines(value: &str, level: i32, indent: usize) -> Vec<String> {
use crate::calc::engine::cvt::fmt_g;
let pad = " ".repeat(indent);
let render = |token: &str| -> String {
let t = token.trim();
match const_value_dtype(t) {
"double" => t
.parse::<f64>()
.map(|v| fmt_g(v, 6, false, false))
.unwrap_or_else(|_| t.to_string()),
"string" => format!("\"{}\"", t.trim_matches(['"', '\''])),
_ => t.to_string(),
}
};
let v = value.trim();
let Some(inner) = v.strip_prefix('[').and_then(|r| r.strip_suffix(']')) else {
let dtype = const_value_dtype(v);
if dtype == "bug" {
return vec![format!("{pad}'const': bug -- {v}")];
}
return vec![format!("{pad}'const': {dtype} {}", render(v))];
};
let elements: Vec<&str> = inner
.split(',')
.map(str::trim)
.filter(|e| !e.is_empty())
.collect();
let dtype = elements.first().map_or("bug", |e| const_value_dtype(e));
let plural = if elements.len() > 1 { "s" } else { "" };
let head = format!("{pad}'const': array of {} {dtype}{plural}", elements.len());
if level < 2 {
return vec![head];
}
let body: Vec<String> = elements.iter().map(|e| render(e)).collect();
vec![
head,
format!("{}[{}]", " ".repeat(indent + 2), body.join(", ")),
]
}
fn jlink_report_lines(raw: &str, level: i32, indent: usize) -> Vec<String> {
let Ok(normalized) = crate::json5::relaxed_to_strict(raw.trim()) else {
return Vec::new();
};
match crate::server::record::json_const_value(&normalized) {
Some(value) => const_link_report_lines(value, level, indent),
None => Vec::new(),
}
}
fn cmd_dbior() -> CommandDef {
CommandDef::new(
"dbior",
vec![
ArgDesc {
name: "driver name",
arg_type: ArgType::String,
},
ArgDesc {
name: "interest level",
arg_type: ArgType::Int,
},
],
"dbior driver name, interest level - Driver Report.",
|args: &[ArgValue], ctx: &CommandContext| {
let wanted = match &args[0] {
ArgValue::String(s) if !s.is_empty() && s != "*" => Some(s.clone()),
_ => None,
};
let level = match &args[1] {
ArgValue::Int(n) => *n as i32,
_ => 0,
};
for (name, drvet) in crate::server::driver_support::driver_supports() {
if wanted.as_ref().is_some_and(|want| *want != name) {
continue;
}
match drvet.report(level) {
None => ctx.println(&format!("Driver: {name} No report available")),
Some(text) => {
ctx.println(&format!("Driver: {name}"));
if !text.is_empty() {
for line in text.trim_end_matches('\n').split('\n') {
ctx.println(line);
}
}
}
}
}
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_dbjlr() -> CommandDef {
CommandDef::new(
"dbjlr",
vec![
ArgDesc {
name: "record name",
arg_type: ArgType::Record,
},
ArgDesc {
name: "level",
arg_type: ArgType::Int,
},
],
"dbjlr record name, level - Report JSON links",
|args: &[ArgValue], ctx: &CommandContext| {
let target = match &args[0] {
ArgValue::String(s) if !s.is_empty() && s != "*" => Some(s.clone()),
_ => None,
};
let level = match &args[1] {
ArgValue::Int(n) => *n as i32,
_ => 0,
};
match &target {
Some(name) => ctx.println(&format!("JSON links in record '{name}'")),
None => ctx.println("JSON links in all records"),
}
ctx.println("");
for name in record_names_type_major(ctx) {
if target.as_ref().is_some_and(|want| *want != name) {
continue;
}
let Some(rec) = ctx.db().get_record(&name) else {
continue;
};
let record_type = rec.read().record.record_type();
ctx.println(&format!(" {record_type} record '{name}':"));
for (field, raw, _) in ctx.db().record_link_fields(&name) {
let text = raw.trim();
if !(text.starts_with('{') && text.ends_with('}')) {
continue;
}
ctx.println(&format!(" Link field '{field}':"));
for line in jlink_report_lines(text, level, 6) {
ctx.println(&line);
}
}
if target.is_some() {
break;
}
}
Ok(CommandOutcome::Continue)
},
)
}
fn dbel_subscription_lines(
field: &str,
mask: u16,
level: i32,
report: &crate::server::event_queue::QueReport,
) -> Vec<String> {
use crate::server::recgbl::EventMask;
let mut line = format!("{field:>4.4}");
let select = EventMask::from_bits(mask);
line.push_str(" { ");
for (bit, name) in [
(EventMask::VALUE, "VALUE "),
(EventMask::LOG, "LOG "),
(EventMask::ALARM, "ALARM "),
(EventMask::PROPERTY, "PROPERTY "),
] {
if select.contains(bit) {
line.push_str(name);
}
}
line.push('}');
if report.npend != 0 {
line.push_str(&format!(" undelivered={}", report.npend));
}
if level > 1 {
if report.ring_space == 0 {
line.push_str(", queue full");
} else if report.ring_space == report.ring_size {
line.push_str(", queue empty");
} else {
line.push_str(&format!(", unused entries={}", report.ring_space));
}
}
let mut lines = vec![];
if level > 2 {
if report.nreplace != 0 {
line.push_str(&format!(", discarded by replacement={}", report.nreplace));
}
if report.latest_only {
line.push_str(", queueing disabled");
}
if report.n_duplicates != 0 {
line.push_str(&format!(", duplicate count ={}", report.n_duplicates));
lines.push(std::mem::take(&mut line));
}
}
lines.push(line);
lines
}
fn cmd_dbel() -> CommandDef {
CommandDef::new(
"dbel",
vec![
ArgDesc {
name: "record name",
arg_type: ArgType::Record,
},
ArgDesc {
name: "level",
arg_type: ArgType::Int,
},
],
"dbel record name, level - Database event list.\n\
Show information on dbEvent subscriptions.\n\
Higher level shows more information (0 - 4)\n\
Example: dbel aitest 2",
|args: &[ArgValue], ctx: &CommandContext| {
let ArgValue::String(name) = &args[0] else {
return Ok(CommandOutcome::Continue);
};
let level = match &args[1] {
ArgValue::Int(n) => *n as i32,
_ => 0,
};
let record = name.split('.').next().unwrap_or(name);
let Some(rec) = ctx.db().get_record(record) else {
crate::runtime::log::errlog_printf(&format!(
"Record Not Found filename=\"{}\" line number={} dbNameToAddr failed\n",
file!(),
line!()
));
return Ok(CommandOutcome::Failed);
};
let inst = rec.read();
let mut subs: Vec<(&String, &crate::server::pv::Subscriber)> = inst
.subscribers
.iter()
.flat_map(|(field, bucket)| bucket.iter().map(move |sub| (field, sub)))
.collect();
subs.sort_by_key(|(_, sub)| sub.sid);
if subs.is_empty() {
ctx.println(&format!(
"\"{name}\": No PV event subscriptions ( monitors )."
));
return Ok(CommandOutcome::Continue);
}
ctx.println(&format!(
"{} PV Event Subscriptions ( monitors ).",
subs.len()
));
if level > 0 {
for (field, sub) in subs {
let report = sub.sink.report();
for line in dbel_subscription_lines(field, sub.mask, level, &report) {
ctx.println(&line);
}
}
}
Ok(CommandOutcome::Continue)
},
)
}
async fn dbtpn_lines(
db: &std::sync::Arc<crate::server::database::PvDatabase>,
name: &str,
value: Option<String>,
) -> Vec<String> {
let (base, field) = parse_pv_name(name);
let field = field.to_ascii_uppercase();
let mut lines = Vec::new();
let Some(text) = value else {
let processed = db.process_record_with_notify(base).await;
let ok = match processed {
Ok(completion) => match completion.into_handle() {
Some(rx) => rx.await.is_ok(),
None => true,
},
Err(_) => false,
};
if !ok {
lines.push(format!("{base} dbtpnCallback processNotify.status 2"));
return lines;
}
let rendered = db.get_record(base).and_then(|rec| {
let inst = rec.read();
let snap = inst.snapshot_for_field(&field)?;
let precision = snap.precision().unwrap_or(6);
Some(dbr_string_text(&snap.value, precision))
});
match rendered {
Some(text) => lines.push(format!("dbtpn:getCallback value {text}")),
None => {
lines.push("dbtpn:getCallback error".to_string());
lines.push(format!("{base} dbtpnCallback processNotify.status 2"));
return lines;
}
}
lines.push(format!("dbtpnCallback: success record={base}"));
return lines;
};
let put = db
.put_record_field_from_ca(base, &field, EpicsValue::String(text.as_str().into()))
.await;
match put {
Ok(completion) => {
let settled = match completion.into_handle() {
Some(rx) => rx.await.is_ok(),
None => true,
};
if settled {
lines.push(format!("dbtpnCallback: success record={base}"));
} else {
lines.push(format!("{base} dbtpnCallback processNotify.status 2"));
}
}
Err(_) => lines.push(format!("{base} dbtpnCallback processNotify.status 2")),
}
lines
}
fn dbr_string_text(value: &EpicsValue, precision: i16) -> String {
use crate::calc::engine::cvt::cvt_double_to_string;
match value {
EpicsValue::Double(v) => cvt_double_to_string(*v, precision.max(0) as u16),
EpicsValue::Float(v) => cvt_double_to_string(*v as f64, precision.max(0) as u16),
EpicsValue::String(s) => s.to_string(),
other => match other.get_convert(crate::types::DbFieldType::String) {
Ok(EpicsValue::String(s)) => s.to_string(),
_ => String::new(),
},
}
}
fn cmd_dbtpn() -> CommandDef {
CommandDef::new(
"dbtpn",
vec![
ArgDesc {
name: "record name",
arg_type: ArgType::Record,
},
ArgDesc {
name: "value",
arg_type: ArgType::String,
},
],
"dbtpn record name, value - Database Test Process Notify\n\
Without value, begin async. processing and get\n\
With value, begin put, process, and get\n\
Example: dbtpn aitest\n\
Example: dbtpn aitest 5.0",
|args: &[ArgValue], ctx: &CommandContext| {
let ArgValue::String(name) = &args[0] else {
ctx.println("Usage: dbtpn \"name\", \"value\"");
return Ok(CommandOutcome::Failed);
};
let (base, field) = parse_pv_name(name);
let known = ctx.db().get_record(base).is_some_and(|rec| {
let inst = rec.read();
inst.snapshot_for_field(&field.to_ascii_uppercase())
.is_some()
});
if !known {
ctx.println("dbtpn: No such channel");
return Ok(CommandOutcome::Failed);
}
let db = ctx.db().clone();
let name = name.clone();
let value = match &args[1] {
ArgValue::String(v) => Some(v.clone()),
_ => None,
};
ctx.bridge().spawn(async move {
for line in dbtpn_lines(&db, &name, value).await {
println!("{line}");
}
});
Ok(CommandOutcome::Continue)
},
)
}
async fn tpn_lines(
db: &std::sync::Arc<crate::server::database::PvDatabase>,
name: &str,
value: String,
) -> Vec<String> {
let (base, field) = parse_pv_name(name);
let field = field.to_ascii_uppercase();
let put = db
.put_record_field_from_ca(base, &field, EpicsValue::String(value.as_str().into()))
.await;
let ok = match put {
Ok(completion) => match completion.into_handle() {
Some(rx) => rx.await.is_ok(),
None => true,
},
Err(_) => false,
};
if ok {
vec![format!("tpnCallback '{base}': Success")]
} else {
vec![format!("tpnCallback '{base}': Notify status 2")]
}
}
fn cmd_tpn() -> CommandDef {
CommandDef::new(
"tpn",
vec![
ArgDesc {
name: "record name",
arg_type: ArgType::Record,
},
ArgDesc {
name: "value",
arg_type: ArgType::String,
},
],
"tpn record name, value - Test Process Notify.\n\
Example: tpn aitest 5.0",
|args: &[ArgValue], ctx: &CommandContext| {
let (Some(ArgValue::String(name)), Some(ArgValue::String(value))) =
(args.first(), args.get(1))
else {
ctx.println("Usage: tpn \"pv_name\", \"value\"");
return Ok(CommandOutcome::Failed);
};
let (base, field) = parse_pv_name(name);
let known = ctx.db().get_record(base).is_some_and(|rec| {
let inst = rec.read();
inst.snapshot_for_field(&field.to_ascii_uppercase())
.is_some()
});
if !known {
ctx.println("Channel couldn't be created");
return Ok(CommandOutcome::Failed);
}
let db = ctx.db().clone();
let name = name.clone();
let value = value.clone();
ctx.bridge().spawn(async move {
for line in tpn_lines(&db, &name, value).await {
println!("{line}");
}
});
Ok(CommandOutcome::Continue)
},
)
}
fn dbglob_handler(args: &[ArgValue], ctx: &CommandContext) -> CommandResult {
let pattern = match args.first() {
Some(ArgValue::String(s)) if !s.is_empty() => s.as_str(),
_ => {
ctx.println("Usage: dbglob \"pattern\" \"fields\"");
return Ok(CommandOutcome::Failed);
}
};
let fields: Vec<String> = match args.get(1) {
Some(ArgValue::String(s)) => split_fields_list(s),
_ => Vec::new(),
};
let mut names: Vec<String> = db_nodes_type_major(ctx)
.into_iter()
.map(|node| node.name)
.collect();
let mut extra: Vec<String> = ctx.block_on(ctx.db().all_simple_pv_names());
extra.sort();
extra.dedup();
extra.retain(|n| !names.contains(n));
names.extend(extra);
for name in &names {
if !glob_match(pattern, name) {
continue;
}
print_fields_list(ctx, name, &fields);
}
Ok(CommandOutcome::Continue)
}
fn cmd_dbsr() -> CommandDef {
CommandDef::new(
"dbsr",
vec![ArgDesc {
name: "interest level",
arg_type: ArgType::Int,
}],
"dbsr [interest level] — Database Server Report",
|args: &[ArgValue], ctx: &CommandContext| {
let level = match args.first() {
Some(ArgValue::Int(n)) => *n as u32,
_ => 0,
};
crate::server::db_server::dbsr(level, &|line: &str| ctx.println(line));
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_dbglob() -> CommandDef {
CommandDef::new(
"dbglob",
vec![
ArgDesc {
name: "pattern",
arg_type: ArgType::Record,
},
ArgDesc {
name: "fields",
arg_type: ArgType::String,
},
],
"dbglob [pattern] [fields] — Search records by name pattern \
(epics-base PR #626; `?` matches 0-or-1 chars)",
dbglob_handler,
)
}
fn cmd_dbgrep() -> CommandDef {
CommandDef::new(
"dbgrep",
vec![
ArgDesc {
name: "pattern",
arg_type: ArgType::Record,
},
ArgDesc {
name: "fields",
arg_type: ArgType::String,
},
],
"dbgrep [pattern] [fields] — Search records by name pattern \
(legacy spelling of dbglob, epics-base PR #626)",
dbglob_handler,
)
}
fn scan_lists_exist(ctx: &CommandContext) -> bool {
ctx.db().ioc_is_running()
}
fn cmd_scanppl() -> CommandDef {
CommandDef::new(
"scanppl",
vec![ArgDesc {
name: "rate",
arg_type: ArgType::Double,
}],
"scanppl [rate] — Print periodic scan lists, optionally just one rate",
|args: &[ArgValue], ctx: &CommandContext| {
if !scan_lists_exist(ctx) {
ctx.println("scanppl: dbScan subsystem not initialized");
return Ok(CommandOutcome::Failed);
}
let rate = match args.first() {
Some(ArgValue::Double(r)) if *r > 0.0 => Some(*r),
_ => None,
};
let scan_types = crate::server::scan::periodic_scans();
for st in &scan_types {
if let Some(rate) = rate {
let matches = st
.interval()
.is_some_and(|d| (rate - d.as_secs_f64()).abs() <= 0.05);
if !matches {
continue;
}
}
let names = ctx.block_on(ctx.db().records_for_scan(*st));
if names.is_empty() {
continue;
}
let overruns = st
.scan_list()
.map(|l| ctx.db().scan_overruns(l))
.unwrap_or(0);
ctx.println(&format!(
"Records with SCAN = '{st}' ({overruns} over-runs):"
));
for name in &names {
ctx.println(&format!(" {name:<28}"));
}
}
Ok(CommandOutcome::Continue)
},
)
}
const SCAN_PRIORITY_NAMES: [&str; 3] = ["Low", "Medium", "High"];
fn print_scan_list(ctx: &CommandContext, header: &str, names: &[String]) {
if names.is_empty() {
return;
}
ctx.println(header);
for name in names {
ctx.println(&format!(" {name:<28}"));
}
}
fn scan_lists_by_priority(ctx: &CommandContext, names: &[String]) -> [Vec<String>; 3] {
let mut out: [Vec<String>; 3] = [Vec::new(), Vec::new(), Vec::new()];
for name in names {
let Some(rec) = ctx.db().get_record(name) else {
continue;
};
let prio = rec.read().common.prio;
let idx = (prio.clamp(0, 2)) as usize;
out[idx].push(name.clone());
}
out
}
fn cmd_scanpel() -> CommandDef {
CommandDef::new(
"scanpel",
vec![ArgDesc {
name: "event name",
arg_type: ArgType::String,
}],
"scanpel [\"event name\"] — Print info for records with SCAN = \"Event\".",
|args: &[ArgValue], ctx: &CommandContext| {
use crate::server::database::scan_index::normalize_event_name;
use crate::server::record::ScanType;
if !scan_lists_exist(ctx) {
return Ok(CommandOutcome::Continue);
}
let pattern = match args.first() {
Some(ArgValue::String(p)) if !p.is_empty() => Some(p.clone()),
_ => None,
};
let mut by_event: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
for name in ctx.block_on(ctx.db().records_for_scan(ScanType::Event)) {
let Some(rec) = ctx.db().get_record(&name) else {
continue;
};
let evnt = normalize_event_name(&rec.read().common.evnt);
if evnt.is_empty() {
continue;
}
by_event.entry(evnt).or_default().push(name);
}
for (event, names) in &by_event {
if let Some(pattern) = &pattern
&& !epics_strn_glob_match(event.as_bytes(), event.len(), pattern.as_bytes())
{
continue;
}
ctx.println(&format!("Event \"{event}\""));
for (prio, list) in scan_lists_by_priority(ctx, names).iter().enumerate() {
print_scan_list(
ctx,
&format!(" Priority {}", SCAN_PRIORITY_NAMES[prio]),
list,
);
}
}
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_scanpiol() -> CommandDef {
CommandDef::new(
"scanpiol",
vec![],
"scanpiol — Print info for records with SCAN = \"I/O Intr\".",
|_args: &[ArgValue], ctx: &CommandContext| {
use crate::server::record::ScanType;
if !scan_lists_exist(ctx) {
return Ok(CommandOutcome::Continue);
}
let names = ctx.block_on(ctx.db().records_for_scan(ScanType::IoIntr));
for (prio, list) in scan_lists_by_priority(ctx, &names).iter().enumerate() {
print_scan_list(
ctx,
&format!("IO Event: Priority {}", SCAN_PRIORITY_NAMES[prio]),
list,
);
}
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_pushd() -> CommandDef {
CommandDef::new(
"pushd",
vec![ArgDesc {
name: "dir",
arg_type: ArgType::Path,
}],
"pushd [dir] — push current dir onto stack and cd to <dir>",
|args: &[ArgValue], ctx: &CommandContext| {
let cwd = match std::env::current_dir() {
Ok(p) => p,
Err(e) => {
return Err(format!("pushd: cannot read cwd: {e}"));
}
};
match &args[0] {
ArgValue::String(dir) => {
if let Err(e) = super::core_commands::set_working_dir(dir) {
return Err(format!("pushd: {dir}: {e}"));
}
dir_stack().lock().unwrap().push(cwd);
}
_ => {
let mut stack = dir_stack().lock().unwrap();
let Some(top) = stack.pop() else {
return Err("pushd: directory stack empty".into());
};
if let Err(e) = super::core_commands::set_working_dir(&top) {
stack.push(top);
return Err(format!("pushd: {e}"));
}
stack.push(cwd);
}
}
print_stack(ctx);
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_popd() -> CommandDef {
CommandDef::new(
"popd",
vec![],
"popd — pop top of directory stack and cd to it",
|_args: &[ArgValue], ctx: &CommandContext| {
let mut stack = dir_stack().lock().unwrap();
let Some(top) = stack.pop() else {
return Err("popd: directory stack empty".into());
};
if let Err(e) = super::core_commands::set_working_dir(&top) {
stack.push(top);
return Err(format!("popd: {e}"));
}
drop(stack);
print_stack(ctx);
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_dirs() -> CommandDef {
CommandDef::new(
"dirs",
vec![],
"dirs — list the iocsh directory stack",
|_args: &[ArgValue], ctx: &CommandContext| {
print_stack(ctx);
Ok(CommandOutcome::Continue)
},
)
}
const S_DB_LIB_RECORD_TYPE_NOT_FOUND: u32 = (512 << 16) | 1;
const S_DB_LIB_REC_EXISTS: u32 = (512 << 16) | 3;
const S_DB_LIB_REC_NOT_FOUND: u32 = (512 << 16) | 5;
const S_DB_LIB_POST_INIT_REC_REGISTER: u32 = (512 << 16) | 31;
const S_DB_LIB_RECORD_NAME_MISSING: u32 = (512 << 16) | 33;
fn cmd_db_create_record() -> CommandDef {
CommandDef::new(
"dbCreateRecord",
vec![
ArgDesc {
name: "pdbbase",
arg_type: ArgType::String,
},
ArgDesc {
name: "recordType",
arg_type: ArgType::String,
},
ArgDesc {
name: "recordName",
arg_type: ArgType::String,
},
],
"dbCreateRecord pdbbase <type> <name> — Create a new record of <type> (before iocInit)",
|args: &[ArgValue], ctx: &CommandContext| {
check_pdbbase(&args[0])?;
if let Err(e) = ctx.db().begin_load() {
return Err(format!("{S_DB_LIB_POST_INIT_REC_REGISTER} {e}"));
}
let name = match &args[2] {
ArgValue::String(s) if !s.is_empty() => s.clone(),
_ => {
return Err(format!(
"{S_DB_LIB_RECORD_NAME_MISSING} Record name is required"
));
}
};
let rec_type = match &args[1] {
ArgValue::String(s) => s.clone(),
_ => {
return Err(format!(
"{S_DB_LIB_RECORD_TYPE_NOT_FOUND} Record Type does not exist"
));
}
};
if let Err(e) = db_loader::validate_record_name(&name, 0, 0) {
return Err(format!("dbCreateRecord: {e}"));
}
if ctx.db().get_record(&name).is_some() {
return Err(format!("{S_DB_LIB_REC_EXISTS} Record Already exists"));
}
let record = db_loader::create_record(&rec_type).map_err(|_| {
format!("{S_DB_LIB_RECORD_TYPE_NOT_FOUND} Record Type does not exist")
})?;
if let Err(e) = ctx.block_on(ctx.db().add_record(&name, record)) {
return Err(format!("dbCreateRecord: {e}"));
}
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_post_event() -> CommandDef {
CommandDef::new(
"postEvent",
vec![ArgDesc {
name: "event name",
arg_type: ArgType::String,
}],
"postEvent <event name> — Manually scan all records with EVNT == name.",
post_event_handler,
)
}
fn post_event_handler(args: &[ArgValue], ctx: &CommandContext) -> CommandResult {
match args.first() {
Some(ArgValue::String(name)) if !name.trim().is_empty() => {
ctx.block_on(ctx.db().post_event_named(name));
}
_ => {}
}
Ok(CommandOutcome::Continue)
}
pub(super) fn epics_strn_glob_match(s: &[u8], len: usize, pattern: &[u8]) -> bool {
let len = len.min(s.len());
let at = |p: usize| pattern.get(p).copied().unwrap_or(0);
let mut mp: Option<usize> = None;
let mut cp: usize = 0;
let mut i: usize = 0;
let mut p: usize = 0;
while i < len && at(p) != b'*' {
if at(p) != s[i] && at(p) != b'?' {
return false;
}
p += 1;
i += 1;
}
while i < len {
if at(p) == b'*' {
p += 1;
if at(p) == 0 {
return true;
}
mp = Some(p);
cp = i + 1;
} else if at(p) == s[i] || at(p) == b'?' {
p += 1;
i += 1;
} else {
p = mp.unwrap_or(0);
i = cp;
cp += 1;
}
}
while at(p) == b'*' {
p += 1;
}
at(p) == 0
}
fn glob_match(pattern: &str, text: &str) -> bool {
epics_strn_glob_match(text.as_bytes(), text.len(), pattern.as_bytes())
}
fn cmd_ioc_stats() -> CommandDef {
CommandDef::new(
"iocStats",
vec![],
"iocStats — Show IOC runtime statistics",
|_args: &[ArgValue], ctx: &CommandContext| {
let names = ctx.block_on(ctx.db().all_record_names());
ctx.println(&format!("Records: {}", names.len()));
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
let start = START.get_or_init(std::time::Instant::now);
let uptime = start.elapsed();
let hours = uptime.as_secs() / 3600;
let mins = (uptime.as_secs() % 3600) / 60;
let secs = uptime.as_secs() % 60;
ctx.println(&format!("Uptime: {hours}h {mins}m {secs}s"));
#[cfg(target_os = "linux")]
if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
for line in status.lines() {
if let Some(val) = line.strip_prefix("VmRSS:") {
ctx.println(&format!("RSS: {}", val.trim()));
break;
}
}
}
let threads = std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(1);
ctx.println(&format!("CPU cores: {threads}"));
use crate::server::record::ScanType;
let scan_types = [
ScanType::SEC01,
ScanType::SEC02,
ScanType::SEC05,
ScanType::SEC1,
ScanType::SEC2,
ScanType::SEC5,
ScanType::SEC10,
];
let mut total_scanned = 0;
for st in &scan_types {
total_scanned += ctx.block_on(ctx.db().records_for_scan(*st)).len();
}
let io_intr = ctx
.block_on(ctx.db().records_for_scan(ScanType::IoIntr))
.len();
ctx.println(&format!("Periodic: {total_scanned} records"));
ctx.println(&format!("I/O Intr: {io_intr} records"));
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_db_load_records() -> CommandDef {
CommandDef::new(
"dbLoadRecords",
vec![
ArgDesc {
name: "file",
arg_type: ArgType::Path,
},
ArgDesc {
name: "macros",
arg_type: ArgType::String,
},
],
"dbLoadRecords file [macros] - Load records from a .db/.template file",
|args: &[ArgValue], ctx: &CommandContext| {
let path = match &args[0] {
ArgValue::String(s) => s,
_ => {
ctx.println("Usage: dbLoadRecords \"file\", \"subs\"");
return Ok(CommandOutcome::Failed);
}
};
let macros_str = match &args[1] {
ArgValue::String(s) => s.as_str(),
_ => "",
};
let mut faults = db_loader::DbFaults::default();
match db_load_records(ctx, path, parse_macro_string(macros_str), &mut faults) {
Ok(()) => Ok(CommandOutcome::Continue),
Err(_) => Ok(CommandOutcome::Failed),
}
},
)
}
fn cmd_db_load_database() -> CommandDef {
CommandDef::new(
"dbLoadDatabase",
vec![
ArgDesc {
name: "file name",
arg_type: ArgType::Path,
},
ArgDesc {
name: "path",
arg_type: ArgType::Path,
},
ArgDesc {
name: "substitutions",
arg_type: ArgType::String,
},
],
"dbLoadDatabase file [path] [substitutions] - Load a .dbd or .db file",
|args: &[ArgValue], ctx: &CommandContext| {
let file = match &args[0] {
ArgValue::String(s) => s,
_ => {
ctx.println("Usage: dbLoadDatabase \"file\", \"path\", \"subs\"");
return Ok(CommandOutcome::Failed);
}
};
let path = match &args[1] {
ArgValue::String(s) => s.as_str(),
_ => "",
};
let substitutions = match &args[2] {
ArgValue::String(s) => s.as_str(),
_ => "",
};
let mut faults = db_loader::DbFaults::default();
match db_read_database(ctx, file, path, substitutions, &mut faults) {
Ok(_) => Ok(CommandOutcome::Continue),
Err(_) => Ok(CommandOutcome::Failed),
}
},
)
}
enum DbReadFailure {
AfterIocInit,
CannotOpen,
Rejected,
}
fn db_read_database(
ctx: &CommandContext,
file: &str,
path: &str,
substitutions: &str,
faults: &mut db_loader::DbFaults,
) -> Result<(), DbReadFailure> {
db_read_database_macros(ctx, file, path, parse_macro_string(substitutions), faults)
}
fn db_read_database_macros(
ctx: &CommandContext,
file: &str,
path: &str,
macros: HashMap<String, String>,
faults: &mut db_loader::DbFaults,
) -> Result<(), DbReadFailure> {
if ctx.db().begin_load().is_err() {
return Err(DbReadFailure::AfterIocInit);
}
let (config, file_path) = resolve_db_file(file, path);
let Some(file_path) = file_path else {
ctx.eprintln(&format!("{ERL_ERROR}: Can't open file '{file}'"));
return Err(DbReadFailure::CannotOpen);
};
let parsed = db_loader::parse_db_opened_with_breaktables(&file_path, ¯os, &config)
.map_err(|_| DbReadFailure::Rejected)?;
let breaktable_registry =
ctx.block_on(async { ctx.db().add_breaktables(parsed.breaktables).await });
super::dbstatic_commands::add_loaded_registrars(&parsed.dbd.registrars);
super::dbstatic_commands::add_loaded_link_types(&parsed.dbd.link_types);
faults.absorb(parsed.faults);
ctx.block_on(install_record_defs(
ctx,
parsed.records,
parsed.unresolved_aliases,
&breaktable_registry,
faults,
))?;
Ok(())
}
fn db_load_records(
ctx: &CommandContext,
file: &str,
macros: HashMap<String, String>,
faults: &mut db_loader::DbFaults,
) -> Result<(), DbReadFailure> {
let read = db_read_database_macros(ctx, file, "", macros, faults);
if let Err(failure) = &read {
ctx.eprintln(&format!("{ERL_ERROR}: Failed to load '{file}'"));
if matches!(failure, DbReadFailure::AfterIocInit) {
ctx.eprintln(" Records cannot be loaded after iocInit!");
}
}
read
}
fn db_load_config(explicit_path: &str) -> db_loader::DbLoadConfig {
let include_paths = if explicit_path.is_empty() {
std::env::var("EPICS_DB_INCLUDE_PATH").map_or_else(
|_| vec![std::path::PathBuf::from(".")],
|val| db_loader::db_path(&val),
)
} else {
db_loader::db_path(explicit_path)
};
db_loader::set_loaded_path(&include_paths);
db_loader::DbLoadConfig {
include_paths,
max_include_depth: 32,
}
}
fn resolve_db_file(
path: &str,
include_path: &str,
) -> (db_loader::DbLoadConfig, Option<db_loader::DbOpenedFile>) {
let config = db_load_config(include_path);
let opened = db_loader::db_open_file_located(path, &config.include_paths);
(config, opened)
}
fn resolve_substitutions_file(path: &str, search_path: &str) -> std::path::PathBuf {
let config = db_load_config("");
let direct = std::path::PathBuf::from(path);
if direct.exists() || direct.is_absolute() {
return direct;
}
let search = if search_path.is_empty() {
config.include_paths
} else {
db_loader::db_path(search_path)
};
db_loader::db_open_file(path, &search).unwrap_or(direct)
}
fn eprint_substitution_fault(ctx: &CommandContext, fault: &db_loader::SubstitutionFault) {
match &fault.message {
Some(message) => ctx.eprintln(&format!("Substitution file error: {message}")),
None => ctx.eprintln("Substitution file error."),
}
ctx.eprintln(&format!("line {}: '{}'", fault.line, fault.yytext));
}
fn c_sub_collect(cmd_collect: &str, row: &[db_loader::RowMacro]) -> String {
let mut parts: Vec<String> = Vec::with_capacity(row.len() + 1);
if !cmd_collect.is_empty() {
parts.push(cmd_collect.to_owned());
}
for macro_ in row {
let db_loader::RowMacro { name, value, .. } = macro_;
if macro_.quoted {
parts.push(format!("{name}=\"{value}\""));
} else {
parts.push(format!("{name}={value}"));
}
}
parts.join(",")
}
fn record_type_unavailable(ctx: &CommandContext, record_type: &str, name: &str) -> RecordFault {
if crate::server::record::dbd_generated::record_fields(record_type).is_some() {
ctx.println("\t*** Did you run x_RegisterRecordDeviceDriver(pdbbase) yet? ***");
crate::runtime::log::errlog_printf(&format!(
"dbAllocRecord({name}) with {record_type} rec_size = 0\n"
));
return RecordFault::Fatal(format!(
"{ERL_ERROR}: Can't create {record_type} record '{name}'"
));
}
RecordFault::Fatal(format!(
"{ERL_ERROR}: Record type '{record_type}' for record '{name}' not found"
))
}
fn apply_fields_one_at_a_time(
record: &mut Box<dyn crate::server::record::Record>,
record_name: &str,
fields: &[db_loader::DbFieldDef],
common_fields: &mut Vec<(String, EpicsValue)>,
faults: &mut db_loader::DbFaults,
) {
for f in fields {
let Err(e) = db_loader::apply_fields(record, std::slice::from_ref(f), common_fields) else {
continue;
};
faults.seek(f.line, ")");
faults.report(db_loader::DbDiagnostic::new(format!(
"{ERL_ERROR}: Can't set '{record_name}.{}' to '{}' {e}",
f.name,
f.value.as_str_lossy(),
)));
}
}
enum RecordFault {
Recoverable(String),
Fatal(String),
}
async fn install_record_defs(
ctx: &CommandContext,
defs: Vec<db_loader::DbRecordDef>,
unresolved_aliases: Vec<(String, String)>,
breaktable_registry: &crate::server::cvt_bpt::BreakTableRegistry,
faults: &mut db_loader::DbFaults,
) -> Result<(), DbReadFailure> {
for mut def in defs {
let modify_block = def.record_type == "*";
if modify_block {
let existing = ctx
.db()
.get_record(&def.name)
.map(|rec| rec.read().record.record_type().to_string());
match existing {
Some(t) => def.record_type = t,
None => {
faults.recoverable(format!("{ERL_ERROR}: Record '{}' not found", def.name));
continue;
}
}
}
if def.record_type == "#" {
if !ctx.db().remove_record(&def.name).await {
ctx.eprintln(&format!(
"{ERL_WARNING}: Record '{}' not found, can't delete",
def.name
));
}
continue;
}
db_loader::resolve_linr_breaktable_names(
&def.record_type,
&mut def.fields,
breaktable_registry,
);
let (screen_type, screen_name) = (def.record_type.clone(), def.name.clone());
def.fields.retain(|f| {
let Some(refusal) = db_loader::menu_value_refusal(
&screen_type,
&screen_name,
&f.name.to_uppercase(),
&f.value.as_str_lossy(),
) else {
return true;
};
faults.seek(f.line, ")");
faults.report(db_loader::DbDiagnostic {
notice: refusal.notice,
message: refusal.line,
suggestion: refusal.suggestion,
});
false
});
let added: Result<(), RecordFault> = async {
let existing = if let Some(rec) = ctx.db().get_record(&def.name) {
let r = rec.read();
let existing_type = r.record.record_type();
if existing_type != def.record_type {
return Err(RecordFault::Recoverable(format!(
"{ERL_ERROR}: {} record '{}' already exists, can't load {} record",
def.record_type, def.name, existing_type
)));
}
drop(r);
if db_records_once_only() && !modify_block {
return Err(RecordFault::Recoverable(format!(
"{ERL_ERROR}: Record '{}' already defined; dbRecordsOnceOnly is set,\n \
so can't modify record.",
def.name
)));
}
Some(rec)
} else {
None
};
let mut common_fields = Vec::new();
let is_merge = existing.is_some();
let rec_arc = if let Some(rec_arc) = existing {
{
let mut inst = rec_arc.write();
apply_fields_one_at_a_time(
&mut inst.record,
&def.name,
&def.fields,
&mut common_fields,
faults,
);
}
rec_arc
} else {
let mut record = db_loader::create_record(&def.record_type).map_err(|_| {
record_type_unavailable(ctx, &def.record_type, &def.name)
})?;
apply_fields_one_at_a_time(
&mut record,
&def.name,
&def.fields,
&mut common_fields,
faults,
);
let load = RecordLoad {
common_fields: std::mem::take(&mut common_fields),
info_tags: def.info_tags.clone(),
};
if ctx
.db()
.add_loaded_record(&def.name, record, load)
.await
.is_err()
{
return Err(RecordFault::Fatal(format!(
"{ERL_ERROR}: Can't create {} record '{}'",
def.record_type, def.name
)));
}
ctx.db().get_record(&def.name).ok_or_else(|| {
RecordFault::Fatal(format!(
"{ERL_ERROR}: dbLoadRecords: '{}' vanished between add_record and get_record",
def.name
))
})?
};
for alias in &def.aliases {
install_alias(ctx, alias, &def.name, faults).await;
}
let init_deferred = ctx.db().record_init_deferred(&def.name);
if is_merge {
{
let mut instance = rec_arc.write();
for (k, v) in &def.info_tags {
instance.set_info(k, v);
}
}
for (name, value) in common_fields {
use crate::server::record::CommonFieldPutResult;
let put = rec_arc.write().put_common_field_db_load(&name, value);
match put {
Ok(CommonFieldPutResult::ScanChanged {
old_scan,
new_scan,
phas,
}) => {
ctx.db()
.update_scan_index(&def.name, old_scan, new_scan, phas, phas);
}
Ok(CommonFieldPutResult::PhasChanged {
scan,
old_phas,
new_phas,
}) => {
ctx.db()
.update_scan_index(&def.name, scan, scan, old_phas, new_phas);
}
Ok(CommonFieldPutResult::NoChange) => {}
Err(e) => {
faults.recoverable(format!(
"put_common_field({name}) failed for {}: {e}",
def.name
));
}
}
}
if !init_deferred {
rec_arc.write().run_init_passes(&def.name);
}
}
if !init_deferred {
{
let mut instance = rec_arc.write();
let inst = &mut *instance;
inst.record.init_links(&inst.common);
}
ctx.db().rec_gbl_init_constant_links(&rec_arc);
if is_merge {
ctx.db().rec_gbl_init_simm(&rec_arc);
ctx.db().arm_watchdog(&def.name);
}
}
Ok(())
}
.await;
match added {
Ok(()) => {}
Err(RecordFault::Recoverable(msg)) => faults.recoverable(msg),
Err(RecordFault::Fatal(msg)) => {
faults.recoverable(msg);
return Err(DbReadFailure::Rejected);
}
}
}
for (target, alias) in unresolved_aliases {
if ctx.db().get_record(&target).is_none() {
faults.recoverable(db_loader::unknown_alias_message(&alias, &target));
continue;
}
install_alias(ctx, &alias, &target, faults).await;
}
if faults.is_empty() {
Ok(())
} else {
Err(DbReadFailure::Rejected)
}
}
fn cmd_db_load_template() -> CommandDef {
CommandDef::new(
"dbLoadTemplate",
vec![
ArgDesc {
name: "subFile",
arg_type: ArgType::Path,
},
ArgDesc {
name: "var1=value1,var2=value2",
arg_type: ArgType::String,
},
ArgDesc {
name: "path1:path2:...",
arg_type: ArgType::String,
},
],
"dbLoadTemplate subFile [globalMacros] [path] - Load records from a .substitutions file",
|args: &[ArgValue], ctx: &CommandContext| {
let path = match &args[0] {
ArgValue::String(s) if !s.is_empty() => s,
_ => {
ctx.eprintln("must specify variable substitution file");
return Ok(CommandOutcome::Failed);
}
};
let macros_str = match &args[1] {
ArgValue::String(s) => s.as_str(),
_ => "",
};
let search_path = match &args[2] {
ArgValue::String(s) => s.as_str(),
_ => "",
};
let max_vars = db_loader::db_template_max_vars();
if max_vars < 1 {
ctx.eprintln(&format!(
"{ERL_ERROR}: dbTemplateMaxVars = {max_vars}, must be +ve"
));
return Ok(CommandOutcome::Failed);
}
let file_path = resolve_substitutions_file(path, search_path);
let text = match std::fs::read_to_string(&file_path) {
Ok(text) => text,
Err(e) => {
ctx.eprintln(&format!(
"dbLoadTemplate: error opening sub file {path}: {}",
super::c_strerror(&e)
));
return Ok(CommandOutcome::Failed);
}
};
let subs = db_loader::parse_substitutions(&text);
for event in subs.events {
let load = match event {
db_loader::SubstitutionEvent::Fault(fault) => {
eprint_substitution_fault(ctx, &fault);
continue;
}
db_loader::SubstitutionEvent::Notice(text) => {
ctx.eprintln(&text);
continue;
}
db_loader::SubstitutionEvent::Load(load) => load,
};
let mut merged = parse_macro_string(macros_str);
merged.extend(
load.macros
.iter()
.map(|m| (m.name.clone(), m.value.clone())),
);
let mut faults = db_loader::DbFaults::default();
if db_load_records(ctx, &load.file, merged, &mut faults).is_err() {
ctx.eprintln(&format!(
"dbLoadRecords(\"{}\", {})",
load.file,
c_sub_collect(macros_str, &load.macros)
));
eprint_substitution_fault(
ctx,
&db_loader::SubstitutionFault::row_failed(&load),
);
return Ok(CommandOutcome::Failed);
}
}
if let Some(fault) = subs.stopped {
eprint_substitution_fault(ctx, &fault);
return Ok(CommandOutcome::Failed);
}
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_epics_env_set() -> CommandDef {
CommandDef::new(
"epicsEnvSet",
vec![
ArgDesc {
name: "name",
arg_type: ArgType::String,
},
ArgDesc {
name: "value",
arg_type: ArgType::String,
},
],
"epicsEnvSet name value - Set an environment variable",
|args: &[ArgValue], ctx: &CommandContext| {
let ArgValue::String(name) = &args[0] else {
ctx.eprintln("Missing environment variable name argument.");
return Ok(CommandOutcome::Failed);
};
let ArgValue::String(value) = &args[1] else {
ctx.eprintln("Missing environment variable value argument.");
return Ok(CommandOutcome::Failed);
};
super::iocsh_env_clear(name);
unsafe { std::env::set_var(name, value) };
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_ioc_init() -> CommandDef {
CommandDef::new(
"iocInit",
vec![],
"iocInit - Initialize the IOC",
|_args: &[ArgValue], ctx: &CommandContext| {
match crate::server::ioc_app::build_from_shell(ctx.bridge()) {
crate::server::ioc_app::ShellTransition::Done => {
let _ = crate::server::ioc_app::run_from_shell(ctx.bridge());
return Ok(CommandOutcome::Continue);
}
crate::server::ioc_app::ShellTransition::Failed => {
return Ok(CommandOutcome::Failed);
}
crate::server::ioc_app::ShellTransition::Refused => {
crate::runtime::log::errlog_printf(&crate::server::ioc_app::build_refusal());
return Ok(CommandOutcome::Failed);
}
crate::server::ioc_app::ShellTransition::NotOurs => {}
}
if !crate::server::ioc_app::build_without_application(|| {
ctx.block_on(async { ctx.db().ioc_init().await });
}) {
return Ok(CommandOutcome::Failed);
}
if crate::server::ioc_app::ioc_run() == 0 {
Ok(CommandOutcome::Continue)
} else {
Ok(CommandOutcome::Failed)
}
},
)
}
fn cmd_exit() -> CommandDef {
CommandDef::new(
"exit",
vec![],
"exit - Exit the IOC shell",
|_args: &[ArgValue], _ctx: &CommandContext| Ok(CommandOutcome::Exit),
)
}
fn escape_char_array_for_dbgf(buf: &[u8]) -> String {
let mut out = String::with_capacity(buf.len());
for &b in buf {
match b {
b'\n' => out.push_str("\\n"),
b'\t' => out.push_str("\\t"),
b'\r' => out.push_str("\\r"),
b'\\' => out.push_str("\\\\"),
b'"' => out.push_str("\\\""),
0x07 => out.push_str("\\a"),
0x08 => out.push_str("\\b"),
0x0c => out.push_str("\\f"),
0x0b => out.push_str("\\v"),
0x20..=0x7e => out.push(b as char),
_ => out.push_str(&format!("\\x{b:02x}")),
}
}
out
}
pub fn macro_defn_pairs(s: &str) -> Vec<(String, Option<String>)> {
#[derive(PartialEq, Clone, Copy)]
enum St {
PreName,
InName,
PreValue,
InValue,
}
let mut out: Vec<(String, Option<String>)> = Vec::new();
let mut state = St::PreName;
let mut name = String::new();
let mut value = String::new();
let mut pending_ws = String::new();
let mut quote: Option<char> = None;
macro_rules! enter_token {
() => {{
match state {
St::PreName => state = St::InName,
St::PreValue => state = St::InValue,
_ => {}
}
matches!(state, St::InValue)
}};
}
macro_rules! push_lit {
($c:expr) => {{
match state {
St::PreName => state = St::InName,
St::PreValue => state = St::InValue,
_ => {}
}
let target = if matches!(state, St::InName) {
&mut name
} else {
&mut value
};
target.push_str(&pending_ws);
pending_ws.clear();
target.push($c);
}};
}
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '\\' && i + 1 < chars.len() {
if enter_token!() {
push_lit!('\\');
}
push_lit!(chars[i + 1]);
i += 2;
continue;
}
if let Some(q) = quote {
if c == q {
quote = None;
if enter_token!() {
push_lit!(c);
}
} else {
push_lit!(c);
}
i += 1;
continue;
}
if c == '\'' || c == '"' {
quote = Some(c);
if enter_token!() {
push_lit!(c);
}
i += 1;
continue;
}
match state {
St::PreName => {
if c == '=' {
state = St::PreValue;
} else if !(crate::runtime::stdlib::c_isspace(c) || c == ',') {
state = St::InName;
name.push(c);
}
}
St::InName => {
if c == '=' {
pending_ws.clear();
state = St::PreValue;
} else if c == ',' {
pending_ws.clear();
out.push((std::mem::take(&mut name), None));
state = St::PreName;
} else if crate::runtime::stdlib::c_isspace(c) {
pending_ws.push(c);
} else {
name.push_str(&pending_ws);
pending_ws.clear();
name.push(c);
}
}
St::PreValue => {
if c == ',' {
out.push((std::mem::take(&mut name), Some(String::new())));
state = St::PreName;
} else if !crate::runtime::stdlib::c_isspace(c) {
state = St::InValue;
value.push(c);
}
}
St::InValue => {
if c == ',' {
pending_ws.clear();
out.push((std::mem::take(&mut name), Some(std::mem::take(&mut value))));
state = St::PreName;
} else if crate::runtime::stdlib::c_isspace(c) {
pending_ws.push(c);
} else {
value.push_str(&pending_ws);
pending_ws.clear();
value.push(c);
}
}
}
i += 1;
}
match state {
St::PreName => {}
St::InName => out.push((std::mem::take(&mut name), None)),
St::PreValue => out.push((std::mem::take(&mut name), Some(String::new()))),
St::InValue => out.push((std::mem::take(&mut name), Some(std::mem::take(&mut value)))),
}
out
}
pub(super) fn parse_macro_string(s: &str) -> HashMap<String, String> {
let mut macros = HashMap::new();
for (k, v) in macro_defn_pairs(s) {
if let Some(v) = v {
if k.is_empty() {
continue;
}
macros.insert(k, v);
}
}
macros
}
pub(super) fn check_pdbbase(arg: &ArgValue) -> Result<(), String> {
if let ArgValue::String(pdbbase) = arg
&& !(pdbbase.is_empty() || pdbbase.starts_with('0') || pdbbase == "pdbbase")
{
return Err(format!("Expecting 'pdbbase' got '{pdbbase}'."));
}
Ok(())
}
fn c_exponential(v: f64) -> String {
if !v.is_finite() {
return format!("{v}");
}
let formatted = format!("{v:.6e}");
let (mantissa, exponent) = formatted
.split_once('e')
.expect("Rust LowerExp always emits an `e`");
let exponent: i32 = exponent.parse().expect("Rust LowerExp emits a decimal");
let sign = if exponent < 0 { '-' } else { '+' };
format!("{mantissa}e{sign}{:02}", exponent.abs())
}
fn link_field_kind(record_type: &str, field: &str) -> Option<&'static str> {
use crate::types::DbfLinkClass;
Some(match crate::types::dbf_link_class(record_type, field)? {
DbfLinkClass::InLink => "\t INLINK",
DbfLinkClass::OutLink => "\tOUTLINK",
DbfLinkClass::FwdLink => "\tFWDLINK",
})
}
fn dblsr_link_lines(db: &crate::server::database::PvDatabase, record: &str) -> Vec<String> {
use crate::server::record::ParsedLink;
let Some(record_type) = db
.get_record(record)
.map(|rec| rec.read().record.record_type())
else {
return Vec::new();
};
db.record_link_fields(record)
.into_iter()
.filter_map(|(field, _, parsed)| match parsed {
ParsedLink::Db(link) => {
let addressed = link.target().record;
let target = db
.resolve_alias(&addressed)
.unwrap_or_else(|| addressed.clone());
db.get_record_no_resolve(&target)?;
let pp = if link.policy == crate::server::record::LinkProcessPolicy::ProcessPassive
{
" PP"
} else {
"NPP"
};
Some(format!(
"\t{field}{} {pp} {} {target}",
link_field_kind(record_type, &field)?,
crate::server::record::record_instance::monitor_switch_word(
link.monitor_switch
)
))
}
_ => None,
})
.collect()
}
fn dblsr_set_lines(
db: &crate::server::database::PvDatabase,
set: &crate::server::database::LockSetInfo,
level: i64,
) -> Vec<String> {
let mut lines = vec![format!(
"Lock Set {} {} members {} refs epicsMutexId {}",
set.id,
set.members.len(),
set.refs,
set.mutex
.as_ref()
.map_or_else(|| "(nil)".to_string(), |m| format!("{:#x}", m.addr()))
)];
if level == 0 {
return lines;
}
for member in &set.members {
lines.push(member.clone());
if level <= 1 {
continue;
}
lines.extend(dblsr_link_lines(db, member));
}
lines
}
fn cmd_dblsr() -> CommandDef {
CommandDef::new(
"dblsr",
vec![
ArgDesc {
name: "record name",
arg_type: ArgType::Record,
},
ArgDesc {
name: "interest level",
arg_type: ArgType::Int,
},
],
"dblsr [record name] [interest level] — Database Lockset report.\n\
Generate a report showing the lock set to which each record belongs.\n\
interest level 0 - Show lock set information only.\n\
\x20 1 - Show each record in the lock set.\n\
\x20 2 - Show each record and all database links in the lock set.",
|args: &[ArgValue], ctx: &CommandContext| {
let name = match &args[0] {
ArgValue::String(s) if s != "*" && !s.is_empty() => Some(s.clone()),
_ => None,
};
let level = match args[1] {
ArgValue::Int(v) => v,
_ => 0,
};
let db = ctx.db();
let sets = match name {
Some(name) => {
if db.get_record(&name).is_none() {
ctx.println("Record not found");
return Ok(CommandOutcome::Continue);
}
match db.lock_set_of(&name) {
Some(set) => vec![set],
None => return Ok(CommandOutcome::Continue),
}
}
None => db.lock_set_report().active,
};
for set in &sets {
for line in dblsr_set_lines(db, set, level) {
ctx.println(&line);
}
}
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_dbcar() -> CommandDef {
CommandDef::new(
"dbcar",
vec![
ArgDesc {
name: "record name",
arg_type: ArgType::Record,
},
ArgDesc {
name: "level",
arg_type: ArgType::Int,
},
],
"dbcar [record name] [level] — Database Channel Access Report.\n\
Shows status of Channel Access links (CA_LINK).\n\
level 0 - Shows statistics for all links.\n \
\x201 - Shows info. of only disconnected links.\n \
\x202 - Shows info. for all links.",
|args: &[ArgValue], ctx: &CommandContext| {
use crate::server::record::ParsedLink;
let only = match &args[0] {
ArgValue::String(s) if !s.is_empty() && s != "*" => Some(s.clone()),
_ => None,
};
let level = match args[1] {
ArgValue::Int(v) => v,
_ => 0,
};
match &only {
None => ctx.println("CA links in all records\n"),
Some(name) => ctx.println(&format!("CA links in record named '{name}'\n")),
}
let records = match &only {
Some(name) => vec![name.clone()],
None => record_names_type_major(ctx),
};
let ca_lset = ctx.block_on(ctx.db().link_set("ca"));
let mut ncalinks = 0i64;
let mut nconnected = 0i64;
let mut no_read_access = 0i64;
let mut no_write_access = 0i64;
let mut total_disconnect = 0u64;
let mut total_no_write = 0u64;
for record in records {
let Some(rec) = ctx.db().get_record(&record) else {
continue;
};
let display = rec.read().name.clone();
let mut fields = ctx.db().record_link_fields(&record);
fields.sort_by_key(|(field, _, _)| {
usize::from(!matches!(field.as_str(), "TSEL" | "SDIS" | "FLNK"))
});
for (field, _raw, parsed) in fields {
let ParsedLink::Ca(ca) = parsed else { continue };
ncalinks += 1;
let diagnostics = ca_lset
.as_ref()
.and_then(|lset| ctx.block_on(lset.link_diagnostics(&ca.pv)));
let no_write = ctx.db().external_link_puts_coalesced_for(&ca.pv);
match diagnostics {
Some(d) if d.connected => {
nconnected += 1;
total_disconnect += d.n_disconnect;
total_no_write += no_write;
if !d.read_access {
no_read_access += 1;
}
if !d.write_access {
no_write_access += 1;
}
if level > 1 {
ctx.println(&format!(
"{display:>28}.{field:<4} ==> {:<28} ({}, {no_write})",
ca.pv, d.n_disconnect
));
const RIGHTS: [&str; 4] =
["No Access", "Read Only", "Write Only", "Read/Write"];
let rights =
usize::from(d.read_access) | usize::from(d.write_access) << 1;
ctx.println(&format!(
"{:>21} [{}{}{}{}] host {}, {}",
"",
if d.input_native { "IN" } else { " " },
if d.input_string { "IS" } else { " " },
if d.output_native { "ON" } else { " " },
if d.output_string { "OS" } else { " " },
d.host,
RIGHTS[rights],
));
}
}
other => {
if level > 0 {
let (disconnects, no_write) = match other {
Some(d) => (d.n_disconnect, no_write),
None => (0, 0),
};
ctx.println(&format!(
"{display:>28}.{field:<4} --> {:<28} ({disconnects}, {no_write})",
ca.pv
));
}
}
}
}
}
if (level > 1 && nconnected > 0) || (level > 0 && ncalinks != nconnected) {
ctx.println("");
}
ctx.println(&format!(
"Total {ncalinks} CA link{}; {nconnected} connected, {} not connected.",
if ncalinks != 1 { "s" } else { "" },
ncalinks - nconnected,
));
ctx.println(&format!(
" {no_read_access} can't read, {no_write_access} can't write. \
({total_disconnect} disconnects, {total_no_write} writes prohibited)\n"
));
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_db_notify_dump() -> CommandDef {
CommandDef::new(
"dbNotifyDump",
vec![],
"dbNotifyDump — Report status of any active async processing with completion notification.",
|_args: &[ArgValue], ctx: &CommandContext| {
use std::sync::Arc;
struct Row {
name: String,
notify: Option<Arc<crate::server::record::NotifyWaitSet>>,
pact: bool,
queued: usize,
}
let rows: Vec<Row> = record_names_type_major(ctx)
.into_iter()
.filter_map(|name| {
let rec = ctx.db().get_record(&name)?;
let inst = rec.read();
Some(Row {
notify: inst.notify.clone(),
pact: inst.is_processing(),
queued: inst.notify_restart_len(),
name,
})
})
.collect();
for row in rows
.iter()
.filter(|r| r.notify.as_ref().and_then(|w| w.entry_record()) == Some(&r.name[..]))
{
let ws = row.notify.as_ref().expect("filtered");
let state = if ws.completed() { 5 } else { 4 };
ctx.println(&format!(
"{} state {} ppn {:p}\n waitList",
row.name,
state,
Arc::as_ptr(ws)
));
for member in rows
.iter()
.filter(|m| m.notify.as_ref().is_some_and(|w| Arc::ptr_eq(w, ws)))
{
ctx.println(&format!(
" {} pact {}",
member.name,
u8::from(member.pact)
));
}
if row.queued > 0 {
ctx.println(&format!("{} restartList", row.name));
for _ in 0..row.queued {
ctx.println(&format!(" {}", row.name));
}
}
}
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_db_put_attribute() -> CommandDef {
CommandDef::new(
"dbPutAttribute",
vec![
ArgDesc {
name: "record type",
arg_type: ArgType::String,
},
ArgDesc {
name: "attribute name",
arg_type: ArgType::String,
},
ArgDesc {
name: "value",
arg_type: ArgType::String,
},
],
"dbPutAttribute <record type> <attribute name> <value> — Set/Create record attribute.",
|args: &[ArgValue], ctx: &CommandContext| {
let text = |arg: &ArgValue| match arg {
ArgValue::String(s) => Some(s.clone()),
_ => None,
};
let record_type = text(&args[0]).unwrap_or_default();
let name = text(&args[1]);
let value = text(&args[2]);
match ctx.db().put_record_type_attribute(
&record_type,
name.as_deref(),
value.as_deref(),
) {
Ok(()) => Ok(CommandOutcome::Continue),
Err(e) => {
crate::runtime::log::errlog_printf(&format!(
"{} filename=\"{}\" line number={} dbPutAttribute failure\n",
e.message(),
file!(),
line!()
));
Ok(CommandOutcome::Failed)
}
}
},
)
}
fn cmd_db_lock_show_locked() -> CommandDef {
CommandDef::new(
"dbLockShowLocked",
vec![ArgDesc {
name: "interest level",
arg_type: ArgType::Int,
}],
"dbLockShowLocked [interest level] — Show Locksets which are currently locked.\n\
interest level argument is passed to epicsMutexShow to adjust reported\n\
information.",
|args: &[ArgValue], ctx: &CommandContext| {
let level = match args[0] {
ArgValue::Int(v) => v.max(0) as u32,
_ => 0,
};
let report = ctx.db().lock_set_report();
ctx.println(&format!("Active lockSets: {}", report.active.len()));
ctx.println(&format!("Free lockSets: {}", report.free));
for (pass, header) in ["listTypeScanLock", "listTypeRecordLock"]
.into_iter()
.enumerate()
{
if report.active.is_empty() {
continue;
}
ctx.println(header);
for set in &report.active {
if pass == 0 && !set.locked {
continue;
}
let Some(info) = &set.mutex else { continue };
for line in info.show_lines(level) {
ctx.println(&line);
}
}
}
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_dbnr() -> CommandDef {
CommandDef::new(
"dbnr",
vec![ArgDesc {
name: "verbose",
arg_type: ArgType::Int,
}],
"dbnr [verbose] — List number of records and aliases by type.\n\
If verbose, list all record types regardless of being instanced",
|args: &[ArgValue], ctx: &CommandContext| {
use crate::server::record::dbd_generated::RECORD_TYPES;
let verbose = matches!(args[0], ArgValue::Int(v) if v != 0);
let mut per_type: HashMap<String, (i64, i64)> = HashMap::new();
for node in db_nodes_type_major(ctx) {
let Some(rec) = ctx.db().get_record(&node.name) else {
continue;
};
let record_type = rec.read().record.record_type().to_string();
let counts = per_type.entry(record_type).or_default();
if node.alias_of.is_some() {
counts.1 += 1;
} else {
counts.0 += 1;
}
}
let mut runtime_types: Vec<String> = per_type
.keys()
.filter(|t| !RECORD_TYPES.contains(&t.as_str()))
.cloned()
.collect();
runtime_types.sort();
ctx.println("Records Aliases Record Type");
let (mut total_records, mut total_aliases) = (0i64, 0i64);
for record_type in RECORD_TYPES
.iter()
.map(|t| (*t).to_string())
.chain(runtime_types)
{
let (nrecords, naliases) = per_type.get(&record_type).copied().unwrap_or((0, 0));
total_aliases += naliases;
total_records += nrecords;
if verbose || nrecords != 0 {
ctx.println(&format!(" {nrecords:5} {naliases:5} {record_type}"));
}
}
ctx.println(&format!(
"Total {total_records} records, {total_aliases} aliases"
));
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_dbla() -> CommandDef {
CommandDef::new(
"dbla",
vec![ArgDesc {
name: "pattern",
arg_type: ArgType::Record,
}],
"dbla [pattern] — List record alias()s by alias name pattern.",
|args: &[ArgValue], ctx: &CommandContext| {
let pattern = match &args[0] {
ArgValue::String(s) if !s.is_empty() => Some(s.clone()),
_ => None,
};
for node in db_nodes_type_major(ctx) {
let Some(target) = node.alias_of else {
continue;
};
if let Some(pattern) = &pattern
&& !glob_match(pattern, &node.name)
{
continue;
}
ctx.println(&format!("{} -> {target}", node.name));
}
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_dbli() -> CommandDef {
CommandDef::new(
"dbli",
vec![ArgDesc {
name: "pattern",
arg_type: ArgType::String,
}],
"dbli [pattern] — List info() tags with names matching pattern.",
|args: &[ArgValue], ctx: &CommandContext| {
let pattern = match &args[0] {
ArgValue::String(s) if !s.is_empty() => Some(s.clone()),
_ => None,
};
for name in record_names_type_major(ctx) {
let Some(rec) = ctx.db().get_record(&name) else {
continue;
};
let mut tags: Vec<(String, String)> = rec
.read()
.info
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
tags.sort();
for (key, value) in tags {
if let Some(pattern) = &pattern
&& !glob_match(pattern, &key)
{
continue;
}
ctx.println(&format!("{name} info({key}, \"{value}\")"));
}
}
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_db_create_alias() -> CommandDef {
CommandDef::new(
"dbCreateAlias",
vec![
ArgDesc {
name: "pdbbase",
arg_type: ArgType::String,
},
ArgDesc {
name: "record",
arg_type: ArgType::Record,
},
ArgDesc {
name: "alias",
arg_type: ArgType::Record,
},
],
"dbCreateAlias pdbbase <record> <alias> — Add a new record alias.",
|args: &[ArgValue], ctx: &CommandContext| {
check_pdbbase(&args[0])?;
let (ArgValue::String(record), ArgValue::String(alias)) = (&args[1], &args[2]) else {
return Err(format!("{S_DB_LIB_REC_NOT_FOUND} Record Not Found"));
};
let target = ctx
.db()
.resolve_alias(record)
.unwrap_or_else(|| record.clone());
match ctx.block_on(ctx.db().add_alias(alias, &target)) {
Ok(()) => Ok(CommandOutcome::Continue),
Err(CaError::ChannelNotFound(_)) => {
Err(format!("{S_DB_LIB_REC_NOT_FOUND} Record Not Found"))
}
Err(_) => Err(format!("{S_DB_LIB_REC_EXISTS} Record Already exists")),
}
},
)
}
fn print_db_state(ctx: &CommandContext, name: &str, state: &std::sync::Arc<DbState>, level: i64) {
let value = if state.get() { "TRUE" } else { "FALSE" };
if level >= 1 {
ctx.println(&format!(
"id {:p} '{name}' : {value}",
std::sync::Arc::as_ptr(state)
));
} else {
ctx.println(value);
}
}
fn db_state_name_arg() -> ArgDesc {
ArgDesc {
name: "name",
arg_type: ArgType::String,
}
}
fn cmd_db_state_create() -> CommandDef {
CommandDef::new(
"dbStateCreate",
vec![db_state_name_arg()],
"dbStateCreate <name> — Allocate new state name for \"state\" filter.",
|args: &[ArgValue], ctx: &CommandContext| {
let ArgValue::String(name) = &args[0] else {
return Ok(CommandOutcome::Failed);
};
crate::server::database::filters::sync::db_state_registry().get_or_create(name);
let _ = ctx;
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_db_state_set() -> CommandDef {
CommandDef::new(
"dbStateSet",
vec![db_state_name_arg()],
"dbStateSet <name> — Change state to set for \"state\" filter.",
|args: &[ArgValue], ctx: &CommandContext| {
let Some(state) = find_db_state(&args[0]) else {
return Ok(CommandOutcome::Failed);
};
state.set(true);
let _ = ctx;
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_db_state_clear() -> CommandDef {
CommandDef::new(
"dbStateClear",
vec![db_state_name_arg()],
"dbStateClear <name> — Change state to clear for \"state\" filter.",
|args: &[ArgValue], ctx: &CommandContext| {
let Some(state) = find_db_state(&args[0]) else {
return Ok(CommandOutcome::Failed);
};
state.set(false);
let _ = ctx;
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_db_state_show() -> CommandDef {
CommandDef::new(
"dbStateShow",
vec![
db_state_name_arg(),
ArgDesc {
name: "level",
arg_type: ArgType::Int,
},
],
"dbStateShow <name> [level] — Show set/clear status of named state.",
|args: &[ArgValue], ctx: &CommandContext| {
let Some(state) = find_db_state(&args[0]) else {
return Ok(CommandOutcome::Failed);
};
let name = match &args[0] {
ArgValue::String(s) => s.as_str(),
_ => "",
};
let level = match args[1] {
ArgValue::Int(v) => v,
_ => 0,
};
print_db_state(ctx, name, &state, level);
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_db_state_show_all() -> CommandDef {
CommandDef::new(
"dbStateShowAll",
vec![ArgDesc {
name: "level",
arg_type: ArgType::Int,
}],
"dbStateShowAll [level] — Show set/clear status of all named states.",
|args: &[ArgValue], ctx: &CommandContext| {
let level = match args[0] {
ArgValue::Int(v) => v,
_ => 0,
};
for (name, state) in
crate::server::database::filters::sync::db_state_registry().entries()
{
print_db_state(ctx, &name, &state, level + 1);
}
Ok(CommandOutcome::Continue)
},
)
}
fn find_db_state(arg: &ArgValue) -> Option<std::sync::Arc<DbState>> {
let ArgValue::String(name) = arg else {
return None;
};
crate::server::database::filters::sync::db_state_registry().find(name)
}
fn cmd_db_dump_path() -> CommandDef {
CommandDef::new(
"dbDumpPath",
vec![ArgDesc {
name: "pdbbase",
arg_type: ArgType::String,
}],
"dbDumpPath pdbbase — Dump .db/.dbd file search path.",
|args: &[ArgValue], ctx: &CommandContext| {
check_pdbbase(&args[0])?;
let paths = db_loader::loaded_path().unwrap_or_default();
if paths.is_empty() {
ctx.println("no path defined");
return Ok(CommandOutcome::Continue);
}
let joined: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
ctx.println(&joined.join(&db_loader::PATH_LIST_SEPARATOR.to_string()));
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_db_dump_breaktable() -> CommandDef {
CommandDef::new(
"dbDumpBreaktable",
vec![
ArgDesc {
name: "pdbbase",
arg_type: ArgType::String,
},
ArgDesc {
name: "tableName",
arg_type: ArgType::String,
},
],
"dbDumpBreaktable pdbbase [tableName] — Dump the given break table.\n\
If the last argument is missing, dump all breakpoint tables.",
|args: &[ArgValue], ctx: &CommandContext| {
check_pdbbase(&args[0])?;
let wanted = match &args[1] {
ArgValue::String(s) => Some(s.clone()),
_ => None,
};
let registry = ctx.db().breaktable_registry();
for table in registry.tables() {
if let Some(wanted) = &wanted
&& *wanted != table.name
{
continue;
}
ctx.println(&format!("breaktable({}) {{", table.name));
for point in &table.points {
ctx.println(&format!(
"\traw={:.6} slope={} eng={:.6}",
point.raw,
c_exponential(point.slope),
point.eng
));
}
ctx.println("}");
}
Ok(CommandOutcome::Continue)
},
)
}
#[cfg(test)]
mod dbgf_escape_tests {
use super::escape_char_array_for_dbgf;
#[test]
fn printable_ascii_passes_through() {
assert_eq!(escape_char_array_for_dbgf(b"hello"), "hello");
assert_eq!(escape_char_array_for_dbgf(b"a b c"), "a b c");
}
#[test]
fn common_c_escapes() {
assert_eq!(escape_char_array_for_dbgf(b"a\nb"), "a\\nb");
assert_eq!(escape_char_array_for_dbgf(b"a\tb"), "a\\tb");
assert_eq!(escape_char_array_for_dbgf(b"a\rb"), "a\\rb");
assert_eq!(escape_char_array_for_dbgf(b"a\\b"), "a\\\\b");
assert_eq!(escape_char_array_for_dbgf(b"a\"b"), "a\\\"b");
assert_eq!(escape_char_array_for_dbgf(b"a\x07b"), "a\\ab");
assert_eq!(escape_char_array_for_dbgf(b"a\x08b"), "a\\bb");
assert_eq!(escape_char_array_for_dbgf(b"a\x0cb"), "a\\fb");
assert_eq!(escape_char_array_for_dbgf(b"a\x0bb"), "a\\vb");
}
#[test]
fn other_bytes_use_hex_escape() {
assert_eq!(escape_char_array_for_dbgf(&[0x01, 0xff]), "\\x01\\xff");
assert_eq!(escape_char_array_for_dbgf(&[0x7f]), "\\x7f");
assert_eq!(escape_char_array_for_dbgf(&[]), "");
}
}
fn common_field_dbf_type(field: &str) -> Option<crate::types::DbFieldType> {
use crate::types::DbFieldType;
match field {
"SCAN" => Some(DbFieldType::String),
"DTYP" => Some(DbFieldType::String),
"INP" | "OUT" | "FLNK" | "ASG" => Some(DbFieldType::String),
"SEVR" | "STAT" | "PINI" => Some(DbFieldType::Short),
"UDF" | "TPRO" => Some(DbFieldType::Char),
"HIHI" | "HIGH" | "LOW" | "LOLO" => Some(DbFieldType::Double),
"HHSV" | "HSV" | "LSV" | "LLSV" => Some(DbFieldType::Short),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_field_that_resolves_but_cannot_be_read_prints_the_type_and_failed() {
let lines = printbuffer_lines(DbfCode::NoAccess.name(), 1, None);
assert_eq!(lines, vec!["DBF_NOACCESS: failed. ".to_string()]);
assert_eq!(
printbuffer_lines("CHAR", 1, None),
vec!["DBF_CHAR: failed. ".to_string()]
);
}
#[test]
fn the_failed_read_reports_c_s_own_dbget_message() {
assert_eq!(
dbget_bad_dbrtype_message(DbfCode::NoAccess, DbfCode::NoAccess),
"dbGet: dbrType = 17, field_type = DBF_NOACCESS (17)."
);
assert_eq!(
dbget_bad_dbrtype_message(DbfCode::String, DbfCode::Device),
"dbGet: dbrType = 0, field_type = DBF_DEVICE (13)."
);
}
#[test]
fn a_no_access_field_reports_the_width_its_declaration_carries() {
assert_eq!(c_field_size(DbfCode::NoAccess, 8), Some(8));
assert_eq!(c_field_size(DbfCode::NoAccess, 1), Some(1));
assert_eq!(c_field_size(DbfCode::NoAccess, 0), None);
assert_eq!(c_field_size(DbfCode::Inlink, 0), None);
}
#[test]
fn the_link_word_comes_from_the_declaration_not_the_field_name() {
for (record_type, field, word) in [
("fanout", "LNK1", "\tFWDLINK"),
("fanout", "LNK2", "\tFWDLINK"),
("seq", "LNK1", "\tOUTLINK"),
("seq", "DOL1", "\t INLINK"),
("dfanout", "OUTA", "\tOUTLINK"),
("dfanout", "OUTB", "\tOUTLINK"),
("aSub", "OUTA", "\tOUTLINK"),
("aSub", "SUBL", "\t INLINK"),
("ao", "SIOL", "\tOUTLINK"),
("ao", "SIML", "\t INLINK"),
("ai", "SIOL", "\t INLINK"),
("calc", "FLNK", "\tFWDLINK"),
("calc", "INPA", "\t INLINK"),
("ao", "OUT", "\tOUTLINK"),
] {
assert_eq!(
link_field_kind(record_type, field),
Some(word),
"{record_type}.{field}"
);
}
}
#[test]
fn a_field_that_is_not_a_link_has_no_word() {
assert_eq!(link_field_kind("calc", "VAL"), None);
assert_eq!(link_field_kind("calc", "NOSUCH"), None);
assert_eq!(link_field_kind("nosuchrecord", "INP"), None);
}
#[test]
fn the_builtin_table_registers_no_name_twice() {
let mut reg = CommandRegistry::new();
register_builtins(&mut reg);
assert!(
reg.displaced().is_empty(),
"two built-in families claim the same iocsh name(s): {:?}",
reg.displaced()
);
}
use crate::server::database::PvDatabase;
use crate::server::records::ai::AiRecord;
use crate::types::EpicsValue;
use std::sync::Arc;
fn make_ctx() -> (Arc<PvDatabase>, CommandContext) {
let rt = tokio::runtime::Runtime::new().unwrap();
let db = Arc::new(PvDatabase::new());
let bridge = {
let _guard = rt.enter();
crate::runtime::task::BlockingBridge::capture()
};
let ctx = CommandContext::new(db.clone(), bridge);
std::mem::forget(rt);
(db, ctx)
}
fn lock_set_ctx() -> (Arc<PvDatabase>, CommandContext, tempfile::TempDir) {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t9.db");
std::fs::write(
&path,
"record(calc, \"R:A\") { field(INPA, \"R:B\") field(CALC, \"A+1\") field(FLNK, \"R:B\") }\n\
record(calc, \"R:B\") { field(CALC, \"1\") }\n\
record(ai, \"R:C\") { }\n",
)
.unwrap();
run_cmd(&ctx, "dbLoadRecords", &[path.to_str().unwrap()]);
ctx.block_on(db.ioc_init());
(db, ctx, dir)
}
fn notify_ctx() -> (Arc<PvDatabase>, CommandContext, tempfile::TempDir) {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notify.db");
std::fs::write(
&path,
"record(calc, \"N:A\") { field(CALC, \"1\") }\n\
record(calc, \"N:B\") { field(CALC, \"2\") }\n",
)
.unwrap();
run_cmd(&ctx, "dbLoadRecords", &[path.to_str().unwrap()]);
ctx.block_on(db.ioc_init());
(db, ctx, dir)
}
fn install_notify(db: &PvDatabase, record: &str) -> Arc<crate::server::record::NotifyWaitSet> {
let (tx, _rx) = crate::runtime::sync::oneshot::channel();
db.get_record(record)
.unwrap()
.write()
.install_or_queue_notify(tx)
.expect("the slot was free")
}
#[test]
fn db_notify_dump_prints_nothing_on_an_idle_ioc() {
let (_db, ctx, _dir) = notify_ctx();
assert_eq!(run_cmd(&ctx, "dbNotifyDump", &[]), "");
}
#[test]
fn db_notify_dump_prints_the_header_wait_list_and_one_member() {
let (db, ctx, _dir) = notify_ctx();
let _ws = install_notify(&db, "N:A");
let out = run_cmd(&ctx, "dbNotifyDump", &[]);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 3, "{out}");
assert!(lines[0].starts_with("N:A state 4 ppn 0x"), "{}", lines[0]);
assert_eq!(lines[1], " waitList");
assert_eq!(lines[2], " N:A pact 0");
}
#[test]
fn a_settled_wait_set_reports_state_five() {
let (db, ctx, _dir) = notify_ctx();
let ws = install_notify(&db, "N:A");
ws.leave();
assert!(ws.completed());
assert!(
run_cmd(&ctx, "dbNotifyDump", &[])
.lines()
.next()
.unwrap()
.starts_with("N:A state 5 ppn 0x")
);
}
#[test]
fn only_the_entry_record_of_a_chain_heads_a_block() {
let (db, ctx, _dir) = notify_ctx();
let ws = install_notify(&db, "N:A");
db.get_record("N:B")
.unwrap()
.write()
.join_put_notify(Some(&ws));
let out = run_cmd(&ctx, "dbNotifyDump", &[]);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 4, "{out}");
assert!(lines[0].starts_with("N:A state 4 ppn 0x"), "{}", lines[0]);
assert_eq!(lines[1], " waitList");
assert_eq!(lines[2], " N:A pact 0");
assert_eq!(lines[3], " N:B pact 0");
}
#[test]
fn a_joined_member_alone_prints_nothing() {
let (db, ctx, _dir) = notify_ctx();
let ws = install_notify(&db, "N:A");
db.get_record("N:B")
.unwrap()
.write()
.join_put_notify(Some(&ws));
db.get_record("N:A").unwrap().write().notify = None;
assert_eq!(run_cmd(&ctx, "dbNotifyDump", &[]), "");
}
#[test]
fn a_chain_internal_wait_set_heads_no_block() {
let (db, ctx, _dir) = notify_ctx();
let (ws, _rx) = PvDatabase::new_put_notify();
db.get_record("N:A")
.unwrap()
.write()
.join_put_notify(Some(&ws));
assert_eq!(run_cmd(&ctx, "dbNotifyDump", &[]), "");
}
#[test]
fn db_notify_dump_prints_one_restart_line_per_queued_put() {
let (db, ctx, _dir) = notify_ctx();
let _ws = install_notify(&db, "N:A");
{
let rec = db.get_record("N:A").unwrap();
let mut inst = rec.write();
for _ in 0..2 {
let (tx, _rx) = crate::runtime::sync::oneshot::channel();
inst.queue_notify_put(crate::server::record::DeferredNotify::Process {
completion: tx,
});
}
}
let out = run_cmd(&ctx, "dbNotifyDump", &[]);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 6, "{out}");
assert_eq!(lines[3], "N:A restartList");
assert_eq!(lines[4], " N:A");
assert_eq!(lines[5], " N:A");
}
#[derive(Default)]
struct StubCaLset {
links: std::sync::Mutex<HashMap<String, crate::server::database::LinkDiagnostics>>,
entered: std::sync::Mutex<Option<std::sync::mpsc::Sender<()>>>,
}
impl StubCaLset {
fn set(&self, name: &str, d: crate::server::database::LinkDiagnostics) {
self.links.lock().unwrap().insert(name.to_string(), d);
}
fn hold_puts(&self) -> std::sync::mpsc::Receiver<()> {
let (tx, rx) = std::sync::mpsc::channel();
*self.entered.lock().unwrap() = Some(tx);
rx
}
}
#[async_trait::async_trait]
impl crate::server::database::LinkSet for StubCaLset {
fn is_connected(&self, name: &str) -> bool {
self.links
.lock()
.unwrap()
.get(name)
.is_some_and(|d| d.connected)
}
async fn get_value(&self, _name: &str) -> Option<EpicsValue> {
None
}
fn put_admission(&self, name: &str) -> crate::server::database::PutAdmission {
if self.is_connected(name) {
crate::server::database::PutAdmission::Connected
} else {
crate::server::database::PutAdmission::Refused
}
}
async fn put_value(
&self,
_name: &str,
_value: EpicsValue,
_op: crate::server::database::LinkPutOp,
) -> Result<(), String> {
let held = self.entered.lock().unwrap().clone();
match held {
Some(tx) => {
let _ = tx.send(());
std::future::pending::<()>().await;
unreachable!("held forever")
}
None => Ok(()),
}
}
async fn link_diagnostics(
&self,
name: &str,
) -> Option<crate::server::database::LinkDiagnostics> {
self.links.lock().unwrap().get(name).cloned()
}
fn link_names(&self) -> Vec<String> {
self.links.lock().unwrap().keys().cloned().collect()
}
}
fn diag(
connected: bool,
read: bool,
write: bool,
nd: u64,
) -> crate::server::database::LinkDiagnostics {
crate::server::database::LinkDiagnostics {
connected,
host: "ioc1.lab:5064".to_string(),
read_access: read,
write_access: write,
n_disconnect: nd,
input_native: true,
..Default::default()
}
}
fn dbcar_ctx() -> (
Arc<PvDatabase>,
CommandContext,
Arc<StubCaLset>,
tempfile::TempDir,
) {
let rt = tokio::runtime::Runtime::new().unwrap();
let (db, bridge) = {
let _guard = rt.enter();
(
Arc::new(PvDatabase::new()),
crate::runtime::task::BlockingBridge::capture(),
)
};
let ctx = CommandContext::new(db.clone(), bridge);
std::mem::forget(rt);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dbcar.db");
std::fs::write(
&path,
"record(ai, \"L:UP\") { field(INP, \"UP:PV CA\") }\n\
record(ai, \"L:DOWN\") { field(INP, \"DOWN:PV CA\") }\n\
record(ai, \"L:NONE\") { }\n",
)
.unwrap();
run_cmd(&ctx, "dbLoadRecords", &[path.to_str().unwrap()]);
let lset = Arc::new(StubCaLset::default());
ctx.block_on(db.register_link_set("ca", lset.clone()));
ctx.block_on(db.ioc_init());
(db, ctx, lset, dir)
}
#[test]
fn dbcar_on_a_link_free_ioc_prints_only_the_summary() {
let (_db, ctx) = make_ctx();
assert_eq!(
run_cmd(&ctx, "dbcar", &[]),
"CA links in all records\n\
\n\
Total 0 CA links; 0 connected, 0 not connected.\n \
0 can't read, 0 can't write. (0 disconnects, 0 writes prohibited)\n\
\n"
);
}
#[test]
fn one_link_is_singular() {
let (_db, ctx, lset, _dir) = dbcar_ctx();
lset.set("UP:PV", diag(true, true, true, 0));
let out = run_cmd(&ctx, "dbcar", &["L:UP"]);
assert!(
out.contains("Total 1 CA link; 1 connected, 0 not connected."),
"{out}"
);
}
#[test]
fn level_zero_prints_no_link_lines() {
let (_db, ctx, lset, _dir) = dbcar_ctx();
lset.set("UP:PV", diag(true, true, true, 2));
lset.set("DOWN:PV", diag(false, true, true, 5));
let out = run_cmd(&ctx, "dbcar", &[]);
assert!(!out.contains("==>"), "{out}");
assert!(!out.contains("-->"), "{out}");
assert!(
out.contains("Total 2 CA links; 1 connected, 1 not connected."),
"{out}"
);
assert!(
out.contains("(2 disconnects, 0 writes prohibited)"),
"{out}"
);
}
#[test]
fn level_one_prints_only_the_disconnected_links() {
let (_db, ctx, lset, _dir) = dbcar_ctx();
lset.set("UP:PV", diag(true, true, true, 0));
lset.set("DOWN:PV", diag(false, true, true, 5));
let out = run_cmd(&ctx, "dbcar", &["", "1"]);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(
lines[2],
" L:DOWN.INP --> DOWN:PV (5, 0)"
);
assert!(!out.contains("==>"), "{out}");
}
#[test]
fn level_two_prints_the_host_and_rights_line() {
let (_db, ctx, lset, _dir) = dbcar_ctx();
lset.set("UP:PV", diag(true, true, true, 3));
let out = run_cmd(&ctx, "dbcar", &["L:UP", "2"]);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(
lines[2],
" L:UP.INP ==> UP:PV (3, 0)"
);
assert_eq!(
lines[3],
" [IN ] host ioc1.lab:5064, Read/Write"
);
}
#[test]
fn every_access_rights_combination_has_its_word() {
for (read, write, word, no_read, no_write) in [
(false, false, "No Access", 1, 1),
(true, false, "Read Only", 0, 1),
(false, true, "Write Only", 1, 0),
(true, true, "Read/Write", 0, 0),
] {
let (_db, ctx, lset, _dir) = dbcar_ctx();
lset.set("UP:PV", diag(true, read, write, 0));
let out = run_cmd(&ctx, "dbcar", &["L:UP", "2"]);
assert!(
out.contains(&format!("host ioc1.lab:5064, {word}")),
"{out}"
);
assert!(
out.contains(&format!(
" {no_read} can't read, {no_write} can't write."
)),
"{word}: {out}"
);
}
}
#[test]
fn a_never_opened_link_reports_zero_counters() {
let (_db, ctx, _lset, _dir) = dbcar_ctx();
let out = run_cmd(&ctx, "dbcar", &["L:UP", "1"]);
assert!(
out.contains("--> UP:PV (0, 0)"),
"{out}"
);
}
#[test]
fn the_blank_line_before_the_summary_follows_cs_gate() {
let blanks = |out: &str| out.lines().filter(|l| l.is_empty()).count();
let run = |level: &str, down_connected: bool| {
let (_db, ctx, lset, _dir) = dbcar_ctx();
lset.set("UP:PV", diag(true, true, true, 0));
lset.set("DOWN:PV", diag(down_connected, true, true, 0));
blanks(&run_cmd(&ctx, "dbcar", &["", level]))
};
assert_eq!(run("0", true), 2);
assert_eq!(run("0", false), 2);
assert_eq!(run("1", true), 2);
assert_eq!(run("1", false), 3);
assert_eq!(run("2", true), 3);
}
#[test]
fn the_record_argument_selects_c_s_three_ways() {
let (_db, ctx, lset, _dir) = dbcar_ctx();
lset.set("UP:PV", diag(true, true, true, 0));
lset.set("DOWN:PV", diag(true, true, true, 0));
for all in ["", "*"] {
let out = run_cmd(&ctx, "dbcar", &[all]);
assert!(out.starts_with("CA links in all records\n\n"), "{out}");
assert!(out.contains("Total 2 CA links;"), "{out}");
}
let named = run_cmd(&ctx, "dbcar", &["L:UP"]);
assert!(
named.starts_with("CA links in record named 'L:UP'\n\n"),
"{named}"
);
assert!(named.contains("Total 1 CA link;"), "{named}");
let missing = run_cmd(&ctx, "dbcar", &["L:NOSUCH"]);
assert!(missing.contains("Total 0 CA links;"), "{missing}");
}
#[test]
fn an_alias_reports_under_the_records_own_name() {
let (db, ctx, lset, _dir) = dbcar_ctx();
lset.set("UP:PV", diag(true, true, true, 0));
ctx.block_on(db.add_alias("L:ALIAS", "L:UP")).unwrap();
let out = run_cmd(&ctx, "dbcar", &["L:ALIAS", "2"]);
assert!(out.contains("CA links in record named 'L:ALIAS'"), "{out}");
assert!(out.contains("L:UP.INP ==>"), "{out}");
}
#[test]
fn dbcommon_link_fields_print_before_the_record_types_own() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("order.db");
std::fs::write(
&path,
"record(ai, \"O:A\") { field(INP, \"IN:PV CA\") field(FLNK, \"FWD:PV CA\") }\n",
)
.unwrap();
run_cmd(&ctx, "dbLoadRecords", &[path.to_str().unwrap()]);
let lset = Arc::new(StubCaLset::default());
ctx.block_on(db.register_link_set("ca", lset.clone()));
ctx.block_on(db.ioc_init());
lset.set("IN:PV", diag(true, true, true, 0));
lset.set("FWD:PV", diag(true, true, true, 0));
let out = run_cmd(&ctx, "dbcar", &["O:A", "2"]);
let arrows: Vec<&str> = out
.lines()
.filter(|l| l.contains("==>"))
.map(|l| l.split_whitespace().next().unwrap())
.collect();
assert_eq!(arrows, ["O:A.FLNK", "O:A.INP"], "{out}");
}
#[test]
fn a_superseded_out_link_write_is_one_write_prohibited() {
let (db, ctx, lset, _dir) = dbcar_ctx();
lset.set("UP:PV", diag(true, true, true, 0));
let entered = lset.hold_puts();
db.write_external_pv("UP:PV", EpicsValue::Double(1.0), None)
.unwrap();
entered.recv().expect("the first write reached the lset");
db.write_external_pv("UP:PV", EpicsValue::Double(2.0), None)
.unwrap();
assert_eq!(db.external_link_puts_coalesced_for("UP:PV"), 0);
db.write_external_pv("UP:PV", EpicsValue::Double(3.0), None)
.unwrap();
assert_eq!(db.external_link_puts_coalesced_for("UP:PV"), 1);
let out = run_cmd(&ctx, "dbcar", &["L:UP", "2"]);
assert!(
out.contains("==> UP:PV (0, 1)"),
"{out}"
);
assert!(
out.contains("(0 disconnects, 1 writes prohibited)"),
"{out}"
);
}
fn attr_ctx() -> (Arc<PvDatabase>, CommandContext, tempfile::TempDir) {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("attr.db");
std::fs::write(
&path,
"record(ai, \"A:ONE\") { field(DESC, \"one\") }\n\
record(calc, \"A:TWO\") { field(CALC, \"1\") }\n",
)
.unwrap();
run_cmd(&ctx, "dbLoadRecords", &[path.to_str().unwrap()]);
ctx.block_on(db.ioc_init());
(db, ctx, dir)
}
fn attr_of(db: &PvDatabase, pv: &str) -> Option<String> {
match db.get_pv(pv) {
Ok(crate::types::EpicsValue::String(s)) => Some(s.to_string()),
_ => None,
}
}
#[test]
fn every_record_type_starts_with_rtyp_and_vers() {
let (db, ctx, _dir) = attr_ctx();
assert_eq!(attr_of(&db, "A:ONE.RTYP").as_deref(), Some("ai"));
assert_eq!(
attr_of(&db, "A:ONE.VERS").as_deref(),
Some("none specified")
);
assert_eq!(attr_of(&db, "A:TWO.RTYP").as_deref(), Some("calc"));
assert!(
run_cmd(&ctx, "dbgf", &["A:ONE.VERS"]).contains("\"none specified\""),
"the shell path resolves it too"
);
}
#[test]
fn db_put_attribute_rewrites_one_record_type_only() {
let (db, ctx, _dir) = attr_ctx();
let (out, failed) = run_cmd_outcome(&ctx, "dbPutAttribute", &["ai", "VERS", "1.2.3"]);
assert_eq!(out, "", "C prints nothing on success");
assert!(!failed);
assert_eq!(attr_of(&db, "A:ONE.VERS").as_deref(), Some("1.2.3"));
assert_eq!(
attr_of(&db, "A:TWO.VERS").as_deref(),
Some("none specified")
);
}
#[test]
fn db_put_attribute_creates_a_name_no_record_type_declares() {
let (db, ctx, _dir) = attr_ctx();
run_cmd(&ctx, "dbPutAttribute", &["ai", "FOO", "bar"]);
assert_eq!(attr_of(&db, "A:ONE.FOO").as_deref(), Some("bar"));
assert!(
db.get_pv("A:TWO.FOO").is_err(),
"a `calc` gained nothing from an `ai` attribute"
);
}
#[test]
fn db_put_attribute_keeps_thirty_nine_characters() {
let (db, ctx, _dir) = attr_ctx();
run_cmd(
&ctx,
"dbPutAttribute",
&["ai", "LONG", "0123456789012345678901234567890123456789ZZZ"],
);
assert_eq!(
attr_of(&db, "A:ONE.LONG").as_deref(),
Some("012345678901234567890123456789012345678")
);
}
#[test]
fn db_put_attribute_separates_a_missing_argument_from_an_empty_one() {
let (db, ctx, _dir) = attr_ctx();
let (out, failed) = run_cmd_outcome(&ctx, "dbPutAttribute", &["ai", "EMPTY"]);
assert_eq!(out, "");
assert!(!failed, "a missing VALUE is `\"\"`, not an error");
assert_eq!(attr_of(&db, "A:ONE.EMPTY").as_deref(), Some(""));
let (out, failed) = run_cmd_outcome(&ctx, "dbPutAttribute", &["ai"]);
assert_eq!(out, "", "the whole failure is on the errlog stream");
assert!(failed, "a missing NAME is S_db_badField");
let (_, failed) = run_cmd_outcome(&ctx, "dbPutAttribute", &["ai", "", "empty-name"]);
assert!(!failed, "an empty name is a name C accepts");
}
#[test]
fn db_put_attribute_refuses_a_record_type_the_dbd_does_not_declare() {
let (db, ctx, _dir) = attr_ctx();
let (out, failed) = run_cmd_outcome(&ctx, "dbPutAttribute", &["nosuchtype", "VERS", "x"]);
assert_eq!(out, "");
assert!(failed);
assert_eq!(
db.record_type_attributes("nosuchtype"),
Vec::<(String, String)>::new()
);
}
#[test]
fn an_attribute_name_answers_the_search_side_too() {
let (db, ctx, _dir) = attr_ctx();
assert!(ctx.block_on(db.has_name("A:ONE.VERS")));
assert!(!ctx.block_on(db.has_name("A:ONE.NOSUCHATTR")));
run_cmd(&ctx, "dbPutAttribute", &["ai", "NOSUCHATTR", "now-it-is"]);
assert!(ctx.block_on(db.has_name("A:ONE.NOSUCHATTR")));
assert!(
!ctx.block_on(db.has_name("A:TWO.NOSUCHATTR")),
"the attribute belongs to `ai`, not to every type"
);
}
#[test]
fn a_declared_field_shadows_the_attribute_of_the_same_name() {
let (db, ctx, _dir) = attr_ctx();
run_cmd(&ctx, "dbPutAttribute", &["ai", "DESC", "shadowed"]);
assert_eq!(
attr_of(&db, "A:ONE.DESC").as_deref(),
Some("one"),
"the DESC FIELD still answers"
);
assert_eq!(
db.record_type_attribute("ai", "DESC").as_deref(),
Some("shadowed"),
"and the attribute was still written"
);
}
#[test]
fn record_type_attributes_come_back_sorted_by_name() {
let (db, ctx, _dir) = attr_ctx();
run_cmd(&ctx, "dbPutAttribute", &["ai", "BBB", "b"]);
run_cmd(&ctx, "dbPutAttribute", &["ai", "AAA", "a"]);
let names: Vec<String> = db
.record_type_attributes("ai")
.into_iter()
.map(|(k, _)| k)
.collect();
assert_eq!(names, ["AAA", "BBB", "RTYP", "VERS"]);
}
#[test]
fn dblsr_reports_one_set_per_link_component() {
let (_db, ctx, _dir) = lock_set_ctx();
let out = run_cmd(&ctx, "dblsr", &["*", "0"]);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 2, "three records, two lock sets:\n{out}");
for (line, members) in lines.iter().zip([2, 1]) {
let head = line
.split(" epicsMutexId ")
.next()
.expect("split always yields one");
let id: u64 = head.split_whitespace().nth(2).unwrap().parse().unwrap();
assert_eq!(
head,
format!("Lock Set {id} {members} members {members} refs"),
"in {line}"
);
assert!(
line.contains(" epicsMutexId 0x"),
"C prints the set's epicsMutexId with %p: {line}"
);
}
}
#[test]
fn dblsr_levels_add_members_then_their_links() {
let (_db, ctx, _dir) = lock_set_ctx();
let level1 = run_cmd(&ctx, "dblsr", &["*", "1"]);
assert!(level1.contains("\nR:A\nR:B\n"), "{level1}");
assert!(!level1.contains("INPA"), "level 1 stops at the names");
let level2 = run_cmd(&ctx, "dblsr", &["*", "2"]);
let block = level2.split_once("\nR:A\n").expect("R:A heads its set").1;
assert!(
block.starts_with("\tFLNK\tFWDLINK NPP NMS R:B\n\tINPA\t INLINK NPP NMS R:B\nR:B\n"),
"{level2}"
);
}
#[test]
fn dblsr_prints_the_pp_and_ms_modifiers_a_link_carries() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pp.db");
std::fs::write(
&path,
"record(calc, \"P:A\") { field(INPA, \"P:B PP MS\") field(CALC, \"A\") }\n\
record(calc, \"P:B\") { field(CALC, \"1\") }\n",
)
.unwrap();
run_cmd(&ctx, "dbLoadRecords", &[path.to_str().unwrap()]);
ctx.block_on(db.ioc_init());
let out = run_cmd(&ctx, "dblsr", &["*", "2"]);
assert!(out.contains("\tINPA\t INLINK PP MS P:B\n"), "{out}");
}
#[test]
fn dblsr_selects_one_record_s_set_and_reports_a_miss() {
let (_db, ctx, _dir) = lock_set_ctx();
let one = run_cmd(&ctx, "dblsr", &["R:C", "1"]);
assert_eq!(one.lines().count(), 2, "one header, one member:\n{one}");
assert!(one.ends_with("\nR:C\n"), "{one}");
assert_eq!(
run_cmd(&ctx, "dblsr", &["R:NONE", "0"]),
"Record not found\n"
);
}
#[test]
fn db_lock_show_locked_reports_the_counts_both_headers_and_a_row_per_set() {
let (_db, ctx, _dir) = lock_set_ctx();
let out = run_cmd(&ctx, "dbLockShowLocked", &["0"]);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "Active lockSets: 2");
assert_eq!(lines[1], "Free lockSets: 1");
assert_eq!(lines[2], "listTypeScanLock");
assert_eq!(
lines[3], "listTypeRecordLock",
"no set is held, so the first pass is a bare header:\n{out}"
);
assert_eq!(lines.len(), 6, "one row per active set:\n{out}");
for line in &lines[4..] {
assert!(
line.starts_with("epicsMutexId 0x") && line.contains("record_lock.rs line "),
"{line}"
);
}
}
#[test]
fn db_lock_show_locked_counts_nothing_before_ioc_init() {
let (_db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t9.db");
std::fs::write(
&path,
"record(calc, \"R:A\") { field(INPA, \"R:B\") field(CALC, \"A+1\") field(FLNK, \"R:B\") }\n\
record(calc, \"R:B\") { field(CALC, \"1\") }\n\
record(ai, \"R:C\") { }\n",
)
.unwrap();
run_cmd(&ctx, "dbLoadRecords", &[path.to_str().unwrap()]);
let out = run_cmd(&ctx, "dbLockShowLocked", &["0"]);
assert_eq!(
out, "Active lockSets: 0\nFree lockSets: 0\n",
"the sets are built at iocInit, not at dbLoadRecords"
);
assert_eq!(run_cmd(&ctx, "dblsr", &["*", "0"]), "");
}
#[test]
fn a_held_set_shows_in_the_scan_lock_pass() {
let (db, ctx, _dir) = lock_set_ctx();
let holder = db.clone();
let (tx, rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let held = std::thread::spawn(move || {
let _g = holder.lock_record("R:A");
tx.send(()).unwrap();
release_rx.recv().unwrap();
});
rx.recv().unwrap();
let out = run_cmd(&ctx, "dbLockShowLocked", &["0"]);
let scan_pass: Vec<&str> = out
.lines()
.skip_while(|l| *l != "listTypeScanLock")
.skip(1)
.take_while(|l| *l != "listTypeRecordLock")
.collect();
assert_eq!(scan_pass.len(), 1, "exactly the held set:\n{out}");
release_tx.send(()).unwrap();
held.join().unwrap();
}
#[test]
fn db_lock_show_locked_level_1_adds_the_osd_line() {
let (_db, ctx, _dir) = lock_set_ctx();
let out = run_cmd(&ctx, "dbLockShowLocked", &["1"]);
assert_eq!(
out.matches(" uaddr=0x").count(),
2,
"one OSD line per active set:\n{out}"
);
}
#[test]
fn dbior_reports_every_driver_or_only_the_named_one() {
use crate::server::driver_support::{DriverSupport, register_driver_support};
struct Chatty;
impl DriverSupport for Chatty {
fn report(&self, level: i32) -> Option<String> {
Some(format!(" chatty state, level {level}"))
}
}
struct NoReport;
impl DriverSupport for NoReport {
fn report(&self, _level: i32) -> Option<String> {
None
}
}
let (_db, ctx) = make_ctx();
register_driver_support("drvDbiorChatty", Arc::new(Chatty));
register_driver_support("drvDbiorSilent", Arc::new(NoReport));
let all = run_cmd(&ctx, "dbior", &[]);
assert!(
all.contains("Driver: drvDbiorChatty\n chatty state, level 0\n"),
"a driver with a report prints its header then the report: {all}"
);
assert!(
all.contains("Driver: drvDbiorSilent No report available\n"),
"a NULL report slot is one line and no header: {all}"
);
let one = run_cmd(&ctx, "dbior", &["drvDbiorChatty", "3"]);
assert!(
one.contains("Driver: drvDbiorChatty\n chatty state, level 3\n"),
"the interest level reaches report(): {one}"
);
assert!(
!one.contains("drvDbiorSilent"),
"a named driver excludes every other: {one}"
);
assert!(run_cmd(&ctx, "dbior", &["*"]).contains("drvDbiorSilent"));
assert_eq!(run_cmd(&ctx, "dbior", &["drvDbiorNoSuch"]), "");
}
#[test]
fn scanppl_prints_the_periodic_lists_and_only_those() {
use crate::server::record::ScanType;
use std::io::Write;
use std::sync::Mutex;
struct Sink(Arc<Mutex<Vec<u8>>>);
impl Write for Sink {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("TICKER", Box::new(AiRecord::new(1.0)))
.await
.unwrap();
db.add_record("IDLE", Box::new(AiRecord::new(1.0)))
.await
.unwrap();
});
db.get_record("TICKER").unwrap().write().common.scan = ScanType::SEC1;
db.update_scan_index("TICKER", ScanType::Passive, ScanType::SEC1, 0, 0);
db.get_record("IDLE").unwrap().write().common.scan = ScanType::Event;
db.update_scan_index("IDLE", ScanType::Passive, ScanType::Event, 0, 0);
for _ in 0..3 {
db.record_scan_overrun(ScanType::SEC1);
}
ctx.block_on(db.ioc_init());
let buf = Arc::new(Mutex::new(Vec::new()));
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("scanppl").unwrap();
let args = parse_args(&[], &cmd.args).unwrap();
ctx.with_output(Sink(buf.clone()), || {
cmd.handler.call(&args, &ctx).unwrap();
});
let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
assert!(
out.contains("Records with SCAN = '1 second' (3 over-runs):\n"),
"periodic list header carries its over-run count — got:\n{out}"
);
assert!(
out.contains(&format!(" {:<28}\n", "TICKER")),
"record lines are C's ` %-28s` — got:\n{out:?}"
);
assert!(
!out.contains("IDLE"),
"a record on the Event list must not appear in scanppl — got:\n{out}"
);
assert!(
!out.contains("Event"),
"scanppl prints no Event list — got:\n{out}"
);
assert!(
!out.contains("Passive"),
"a Passive record is in no scan list at all — got:\n{out}"
);
assert!(
!out.contains("I/O Intr"),
"the I/O Intr lists are scanpiol's — got:\n{out}"
);
assert_eq!(
out.matches("Records with SCAN").count(),
1,
"only the one non-empty periodic list prints a header — got:\n{out}"
);
db.get_record("IDLE").unwrap().write().common.scan = ScanType::SEC10;
db.update_scan_index("IDLE", ScanType::Event, ScanType::SEC10, 0, 0);
let buf2 = Arc::new(Mutex::new(Vec::new()));
ctx.with_output(Sink(buf2.clone()), || {
cmd.handler.call(&args, &ctx).unwrap();
});
let out2 = String::from_utf8(buf2.lock().unwrap().clone()).unwrap();
let at10 = out2
.find("SCAN = '10 second'")
.unwrap_or_else(|| panic!("no 10 second list — got:\n{out2}"));
let at1 = out2
.find("SCAN = '1 second'")
.unwrap_or_else(|| panic!("no 1 second list — got:\n{out2}"));
assert!(
at10 < at1,
"papPeriodic order is slowest-first — got:\n{out2}"
);
}
#[test]
fn the_scan_reports_wait_for_scan_init() {
use crate::server::record::ScanType;
let (db, ctx) = make_ctx();
ctx.block_on(async {
for name in ["P1", "E1", "IO1"] {
db.add_record(name, Box::new(AiRecord::new(1.0)))
.await
.unwrap();
}
});
for (name, scan, evnt) in [
("P1", ScanType::SEC1, ""),
("E1", ScanType::Event, "alarm"),
("IO1", ScanType::IoIntr, ""),
] {
{
let rec = db.get_record(name).unwrap();
let mut w = rec.write();
w.common.scan = scan;
w.common.evnt = evnt.to_string();
}
db.update_scan_index(name, ScanType::Passive, scan, 0, 0);
}
assert_eq!(
run_cmd_outcome(&ctx, "scanppl", &[]),
(
"scanppl: dbScan subsystem not initialized\n".to_string(),
true
)
);
assert_eq!(
run_cmd_outcome(&ctx, "scanpel", &[]),
(String::new(), false)
);
assert_eq!(
run_cmd_outcome(&ctx, "scanpiol", &[]),
(String::new(), false)
);
ctx.block_on(db.ioc_init());
let (out, failed) = run_cmd_outcome(&ctx, "scanppl", &[]);
assert!(
!failed,
"an initialized scan subsystem does not fail the line"
);
assert!(
out.contains("Records with SCAN = '1 second' (0 over-runs):"),
"the gate must open at iocInit — got:\n{out}"
);
assert!(
run_cmd(&ctx, "scanpel", &[]).contains("Event \"alarm\""),
"so must scanpel's"
);
}
#[test]
fn scanpel_groups_by_event_then_priority_like_c() {
use crate::server::record::ScanType;
let (db, ctx) = make_ctx();
ctx.block_on(async {
for name in ["E_LOW", "E_HIGH", "E_ALARM", "IO_MED"] {
db.add_record(name, Box::new(AiRecord::new(1.0)))
.await
.unwrap();
}
});
for (name, scan, evnt, prio) in [
("E_LOW", ScanType::Event, "5", 0i16),
("E_HIGH", ScanType::Event, " 5 ", 2),
("E_ALARM", ScanType::Event, "alarm", 0),
("IO_MED", ScanType::IoIntr, "", 1),
] {
{
let rec = db.get_record(name).unwrap();
let mut w = rec.write();
w.common.scan = scan;
w.common.evnt = evnt.to_string();
w.common.prio = prio;
}
db.update_scan_index(name, ScanType::Passive, scan, 0, 0);
}
ctx.block_on(db.ioc_init());
let out = run_cmd(&ctx, "scanpel", &[]);
assert!(out.contains("Event \"5\"\n"), "event header — got:\n{out}");
assert!(
out.contains("Event \"alarm\"\n"),
"a non-numeric event name is an event too — got:\n{out}"
);
assert!(
out.contains(&format!(" Priority Low\n {:<28}\n", "E_LOW")),
"PRIO 0 lands in C's Low band, 28-column padded — got:\n{out}"
);
assert!(
out.contains(&format!(" Priority High\n {:<28}\n", "E_HIGH")),
"PRIO 2 lands in C's High band — got:\n{out}"
);
assert!(
!out.contains("Priority Medium"),
"printList prints nothing for an empty band — got:\n{out}"
);
assert!(
!out.contains("IO_MED"),
"an I/O Intr record is not on any event list — got:\n{out}"
);
let globbed = run_cmd(&ctx, "scanpel", &["al*"]);
assert!(globbed.contains("Event \"alarm\""), "got:\n{globbed}");
assert!(!globbed.contains("Event \"5\""), "got:\n{globbed}");
assert_eq!(run_cmd(&ctx, "scanpel", &["nosuch"]), "");
let io = run_cmd(&ctx, "scanpiol", &[]);
assert!(
io.contains(&format!(
"IO Event: Priority Medium\n {:<28}\n",
"IO_MED"
)),
"PRIO 1 lands in C's Medium band — got:\n{io}"
);
assert!(
!io.contains("Priority Low") && !io.contains("Priority High"),
"empty bands print nothing — got:\n{io}"
);
}
#[test]
fn a_record_type_with_no_alarm_slot_dumps_four_nans_on_the_real_families() {
let (db, ctx) = make_ctx();
load_records(&ctx, r#"record(bo, "B:ONE") { }"#).expect("bo must load");
ctx.block_on(db.ioc_init());
let out = run_cmd(&ctx, "gft", &["B:ONE"]);
for line in [
"\nDBR_GR_DOUBLE\t17 3 0\n\t 0.000 0.000 nan nan nan nan",
"\nDBR_GR_FLOAT\t17 3 0\n\t 0.000 0.000 nan nan nan nan",
"\nDBR_CTRL_DOUBLE\t17 3 0\n\t 0.000 0.000 nan nan nan nan 65535.000 0.000",
"\nDBR_GR_SHORT\t17 3 \n\t 0 0 0 0 0 0",
"\nDBR_CTRL_SHORT\t17 3 \n\t 0 0 0 0 0 0 -1 0",
"\nDBR_CTRL_CHAR\t17 3 \n\t 0 0 0 0 0 0 255 0",
] {
assert!(out.contains(line), "missing {line:?} in:\n{out}");
}
}
#[test]
fn an_unprocessed_waveform_dumps_nelm_zeros_not_an_empty_list() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
r#"record(waveform, "V:ONE") { field(FTVL, "DOUBLE") field(NELM, "3") }"#,
)
.expect("waveform must load");
ctx.block_on(db.ioc_init());
let out = run_cmd(&ctx, "gft", &["V:ONE"]);
assert!(out.contains(" No Elements: 3\n"), "got:\n{out}");
for line in [
"\nDBR_SHORT\t\n0 0 0 \n",
"\nDBR_FLOAT\t\n0.0000 0.0000 0.0000 \n",
"\nDBR_ENUM\t\n0 0 0 \n",
"\nDBR_CHAR\t\n0 0 0 \n",
"\nDBR_LONG\t\n0 0 0 \n",
"\nDBR_DOUBLE\t\n0.0000 0.0000 0.0000 \n",
"\nDBR_STS_LONG\t17 3\n0 0 0 \n",
"\nDBR_TIME_DOUBLE\t17 3\tTimeStamp: <undefined>\n0.0000 0.0000 0.0000 \n",
"\n 0 0 0 \n",
"\n0.000000 0.000000 0.000000 \n",
] {
assert!(out.contains(line), "missing {line:?} in:\n{out}");
}
assert!(out.contains("\nDBR_STRING\t\nDBR_SHORT"), "got:\n{out}");
assert!(
!out.contains("\nDBR_STS_LONG\t17 3\tValue:"),
"got:\n{out}"
);
}
#[test]
fn gft_dumps_every_dbr_request_type_the_way_c_does() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("R:AI", Box::new(AiRecord::new(25.5)))
.await
.unwrap();
for (field, value) in [
("EGU", EpicsValue::String("millimetres".into())),
("PREC", EpicsValue::Short(3)),
("HOPR", EpicsValue::Double(100.0)),
("LOPR", EpicsValue::Double(-100.0)),
] {
db.put_pv(&format!("R:AI.{field}"), value).await.unwrap();
}
db.ioc_init().await;
});
let out = run_cmd(&ctx, "gft", &["R:AI"]);
assert!(
out.starts_with(" Record Name: R:AI\nRecord Address: 0x0x"),
"got:\n{out}"
);
assert!(
out.contains(
" Export Type: 6\n Field Address: (none)\n Field Size: 8\n No Elements: 1\n"
),
"got:\n{out}"
);
assert!(out.contains("\nDBR_STRING\t25.500 \n"), "got:\n{out}");
assert!(out.contains("\nDBR_FLOAT\t25.5000 \n"), "got:\n{out}");
assert!(out.contains("\nDBR_DOUBLE\t25.5000 \n"), "got:\n{out}");
assert!(out.contains("\nDBR_CHAR\t25 \n"), "got:\n{out}");
assert!(
out.contains("\nDBR_STS_DOUBLE\t17 3\tValue: 25.5000 \n"),
"got:\n{out}"
);
assert!(
out.contains("\nDBR_GR_SHORT\t17 3 millime\n\t 100 -100 0 0 0 0\tValue: 25 \n"),
"got:\n{out}"
);
assert!(
out.contains("\nDBR_GR_CHAR\t17 3 millime\n\t 100 156 "),
"the char family narrows -100 to 156 — got:\n{out}"
);
assert!(
out.contains("\nDBR_GR_DOUBLE\t17 3 millime 3\n\t 100.000 -100.000 nan nan nan nan\tValue: 25.5000 \n"),
"got:\n{out}"
);
assert!(
out.contains(" 0 0 100 156\tValue: 25 \n"),
"got:\n{out}"
);
assert!(out.contains("\tValue: 25.500000 \n"), "got:\n{out}");
assert!(
out.contains("\n\tDBR_PUT_ACKT Failed\n\tDBR_PUT_ACKS Failed\n"),
"got:\n{out}"
);
assert!(
out.contains("\nDBR_STSACK_STRING\t17 3 1 0 25.500\n"),
"got:\n{out}"
);
assert!(out.ends_with("\nDBR_CLASS_NAME\tai\n"), "got:\n{out}");
}
#[test]
fn gft_skips_the_numeric_types_on_a_string_channel_and_labels_an_enum() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("R:AI", Box::new(AiRecord::new(0.0)))
.await
.unwrap();
db.ioc_init().await;
});
let name = run_cmd(&ctx, "gft", &["R:AI.NAME"]);
assert!(name.contains(" Export Type: 0\n"), "got:\n{name}");
assert!(name.contains(" Field Size: 61\n"), "got:\n{name}");
assert_eq!(
name.lines().filter(|l| l.starts_with("DBR_")).count(),
5,
"only the five STRING request types — got:\n{name}"
);
assert!(name.contains("\nDBR_STRING\tR:AI \n"), "got:\n{name}");
assert!(
!name.contains("DBR_SHORT") && !name.contains("DBR_DOUBLE"),
"got:\n{name}"
);
let scan = run_cmd(&ctx, "gft", &["R:AI.SCAN"]);
assert!(scan.contains(" Export Type: 3\n"), "got:\n{scan}");
assert!(scan.contains("\nDBR_STRING\tPassive \n"), "got:\n{scan}");
assert!(scan.contains("\tValue: Passive\n"), "got:\n{scan}");
assert!(
scan.contains("\tValue: 0\n\t 10\n\tPassive\n\tEvent\n\tI/O Intr\n"),
"got:\n{scan}"
);
}
#[test]
fn gft_reports_a_missing_argument_and_an_unknown_pv_the_way_c_does() {
let (_db, ctx) = make_ctx();
let (out, failed) = run_cmd_outcome(&ctx, "gft", &[]);
assert_eq!(out, "Usage: gft \"pv_name\"\n");
assert!(failed, "C returns -1");
let (out, failed) = run_cmd_outcome(&ctx, "gft", &["NO:SUCH"]);
assert_eq!(out, "Channel couldn't be created\n");
assert!(failed, "C returns 1");
}
#[test]
fn gft_clamps_a_waveform_to_ten_elements_and_wraps_them() {
use crate::server::records::waveform::WaveformRecord;
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record(
"R:WFC",
Box::new(WaveformRecord::new(32, crate::types::DbFieldType::Char)),
)
.await
.unwrap();
db.ioc_init().await;
db.put_record_field_from_ca_no_notify(
"R:WFC",
"VAL",
EpicsValue::CharArray(b"ABCDEFGHIJKLMNO".to_vec()),
)
.await
.unwrap();
});
let out = run_cmd(&ctx, "gft", &["R:WFC"]);
assert!(
out.contains(
" Export Type: 4\n Field Address: (none)\n Field Size: 1\n No Elements: 32\n"
),
"the header carries NELM, unclamped — got:\n{out}"
);
assert!(
out.contains("\nDBR_STRING\t\n65 66 67 68 69 \n70 71 72 73 74 \n"),
"got:\n{out}"
);
assert!(
out.contains("\nDBR_SHORT\t\n65 66 67 68 69 70 71 72 73 74 \n"),
"got:\n{out}"
);
assert!(
out.contains("\nDBR_DOUBLE\t\n65.0000 66.0000 67.0000 68.0000 69.0000 70.0000 71.0000 72.0000 73.0000 74.0000 \n"),
"got:\n{out}"
);
assert!(
out.contains("\nDBR_STS_SHORT\t 0 0\n65 66 "),
"got:\n{out}"
);
assert!(
out.contains("\nDBR_STS_STRING\t 0 0\tValue: 65\n"),
"got:\n{out}"
);
}
#[test]
fn pft_walks_the_whole_ladder_on_a_numeric_field() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("R:AI", Box::new(AiRecord::new(25.5)))
.await
.unwrap();
db.put_pv("R:AI.PREC", EpicsValue::Short(3)).await.unwrap();
db.ioc_init().await;
});
let out = run_cmd(&ctx, "pft", &["R:AI", "12.7"]);
assert!(
out.contains(
" Export Type: 6\n Field Address: (none)\n Field Size: 8\n No Elements: 1\n"
),
"the header is `gft`'s — got:\n{out}"
);
assert!(
out.contains(concat!(
"DBR_STRING\t12.700 \n",
"DBR_SHORT\t12 \n",
"DBR_LONG\t12 \n",
"DBR_FLOAT\t12.7000 \n",
"DBR_DOUBLE\t12.7000 \n",
"DBR_CHAR\t12 \n",
"DBR_ENUM\t12 \n",
)),
"got:\n{out}"
);
assert!(out.ends_with("DBR_ENUM\t12 \n\n"), "got:\n{out}");
}
#[test]
fn pft_reports_a_refused_put_and_still_reads_the_field_back() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("R:AI", Box::new(AiRecord::new(12.0)))
.await
.unwrap();
db.put_pv("R:AI.PREC", EpicsValue::Short(3)).await.unwrap();
db.ioc_init().await;
});
let out = run_cmd(&ctx, "pft", &["R:AI", "abc"]);
assert!(
out.ends_with(" No Elements: 1\n\n\t failed DBR_STRING\t12.000 \n\n"),
"got:\n{out}"
);
let name = run_cmd(&ctx, "pft", &["R:AI.NAME", "x"]);
assert!(
name.ends_with(
" Field Size: 61\n No Elements: 1\n\n\t failed DBR_STRING\tR:AI \n"
),
"got:\n{name}"
);
}
#[test]
fn pft_stops_after_the_string_rung_on_a_string_or_enum_channel() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("R:AI", Box::new(AiRecord::new(0.0)))
.await
.unwrap();
db.ioc_init().await;
});
let scan = run_cmd(&ctx, "pft", &["R:AI.SCAN", "1"]);
assert!(
scan.contains(" Export Type: 3\n Field Address: (none)\n Field Size: 2\n"),
"got:\n{scan}"
);
assert!(
scan.ends_with(" No Elements: 1\nDBR_STRING\tEvent \n"),
"got:\n{scan}"
);
assert!(
!scan.contains("DBR_SHORT"),
"the ladder stops — got:\n{scan}"
);
let egu = run_cmd(&ctx, "pft", &["R:AI.EGU", "cm"]);
assert!(egu.contains(" Export Type: 0\n"), "got:\n{egu}");
assert!(egu.ends_with("\nDBR_STRING\tcm \n"), "got:\n{egu}");
assert!(!egu.contains("DBR_DOUBLE"), "got:\n{egu}");
}
#[test]
fn pft_puts_one_element_of_a_waveform() {
use crate::server::records::waveform::WaveformRecord;
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record(
"R:WFC",
Box::new(WaveformRecord::new(32, crate::types::DbFieldType::Char)),
)
.await
.unwrap();
db.ioc_init().await;
});
let out = run_cmd(&ctx, "pft", &["R:WFC", "77"]);
assert!(
out.contains(
" Export Type: 4\n Field Address: (none)\n Field Size: 1\n No Elements: 32\n"
),
"got:\n{out}"
);
assert!(
out.ends_with(concat!(
"DBR_STRING\t77 \n",
"DBR_SHORT\t77 \n",
"DBR_LONG\t77 \n",
"DBR_FLOAT\t77.0000 \n",
"DBR_DOUBLE\t77.0000 \n",
"DBR_CHAR\t77 \n",
"DBR_ENUM\t77 \n\n",
)),
"got:\n{out}"
);
}
#[test]
fn pft_reports_a_missing_argument_and_an_unknown_pv_the_way_c_does() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("R:AI", Box::new(AiRecord::new(0.0)))
.await
.unwrap();
db.ioc_init().await;
});
assert_eq!(
run_cmd(&ctx, "pft", &[]),
"Usage: pft \"pv_name\", \"value\"\n"
);
assert_eq!(
run_cmd(&ctx, "pft", &["R:AI"]),
"Usage: pft \"pv_name\", \"value\"\n"
);
assert_eq!(
run_cmd(&ctx, "pft", &["R:NOSUCH", "1"]),
"Channel couldn't be created\n"
);
}
fn capture(ctx: &CommandContext, f: impl FnOnce()) -> String {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
ctx.with_output(std::fs::File::create(&path).unwrap(), f);
std::fs::read_to_string(&path).unwrap()
}
fn run_cmd(ctx: &CommandContext, name: &str, tokens: &[&str]) -> String {
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get(name).unwrap();
let tokens: Vec<String> = tokens.iter().map(|t| t.to_string()).collect();
let args = parse_args(&tokens, &cmd.args).unwrap();
capture(ctx, || {
cmd.handler.call(&args, ctx).unwrap();
})
}
fn run_cmd_outcome(ctx: &CommandContext, name: &str, tokens: &[&str]) -> (String, bool) {
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get(name).unwrap();
let tokens: Vec<String> = tokens.iter().map(|t| t.to_string()).collect();
let args = parse_args(&tokens, &cmd.args).unwrap();
let mut failed = false;
let printed = capture(ctx, || {
failed = matches!(cmd.handler.call(&args, ctx), Ok(CommandOutcome::Failed));
});
(printed, failed)
}
#[test]
fn dba_prints_the_declared_type_not_the_served_one() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
"record(ai, \"R:AI\") { field(DTYP, \"Soft Channel\") field(INP, \"R:LO\") }\n\
record(longout, \"R:LO\") { field(DTYP, \"Soft Channel\") }",
)
.unwrap();
ctx.block_on(db.ioc_init());
let lines = |pv: &str| -> Vec<String> {
run_cmd(&ctx, "dba", &[pv])
.lines()
.map(str::to_string)
.collect()
};
let val = lines("R:AI");
assert_eq!(val[1], " No Elements: 1", "{val:?}");
assert_eq!(val[2], " Record Type: ai", "{val:?}");
assert_eq!(val[3], " Field Type: 10 = DBF_DOUBLE", "{val:?}");
assert_eq!(val[4], " Field Size: 8", "{val:?}");
assert_eq!(val[5], " Special: 0", "{val:?}");
assert_eq!(val[6], "DBR Field Type: 10 = DBR_DOUBLE", "{val:?}");
let inp = lines("R:AI.INP");
assert_eq!(inp[3], " Field Type: 14 = DBF_INLINK", "{inp:?}");
assert_eq!(inp[4], " Field Size: (none)", "{inp:?}");
assert_eq!(inp[5], " Special: 0", "{inp:?}");
assert_eq!(inp[6], "DBR Field Type: 0 = DBR_STRING", "{inp:?}");
let scan = lines("R:AI.SCAN");
assert_eq!(scan[3], " Field Type: 12 = DBF_MENU", "{scan:?}");
assert_eq!(scan[4], " Field Size: 2", "{scan:?}");
assert_eq!(scan[5], " Special: 3", "{scan:?}");
assert_eq!(scan[6], "DBR Field Type: 11 = DBR_ENUM", "{scan:?}");
let name = lines("R:AI.NAME");
assert_eq!(name[3], " Field Type: 0 = DBF_STRING", "{name:?}");
assert_eq!(name[4], " Field Size: 61", "{name:?}");
assert_eq!(name[5], " Special: 1", "{name:?}");
assert_eq!(name[6], "DBR Field Type: 0 = DBR_STRING", "{name:?}");
}
#[test]
fn dbas_pointers_answer_identity_the_way_cs_do() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
"record(ai, \"R:A\") { field(DTYP, \"Soft Channel\") }\n\
record(ai, \"R:B\") { field(DTYP, \"Soft Channel\") }",
)
.unwrap();
ctx.block_on(db.ioc_init());
let head = |pv: &str| {
run_cmd(&ctx, "dba", &[pv])
.lines()
.next()
.unwrap()
.to_string()
};
let a_val = head("R:A");
let a_again = head("R:A.VAL");
let b_val = head("R:B");
assert_eq!(a_val, a_again);
assert_ne!(a_val, b_val, "two records shared a record address");
let desc = |l: &str| l.split("Field Description: ").nth(1).unwrap().to_string();
assert_eq!(desc(&a_val), desc(&b_val));
assert!(a_val.contains("Field Address: (none)"), "{a_val:?}");
}
#[test]
fn the_db_test_commands_fail_the_line_where_c_returns_non_zero() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
"record(longout, \"R:LO\") { field(DTYP, \"Soft Channel\") }",
)
.unwrap();
ctx.block_on(db.ioc_init());
for (cmd, tokens, expected) in [
("dbgf", &[][..], "Usage: dbgf \"pv name\"\n"),
("dbgf", &["R:NOPE"][..], "PV 'R:NOPE' not found\n"),
("dbpf", &[][..], "Usage: dbpf \"pv name\", \"value\"\n"),
("dbpf", &["R:NOPE", "1"][..], "PV 'R:NOPE' not found\n"),
("dbpr", &[][..], "Usage: dbpr \"pv name\", level\n"),
("dbpr", &["R:NOPE"][..], "PV 'R:NOPE' not found\n"),
("dbglob", &[][..], "Usage: dbglob \"pattern\" \"fields\"\n"),
("dbgrep", &[][..], "Usage: dbglob \"pattern\" \"fields\"\n"),
] {
let (printed, failed) = run_cmd_outcome(&ctx, cmd, tokens);
assert_eq!(printed, expected, "{cmd} {tokens:?}");
assert!(failed, "{cmd} {tokens:?} must fail the line");
}
let (printed, failed) = run_cmd_outcome(&ctx, "dbgf", &["R:LO"]);
assert!(!failed, "a resolved dbgf must not fail the line");
assert!(printed.starts_with("DBF_LONG:"), "got {printed:?}");
let (_, failed) = run_cmd_outcome(&ctx, "dbglob", &["R:*"]);
assert!(!failed, "a matching dbglob must not fail the line");
}
#[test]
fn a_refused_dbpf_prints_only_its_read_back_and_fails_the_line() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
"record(ai, \"P:A\") { field(DTYP, \"Soft Channel\") field(INP, \"P:B\") }\n\
record(ai, \"P:B\") { field(VAL, \"3\") }\n",
)
.unwrap();
ctx.block_on(db.ioc_init());
for (pv, value, read_back) in [
("P:A.PREC", "abc", "DBF_SHORT: 0 = 0x0 \n"),
("P:A.RTYP", "x", "DBF_STRING: \"ai\" \n"),
(
"P:A.INP",
"@bogus port",
"DBF_STRING: \"P:B NPP NMS\" \n",
),
] {
let (printed, failed) = run_cmd_outcome(&ctx, "dbpf", &[pv, value]);
assert_eq!(printed, read_back, "dbpf {pv} {value:?}");
assert!(failed, "dbpf {pv} {value:?} must fail the line");
}
}
#[test]
fn a_refused_json_array_answers_on_the_errlog_and_leaves_the_field() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
"record(waveform, \"P:W\") { field(FTVL, \"LONG\") field(NELM, \"4\") }\n",
)
.unwrap();
ctx.block_on(db.ioc_init());
let good = run_cmd(&ctx, "dbpf", &["P:W.VAL", "[1,2]"]);
assert_eq!(good, "DBF_LONG[2]: 1 = 0x1 2 = 0x2 \n");
let buf = CaptureBuf::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(buf.clone())
.with_max_level(tracing::Level::INFO)
.with_target(false)
.without_time()
.finish();
let (printed, failed) = {
let _guard = tracing::subscriber::set_default(subscriber);
run_cmd_outcome(&ctx, "dbpf", &["P:W.VAL", "[1,2,zz]"])
};
assert_eq!(
printed, "",
"C prints the refusal on the errlog, not stdout"
);
assert!(failed, "a refused JSON literal fails the line");
let logged = String::from_utf8_lossy(&buf.0.lock().unwrap()).into_owned();
assert!(
logged.contains("dbConvertJSON: "),
"the converter's own line must survive, got: {logged:?}"
);
assert_eq!(
run_cmd(&ctx, "dbgf", &["P:W.VAL"]),
"DBF_LONG[2]: 1 = 0x1 2 = 0x2 \n"
);
}
#[derive(Clone, Default)]
struct CaptureBuf(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for CaptureBuf {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureBuf {
type Writer = CaptureBuf;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
#[test]
fn astac_on_an_unknown_record_answers_on_errlog_in_c_s_shape() {
let (_db, ctx) = make_ctx();
let buf = CaptureBuf::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(buf.clone())
.with_max_level(tracing::Level::INFO)
.with_target(false)
.without_time()
.finish();
let printed = {
let _guard = tracing::subscriber::set_default(subscriber);
run_cmd(&ctx, "astac", &["NOSUCH:REC", "user", "host"])
};
assert_eq!(printed, "", "C writes this to the errlog, not stdout");
let logged = String::from_utf8_lossy(&buf.0.lock().unwrap()).into_owned();
assert!(
logged.contains("Record Not Found filename=\"")
&& logged.contains("access_commands.rs\" line number=")
&& logged.contains(" dbNameToAddr error"),
"errlog line must carry C's shape with our own location, got: {logged:?}"
);
}
#[test]
fn dbl_sentinel_fields_and_unknown_type_match_c() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("AI_REC", Box::new(AiRecord::new(1.0)))
.await
.unwrap();
db.add_record(
"BO_REC",
Box::new(crate::server::records::bo::BoRecord::new(0)),
)
.await
.unwrap();
});
let starred = run_cmd(&ctx, "dbl", &["*"]);
assert_eq!(
starred,
"AI_REC
BO_REC
",
"got {starred:?}"
);
assert_eq!(
run_cmd(&ctx, "dbl", &[""]),
"AI_REC
BO_REC
"
);
let fields = run_cmd(&ctx, "dbl", &["ai", "VAL recordType"]);
assert_eq!(fields, "AI_REC, \"1\", \"ai\"\n", "got {fields:?}");
let commas = run_cmd(&ctx, "dbl", &["ai", "VAL,recordType"]);
assert_eq!(commas, "AI_REC, \n", "got {commas:?}");
assert_eq!(run_cmd(&ctx, "dbl", &["nosuchtype"]), "No record type\n");
}
#[test]
fn dbglob_line_shape_matches_c() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("SIM:T1", Box::new(AiRecord::new(1.0)))
.await
.unwrap();
db.add_record("SIM:T2", Box::new(AiRecord::new(2.0)))
.await
.unwrap();
db.add_record("OTHER", Box::new(AiRecord::new(3.0)))
.await
.unwrap();
});
let plain = run_cmd(&ctx, "dbglob", &["SIM:*"]);
assert_eq!(plain, "SIM:T1\nSIM:T2\n", "got {plain:?}");
let with_fields = run_cmd(&ctx, "dbglob", &["SIM:*", "VAL recordType"]);
assert_eq!(
with_fields, "SIM:T1, \"1\", \"ai\"\nSIM:T2, \"2\", \"ai\"\n",
"got {with_fields:?}"
);
assert_eq!(
run_cmd(&ctx, "dbglob", &[]),
"Usage: dbglob \"pattern\" \"fields\"\n"
);
assert_eq!(
run_cmd(&ctx, "dbgrep", &[""]),
"Usage: dbglob \"pattern\" \"fields\"\n"
);
}
#[test]
fn dbgf_line_shape_matches_c() {
assert_eq!(
native_readback_lines(DbfCode::Long, &EpicsValue::Long(42)),
vec![format!("DBF_LONG:{}42 = 0x2a ", " ".repeat(11))]
);
assert_eq!(
native_readback_lines(DbfCode::Long, &EpicsValue::Long(-1)),
vec![format!(
"DBF_LONG:{}-1 = 0xffffffff{}",
" ".repeat(11),
" ".repeat(5)
)]
);
assert_eq!(
native_readback_lines(DbfCode::Double, &EpicsValue::Double(25.0)),
vec![format!("DBF_DOUBLE:{}25{}", " ".repeat(9), " ".repeat(8))]
);
assert_eq!(
native_readback_lines(DbfCode::Double, &EpicsValue::Double(1.0 / 3.0))[0].trim_end(),
"DBF_DOUBLE: 0.333333333333"
);
assert_eq!(
native_readback_lines(DbfCode::String, &EpicsValue::String("hi\"there".into()))[0]
.trim_end(),
"DBF_STRING: \"hi\\\"there\""
);
assert_eq!(
native_readback_lines(DbfCode::Int64, &EpicsValue::Int64(-5))[0].trim_end(),
"DBF_INT64: -5 = -0x5"
);
assert_eq!(
native_readback_lines(DbfCode::Int64, &EpicsValue::Int64(i64::MIN))[0].trim_end(),
"DBF_INT64: -9223372036854775808 = -0x8000000000000000"
);
}
#[test]
fn dbgf_reads_the_field_declaration_not_the_stored_variant() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("decl.db");
std::fs::write(
&path,
"record(sel, \"G:S1\") { field(SELM, \"Low Signal\") }\n\
record(ai, \"G:A1\") { field(SCAN, \"1 second\") field(LINR, \"SLOPE\") }\n\
record(bo, \"G:B1\") { field(VAL, \"1\") }\n\
record(bo, \"G:B2\") { field(ZNAM, \"Off\") field(ONAM, \"On\") field(VAL, \"1\") }\n",
)
.unwrap();
run_cmd(&ctx, "dbLoadRecords", &[path.to_str().unwrap()]);
ctx.block_on(db.ioc_init());
for (pv, expected) in [
("G:S1.SELM", "DBF_STRING: \"Low Signal\""),
("G:A1.SCAN", "DBF_STRING: \"1 second\""),
("G:A1.LINR", "DBF_STRING: \"SLOPE\""),
("G:A1.PRIO", "DBF_STRING: \"LOW\""),
("G:A1.DTYP", "DBF_STRING: \"Soft Channel\""),
("G:B1", "DBF_STRING: \"\""),
("G:B2", "DBF_STRING: \"On\""),
] {
assert_eq!(
run_cmd(&ctx, "dbgf", &[pv]).trim_end(),
expected,
"dbgf {pv}"
);
}
let report = run_cmd(&ctx, "dbpr", &["G:A1", "1"]);
for cell in ["LINR: SLOPE", "SCAN: 1 second", "DTYP: Soft Channel"] {
assert!(
report.contains(cell),
"dbpr G:A1 missing {cell:?}: {report}"
);
}
assert!(
run_cmd(&ctx, "dbpr", &["G:B1", "1"]).contains("VAL : 1"),
"dbpr renders a DBF_ENUM VAL as its number"
);
assert!(
run_cmd(&ctx, "dbpr", &["G:S1", "1"]).contains("SELM: Low Signal"),
"dbpr renders a DBF_MENU field as its choice"
);
}
#[test]
fn a_menu_field_has_no_range_whether_or_not_it_carries_its_own_menu() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("range.db");
std::fs::write(
&path,
"record(ai, \"G:A1\") { field(SCAN, \"1 second\") field(LINR, \"SLOPE\") }\n",
)
.unwrap();
run_cmd(&ctx, "dbLoadRecords", &[path.to_str().unwrap()]);
ctx.block_on(db.ioc_init());
let gr_long = |pv: &str| {
let out = run_cmd(&ctx, "gft", &[pv]);
let mut lines = out.lines().skip_while(|l| !l.starts_with("DBR_GR_LONG"));
lines
.next()
.unwrap_or_else(|| panic!("no DBR_GR_LONG in gft {pv}:\n{out}"));
let row = lines
.next()
.unwrap_or_else(|| panic!("DBR_GR_LONG has no payload in gft {pv}:\n{out}"));
let nums: Vec<&str> = row.split_whitespace().collect();
(nums[0].to_string(), nums[1].to_string())
};
for pv in ["G:A1.SCAN", "G:A1.DTYP", "G:A1.LINR"] {
assert_eq!(
gr_long(pv),
("0".to_string(), "0".to_string()),
"{pv} is a DBF_MENU/DBF_DEVICE field: C's switch has no case",
);
}
assert_eq!(
gr_long("G:A1.PHAS"),
("32767".to_string(), "-32768".to_string())
);
assert_eq!(gr_long("G:A1.UDF"), ("255".to_string(), "0".to_string()));
}
#[test]
fn dbpf_puts_an_array_field_in_its_own_dbr_type() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("arr.db");
std::fs::write(
&path,
"record(waveform, \"B:WL\") { field(FTVL, \"LONG\") field(NELM, \"4\") }\n\
record(waveform, \"B:WC\") { field(FTVL, \"CHAR\") field(NELM, \"8\") }\n\
record(waveform, \"B:WD\") { field(FTVL, \"DOUBLE\") field(NELM, \"3\") }\n\
record(waveform, \"B:WS\") { field(FTVL, \"STRING\") field(NELM, \"3\") }\n\
record(ai, \"B:A1\") { }\n",
)
.unwrap();
run_cmd(&ctx, "dbLoadRecords", &[path.to_str().unwrap()]);
ctx.block_on(db.ioc_init());
for (pv, put, expected) in [
("B:WL", "7", "DBF_LONG: 7 = 0x7"),
(
"B:WL",
"[1,2,3]",
"DBF_LONG[3]: 1 = 0x1 2 = 0x2 3 = 0x3",
),
(
"B:WL",
"[1,2,3,4,5]",
"DBF_LONG[4]: 1 = 0x1 2 = 0x2 3 = 0x3 4 = 0x4",
),
("B:WL", "", "DBF_LONG[0]: (empty)"),
("B:WL", "[1.9,2.1]", "DBF_LONG[2]: 1 = 0x1 2 = 0x2"),
("B:WD", "[1.5,2.5]", "DBF_DOUBLE[2]: 1.5 2.5"),
("B:WD", "3", "DBF_DOUBLE: 3"),
("B:A1", "1.25", "DBF_DOUBLE: 1.25"),
] {
assert_eq!(
run_cmd(&ctx, "dbpf", &[pv, put]).trim_end(),
expected,
"dbpf {pv} {put:?}"
);
}
for bad in ["hey", "null", "true", "{}", "[[1]]", "1 2 3", "[\"a\"]"] {
assert_eq!(
run_cmd_outcome(&ctx, "dbpf", &["B:WL", bad]).0,
"",
"dbpf B:WL {bad:?} must print nothing"
);
}
assert_eq!(
run_cmd(&ctx, "dbgf", &["B:WL"]).trim_end(),
"DBF_LONG[2]: 1 = 0x1 2 = 0x2",
"a refused dbpf leaves the field alone"
);
for (put, expected) in [
("65", "DBF_CHAR[3]: \"65\""),
("ABC", "DBF_CHAR[4]: \"ABC\""),
("", "DBF_CHAR: 0 = 0x0"),
] {
assert_eq!(
run_cmd(&ctx, "dbpf", &["B:WC", put]).trim_end(),
expected,
"dbpf B:WC {put:?}"
);
}
assert_eq!(run_cmd_outcome(&ctx, "dbpf", &["B:WS", "hey"]).0, "");
assert_eq!(
run_cmd(&ctx, "dbpf", &["B:WS", "[\"a\",\"bb\"]"]).trim_end(),
"DBF_STRING[2]: \"a\" \"bb\""
);
}
#[test]
fn printbuffer_char_keys_on_no_elements_and_stops_at_the_nul() {
let render = |bytes: Vec<u8>| {
native_readback_lines(DbfCode::Char, &EpicsValue::CharArray(bytes))[0]
.trim_end()
.to_string()
};
assert_eq!(render(vec![b'a', b'b', 0]), "DBF_CHAR[3]: \"ab\"");
assert_eq!(render(vec![b'a', b'b']), "DBF_CHAR[2]: \"ab\"");
assert_eq!(
render(vec![b'a', 0, b'z', b'z']),
"DBF_CHAR[4]: \"a\""
);
assert_eq!(render(vec![0, 0, 0]), "DBF_CHAR[3]:");
assert_eq!(render(vec![0]), "DBF_CHAR: 0 = 0x0");
assert_eq!(render(vec![b'A']), "DBF_CHAR: 65 = 0x41 = \'A\'");
assert_eq!(render(vec![b' ']), "DBF_CHAR: 32 = 0x20 = \' \'");
assert_eq!(render(vec![0xc8]), "DBF_CHAR: -56 = 0xc8");
assert_eq!(
native_readback_lines(DbfCode::Char, &EpicsValue::Char(0xc8))[0].trim_end(),
"DBF_CHAR: -56 = 0xc8"
);
}
#[test]
fn real_to_string_matches_dbgetstring() {
for (value, expected) in [
(0.0, "0"),
(1.5, "1.5"),
(0.1, "0.1"),
(1e7, "10000000"),
(1e6, "1000000"),
(123456789.0, "123456789"),
(-1e7, "-10000000"),
(1e5, "100000"),
(1e-3, "1.0e-03"),
(0.001234, "0.001234"),
(0.01, "0.01"),
(1e-6, "1.0e-06"),
(1e-7, "1.0e-07"),
(1e-15, "1.0e-15"),
(1e-20, "1.0e-20"),
(1e15, "1.0e+15"),
(1e20, "1.0e+20"),
(6.02e23, "6.02e+23"),
(0.000999999, "9.99999e-04"),
(1.2345678901234567e19, "1.23456789012346e+19"),
(f64::MAX, "1.79769313486232e+308"),
(0.5, "0.5"),
(0.6, "0.6"),
(2.5, "2.5"),
(7.5, "7.5"),
(1.05, "1.05"),
(2.675, "2.675"),
(1.0 / 3.0, "0.33333333333333"),
(999999.9, "999999.9"),
(100000.5, "100000.5"),
(99999.99999999999, "100000"),
(9.999999999, "9.999999999"),
(3.14159265358979, "3.14159265358979"),
(123456.789, "123456.789"),
(-1.5, "-1.5"),
(-0.1, "-0.1"),
(f64::NAN, "nan"),
] {
assert_eq!(real_to_string(value, true), expected, "{value}");
}
assert_eq!(real_to_string(1.0 / 3.0, false), "0.333333");
}
#[test]
fn dbgf_missing_name_prints_usage() {
let (_db, ctx) = make_ctx();
assert_eq!(run_cmd(&ctx, "dbgf", &[]), "Usage: dbgf \"pv name\"\n");
}
#[test]
fn glob_match_question_mark_consumes_exactly_one() {
assert!(glob_match("ab?", "abc"));
assert!(!glob_match("ab?", "ab"));
assert!(!glob_match("ab?", "abcd"));
assert!(glob_match("*", "anything"));
assert!(glob_match("a*d", "abcd"));
assert!(!glob_match("a*d", "abce"));
assert!(glob_match("", ""));
assert!(!glob_match("", "x"));
assert!(!glob_match("REC?", "REC"));
assert!(glob_match("REC?", "RECX"));
assert!(!glob_match("REC?", "RECXY"));
assert!(glob_match("REC*", "REC"));
assert!(glob_match("REC*", "RECX"));
assert!(glob_match("REC*", "RECXY"));
}
#[test]
fn registrations_declare_the_c_argument_count() {
let mut reg = CommandRegistry::new();
register_builtins(&mut reg);
for (name, nargs) in [
("dbLoadTemplate", 3), ("dbCreateRecord", 3), ("scanppl", 1), ("dbl", 2), ] {
assert_eq!(
reg.get(name).unwrap().args.len(),
nargs,
"{name} must declare C's argument count"
);
}
}
#[test]
fn db_create_record_takes_the_pdbbase_argument() {
let (db, ctx) = make_ctx();
let mut reg = CommandRegistry::new();
register_builtins(&mut reg);
let cmd = reg.get("dbCreateRecord").unwrap();
let tokens = ["pdbbase", "ai", "SIM:NEW"].map(str::to_string);
let args = parse_args(&tokens, &cmd.args).unwrap();
cmd.handler
.call(&args, &ctx)
.expect("must create the record");
assert!(db.get_record("SIM:NEW").is_some());
let bad = ["ai", "SIM:OTHER", "x"].map(str::to_string);
let args = parse_args(&bad, &cmd.args).unwrap();
let Err(msg) = cmd.handler.call(&args, &ctx) else {
panic!("a non-pdbbase first argument must be refused");
};
assert_eq!(msg, "Expecting 'pdbbase' got 'ai'.");
}
#[test]
fn db_create_record_asks_for_the_name_before_the_type() {
let (db, ctx) = make_ctx();
let mut reg = CommandRegistry::new();
register_builtins(&mut reg);
let cmd = reg.get("dbCreateRecord").unwrap();
let call = |tokens: &[&str]| {
let tokens: Vec<String> = tokens.iter().map(|t| t.to_string()).collect();
let args = parse_args(&tokens, &cmd.args).expect("C accepts a missing argument");
match cmd.handler.call(&args, &ctx) {
Err(msg) => msg,
Ok(_) => panic!("{tokens:?} must fail"),
}
};
assert_eq!(call(&["pdbbase"]), "33554465 Record name is required");
assert_eq!(call(&["pdbbase", "ai"]), "33554465 Record name is required");
assert_eq!(
call(&["pdbbase", "ai", ""]),
"33554465 Record name is required"
);
assert_eq!(call(&[]), "33554465 Record name is required");
assert!(
db.get_record("").is_none(),
"no record may be created for an empty name"
);
}
#[test]
#[serial_test::serial(epics_env)]
fn db_load_template_third_argument_finds_the_subs_file() {
let (db, ctx) = make_ctx();
let subs_dir = tempfile::tempdir().unwrap();
let tpl_dir = tempfile::tempdir().unwrap();
std::fs::write(
tpl_dir.path().join("t.template"),
"record(ai,\"SIM:FROM_ARG\") { }\n",
)
.unwrap();
std::fs::write(
subs_dir.path().join("t.substitutions"),
"file \"t.template\" { { } }\n",
)
.unwrap();
let _guard = DbIncludePath::set(tpl_dir.path());
let mut reg = CommandRegistry::new();
register_builtins(&mut reg);
let cmd = reg.get("dbLoadTemplate").unwrap();
let tokens = [
"t.substitutions".to_string(),
String::new(),
subs_dir.path().display().to_string(),
];
let args = parse_args(&tokens, &cmd.args).unwrap();
cmd.handler
.call(&args, &ctx)
.expect("the third argument must locate the .substitutions file");
assert!(db.get_record("SIM:FROM_ARG").is_some());
}
#[test]
fn test_dbl() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("REC_A", Box::new(AiRecord::new(1.0)))
.await
.unwrap();
db.add_record("REC_B", Box::new(AiRecord::new(2.0)))
.await
.unwrap();
});
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbl").unwrap();
let args = parse_args(&[], &cmd.args).unwrap();
let result = cmd.handler.call(&args, &ctx);
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_dbgf() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("TEMP", Box::new(AiRecord::new(25.0)))
.await
.unwrap();
db.ioc_init().await;
});
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbgf").unwrap();
let tokens = vec!["TEMP".to_string()];
let args = parse_args(&tokens, &cmd.args).unwrap();
let result = cmd.handler.call(&args, &ctx);
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
fn dbl_lists_records_type_major_in_load_order() {
use crate::server::records::bo::BoRecord;
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("Z:A1", Box::new(AiRecord::new(0.0)))
.await
.unwrap();
db.add_record("A:B1", Box::new(BoRecord::new(0)))
.await
.unwrap();
db.add_record("M:A2", Box::new(AiRecord::new(0.0)))
.await
.unwrap();
});
assert_eq!(run_cmd(&ctx, "dbl", &[]), "Z:A1\nM:A2\nA:B1\n");
assert_eq!(run_cmd(&ctx, "dbl", &["ai"]), "Z:A1\nM:A2\n");
}
#[test]
fn an_unknown_pv_is_reported_on_stdout_by_every_db_command() {
let (_db, ctx) = make_ctx();
assert_eq!(
run_cmd(&ctx, "dbgf", &["NONEXISTENT"]),
"PV 'NONEXISTENT' not found\n"
);
assert_eq!(
run_cmd(&ctx, "dbpf", &["NONEXISTENT", "1"]),
"PV 'NONEXISTENT' not found\n"
);
assert_eq!(
run_cmd(&ctx, "dbpr", &["NONEXISTENT"]),
"PV 'NONEXISTENT' not found\n"
);
}
#[test]
fn dbpf_reads_back_through_the_dbgf_printer() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("TEMP", Box::new(AiRecord::new(0.0)))
.await
.unwrap();
db.ioc_init().await;
});
let put = run_cmd(&ctx, "dbpf", &["TEMP", "42"]);
assert_eq!(put, run_cmd(&ctx, "dbgf", &["TEMP"]));
assert_eq!(put.trim_end(), "DBF_DOUBLE: 42");
let prec = run_cmd(&ctx, "dbpf", &["TEMP.PREC", "3"]);
assert_eq!(prec, run_cmd(&ctx, "dbgf", &["TEMP.PREC"]));
assert_eq!(prec.trim_end(), "DBF_SHORT: 3 = 0x3");
let (refused, failed) = run_cmd_outcome(&ctx, "dbpf", &["TEMP.SEVR", "0"]);
assert!(failed, "a refused put fails the line");
assert_eq!(refused, run_cmd(&ctx, "dbgf", &["TEMP.SEVR"]));
}
#[test]
fn a_missing_argument_reaches_c_s_own_diagnostic() {
let (_db, ctx) = make_ctx();
assert_eq!(
run_cmd(&ctx, "dbpr", &[]),
"Usage: dbpr \"pv name\", level\n"
);
assert_eq!(
run_cmd(&ctx, "dbpr", &[""]),
"Usage: dbpr \"pv name\", level\n"
);
assert_eq!(
run_cmd(&ctx, "dbLoadRecords", &[]),
"Usage: dbLoadRecords \"file\", \"subs\"\n"
);
assert!(matches!(
run_capturing(&ctx, "dbLoadRecords", &[]).2,
Ok(CommandOutcome::Failed)
));
assert_eq!(
run_cmd(&ctx, "astac", &[]),
"Usage: astac \"record name\", \"user\", \"host\"\n"
);
assert_eq!(
run_cmd(&ctx, "astac", &["REC", "user"]),
"Usage: astac \"record name\", \"user\", \"host\"\n"
);
assert_eq!(run_cmd(&ctx, "dbLoadTemplate", &[]), "");
assert_eq!(run_cmd(&ctx, "dbLoadTemplate", &[""]), "");
for args in [&[][..], &[""][..]] {
let (_out, err, result) = run_capturing(&ctx, "dbLoadTemplate", args);
assert_eq!(err, "must specify variable substitution file\n");
assert!(
matches!(result, Ok(CommandOutcome::Failed)),
"dbLoadTemplate.y:350-352 returns -1 and dbtoolsIocRegister.c:33-36 sets it as the shell error"
);
}
assert_eq!(
run_cmd(&ctx, "dbglob", &[]),
"Usage: dbglob \"pattern\" \"fields\"\n"
);
assert_eq!(run_cmd(&ctx, "dbgf", &[]), "Usage: dbgf \"pv name\"\n");
}
#[test]
fn dbpf_missing_arguments_print_usage() {
let (_db, ctx) = make_ctx();
assert_eq!(
run_cmd(&ctx, "dbpf", &[]),
"Usage: dbpf \"pv name\", \"value\"\n"
);
assert_eq!(
run_cmd(&ctx, "dbpf", &["TEMP"]),
"Usage: dbpf \"pv name\", \"value\"\n"
);
}
#[test]
fn db_load_records_dtyp_macro_leaves_literal_dtyp_alone() {
use std::io::Write;
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("scaler_shaped.db");
let mut f = std::fs::File::create(&path).unwrap();
write!(
f,
r#"
record(bo, "$(P)_calcEnable") {{
field(DTYP, "Soft Channel")
field(ZNAM, "ENABLE")
}}
record(ai, "$(P)") {{
field(DTYP, "$(DTYP)")
}}
record(bo, "$(P)_calc_ctrl") {{
field(DTYP, "Soft Channel")
field(ONAM, "Cts/sec")
}}
"#
)
.unwrap();
drop(f);
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbLoadRecords").unwrap();
let tokens = vec![
path.to_str().unwrap().to_string(),
"P=SCALER1,DTYP=Scaler-rs".to_string(),
];
let args = parse_args(&tokens, &cmd.args).unwrap();
cmd.handler.call(&args, &ctx).unwrap();
let dtyp_of = |name: &str| -> String {
ctx.block_on(async {
let rec = db.get_record(name).expect("record loaded");
let inst = rec.read();
inst.common.dtyp.clone()
})
};
assert_eq!(dtyp_of("SCALER1_calcEnable"), "Soft Channel");
assert_eq!(dtyp_of("SCALER1_calc_ctrl"), "Soft Channel");
assert_eq!(dtyp_of("SCALER1"), "Scaler-rs");
}
#[test]
fn test_dbpf_and_readback() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("TEMP", Box::new(AiRecord::new(0.0)))
.await
.unwrap();
db.ioc_init().await;
});
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbpf").unwrap();
let tokens = vec!["TEMP".to_string(), "42.0".to_string()];
let args = parse_args(&tokens, &cmd.args).unwrap();
let result = cmd.handler.call(&args, &ctx);
assert!(matches!(result, Ok(CommandOutcome::Continue)));
let val = db.get_pv("TEMP").unwrap();
match val {
EpicsValue::Double(v) => assert!((v - 42.0).abs() < 1e-10),
other => panic!("expected Double(42.0), got {:?}", other),
}
}
#[test]
fn test_dbpf_dtyp_accepts_device_support_name() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("DEV", Box::new(AiRecord::new(0.0)))
.await
.unwrap();
db.ioc_init().await;
});
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbpf").unwrap();
let tokens = vec!["DEV.DTYP".to_string(), "Async Soft Channel".to_string()];
let args = parse_args(&tokens, &cmd.args).unwrap();
let result = cmd.handler.call(&args, &ctx);
if let Err(e) = &result {
panic!("dbpf DEV.DTYP 'Async Soft Channel' should succeed, got Err({e})");
}
assert!(matches!(result, Ok(CommandOutcome::Continue)));
let dtyp = ctx.block_on(async {
let rec = db.get_record("DEV").expect("record present");
let inst = rec.read();
inst.common.dtyp.clone()
});
assert_eq!(dtyp, "Async Soft Channel");
}
#[test]
fn dbpf_menu_label_resolves_against_the_fields_own_menu() {
use crate::server::records::sel::SelRecord;
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("SL", Box::new(SelRecord::default()))
.await
.unwrap();
db.ioc_init().await;
});
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbpf").unwrap();
let put = |label: &str| {
let tokens = vec!["SL.SELM".to_string(), label.to_string()];
let args = parse_args(&tokens, &cmd.args).unwrap();
cmd.handler.call(&args, &ctx)
};
assert!(matches!(put("Specified"), Ok(CommandOutcome::Continue)));
let selm = ctx.block_on(async {
let rec = db.get_record("SL").expect("record present");
let inst = rec.read();
inst.record.get_field("SELM")
});
assert_eq!(
selm,
Some(EpicsValue::Enum(0)),
"selSELM's \"Specified\" is 0; menuFanout's 1 is the cross-menu guess"
);
assert!(matches!(put("High Signal"), Ok(CommandOutcome::Continue)));
let selm = ctx.block_on(async {
let rec = db.get_record("SL").expect("record present");
let inst = rec.read();
inst.record.get_field("SELM")
});
assert_eq!(selm, Some(EpicsValue::Enum(1)));
}
#[test]
fn test_dbpf_dtyp_accepts_contributed_device_support_name() {
use crate::server::record::register_device_menu;
register_device_menu("ai", &["Dbpf Contributed Probe"]);
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("CONTRIB", Box::new(AiRecord::new(0.0)))
.await
.unwrap();
db.ioc_init().await;
});
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbpf").unwrap();
let tokens = vec![
"CONTRIB.DTYP".to_string(),
"Dbpf Contributed Probe".to_string(),
];
let args = parse_args(&tokens, &cmd.args).unwrap();
let result = cmd.handler.call(&args, &ctx);
if let Err(e) = &result {
panic!("dbpf CONTRIB.DTYP 'Dbpf Contributed Probe' should succeed, got Err({e})");
}
assert!(matches!(result, Ok(CommandOutcome::Continue)));
let dtyp = ctx.block_on(async {
let rec = db.get_record("CONTRIB").expect("record present");
let inst = rec.read();
inst.common.dtyp.clone()
});
assert_eq!(dtyp, "Dbpf Contributed Probe");
}
#[test]
fn test_dbpr_levels() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("TEMP", Box::new(AiRecord::new(25.0)))
.await
.unwrap();
});
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
for level in [0, 1, 2] {
let cmd = registry.get("dbpr").unwrap();
let tokens = vec!["TEMP".to_string(), level.to_string()];
let args = parse_args(&tokens, &cmd.args).unwrap();
let result = cmd.handler.call(&args, &ctx);
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
}
fn dbpr_field_names(printed: &str) -> Vec<String> {
printed
.lines()
.flat_map(|line| line.as_bytes().chunks(20))
.filter_map(|cell| {
let cell = String::from_utf8_lossy(cell);
let (name, _) = cell.split_once(':')?;
let name = name.trim_end();
(name.len() <= 4
&& !name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()))
.then(|| name.to_string())
})
.collect()
}
#[test]
fn dbpr_prints_every_field_whose_interest_the_level_covers() {
use crate::server::record::dbd_generated;
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("TEMP", Box::new(AiRecord::new(25.0)))
.await
.unwrap();
});
let mut declared: Vec<(&str, u8)> = dbd_generated::DB_COMMON_FIELDS
.iter()
.chain(dbd_generated::record_fields("ai").unwrap())
.map(|d| (d.name, d.interest))
.collect();
declared.sort();
for level in 0..=4u8 {
let printed = run_cmd(&ctx, "dbpr", &["TEMP", &level.to_string()]);
let seen = dbpr_field_names(&printed);
let seen: Vec<&str> = seen.iter().map(String::as_str).collect();
for (name, interest) in &declared {
if *interest > level {
assert!(
!seen.contains(name),
"level {level} printed {name} (interest {interest})"
);
}
}
for name in ["NAME", "DESC", "ASG", "STAT"] {
assert!(
seen.contains(&name),
"level {level} lost {name}: {printed:?}"
);
}
if level >= 1 {
assert!(seen.contains(&"SCAN"), "level {level}: {printed:?}");
}
}
assert!(
run_cmd(&ctx, "dbpr", &["TEMP", "1"]).contains("SCAN: Passive"),
"{:?}",
run_cmd(&ctx, "dbpr", &["TEMP", "1"])
);
}
fn dbpr_field(printed: &str, field: &str) -> String {
let key = format!("{field:<4}: ");
let idx = printed
.find(&key)
.unwrap_or_else(|| panic!("{field} absent from {printed:?}"));
let rest = &printed[idx + key.len()..];
let end = rest
.find(" ")
.unwrap_or(rest.len())
.min(rest.find('\n').unwrap_or(rest.len()));
rest[..end].to_string()
}
#[test]
fn dbpr_renders_the_no_access_rows_from_the_declaration() {
let (db, ctx) = make_ctx();
load_records(&ctx, r#"record(ai, "N:AI") { field(VAL, "7.0") }"#)
.expect("ai .db must load");
ctx.block_on(db.ioc_init());
let printed = capture(&ctx, || {
dbpr_report(&ctx, "N:AI", 4);
});
assert_eq!(dbpr_field(&printed, "BKPT"), "00", "dbpr N:AI 4 BKPT");
assert_eq!(
dbpr_field(&printed, "TIME"),
"<undefined>",
"dbpr N:AI 4 TIME"
);
}
#[test]
fn a_base_hex_field_renders_hex_in_dbpr_only() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
r#"record(mbbi, "H:I") { field(ZRVL, "10") field(ONVL, "255") field(NOBT, "4") field(SHFT, "2") field(MASK, "15") }"#,
)
.expect("mbbi .db must load");
ctx.block_on(db.ioc_init());
let printed = capture(&ctx, || {
dbpr_report(&ctx, "H:I", 2);
});
for (field, expected) in [
("ZRVL", "0xa"),
("ONVL", "0xff"),
("TWVL", "0x0"),
("FFVL", "0x0"),
("MASK", "15"),
("NOBT", "4"),
("SHFT", "2"),
("RVAL", "0"),
] {
assert_eq!(dbpr_field(&printed, field), expected, "dbpr H:I 2 {field}");
}
assert_eq!(
run_cmd(&ctx, "dbgf", &["H:I.ZRVL"]).trim_end(),
"DBF_ULONG: 10 = 0xa"
);
}
#[test]
fn the_hex_renderer_matches_c_s_two_converters() {
for (value, expected) in [
(EpicsValue::ULong(0), "0x0"),
(EpicsValue::ULong(10), "0xa"),
(EpicsValue::ULong(u32::MAX), "0xffffffff"),
(EpicsValue::Long(-1), "0xffffffff"),
(EpicsValue::Short(-1), "0xffffffff"),
(EpicsValue::Char(0xff), "0xffffffff"),
(EpicsValue::UChar(255), "0xff"),
(EpicsValue::UShort(65535), "0xffff"),
(EpicsValue::Int64(0), "0x0"),
(EpicsValue::Int64(-42), "-0x2a"),
(EpicsValue::Int64(i64::MIN), "-0x8000000000000000"),
(EpicsValue::UInt64(u64::MAX), "0xffffffffffffffff"),
] {
assert_eq!(
super::hex_string(&value).as_deref(),
Some(expected),
"{value:?}"
);
}
assert_eq!(super::hex_string(&EpicsValue::Double(1.5)), None);
assert_eq!(super::hex_string(&EpicsValue::String("x".into())), None);
}
const DBPR_LINK_DB: &str = r#"
record(ai, "L:B") { field(VAL, "1") }
record(ai, "P:CON") { field(INP, "5") }
record(ai, "P:ARR") { field(INP, "[1,2,3]") }
record(ai, "P:LOCAL") { field(INP, "L:B.VAL") }
record(ai, "P:LOCALCP") { field(INP, "L:B.VAL CPP MS") }
record(ai, "P:EXT") { field(INP, "OTHER:PV") }
record(ai, "P:CA") { field(INP, "OTHER:PV CA") }
record(ai, "P:JSON") { field(INP, {"const":[1,2,3]}) }
record(ai, "P:UNSET") { }
record(ai, "P:FL") { field(FLNK, "L:B") }
"#;
#[test]
fn a_link_field_prints_the_type_dbinitlink_resolved_it_to() {
let (db, ctx) = make_ctx();
load_records(&ctx, DBPR_LINK_DB).expect("link .db must load");
ctx.block_on(db.ioc_init());
for (record, field, expected) in [
("P:UNSET", "INP", "CONSTANT"),
("P:CON", "INP", "CONSTANT 5"),
("P:ARR", "INP", "CONSTANT [1,2,3]"),
("P:LOCAL", "INP", "DB_LINK L:B.VAL NPP NMS"),
("P:LOCALCP", "INP", "CA_LINK L:B.VAL CPP MS"),
("P:EXT", "INP", "CA_LINK OTHER:PV NPP NMS"),
("P:CA", "INP", "CA_LINK OTHER:PV CA NMS"),
("P:JSON", "INP", r#"JSON_LINK {"const":[1,2,3]}"#),
("P:FL", "FLNK", "DB_LINK L:B"),
] {
let printed = run_cmd(&ctx, "dbpr", &[record, "1"]);
assert_eq!(
dbpr_field(&printed, field),
expected,
"{record}.{field} in {printed:?}"
);
}
}
#[test]
fn before_ioc_init_a_link_prints_the_word_link_and_its_stored_text() {
let (_db, ctx) = make_ctx();
load_records(&ctx, DBPR_LINK_DB).expect("link .db must load");
for (record, field, expected) in [
("P:LOCAL", "INP", "LINK L:B.VAL"),
("P:CON", "INP", "LINK 5"),
("P:JSON", "INP", r#"LINK {"const":[1,2,3]}"#),
("P:UNSET", "INP", "CONSTANT"),
] {
let printed = run_cmd(&ctx, "dbpr", &[record, "1"]);
assert_eq!(
dbpr_field(&printed, field),
expected,
"{record}.{field} in {printed:?}"
);
}
}
#[test]
fn test_dbl_filter_by_type() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("AI_REC", Box::new(AiRecord::new(1.0)))
.await
.unwrap();
db.add_record(
"BO_REC",
Box::new(crate::server::records::bo::BoRecord::new(0)),
)
.await
.unwrap();
});
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbl").unwrap();
let tokens = vec!["ai".to_string()];
let args = parse_args(&tokens, &cmd.args).unwrap();
let result = cmd.handler.call(&args, &ctx);
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_exit() {
let (_db, ctx) = make_ctx();
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("exit").unwrap();
let args = parse_args(&[], &cmd.args).unwrap();
let result = cmd.handler.call(&args, &ctx);
assert!(matches!(result, Ok(CommandOutcome::Exit)));
}
#[test]
fn test_db_load_records_same_type_duplicate_merges_fields() {
use std::io::Write;
let (db, ctx) = make_ctx();
let tmp = tempfile::Builder::new()
.suffix(".db")
.tempfile()
.expect("tempfile");
writeln!(
tmp.as_file(),
r#"
record(mbbo, "DUP:CM") {{
field(DESC, "first")
field(ZRST, "Mono")
field(ONST, "Bayer")
}}
record(mbbo, "DUP:CM") {{
field(DESC, "second")
field(ZRST, "Mono-Override")
}}
"#
)
.expect("write tempfile");
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbLoadRecords").unwrap();
let args = parse_args(&[tmp.path().to_string_lossy().to_string()], &cmd.args).unwrap();
let result = cmd.handler.call(&args, &ctx);
assert!(
matches!(result, Ok(CommandOutcome::Continue)),
"merge-duplicate must succeed; got Err? {}",
result.is_err()
);
ctx.block_on(async {
let rec = db
.get_record("DUP:CM")
.expect("DUP:CM must be registered exactly once");
let inst = rec.read();
assert_eq!(inst.common.desc, "second", "second block's DESC must win");
assert_eq!(
inst.record.get_field("ZRST"),
Some(crate::types::EpicsValue::String("Mono-Override".into())),
"second block's ZRST must override the first"
);
assert_eq!(
inst.record.get_field("ONST"),
Some(crate::types::EpicsValue::String("Bayer".into())),
"ONST from first block survives (no override)"
);
});
}
#[test]
fn test_db_load_records_merge_repoints_linr_to_new_breaktable() {
use std::io::Write;
let (db, ctx) = make_ctx();
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbLoadRecords").unwrap();
let tmp1 = tempfile::Builder::new()
.suffix(".db")
.tempfile()
.expect("tempfile");
writeln!(
tmp1.as_file(),
r#"record(ao, "BPT:RBK") {{ field(DESC, "first") }}"#
)
.expect("write tempfile 1");
let args = parse_args(&[tmp1.path().to_string_lossy().to_string()], &cmd.args).unwrap();
assert!(matches!(
cmd.handler.call(&args, &ctx),
Ok(CommandOutcome::Continue)
));
let tmp2 = tempfile::Builder::new()
.suffix(".db")
.tempfile()
.expect("tempfile");
writeln!(
tmp2.as_file(),
r#"
breaktable(ramp) {{ 0 0 100 10 300 30 }}
record(ao, "BPT:RBK") {{ field(LINR, "ramp") }}
"#
)
.expect("write tempfile 2");
let args = parse_args(&[tmp2.path().to_string_lossy().to_string()], &cmd.args).unwrap();
assert!(matches!(
cmd.handler.call(&args, &ctx),
Ok(CommandOutcome::Continue)
));
ctx.block_on(async {
let rec = db.get_record("BPT:RBK").expect("BPT:RBK exists");
let mut inst = rec.write();
assert_eq!(
inst.record.get_field("LINR"),
Some(crate::types::EpicsValue::Short(15))
);
inst.record
.put_field("VAL", crate::types::EpicsValue::Double(5.0))
.unwrap();
inst.record.process().unwrap();
assert_eq!(
inst.record.get_field("RVAL"),
Some(crate::types::EpicsValue::Long(50)),
"merge-repointed LINR must linearise through the new table"
);
});
}
#[test]
fn test_db_load_records_later_table_does_not_repoint_resolved_record() {
use std::io::Write;
let (db, ctx) = make_ctx();
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbLoadRecords").unwrap();
let tmp1 = tempfile::Builder::new()
.suffix(".db")
.tempfile()
.expect("tempfile");
writeln!(
tmp1.as_file(),
r#"
breaktable(zebra) {{ 0 0 100 10 }}
record(ai, "A:BPT") {{ field(LINR, "zebra") }}
"#
)
.expect("write tempfile 1");
let args = parse_args(&[tmp1.path().to_string_lossy().to_string()], &cmd.args).unwrap();
assert!(matches!(
cmd.handler.call(&args, &ctx),
Ok(CommandOutcome::Continue)
));
let tmp2 = tempfile::Builder::new()
.suffix(".db")
.tempfile()
.expect("tempfile");
writeln!(tmp2.as_file(), r#"breaktable(alpha) {{ 0 0 100 100 }}"#)
.expect("write tempfile 2");
let args = parse_args(&[tmp2.path().to_string_lossy().to_string()], &cmd.args).unwrap();
assert!(matches!(
cmd.handler.call(&args, &ctx),
Ok(CommandOutcome::Continue)
));
ctx.block_on(async {
let rec = db.get_record("A:BPT").expect("A:BPT exists");
let mut inst = rec.write();
inst.record
.put_field("RVAL", crate::types::EpicsValue::Long(50))
.unwrap();
inst.record.process().unwrap();
assert_eq!(
inst.record.get_field("VAL"),
Some(crate::types::EpicsValue::Double(5.0)),
"resolved record must still convert through zebra, not the later alpha"
);
});
}
#[test]
fn post_event_takes_the_event_name_and_prints_nothing() {
let (db, ctx) = make_ctx();
let mut reg = CommandRegistry::new();
register_builtins(&mut reg);
let cmd = reg.get("postEvent").unwrap();
assert_eq!(cmd.args.len(), 1);
assert!(matches!(cmd.args[0].arg_type, ArgType::String));
assert!(reg.get("post_event").is_none());
load_records(
&ctx,
r#"record(ai, "X:E") { field(SCAN, "Event") field(EVNT, "reset") }"#,
)
.expect("load");
ctx.block_on(db.ioc_init());
let out = run_cmd(&ctx, "postEvent", &["reset"]);
assert_eq!(out, "", "C's postEventCallFunc prints nothing");
}
#[test]
fn record_star_modifies_in_place_and_hash_deletes() {
let (db, ctx) = make_ctx();
load_records(&ctx, r#"record(ai, "X:T") { field(VAL, "5") }"#).expect("first load");
load_records(&ctx, r#"record("*", "X:T") { field(VAL, "90") }"#)
.expect("a '*' block must load");
ctx.block_on(async {
let rec = db.get_record("X:T").expect("X:T");
let inst = rec.read();
assert_eq!(
inst.record.get_field("VAL"),
Some(crate::types::EpicsValue::Double(90.0))
);
});
load_records(&ctx, r##"record("#", "X:T") { }"##).expect("a '#' block must load");
assert!(!exists(&db, &ctx, "X:T"), "'#' deletes the record");
}
#[test]
fn record_star_and_hash_report_a_missing_name_differently() {
let (db, ctx) = make_ctx();
let err = load_records(
&ctx,
r#"
record("*", "NO:SUCH") { field(VAL, "1") }
record(ai, "AFTER:STAR") { }
"#,
)
.expect_err("a '*' miss must fail the call's status");
assert_eq!(err, format!("{ERL_ERROR}: Record 'NO:SUCH' not found"));
assert!(exists(&db, &ctx, "AFTER:STAR"));
load_records(
&ctx,
r##"
record("#", "NO:SUCH") { }
record(ai, "AFTER:HASH") { }
"##,
)
.expect("a '#' miss is only a warning");
assert!(exists(&db, &ctx, "AFTER:HASH"));
}
#[test]
fn test_db_load_records_different_type_duplicate_is_skipped() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("DUP:CM", Box::new(AiRecord::new(0.0)))
.await
.unwrap();
});
let err = load_records(
&ctx,
r#"
record(mbbo, "DUP:CM") { field(ZRST, "Mono") }
record(ai, "SIM:C") { field(VAL, "1") }
"#,
)
.expect_err("the load's status must still be non-zero");
assert_eq!(
err,
format!("{ERL_ERROR}: mbbo record 'DUP:CM' already exists, can't load ai record")
);
assert!(
exists(&db, &ctx, "SIM:C"),
"the record after the duplicate must still load"
);
ctx.block_on(async {
let rec = db.get_record("DUP:CM").expect("DUP:CM");
let inst = rec.read();
assert_eq!(
inst.record.record_type(),
"ai",
"the existing record keeps its own type"
);
});
}
#[test]
fn dbtgf_prints_the_value_in_every_dbr_request_type() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
"record(ai, \"R:SRC\") { field(DTYP, \"Soft Channel\") \
field(VAL, \"3.5\") field(EGU, \"mm\") field(PREC, \"3\") }",
)
.unwrap();
ctx.block_on(db.ioc_init());
let out = run_cmd(&ctx, "dbtgf", &["R:SRC"]);
let value_lines: Vec<&str> = out
.lines()
.skip_while(|l| !l.starts_with("DBF_"))
.map(|l| l.trim_end())
.collect();
assert_eq!(
value_lines,
vec![
"DBF_DOUBLE[0]: (empty)",
"DBF_STRING: \"3.500\"",
"DBF_CHAR: 3 = 0x3",
"DBF_UCHAR: 3 = 0x3",
"DBF_SHORT: 3 = 0x3",
"DBF_USHORT: 3 = 0x3",
"DBF_LONG: 3 = 0x3",
"DBF_ULONG: 3 = 0x3",
"DBF_INT64: 3 = 0x3",
"DBF_UINT64: 3 = 0x3",
"DBF_FLOAT: 3.5",
"DBF_DOUBLE: 3.5",
"DBF_ENUM: 3",
],
"full output:\n{out}"
);
let opts: Vec<&str> = out.lines().take_while(|l| !l.starts_with("DBF_")).collect();
assert_eq!(opts[0], "status = 17, severity = 0");
assert_eq!(opts[1], "units = \"mm\"");
assert_eq!(opts[2], "precision = 3");
assert_eq!(opts[4], "enum strings not returned");
assert!(opts[5].starts_with("grLong: "), "{opts:?}");
assert!(opts[7].starts_with("ctrlLong: "), "{opts:?}");
assert!(opts[9].starts_with("alLong: "), "{opts:?}");
}
#[test]
fn dbtgf_enum_strings_follow_the_address_class() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
"record(bo, \"E:B\") { field(ZNAM, \"Off\") field(ONAM, \"On\") }\n\
record(mbbo, \"E:M\") { }\n",
)
.unwrap();
ctx.block_on(db.ioc_init());
let strs = |pv: &str| -> String {
run_cmd(&ctx, "dbtgf", &[pv])
.lines()
.find(|l| l.starts_with("no_strs") || l.starts_with("enum strings"))
.unwrap_or_default()
.to_string()
};
assert_eq!(strs("E:B"), "no_strs = 2:");
assert_eq!(strs("E:M"), "enum strings not returned");
}
#[test]
fn the_dbt_commands_refuse_before_ioc_init() {
for (cmd, tokens) in [
("dbgf", &["R:SRC"][..]),
("dbpf", &["R:SRC", "1.5"][..]),
("dbtgf", &["R:SRC"][..]),
("dbtpf", &["R:SRC", "1"][..]),
("dbtr", &["R:SRC"][..]),
] {
let (_db, ctx) = make_ctx();
load_records(&ctx, "record(ai, \"R:SRC\") { field(VAL, \"3.5\") }").unwrap();
let (out, failed) = run_cmd_outcome(&ctx, cmd, tokens);
assert_eq!(out, format!("{cmd} only works after iocInit\n"));
assert!(failed, "{cmd} must fail the line");
}
let (_db, ctx) = make_ctx();
load_records(&ctx, "record(ai, \"R:SRC\") { field(VAL, \"3.5\") }").unwrap();
let (out, failed) = run_cmd_outcome(&ctx, "dbpr", &["R:SRC"]);
assert!(!failed, "dbpr is ungated before iocInit");
assert!(out.contains("VAL : 3.5"), "full output:\n{out}");
}
#[test]
fn dbtpf_puts_the_text_as_every_dbr_request_type() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
"record(ai, \"R:SRC\") { field(DTYP, \"Soft Channel\") field(VAL, \"3.5\") }",
)
.unwrap();
ctx.block_on(db.ioc_init());
let out = run_cmd(&ctx, "dbtpf", &["R:SRC", "9.25"]);
let lines: Vec<&str> = out.lines().map(|l| l.trim_end()).collect();
assert_eq!(
lines,
vec![
"Put as DBR_STRING Ok, result as DBF_DOUBLE: 9.25",
"Cvt to DBR_CHAR failed.",
"Cvt to DBR_UCHAR failed.",
"Cvt to DBR_SHORT failed.",
"Cvt to DBR_USHORT failed.",
"Cvt to DBR_LONG failed.",
"Cvt to DBR_ULONG failed.",
"Cvt to DBR_INT64 failed.",
"Cvt to DBR_UINT64 failed.",
"Put as DBR_FLOAT Ok, result as DBF_DOUBLE: 9.25",
"Put as DBR_DOUBLE Ok, result as DBF_DOUBLE: 9.25",
"Cvt to DBR_ENUM failed.",
],
"full output:\n{out}"
);
}
#[test]
fn dbtr_processes_then_prints_at_level_three() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
"record(ai, \"R:SRC\") { field(DTYP, \"Soft Channel\") field(VAL, \"3.5\") }",
)
.unwrap();
ctx.block_on(db.ioc_init());
let printed = run_cmd(&ctx, "dbtr", &["R:SRC"]);
let at_level_3 = run_cmd(&ctx, "dbpr", &["R:SRC", "3"]);
assert_eq!(
dbpr_field_names(&printed),
dbpr_field_names(&at_level_3),
"dbtr prints dbpr's level-3 field set\n{printed}"
);
assert!(
printed.contains("UDF : 0"),
"the record was processed:\n{printed}"
);
}
#[test]
fn dbtr_reports_a_missing_name_and_an_unknown_pv() {
let (_db, ctx) = make_ctx();
assert_eq!(run_cmd(&ctx, "dbtr", &[]), "Usage: dbtr \"pv name\"\n");
assert_eq!(run_cmd(&ctx, "dbtr", &["NOPE"]), "PV 'NOPE' not found\n");
assert_eq!(run_cmd(&ctx, "dbtgf", &[]), "Usage: dbtgf \"pv name\"\n");
assert_eq!(
run_cmd(&ctx, "dbtpf", &["R:SRC"]),
"Usage: dbtpf \"pv name\", \"value\"\n"
);
}
#[test]
fn an_empty_array_keeps_empty_on_the_header_s_tab_stop() {
assert_eq!(
printbuffer_lines("DOUBLE", 0, Some(&[])),
vec!["DBF_DOUBLE[0]: (empty) "]
);
}
#[test]
fn dbjlr_reports_every_records_json_links() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
"record(ai, \"R:SRC\") { field(DTYP, \"Soft Channel\") field(VAL, \"3.5\") } \
record(ai, \"R:JLINK\") { field(DTYP, \"Soft Channel\") field(INP, {\"const\": 4.25}) } \
record(calc, \"R:CALC\") { field(INPA, \"R:SRC CP\") field(CALC, \"A*2\") } \
record(longout, \"R:LO\") { field(DTYP, \"Soft Channel\") }",
)
.unwrap();
ctx.block_on(db.ioc_init());
let out = run_cmd(&ctx, "dbjlr", &[]);
assert_eq!(
out.lines().collect::<Vec<_>>(),
vec![
"JSON links in all records",
"",
" ai record 'R:SRC':",
" ai record 'R:JLINK':",
" Link field 'INP':",
" 'const': double 4.25",
" calc record 'R:CALC':",
" longout record 'R:LO':",
],
"full output:\n{out}"
);
assert_eq!(
run_cmd(&ctx, "dbjlr", &["R:JLINK"])
.lines()
.collect::<Vec<_>>(),
vec![
"JSON links in record 'R:JLINK'",
"",
" ai record 'R:JLINK':",
" Link field 'INP':",
" 'const': double 4.25",
]
);
assert_eq!(
run_cmd(&ctx, "dbjlr", &["R:NOPE"]),
"JSON links in record 'R:NOPE'\n\n"
);
}
#[test]
fn const_link_report_names_the_json_type_it_parsed() {
assert_eq!(
const_link_report_lines("4.25", 0, 6),
vec![" 'const': double 4.25"]
);
assert_eq!(
const_link_report_lines("4", 0, 6),
vec![" 'const': integer 4"]
);
assert_eq!(
const_link_report_lines("\"hi\"", 0, 6),
vec![" 'const': string \"hi\""]
);
assert_eq!(
const_link_report_lines("[1, 2, 3]", 0, 6),
vec![" 'const': array of 3 integers"]
);
assert_eq!(
const_link_report_lines("[1, 2, 3]", 2, 6),
vec![" 'const': array of 3 integers", " [1, 2, 3]"]
);
assert_eq!(
const_link_report_lines("[1.5]", 2, 6),
vec![" 'const': array of 1 double", " [1.5]"]
);
}
#[test]
fn dbel_lists_a_records_event_subscriptions() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
"record(ai, \"R:SRC\") { field(DTYP, \"Soft Channel\") field(VAL, \"3.5\") } \
record(longout, \"R:LO\") { field(DTYP, \"Soft Channel\") }",
)
.unwrap();
ctx.block_on(db.ioc_init());
let reader = db
.get_record("R:SRC")
.unwrap()
.write()
.add_subscriber(
"VAL",
1,
crate::types::DbFieldType::Double,
(crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::ALARM)
.bits(),
)
.unwrap();
assert_eq!(
run_cmd(&ctx, "dbel", &["R:SRC"]),
"1 PV Event Subscriptions ( monitors ).\n"
);
assert_eq!(
run_cmd(&ctx, "dbel", &["R:SRC", "1"]),
"1 PV Event Subscriptions ( monitors ).\n VAL { VALUE ALARM }\n"
);
assert_eq!(
run_cmd(&ctx, "dbel", &["R:SRC", "2"]),
"1 PV Event Subscriptions ( monitors ).\n VAL { VALUE ALARM }, queue empty\n",
"C's level-2 line carries a `thread=` token the port has no thread for"
);
assert_eq!(
run_cmd(&ctx, "dbel", &["R:LO"]),
"\"R:LO\": No PV event subscriptions ( monitors ).\n"
);
assert_eq!(
run_cmd(&ctx, "dbel", &["R:LO.VAL"]),
"\"R:LO.VAL\": No PV event subscriptions ( monitors ).\n"
);
assert_eq!(run_cmd(&ctx, "dbel", &["R:NOPE"]), "");
assert_eq!(run_cmd(&ctx, "dbel", &[]), "");
drop(reader);
}
#[test]
fn dbel_level_3_reports_the_queue_counters() {
use crate::server::event_queue::QueReport;
use crate::server::recgbl::EventMask;
let mask = (EventMask::VALUE | EventMask::LOG).bits();
let idle = QueReport {
npend: 0,
ring_space: 144,
ring_size: 144,
nreplace: 0,
latest_only: false,
n_duplicates: 0,
};
assert_eq!(
dbel_subscription_lines("VAL", mask, 3, &idle),
vec![" VAL { VALUE LOG }, queue empty"]
);
let busy = QueReport {
npend: 2,
ring_space: 140,
ring_size: 144,
nreplace: 7,
latest_only: true,
n_duplicates: 3,
};
assert_eq!(
dbel_subscription_lines("SEVR", mask, 3, &busy),
vec![
"SEVR { VALUE LOG } undelivered=2, unused entries=140, \
discarded by replacement=7, queueing disabled, duplicate count =3",
"",
]
);
assert_eq!(
dbel_subscription_lines("PROC", EventMask::PROPERTY.bits(), 0, &idle),
vec!["PROC { PROPERTY }"]
);
let full = QueReport {
ring_space: 0,
..idle
};
assert_eq!(
dbel_subscription_lines("A", EventMask::NONE.bits(), 2, &full),
vec![" A { }, queue full"]
);
}
#[test]
fn dbtpn_puts_processes_and_reads_back_as_dbr_string() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
"record(ai, \"R:SRC\") { field(DTYP, \"Soft Channel\") field(VAL, \"3.5\") \
field(EGU, \"mm\") field(PREC, \"3\") } \
record(longout, \"R:LO\") { field(DTYP, \"Soft Channel\") }",
)
.unwrap();
ctx.block_on(db.ioc_init());
assert_eq!(
ctx.block_on(dbtpn_lines(&db, "R:LO", Some("7".to_string()))),
vec!["dbtpnCallback: success record=R:LO"]
);
assert_eq!(db.get_pv("R:LO").unwrap(), EpicsValue::Long(7));
assert_eq!(
ctx.block_on(dbtpn_lines(&db, "R:SRC", None)),
vec![
"dbtpn:getCallback value 3.500",
"dbtpnCallback: success record=R:SRC",
],
"C reads the channel back as DBR_STRING, which honours PREC"
);
assert_eq!(
run_cmd(&ctx, "dbtpn", &["R:NOPE", "1"]),
"dbtpn: No such channel\n"
);
assert_eq!(
run_cmd(&ctx, "dbtpn", &["R:SRC.NOSUCH", "1"]),
"dbtpn: No such channel\n",
"C `dbChannelCreate` resolves the FIELD too"
);
assert_eq!(
run_cmd(&ctx, "dbtpn", &[]),
"Usage: dbtpn \"name\", \"value\"\n"
);
}
#[test]
fn tpn_puts_and_processes_then_names_the_record_in_its_callback() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
"record(longout, \"R:LO\") { field(DTYP, \"Soft Channel\") }",
)
.unwrap();
ctx.block_on(db.ioc_init());
assert_eq!(
ctx.block_on(tpn_lines(&db, "R:LO", "7".to_string())),
vec!["tpnCallback 'R:LO': Success"]
);
assert_eq!(db.get_pv("R:LO").unwrap(), EpicsValue::Long(7));
assert_eq!(
ctx.block_on(tpn_lines(&db, "R:LO.VAL", "9".to_string())),
vec!["tpnCallback 'R:LO': Success"]
);
assert_eq!(
run_cmd(&ctx, "tpn", &["R:NOPE", "1"]),
"Channel couldn't be created\n"
);
assert_eq!(
run_cmd(&ctx, "tpn", &["R:LO.NOSUCH", "1"]),
"Channel couldn't be created\n",
"C `dbChannel_create` resolves the FIELD too"
);
assert_eq!(
run_cmd(&ctx, "tpn", &["R:LO"]),
"Usage: tpn \"pv_name\", \"value\"\n",
"C requires BOTH arguments; dbtpn does not"
);
assert_eq!(
run_cmd(&ctx, "tpn", &[]),
"Usage: tpn \"pv_name\", \"value\"\n"
);
}
fn load_records(ctx: &CommandContext, body: &str) -> Result<(), String> {
use std::io::Write;
let tmp = tempfile::Builder::new().suffix(".db").tempfile().unwrap();
write!(tmp.as_file(), "{body}").unwrap();
let mut faults = db_loader::DbFaults::default();
match db_read_database(ctx, &tmp.path().to_string_lossy(), "", "", &mut faults) {
Ok(_) => Ok(()),
Err(DbReadFailure::Rejected) => Err(faults
.first_diagnostic()
.expect("a rejected read reported at least one diagnostic")),
Err(DbReadFailure::CannotOpen) => Err(format!(
"{ERL_ERROR}: Can't open file '{}'",
tmp.path().display()
)),
Err(DbReadFailure::AfterIocInit) => Err(format!(
"{ERL_ERROR}: Failed to load '{}'\n Records cannot be loaded after iocInit!",
tmp.path().display()
)),
}
}
fn create_record(ctx: &CommandContext, name: &str) -> Result<(), String> {
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbCreateRecord").unwrap();
let args = parse_args(
&["pdbbase".to_string(), "ai".to_string(), name.to_string()],
&cmd.args,
)
.unwrap();
cmd.handler.call(&args, ctx).map(|_| ())
}
fn exists(db: &PvDatabase, ctx: &CommandContext, name: &str) -> bool {
ctx.block_on(async { db.get_record(name).is_some() })
}
#[test]
#[serial_test::serial(epics_env)]
fn db_load_records_does_not_search_the_path_list_for_a_name_with_a_separator() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let _path = DbIncludePath::set(dir.path());
std::fs::create_dir(dir.path().join("sub")).unwrap();
write_file(
&dir.path().join("sub"),
"sep.db",
r#"record(ai, "SEP") { }"#,
);
let (_out, err, result) = run_capturing(&ctx, "dbLoadRecords", &["sub/sep.db"]);
assert!(
matches!(result, Ok(CommandOutcome::Failed)),
"a name with a separator must not be searched on the path list"
);
assert!(err.contains("sub/sep.db"), "got: {err}");
assert!(!exists(&db, &ctx, "SEP"));
write_file(dir.path(), "bare.db", r#"record(ai, "BARE") { }"#);
let (_out, _err, result) = run_capturing(&ctx, "dbLoadRecords", &["bare.db"]);
assert!(
matches!(result, Ok(CommandOutcome::Continue)),
"bare name on the list"
);
assert!(exists(&db, &ctx, "BARE"));
}
#[test]
#[serial_test::serial(epics_env)]
fn db_load_records_alias_resolves_against_an_earlier_load() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let _path = DbIncludePath::set(dir.path());
write_file(
dir.path(),
"a.db",
"record(ai, \"BASE:TEMP\") { field(DESC, \"t\") }\n",
);
write_file(
dir.path(),
"b.db",
"alias(\"BASE:TEMP\", \"OLD:TEMP\")\nrecord(ai, \"B:X\") { }\n",
);
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbLoadRecords").unwrap();
for name in ["a.db", "b.db"] {
let args = parse_args(&[name.to_string()], &cmd.args).unwrap();
cmd.handler.call(&args, &ctx).unwrap_or_else(|e| {
panic!("{name}: {e}");
});
}
assert!(exists(&db, &ctx, "BASE:TEMP"));
assert!(exists(&db, &ctx, "B:X"), "b.db's own record must load");
assert_eq!(db.resolve_alias("OLD:TEMP").as_deref(), Some("BASE:TEMP"));
}
#[test]
#[serial_test::serial(epics_env)]
fn db_load_records_unknown_alias_target_fails_status_but_keeps_records() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let _path = DbIncludePath::set(dir.path());
write_file(
dir.path(),
"c.db",
"alias(\"NOPE\", \"BAD\")\nrecord(ai, \"C:Y\") { }\n",
);
let mut faults = db_loader::DbFaults::default();
let Err(DbReadFailure::Rejected) = db_read_database(&ctx, "c.db", "", "", &mut faults)
else {
panic!("an unknown alias target must fail the call's status");
};
let err = faults.first_diagnostic().unwrap();
assert!(err.contains("names an unknown record 'NOPE'"), "got: {err}");
assert!(exists(&db, &ctx, "C:Y"), "the record that parsed must stay");
assert!(db.resolve_alias("BAD").is_none());
}
#[test]
#[serial_test::serial(epics_env)]
fn an_unopenable_include_keeps_the_records_around_it() {
let (db, ctx) = make_ctx();
let err = load_records(
&ctx,
r#"
record(ai, "SIM:A") { field(VAL, "1") }
include "missing.db"
record(ai, "SIM:B") { field(VAL, "2") }
"#,
)
.expect_err("the load's status must still be non-zero");
assert_eq!(
err,
format!("{ERL_ERROR}: Can't open include file 'missing.db'")
);
assert!(exists(&db, &ctx, "SIM:A"));
assert!(exists(&db, &ctx, "SIM:B"));
}
#[test]
fn record_creation_before_ioc_init_is_allowed() {
let (db, ctx) = make_ctx();
load_records(&ctx, r#"record(ai, "PRE") { field(VAL, "1") }"#).expect("pre-init load");
create_record(&ctx, "PRE2").expect("pre-init dbCreateRecord");
assert!(exists(&db, &ctx, "PRE"));
assert!(exists(&db, &ctx, "PRE2"));
}
#[test]
fn db_load_records_after_ioc_init_is_refused_and_creates_nothing() {
let (db, ctx) = make_ctx();
load_records(&ctx, r#"record(ai, "CO") { field(VAL, "1") }"#).expect("pre-init load");
ctx.block_on(db.ioc_init());
let err = load_records(
&ctx,
r#"
record(ai, "LATER") { field(VAL, "1") }
record(ai, "LATER2") { field(VAL, "2") }
"#,
)
.expect_err("C refuses a .db read once the IOC is running");
assert!(
err.contains("Records cannot be loaded after iocInit!"),
"expected C's dbLoadRecords diagnostic; got {err}"
);
assert!(!exists(&db, &ctx, "LATER"), "no record may be created");
assert!(!exists(&db, &ctx, "LATER2"));
assert!(exists(&db, &ctx, "CO"), "the loaded database is untouched");
}
#[test]
fn db_create_record_after_ioc_init_is_refused_and_creates_nothing() {
let (db, ctx) = make_ctx();
load_records(&ctx, r#"record(ai, "CO") { field(VAL, "1") }"#).expect("pre-init load");
ctx.block_on(db.ioc_init());
let err = create_record(&ctx, "NEWREC").expect_err("C refuses dbCreateRecord once running");
assert_eq!(
err,
"33554463 IOC already initialized - No new records can be added"
);
assert!(!exists(&db, &ctx, "NEWREC"));
}
#[test]
fn test_help_registered() {
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let names = registry.list();
assert!(names.contains(&"help"));
assert!(names.contains(&"dbl"));
assert!(names.contains(&"dbgf"));
assert!(names.contains(&"dbpf"));
assert!(names.contains(&"dbpr"));
assert!(names.contains(&"dbLoadRecords"));
assert!(names.contains(&"epicsEnvSet"));
assert!(names.contains(&"exit"));
}
#[test]
fn test_parse_macro_string() {
let macros = parse_macro_string("P=IOC:,R=TEMP");
assert_eq!(macros.get("P").unwrap(), "IOC:");
assert_eq!(macros.get("R").unwrap(), "TEMP");
let empty = parse_macro_string("");
assert!(empty.is_empty());
}
#[test]
fn parse_macro_string_keeps_values_raw_for_the_expander() {
use crate::server::db_loader::{MacroExpandOptions, expand_macros};
fn expand(m: &HashMap<String, String>, name: &str) -> String {
expand_macros(&format!("$({name})"), m, MacroExpandOptions::default()).text
}
let m = parse_macro_string(r#"DESC="a,b",P=IOC:"#);
assert_eq!(m.get("DESC").unwrap(), r#""a,b""#);
assert_eq!(expand(&m, "DESC"), "a,b");
assert_eq!(m.get("P").unwrap(), "IOC:");
let m = parse_macro_string(r#"DESC=a\,b,P=IOC:"#);
assert_eq!(m.get("DESC").unwrap(), r#"a\,b"#);
assert_eq!(expand(&m, "DESC"), "a,b");
let m = parse_macro_string(r#"B=a\\b"#);
assert_eq!(m.get("B").unwrap(), r#"a\\b"#);
assert_eq!(expand(&m, "B"), r#"a\b"#);
let m = parse_macro_string(r#" P = IOC: , "R" = TEMP "#);
assert_eq!(m.get("P").unwrap(), "IOC:");
assert_eq!(m.get("R").unwrap(), "TEMP");
let m = parse_macro_string(r#"MSG="a b c""#);
assert_eq!(m.get("MSG").unwrap(), r#""a b c""#);
assert_eq!(expand(&m, "MSG"), "a b c");
let m = parse_macro_string("A=1,DROP,B=2");
assert_eq!(m.get("A").unwrap(), "1");
assert_eq!(m.get("B").unwrap(), "2");
assert!(!m.contains_key("DROP"));
}
#[test]
fn r17_66_db_load_records_runs_the_post_init_udf_tail() {
use std::io::Write;
let (db, ctx) = make_ctx();
let tmp = tempfile::Builder::new()
.suffix(".db")
.tempfile()
.expect("tempfile");
write!(
tmp.as_file(),
r#"
record(histogram, "HG") {{ field(NELM, "8") field(SVL, "0") }}
record(mbboDirect, "MBD") {{ field(B0, "1") field(B2, "1") }}
record(mbboDirect, "MBD0") {{ }}
"#
)
.expect("write tempfile");
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbLoadRecords").unwrap();
let args = parse_args(&[tmp.path().to_string_lossy().to_string()], &cmd.args).unwrap();
assert!(matches!(
cmd.handler.call(&args, &ctx),
Ok(CommandOutcome::Continue)
));
ctx.block_on(async {
db.ioc_init().await;
let udf = |name: &'static str| {
let db = db.clone();
async move { db.get_record(name).unwrap().read().common.udf != 0 }
};
assert!(
!udf("HG").await,
"histogram: `clear_histogram` clears UDF at init (softIoc: UDF 0)"
);
assert!(
!udf("MBD").await,
"mbboDirect: the B0..B1F fold clears UDF at init (softIoc: UDF 0)"
);
assert_eq!(
db.get_record("MBD").unwrap().read().record.get_field("VAL"),
Some(crate::types::EpicsValue::Long(5)),
"B0|B2 folds into VAL=5"
);
assert!(
udf("MBD0").await,
"a bare mbboDirect has no DOL and no bits: C leaves it UDF=1"
);
});
}
fn write_file(dir: &std::path::Path, name: &str, body: &str) -> std::path::PathBuf {
use std::io::Write;
let p = dir.join(name);
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(body.as_bytes()).unwrap();
p
}
fn run_capturing(
ctx: &CommandContext,
name: &str,
tokens: &[&str],
) -> (String, String, CommandResult) {
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get(name).unwrap();
let tokens: Vec<String> = tokens.iter().map(|t| t.to_string()).collect();
let args = parse_args(&tokens, &cmd.args).unwrap();
let err_file = tempfile::NamedTempFile::new().unwrap();
let err_path = err_file.path().to_path_buf();
let mut result = Ok(CommandOutcome::Continue);
let out = capture(ctx, || {
ctx.with_error(std::fs::File::create(&err_path).unwrap(), || {
result = cmd.handler.call(&args, ctx);
});
});
(out, std::fs::read_to_string(&err_path).unwrap(), result)
}
#[test]
fn a_missing_epics_env_set_argument_is_the_bodys_own_unframed_sentence() {
let (_db, ctx) = make_ctx();
let (out, err, result) = run_capturing(&ctx, "epicsEnvSet", &[]);
assert_eq!(err, "Missing environment variable name argument.\n");
assert_eq!(out, "", "the refusal is a diagnostic, not output");
assert!(
matches!(result, Ok(CommandOutcome::Failed)),
"C's `iocshSetError(-1)` fails the line without a diagnostic \
of the shell's own, which `Err` would frame"
);
let (_out, err, result) = run_capturing(&ctx, "epicsEnvSet", &["ONLYONE"]);
assert_eq!(err, "Missing environment variable value argument.\n");
assert!(matches!(result, Ok(CommandOutcome::Failed)));
}
const SHIPPED_BPT_TYPE_KDEGC: &str =
concat!(env!("CARGO_MANIFEST_DIR"), "/dbd/bptTypeKdegC.dbd");
#[test]
fn the_database_command_census_matches_the_registry() {
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let present: std::collections::HashSet<&str> = registry.list().into_iter().collect();
let measured: Vec<&str> = C_DATABASE_COMMANDS
.iter()
.copied()
.filter(|name| !present.contains(name))
.collect();
assert_eq!(measured, ABSENT_DATABASE_COMMANDS);
}
fn once_only_guard() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn the_once_only_var_is_registered_and_reaches_the_loader() {
let _g = once_only_guard();
let (_db, ctx) = make_ctx();
assert_eq!(
run_cmd(&ctx, "var", &["dbRecordsOnceOnly"]).trim(),
"int dbRecordsOnceOnly = 0"
);
assert!(super::super::vars::variable_names().contains(&"dbRecordsOnceOnly"));
run_cmd(&ctx, "var", &["dbRecordsOnceOnly", "1"]);
assert!(db_records_once_only());
run_cmd(&ctx, "var", &["dbRecordsOnceOnly", "-2"]);
assert!(db_records_once_only());
run_cmd(&ctx, "var", &["dbRecordsOnceOnly", "0"]);
assert!(!db_records_once_only());
}
#[test]
fn once_only_refuses_a_second_declaration_and_drops_its_body() {
let _g = once_only_guard();
let (db, ctx) = make_ctx();
run_cmd(&ctx, "var", &["dbRecordsOnceOnly", "1"]);
let err = load_records(
&ctx,
r#"record(ai, "D:ONE") { field(DESC, "first") }
record(ai, "D:ONE") { field(DESC, "second") field(EGU, "V") }"#,
)
.expect_err("the load reports the duplicate");
run_cmd(&ctx, "var", &["dbRecordsOnceOnly", "0"]);
assert_eq!(
err,
format!(
"{ERL_ERROR}: Record 'D:ONE' already defined; dbRecordsOnceOnly is set,\n \
so can't modify record."
)
);
assert_eq!(db.get_pv("D:ONE.DESC").unwrap().to_string(), "first");
assert_eq!(db.get_pv("D:ONE.EGU").unwrap().to_string(), "");
}
#[test]
fn the_default_merges_the_second_declaration() {
let _g = once_only_guard();
let (db, ctx) = make_ctx();
run_cmd(&ctx, "var", &["dbRecordsOnceOnly", "0"]);
load_records(
&ctx,
r#"record(ai, "D:ONE") { field(DESC, "first") }
record(ai, "D:ONE") { field(DESC, "second") field(EGU, "V") }"#,
)
.expect("the default merges");
assert_eq!(db.get_pv("D:ONE.DESC").unwrap().to_string(), "second");
assert_eq!(db.get_pv("D:ONE.EGU").unwrap().to_string(), "V");
}
#[test]
fn once_only_does_not_reach_a_star_modify_block() {
let _g = once_only_guard();
let (db, ctx) = make_ctx();
run_cmd(&ctx, "var", &["dbRecordsOnceOnly", "0"]);
load_records(&ctx, r#"record(ai, "D:ONE") { field(DESC, "first") }"#).expect("load");
run_cmd(&ctx, "var", &["dbRecordsOnceOnly", "1"]);
let outcome = load_records(&ctx, r#"record("*", "D:ONE") { field(DESC, "edited") }"#);
run_cmd(&ctx, "var", &["dbRecordsOnceOnly", "0"]);
outcome.expect("a modify block is exempt");
assert_eq!(db.get_pv("D:ONE.DESC").unwrap().to_string(), "edited");
}
#[test]
fn a_repeat_alias_is_silent_by_default_and_an_error_once_only() {
let _g = once_only_guard();
let (db, ctx) = make_ctx();
run_cmd(&ctx, "var", &["dbRecordsOnceOnly", "0"]);
load_records(
&ctx,
r#"record(ai, "D:TWO") { field(DESC, "t") }
alias("D:TWO", "D:ALIAS")
alias("D:TWO", "D:ALIAS")"#,
)
.expect("the default accepts the repeat");
assert_eq!(db.get_pv("D:ALIAS.DESC").unwrap().to_string(), "t");
run_cmd(&ctx, "var", &["dbRecordsOnceOnly", "1"]);
let err = load_records(&ctx, r#"alias("D:TWO", "D:ALIAS")"#).expect_err("once-only");
run_cmd(&ctx, "var", &["dbRecordsOnceOnly", "0"]);
assert_eq!(
err,
format!("{ERL_ERROR}: Alias 'D:ALIAS' already defined; dbRecordsOnceOnly is set.")
);
assert_eq!(db.get_pv("D:ALIAS.DESC").unwrap().to_string(), "t");
}
#[test]
fn an_alias_naming_a_different_record_is_still_rejected() {
let _g = once_only_guard();
let (_db, ctx) = make_ctx();
run_cmd(&ctx, "var", &["dbRecordsOnceOnly", "0"]);
load_records(
&ctx,
r#"record(ai, "D:ONE") { }
record(ai, "D:TWO") { }
alias("D:ONE", "D:ALIAS")
alias("D:TWO", "D:ALIAS")"#,
)
.expect_err("one name cannot alias two records");
}
#[test]
fn a_ca_read_of_a_link_field_gets_cs_rendering() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
r#"record(calc, "L:B") { field(CALC, "1") }
record(ai, "A:BARE") { field(INP, "L:B.VAL") }
record(ao, "A:TWO") { field(OUT, "L:B.VAL PP MSS") field(FLNK, "L:B PP MS") }
record(ao, "A:CAO") { field(OUT, "OTHER:PV CA") }"#,
)
.expect("load");
ctx.block_on(db.ioc_init());
for (pv, want) in [
("A:BARE.INP", "L:B.VAL NPP NMS"),
("A:TWO.OUT", "L:B.VAL PP MSS"),
("A:TWO.FLNK", "L:B"),
("A:CAO.OUT", "OTHER:PV CA NMS"),
("L:B.FLNK", ""),
] {
assert_eq!(db.get_pv(pv).unwrap().to_string(), want, "{pv}");
}
}
#[test]
fn dbpf_on_a_link_field_reads_back_the_parsed_link() {
let (db, ctx) = make_ctx();
load_records(
&ctx,
r#"record(ai, "L:A") { field(VAL, "3.5") }
record(ai, "L:B") { }
record(calc, "C:ONE") { field(INPA, "L:A NPP NMS") field(CALC, "A+1") }"#,
)
.expect("load");
ctx.block_on(db.ioc_init());
let out = run_cmd(&ctx, "dbpf", &["C:ONE.INPA", "L:B"]);
assert_eq!(out, "DBF_STRING: \"L:B NPP NMS\" \n");
let out = run_cmd(&ctx, "dbgf", &["C:ONE.INPA"]);
assert_eq!(out, "DBF_STRING: \"L:B NPP NMS\" \n");
let out = run_cmd(&ctx, "dbpf", &["C:ONE.FLNK", "L:A"]);
assert_eq!(out, "DBF_STRING: \"L:A\" \n");
}
#[test]
fn every_port_only_command_is_registered_and_is_not_a_c_name() {
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let present: std::collections::HashSet<&str> = registry.list().into_iter().collect();
for (name, _) in PORT_ONLY_COMMANDS {
assert!(
present.contains(name),
"{name} is documented port-only but not registered"
);
assert!(
!C_DATABASE_COMMANDS.contains(name),
"{name} IS a dbIocRegister.c name, so it is not port-only"
);
}
}
#[test]
fn db_load_database_is_registered() {
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
assert!(registry.list().contains(&"dbLoadDatabase"));
}
#[test]
fn db_load_database_without_a_file_prints_usage_and_fails() {
let (_db, ctx) = make_ctx();
let (out, err, result) = run_capturing(&ctx, "dbLoadDatabase", &[]);
assert_eq!(out, "Usage: dbLoadDatabase \"file\", \"path\", \"subs\"\n");
assert_eq!(err, "");
assert!(matches!(result, Ok(CommandOutcome::Failed)));
}
#[test]
fn db_load_database_installs_records_and_breaktables_from_a_dbd() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
write_file(
dir.path(),
"tcurve.dbd",
"breaktable(typeTdegC) {\n 0.0 0.0\n 100.0 10.0\n 200.0 30.0\n}\n\
record(ai, \"R:FROMDBD\") {\n field(DESC, \"declared in a .dbd\")\n}\n",
);
let (out, err, result) = run_capturing(
&ctx,
"dbLoadDatabase",
&["tcurve.dbd", &dir.path().display().to_string()],
);
assert_eq!(out, "", "C prints nothing when the load worked");
assert_eq!(err, "");
assert!(matches!(result, Ok(CommandOutcome::Continue)));
assert!(
exists(&db, &ctx, "R:FROMDBD"),
"a .dbd record(...) installs"
);
let desc = ctx.block_on(async {
let rec = db.get_record("R:FROMDBD").unwrap();
let r = rec.read();
r.common.desc.to_string()
});
assert_eq!(desc, "declared in a .dbd");
load_records(
&ctx,
r#"
record(ai, "R:LIN") {
field(DTYP, "Raw Soft Channel")
field(LINR, "typeTdegC")
field(PREC, "3")
field(INP, "0")
}
record(ai, "R:NOLIN") {
field(DTYP, "Raw Soft Channel")
field(LINR, "typeSdegC")
field(PREC, "3")
field(INP, "0")
}
"#,
)
.expect("the .db must load");
ctx.block_on(async { db.ioc_init().await });
run_cmd(&ctx, "dbpf", &["R:LIN.RVAL", "150"]);
assert_eq!(ai_val(&db, &ctx, "R:LIN"), 20.0, "typeTdegC converts 150");
run_cmd(&ctx, "dbpf", &["R:NOLIN.RVAL", "150"]);
assert_eq!(
ai_val(&db, &ctx, "R:NOLIN"),
150.0,
"typeSdegC names no loaded table, so the raw value stands"
);
}
#[test]
fn a_file_that_cannot_be_opened_reports_through_the_shared_read() {
let (_db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let empty = dir.path().display().to_string();
let (out, err, result) = run_capturing(&ctx, "dbLoadDatabase", &["nosuch.dbd", &empty]);
assert_eq!(out, "");
assert_eq!(err, format!("{ERL_ERROR}: Can't open file 'nosuch.dbd'\n"));
assert!(matches!(result, Ok(CommandOutcome::Failed)));
let missing = dir.path().join("nosuch.db").display().to_string();
let (out, err, result) = run_capturing(&ctx, "dbLoadRecords", &[&missing]);
assert_eq!(out, "");
assert_eq!(
err,
format!(
"{ERL_ERROR}: Can't open file '{missing}'\n\
{ERL_ERROR}: Failed to load '{missing}'\n"
)
);
assert!(matches!(result, Ok(CommandOutcome::Failed)));
}
#[test]
fn a_refused_menu_field_is_worded_like_c_wherever_the_field_lives() {
let (db, ctx) = make_ctx();
let err = load_records(&ctx, r#"record(sel, "S1") { field(SELM, "Bogus") }"#)
.expect_err("C refuses selSELM (measured on softIoc @R7.0.10)");
assert_eq!(
err,
format!(
"{ERL_ERROR}: Can't set 'S1.SELM' to 'Bogus' using menu selSELM : Illegal choice"
)
);
assert!(exists(&db, &ctx, "S1"), "C keeps the record");
let err = load_records(&ctx, r#"record(ai, "L1") { field(LINR, "NoSuchTable") }"#)
.expect_err("C refuses menuConvert");
assert_eq!(
err,
format!(
"{ERL_ERROR}: Can't set 'L1.LINR' to 'NoSuchTable' using menu menuConvert : Illegal choice"
)
);
assert!(exists(&db, &ctx, "L1"));
}
#[test]
fn one_refused_field_does_not_discard_the_records_after_it() {
let (db, ctx) = make_ctx();
let err = load_records(
&ctx,
r#"
record(ai, "R1") { field(SCAN, "Passive") }
record(ai, "R2") { field(SCAN, "Passiv") }
record(ai, "R3") { field(PINI, "YESS") }
record(ai, "R4") { field(SCAN, "1 second") }
"#,
)
.expect_err("the load's status goes non-zero at the END");
assert!(err.contains("R2.SCAN"), "first refusal reported; got {err}");
assert!(
err.contains("(+1 more)"),
"R3's was reported too; got {err}"
);
for name in ["R1", "R2", "R3", "R4"] {
assert!(exists(&db, &ctx, name), "C's dbl lists {name}");
}
}
#[test]
fn a_successful_load_prints_nothing() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let _path = DbIncludePath::set(dir.path());
write_file(dir.path(), "quiet.db", "record(ai, \"Q1\") { }\n");
let (out, err, result) = run_capturing(&ctx, "dbLoadRecords", &["quiet.db"]);
assert_eq!(out, "", "C prints nothing on the success path");
assert_eq!(err, "");
assert!(matches!(result, Ok(CommandOutcome::Continue)));
assert!(exists(&db, &ctx, "Q1"), "the record still loaded");
}
#[test]
fn a_successful_template_load_prints_nothing() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let _path = DbIncludePath::set(dir.path());
write_file(dir.path(), "row.db", "record(ai, \"$(N)\") { }\n");
write_file(
dir.path(),
"quiet.substitutions",
"file row.db { pattern { N }\n{ \"T1\" }\n{ \"T2\" }\n}\n",
);
let (out, err, result) = run_capturing(&ctx, "dbLoadTemplate", &["quiet.substitutions"]);
assert_eq!(out, "");
assert_eq!(err, "");
assert!(matches!(result, Ok(CommandOutcome::Continue)));
assert!(exists(&db, &ctx, "T1") && exists(&db, &ctx, "T2"));
}
#[test]
fn a_rejected_load_is_reported_once() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let _path = DbIncludePath::set(dir.path());
write_file(
dir.path(),
"once.db",
"alias(\"NOPE\", \"BAD\")\nrecord(ai, \"KEPT\") { }\n",
);
let (out, err, result) = run_capturing(&ctx, "dbLoadRecords", &["once.db"]);
assert_eq!(out, "");
assert_eq!(err, format!("{ERL_ERROR}: Failed to load 'once.db'\n"));
assert!(matches!(result, Ok(CommandOutcome::Failed)));
assert!(exists(&db, &ctx, "KEPT"), "the record that parsed stays");
}
#[test]
fn a_load_after_ioc_init_is_silent_for_db_load_database_only() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let dbd = write_file(dir.path(), "late.dbd", "record(ai, \"R:LATE\") { }\n")
.display()
.to_string();
let dbf = write_file(dir.path(), "late.db", "record(ai, \"R:LATE2\") { }\n")
.display()
.to_string();
ctx.block_on(async { db.ioc_init().await });
let (out, err, result) = run_capturing(&ctx, "dbLoadDatabase", &[&dbd]);
assert_eq!(out, "");
assert_eq!(err, "");
assert!(matches!(result, Ok(CommandOutcome::Failed)));
assert!(!exists(&db, &ctx, "R:LATE"), "nothing is created");
let (out, err, result) = run_capturing(&ctx, "dbLoadRecords", &[&dbf]);
assert_eq!(out, "");
assert_eq!(
err,
format!(
"{ERL_ERROR}: Failed to load '{dbf}'\n \
Records cannot be loaded after iocInit!\n"
)
);
assert!(matches!(result, Ok(CommandOutcome::Failed)));
assert!(!exists(&db, &ctx, "R:LATE2"));
}
struct DbIncludePath(Option<std::ffi::OsString>);
impl DbIncludePath {
fn set(dir: &std::path::Path) -> Self {
let prev = std::env::var_os("EPICS_DB_INCLUDE_PATH");
unsafe { std::env::set_var("EPICS_DB_INCLUDE_PATH", dir) };
Self(prev)
}
}
impl Drop for DbIncludePath {
fn drop(&mut self) {
unsafe {
match self.0.take() {
Some(v) => std::env::set_var("EPICS_DB_INCLUDE_PATH", v),
None => std::env::remove_var("EPICS_DB_INCLUDE_PATH"),
}
}
}
}
fn load_template(
ctx: &CommandContext,
sub_file: &std::path::Path,
global_macros: Option<&str>,
) -> Result<(), String> {
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbLoadTemplate").unwrap();
let mut tokens = vec![sub_file.to_string_lossy().to_string()];
if let Some(g) = global_macros {
tokens.push(g.to_string());
}
let args = parse_args(&tokens, &cmd.args).unwrap();
let err_file = tempfile::NamedTempFile::new().unwrap();
let err_path = err_file.path().to_path_buf();
let mut result = Ok(CommandOutcome::Continue);
ctx.with_error(std::fs::File::create(&err_path).unwrap(), || {
result = cmd.handler.call(&args, ctx);
});
match result {
Ok(CommandOutcome::Failed) => Err(std::fs::read_to_string(&err_path).unwrap()),
Ok(_) => Ok(()),
Err(e) => Err(e),
}
}
fn ai_val(db: &PvDatabase, _ctx: &CommandContext, name: &str) -> f64 {
let rec = db
.get_record(name)
.unwrap_or_else(|| panic!("record '{name}' does not exist"));
let r = rec.read();
match r.record.get_field("VAL") {
Some(EpicsValue::Double(d)) => d,
other => panic!("unexpected VAL for '{name}': {other:?}"),
}
}
#[test]
#[serial_test::serial(epics_env)]
fn db_load_template_expands_rows_with_per_row_macros() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let _path = DbIncludePath::set(dir.path());
write_file(
dir.path(),
"a.db",
r#"record(ai, "$(P)AI$(N)") { field(VAL, "$(V)") }"#,
);
let subs = write_file(
dir.path(),
"a.substitutions",
r#"
file "a.db" {
pattern { P, N, V }
{ "IOC:", "1", "1.5" }
{ "IOC:", "2", "2.5" }
}
"#,
);
load_template(&ctx, &subs, None).expect("template load");
assert!(exists(&db, &ctx, "IOC:AI1"), "row 1 expands to IOC:AI1");
assert!(exists(&db, &ctx, "IOC:AI2"), "row 2 expands to IOC:AI2");
assert!(!exists(&db, &ctx, "IOC:AI3"), "only two rows");
assert_eq!(ai_val(&db, &ctx, "IOC:AI1"), 1.5, "row 1 V substituted");
assert_eq!(ai_val(&db, &ctx, "IOC:AI2"), 2.5, "row 2 V substituted");
}
#[test]
#[serial_test::serial(epics_env)]
fn db_load_template_keeps_the_rows_that_already_loaded() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let _path = DbIncludePath::set(dir.path());
write_file(dir.path(), "ok.db", r#"record(ai, "ROW:$(N)") { }"#);
let subs = write_file(
dir.path(),
"partial.substitutions",
r#"
file "ok.db" {
{ N=1 }
}
file "gone.db" {
{ N=2 }
}
file "ok.db" {
{ N=3 }
}
"#,
);
load_template(&ctx, &subs, None).expect_err("the missing template must fail the call");
assert!(
exists(&db, &ctx, "ROW:1"),
"the row before the failure stays"
);
assert!(
!exists(&db, &ctx, "ROW:3"),
"the rows after it are not loaded"
);
}
#[test]
#[serial_test::serial(epics_env)]
fn db_load_template_global_macros_apply_and_row_overrides() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let _path = DbIncludePath::set(dir.path());
write_file(
dir.path(),
"b.db",
r#"record(ai, "$(P)AI$(N)") { field(VAL, "$(V)") }"#,
);
let subs = write_file(
dir.path(),
"b.substitutions",
r#"
file "b.db" {
{ N=1, V=1 }
{ N=2 }
}
"#,
);
load_template(&ctx, &subs, Some("V=9,P=IOC:")).expect("template load");
assert!(exists(&db, &ctx, "IOC:AI1"), "global P reaches row 1");
assert!(exists(&db, &ctx, "IOC:AI2"), "global P reaches row 2");
assert_eq!(
ai_val(&db, &ctx, "IOC:AI1"),
1.0,
"row-level V=1 overrides the global V=9"
);
assert_eq!(
ai_val(&db, &ctx, "IOC:AI2"),
9.0,
"row 2 has no V, so the global V=9 applies"
);
}
#[test]
#[serial_test::serial(epics_env)]
fn db_load_template_parity_with_hand_written_db_load_records() {
let dir = tempfile::tempdir().unwrap();
let _path = DbIncludePath::set(dir.path());
write_file(
dir.path(),
"p.db",
r#"record(ai, "$(P)AI$(N)") { field(VAL, "$(V)") field(EGU, "$(EGU)") }"#,
);
let subs = write_file(
dir.path(),
"p.substitutions",
r#"
file "p.db" {
pattern { P, N, V, EGU }
{ "IOC:", "1", "1.5", "volts" }
{ "IOC:", "2", "2.5", "amps" }
}
"#,
);
let (db_t, ctx_t) = make_ctx();
load_template(&ctx_t, &subs, None).expect("template load");
let (db_r, ctx_r) = make_ctx();
load_records(
&ctx_r,
r#"
record(ai, "IOC:AI1") { field(VAL, "1.5") field(EGU, "volts") }
record(ai, "IOC:AI2") { field(VAL, "2.5") field(EGU, "amps") }
"#,
)
.expect("hand-written load");
for name in ["IOC:AI1", "IOC:AI2"] {
{
let rt = db_t.get_record(name).expect("template record");
let rr = db_r.get_record(name).expect("hand record");
let rt = rt.read();
let rr = rr.read();
assert_eq!(
rt.record.record_type(),
rr.record.record_type(),
"{name}: record type parity"
);
assert_eq!(
rt.record.get_field("VAL"),
rr.record.get_field("VAL"),
"{name}: VAL parity"
);
assert_eq!(
rt.record.get_field("EGU"),
rr.record.get_field("EGU"),
"{name}: EGU parity"
);
assert_eq!(rt.common.udf, rr.common.udf, "{name}: UDF parity");
}
}
}
#[test]
fn db_load_template_file_not_found_errors() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope.substitutions");
let reason = super::super::c_strerror(
&std::fs::read_to_string(&missing).expect_err("the path must not exist"),
);
#[cfg(all(unix, target_env = "gnu"))]
assert_eq!(reason, "No such file or directory");
let err = load_template(&ctx, &missing, None)
.expect_err("a nonexistent substitutions file must error");
assert_eq!(
err,
format!(
"dbLoadTemplate: error opening sub file {}: {reason}\n",
missing.display()
)
);
assert!(!exists(&db, &ctx, "IOC:AI1"), "nothing is created on error");
}
#[test]
fn db_load_template_malformed_substitutions_errors() {
let (_db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let subs = write_file(dir.path(), "bad.substitutions", "{ A=1 }\n");
let err = load_template(&ctx, &subs, None)
.expect_err("a malformed substitutions file must error");
assert_eq!(err, "Substitution file error: syntax error\nline 1: '{'\n");
}
#[test]
fn db_load_template_is_registered() {
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
assert!(registry.list().contains(&"dbLoadTemplate"));
}
#[test]
fn db_load_template_after_ioc_init_is_refused_and_creates_nothing() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
write_file(
dir.path(),
"t.db",
r#"record(ai, "$(N)") { field(VAL, "1") }"#,
);
let subs = write_file(
dir.path(),
"t.substitutions",
r#"file "t.db" { { N=LATER } }"#,
);
ctx.block_on(db.ioc_init());
let err =
load_template(&ctx, &subs, None).expect_err("C refuses a load once the IOC is running");
assert!(
err.contains("Records cannot be loaded after iocInit!"),
"expected C's diagnostic; got {err}"
);
assert!(!exists(&db, &ctx, "LATER"), "no record may be created");
}
#[test]
fn dbnr_prints_c_s_columns_and_counts_aliases_in_the_total() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("A:one", Box::new(AiRecord::new(1.0)))
.await
.unwrap();
db.add_record(
"B:one",
Box::new(crate::server::records::bi::BiRecord::new(0)),
)
.await
.unwrap();
db.add_alias("A:alias1", "A:one").await.unwrap();
db.add_alias("B:aliasX", "B:one").await.unwrap();
});
assert_eq!(
run_cmd(&ctx, "dbnr", &[]),
"Records Aliases Record Type
1 1 ai
1 1 bi
Total 2 records, 2 aliases
"
);
let verbose = run_cmd(&ctx, "dbnr", &["1"]);
assert!(
verbose.contains(" 0 0 calc\n"),
"verbose must list an uninstanced type — got:\n{verbose}"
);
assert!(
verbose.ends_with("Total 2 records, 2 aliases\n"),
"the totals do not change with verbose — got:\n{verbose}"
);
}
#[test]
fn dbla_prints_alias_to_target_and_globs_the_alias_name() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("A:one", Box::new(AiRecord::new(1.0)))
.await
.unwrap();
db.add_record(
"B:one",
Box::new(crate::server::records::bi::BiRecord::new(0)),
)
.await
.unwrap();
db.add_alias("A:alias1", "A:one").await.unwrap();
db.add_alias("B:aliasX", "B:one").await.unwrap();
});
assert_eq!(
run_cmd(&ctx, "dbla", &[]),
"A:alias1 -> A:one\nB:aliasX -> B:one\n"
);
assert_eq!(run_cmd(&ctx, "dbla", &["A:*"]), "A:alias1 -> A:one\n");
assert_eq!(run_cmd(&ctx, "dbla", &["*:one"]), "");
}
#[test]
fn dbl_lists_alias_nodes_where_they_were_declared() {
let (db, ctx) = make_ctx();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("alias.db");
std::fs::write(
&path,
"record(ai, \"A:ONE\") { field(VAL, \"1\") alias(\"A:ONE:ALT\") }\n\
record(bo, \"B:TWO\") { }\n\
record(ai, \"A:THREE\") { field(VAL, \"3\") }\n\
alias(\"A:THREE\", \"A:THREE:ALT\")\n",
)
.unwrap();
run_cmd(&ctx, "dbLoadRecords", &[path.to_str().unwrap()]);
ctx.block_on(db.ioc_init());
assert_eq!(
run_cmd(&ctx, "dbl", &[]),
"A:ONE\nA:ONE:ALT\nA:THREE\nA:THREE:ALT\nB:TWO\n",
"C lists the alias nodes; a site building its PV inventory with \
`dbl > pvlist` needs the names its clients use"
);
assert_eq!(
run_cmd(&ctx, "dbl", &["ai"]),
"A:ONE\nA:ONE:ALT\nA:THREE\nA:THREE:ALT\n",
"an alias belongs to the type list of the record it names"
);
assert_eq!(
run_cmd(&ctx, "dbla", &[]),
"A:ONE:ALT -> A:ONE\nA:THREE:ALT -> A:THREE\n"
);
assert_eq!(
run_cmd(&ctx, "dbgrep", &["*ALT"]),
"A:ONE:ALT\nA:THREE:ALT\n"
);
assert_eq!(
run_cmd(&ctx, "dbnr", &[]),
"Records Aliases Record Type
2 2 ai
1 0 bo
Total 3 records, 2 aliases
"
);
}
#[test]
fn dbli_globs_the_info_tag_name_not_the_record_name() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("A:one", Box::new(AiRecord::new(1.0)))
.await
.unwrap();
});
{
let rec = db.get_record("A:one").unwrap();
let mut inst = rec.write();
inst.set_info("autosaveFields", "VAL");
inst.set_info("zzz", "second");
}
assert_eq!(
run_cmd(&ctx, "dbli", &[]),
"A:one info(autosaveFields, \"VAL\")\nA:one info(zzz, \"second\")\n"
);
assert_eq!(
run_cmd(&ctx, "dbli", &["auto*"]),
"A:one info(autosaveFields, \"VAL\")\n"
);
assert_eq!(run_cmd(&ctx, "dbli", &["A:*"]), "");
}
#[test]
fn db_create_alias_carries_c_s_status_numbers() {
let (db, ctx) = make_ctx();
ctx.block_on(async {
db.add_record("A:one", Box::new(AiRecord::new(1.0)))
.await
.unwrap();
});
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbCreateAlias").unwrap();
let call = |tokens: &[&str]| {
let tokens: Vec<String> = tokens.iter().map(|t| t.to_string()).collect();
let args = parse_args(&tokens, &cmd.args).expect("C accepts a missing argument");
cmd.handler.call(&args, &ctx).map(|_| ())
};
assert!(call(&["pdbbase", "A:one", "A:alias2"]).is_ok());
assert_eq!(db.resolve_alias("A:alias2").as_deref(), Some("A:one"));
assert!(call(&["pdbbase", "A:alias2", "A:alias3"]).is_ok());
assert_eq!(db.resolve_alias("A:alias3").as_deref(), Some("A:one"));
assert_eq!(
call(&["pdbbase", "A:one", "A:alias2"]).unwrap_err(),
"33554435 Record Already exists"
);
assert_eq!(
call(&["pdbbase", "NOSUCH", "A:alias9"]).unwrap_err(),
"33554437 Record Not Found"
);
assert_eq!(call(&["pdbbase"]).unwrap_err(), "33554437 Record Not Found");
assert_eq!(
call(&["ai", "A:one", "A:alias9"]).unwrap_err(),
"Expecting 'pdbbase' got 'ai'."
);
}
#[test]
fn db_state_commands_follow_c_s_level_and_creation_order() {
let (_db, ctx) = make_ctx();
let first = "IOCSH_TEST_STATE_1";
let second = "IOCSH_TEST_STATE_2";
assert_eq!(run_cmd(&ctx, "dbStateCreate", &[first]), "");
assert_eq!(run_cmd(&ctx, "dbStateCreate", &[second]), "");
assert_eq!(run_cmd(&ctx, "dbStateShow", &[first]), "FALSE\n");
assert_eq!(run_cmd(&ctx, "dbStateSet", &[first]), "");
assert_eq!(run_cmd(&ctx, "dbStateShow", &[first]), "TRUE\n");
assert_eq!(run_cmd(&ctx, "dbStateClear", &[first]), "");
assert_eq!(run_cmd(&ctx, "dbStateShow", &[first]), "FALSE\n");
run_cmd(&ctx, "dbStateSet", &[second]);
let level_one = run_cmd(&ctx, "dbStateShow", &[second, "1"]);
assert!(
level_one.starts_with("id 0x") && level_one.ends_with(&format!("'{second}' : TRUE\n")),
"level 1 must carry C's id prefix — got:\n{level_one}"
);
for argv in [vec![], vec!["1"]] {
let all = run_cmd(&ctx, "dbStateShowAll", &argv);
let at_first = all
.find(&format!("'{first}' : FALSE"))
.unwrap_or_else(|| panic!("{first} missing from:\n{all}"));
let at_second = all
.find(&format!("'{second}' : TRUE"))
.unwrap_or_else(|| panic!("{second} missing from:\n{all}"));
assert!(at_first < at_second, "creation order lost:\n{all}");
assert!(all.starts_with("id 0x"), "prefix missing:\n{all}");
}
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
for name in ["dbStateSet", "dbStateClear", "dbStateShow", "dbStateCreate"] {
let cmd = registry.get(name).unwrap();
let tokens = if name == "dbStateCreate" {
Vec::new()
} else {
vec!["IOCSH_TEST_NO_SUCH_STATE".to_string()]
};
let args = parse_args(&tokens, &cmd.args).unwrap();
let printed = capture(&ctx, || {
assert!(
matches!(cmd.handler.call(&args, &ctx), Ok(CommandOutcome::Failed)),
"{name} must fail the line"
);
});
assert_eq!(printed, "", "{name} must print nothing on a miss");
}
}
#[test]
fn db_dump_breaktable_prints_c_s_bytes_for_type_kdegc() {
let (_db, ctx) = make_ctx();
assert_eq!(
run_cmd(&ctx, "dbDumpBreaktable", &["pdbbase", "typeKdegC"]),
""
);
let (_, err, result) = run_capturing(&ctx, "dbLoadDatabase", &[SHIPPED_BPT_TYPE_KDEGC]);
assert_eq!(err, "");
assert!(matches!(result, Ok(CommandOutcome::Continue)));
let expected = "breaktable(typeKdegC) {
\traw=0.000000 slope=2.472694e-01 eng=0.000000
\traw=299.268700 slope=2.462073e-01 eng=74.000000
\traw=660.752744 slope=2.499770e-01 eng=163.000000
\traw=1104.793671 slope=2.409860e-01 eng=274.000000
\traw=1702.338802 slope=2.374113e-01 eng=418.000000
\traw=2902.787322 slope=2.438970e-01 eng=703.000000
\traw=3427.599045 slope=2.516896e-01 eng=831.000000
\traw=3912.323051 slope=2.573081e-01 eng=953.000000
\traw=4098.869854 slope=2.573081e-01 eng=1001.000000
}
";
assert_eq!(
run_cmd(&ctx, "dbDumpBreaktable", &["pdbbase", "typeKdegC"]),
expected
);
assert_eq!(
run_cmd(&ctx, "dbDumpBreaktable", &["pdbbase", "typeK*"]),
""
);
assert_eq!(
run_cmd(&ctx, "dbDumpBreaktable", &["pdbbase", "nosuch"]),
""
);
assert!(
run_cmd(&ctx, "dbDumpBreaktable", &["pdbbase"]).contains(expected),
"a bare `dbDumpBreaktable pdbbase` must dump every table"
);
}
#[test]
fn c_exponential_writes_c_s_two_digit_signed_exponent() {
assert_eq!(c_exponential(0.2472694), "2.472694e-01");
assert_eq!(c_exponential(0.0), "0.000000e+00");
assert_eq!(c_exponential(-1.5), "-1.500000e+00");
assert_eq!(c_exponential(1.0e100), "1.000000e+100");
assert_eq!(c_exponential(1.0e-7), "1.000000e-07");
}
#[test]
#[serial_test::serial(epics_env)]
fn db_dump_path_reports_the_path_the_last_load_installed() {
let (_db, ctx) = make_ctx();
db_loader::set_loaded_path(&[]);
assert_eq!(
run_cmd(&ctx, "dbDumpPath", &["pdbbase"]).trim_end(),
"no path defined",
"an empty path list is C's `no path defined`"
);
let dir = tempfile::tempdir().unwrap();
let _path = DbIncludePath::set(dir.path());
write_file(dir.path(), "one.db", r#"record(ai, "PATHONE") { }"#);
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbLoadRecords").unwrap();
let args = parse_args(&["one.db".to_string()], &cmd.args).unwrap();
capture(&ctx, || {
cmd.handler.call(&args, &ctx).unwrap();
});
assert_eq!(
run_cmd(&ctx, "dbDumpPath", &["pdbbase"]).trim_end(),
dir.path().display().to_string(),
"after a load the report is the list that load installed"
);
let mut registry = CommandRegistry::new();
register_builtins(&mut registry);
let cmd = registry.get("dbDumpPath").unwrap();
let args = parse_args(&["notpdbbase".to_string()], &cmd.args).unwrap();
assert!(
cmd.handler.call(&args, &ctx).is_err(),
"dbDumpPath must reject a first argument that is not pdbbase"
);
}
}