use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::server::iocsh::registry::{
ArgDesc, ArgType, ArgValue, CommandContext, CommandDef, CommandOutcome,
};
use super::backup::BackupConfig;
use super::format::CompatMode;
use super::macros::MacroContext;
use super::manager::AutosaveBuilder;
use super::save_set::{SaveSetConfig, SaveStrategy, TriggerMode};
#[derive(Debug, Clone)]
pub struct MonitorSetDef {
pub filename: String,
pub period: Duration,
pub macros: String,
}
#[derive(Debug, Clone)]
pub struct TriggeredSetDef {
pub filename: String,
pub trigger_pv: String,
pub macros: String,
}
const TRIGGER_POLL_INTERVAL: Duration = Duration::from_secs(1);
impl MonitorSetDef {
pub fn poll_period(seconds: i64) -> Duration {
Duration::from_secs(seconds.max(1) as u64)
}
}
fn is_valid_first_pv_char(name: &str) -> bool {
name.chars().next().is_some_and(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, '_' | '-' | '+' | ':' | '[' | ']' | '<' | '>' | ';')
})
}
#[derive(Debug, Clone)]
pub struct RestoreDef {
pub filename: String,
pub macros: String,
}
#[derive(Debug, Default)]
pub struct AutosaveStartupConfig {
pub request_file_paths: Vec<PathBuf>,
pub save_file_path: Option<PathBuf>,
pub status_prefix: Option<String>,
pub monitor_sets: Vec<MonitorSetDef>,
pub triggered_sets: Vec<TriggeredSetDef>,
pub pass0_restores: Vec<RestoreDef>,
pub pass1_restores: Vec<RestoreDef>,
pub compat: CompatMode,
}
impl AutosaveStartupConfig {
pub fn new() -> Self {
Self::default()
}
pub fn resolve_save_file(&self, filename: &str) -> PathBuf {
let base = filename.trim_end_matches(".req");
let sav_name = if base.ends_with(".sav") {
base.to_string()
} else {
format!("{base}.sav")
};
match &self.save_file_path {
Some(dir) => dir.join(&sav_name),
None => PathBuf::from(&sav_name),
}
}
pub fn into_builder(&self) -> AutosaveBuilder {
let mut builder = AutosaveBuilder::new().compat(self.compat);
if let Some(ref prefix) = self.status_prefix {
builder = builder.status_prefix(prefix);
}
for def in &self.monitor_sets {
let request_file = Some(PathBuf::from(&def.filename));
let save_path = self.resolve_save_file(&def.filename);
let macros = if def.macros.is_empty() {
HashMap::new()
} else {
MacroContext::parse_inline(&def.macros)
};
builder = builder.add_set(SaveSetConfig {
name: def.filename.clone(),
save_path,
strategy: SaveStrategy::Periodic {
interval: def.period,
},
request_file,
request_pvs: Vec::new(),
backup: BackupConfig::default(),
macros,
search_paths: self.request_file_paths.clone(),
});
}
for def in &self.triggered_sets {
let request_file = Some(PathBuf::from(&def.filename));
let save_path = self.resolve_save_file(&def.filename);
let macros = if def.macros.is_empty() {
HashMap::new()
} else {
MacroContext::parse_inline(&def.macros)
};
let strategy = SaveStrategy::Triggered {
trigger_pv: def.trigger_pv.clone(),
mode: TriggerMode::AnyChange,
poll_interval: TRIGGER_POLL_INTERVAL,
};
builder = builder.add_set(SaveSetConfig {
name: format!("{}_triggered", def.filename),
save_path,
strategy,
request_file,
request_pvs: Vec::new(),
backup: BackupConfig::default(),
macros,
search_paths: self.request_file_paths.clone(),
});
}
builder
}
pub fn register_startup_commands(holder: Arc<Mutex<Self>>) -> Vec<CommandDef> {
let mut commands = Vec::new();
{
let h = holder.clone();
commands.push(CommandDef::new(
"set_requestfile_path",
vec![
ArgDesc {
name: "path",
arg_type: ArgType::String,
},
ArgDesc {
name: "pathsub",
arg_type: ArgType::String,
},
],
"set_requestfile_path(path, pathsub) - Add request file search path",
move |args: &[ArgValue], _ctx: &CommandContext| {
let path = match &args[0] {
ArgValue::String(s) => s.clone(),
_ => return Err("path argument required".into()),
};
let full_path = match args.get(1) {
Some(ArgValue::String(sub)) if !sub.is_empty() => {
PathBuf::from(&path).join(sub)
}
_ => PathBuf::from(&path),
};
eprintln!("set_requestfile_path: {}", full_path.display());
h.lock().unwrap().request_file_paths.push(full_path);
Ok(CommandOutcome::Continue)
},
));
}
{
let h = holder.clone();
commands.push(CommandDef::new(
"set_savefile_path",
vec![
ArgDesc {
name: "path",
arg_type: ArgType::String,
},
ArgDesc {
name: "pathsub",
arg_type: ArgType::String,
},
],
"set_savefile_path(path, pathsub) - Set save file directory",
move |args: &[ArgValue], _ctx: &CommandContext| {
let path = match &args[0] {
ArgValue::String(s) => s.clone(),
_ => return Err("path argument required".into()),
};
let full_path = match args.get(1) {
Some(ArgValue::String(sub)) if !sub.is_empty() => {
PathBuf::from(&path).join(sub)
}
_ => PathBuf::from(&path),
};
eprintln!("set_savefile_path: {}", full_path.display());
if let Err(e) = std::fs::create_dir_all(&full_path) {
eprintln!(" warning: could not create directory: {e}");
}
h.lock().unwrap().save_file_path = Some(full_path);
Ok(CommandOutcome::Continue)
},
));
}
{
let h = holder.clone();
commands.push(CommandDef::new(
"create_monitor_set",
vec![
ArgDesc {
name: "filename",
arg_type: ArgType::String,
},
ArgDesc {
name: "period",
arg_type: ArgType::Int,
},
ArgDesc {
name: "macrostring",
arg_type: ArgType::String,
},
],
"create_monitor_set(filename, period, macrostring) - Create periodic save set",
move |args: &[ArgValue], _ctx: &CommandContext| {
let filename = match &args[0] {
ArgValue::String(s) => s.clone(),
_ => return Err("filename argument required".into()),
};
let period = match &args[1] {
ArgValue::Int(n) => MonitorSetDef::poll_period(*n),
_ => return Err("period argument required".into()),
};
let macros = match args.get(2) {
Some(ArgValue::String(s)) => s.clone(),
_ => String::new(),
};
eprintln!(
"create_monitor_set: {filename}, period={}s",
period.as_secs()
);
h.lock().unwrap().monitor_sets.push(MonitorSetDef {
filename,
period,
macros,
});
Ok(CommandOutcome::Continue)
},
));
}
{
let h = holder.clone();
commands.push(CommandDef::new(
"create_triggered_set",
vec![
ArgDesc {
name: "filename",
arg_type: ArgType::String,
},
ArgDesc {
name: "trigger_channel",
arg_type: ArgType::String,
},
ArgDesc {
name: "macrostring",
arg_type: ArgType::String,
},
],
"create_triggered_set(filename, trigger_channel, macrostring) - \
Create triggered save set (saves when trigger_channel changes)",
move |args: &[ArgValue], _ctx: &CommandContext| {
let filename = match &args[0] {
ArgValue::String(s) => s.clone(),
_ => return Err("filename argument required".into()),
};
let trigger_channel = match &args[1] {
ArgValue::String(s) if is_valid_first_pv_char(s) => s.clone(),
_ => {
return Err(
"save_restore:create_triggered_set: Error: trigger-channel \
name is required."
.into(),
);
}
};
let macros = match args.get(2) {
Some(ArgValue::String(s)) => s.clone(),
_ => String::new(),
};
eprintln!("create_triggered_set: {filename}, trigger={trigger_channel}");
h.lock().unwrap().triggered_sets.push(TriggeredSetDef {
filename,
trigger_pv: trigger_channel,
macros,
});
Ok(CommandOutcome::Continue)
},
));
}
{
let h = holder.clone();
commands.push(CommandDef::new(
"set_pass0_restoreFile",
vec![
ArgDesc {
name: "filename",
arg_type: ArgType::String,
},
ArgDesc {
name: "macrostring",
arg_type: ArgType::String,
},
],
"set_pass0_restoreFile(filename, macrostring) - Restore before device support init",
move |args: &[ArgValue], _ctx: &CommandContext| {
let filename = match &args[0] {
ArgValue::String(s) => s.clone(),
_ => return Err("filename argument required".into()),
};
let macros = match args.get(1) {
Some(ArgValue::String(s)) => s.clone(),
_ => String::new(),
};
eprintln!("set_pass0_restoreFile: {filename}");
h.lock()
.unwrap()
.pass0_restores
.push(RestoreDef { filename, macros });
Ok(CommandOutcome::Continue)
},
));
}
{
let h = holder.clone();
commands.push(CommandDef::new(
"set_pass1_restoreFile",
vec![
ArgDesc {
name: "filename",
arg_type: ArgType::String,
},
ArgDesc {
name: "macrostring",
arg_type: ArgType::String,
},
],
"set_pass1_restoreFile(filename, macrostring) - Restore after device support init",
move |args: &[ArgValue], _ctx: &CommandContext| {
let filename = match &args[0] {
ArgValue::String(s) => s.clone(),
_ => return Err("filename argument required".into()),
};
let macros = match args.get(1) {
Some(ArgValue::String(s)) => s.clone(),
_ => String::new(),
};
eprintln!("set_pass1_restoreFile: {filename}");
h.lock()
.unwrap()
.pass1_restores
.push(RestoreDef { filename, macros });
Ok(CommandOutcome::Continue)
},
));
}
{
let h = holder.clone();
commands.push(CommandDef::new(
"save_restoreSet_CompatMode",
vec![ArgDesc {
name: "mode",
arg_type: ArgType::String,
}],
"save_restoreSet_CompatMode(mode) - 'C'/'CRead' for C-readable .sav, \
else native",
move |args: &[ArgValue], _ctx: &CommandContext| {
let mode = match &args[0] {
ArgValue::String(s) => s.clone(),
_ => return Err("mode argument required".into()),
};
let compat =
if mode.eq_ignore_ascii_case("c") || mode.eq_ignore_ascii_case("cread") {
CompatMode::CRead
} else {
CompatMode::Native
};
eprintln!("save_restoreSet_CompatMode: {compat:?}");
h.lock().unwrap().compat = compat;
Ok(CommandOutcome::Continue)
},
));
}
{
let h = holder.clone();
commands.push(CommandDef::new(
"save_restoreSet_status_prefix",
vec![ArgDesc {
name: "prefix",
arg_type: ArgType::String,
}],
"save_restoreSet_status_prefix(prefix) - Set status PV prefix",
move |args: &[ArgValue], _ctx: &CommandContext| {
let prefix = match &args[0] {
ArgValue::String(s) => s.clone(),
_ => return Err("prefix argument required".into()),
};
eprintln!("save_restoreSet_status_prefix: {prefix}");
h.lock().unwrap().status_prefix = Some(prefix);
Ok(CommandOutcome::Continue)
},
));
}
commands
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::autosave::save_set::{SaveStrategy, TriggerMode};
use std::time::Duration;
fn with_request_file(cfg: &mut AutosaveStartupConfig, dir: &tempfile::TempDir) {
std::fs::write(dir.path().join("settings.req"), "IOC:setpoint\n").unwrap();
cfg.request_file_paths.push(dir.path().to_path_buf());
cfg.save_file_path = Some(dir.path().to_path_buf());
}
#[test]
fn triggered_set_maps_to_triggered_strategy() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = AutosaveStartupConfig::new();
with_request_file(&mut cfg, &dir);
cfg.triggered_sets.push(TriggeredSetDef {
filename: "settings.req".to_string(),
trigger_pv: "IOC:saveTrigger".to_string(),
macros: String::new(),
});
let builder = cfg.into_builder();
let rt = tokio::runtime::Runtime::new().unwrap();
let mgr = rt.block_on(builder.build());
let sets = mgr.sets();
assert_eq!(sets.len(), 1, "one triggered set expected");
match &sets[0].0.config().strategy {
SaveStrategy::Triggered {
trigger_pv,
mode,
poll_interval,
} => {
assert_eq!(trigger_pv.as_str(), "IOC:saveTrigger");
assert_eq!(*mode, TriggerMode::AnyChange);
assert_eq!(*poll_interval, Duration::from_secs(1));
}
other => panic!("expected SaveStrategy::Triggered, got {other:?}"),
}
}
#[test]
fn create_triggered_set_refuses_a_missing_trigger_channel() {
let holder = Arc::new(Mutex::new(AutosaveStartupConfig::new()));
let cmds = AutosaveStartupConfig::register_startup_commands(holder.clone());
let cmd = cmds
.iter()
.find(|c| c.name == "create_triggered_set")
.expect("registered");
let rt = tokio::runtime::Runtime::new().unwrap();
let bridge = {
let _guard = rt.enter();
crate::runtime::task::BlockingBridge::capture()
};
let ctx = CommandContext::new(Arc::new(crate::server::database::PvDatabase::new()), bridge);
for bad in ["", " IOC:trig", "*"] {
let err = cmd
.handler
.call(
&[
ArgValue::String("settings.req".to_string()),
ArgValue::String(bad.to_string()),
],
&ctx,
)
.err()
.expect("a trigger channel that is not a PV name must be refused");
assert!(err.contains("trigger-channel name is required"), "{err}");
}
assert!(
holder.lock().unwrap().triggered_sets.is_empty(),
"a refused call must leave no set behind"
);
}
}