use std::path::Path;
use crate::types::EpicsValue;
use chrono::Local;
use super::error::{AutosaveError, AutosaveResult};
use super::format::{ARRAY_MARKER, CompatMode, END_MARKER, VERSION};
#[derive(Debug, Clone)]
pub struct SaveEntry {
pub pv_name: String,
pub value: String,
pub connected: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MalformedLine {
pub line_no: usize,
pub text: String,
}
#[derive(Debug, Clone, Default)]
pub struct SaveFileContents {
pub entries: Vec<SaveEntry>,
pub malformed: Vec<MalformedLine>,
pub not_connected: usize,
}
pub async fn write_save_file(path: &Path, entries: &[SaveEntry]) -> AutosaveResult<()> {
write_save_file_with_mode(path, entries, CompatMode::Native).await
}
pub async fn write_save_file_with_mode(
path: &Path,
entries: &[SaveEntry],
mode: CompatMode,
) -> AutosaveResult<()> {
let mut content = String::new();
let now = Local::now();
let banner = match mode {
CompatMode::Native => VERSION,
CompatMode::CRead => "save/restore V1.7",
};
content.push_str(&format!(
"# {}\t{}\n",
banner,
now.format("%Y-%m-%d %H:%M:%S")
));
let not_connected = entries.iter().filter(|e| !e.connected).count();
if not_connected > 0 {
content.push_str(&format!(
"! {not_connected} channel(s) not connected - or not all gets were successful\n"
));
}
for entry in entries {
if entry.connected {
content.push_str(&entry.pv_name);
content.push(' ');
content.push_str(&entry.value);
content.push('\n');
} else {
content.push_str(&format!("#{}\t(not connected)\n", entry.pv_name));
}
}
content.push_str(END_MARKER);
content.push('\n');
let tmp_path = path.with_extension("tmp");
let final_path = path.to_path_buf();
let parent = path.parent().map(|p| p.to_path_buf());
crate::runtime::fs::blocking(move || {
use std::io::Write as _;
{
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp_path)?;
file.write_all(content.as_bytes())?;
file.sync_all()?;
}
std::fs::rename(&tmp_path, &final_path)?;
if let Some(parent) = parent
&& let Ok(dir) = std::fs::File::open(parent)
{
let _ = dir.sync_all();
}
Ok(())
})
.await?;
Ok(())
}
pub async fn read_save_file(path: &Path) -> AutosaveResult<Option<SaveFileContents>> {
let read = read_partial_save_file(path).await?;
Ok(read.complete.then_some(read.contents))
}
#[derive(Debug, Clone, Default)]
pub struct PartialSaveFile {
pub contents: SaveFileContents,
pub complete: bool,
}
pub async fn read_partial_save_file(path: &Path) -> AutosaveResult<PartialSaveFile> {
let content = crate::runtime::fs::read_to_string(path)
.await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
e.into()
} else {
AutosaveError::CorruptSaveFile {
path: path.display().to_string(),
message: e.to_string(),
}
}
})?;
Ok(PartialSaveFile {
complete: has_end_marker(&content),
contents: parse_save_content(&content),
})
}
pub async fn validate_save_file(path: &Path) -> AutosaveResult<bool> {
let content = crate::runtime::fs::read_to_string(path).await?;
Ok(has_end_marker(&content))
}
fn has_end_marker(content: &str) -> bool {
for line in content.lines().rev() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
return trimmed == END_MARKER;
}
false
}
fn parse_save_content(content: &str) -> SaveFileContents {
let mut out = SaveFileContents::default();
for (idx, raw) in content.lines().enumerate() {
let line = raw.trim_start();
let framing = line.trim_end();
if framing.is_empty() {
continue;
}
if framing == END_MARKER {
break;
}
let malformed = |out: &mut SaveFileContents| {
out.malformed.push(MalformedLine {
line_no: idx + 1,
text: raw.to_string(),
});
};
if let Some(rest) = framing.strip_prefix('!') {
let digits: String = rest
.trim_start()
.chars()
.take_while(char::is_ascii_digit)
.collect();
out.not_connected = digits.parse().unwrap_or(0);
continue;
}
if framing.starts_with('#') {
let inner = &framing[1..];
if inner.contains("(not connected)")
|| inner.contains("Search Issued")
|| inner.contains("Search Failed")
{
let pv_name = inner.split(['\t', ' ']).next().unwrap_or("").trim();
if !pv_name.is_empty() {
out.entries.push(SaveEntry {
pv_name: pv_name.to_string(),
value: String::new(),
connected: false,
});
}
}
continue;
}
if framing.contains(ARRAY_MARKER) {
match parse_c_array_line(framing) {
Some(entry) => out.entries.push(entry),
None => malformed(&mut out),
}
continue;
}
match line.split_once(' ') {
Some((pv_name, value)) if !pv_name.is_empty() => out.entries.push(SaveEntry {
pv_name: pv_name.to_string(),
value: value.to_string(),
connected: true,
}),
_ => malformed(&mut out),
}
}
out
}
fn encode_array_text<I, S>(elements: I) -> String
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut out = String::from("[");
for (i, elem) in elements.into_iter().enumerate() {
if i > 0 {
out.push(',');
}
let elem = elem.as_ref();
let ambiguous = elem.is_empty()
|| elem.contains([',', ']', '"', '\\'])
|| elem.starts_with(char::is_whitespace)
|| elem.ends_with(char::is_whitespace);
if ambiguous {
out.push('"');
for c in elem.chars() {
if c == '"' || c == '\\' {
out.push('\\');
}
out.push(c);
}
out.push('"');
} else {
out.push_str(elem);
}
}
out.push(']');
out
}
fn decode_array_text(s: &str) -> Vec<String> {
let inner = s.strip_prefix('[').unwrap_or(s);
let inner = inner.strip_suffix(']').unwrap_or(inner);
let mut out = Vec::new();
let mut chars = inner.chars().peekable();
loop {
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
match chars.peek() {
None if out.is_empty() => break,
None => {
out.push(String::new());
break;
}
Some('"') => {
chars.next();
let mut elem = String::new();
loop {
match chars.next() {
Some('\\') => {
if let Some(c) = chars.next() {
elem.push(c);
}
}
Some('"') | None => break,
Some(c) => elem.push(c),
}
}
out.push(elem);
while chars.peek().is_some_and(|&c| c != ',') {
chars.next();
}
}
Some(_) => {
let mut elem = String::new();
while chars.peek().is_some_and(|&c| c != ',') {
elem.push(chars.next().unwrap());
}
out.push(elem.trim_end().to_string());
}
}
match chars.next() {
Some(',') => continue,
_ => break,
}
}
out
}
fn parse_c_array_line(line: &str) -> Option<SaveEntry> {
let marker_pos = line.find(ARRAY_MARKER)?;
let pv_name = line[..marker_pos].trim();
let rest = line[marker_pos + ARRAY_MARKER.len()..].trim();
if !rest.starts_with('{') || !rest.ends_with('}') {
return None;
}
let inner = rest[1..rest.len() - 1].trim();
let elements = parse_c_array_elements(inner);
let value = encode_array_text(&elements);
Some(SaveEntry {
pv_name: pv_name.to_string(),
value,
connected: true,
})
}
fn parse_c_array_elements(s: &str) -> Vec<String> {
let mut elements = Vec::new();
let mut chars = s.chars().peekable();
loop {
while chars.peek().map_or(false, |c| c.is_whitespace()) {
chars.next();
}
if chars.peek().is_none() {
break;
}
if chars.peek() == Some(&'"') {
chars.next(); let mut elem = String::new();
loop {
match chars.next() {
Some('\\') => {
if let Some(c) = chars.next() {
elem.push(c);
}
}
Some('"') => break,
Some(c) => elem.push(c),
None => break,
}
}
elements.push(elem);
} else {
let mut elem = String::new();
while chars.peek().map_or(false, |c| !c.is_whitespace()) {
elem.push(chars.next().unwrap());
}
if !elem.is_empty() {
elements.push(elem);
}
}
}
elements
}
pub fn value_to_save_str(value: &EpicsValue) -> String {
match value {
EpicsValue::String(s) => format!(
"\"{}\"",
s.as_str_lossy().replace('\\', "\\\\").replace('"', "\\\"")
),
EpicsValue::Double(v) => format!("{:.14e}", v),
EpicsValue::Float(v) => format!("{:.7e}", v),
EpicsValue::Short(v) => v.to_string(),
EpicsValue::Long(v) => v.to_string(),
EpicsValue::Int64(v) => v.to_string(),
EpicsValue::Enum(v) => v.to_string(),
EpicsValue::EnumWithChoices { index, .. } => index.to_string(),
EpicsValue::Char(v) => v.to_string(),
EpicsValue::DoubleArray(arr) => {
encode_array_text(arr.iter().map(|v| format!("{:.14e}", v)))
}
EpicsValue::LongArray(arr) => encode_array_text(arr.iter().map(|v| v.to_string())),
EpicsValue::CharArray(arr) => encode_array_text(arr.iter().map(|v| v.to_string())),
EpicsValue::ShortArray(arr) => encode_array_text(arr.iter().map(|v| v.to_string())),
EpicsValue::FloatArray(arr) => encode_array_text(arr.iter().map(|v| format!("{:.7e}", v))),
EpicsValue::EnumArray(arr) => encode_array_text(arr.iter().map(|v| v.to_string())),
EpicsValue::Int64Array(arr) => encode_array_text(arr.iter().map(|v| v.to_string())),
EpicsValue::UInt64(v) => v.to_string(),
EpicsValue::UInt64Array(arr) => encode_array_text(arr.iter().map(|v| v.to_string())),
EpicsValue::UShort(v) => v.to_string(),
EpicsValue::ULong(v) => v.to_string(),
EpicsValue::UShortArray(arr) => encode_array_text(arr.iter().map(|v| v.to_string())),
EpicsValue::ULongArray(arr) => encode_array_text(arr.iter().map(|v| v.to_string())),
EpicsValue::UChar(v) => v.to_string(),
EpicsValue::UCharArray(arr) => encode_array_text(arr.iter().map(|v| v.to_string())),
EpicsValue::StringArray(arr) => {
encode_array_text(arr.iter().map(|s| s.as_str_lossy().into_owned()))
}
}
}
pub fn value_to_save_str_c(value: &EpicsValue) -> String {
fn esc(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn c_array<T, I>(iter: I) -> String
where
I: IntoIterator<Item = T>,
T: ToString,
{
let parts: Vec<String> = iter
.into_iter()
.map(|v| format!("\"{}\"", esc(&v.to_string())))
.collect();
format!("{ARRAY_MARKER} {{ {} }}", parts.join(" "))
}
match value {
EpicsValue::String(s) => s.as_str_lossy().into_owned(),
EpicsValue::Double(v) => format!("{:.14e}", v),
EpicsValue::Float(v) => format!("{:.7e}", v),
EpicsValue::Short(v) => v.to_string(),
EpicsValue::Long(v) => v.to_string(),
EpicsValue::Int64(v) => v.to_string(),
EpicsValue::Enum(v) => v.to_string(),
EpicsValue::EnumWithChoices { index, .. } => index.to_string(),
EpicsValue::Char(v) => v.to_string(),
EpicsValue::DoubleArray(arr) => c_array(arr.iter().map(|v| format!("{:.14e}", v))),
EpicsValue::FloatArray(arr) => c_array(arr.iter().map(|v| format!("{:.7e}", v))),
EpicsValue::LongArray(arr) => c_array(arr.iter()),
EpicsValue::CharArray(arr) => c_array(arr.iter()),
EpicsValue::ShortArray(arr) => c_array(arr.iter()),
EpicsValue::EnumArray(arr) => c_array(arr.iter()),
EpicsValue::Int64Array(arr) => c_array(arr.iter()),
EpicsValue::UInt64(v) => v.to_string(),
EpicsValue::UInt64Array(arr) => c_array(arr.iter()),
EpicsValue::UShort(v) => v.to_string(),
EpicsValue::ULong(v) => v.to_string(),
EpicsValue::UShortArray(arr) => c_array(arr.iter()),
EpicsValue::ULongArray(arr) => c_array(arr.iter()),
EpicsValue::UChar(v) => v.to_string(),
EpicsValue::UCharArray(arr) => c_array(arr.iter()),
EpicsValue::StringArray(arr) => c_array(arr.iter().cloned()),
}
}
pub fn parse_save_value(s: &str, template: &EpicsValue) -> Option<EpicsValue> {
let s = s.trim();
match template {
EpicsValue::String(_) => {
if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
let inner = &s[1..s.len() - 1];
let unescaped = inner.replace("\\\"", "\"").replace("\\\\", "\\");
Some(EpicsValue::String(unescaped.into()))
} else {
Some(EpicsValue::String(s.to_string().into()))
}
}
EpicsValue::Double(_) => s.parse::<f64>().ok().map(EpicsValue::Double),
EpicsValue::Float(_) => s.parse::<f32>().ok().map(EpicsValue::Float),
EpicsValue::Long(_) => s.parse::<i32>().ok().map(EpicsValue::Long),
EpicsValue::Int64(_) => s.parse::<i64>().ok().map(EpicsValue::Int64),
EpicsValue::UInt64(_) => s.parse::<u64>().ok().map(EpicsValue::UInt64),
EpicsValue::Short(_) => s.parse::<i16>().ok().map(EpicsValue::Short),
EpicsValue::Enum(_) | EpicsValue::EnumWithChoices { .. } => {
s.parse::<u16>().ok().map(EpicsValue::Enum)
}
EpicsValue::Char(_) => s.parse::<u8>().ok().map(EpicsValue::Char),
EpicsValue::UChar(_) => s.parse::<u8>().ok().map(EpicsValue::UChar),
EpicsValue::DoubleArray(_) => {
parse_array_str(s, |v| v.parse::<f64>().ok()).map(EpicsValue::DoubleArray)
}
EpicsValue::LongArray(_) => {
parse_array_str(s, |v| v.parse::<i32>().ok()).map(EpicsValue::LongArray)
}
EpicsValue::CharArray(_) => {
parse_array_str(s, |v| v.parse::<u8>().ok()).map(EpicsValue::CharArray)
}
EpicsValue::ShortArray(_) => {
parse_array_str(s, |v| v.parse::<i16>().ok()).map(EpicsValue::ShortArray)
}
EpicsValue::FloatArray(_) => {
parse_array_str(s, |v| v.parse::<f32>().ok()).map(EpicsValue::FloatArray)
}
EpicsValue::EnumArray(_) => {
parse_array_str(s, |v| v.parse::<u16>().ok()).map(EpicsValue::EnumArray)
}
EpicsValue::Int64Array(_) => {
parse_array_str(s, |v| v.parse::<i64>().ok()).map(EpicsValue::Int64Array)
}
EpicsValue::UInt64Array(_) => {
parse_array_str(s, |v| v.parse::<u64>().ok()).map(EpicsValue::UInt64Array)
}
EpicsValue::UShort(_) => s.parse::<u16>().ok().map(EpicsValue::UShort),
EpicsValue::ULong(_) => s.parse::<u32>().ok().map(EpicsValue::ULong),
EpicsValue::UShortArray(_) => {
parse_array_str(s, |v| v.parse::<u16>().ok()).map(EpicsValue::UShortArray)
}
EpicsValue::ULongArray(_) => {
parse_array_str(s, |v| v.parse::<u32>().ok()).map(EpicsValue::ULongArray)
}
EpicsValue::UCharArray(_) => {
parse_array_str(s, |v| v.parse::<u8>().ok()).map(EpicsValue::UCharArray)
}
EpicsValue::StringArray(_) => Some(EpicsValue::StringArray(
decode_array_text(s).into_iter().map(Into::into).collect(),
)),
}
}
fn parse_array_str<T, F>(s: &str, parse_elem: F) -> Option<Vec<T>>
where
F: Fn(&str) -> Option<T>,
{
decode_array_text(s).iter().map(|v| parse_elem(v)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::autosave::format::CompatMode;
use crate::types::EpicsValue;
#[test]
fn c_format_scalar_string_unquoted() {
let v = EpicsValue::String("hello world".to_string().into());
assert_eq!(value_to_save_str_c(&v), "hello world");
assert_eq!(value_to_save_str(&v), "\"hello world\"");
}
#[test]
fn c_format_array_uses_at_array_form() {
let v = EpicsValue::LongArray(vec![1, 2, 3]);
assert_eq!(value_to_save_str_c(&v), "@array@ { \"1\" \"2\" \"3\" }");
assert_eq!(value_to_save_str(&v), "[1,2,3]");
}
#[epics_macros_rs::epics_test]
async fn c_compat_save_file_has_c_banner_and_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("c.sav");
let entries = vec![
SaveEntry {
pv_name: "PV:SCALAR".to_string(),
value: value_to_save_str_c(&EpicsValue::Long(42)),
connected: true,
},
SaveEntry {
pv_name: "PV:ARRAY".to_string(),
value: value_to_save_str_c(&EpicsValue::LongArray(vec![10, 20])),
connected: true,
},
];
write_save_file_with_mode(&path, &entries, CompatMode::CRead)
.await
.unwrap();
let content = crate::runtime::fs::read_to_string(&path).await.unwrap();
assert!(
content.starts_with("# save/restore"),
"C-compat file must carry the save/restore banner, got: {content}"
);
assert!(content.contains("PV:ARRAY @array@ { \"10\" \"20\" }"));
let read = read_save_file(&path)
.await
.unwrap()
.expect("valid file")
.entries;
assert_eq!(read.len(), 2);
let arr = read.iter().find(|e| e.pv_name == "PV:ARRAY").unwrap();
assert_eq!(arr.value, "[10,20]");
let parsed = parse_save_value(&arr.value, &EpicsValue::LongArray(vec![])).unwrap();
assert_eq!(parsed, EpicsValue::LongArray(vec![10, 20]));
}
#[epics_macros_rs::epics_test]
async fn native_save_file_keeps_native_banner() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("native.sav");
write_save_file(
&path,
&[SaveEntry {
pv_name: "PV1".to_string(),
value: "1".to_string(),
connected: true,
}],
)
.await
.unwrap();
let content = crate::runtime::fs::read_to_string(&path).await.unwrap();
assert!(content.starts_with("# autosave-rs"));
}
}