mod access_commands;
mod commands;
mod core_commands;
pub mod registry;
pub use commands::macro_defn_pairs;
use std::collections::HashMap;
use std::fs::File;
use std::sync::{Arc, RwLock};
use crate::server::database::PvDatabase;
use registry::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
enum OnError {
#[default]
Continue,
Break,
Halt,
Wait(u64),
}
pub struct IocShell {
registry: Arc<RwLock<CommandRegistry>>,
ctx: CommandContext,
on_error: std::cell::Cell<OnError>,
}
impl IocShell {
pub fn new(db: Arc<PvDatabase>, bridge: crate::runtime::task::BlockingBridge) -> Self {
let mut registry = CommandRegistry::new();
commands::register_builtins(&mut registry);
crate::runtime::env::register_iocsh_env_vars();
Self {
registry: Arc::new(RwLock::new(registry)),
ctx: CommandContext::new(db, bridge),
on_error: std::cell::Cell::new(OnError::Continue),
}
}
pub fn register(&self, def: CommandDef) {
self.registry.write().unwrap().register(def);
}
pub fn execute_line(&self, line: &str) -> CommandResult {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return Ok(CommandOutcome::Continue);
}
if let Some(rest) = line.strip_prefix('<') {
let filename = registry::substitute_env_vars(rest.trim());
return self
.execute_script(&filename)
.map(|_| CommandOutcome::Continue);
}
{
let toks = tokenize(line);
match toks.first().map(|s| s.as_str()) {
Some("iocshLoad") => {
if toks.len() < 2 {
return Err("iocshLoad <path> [macros]".into());
}
let macros = toks
.get(2)
.map(|s| commands::parse_macro_string(s))
.unwrap_or_default();
return self
.execute_script_with_macros(&toks[1], ¯os)
.map(|_| CommandOutcome::Continue);
}
Some("iocshCmd") => {
let Some(cmd) = toks.get(1) else {
return Err("iocshCmd <command>".into());
};
return self.execute_line(cmd);
}
Some("iocshRun") => {
let Some(cmds) = toks.get(1) else {
return Err("iocshRun <commands>".into());
};
let mut last = Ok(CommandOutcome::Continue);
for one in cmds.split(';') {
let one = one.trim();
if one.is_empty() {
continue;
}
match self.execute_line(one) {
Ok(CommandOutcome::Exit) => return Ok(CommandOutcome::Exit),
Ok(CommandOutcome::Continue) => {}
Err(e) => last = Err(e),
}
}
return last;
}
Some("on") => {
return self.handle_on_command(&toks);
}
_ => {}
}
}
let (cmd_line, redirect) = parse_redirect(line);
if let Some(redir) = redirect {
let result = self.execute_command(cmd_line, Some(&redir));
return result;
}
self.execute_command(cmd_line, None)
}
fn execute_command(&self, line: &str, redirect: Option<&Redirect>) -> CommandResult {
let Some(redir) = redirect else {
return self.execute_command_inner(line);
};
if redir.fd != 1 {
eprintln!(
"iocsh: fd {} redirect (stderr/other) not plumbed — \
stdout left intact, '{}' not captured",
redir.fd, redir.path
);
return self.execute_command_inner(line);
}
let file_result = if redir.append {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&redir.path)
} else {
File::create(&redir.path)
};
match file_result {
Ok(file) => self
.ctx
.with_output(file, || self.execute_command_inner(line)),
Err(e) => {
eprintln!("cannot open '{}': {}", redir.path, e);
Ok(CommandOutcome::Continue)
}
}
}
fn execute_command_inner(&self, line: &str) -> CommandResult {
if let Some(diag) = registry::lint_line(line) {
return Err(diag.to_string());
}
let tokens = tokenize(line);
if tokens.is_empty() {
return Ok(CommandOutcome::Continue);
}
let cmd_name = &tokens[0];
let arg_tokens = &tokens[1..];
let registry = self.registry.read().unwrap();
if cmd_name == "help" {
return self.execute_help(arg_tokens, ®istry);
}
let def = registry
.get(cmd_name)
.ok_or_else(|| format!("unknown command: '{cmd_name}'"))?;
let args = parse_args(arg_tokens, &def.args)?;
def.handler.call(&args, &self.ctx)
}
pub fn execute_script_with_macros(
&self,
path: &str,
macros: &HashMap<String, String>,
) -> Result<(), String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("cannot read '{path}': {e}"))?;
let mut last_err: Option<String> = None;
for (line_num, line) in join_backslash_continuations(&content) {
let expanded = if macros.is_empty() {
line
} else {
crate::server::db_loader::substitute_macros(&line, macros)
};
if echoes_script_line(&expanded) {
println!("{expanded}");
}
match self.execute_line(&expanded) {
Ok(CommandOutcome::Continue) => {}
Ok(CommandOutcome::Exit) => {
return last_err.map(Err).unwrap_or(Ok(()));
}
Err(e) => {
eprintln!("{path}:{line_num}: Error: {e}");
let formatted = format!("{path}:{line_num}: {e}");
if self.react_to_error() {
return Err(formatted);
}
last_err = Some(formatted);
}
}
}
last_err.map(Err).unwrap_or(Ok(()))
}
pub fn execute_script(&self, path: &str) -> Result<(), String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("cannot read '{}': {}", path, e))?;
let mut last_err: Option<String> = None;
for (line_num, line) in join_backslash_continuations(&content) {
if echoes_script_line(&line) {
println!("{line}");
}
match self.execute_line(&line) {
Ok(CommandOutcome::Continue) => {}
Ok(CommandOutcome::Exit) => {
return last_err.map(Err).unwrap_or(Ok(()));
}
Err(e) => {
eprintln!("{path}:{line_num}: Error: {e}");
let formatted = format!("{path}:{line_num}: {e}");
if self.react_to_error() {
return Err(formatted);
}
last_err = Some(formatted);
}
}
}
last_err.map(Err).unwrap_or(Ok(()))
}
pub fn run_repl(&self) -> Result<(), String> {
#[cfg(not(target_os = "rtems"))]
{
use std::io::IsTerminal;
let histedit_disabled = crate::runtime::env_table::IOCSH_HISTEDIT_DISABLE
.get()
.is_some();
if std::io::stdin().is_terminal() && !histedit_disabled {
return self.run_repl_interactive();
}
}
self.run_repl_piped()
}
#[cfg(not(target_os = "rtems"))]
fn run_repl_interactive(&self) -> Result<(), String> {
let history_size =
usize::try_from(crate::runtime::env_table::IOCSH_HISTSIZE.long_or_default())
.unwrap_or(0);
let config = rustyline::Config::builder()
.max_history_size(history_size)
.map_err(|e| format!("invalid rustyline history config: {e}"))?
.build();
let mut rl = rustyline::DefaultEditor::with_config(config)
.map_err(|e| format!("failed to initialize readline: {e}"))?;
let want_color = use_ansi_color();
let ps1 = crate::runtime::env_table::IOCSH_PS1
.get()
.unwrap_or_default();
let raw_prompt = strip_ansi(&ps1);
let styled_prompt = if want_color { ps1 } else { raw_prompt.clone() };
let prompt = (raw_prompt.as_str(), styled_prompt.as_str());
loop {
match rl.readline(&prompt) {
Ok(line) => {
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
let _ = rl.add_history_entry(&line);
match self.execute_line(&line) {
Ok(CommandOutcome::Continue) => {}
Ok(CommandOutcome::Exit) => break,
Err(e) => eprintln!("{}", format_error(&e, want_color)),
}
}
Err(rustyline::error::ReadlineError::Eof) => break,
Err(rustyline::error::ReadlineError::Interrupted) => continue,
Err(e) => {
eprintln!(
"{}",
format_error(&format!("readline error: {e}"), want_color)
);
break;
}
}
}
Ok(())
}
fn run_repl_piped(&self) -> Result<(), String> {
use std::io::BufRead;
let stdin = std::io::stdin();
let mut handle = stdin.lock();
let mut line = String::new();
loop {
line.clear();
match handle.read_line(&mut line) {
Ok(0) => break, Ok(_) => {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match self.execute_line(trimmed) {
Ok(CommandOutcome::Continue) => {}
Ok(CommandOutcome::Exit) => break,
Err(e) => eprintln!("Error: {e}"),
}
}
Err(e) => {
eprintln!("stdin read error: {e}");
break;
}
}
}
Ok(())
}
fn handle_on_command(&self, toks: &[String]) -> CommandResult {
if toks.get(1).map(|s| s.as_str()) != Some("error") {
return Err("on error continue|break|halt|wait <delay>".into());
}
let mode = match toks.get(2).map(|s| s.as_str()) {
Some("continue") => OnError::Continue,
Some("break") => OnError::Break,
Some("halt") => OnError::Halt,
Some("wait") => {
let delay = toks
.get(3)
.and_then(|s| s.parse::<u64>().ok())
.ok_or("on error wait <delay-seconds>")?;
OnError::Wait(delay)
}
_ => return Err("on error continue|break|halt|wait <delay>".into()),
};
self.on_error.set(mode);
Ok(CommandOutcome::Continue)
}
fn react_to_error(&self) -> bool {
match self.on_error.get() {
OnError::Continue => false,
OnError::Break | OnError::Halt => true,
OnError::Wait(delay) => {
if delay > 0 {
std::thread::sleep(std::time::Duration::from_secs(delay));
}
false
}
}
}
fn execute_help(&self, arg_tokens: &[String], registry: &CommandRegistry) -> CommandResult {
if let Some(name) = arg_tokens.first() {
if let Some(def) = registry.get(name) {
self.ctx.println(&def.usage);
} else {
self.ctx.println(&format!("unknown command: '{name}'"));
}
} else {
self.ctx.println("Available commands:");
for name in registry.list() {
self.ctx.println(&format!(" {name}"));
}
}
Ok(CommandOutcome::Continue)
}
}
#[cfg(not(target_os = "rtems"))]
fn use_ansi_color() -> bool {
if std::env::var_os("NO_COLOR").is_some() {
return false;
}
if let Ok(v) = std::env::var("EPICS_RS_IOCSH_NO_COLOR") {
let t = v.trim().to_ascii_uppercase();
if matches!(t.as_str(), "1" | "YES" | "TRUE" | "ON") {
return false;
}
}
true
}
#[cfg(not(target_os = "rtems"))]
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c != '\x1b' {
out.push(c);
continue;
}
if chars.next() != Some('[') {
continue;
}
for c in chars.by_ref() {
if ('\x40'..='\x7e').contains(&c) {
break;
}
}
}
out
}
#[cfg(not(target_os = "rtems"))]
fn format_error(msg: &str, color: bool) -> String {
if color {
format!("\x1b[1;31mError:\x1b[0m {msg}")
} else {
format!("Error: {msg}")
}
}
fn echoes_script_line(line: &str) -> bool {
!line.trim_start().starts_with("#-")
}
pub(crate) fn join_backslash_continuations(input: &str) -> Vec<(usize, String)> {
let mut out = Vec::new();
let mut current = String::new();
let mut start_line: Option<usize> = None;
for (idx, line) in input.lines().enumerate() {
let physical_no = idx + 1;
if start_line.is_none() {
start_line = Some(physical_no);
}
if let Some(stripped) = line.strip_suffix('\\') {
current.push_str(stripped);
} else {
current.push_str(line);
out.push((
start_line.take().unwrap_or(physical_no),
std::mem::take(&mut current),
));
}
}
if !current.is_empty() {
out.push((start_line.unwrap_or(1), current));
}
out
}
struct Redirect {
path: String,
append: bool,
fd: u8,
}
fn parse_redirect(line: &str) -> (&str, Option<Redirect>) {
let bytes = line.as_bytes();
let mut in_quote = false;
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'"' => in_quote = !in_quote,
b'>' if !in_quote => {
let (op_start, fd) = if i > 0 && bytes[i - 1].is_ascii_digit() {
let d = bytes[i - 1];
let at_boundary =
i == 1 || matches!(bytes[i - 2], b' ' | b'\t' | b'\r' | b'\n');
if at_boundary && (b'1'..=b'9').contains(&d) {
(i - 1, d - b'0')
} else {
(i, 1u8)
}
} else {
(i, 1u8)
};
let is_append = i + 1 < bytes.len() && bytes[i + 1] == b'>';
let cmd = line[..op_start].trim_end();
let skip = if is_append { 2 } else { 1 };
let path = line[i + skip..].trim();
if path.is_empty() {
return (line, None);
}
return (
cmd,
Some(Redirect {
path: registry::substitute_env_vars(path),
append: is_append,
fd,
}),
);
}
_ => {}
}
i += 1;
}
(line, None)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::records::ai::AiRecord;
#[test]
fn echoes_script_line_suppresses_only_hash_dash() {
assert!(!echoes_script_line("#-"));
assert!(!echoes_script_line("#- a quiet note"));
assert!(!echoes_script_line("#-nospace"));
assert!(!echoes_script_line(" #- leading whitespace"));
assert!(echoes_script_line("#"));
assert!(echoes_script_line("# normal comment"));
assert!(echoes_script_line("# -space after hash"));
assert!(echoes_script_line("dbLoadRecords(\"x.db\")"));
assert!(echoes_script_line(""));
}
fn make_shell() -> IocShell {
let rt = tokio::runtime::Runtime::new().unwrap();
let db = Arc::new(PvDatabase::new());
let bridge = {
let _guard = rt.enter();
crate::runtime::task::BlockingBridge::capture()
};
rt.block_on(async {
db.add_record("TEST_REC", Box::new(AiRecord::new(42.0)))
.await
.unwrap();
});
std::mem::forget(rt);
IocShell::new(db, bridge)
}
#[test]
fn a_shell_publishes_the_version_macros_a_db_can_expand() {
let _shell = make_shell();
assert_eq!(
registry::substitute_env_vars("$(EPICS_VERSION_FULL)"),
crate::runtime::version::EPICS_VERSION_FULL,
);
assert_eq!(
registry::substitute_env_vars("${ARCH}"),
crate::runtime::build_info::TARGET_ARCH,
);
}
#[test]
fn command_def_is_clone_and_handler_shared() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let cmd = CommandDef::new(
"myCmd",
vec![],
"myCmd — count invocations",
move |_args: &[ArgValue], _ctx: &CommandContext| {
calls_clone.fetch_add(1, Ordering::Relaxed);
Ok(CommandOutcome::Continue)
},
);
let cmd_dup = cmd.clone();
let shell = make_shell();
shell.register(cmd);
shell.execute_line("myCmd").unwrap();
let shell2 = make_shell();
shell2.register(cmd_dup);
shell2.execute_line("myCmd").unwrap();
assert_eq!(calls.load(Ordering::Relaxed), 2);
}
#[test]
fn test_execute_line_dbl() {
let shell = make_shell();
let result = shell.execute_line("dbl");
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_execute_line_unknown() {
let shell = make_shell();
let result = shell.execute_line("nonexistent_cmd");
assert!(result.is_err());
}
#[test]
fn test_execute_line_empty() {
let shell = make_shell();
let result = shell.execute_line("");
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_execute_line_comment() {
let shell = make_shell();
let result = shell.execute_line("# this is a comment");
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_execute_line_missing_required_arg() {
let shell = make_shell();
let result = shell.execute_line("dbgf");
assert!(result.is_err());
}
#[test]
fn test_execute_line_help() {
let shell = make_shell();
let result = shell.execute_line("help");
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_execute_line_help_specific() {
let shell = make_shell();
let result = shell.execute_line("help dbl");
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_execute_line_include_syntax() {
let shell = make_shell();
let result = shell.execute_line("< nonexistent_file.cmd");
assert!(result.is_err());
}
#[test]
fn test_register_custom_command() {
let shell = make_shell();
shell.register(CommandDef::new(
"myCmd",
vec![],
"myCmd - custom command",
|_args: &[ArgValue], _ctx: &CommandContext| Ok(CommandOutcome::Continue),
));
let result = shell.execute_line("myCmd");
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_redirect_dbl_to_file() {
let shell = make_shell();
let tmp = std::env::temp_dir().join("iocsh_test_dbl_redirect.txt");
let _ = std::fs::remove_file(&tmp);
let line = format!("dbl > {}", tmp.display());
let result = shell.execute_line(&line);
assert!(matches!(result, Ok(CommandOutcome::Continue)));
let content = std::fs::read_to_string(&tmp).unwrap();
assert!(
content.contains("TEST_REC"),
"dbl output should contain TEST_REC, got: {content}"
);
std::fs::remove_file(&tmp).ok();
}
#[test]
fn test_redirect_append() {
let shell = make_shell();
let tmp = std::env::temp_dir().join("iocsh_test_append.txt");
std::fs::write(&tmp, "existing\n").unwrap();
let line = format!("dbl >> {}", tmp.display());
let result = shell.execute_line(&line);
assert!(matches!(result, Ok(CommandOutcome::Continue)));
let content = std::fs::read_to_string(&tmp).unwrap();
assert!(content.starts_with("existing\n"));
assert!(content.contains("TEST_REC"));
std::fs::remove_file(&tmp).ok();
}
#[test]
fn test_redirect_fd2_leaves_stdout_intact() {
let shell = make_shell();
let tmp = std::env::temp_dir().join("iocsh_test_fd2_redirect.txt");
let _ = std::fs::remove_file(&tmp);
let line = format!("dbl 2> {}", tmp.display());
let result = shell.execute_line(&line);
assert!(matches!(result, Ok(CommandOutcome::Continue)));
let captured = std::fs::read_to_string(&tmp).unwrap_or_default();
assert!(
!captured.contains("TEST_REC"),
"fd-2 redirect must not capture stdout, got: {captured}"
);
std::fs::remove_file(&tmp).ok();
}
#[test]
fn test_parse_redirect() {
let (cmd, redir) = parse_redirect("dbl > /tmp/out.txt");
assert_eq!(cmd, "dbl");
let r = redir.unwrap();
assert_eq!(r.path, "/tmp/out.txt");
assert!(!r.append);
assert_eq!(r.fd, 1, "bare > defaults to fd 1");
let (cmd, redir) = parse_redirect("dbl >> /tmp/out.txt");
assert_eq!(cmd, "dbl");
let r = redir.unwrap();
assert!(r.append);
assert_eq!(r.fd, 1);
let (cmd, redir) = parse_redirect("dbl");
assert_eq!(cmd, "dbl");
assert!(redir.is_none());
}
#[test]
fn test_parse_redirect_fd_numbered() {
let (cmd, redir) = parse_redirect("dbl 1>/tmp/out.txt");
assert_eq!(cmd, "dbl");
let r = redir.unwrap();
assert_eq!(r.path, "/tmp/out.txt");
assert!(!r.append);
assert_eq!(r.fd, 1);
let (cmd, redir) = parse_redirect("dbl 2>/tmp/err.txt");
assert_eq!(cmd, "dbl");
let r = redir.unwrap();
assert_eq!(r.path, "/tmp/err.txt");
assert!(!r.append);
assert_eq!(r.fd, 2);
let (cmd, redir) = parse_redirect("dbl 2>>/tmp/err.txt");
assert_eq!(cmd, "dbl");
let r = redir.unwrap();
assert_eq!(r.path, "/tmp/err.txt");
assert!(r.append);
assert_eq!(r.fd, 2);
let (cmd, redir) = parse_redirect("cmd5>file");
let r = redir.unwrap();
assert_eq!(r.fd, 1, "digit not at boundary is part of command");
assert_eq!(cmd, "cmd5");
let (_cmd, redir) = parse_redirect("foo 9>x");
assert_eq!(redir.unwrap().fd, 9);
let (cmd, redir) = parse_redirect("foo 0>x");
let r = redir.unwrap();
assert_eq!(r.fd, 1);
assert_eq!(cmd, "foo 0");
}
#[test]
fn test_execute_line_db_create_record_happy_path() {
let shell = make_shell();
let result = shell.execute_line("dbCreateRecord ai NEW:AI");
assert!(matches!(result, Ok(CommandOutcome::Continue)));
let result = shell.execute_line("dbl ai");
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_execute_line_db_create_record_rejects_duplicate() {
let shell = make_shell();
shell.execute_line("dbCreateRecord ai TEST_REC").unwrap();
let r = shell.execute_line("dbgf TEST_REC");
assert!(matches!(r, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_execute_line_db_create_record_rejects_bad_name() {
let shell = make_shell();
let r = shell.execute_line("dbCreateRecord ai \"BAD NAME\"");
assert!(matches!(r, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_execute_line_db_create_record_rejects_unknown_type() {
let shell = make_shell();
let r = shell.execute_line("dbCreateRecord nonexistent NEW_REC");
assert!(matches!(r, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_backslash_continuation_scenarios() {
let input = concat!(
"1 not a multiline string\n",
"2 first multiline \\\n",
"string\n",
"3 second multiline \\\n",
"string \\\n",
"with more lines\n",
"4 several lines .. \\\n",
"next line is empty: \\\n",
"\\\n",
"next has only a space:\\\n",
" \\\n",
"next line has 3 spaces:\\\n",
" \\\n",
"END\n",
"5 it is fine to sp\\\n",
"it words, or really \\\n",
"c\\\n",
"h\\\n",
"o\\\n",
"p\\\n",
" them up!\n",
"\\\n",
"6 start with backslash , fine with me but why?\n",
"7 have a trailing space after backslash \\ \n",
"8 not part of the string no. 7\n",
);
let lines: Vec<String> = join_backslash_continuations(input)
.into_iter()
.map(|(_, l)| l)
.collect();
assert_eq!(lines[0], "1 not a multiline string");
assert_eq!(lines[1], "2 first multiline string");
assert_eq!(lines[2], "3 second multiline string with more lines");
assert_eq!(
lines[3],
"4 several lines .. next line is empty: next has only a space: next line has 3 spaces: END"
);
assert_eq!(
lines[4],
"5 it is fine to spit words, or really chop them up!"
);
assert_eq!(lines[5], "6 start with backslash , fine with me but why?");
assert_eq!(lines[6], "7 have a trailing space after backslash \\ ");
assert_eq!(lines[7], "8 not part of the string no. 7");
assert_eq!(lines.len(), 8);
}
#[test]
fn test_backslash_continuation_line_numbers() {
let input = "a\nb \\\nc\nd\n";
let out = join_backslash_continuations(input);
assert_eq!(
out,
vec![(1, "a".into()), (2, "b c".into()), (4, "d".into())]
);
}
#[test]
fn test_backslash_continuation_no_trailing_newline() {
let out = join_backslash_continuations("partial");
assert_eq!(out, vec![(1, "partial".into())]);
}
#[test]
fn test_backslash_continuation_crlf() {
let out = join_backslash_continuations("a \\\r\nb\r\n");
assert_eq!(out, vec![(1, "a b".into())]);
}
#[test]
fn test_iocsh_script_backslash_continuation_end_to_end() {
let shell = make_shell();
let tmp = std::env::temp_dir().join("iocsh_multiline.cmd");
std::fs::write(&tmp, "dbgf \\\nTEST_REC.VAL\n").unwrap();
let result = shell.execute_script(tmp.to_str().unwrap());
std::fs::remove_file(&tmp).ok();
assert!(result.is_ok(), "joined `dbgf TEST_REC.VAL` must succeed");
}
#[test]
fn test_iocsh_load_macro_substitutes_command_name() {
let shell = make_shell();
let tmp = std::env::temp_dir().join("iocsh_load_macro_cmd.cmd");
std::fs::write(&tmp, "$(CMD)\n").unwrap();
let line = format!("iocshLoad {} CMD=dbl", tmp.display());
let result = shell.execute_line(&line);
std::fs::remove_file(&tmp).ok();
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_iocsh_load_no_macros() {
let shell = make_shell();
let tmp = std::env::temp_dir().join("iocsh_load_no_macros.cmd");
std::fs::write(&tmp, "dbl\n").unwrap();
let line = format!("iocshLoad {}", tmp.display());
let result = shell.execute_line(&line);
std::fs::remove_file(&tmp).ok();
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_iocsh_load_missing_path_errors() {
let shell = make_shell();
let result = shell.execute_line("iocshLoad");
assert!(result.is_err());
}
#[test]
fn test_db_load_records_different_type_duplicate_propagates() {
let shell = make_shell();
let db_path = std::env::temp_dir().join("iocsh_dup_load.db");
std::fs::write(&db_path, "record(mbbo, \"TEST_REC\") {}\n").unwrap();
let script_path = std::env::temp_dir().join("iocsh_dup_load.cmd");
std::fs::write(
&script_path,
format!("dbLoadRecords {}\n", db_path.display()),
)
.unwrap();
let result = shell.execute_script(script_path.to_str().unwrap());
let _ = std::fs::remove_file(&db_path);
let _ = std::fs::remove_file(&script_path);
assert!(
result.is_err(),
"dbLoadRecords with type-mismatched duplicate must propagate Err"
);
}
#[test]
fn test_iocsh_load_cpp_paren_syntax() {
let shell = make_shell();
let tmp = std::env::temp_dir().join("iocsh_load_paren.cmd");
std::fs::write(&tmp, "$(CMD)\n").unwrap();
let line = format!("iocshLoad(\"{}\", \"CMD=dbl\")", tmp.display());
let result = shell.execute_line(&line);
std::fs::remove_file(&tmp).ok();
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_iocsh_load_per_line_errors_continue_and_propagate() {
let shell = make_shell();
let tmp = std::env::temp_dir().join("iocsh_load_err.cmd");
std::fs::write(&tmp, "nonexistent_cmd\ndbl\n").unwrap();
let line = format!("iocshLoad {}", tmp.display());
let result = shell.execute_line(&line);
std::fs::remove_file(&tmp).ok();
assert!(
result.is_err(),
"iocshLoad with bad command must surface Err"
);
}
#[test]
fn test_execute_line_db_create_record_missing_args() {
let shell = make_shell();
let r = shell.execute_line("dbCreateRecord ai");
assert!(r.is_err());
}
#[test]
fn test_format_error_with_and_without_color() {
let plain = format_error("oops", false);
assert_eq!(plain, "Error: oops");
let colored = format_error("oops", true);
assert!(colored.starts_with("\x1b[1;31mError:\x1b[0m "));
assert!(colored.contains("oops"));
}
#[test]
fn test_iocsh_cmd_runs_single_command() {
let shell = make_shell();
let result = shell.execute_line(r#"iocshCmd("dbl")"#);
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_iocsh_run_runs_multiple_commands() {
let shell = make_shell();
let result = shell.execute_line(r#"iocshRun("dbl; pwd")"#);
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
fn test_core_commands_registered() {
let shell = make_shell();
for line in ["echo hello", "pwd", "date", "epicsPrtEnvParams"] {
assert!(
matches!(shell.execute_line(line), Ok(CommandOutcome::Continue)),
"core command line `{line}` must run"
);
}
}
#[test]
fn test_as_commands_registered() {
let _guard = super::access_commands::as_state_test_guard();
super::access_commands::reset_as_state_for_test();
let shell = make_shell();
assert!(matches!(
shell.execute_line("asInit"),
Ok(CommandOutcome::Continue)
));
assert!(matches!(
shell.execute_line("asprules"),
Ok(CommandOutcome::Continue)
));
}
#[test]
fn test_dbsr_is_server_report() {
let shell = make_shell();
let tmp = std::env::temp_dir().join("iocsh_dbsr_report.txt");
let _ = std::fs::remove_file(&tmp);
let line = format!("dbsr > {}", tmp.display());
assert!(matches!(
shell.execute_line(&line),
Ok(CommandOutcome::Continue)
));
let content = std::fs::read_to_string(&tmp).unwrap();
assert!(
content.contains("Database Server Report"),
"dbsr must print the server report, got: {content}"
);
assert!(
!content.contains("Total:") || content.contains("Total channels"),
"dbsr must not be the dbgrep name-search output"
);
std::fs::remove_file(&tmp).ok();
}
#[test]
fn test_on_error_break_stops_script() {
let shell = make_shell();
let tmp = std::env::temp_dir().join("iocsh_on_error_break.cmd");
std::fs::write(
&tmp,
"on error break\nnonexistent_cmd\ndbCreateRecord ai SHOULD_NOT_EXIST\n",
)
.unwrap();
let result = shell.execute_script(tmp.to_str().unwrap());
std::fs::remove_file(&tmp).ok();
assert!(result.is_err(), "on error break must surface Err");
assert!(
shell.execute_line("dbgf SHOULD_NOT_EXIST").is_err(),
"on error break must stop before line 3 runs"
);
}
#[test]
fn test_single_quote_tokenization() {
assert_eq!(
tokenize("dbpf REC:VAL 'hello world'"),
vec!["dbpf", "REC:VAL", "hello world"]
);
assert_eq!(tokenize("cmd('a, b', c)"), vec!["cmd", "a, b", "c"]);
}
#[test]
fn test_malformed_line_is_rejected() {
let shell = make_shell();
assert!(
shell.execute_line(r#"echo "unterminated"#).is_err(),
"unbalanced quote must be rejected"
);
assert!(
shell.execute_line("echo trailing\\").is_err(),
"trailing backslash must be rejected"
);
}
#[test]
#[serial_test::serial(epics_env)]
fn test_use_ansi_color_respects_no_color() {
let no_color = std::env::var_os("NO_COLOR");
let epics_no = std::env::var_os("EPICS_RS_IOCSH_NO_COLOR");
unsafe {
std::env::remove_var("NO_COLOR");
std::env::remove_var("EPICS_RS_IOCSH_NO_COLOR");
}
assert!(use_ansi_color());
unsafe { std::env::set_var("NO_COLOR", "1") };
assert!(!use_ansi_color(), "NO_COLOR=1 must disable color");
unsafe { std::env::remove_var("NO_COLOR") };
unsafe { std::env::set_var("EPICS_RS_IOCSH_NO_COLOR", "yes") };
assert!(
!use_ansi_color(),
"EPICS_RS_IOCSH_NO_COLOR=yes must disable color"
);
unsafe { std::env::remove_var("EPICS_RS_IOCSH_NO_COLOR") };
if let Some(v) = no_color {
unsafe { std::env::set_var("NO_COLOR", v) };
}
if let Some(v) = epics_no {
unsafe { std::env::set_var("EPICS_RS_IOCSH_NO_COLOR", v) };
}
}
}