use epics_base_rs::types::{EpicsValue, PvString};
pub const DEFAULT_CLI_TIMEOUT_SECS: f64 = 1.0;
pub const INDEFINITE_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(10 * 365 * 24 * 60 * 60);
pub fn env_default_timeout() -> f64 {
std::env::var("EPICS_CLI_TIMEOUT")
.ok()
.and_then(|s| s.parse::<f64>().ok())
.filter(|v| v.is_finite() && *v >= 0.0)
.unwrap_or(DEFAULT_CLI_TIMEOUT_SECS)
}
pub fn timeout_duration(secs: f64) -> std::time::Duration {
if secs == 0.0 {
return INDEFINITE_TIMEOUT;
}
let s = if secs.is_finite() && secs > 0.0 {
secs
} else {
DEFAULT_CLI_TIMEOUT_SECS
};
std::time::Duration::from_secs_f64(s)
}
pub const PV_NAME_WIDTH: usize = 30;
#[derive(Debug, Clone, Copy)]
pub enum FloatStyle {
G,
E,
F,
}
#[derive(Debug, Clone, Copy)]
pub struct FloatFormat {
pub style: FloatStyle,
pub precision: u32,
}
impl Default for FloatFormat {
fn default() -> Self {
Self {
style: FloatStyle::G,
precision: 6,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IntStyle {
Dec,
Hex,
Oct,
Bin,
}
#[derive(Debug, Clone)]
pub struct ValueFormat {
pub float: FloatFormat,
pub int_style: IntStyle,
pub float_as_int: bool,
pub enum_as_number: bool,
pub char_array_as_string: bool,
pub max_elements: Option<usize>,
pub field_separator: char,
}
impl Default for ValueFormat {
fn default() -> Self {
Self {
float: FloatFormat::default(),
int_style: IntStyle::Dec,
float_as_int: false,
enum_as_number: false,
char_array_as_string: false,
max_elements: None,
field_separator: ' ',
}
}
}
pub fn format_value(
v: &EpicsValue,
fmt: &ValueFormat,
enum_strings: Option<&[PvString]>,
req_elems_present: bool,
) -> String {
let sep = fmt.field_separator;
match v {
EpicsValue::String(s) => escape_from_raw(s.as_bytes()),
EpicsValue::Short(n) => format_int_i64(*n as i64, fmt.int_style),
EpicsValue::Long(n) => format_int_i64(*n as i64, fmt.int_style),
EpicsValue::Int64(n) => format_int_wide(n.to_string(), *n as u64, fmt.int_style),
EpicsValue::UInt64(n) => format_int_wide(n.to_string(), *n, fmt.int_style),
EpicsValue::UShort(n) => format_int_i64(*n as i64, fmt.int_style),
EpicsValue::ULong(n) => format_int_i64(*n as i64, fmt.int_style),
EpicsValue::Char(n) => format_int_i64((*n as i8) as i64, fmt.int_style),
EpicsValue::Enum(idx) => format_enum(*idx as i64, fmt, enum_strings),
EpicsValue::EnumWithChoices { index, .. } => format_enum(*index as i64, fmt, enum_strings),
EpicsValue::Float(x) => format_float(*x as f64, fmt),
EpicsValue::Double(x) => format_float(*x, fmt),
EpicsValue::ShortArray(arr) => render_array_int(
arr.iter().map(|&n| n as i64),
arr.len(),
fmt,
sep,
req_elems_present,
),
EpicsValue::LongArray(arr) => render_array_int(
arr.iter().map(|&n| n as i64),
arr.len(),
fmt,
sep,
req_elems_present,
),
EpicsValue::Int64Array(arr) => render_array_iter(
arr.iter()
.map(|&n| format_int_wide(n.to_string(), n as u64, fmt.int_style)),
arr.len(),
fmt,
sep,
req_elems_present,
),
EpicsValue::UInt64Array(arr) => render_array_iter(
arr.iter()
.map(|&n| format_int_wide(n.to_string(), n, fmt.int_style)),
arr.len(),
fmt,
sep,
req_elems_present,
),
EpicsValue::UShortArray(arr) => render_array_int(
arr.iter().map(|&n| n as i64),
arr.len(),
fmt,
sep,
req_elems_present,
),
EpicsValue::ULongArray(arr) => render_array_int(
arr.iter().map(|&n| n as i64),
arr.len(),
fmt,
sep,
req_elems_present,
),
EpicsValue::EnumArray(arr) => {
let mut parts = Vec::with_capacity(arr.len() + 1);
if req_elems_present || arr.len() > 1 {
parts.push(arr.len().to_string());
}
let take = fmt.max_elements.unwrap_or(arr.len()).min(arr.len());
for &idx in &arr[..take] {
parts.push(format_enum(idx as i64, fmt, enum_strings));
}
parts.join(&sep.to_string())
}
EpicsValue::FloatArray(arr) => render_array_iter(
arr.iter().map(|&x| format_float(x as f64, fmt)),
arr.len(),
fmt,
sep,
req_elems_present,
),
EpicsValue::DoubleArray(arr) => render_array_iter(
arr.iter().map(|&x| format_float(x, fmt)),
arr.len(),
fmt,
sep,
req_elems_present,
),
EpicsValue::CharArray(arr) => {
if fmt.char_array_as_string && (req_elems_present || arr.len() > 1) {
let end = arr.iter().position(|&b| b == 0).unwrap_or(arr.len());
escape_from_raw(&arr[..end])
} else {
render_array_int(
arr.iter().map(|&b| (b as i8) as i64),
arr.len(),
fmt,
sep,
req_elems_present,
)
}
}
EpicsValue::StringArray(arr) => {
let mut parts = Vec::with_capacity(arr.len() + 1);
if req_elems_present || arr.len() > 1 {
parts.push(arr.len().to_string());
}
let take = fmt.max_elements.unwrap_or(arr.len()).min(arr.len());
parts.extend(arr[..take].iter().map(|s| escape_from_raw(s.as_bytes())));
parts.join(&sep.to_string())
}
}
}
fn render_array_int<I: Iterator<Item = i64>>(
iter: I,
total: usize,
fmt: &ValueFormat,
sep: char,
req_elems_present: bool,
) -> String {
let take = fmt.max_elements.unwrap_or(total).min(total);
let mut parts = Vec::with_capacity(take + 1);
if req_elems_present || total > 1 {
parts.push(total.to_string());
}
for n in iter.take(take) {
parts.push(format_int_i64(n, fmt.int_style));
}
parts.join(&sep.to_string())
}
fn render_array_iter<I: Iterator<Item = String>>(
iter: I,
total: usize,
fmt: &ValueFormat,
sep: char,
req_elems_present: bool,
) -> String {
let take = fmt.max_elements.unwrap_or(total).min(total);
let mut parts = Vec::with_capacity(take + 1);
if req_elems_present || total > 1 {
parts.push(total.to_string());
}
parts.extend(iter.take(take));
parts.join(&sep.to_string())
}
fn format_enum(idx: i64, fmt: &ValueFormat, enum_strings: Option<&[PvString]>) -> String {
if !fmt.enum_as_number
&& let Some(strs) = enum_strings
&& idx >= 0
&& (idx as usize) < strs.len()
{
return escape_from_raw(strs[idx as usize].as_bytes());
}
format_int_i64(idx, fmt.int_style)
}
fn escape_from_raw(src: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(src.len());
for &c in src {
match c {
0x07 => out.push_str("\\a"),
0x08 => out.push_str("\\b"),
0x0c => out.push_str("\\f"),
b'\n' => out.push_str("\\n"),
b'\r' => out.push_str("\\r"),
b'\t' => out.push_str("\\t"),
0x0b => out.push_str("\\v"),
b'\\' => out.push_str("\\\\"),
b'\'' => out.push_str("\\'"),
b'"' => out.push_str("\\\""),
0 => out.push_str("\\0"),
0x20..=0x7e => out.push(c as char),
_ => {
out.push('\\');
out.push('x');
out.push(HEX[(c >> 4) as usize] as char);
out.push(HEX[(c & 0x0f) as usize] as char);
}
}
}
out
}
fn format_int_i64(n: i64, style: IntStyle) -> String {
let v32 = n as i32; let bits = v32 as u32; match style {
IntStyle::Dec => v32.to_string(),
IntStyle::Hex => format!("0x{bits:X}"),
IntStyle::Oct => format!("0o{bits:o}"),
IntStyle::Bin => format!("{bits:b}"),
}
}
fn format_int_wide(decimal: String, bits: u64, style: IntStyle) -> String {
match style {
IntStyle::Dec => decimal,
IntStyle::Hex => format!("0x{bits:X}"),
IntStyle::Oct => format!("0o{bits:o}"),
IntStyle::Bin => format!("{bits:b}"),
}
}
fn format_float(x: f64, fmt: &ValueFormat) -> String {
if fmt.float_as_int {
let rounded = if x.is_nan() { 0i64 } else { x.round() as i64 };
return format_int_i64(rounded, fmt.int_style);
}
if !x.is_finite() {
return format!("{x}");
}
let p = fmt.float.precision as usize;
match fmt.float.style {
FloatStyle::F => format!("{x:.p$}"),
FloatStyle::E => format_e(x, p),
FloatStyle::G => format_g(x, p.max(1)),
}
}
fn decision_exponent(abs: f64, precision: usize) -> i32 {
let raw_exp = abs.log10().floor() as i32;
let scale = 10f64.powi(precision as i32 - 1 - raw_exp);
if !scale.is_finite() || scale == 0.0 {
return raw_exp;
}
let rounded_scaled = (abs * scale).round();
if !rounded_scaled.is_finite() || rounded_scaled <= 0.0 {
return raw_exp;
}
raw_exp + (rounded_scaled.log10().floor() as i32 - (precision as i32 - 1))
}
fn format_g(x: f64, precision: usize) -> String {
if x == 0.0 {
return "0".to_string();
}
let abs = x.abs();
let exp = decision_exponent(abs, precision);
if exp >= -4 && exp < precision as i32 {
let digits = (precision as i32 - 1 - exp).max(0) as usize;
let s = format!("{x:.digits$}");
trim_g_fixed(&s)
} else {
format_g_scientific(x, precision)
}
}
fn trim_g_fixed(s: &str) -> String {
if !s.contains('.') {
return s.to_string();
}
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
fn format_g_scientific(x: f64, precision: usize) -> String {
let s = format!("{:.*e}", precision - 1, x);
rewrite_rust_e_as_c(&s, true)
}
fn format_e(x: f64, precision: usize) -> String {
let s = format!("{x:.precision$e}");
rewrite_rust_e_as_c(&s, false)
}
fn rewrite_rust_e_as_c(s: &str, trim_mantissa: bool) -> String {
let Some(e_pos) = s.find('e') else {
return s.to_string();
};
let mantissa = &s[..e_pos];
let exp_part = &s[e_pos + 1..];
let mantissa_out = if trim_mantissa && mantissa.contains('.') {
let t = mantissa.trim_end_matches('0').trim_end_matches('.');
t.to_string()
} else {
mantissa.to_string()
};
let (sign, digits) = if let Some(d) = exp_part.strip_prefix('-') {
('-', d)
} else if let Some(d) = exp_part.strip_prefix('+') {
('+', d)
} else {
('+', exp_part)
};
let exp_padded = if digits.len() < 2 {
format!("{sign}0{digits}")
} else {
format!("{sign}{digits}")
};
format!("{mantissa_out}e{exp_padded}")
}
#[cfg(test)]
mod tests {
use super::*;
fn fmt_default() -> ValueFormat {
ValueFormat::default()
}
#[test]
fn g_default_precision_matches_c() {
assert_eq!(format_g(475.123, 6), "475.123");
assert_eq!(format_g(1.0, 6), "1");
assert_eq!(format_g(0.0, 6), "0");
assert_eq!(format_g(1e-5, 6), "1e-05");
assert_eq!(format_g(1e10, 6), "1e+10");
assert_eq!(format_g(0.0001, 6), "0.0001");
assert_eq!(format_g(1234567.0, 6), "1.23457e+06");
}
#[test]
fn g_rounding_boundary_picks_scientific() {
assert_eq!(format_g(999999.5, 6), "1e+06");
assert_eq!(format_g(999998.0, 6), "999998");
assert_eq!(format_g(9.999995, 6), "10");
assert_eq!(format_g(-999999.5, 6), "-1e+06");
}
#[test]
fn g_extreme_magnitudes_do_not_produce_garbage() {
assert_eq!(format_g(1e-308, 6), "1e-308");
assert_eq!(format_g(5e-300, 6), "5e-300");
assert_eq!(format_g(1e308, 6), "1e+308");
assert!(format_g(f64::MIN_POSITIVE, 6).contains("e-"));
}
#[test]
fn e_format_matches_c() {
assert_eq!(format_e(1.5, 6), "1.500000e+00");
assert_eq!(format_e(1234.5, 2), "1.23e+03");
}
#[test]
fn classic_int_base_format_matches_sprint_long() {
assert_eq!(format_int_i64(-1, IntStyle::Hex), "0xFFFFFFFF");
assert_eq!(format_int_i64(-1, IntStyle::Oct), "0o37777777777");
assert_eq!(format_int_i64(-1, IntStyle::Bin), "1".repeat(32));
assert_eq!(format_int_i64(-1, IntStyle::Dec), "-1");
assert_eq!(format_int_i64(0, IntStyle::Bin), "0");
assert_eq!(format_int_i64(1235, IntStyle::Hex), "0x4D3");
assert_eq!(format_int_i64(1235, IntStyle::Oct), "0o2323");
assert_eq!(format_int_i64(1235, IntStyle::Bin), "10011010011");
}
#[test]
fn wide_int64_uint64_keep_64_bits_explicit() {
let mut hex = fmt_default();
hex.int_style = IntStyle::Hex;
assert_eq!(
format_value(&EpicsValue::Int64(-1), &hex, None, false),
"0xFFFFFFFFFFFFFFFF",
"Int64 -1 keeps the full 64-bit pattern, uppercase"
);
assert_eq!(
format_value(&EpicsValue::UInt64(u64::MAX), &fmt_default(), None, false),
u64::MAX.to_string()
);
let mut bin = fmt_default();
bin.int_style = IntStyle::Bin;
assert_eq!(
format_value(&EpicsValue::UInt64(5), &bin, None, false),
"101"
);
}
#[test]
fn array_renders_count_then_values() {
let v = EpicsValue::DoubleArray(vec![1.0, 2.5, 3.0]);
let s = format_value(&v, &fmt_default(), None, false);
assert_eq!(s, "3 1 2.5 3");
}
#[test]
fn single_element_array_omits_count_without_req_elems() {
let v = EpicsValue::DoubleArray(vec![2.5]);
assert_eq!(format_value(&v, &fmt_default(), None, false), "2.5");
assert_eq!(format_value(&v, &fmt_default(), None, true), "1 2.5");
let v2 = EpicsValue::DoubleArray(vec![1.0, 2.5]);
assert_eq!(format_value(&v2, &fmt_default(), None, false), "2 1 2.5");
}
#[test]
fn enum_with_strings_renders_string() {
let strs: Vec<PvString> = vec!["off".into(), "on".into()];
let v = EpicsValue::Enum(1);
let s = format_value(&v, &fmt_default(), Some(&strs), false);
assert_eq!(s, "on");
}
#[test]
fn enum_n_flag_renders_index() {
let strs: Vec<PvString> = vec!["off".into(), "on".into()];
let v = EpicsValue::Enum(1);
let mut fmt = fmt_default();
fmt.enum_as_number = true;
let s = format_value(&v, &fmt, Some(&strs), false);
assert_eq!(s, "1");
}
#[test]
fn char_array_long_string_strips_at_nul() {
let v = EpicsValue::CharArray(b"hello\0xxxx".to_vec());
let mut fmt = fmt_default();
fmt.char_array_as_string = true;
assert_eq!(format_value(&v, &fmt, None, false), "hello");
}
#[test]
fn cli_readback_escapes_raw_string_bytes() {
assert_eq!(escape_from_raw(b"a\tb\nc"), "a\\tb\\nc");
assert_eq!(escape_from_raw(b"a\\b\"c'd"), "a\\\\b\\\"c\\'d");
assert_eq!(escape_from_raw(&[0x00, 0x01, b'A', 0x7f]), "\\0\\x01A\\x7f");
assert_eq!(escape_from_raw(&[0xc3, 0xa9]), "\\xc3\\xa9");
assert_eq!(
format_value(
&EpicsValue::String("x\ty".into()),
&fmt_default(),
None,
false
),
"x\\ty"
);
let a = EpicsValue::StringArray(vec!["a\nb".into(), "c".into()]);
assert_eq!(format_value(&a, &fmt_default(), None, false), "2 a\\nb c");
let mut sfmt = fmt_default();
sfmt.char_array_as_string = true;
let cv = EpicsValue::CharArray(b"hi\tthere\0junk".to_vec());
assert_eq!(format_value(&cv, &sfmt, None, true), "hi\\tthere");
}
#[test]
fn float_as_int_rounds_then_renders() {
let v = EpicsValue::Double(1234.6);
let mut fmt = fmt_default();
fmt.float_as_int = true;
fmt.int_style = IntStyle::Hex;
assert_eq!(format_value(&v, &fmt, None, false), "0x4D3");
}
#[test]
fn pv_name_width_constant_is_30() {
assert_eq!(PV_NAME_WIDTH, 30);
}
#[test]
fn timeout_duration_clamps_pathological_floats() {
let d = timeout_duration(f64::NAN);
assert_eq!(d.as_secs_f64(), DEFAULT_CLI_TIMEOUT_SECS);
let d = timeout_duration(f64::INFINITY);
assert_eq!(d.as_secs_f64(), DEFAULT_CLI_TIMEOUT_SECS);
let d = timeout_duration(f64::NEG_INFINITY);
assert_eq!(d.as_secs_f64(), DEFAULT_CLI_TIMEOUT_SECS);
let d = timeout_duration(-1.0);
assert_eq!(d.as_secs_f64(), DEFAULT_CLI_TIMEOUT_SECS);
}
#[test]
fn timeout_duration_preserves_positive_finite() {
let d = timeout_duration(2.5);
assert!((d.as_secs_f64() - 2.5).abs() < 1e-9);
}
#[test]
fn timeout_zero_means_indefinite() {
assert_eq!(timeout_duration(0.0), INDEFINITE_TIMEOUT);
assert_eq!(timeout_duration(-0.0), INDEFINITE_TIMEOUT);
assert!(INDEFINITE_TIMEOUT.as_secs() > 365 * 24 * 60 * 60);
}
#[tokio::test]
async fn indefinite_timeout_arms_tokio_timer_without_panic() {
let r = tokio::time::timeout(INDEFINITE_TIMEOUT, async { 7 }).await;
assert_eq!(r.unwrap(), 7);
}
#[serial_test::serial]
#[test]
fn env_default_timeout_rejects_nan_inf() {
unsafe { std::env::set_var("EPICS_CLI_TIMEOUT", "NaN") };
assert_eq!(env_default_timeout(), DEFAULT_CLI_TIMEOUT_SECS);
unsafe { std::env::set_var("EPICS_CLI_TIMEOUT", "inf") };
assert_eq!(env_default_timeout(), DEFAULT_CLI_TIMEOUT_SECS);
unsafe { std::env::set_var("EPICS_CLI_TIMEOUT", "-3") };
assert_eq!(env_default_timeout(), DEFAULT_CLI_TIMEOUT_SECS);
unsafe { std::env::set_var("EPICS_CLI_TIMEOUT", "2.5") };
assert!((env_default_timeout() - 2.5).abs() < 1e-9);
unsafe { std::env::remove_var("EPICS_CLI_TIMEOUT") };
}
#[serial_test::serial]
#[test]
fn env_zero_resolves_to_indefinite() {
unsafe { std::env::set_var("EPICS_CLI_TIMEOUT", "0") };
assert_eq!(env_default_timeout(), 0.0);
assert_eq!(timeout_duration(env_default_timeout()), INDEFINITE_TIMEOUT);
unsafe { std::env::remove_var("EPICS_CLI_TIMEOUT") };
}
#[test]
fn max_elements_caps_array() {
let v = EpicsValue::LongArray((0..10).collect());
let mut fmt = fmt_default();
fmt.max_elements = Some(3);
let s = format_value(&v, &fmt, None, true);
assert_eq!(s, "10 0 1 2");
}
}