use chrono::{DateTime, Local};
use clap::Parser;
use epics_base_rs::server::snapshot::{DbrClass, Snapshot};
use epics_base_rs::types::WallTime;
use epics_ca_rs::CaError;
use epics_ca_rs::cli::{PV_NAME_WIDTH, ValueFormat, format_value};
use epics_ca_rs::client::{CaChannel, CaClient, enum_string_readback_dbr};
use std::time::SystemTime;
fn format_server_timestamp(ts: WallTime) -> String {
let dt: DateTime<Local> = SystemTime::from(ts).into();
dt.format("%Y-%m-%d %H:%M:%S%.6f").to_string()
}
fn sevr_to_str(sevr: u16) -> &'static str {
match sevr {
0 => "NO_ALARM",
1 => "MINOR",
2 => "MAJOR",
3 => "INVALID",
_ => "Illegal value",
}
}
fn stat_to_str(stat: u16) -> &'static str {
match stat {
0 => "NO_ALARM",
1 => "READ",
2 => "WRITE",
3 => "HIHI",
4 => "HIGH",
5 => "LOLO",
6 => "LOW",
7 => "STATE",
8 => "COS",
9 => "COMM",
10 => "TIMEOUT",
11 => "HW_LIMIT",
12 => "CALC",
13 => "SCAN",
14 => "LINK",
15 => "SOFT",
16 => "BAD_SUB",
17 => "UDF",
18 => "DISABLE",
19 => "SIMM",
20 => "READ_ACCESS",
21 => "WRITE_ACCESS",
_ => "Illegal value",
}
}
fn print_long_line(prefix: &str, name_col: &str, sep: char, snap: &Snapshot, fmt: &ValueFormat) {
let enum_strings = snap.enums.as_ref().map(|e| e.strings.as_slice());
let val = format_value(&snap.value, fmt, enum_strings, false);
let ts = format_server_timestamp(snap.timestamp);
let stat = snap.alarm.status;
let sevr = snap.alarm.severity;
if stat == 0 && sevr == 0 {
println!("{prefix}{name_col}{sep}{ts}{sep}{val}{sep}{sep}");
} else {
println!(
"{prefix}{name_col}{sep}{ts}{sep}{val}{sep}{stat_str}{sep}{sevr_str}",
stat_str = stat_to_str(stat),
sevr_str = sevr_to_str(sevr),
);
}
}
const VERSION_INFO: &str = concat!(
"\nEPICS Version epics-rs ",
env!("CARGO_PKG_VERSION"),
", CA Protocol version 4.13"
);
#[derive(Parser)]
#[command(
name = "caput-rs",
about = "Write a value to an EPICS PV",
disable_version_flag = true
)]
struct Args {
#[arg(short = 'V', long, hide = true)]
version: bool,
#[arg(short = 'w', long = "timeout")]
timeout: Option<f64>,
#[arg(short = 'c', long = "callback")]
callback: bool,
#[arg(short = 'p', long)]
priority: Option<u8>,
#[arg(short = 't', long)]
terse: bool,
#[arg(short = 'l', long = "long")]
long_mode: bool,
#[arg(short = 'n', long = "num-enum", overrides_with = "force_string")]
force_numeric: bool,
#[arg(short = 's', long = "string-enum", overrides_with = "force_numeric")]
force_string: bool,
#[arg(short = 'S', long = "long-string", overrides_with = "array_mode")]
long_string: bool,
#[arg(short = 'a', long = "array", overrides_with = "long_string")]
array_mode: bool,
#[arg(short = 'F', long = "field-separator", value_name = "OFS")]
field_separator: Option<char>,
#[arg(required_unless_present_any = ["version"])]
pv_name: Option<String>,
#[arg(allow_hyphen_values = true, trailing_var_arg = true)]
values: Vec<String>,
}
#[tokio::main]
async fn main() {
let args = Args::parse();
if args.version {
println!("{VERSION_INFO}");
return;
}
let pv_name = args.pv_name.expect("clap enforces required");
if args.values.is_empty() {
eprintln!("caput-rs: missing value");
std::process::exit(1);
}
let client = CaClient::new().await.expect("failed to create CA client");
let timeout = epics_ca_rs::cli::timeout_duration(
args.timeout
.unwrap_or_else(epics_ca_rs::cli::env_default_timeout),
);
let ch = client.create_channel_with_priority(&pv_name, args.priority.unwrap_or(0));
if let Err(e) = ch.wait_connected(timeout).await {
eprintln!("error: {e}");
std::process::exit(1);
}
let native_type = match ch.native_field_type() {
Ok(t) => t,
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
};
let enum_menu: Vec<epics_ca_rs::PvString> = if native_type == epics_ca_rs::DbFieldType::Enum {
match ch.get_with_metadata(DbrClass::Gr).await {
Ok(snap) => snap.enums.map(|e| e.strings).unwrap_or_default(),
Err(CaError::Timeout) => {
eprintln!("Read operation timed out: ENUM data was not read.");
std::process::exit(1);
}
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
} else {
Vec::new()
};
let enum_dbr = enum_string_readback_dbr(native_type, args.long_mode, !args.force_numeric);
let long_mode = args.long_mode;
let read_display = move |ch: CaChannel| async move {
match (long_mode, enum_dbr) {
(true, Some(rt)) => ch
.get_with_dbr_type(rt, 0)
.await
.map(|s| (s.value.clone(), Some(s))),
(true, None) => ch
.get_with_metadata(DbrClass::Time)
.await
.map(|s| (s.value.clone(), Some(s))),
(false, Some(rt)) => ch.get_with_dbr_type(rt, 0).await.map(|s| (s.value, None)),
(false, None) => ch.get_with_timeout(timeout).await.map(|(_t, v)| (v, None)),
}
};
let (old_value, old_snap) = if args.terse {
(None, None)
} else {
match read_display(ch.clone()).await {
Ok((v, s)) => (Some(v), s),
Err(CaError::Timeout) => {
eprintln!("Read operation timed out: PV data was not read.");
std::process::exit(1);
}
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
};
let parsed_value = match build_write_value(
&args.values,
native_type,
args.force_numeric,
args.force_string,
args.long_string,
args.array_mode,
&enum_menu,
) {
Ok(v) => v,
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
};
let result = match &parsed_value {
WriteValue::Wire { dbr_type, value } => {
if args.callback {
ch.put_as_dbr_with_timeout(*dbr_type, value, timeout).await
} else {
ch.put_as_dbr_nowait(*dbr_type, value).await
}
}
WriteValue::EnumString(s) => {
let v = epics_ca_rs::EpicsValue::String(s.clone());
let dbr = epics_ca_rs::DbFieldType::String as u16;
if args.callback {
ch.put_as_dbr_with_timeout(dbr, &v, timeout).await
} else {
ch.put_as_dbr_nowait(dbr, &v).await
}
}
WriteValue::EnumStringArray(v) => {
let arr = epics_ca_rs::EpicsValue::StringArray(v.clone());
let dbr = epics_ca_rs::DbFieldType::String as u16;
if args.callback {
ch.put_as_dbr_with_timeout(dbr, &arr, timeout).await
} else {
ch.put_as_dbr_nowait(dbr, &arr).await
}
}
};
if let Err(e) = result {
eprintln!("error: {e}");
std::process::exit(1);
}
let echo_fallback = parsed_value.echo_fallback();
let (new_value, new_snap) = match read_display(ch.clone()).await {
Ok(pair) => pair,
Err(e) => match postput_read_fatal(&e) {
Some(FatalReadback::Timeout) => {
eprintln!("Read operation timed out: PV data was not read.");
std::process::exit(1);
}
Some(FatalReadback::Disconnect) => {
eprintln!("error: {e}");
std::process::exit(1);
}
None => (echo_fallback.clone(), None),
},
};
let mut fmt = ValueFormat::default();
if let Some(c) = args.field_separator {
fmt.field_separator = c;
}
let sep = fmt.field_separator;
let old_rendered = old_value
.as_ref()
.map(|v| format_value(v, &fmt, None, false))
.unwrap_or_default();
let new_rendered = format_value(&new_value, &fmt, None, false);
let is_scalar = new_value.count() == 1;
let pad = |name: &str| -> String {
if is_scalar && sep == ' ' {
format!("{name:<width$}", width = PV_NAME_WIDTH)
} else {
name.to_string()
}
};
if args.terse {
println!("{new_rendered}");
} else if args.long_mode {
let name_col = pad(&pv_name);
match &old_snap {
Some(s) => print_long_line("Old : ", &name_col, sep, s, &fmt),
None => println!("Old : {name_col}{sep}*{sep}{old_rendered}{sep}{sep}"),
}
match &new_snap {
Some(s) => print_long_line("New : ", &name_col, sep, s, &fmt),
None => println!("New : {name_col}{sep}*{sep}{new_rendered}{sep}{sep}"),
}
} else {
println!(
"Old : {name}{sep}{val}",
name = pad(&pv_name),
val = old_rendered
);
println!(
"New : {name}{sep}{val}",
name = pad(&pv_name),
val = new_rendered
);
}
}
#[derive(Debug)]
enum WriteValue {
Wire {
dbr_type: u16,
value: epics_ca_rs::EpicsValue,
},
EnumString(epics_ca_rs::PvString),
EnumStringArray(Vec<epics_ca_rs::PvString>),
}
impl WriteValue {
fn echo_fallback(&self) -> epics_ca_rs::EpicsValue {
match self {
WriteValue::Wire { value, .. } => value.clone(),
WriteValue::EnumString(s) => epics_ca_rs::EpicsValue::String(s.clone()),
WriteValue::EnumStringArray(v) => epics_ca_rs::EpicsValue::StringArray(v.clone()),
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum FatalReadback {
Timeout,
Disconnect,
}
fn postput_read_fatal(err: &CaError) -> Option<FatalReadback> {
match err {
CaError::Timeout => Some(FatalReadback::Timeout),
CaError::Disconnected | CaError::Shutdown => Some(FatalReadback::Disconnect),
_ => None,
}
}
fn raw_from_escaped(s: &str) -> Vec<u8> {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
'next: while i < b.len() {
let mut c = b[i];
i += 1;
loop {
if c == 0 {
return out;
}
if c != b'\\' {
out.push(c);
continue 'next;
}
if i >= b.len() {
return out;
}
c = b[i];
i += 1;
match c {
b'a' => out.push(0x07),
b'b' => out.push(0x08),
b'f' => out.push(0x0C),
b'n' => out.push(b'\n'),
b'r' => out.push(b'\r'),
b't' => out.push(b'\t'),
b'v' => out.push(0x0B),
b'\\' => out.push(b'\\'),
b'\'' => out.push(b'\''),
b'"' => out.push(b'"'),
b'0' => out.push(0),
b'x' => {
if i >= b.len() {
return out;
}
c = b[i];
i += 1;
let Some(hi) = (c as char).to_digit(16) else {
continue;
};
let u = hi as u8;
if i >= b.len() {
out.push(u);
return out;
}
c = b[i];
i += 1;
match (c as char).to_digit(16) {
Some(lo) => out.push(u << 4 | lo as u8),
None => {
out.push(u);
continue;
}
}
}
other => out.push(other),
}
continue 'next;
}
}
out
}
const DBR_STRING_PAYLOAD_MAX: usize = 39;
fn raw_from_escaped_string(s: &str) -> epics_ca_rs::PvString {
let mut bytes = raw_from_escaped(s);
bytes.truncate(DBR_STRING_PAYLOAD_MAX);
epics_ca_rs::PvString::from_bytes(bytes)
}
fn build_write_value(
values: &[String],
native_type: epics_ca_rs::DbFieldType,
force_numeric: bool,
force_string: bool,
long_string: bool,
array_mode: bool,
enum_menu: &[epics_ca_rs::PvString],
) -> Result<WriteValue, String> {
if array_mode {
let tokens = &values[1..];
if native_type == epics_ca_rs::DbFieldType::Enum {
return build_enum_array(tokens, force_numeric, force_string, enum_menu);
}
let escaped: Vec<epics_ca_rs::PvString> =
tokens.iter().map(|t| raw_from_escaped_string(t)).collect();
return Ok(WriteValue::Wire {
dbr_type: epics_ca_rs::DbFieldType::String as u16,
value: epics_ca_rs::EpicsValue::StringArray(escaped),
});
}
let joined = values.join(" ");
if native_type == epics_ca_rs::DbFieldType::Enum {
return match classify_enum_token(&joined, force_numeric, force_string, enum_menu)? {
EnumToken::Name(s) => Ok(WriteValue::EnumString(s)),
EnumToken::Number(n) => Ok(WriteValue::Wire {
dbr_type: epics_ca_rs::DbFieldType::Double as u16,
value: epics_ca_rs::EpicsValue::Double(n),
}),
};
}
if long_string {
let mut bytes = raw_from_escaped(&joined);
bytes.push(0);
return Ok(WriteValue::Wire {
dbr_type: epics_ca_rs::DbFieldType::Char as u16,
value: epics_ca_rs::EpicsValue::CharArray(bytes),
});
}
Ok(WriteValue::Wire {
dbr_type: epics_ca_rs::DbFieldType::String as u16,
value: epics_ca_rs::EpicsValue::String(raw_from_escaped_string(&joined)),
})
}
enum EnumToken {
Name(epics_ca_rs::PvString),
Number(f64),
}
fn classify_enum_token(
token: &str,
force_numeric: bool,
force_string: bool,
menu: &[epics_ca_rs::PvString],
) -> Result<EnumToken, String> {
if force_numeric {
return parse_enum_double(token)
.map(EnumToken::Number)
.ok_or_else(|| format!("Enum index value '{token}' is not a number."));
}
let escaped = raw_from_escaped_string(token);
if menu
.iter()
.any(|name| name.as_bytes() == escaped.as_bytes())
{
return Ok(EnumToken::Name(escaped));
}
if force_string {
return Err(format!("Enum string value '{escaped}' invalid."));
}
parse_enum_double(&String::from_utf8_lossy(escaped.as_bytes()))
.map(EnumToken::Number)
.ok_or_else(|| format!("Enum string value '{escaped}' invalid."))
}
fn parse_enum_double(s: &str) -> Option<f64> {
let t = s.trim();
if t.is_empty() {
return None;
}
t.parse::<f64>().ok()
}
fn build_enum_array(
tokens: &[String],
force_numeric: bool,
force_string: bool,
menu: &[epics_ca_rs::PvString],
) -> Result<WriteValue, String> {
let classified: Vec<EnumToken> = tokens
.iter()
.map(|t| classify_enum_token(t, force_numeric, force_string, menu))
.collect::<Result<_, _>>()?;
let mut numbers = Vec::with_capacity(classified.len());
let mut all_number = true;
for c in &classified {
match c {
EnumToken::Number(n) => numbers.push(*n),
EnumToken::Name(_) => all_number = false,
}
}
if all_number {
return Ok(WriteValue::Wire {
dbr_type: epics_ca_rs::DbFieldType::Double as u16,
value: epics_ca_rs::EpicsValue::DoubleArray(numbers),
});
}
let names = tokens
.iter()
.zip(&classified)
.map(|(t, c)| match c {
EnumToken::Name(s) => s.clone(),
EnumToken::Number(_) => raw_from_escaped_string(t),
})
.collect();
Ok(WriteValue::EnumStringArray(names))
}
#[cfg(test)]
mod tests {
use super::{
Args, FatalReadback, WriteValue, build_write_value, postput_read_fatal, raw_from_escaped,
raw_from_escaped_string,
};
use clap::Parser;
use epics_ca_rs::{CaError, DbFieldType, EpicsValue};
fn vals(s: &[&str]) -> Vec<String> {
s.iter().map(|x| x.to_string()).collect()
}
fn menu_vals(s: &[&str]) -> Vec<epics_ca_rs::PvString> {
s.iter().map(|x| (*x).into()).collect()
}
#[test]
fn enum_and_array_flags_are_last_wins_pairs() {
let parse = |extra: &[&str]| {
let mut argv = vec!["caput-rs"];
argv.extend_from_slice(extra);
argv.extend_from_slice(&["PV", "1"]);
Args::try_parse_from(argv).expect("flags must parse without a conflict error")
};
let a = parse(&["-n", "-s"]);
assert!(
a.force_string && !a.force_numeric,
"-n -s → string (last wins)"
);
let a = parse(&["-s", "-n"]);
assert!(
a.force_numeric && !a.force_string,
"-s -n → numeric (last wins)"
);
let a = parse(&["-n"]);
assert!(a.force_numeric && !a.force_string, "-n alone → numeric");
let a = parse(&["-s"]);
assert!(a.force_string && !a.force_numeric, "-s alone → string");
let a = parse(&["-a", "-S"]);
assert!(
a.long_string && !a.array_mode,
"-a -S → long string (last wins)"
);
let a = parse(&["-S", "-a"]);
assert!(a.array_mode && !a.long_string, "-S -a → array (last wins)");
let a = parse(&["-a"]);
assert!(a.array_mode && !a.long_string, "-a alone → array");
let a = parse(&["-S"]);
assert!(a.long_string && !a.array_mode, "-S alone → long string");
}
#[test]
fn postput_read_timeout_is_fatal() {
assert_eq!(
postput_read_fatal(&CaError::Timeout),
Some(FatalReadback::Timeout),
"read timeout must fail caput like C ca_pend_io ECA_TIMEOUT"
);
}
#[test]
fn postput_read_disconnect_is_fatal() {
assert_eq!(
postput_read_fatal(&CaError::Disconnected),
Some(FatalReadback::Disconnect),
"disconnected readback must fail caput like C caget !nConn"
);
assert_eq!(
postput_read_fatal(&CaError::Shutdown),
Some(FatalReadback::Disconnect),
"client shutdown during readback must fail caput"
);
}
#[test]
fn postput_read_other_errors_are_nonfatal() {
assert_eq!(
postput_read_fatal(&CaError::ServerError(0x178)), None,
"read-access-denied exits 0 in C, must stay non-fatal"
);
assert_eq!(
postput_read_fatal(&CaError::Protocol("bad frame".into())),
None,
"other readback errors exit 0 in C, must stay non-fatal"
);
}
#[test]
fn raw_from_escaped_matches_epics_strn_raw_from_escaped() {
assert_eq!(
raw_from_escaped("\\a\\b\\f\\n\\r\\t\\v"),
vec![0x07, 0x08, 0x0C, 0x0A, 0x0D, 0x09, 0x0B]
);
assert_eq!(raw_from_escaped("\\\\\\'\\\""), vec![b'\\', b'\'', b'"']);
assert_eq!(raw_from_escaped("\\0"), vec![0]);
assert_eq!(raw_from_escaped("\\x41"), vec![0x41]); assert_eq!(raw_from_escaped("\\xA"), vec![0x0A]); assert_eq!(raw_from_escaped("\\xG"), vec![b'G']); assert_eq!(raw_from_escaped("\\q"), vec![b'q']); assert_eq!(raw_from_escaped("a\\"), vec![b'a']); assert_eq!(raw_from_escaped("a\0b"), vec![b'a']); }
#[test]
fn long_string_takes_precedence_over_char_parse() {
let r = build_write_value(
&vals(&["hello"]),
DbFieldType::Char,
false,
false,
true,
false,
&[],
);
match r {
Ok(WriteValue::Wire {
dbr_type,
value: EpicsValue::CharArray(bytes),
}) => {
assert_eq!(dbr_type, DbFieldType::Char as u16, "DBR_CHAR wire type");
assert_eq!(bytes, b"hello\0", "NUL-terminated long string bytes");
}
_ => panic!("expected Ok(DBR_CHAR Wire CharArray) for -S on a CHAR PV"),
}
}
#[test]
fn long_string_applies_to_every_non_enum_native_type() {
for nt in [
DbFieldType::Char,
DbFieldType::Double,
DbFieldType::Long,
DbFieldType::String,
] {
let r = build_write_value(&vals(&["not a number"]), nt, false, false, true, false, &[]);
assert!(
matches!(
r,
Ok(WriteValue::Wire {
dbr_type,
value: EpicsValue::CharArray(_),
}) if dbr_type == DbFieldType::Char as u16
),
"-S must yield a DBR_CHAR Wire CharArray for non-ENUM native type {nt:?}"
);
}
}
#[test]
fn enum_field_type_wins_over_long_string() {
let menu = menu_vals(&["Stop", "Run", "not a number"]);
let r = build_write_value(
&vals(&["not a number"]),
DbFieldType::Enum,
false,
false,
true,
false,
&menu,
);
match r {
Ok(WriteValue::EnumString(s)) => assert_eq!(s, "not a number"),
other => panic!("-S on an ENUM PV must yield EnumString, got {other:?}"),
}
let idx = build_write_value(
&vals(&["5"]),
DbFieldType::Enum,
false,
false,
true,
false,
&menu,
);
match idx {
Ok(WriteValue::Wire {
dbr_type,
value: EpicsValue::Double(n),
}) => {
assert_eq!(dbr_type, DbFieldType::Double as u16);
assert_eq!(n, 5.0);
}
other => panic!("out-of-menu index on ENUM PV → DBR_DOUBLE, got {other:?}"),
}
}
#[test]
fn enum_numeric_label_matches_menu_name_before_index() {
let menu = menu_vals(&["0", "1", "2"]);
match build_write_value(
&vals(&["1"]),
DbFieldType::Enum,
false,
false,
false,
false,
&menu,
) {
Ok(WriteValue::EnumString(s)) => assert_eq!(s, "1"),
other => panic!("numeric-looking menu label must go by name, got {other:?}"),
}
match build_write_value(
&vals(&["7"]),
DbFieldType::Enum,
false,
false,
false,
false,
&menu,
) {
Ok(WriteValue::Wire {
dbr_type,
value: EpicsValue::Double(n),
}) => {
assert_eq!(dbr_type, DbFieldType::Double as u16);
assert_eq!(n, 7.0);
}
other => panic!("out-of-menu value → DBR_DOUBLE fallback, got {other:?}"),
}
}
#[test]
fn enum_force_string_rejects_non_menu_value() {
let menu = menu_vals(&["Off", "On"]);
let err = build_write_value(
&vals(&["3"]),
DbFieldType::Enum,
false,
true,
false,
false,
&menu,
);
assert!(
matches!(&err, Err(m) if m.contains("invalid")),
"-s on a non-menu value must error, got {err:?}"
);
match build_write_value(
&vals(&["On"]),
DbFieldType::Enum,
false,
true,
false,
false,
&menu,
) {
Ok(WriteValue::EnumString(s)) => assert_eq!(s, "On"),
other => panic!("-s with a matching name → EnumString, got {other:?}"),
}
}
#[test]
fn enum_force_numeric_sends_dbr_double_ignoring_menu() {
let menu = menu_vals(&["1", "2"]);
match build_write_value(
&vals(&["1"]),
DbFieldType::Enum,
true,
false,
false,
false,
&menu,
) {
Ok(WriteValue::Wire {
dbr_type,
value: EpicsValue::Double(n),
}) => {
assert_eq!(dbr_type, DbFieldType::Double as u16);
assert_eq!(n, 1.0);
}
other => panic!("-n must send DBR_DOUBLE, got {other:?}"),
}
let err = build_write_value(
&vals(&["Open"]),
DbFieldType::Enum,
true,
false,
false,
false,
&menu,
);
assert!(
matches!(&err, Err(m) if m.contains("is not a number")),
"-n on a non-number must error, got {err:?}"
);
}
#[test]
fn enum_array_homogeneous_and_mixed_wire_types() {
let menu = menu_vals(&["Stop", "Run"]);
match build_write_value(
&vals(&["2", "Stop", "Run"]),
DbFieldType::Enum,
false,
false,
false,
true,
&menu,
) {
Ok(WriteValue::EnumStringArray(a)) => {
assert_eq!(a, vec!["Stop", "Run"]);
}
other => panic!("name array → DBR_STRING[], got {other:?}"),
}
match build_write_value(
&vals(&["2", "0", "1"]),
DbFieldType::Enum,
false,
false,
false,
true,
&menu,
) {
Ok(WriteValue::Wire {
dbr_type,
value: EpicsValue::DoubleArray(a),
}) => {
assert_eq!(dbr_type, DbFieldType::Double as u16);
assert_eq!(a, vec![0.0, 1.0]);
}
other => panic!("numeric array → DBR_DOUBLE[], got {other:?}"),
}
match build_write_value(
&vals(&["2", "Stop", "1"]),
DbFieldType::Enum,
false,
false,
false,
true,
&menu,
) {
Ok(WriteValue::EnumStringArray(a)) => assert_eq!(a, vec!["Stop", "1"]),
other => panic!("mixed array → DBR_STRING[], got {other:?}"),
}
}
#[test]
fn long_string_decodes_c_escapes_to_raw_bytes() {
let r = build_write_value(
&vals(&["a\\tb\\n"]),
DbFieldType::Char,
false,
false,
true,
false,
&[],
);
match r {
Ok(WriteValue::Wire {
dbr_type,
value: EpicsValue::CharArray(bytes),
}) => {
assert_eq!(dbr_type, DbFieldType::Char as u16, "DBR_CHAR wire type");
assert_eq!(bytes, vec![b'a', 0x09, b'b', 0x0A, 0x00]);
}
other => panic!("expected escape-decoded DBR_CHAR Wire CharArray, got {other:?}"),
}
}
#[test]
fn native_string_scalar_decodes_c_escapes() {
let r = build_write_value(
&vals(&["\\x41\\\\\\q"]),
DbFieldType::String,
false,
false,
false,
false,
&[],
);
match r {
Ok(WriteValue::Wire {
dbr_type,
value: EpicsValue::String(s),
}) => {
assert_eq!(dbr_type, DbFieldType::String as u16, "DBR_STRING wire type");
assert_eq!(s, "A\\q");
}
other => panic!("expected escape-decoded DBR_STRING Wire, got {other:?}"),
}
}
#[test]
fn non_enum_numeric_scalar_and_array_send_dbr_string() {
match build_write_value(
&vals(&["1.5"]),
DbFieldType::Double,
false,
false,
false,
false,
&[],
) {
Ok(WriteValue::Wire {
dbr_type,
value: EpicsValue::String(s),
}) => {
assert_eq!(dbr_type, DbFieldType::String as u16);
assert_eq!(s, "1.5");
}
other => panic!("numeric scalar must be a DBR_STRING Wire, got {other:?}"),
}
match build_write_value(
&vals(&["3", "10", "20", "30"]),
DbFieldType::Long,
false,
false,
false,
true,
&[],
) {
Ok(WriteValue::Wire {
dbr_type,
value: EpicsValue::StringArray(a),
}) => {
assert_eq!(dbr_type, DbFieldType::String as u16);
assert_eq!(a, vec!["10", "20", "30"]);
}
other => panic!("numeric array must be a DBR_STRING[] Wire, got {other:?}"),
}
}
#[test]
fn array_count_token_is_skipped_without_parsing_or_error() {
match build_write_value(
&vals(&["999", "1", "2"]),
DbFieldType::Long,
false,
false,
false,
true,
&[],
) {
Ok(WriteValue::Wire {
value: EpicsValue::StringArray(a),
..
}) => assert_eq!(a, vec!["1", "2"]),
other => panic!("count mismatch must use all values, got {other:?}"),
}
match build_write_value(
&vals(&["not-a-count", "1", "2"]),
DbFieldType::Long,
false,
false,
false,
true,
&[],
) {
Ok(WriteValue::Wire {
value: EpicsValue::StringArray(a),
..
}) => assert_eq!(a, vec!["1", "2"]),
other => panic!("non-numeric count token must be ignored, got {other:?}"),
}
match build_write_value(
&vals(&["0"]),
DbFieldType::Long,
false,
false,
false,
true,
&[],
) {
Ok(WriteValue::Wire {
value: EpicsValue::StringArray(a),
..
}) => assert!(a.is_empty()),
other => panic!("zero-count -a must reach the write path empty, got {other:?}"),
}
}
#[test]
fn scalar_char_without_long_string_sends_dbr_string() {
for tok in ["65", "hello"] {
match build_write_value(
&vals(&[tok]),
DbFieldType::Char,
false,
false,
false,
false,
&[],
) {
Ok(WriteValue::Wire {
dbr_type,
value: EpicsValue::String(s),
}) => {
assert_eq!(dbr_type, DbFieldType::String as u16);
assert_eq!(s, tok);
}
other => panic!("CHAR scalar without -S must be a DBR_STRING Wire, got {other:?}"),
}
}
}
#[test]
fn overlong_dbr_string_values_truncate_to_39_bytes() {
let long = "a".repeat(50); match build_write_value(
&vals(&[long.as_str()]),
DbFieldType::String,
false,
false,
false,
false,
&[],
) {
Ok(WriteValue::Wire {
value: EpicsValue::String(s),
..
}) => {
assert_eq!(s.len(), 39, "scalar string truncated to 39 bytes");
assert_eq!(s, "a".repeat(39));
}
other => panic!("expected truncated DBR_STRING Wire, got {other:?}"),
}
match build_write_value(
&vals(&["2", long.as_str(), "short"]),
DbFieldType::String,
false,
false,
false,
true,
&[],
) {
Ok(WriteValue::Wire {
value: EpicsValue::StringArray(a),
..
}) => {
assert_eq!(a[0].len(), 39, "array element truncated to 39 bytes");
assert_eq!(a[1], "short");
}
other => panic!("expected truncated DBR_STRING[] Wire, got {other:?}"),
}
let menu_label: epics_ca_rs::PvString = "a".repeat(39).into();
match build_write_value(
&vals(&[long.as_str()]),
DbFieldType::Enum,
false,
true,
false,
false,
std::slice::from_ref(&menu_label),
) {
Ok(WriteValue::EnumString(s)) => {
assert_eq!(s.len(), 39, "enum-by-name value truncated to 39 bytes")
}
other => panic!("expected truncated EnumString, got {other:?}"),
}
match build_write_value(
&vals(&[long.as_str()]),
DbFieldType::Char,
false,
false,
true,
false,
&[],
) {
Ok(WriteValue::Wire {
dbr_type,
value: EpicsValue::CharArray(b),
}) => {
assert_eq!(dbr_type, DbFieldType::Char as u16);
assert_eq!(b.len(), 51, "-S keeps all 50 bytes + NUL, uncapped");
}
other => panic!("expected uncapped DBR_CHAR Wire, got {other:?}"),
}
}
#[test]
fn escaped_high_bytes_reach_wire_verbatim_not_lossy() {
let pv = raw_from_escaped_string("\\xff\\x80\\x41");
assert_eq!(
pv.as_bytes(),
&[0xff, 0x80, 0x41],
"high-byte escapes must survive as literal bytes, not U+FFFD"
);
let pv = raw_from_escaped_string("\\xc3\\x28");
assert_eq!(pv.as_bytes(), &[0xc3, 0x28], "invalid UTF-8 pair preserved");
}
#[test]
fn escaped_high_bytes_truncate_on_byte_boundary() {
let input: String = "\\xff".repeat(40);
let pv = raw_from_escaped_string(&input);
assert_eq!(pv.as_bytes().len(), 39, "byte-oriented cut at 39");
assert!(
pv.as_bytes().iter().all(|&b| b == 0xff),
"every surviving byte is the literal 0xFF, no UTF-8 fixup"
);
}
#[test]
fn build_write_value_dbr_string_preserves_high_bytes() {
match build_write_value(
&vals(&["\\xff\\x80"]),
DbFieldType::String,
false,
false,
false,
false,
&[],
) {
Ok(WriteValue::Wire {
dbr_type,
value: EpicsValue::String(s),
}) => {
assert_eq!(dbr_type, DbFieldType::String as u16, "DBR_STRING wire type");
assert_eq!(
s.as_bytes(),
&[0xff, 0x80],
"DBR_STRING put carries raw high bytes to the wire"
);
}
other => panic!("expected DBR_STRING Wire with raw bytes, got {other:?}"),
}
}
}