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,
}
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")
));
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");
{
use tokio::io::AsyncWriteExt;
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp_path)
.await?;
file.write_all(content.as_bytes()).await?;
file.sync_all().await?;
}
tokio::fs::rename(&tmp_path, path).await?;
if let Some(parent) = path.parent() {
if let Ok(dir) = tokio::fs::File::open(parent).await {
let _ = dir.sync_all().await;
}
}
Ok(())
}
pub async fn read_save_file(path: &Path) -> AutosaveResult<Option<Vec<SaveEntry>>> {
let content = tokio::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(),
}
}
})?;
if !has_end_marker(&content) {
return Ok(None);
}
let entries = parse_save_content(&content);
Ok(Some(entries))
}
pub async fn validate_save_file(path: &Path) -> AutosaveResult<bool> {
let content = tokio::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) -> Vec<SaveEntry> {
let mut entries = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if line == END_MARKER {
break;
}
if line.starts_with('#') {
let inner = &line[1..];
if inner.contains("(not connected)") {
let pv_name = inner.split(['\t', ' ']).next().unwrap_or("").trim();
if !pv_name.is_empty() {
entries.push(SaveEntry {
pv_name: pv_name.to_string(),
value: String::new(),
connected: false,
});
}
}
continue;
}
if line.contains(ARRAY_MARKER) {
if let Some(entry) = parse_c_array_line(line, content) {
entries.push(entry);
continue;
}
}
if let Some(space_pos) = line.find(' ') {
let pv_name = &line[..space_pos];
let value = &line[space_pos + 1..];
entries.push(SaveEntry {
pv_name: pv_name.to_string(),
value: value.to_string(),
connected: true,
});
}
}
entries
}
fn parse_c_array_line(line: &str, _full_content: &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 = format!("[{}]", elements.join(","));
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.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::Char(v) => v.to_string(),
EpicsValue::DoubleArray(arr) => {
let parts: Vec<_> = arr.iter().map(|v| format!("{:.14e}", v)).collect();
format!("[{}]", parts.join(","))
}
EpicsValue::LongArray(arr) => {
let parts: Vec<_> = arr.iter().map(|v| v.to_string()).collect();
format!("[{}]", parts.join(","))
}
EpicsValue::CharArray(arr) => {
let parts: Vec<_> = arr.iter().map(|v| v.to_string()).collect();
format!("[{}]", parts.join(","))
}
EpicsValue::ShortArray(arr) => {
let parts: Vec<_> = arr.iter().map(|v| v.to_string()).collect();
format!("[{}]", parts.join(","))
}
EpicsValue::FloatArray(arr) => {
let parts: Vec<_> = arr.iter().map(|v| format!("{:.7e}", v)).collect();
format!("[{}]", parts.join(","))
}
EpicsValue::EnumArray(arr) => {
let parts: Vec<_> = arr.iter().map(|v| v.to_string()).collect();
format!("[{}]", parts.join(","))
}
EpicsValue::Int64Array(arr) => {
let parts: Vec<_> = arr.iter().map(|v| v.to_string()).collect();
format!("[{}]", parts.join(","))
}
EpicsValue::UInt64(v) => v.to_string(),
EpicsValue::UInt64Array(arr) => {
let parts: Vec<_> = arr.iter().map(|v| v.to_string()).collect();
format!("[{}]", parts.join(","))
}
EpicsValue::StringArray(arr) => {
let parts: Vec<_> = arr
.iter()
.map(|s| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")))
.collect();
format!("[{}]", parts.join(","))
}
}
}
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.clone(),
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::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::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))
} else {
Some(EpicsValue::String(s.to_string()))
}
}
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(_) => s.parse::<u16>().ok().map(EpicsValue::Enum),
EpicsValue::Char(_) => s.parse::<u8>().ok().map(EpicsValue::Char),
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::StringArray(_) => {
let inner = s.trim_start_matches('[').trim_end_matches(']');
if inner.is_empty() {
return Some(EpicsValue::StringArray(Vec::new()));
}
let mut out = Vec::new();
for tok in inner.split(',') {
let tok = tok.trim();
let unq = if tok.starts_with('"') && tok.ends_with('"') && tok.len() >= 2 {
tok[1..tok.len() - 1]
.replace("\\\"", "\"")
.replace("\\\\", "\\")
} else {
tok.to_string()
};
out.push(unq);
}
Some(EpicsValue::StringArray(out))
}
}
}
fn parse_array_str<T, F>(s: &str, parse_elem: F) -> Option<Vec<T>>
where
F: Fn(&str) -> Option<T>,
{
let inner = s.trim_start_matches('[').trim_end_matches(']');
if inner.is_empty() {
return Some(Vec::new());
}
inner.split(',').map(|v| parse_elem(v.trim())).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());
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]");
}
#[tokio::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 = tokio::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");
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]));
}
#[tokio::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 = tokio::fs::read_to_string(&path).await.unwrap();
assert!(content.starts_with("# autosave-rs"));
}
}