mod access_commands;
pub(crate) use access_commands::as_init;
mod breakpoint_commands;
mod commands;
#[cfg(not(epics_embedded_target))]
mod completion;
mod core_commands;
mod dbstatic_commands;
pub(crate) mod misc_commands;
mod queue_commands;
pub mod registry;
mod registry_commands;
mod rtems_commands;
mod time_commands;
pub mod vars;
pub use commands::macro_defn_pairs;
pub use rtems_commands::register_rtems_commands;
pub fn add_registrars(names: &[String]) {
dbstatic_commands::add_registrars(names);
}
use std::collections::HashMap;
use std::fs::File;
use std::sync::{Arc, Mutex, RwLock};
use crate::runtime::log::{
ANSI_ESC_BLUE, ANSI_ESC_BOLD, ANSI_ESC_RED, ANSI_ESC_RESET, ANSI_ESC_UNDERLINE,
};
use crate::server::database::PvDatabase;
use registry::*;
#[derive(Clone, Copy, Debug, PartialEq, Default)]
enum OnError {
#[default]
Continue,
Break,
Halt { timeout: f64 },
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum ErrorReaction {
Resume,
ResumeFailed,
Stop,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Dispatch {
Ran,
Nothing,
}
#[derive(Clone, Debug, PartialEq)]
struct SourceFile {
base: String,
lineno: usize,
}
fn script_basename(path: &str) -> &str {
match path.rfind('/') {
Some(i) => &path[i + 1..],
None => path,
}
}
#[derive(Clone, Debug, PartialEq)]
struct IocshScope {
on_error: OnError,
errored: Option<String>,
interactive: bool,
file_script: bool,
source: Option<SourceFile>,
}
impl IocshScope {
fn script(path: &str) -> Self {
Self {
on_error: OnError::Continue,
errored: None,
interactive: false,
file_script: true,
source: Some(SourceFile {
base: script_basename(path).to_string(),
lineno: 0,
}),
}
}
fn command_line() -> Self {
Self {
on_error: OnError::Break,
errored: None,
interactive: false,
file_script: false,
source: None,
}
}
fn interactive() -> Self {
Self {
on_error: OnError::Continue,
errored: None,
interactive: true,
file_script: false,
source: None,
}
}
}
pub struct IocShell {
registry: Arc<RwLock<CommandRegistry>>,
ctx: CommandContext,
scopes: std::cell::RefCell<Vec<IocshScope>>,
}
thread_local! {
static MACRO_SCOPE: std::cell::RefCell<Vec<HashMap<String, String>>> =
std::cell::RefCell::new(vec![HashMap::new()]);
}
pub(crate) fn iocsh_env_clear(name: &str) {
MACRO_SCOPE.with(|scope| {
for frame in scope.borrow_mut().iter_mut() {
frame.remove(name);
}
});
}
const MAX_SCRIPT_DEPTH: usize = 32;
struct ScopeGuard<'a>(&'a IocShell);
impl Drop for ScopeGuard<'_> {
fn drop(&mut self) {
self.0.scopes.borrow_mut().pop();
}
}
struct MacroScopeGuard;
impl Drop for MacroScopeGuard {
fn drop(&mut self) {
MACRO_SCOPE.with(|scope| {
let mut scope = scope.borrow_mut();
if scope.len() > 1 {
scope.pop();
}
});
}
}
static STARTUP_SCRIPT_PHASE: (Mutex<usize>, std::sync::Condvar) =
(Mutex::new(0), std::sync::Condvar::new());
pub(crate) fn startup_script_phase() -> StartupScriptPhase {
*STARTUP_SCRIPT_PHASE.0.lock().unwrap() += 1;
StartupScriptPhase
}
pub(crate) struct StartupScriptPhase;
impl Drop for StartupScriptPhase {
fn drop(&mut self) {
let mut in_flight = STARTUP_SCRIPT_PHASE.0.lock().unwrap();
*in_flight -= 1;
if *in_flight == 0 {
STARTUP_SCRIPT_PHASE.1.notify_all();
}
}
}
fn await_startup_script_phase() {
let mut in_flight = STARTUP_SCRIPT_PHASE.0.lock().unwrap();
while *in_flight > 0 {
in_flight = STARTUP_SCRIPT_PHASE.1.wait(in_flight).unwrap();
}
}
static COMMAND_REGISTRY: std::sync::OnceLock<Arc<RwLock<CommandRegistry>>> =
std::sync::OnceLock::new();
fn command_registry() -> &'static Arc<RwLock<CommandRegistry>> {
COMMAND_REGISTRY.get_or_init(|| {
let mut registry = CommandRegistry::new();
commands::register_builtins(&mut registry);
Arc::new(RwLock::new(registry))
})
}
pub fn register_command(def: CommandDef) {
command_registry().write().unwrap().register(def);
}
impl IocShell {
pub fn new(db: Arc<PvDatabase>, bridge: crate::runtime::task::BlockingBridge) -> Self {
Self::new_with_acf(
db,
bridge,
crate::server::access_security::new_acf_cell(None),
)
}
pub fn new_with_acf(
db: Arc<PvDatabase>,
bridge: crate::runtime::task::BlockingBridge,
acf: crate::server::access_security::AcfCell,
) -> Self {
let registry = command_registry().clone();
crate::runtime::env::register_iocsh_env_vars();
let ctx = CommandContext::new_with_acf(db, bridge, acf);
ctx.set_command_registry(®istry);
Self {
registry,
ctx,
scopes: std::cell::RefCell::new(Vec::new()),
}
}
fn enter_scope(&self, scope: IocshScope) -> ScopeGuard<'_> {
self.scopes.borrow_mut().push(scope);
ScopeGuard(self)
}
fn current_scope(&self) -> Option<IocshScope> {
self.scopes.borrow().last().cloned()
}
fn set_lineno(&self, lineno: usize) {
if let Some(source) = self
.scopes
.borrow_mut()
.last_mut()
.and_then(|scope| scope.source.as_mut())
{
source.lineno = lineno;
}
}
fn show_error(&self, msg: &str) {
eprintln!("{}", self.format_error(msg));
}
fn format_error(&self, msg: &str) -> String {
let scopes = self.scopes.borrow();
let source = scopes.last().and_then(|scope| scope.source.as_ref());
format_show_error(source, msg, use_ansi_color())
}
fn enter_script(&self, path: &str) -> Result<ScopeGuard<'_>, String> {
let depth = self
.scopes
.borrow()
.iter()
.filter(|scope| scope.file_script)
.count();
if depth >= MAX_SCRIPT_DEPTH {
return Err(format!(
"'{path}': script include depth exceeds {MAX_SCRIPT_DEPTH} — \
recursive '<' / iocshLoad?"
));
}
Ok(self.enter_scope(IocshScope::script(path)))
}
fn push_macro_scope(&self, macros: &HashMap<String, String>) -> MacroScopeGuard {
MACRO_SCOPE.with(|scope| {
let mut scope = scope.borrow_mut();
let mut frame = scope.last().cloned().unwrap_or_default();
frame.extend(macros.iter().map(|(k, v)| (k.clone(), v.clone())));
scope.push(frame);
});
MacroScopeGuard
}
fn expand_line(&self, raw: &str) -> Option<String> {
let expanded = MACRO_SCOPE.with(|scope| {
let scope = scope.borrow();
let macros = scope.last().expect("macro scope stack is never empty");
crate::server::db_loader::expand_macros(
raw,
macros,
crate::server::db_loader::MacroExpandOptions {
env_fallback: true,
..Default::default()
},
)
});
(!expanded.errored()).then_some(expanded.text)
}
pub fn register(&self, def: CommandDef) {
self.registry.write().unwrap().register(def);
}
pub fn execute_line(&self, line: &str) -> CommandResult {
self.execute_line_dispatched(line).0
}
fn execute_line_dispatched(&self, line: &str) -> (CommandResult, Dispatch) {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return (Ok(CommandOutcome::Continue), Dispatch::Nothing);
}
match self.expand_line(line) {
Some(expanded) => self.execute_expanded_line(&expanded),
None => (Ok(CommandOutcome::Failed), Dispatch::Nothing),
}
}
fn execute_expanded_line(&self, line: &str) -> (CommandResult, Dispatch) {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return (Ok(CommandOutcome::Continue), Dispatch::Nothing);
}
if let Some(diag) = registry::lint_line(line) {
return (Err(diag.to_string()), Dispatch::Nothing);
}
if let Some(rest) = line.strip_prefix('<') {
return match self.run_script(rest.trim()) {
Ok(()) => (Ok(CommandOutcome::Continue), Dispatch::Nothing),
Err(ScriptFailure::Reported(_)) => (Ok(CommandOutcome::Failed), Dispatch::Nothing),
Err(ScriptFailure::Unreported(msg)) => (Err(msg), Dispatch::Nothing),
};
}
{
let toks = tokenize(line);
match toks.first().map(|s| s.as_str()) {
Some("iocshLoad") => {
let macros = toks
.get(2)
.map(|s| commands::parse_macro_string(s))
.unwrap_or_default();
let Some(path) = toks.get(1) else {
let _scope = self.push_macro_scope(¯os);
return match self.run_repl() {
Ok(()) => (Ok(CommandOutcome::Continue), Dispatch::Ran),
Err(msg) => (Err(msg), Dispatch::Ran),
};
};
return match self.run_script_with_macros(path, ¯os) {
Ok(()) => (Ok(CommandOutcome::Continue), Dispatch::Ran),
Err(ScriptFailure::Reported(_)) => {
(Ok(CommandOutcome::Failed), Dispatch::Ran)
}
Err(ScriptFailure::Unreported(msg)) => (Err(msg), Dispatch::Ran),
};
}
Some("iocshCmd" | "iocshRun") => {
let Some(cmd) = toks.get(1) else {
return (Ok(CommandOutcome::Continue), Dispatch::Ran);
};
let _scope = self.enter_scope(IocshScope::command_line());
let (outcome, dispatch) = self.execute_line_dispatched(cmd);
let failure = match &outcome {
Err(e) => Some(e.clone()),
Ok(CommandOutcome::Failed) => Some(String::new()),
Ok(_) => None,
};
self.record_line_result(failure, dispatch);
let _ = self.react_to_error();
return (outcome, Dispatch::Ran);
}
Some("on") => {
return (self.handle_on_command(&toks), Dispatch::Ran);
}
_ => {}
}
}
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, Dispatch) {
let Some(redir) = redirect else {
return self.execute_command_inner(line);
};
let file_result = if redir.fd == 0 {
File::open(&redir.path)
} else if redir.append {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&redir.path)
} else {
File::create(&redir.path)
};
let file = match file_result {
Ok(f) => f,
Err(e) => {
self.ctx.eprintln(&self.format_error(&format!(
"Can't open '{}': {}",
redir.path,
c_strerror(&e)
)));
return (Ok(CommandOutcome::Continue), Dispatch::Nothing);
}
};
match redir.fd {
0 => self
.ctx
.with_input(file, || self.execute_command_inner(line)),
1 => self
.ctx
.with_output(file, || self.execute_command_inner(line)),
2 => self
.ctx
.with_error(file, || self.execute_command_inner(line)),
_ => self.execute_command_inner(line),
}
}
fn execute_command_inner(&self, line: &str) -> (CommandResult, Dispatch) {
let tokens = tokenize(line);
if tokens.is_empty() {
return (Ok(CommandOutcome::Continue), Dispatch::Nothing);
}
let cmd_name = &tokens[0];
let arg_tokens = &tokens[1..];
let found = {
let registry = self.registry.read().unwrap();
if cmd_name == "help" {
return (self.execute_help(arg_tokens, ®istry), Dispatch::Ran);
}
registry.get(cmd_name).cloned()
};
let Some(def) = found else {
self.show_error(&format!("Command '{cmd_name}' not registered."));
return (Ok(CommandOutcome::Failed), Dispatch::Nothing);
};
let args = match parse_args(arg_tokens, &def.args) {
Ok(args) => args,
Err(e) => return (Err(e), Dispatch::Nothing),
};
(def.handler.call(&args, &self.ctx), Dispatch::Ran)
}
pub fn execute_script_with_macros(
&self,
path: &str,
macros: &HashMap<String, String>,
) -> Result<(), String> {
self.run_script_with_macros(path, macros)
.map_err(|f| self.report_once(f))
}
fn run_script_with_macros(
&self,
path: &str,
macros: &HashMap<String, String>,
) -> Result<(), ScriptFailure> {
set_startup_script_once(path);
let _scope = self.push_macro_scope(macros);
self.run_script(path)
}
pub fn execute_script(&self, path: &str) -> Result<(), String> {
self.run_script(path).map_err(|f| self.report_once(f))
}
pub fn execute_line_reported(&self, line: &str) -> Result<(), String> {
match self.execute_expanded_line(line).0 {
Ok(CommandOutcome::Continue | CommandOutcome::Exit) => Ok(()),
Ok(CommandOutcome::Failed) => Err(format!("'{line}' failed")),
Err(e) => {
self.show_error(&e);
Err(e)
}
}
}
fn report_once(&self, failure: ScriptFailure) -> String {
match failure {
ScriptFailure::Reported(msg) => msg,
ScriptFailure::Unreported(msg) => {
self.show_error(&msg);
msg
}
}
}
fn run_script(&self, path: &str) -> Result<(), ScriptFailure> {
let _depth = self.enter_script(path).map_err(ScriptFailure::Unreported)?;
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) => {
let reason = c_strerror(&e);
self.ctx
.eprintln(&paint_error(&format!("Can't open {path}: {reason}")));
return Err(ScriptFailure::Reported(format!(
"cannot read '{path}': {reason}"
)));
}
};
let mut failed: Option<String> = None;
for (line_num, raw) in join_backslash_continuations(&content) {
if let Some(msg) = self.react_in_script().map_err(ScriptFailure::Reported)? {
failed = Some(msg);
}
self.set_lineno(line_num);
if raw.trim_start().starts_with('#') {
if let Some(echo) = script_echo(&raw, ANSI_ESC_BLUE, use_ansi_color()) {
println!("{echo}");
}
continue;
}
let (outcome, dispatch) = match self.expand_line(&raw) {
Some(expanded) => {
if let Some(echo) = script_echo(&expanded, ANSI_ESC_BOLD, use_ansi_color()) {
println!("{echo}");
}
self.execute_expanded_line(&expanded)
}
None => (Ok(CommandOutcome::Failed), Dispatch::Nothing),
};
let diagnostic = match &outcome {
Ok(CommandOutcome::Exit) => {
return failed
.map(|m| Err(ScriptFailure::Reported(m)))
.unwrap_or(Ok(()));
}
Ok(CommandOutcome::Continue | CommandOutcome::Failed) => None,
Err(e) => Some(e.clone()),
};
let line_failed = matches!(outcome, Ok(CommandOutcome::Failed) | Err(_));
if let Some(e) = &diagnostic {
self.show_error(e);
}
let failure = line_failed.then(|| match &diagnostic {
Some(e) => format!("{path}:{line_num}: {e}"),
None => format!("{path}:{line_num}"),
});
self.record_line_result(failure, dispatch);
}
if let Some(msg) = self.react_in_script().map_err(ScriptFailure::Reported)? {
failed = Some(msg);
}
failed
.map(|m| Err(ScriptFailure::Reported(m)))
.unwrap_or(Ok(()))
}
pub fn run_repl(&self) -> Result<(), String> {
await_startup_script_phase();
let _scope = self.enter_scope(IocshScope::interactive());
#[cfg(not(epics_embedded_target))]
{
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(epics_embedded_target))]
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}"))?
.completion_type(rustyline::CompletionType::List)
.build();
let mut rl: rustyline::Editor<completion::IocshCompleter, _> =
rustyline::Editor::with_config(config)
.map_err(|e| format!("failed to initialize readline: {e}"))?;
rl.set_helper(Some(completion::IocshCompleter::new(
self.registry.clone(),
self.ctx.db().clone(),
self.ctx.bridge().clone(),
)));
let want_color = use_ansi_color();
let (raw_prompt, styled_prompt) = iocsh_prompt_if(want_color);
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 | CommandOutcome::Failed) => {}
Ok(CommandOutcome::Exit) => break,
Err(e) => self.show_error(&e),
}
}
Err(rustyline::error::ReadlineError::Eof) => break,
Err(rustyline::error::ReadlineError::Interrupted) => continue,
Err(e) => {
self.show_error(&format!("readline error: {e}"));
break;
}
}
}
Ok(())
}
fn run_repl_piped(&self) -> Result<(), String> {
use std::io::{BufRead, Write};
let (_, prompt) = iocsh_prompt();
let stdin = std::io::stdin();
let mut handle = stdin.lock();
let mut line = String::new();
loop {
print!("{prompt}");
let _ = std::io::stdout().flush();
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 | CommandOutcome::Failed) => {}
Ok(CommandOutcome::Exit) => break,
Err(e) => self.show_error(&e),
}
}
Err(e) => {
eprintln!("stdin read error: {e}");
break;
}
}
}
Ok(())
}
fn handle_on_command(&self, toks: &[String]) -> CommandResult {
const USAGE: &str = "Usage: on error [continue | break | halt | wait <delay>]";
let Some(scope) = self.current_scope() else {
return Ok(CommandOutcome::Continue);
};
if scope.interactive {
eprintln!("Interactive shell, 'on error' ignored.");
return Ok(CommandOutcome::Continue);
}
if toks.len() < 3 || toks[1] != "error" {
eprintln!("{USAGE}");
return Ok(CommandOutcome::Continue);
}
let mode = match toks[2].as_str() {
"continue" => OnError::Continue,
"break" => OnError::Break,
"halt" => OnError::Halt { timeout: 0.0 },
"wait" => {
let timeout = match toks.get(3) {
None => {
eprintln!("{USAGE}");
0.0
}
Some(delay) => match crate::runtime::stdlib::epics_scan_double(delay) {
Some(secs) => secs,
None => {
eprintln!("{USAGE}");
eprintln!("Invalid 'on error wait' delay '{delay}'.");
5.0
}
},
};
OnError::Halt { timeout }
}
_ => return Err(USAGE.into()),
};
if let Some(scope) = self.scopes.borrow_mut().last_mut() {
scope.on_error = mode;
}
Ok(CommandOutcome::Continue)
}
fn record_line_result(&self, failure: Option<String>, dispatch: Dispatch) {
let errored = match (failure, dispatch) {
(Some(msg), _) => Some(msg),
(None, Dispatch::Ran) => None,
(None, Dispatch::Nothing) => return,
};
if let Some(scope) = self.scopes.borrow_mut().last_mut() {
scope.errored = errored;
}
}
fn pending_error(&self) -> Option<String> {
self.scopes
.borrow()
.last()
.and_then(|scope| scope.errored.clone())
}
fn react_in_script(&self) -> Result<Option<String>, String> {
match self.react_to_error() {
ErrorReaction::Resume => Ok(None),
ErrorReaction::ResumeFailed => Ok(self.pending_error()),
ErrorReaction::Stop => Err(self.pending_error().unwrap_or_default()),
}
}
fn react_to_error(&self) -> ErrorReaction {
let scope = match self.current_scope() {
Some(scope) => scope,
None => return ErrorReaction::Resume,
};
if scope.interactive || scope.errored.is_none() {
return ErrorReaction::Resume;
}
match scope.on_error {
OnError::Continue => ErrorReaction::Resume,
OnError::Break => {
eprintln!("iocsh Error: Break");
ErrorReaction::Stop
}
OnError::Halt { timeout } if timeout > 0.0 && timeout.is_finite() => {
eprintln!("iocsh Error: Waiting {timeout:.1} sec ...");
crate::runtime::time::sleep_secs(timeout);
ErrorReaction::ResumeFailed
}
OnError::Halt { .. } => {
eprintln!("iocsh Error: Halt");
crate::runtime::task::suspend_self();
ErrorReaction::Stop
}
}
}
fn execute_help(&self, arg_tokens: &[String], registry: &CommandRegistry) -> CommandResult {
let names = registry.list();
if arg_tokens.is_empty() {
self.ctx.println(&format_command_columns(&names));
} else {
let color = use_ansi_color();
let mut first = true;
for pattern in arg_tokens {
for name in &names {
if !commands::epics_strn_glob_match(
name.as_bytes(),
name.len(),
pattern.as_bytes(),
) {
continue;
}
let Some(def) = registry.get(name) else {
continue;
};
self.ctx.println(&format_help_entry(def, color, first));
first = false;
}
}
}
Ok(CommandOutcome::Continue)
}
}
fn format_command_columns(names: &[&str]) -> String {
const WRAP: usize = 79;
const BREAK: usize = 64;
const TAB_STOP: usize = 16;
let mut out = String::new();
let mut col = 0usize;
for name in names {
let width = name.len();
if width + col >= WRAP {
out.push('\n');
col = 0;
}
out.push_str(name);
col += width;
if col >= BREAK {
out.push('\n');
col = 0;
} else {
loop {
out.push(' ');
col += 1;
if col % TAB_STOP == 0 {
break;
}
}
}
}
if col != 0 {
out.push('\n');
}
out.push_str(
"\nType 'help <glob>' for information about commands matching\n\
the name or pattern <glob>, e.g. 'help db*'",
);
out
}
fn format_help_entry(def: &CommandDef, color: bool, first: bool) -> String {
let mut out = String::new();
if !first {
if color {
out.push_str(ANSI_ESC_UNDERLINE);
}
out.push_str(&" ".repeat(60));
if color {
out.push_str(ANSI_ESC_RESET);
}
out.push('\n');
}
out.push('\n');
if color {
out.push_str(ANSI_ESC_BOLD);
out.push_str(&def.name);
out.push_str(ANSI_ESC_RESET);
} else {
out.push_str(&def.name);
}
for arg in &def.args {
if matches!(arg.arg_type, ArgType::Argv) || !arg.name.contains(' ') {
out.push(' ');
out.push_str(arg.name);
} else {
out.push_str(" '");
out.push_str(arg.name);
out.push('\'');
}
}
if !def.usage.is_empty() {
out.push('\n');
out.push('\n');
out.push_str(def.usage.trim_end_matches('\n'));
}
out
}
pub 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
}
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
}
fn format_show_error(source: Option<&SourceFile>, msg: &str, color: bool) -> String {
let body = paint_error_if(msg, color);
match source {
Some(source) => format!(
"{} {} line {}: {body}",
if color {
crate::runtime::log::ERL_ERROR
} else {
"ERROR"
},
source.base,
source.lineno
),
None => body,
}
}
enum ScriptFailure {
Reported(String),
Unreported(String),
}
fn iocsh_prompt() -> (String, String) {
iocsh_prompt_if(use_ansi_color())
}
fn iocsh_prompt_if(color: bool) -> (String, String) {
let ps1 = crate::runtime::env_table::IOCSH_PS1
.get()
.unwrap_or_default();
let raw = strip_ansi(&ps1);
let styled = if color { ps1 } else { raw.clone() };
(raw, styled)
}
fn paint_error(msg: &str) -> String {
paint_error_if(msg, use_ansi_color())
}
fn paint_error_if(msg: &str, color: bool) -> String {
if color {
format!("{ANSI_ESC_RED}{msg}{ANSI_ESC_RESET}")
} else {
msg.to_string()
}
}
fn c_strerror(e: &std::io::Error) -> String {
let text = e.to_string();
match e.raw_os_error() {
Some(errno) => text
.strip_suffix(&format!(" (os error {errno})"))
.unwrap_or(&text)
.to_string(),
None => text,
}
}
fn echoes_script_line(line: &str) -> bool {
!line.trim_start().starts_with("#-")
}
fn script_echo(line: &str, escape: &str, color: bool) -> Option<String> {
if line.is_empty() || !echoes_script_line(line) {
return None;
}
Some(if color {
format!("{escape}{line}{ANSI_ESC_RESET}")
} else {
line.to_string()
})
}
fn set_startup_script_once(path: &str) {
if std::env::var_os("IOCSH_STARTUP_SCRIPT").is_none() {
unsafe { std::env::set_var("IOCSH_STARTUP_SCRIPT", path) };
}
}
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 scan = registry::ShellScan::default();
let mut i = 0;
while i < bytes.len() {
let syntax = scan.is_syntax();
scan.feed(bytes[i]);
match bytes[i] {
b'>' if syntax => {
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: path.to_string(),
append: is_append,
fd,
}),
);
}
b'<' if syntax => {
let cmd = line[..i].trim_end();
let path = line[i + 1..].trim();
if path.is_empty() {
return (line, None);
}
return (
cmd,
Some(Redirect {
path: path.to_string(),
append: false,
fd: 0,
}),
);
}
_ => {}
}
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(""));
}
#[test]
fn script_echo_paints_comments_blue_and_commands_bold() {
assert_eq!(
script_echo("# plain comment", ANSI_ESC_BLUE, true).as_deref(),
Some("\x1b[34;1m# plain comment\x1b[0m")
);
assert_eq!(
script_echo(" # indented comment", ANSI_ESC_BLUE, true).as_deref(),
Some("\x1b[34;1m # indented comment\x1b[0m")
);
assert_eq!(
script_echo("dbLoadRecords(\"x.db\")", ANSI_ESC_BOLD, true).as_deref(),
Some("\x1b[1mdbLoadRecords(\"x.db\")\x1b[0m")
);
assert_eq!(
script_echo(" dbLoadRecords(\"x.db\")", ANSI_ESC_BOLD, true).as_deref(),
Some("\x1b[1m dbLoadRecords(\"x.db\")\x1b[0m")
);
assert_eq!(script_echo("", ANSI_ESC_BOLD, true), None);
assert_eq!(
script_echo("\t", ANSI_ESC_BOLD, true).as_deref(),
Some("\x1b[1m\t\x1b[0m")
);
assert_eq!(script_echo("#- quiet", ANSI_ESC_BLUE, true), None);
assert_eq!(script_echo(" #- quiet", ANSI_ESC_BLUE, true), None);
assert_eq!(
script_echo("# plain comment", ANSI_ESC_BLUE, false).as_deref(),
Some("# plain comment")
);
assert_eq!(script_echo("", ANSI_ESC_BOLD, false), None);
}
#[test]
fn the_prompt_is_iocsh_ps1_painted_or_stripped() {
assert_eq!(
iocsh_prompt_if(true),
(
"epics> ".to_string(),
"\x1b[32;1mepics> \x1b[0m".to_string()
)
);
assert_eq!(
iocsh_prompt_if(false),
("epics> ".to_string(), "epics> ".to_string())
);
}
fn script_token(p: &std::path::Path) -> String {
p.display().to_string().replace('\\', "/")
}
#[test]
#[serial_test::serial(epics_env)]
fn a_quoted_or_escaped_gt_is_never_a_redirect() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let victim = dir.path().join("st.cmd");
std::fs::write(&victim, "iocInit\n").unwrap();
shell
.execute_line("epicsEnvSet(\"EPICS_RS_REDIR\", 'A>B')")
.expect("a single-quoted `>` is data, not a redirect");
assert_eq!(std::env::var("EPICS_RS_REDIR").unwrap(), "A>B");
assert!(
!std::path::Path::new("B')").exists(),
"the redirect target was invented from quoted text"
);
let line = format!("epicsEnvSet(\"EPICS_RS_REDIR2\", 'a>{}')", victim.display());
shell.execute_line(&line).expect("quoted `>` is data");
assert_eq!(
std::fs::read_to_string(&victim).unwrap(),
"iocInit\n",
"a quoted `>` truncated the running startup script"
);
let escaped = dir.path().join("escaped_target");
let line = format!("echo a\\>{}", escaped.display());
shell.execute_line(&line).expect("an escaped `>` is data");
assert!(
!escaped.exists(),
"an escaped `>` was treated as a redirect"
);
let unlinted = dir.path().join("unlinted_target");
let line = format!("dbl > \"{}", unlinted.display());
let err = shell
.execute_line(&line)
.err()
.expect("a malformed line must be refused");
assert_eq!(err, "Unbalanced quote.");
assert!(
!std::path::Path::new(&format!("\"{}", unlinted.display())).exists(),
"a line that fails the lint created its redirect target"
);
unsafe {
std::env::remove_var("EPICS_RS_REDIR");
std::env::remove_var("EPICS_RS_REDIR2");
}
}
#[test]
fn fd2_redirect_captures_diagnostics_and_spares_stdout() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let err = dir.path().join("err.txt");
shell
.execute_line(&format!("dbLoadTemplate(\"\") 2>{}", script_token(&err)))
.expect("the redirect itself must not fail the line");
assert_eq!(
std::fs::read_to_string(&err).unwrap().trim(),
"must specify variable substitution file",
"2> must capture the command's stderr"
);
let err2 = dir.path().join("err2.txt");
shell
.execute_line(&format!("dbl 2>{}", script_token(&err2)))
.unwrap();
assert_eq!(
std::fs::read_to_string(&err2).unwrap(),
"",
"2> must leave stdout alone"
);
let out = dir.path().join("out.txt");
shell
.execute_line(&format!("dbl > {}", script_token(&out)))
.unwrap();
assert!(
std::fs::read_to_string(&out).unwrap().contains("TEST_REC"),
"1> must still capture stdout"
);
}
#[test]
fn high_fd_redirect_creates_the_file_and_captures_nothing() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("fd5.txt");
shell
.execute_line(&format!("dbl 5>{}", script_token(&f)))
.unwrap();
assert!(f.exists(), "C opens the file for every redirected fd");
assert_eq!(std::fs::read_to_string(&f).unwrap(), "");
}
#[test]
fn stdin_redirect_is_parsed_as_fd_zero() {
let (cmd, redir) = parse_redirect("myCmd < /tmp/in.txt");
let redir = redir.expect("`<` is a redirect");
assert_eq!(cmd, "myCmd");
assert_eq!(redir.fd, 0);
assert_eq!(redir.path, "/tmp/in.txt");
assert!(!redir.append);
let (cmd, redir) = parse_redirect("epicsEnvSet(\"X\", 'a<b')");
assert!(redir.is_none(), "a quoted `<` is not a redirect");
assert_eq!(cmd, "epicsEnvSet(\"X\", 'a<b')");
}
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)
}
fn dump_registrar(shell: &IocShell) -> String {
let tmp = tempfile::NamedTempFile::new().unwrap();
shell
.execute_line(&format!(
"dbDumpRegistrar pdbbase > {}",
script_token(tmp.path())
))
.unwrap();
std::fs::read_to_string(tmp.path()).unwrap()
}
#[test]
fn a_registrar_declared_through_the_seam_is_reported_once() {
let shell = make_shell();
add_registrars(&["zzSeamProbe".to_string()]);
let printed = dump_registrar(&shell);
assert!(
printed.contains("registrar(zzSeamProbe)"),
"seam name missing from:\n{printed}"
);
add_registrars(&["zzSeamProbe".to_string()]);
let again = dump_registrar(&shell);
assert_eq!(
again.matches("registrar(zzSeamProbe)").count(),
1,
"{again}"
);
}
#[test]
fn a_shell_publishes_the_version_macros_a_db_can_expand() {
let shell = make_shell();
assert_eq!(
shell.expand_line("$(EPICS_VERSION_FULL)").unwrap(),
crate::runtime::version::EPICS_VERSION_FULL,
);
assert_eq!(
shell.expand_line("${ARCH}").unwrap(),
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();
assert!(matches!(
shell.execute_line("nonexistent_cmd"),
Ok(CommandOutcome::Failed)
));
}
#[test]
fn show_error_renders_c_s_two_forms() {
let source = SourceFile {
base: "d.cmd".into(),
lineno: 1,
};
let miss = "Command 'nosuchcmd' not registered.";
assert_eq!(
format_show_error(Some(&source), miss, true),
"\x1b[31;1mERROR\x1b[0m d.cmd line 1: \x1b[31;1mCommand 'nosuchcmd' not registered.\x1b[0m"
);
assert_eq!(
format_show_error(None, "Command 'alsonosuch' not registered.", true),
"\x1b[31;1mCommand 'alsonosuch' not registered.\x1b[0m"
);
assert_eq!(
format_show_error(Some(&source), miss, false),
"ERROR d.cmd line 1: Command 'nosuchcmd' not registered."
);
assert_eq!(format_show_error(None, miss, false), miss);
let rejected = SourceFile {
base: "s.cmd".into(),
lineno: 10,
};
assert_eq!(
format_show_error(
Some(&rejected),
"Expecting 'pdbbase' got 'notpdbbase'.",
true
),
"\x1b[31;1mERROR\x1b[0m s.cmd line 10: \x1b[31;1mExpecting 'pdbbase' got 'notpdbbase'.\x1b[0m"
);
}
#[test]
fn script_basename_is_c_s_strrchr_slash() {
assert_eq!(script_basename("/tmp/x/sub/d.cmd"), "d.cmd");
assert_eq!(script_basename("d.cmd"), "d.cmd");
assert_eq!(script_basename("./d.cmd"), "d.cmd");
assert_eq!(script_basename("a\\b.cmd"), "a\\b.cmd");
assert_eq!(script_basename("dir/"), "");
}
#[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("epicsEnvSet");
assert!(
matches!(result, Ok(CommandOutcome::Failed)),
"the shell must neither refuse the line nor frame the body's \
sentence"
);
}
#[test]
#[serial_test::serial(epics_env)]
fn epics_env_set_clears_the_shadowing_load_macro() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let inner = dir.path().join("inner.cmd");
std::fs::write(
&inner,
"epicsEnvSet(\"EPICS_RS_PORT\",\"NEW\")\n\
epicsEnvSet(\"EPICS_RS_CHOSEN\",\"$(EPICS_RS_PORT)\")\n",
)
.unwrap();
unsafe {
std::env::remove_var("EPICS_RS_PORT");
std::env::remove_var("EPICS_RS_CHOSEN");
}
shell
.execute_line(&format!(
"iocshLoad(\"{}\",\"EPICS_RS_PORT=OLD\")",
inner.display()
))
.expect("iocshLoad must run");
let chosen = std::env::var("EPICS_RS_CHOSEN").unwrap_or_default();
unsafe {
std::env::remove_var("EPICS_RS_PORT");
std::env::remove_var("EPICS_RS_CHOSEN");
}
assert_eq!(chosen, "NEW");
}
#[test]
#[serial_test::serial(epics_env)]
fn epics_env_unset_clears_the_shadowing_load_macro() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let inner = dir.path().join("inner.cmd");
std::fs::write(
&inner,
"epicsEnvUnset(\"EPICS_RS_GONE\")\n\
epicsEnvSet(\"EPICS_RS_SEEN\",\"$(EPICS_RS_GONE=fallback)\")\n",
)
.unwrap();
unsafe {
std::env::remove_var("EPICS_RS_GONE");
std::env::remove_var("EPICS_RS_SEEN");
}
shell
.execute_line(&format!(
"iocshLoad(\"{}\",\"EPICS_RS_GONE=OLD\")",
inner.display()
))
.expect("iocshLoad must run");
let seen = std::env::var("EPICS_RS_SEEN").unwrap_or_default();
unsafe {
std::env::remove_var("EPICS_RS_GONE");
std::env::remove_var("EPICS_RS_SEEN");
}
assert_eq!(seen, "fallback");
}
#[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)));
}
const C_HELP_NAMES: &[&str] = &[
"#",
"ClockTime_Report",
"afterIocRunning",
"asDumpHash",
"asInit",
"asSetFilename",
"asSetSubstitutions",
"ascar",
"asdbdump",
"asphag",
"aspmem",
"asprules",
"aspuag",
"astac",
"callbackParallelThreads",
"callbackQueueShow",
"callbackSetQueueSize",
"casr",
"cd",
"coreRelease",
"date",
"dbCreateAlias",
"dbCreateRecord",
"dbDumpBreaktable",
"dbDumpDevice",
"dbDumpDriver",
"dbDumpField",
"dbDumpFunction",
"dbDumpLink",
"dbDumpMenu",
"dbDumpPath",
"dbDumpRecord",
"dbDumpRecordType",
"dbDumpRegistrar",
"dbDumpVariable",
"dbLoadDatabase",
"dbLoadRecords",
"dbLoadTemplate",
"dbLockShowLocked",
"dbNotifyDump",
"dbPutAttribute",
"dbPvdDump",
"dbPvdTableSize",
"dbReportDeviceConfig",
"dbStateClear",
"dbStateCreate",
"dbStateSet",
"dbStateShow",
"dbStateShowAll",
"dba",
"dbap",
"dbb",
"dbc",
"dbcar",
"dbd",
"dbel",
"dbgf",
"dbglob",
"dbgrep",
"dbhcr",
"dbior",
"dbjlr",
"dbl",
"dbla",
"dbli",
"dblsr",
"dbnr",
"dbp",
"dbpf",
"dbpr",
"dbs",
"dbsr",
"dbstat",
"dbtgf",
"dbtpf",
"dbtpn",
"dbtr",
"dlload",
"echo",
"eltc",
"epicsEnvSet",
"epicsEnvShow",
"epicsEnvUnset",
"epicsMutexShowAll",
"epicsParamShow",
"epicsPrtEnvParams",
"epicsThreadResume",
"epicsThreadShow",
"epicsThreadShowAll",
"epicsThreadSleep",
"errlog",
"errlogInit",
"errlogInit2",
"errlogShow",
"exit",
"generalTimeReport",
"gft",
"help",
"installLastResortEventProvider",
"iocBuild",
"iocInit",
"iocLogInit",
"iocLogPrefix",
"iocLogShow",
"iocPause",
"iocRun",
"iocshCmd",
"iocshLoad",
"iocshRun",
"on",
"pft",
"postEvent",
"pwd",
"registerAllRecordDeviceDrivers",
"registryDeviceSupportFind",
"registryDriverSupportFind",
"registryDump",
"registryFunctionFind",
"registryRecordTypeFind",
"scanOnceQueueShow",
"scanOnceSetQueueSize",
"scanpel",
"scanpiol",
"scanppl",
"setIocLogDisable",
"softIoc_registerRecordDeviceDriver",
"system",
"taskwdShow",
"tpn",
"var",
];
const C_HELP_BLOCK: &[&str] = &[
"# ClockTime_Report afterIocRunning asDumpHash|",
"asInit asSetFilename asSetSubstitutions ascar|",
"asdbdump asphag aspmem asprules aspuag|",
"astac callbackParallelThreads callbackQueueShow|",
"callbackSetQueueSize casr cd coreRelease|",
"date dbCreateAlias dbCreateRecord dbDumpBreaktable|",
"dbDumpDevice dbDumpDriver dbDumpField dbDumpFunction dbDumpLink|",
"dbDumpMenu dbDumpPath dbDumpRecord dbDumpRecordType|",
"dbDumpRegistrar dbDumpVariable dbLoadDatabase dbLoadRecords dbLoadTemplate|",
"dbLockShowLocked dbNotifyDump dbPutAttribute dbPvdDump|",
"dbPvdTableSize dbReportDeviceConfig dbStateClear dbStateCreate|",
"dbStateSet dbStateShow dbStateShowAll dba dbap|",
"dbb dbc dbcar dbd dbel|",
"dbgf dbglob dbgrep dbhcr dbior|",
"dbjlr dbl dbla dbli dblsr|",
"dbnr dbp dbpf dbpr dbs|",
"dbsr dbstat dbtgf dbtpf dbtpn|",
"dbtr dlload echo eltc epicsEnvSet|",
"epicsEnvShow epicsEnvUnset epicsMutexShowAll epicsParamShow|",
"epicsPrtEnvParams epicsThreadResume |",
"epicsThreadShow epicsThreadShowAll epicsThreadSleep|",
"errlog errlogInit errlogInit2 errlogShow exit|",
"generalTimeReport gft help |",
"installLastResortEventProvider iocBuild iocInit iocLogInit|",
"iocLogPrefix iocLogShow iocPause iocRun iocshCmd|",
"iocshLoad iocshRun on pft postEvent|",
"pwd registerAllRecordDeviceDrivers registryDeviceSupportFind|",
"registryDriverSupportFind registryDump registryFunctionFind|",
"registryRecordTypeFind scanOnceQueueShow |",
"scanOnceSetQueueSize scanpel scanpiol scanppl|",
"setIocLogDisable softIoc_registerRecordDeviceDriver|",
"system taskwdShow tpn var |",
];
fn stub_def(name: &str, args: Vec<ArgDesc>, usage: &str) -> CommandDef {
CommandDef::new(
name.to_string(),
args,
usage.to_string(),
|_args: &[ArgValue], _ctx: &CommandContext| Ok(CommandOutcome::Continue),
)
}
#[test]
fn the_column_layout_reproduces_a_measured_c_help_block() {
let want: String = C_HELP_BLOCK
.iter()
.map(|l| l.trim_end_matches('|'))
.collect::<Vec<_>>()
.join("\n");
let got = format_command_columns(C_HELP_NAMES);
let (list, trailer) = got.split_at(got.find("\n\nType 'help <glob>'").unwrap());
assert_eq!(list, want);
assert_eq!(
trailer,
"\n\nType 'help <glob>' for information about commands matching\n\
the name or pattern <glob>, e.g. 'help db*'"
);
}
#[test]
fn a_name_ending_on_a_tab_stop_is_padded_to_the_next_one() {
let got = format_command_columns(&["0123456789abcdef", "x"]);
assert_eq!(
got.lines().next().unwrap(),
"0123456789abcdef x "
);
}
#[test]
fn a_name_reaching_column_64_ends_the_line_unpadded() {
let long = "a".repeat(45);
let got = format_command_columns(&[&long, "0123456789abcdef", "next"]);
let first = got.lines().next().unwrap();
assert_eq!(first.len(), 64);
assert!(!first.ends_with(' '));
assert_eq!(got.lines().nth(1).unwrap().trim_end(), "next");
}
#[test]
fn a_name_crossing_column_79_starts_the_next_line() {
let long = "b".repeat(64);
let got = format_command_columns(&["short", &long]);
assert_eq!(got.lines().next().unwrap(), "short ");
assert_eq!(got.lines().nth(1).unwrap().trim_end(), long);
}
#[test]
fn an_empty_command_table_prints_only_the_trailer() {
assert_eq!(
format_command_columns(&[]),
"\nType 'help <glob>' for information about commands matching\n\
the name or pattern <glob>, e.g. 'help db*'"
);
}
#[test]
fn an_argument_name_with_a_space_is_quoted_unless_it_is_the_variadic_tail() {
let def = stub_def(
"dbl",
vec![
ArgDesc {
name: "record type",
arg_type: ArgType::String,
},
ArgDesc {
name: "fields",
arg_type: ArgType::String,
},
],
"Database list.",
);
assert_eq!(
format_help_entry(&def, false, true),
"\ndbl 'record type' fields\n\nDatabase list."
);
let variadic = stub_def(
"help",
vec![ArgDesc {
name: "[command ...]",
arg_type: ArgType::Argv,
}],
"With no arguments, list available command names.",
);
assert_eq!(
format_help_entry(&variadic, false, true),
"\nhelp [command ...]\n\nWith no arguments, list available command names."
);
}
#[test]
fn the_rule_between_entries_precedes_every_entry_but_the_first() {
let def = stub_def("cd", vec![], "Change directory.");
assert_eq!(
format_help_entry(&def, false, true),
"\ncd\n\nChange directory."
);
assert_eq!(
format_help_entry(&def, false, false),
format!("{}\n\ncd\n\nChange directory.", " ".repeat(60))
);
assert_eq!(
format_help_entry(&def, true, false),
format!(
"\x1b[4m{}\x1b[0m\n\n\x1b[1mcd\x1b[0m\n\nChange directory.",
" ".repeat(60)
)
);
}
#[test]
fn a_command_without_usage_text_prints_only_its_synopsis() {
let def = stub_def("quiet", vec![], "");
assert_eq!(format_help_entry(&def, false, true), "\nquiet");
}
#[test]
fn help_globs_every_argument_and_stays_silent_on_a_miss() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let miss = dir.path().join("miss.txt");
shell
.execute_line(&format!("help nosuchthing > {}", script_token(&miss)))
.unwrap();
assert_eq!(std::fs::read_to_string(&miss).unwrap(), "");
let hit = dir.path().join("hit.txt");
shell
.execute_line(&format!("help dbPvd* > {}", script_token(&hit)))
.unwrap();
let hit = std::fs::read_to_string(&hit).unwrap();
assert!(hit.contains("dbPvdDump"), "glob must match: {hit}");
assert!(hit.contains("dbPvdTableSize"), "glob must match all: {hit}");
let twice = dir.path().join("twice.txt");
shell
.execute_line(&format!("help dbPvdDump dbPvd* > {}", script_token(&twice)))
.unwrap();
let twice = std::fs::read_to_string(&twice).unwrap();
assert_eq!(
twice
.lines()
.filter(|l| l.ends_with("pdbbase verbose"))
.count(),
2,
"got: {twice}"
);
}
#[test]
fn a_failed_include_errors_the_line_without_a_second_message() {
let shell = make_shell();
let (result, dispatch) = shell.execute_line_dispatched("< nonexistent_file.cmd");
assert!(
matches!(result, Ok(CommandOutcome::Failed)),
"a failed include is the errored flag, not a diagnostic"
);
assert!(
matches!(dispatch, Dispatch::Nothing),
"`<` reaches no registered function"
);
}
#[test]
fn an_unopenable_script_prints_cs_open_failure_once() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let missing_path = dir.path().join("no-such.cmd");
let missing = script_token(&missing_path);
let captured = dir.path().join("captured.err");
let result = shell
.ctx
.with_error(std::fs::File::create(&captured).unwrap(), || {
shell.execute_script(&missing)
});
assert!(result.is_err(), "an unopenable script is a failure");
let reason = c_strerror(&std::fs::File::open(&missing_path).expect_err("no such file"));
let printed = std::fs::read_to_string(&captured).unwrap();
assert_eq!(
printed,
format!(
"{}\n",
paint_error(&format!("Can't open {missing}: {reason}"))
),
"one line, C's wording, C's `strerror` with no Rust suffix"
);
}
#[test]
fn c_strerror_drops_rusts_errno_suffix() {
let enoent = std::io::Error::from_raw_os_error(2);
assert!(enoent.to_string().ends_with(" (os error 2)"));
assert_eq!(
c_strerror(&enoent),
enoent.to_string().trim_end_matches(" (os error 2)")
);
#[cfg(all(unix, target_env = "gnu"))]
assert_eq!(c_strerror(&enoent), "No such file or directory");
let other = std::io::Error::other("stream did not contain valid UTF-8");
assert_eq!(c_strerror(&other), "stream did not contain valid UTF-8");
}
#[test]
fn self_including_script_errors_at_the_depth_cap() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("self.cmd");
std::fs::write(&path, format!("< {}\n", path.display())).unwrap();
shell
.execute_script(&path.display().to_string())
.expect("the cap terminates the recursion; continue keeps ret 0");
assert!(
shell.scopes.borrow().is_empty(),
"scope ticket fully released"
);
let path2 = dir.path().join("self2.cmd");
std::fs::write(&path2, format!("iocshLoad {}\n", script_token(&path2))).unwrap();
shell
.execute_script(&path2.display().to_string())
.expect("same for the iocshLoad spelling");
assert!(
shell.scopes.borrow().is_empty(),
"scope ticket fully released"
);
let path3 = dir.path().join("self3.cmd");
std::fs::write(&path3, format!("on error break\n< {}\n", path3.display())).unwrap();
let err = shell
.execute_script(&path3.display().to_string())
.expect_err("break makes the cap's failure the script's result");
assert_eq!(err, format!("{}:2", path3.display()), "got: {err}");
assert!(
shell.scopes.borrow().is_empty(),
"scope ticket fully released"
);
}
#[test]
fn nested_include_under_the_cap_still_runs() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let inner = dir.path().join("inner.cmd");
std::fs::write(&inner, "#- inner\n").unwrap();
let outer = dir.path().join("outer.cmd");
std::fs::write(&outer, format!("< {}\n", inner.display())).unwrap();
let outer_path = outer.display().to_string();
shell
.execute_script(&outer_path)
.expect("two-level include must succeed");
assert!(shell.scopes.borrow().is_empty());
shell
.execute_script(&outer_path)
.expect("re-running the same include must succeed");
assert!(shell.scopes.borrow().is_empty());
}
#[test]
fn startup_script_env_is_first_load_only() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let inner = dir.path().join("inner.cmd");
std::fs::write(&inner, "#- inner\n").unwrap();
let outer = dir.path().join("outer.cmd");
std::fs::write(&outer, format!("iocshLoad {}\n", script_token(&inner))).unwrap();
let outer_path = outer.display().to_string();
unsafe { std::env::remove_var("IOCSH_STARTUP_SCRIPT") };
shell
.execute_script_with_macros(&outer_path, &HashMap::new())
.unwrap();
assert_eq!(
std::env::var("IOCSH_STARTUP_SCRIPT").as_deref(),
Ok(outer_path.as_str()),
"the outer script wins; the nested iocshLoad must not overwrite"
);
unsafe { std::env::remove_var("IOCSH_STARTUP_SCRIPT") };
shell.execute_script(&inner.display().to_string()).unwrap();
assert!(
std::env::var_os("IOCSH_STARTUP_SCRIPT").is_none(),
"the iocshBody path ('<' includes) must not set the variable"
);
unsafe { std::env::set_var("IOCSH_STARTUP_SCRIPT", "inherited.cmd") };
shell
.execute_script_with_macros(&outer_path, &HashMap::new())
.unwrap();
assert_eq!(
std::env::var("IOCSH_STARTUP_SCRIPT").as_deref(),
Ok("inherited.cmd"),
"a value inherited from the environment is kept (C getenv guard)"
);
unsafe { std::env::remove_var("IOCSH_STARTUP_SCRIPT") };
}
#[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 tmpdir = tempfile::tempdir().expect("fixture root");
let tmp = tmpdir.path().join("iocsh_test_dbl_redirect.txt");
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 tmpdir = tempfile::tempdir().expect("fixture root");
let tmp = tmpdir.path().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 tmpdir = tempfile::tempdir().expect("fixture root");
let tmp = tmpdir.path().join("iocsh_test_fd2_redirect.txt");
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 pdbbase 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();
let r = shell.execute_line("dbCreateRecord pdbbase ai TEST_REC");
assert!(r.is_err(), "duplicate name must return Err");
let r = shell.execute_line("dbpr 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 pdbbase ai \"BAD NAME\"");
assert!(r.is_err(), "bad name must return Err");
}
#[test]
fn test_execute_line_db_create_record_rejects_unknown_type() {
let shell = make_shell();
let r = shell.execute_line("dbCreateRecord pdbbase nonexistent NEW_REC");
assert!(r.is_err(), "unknown record type must return Err");
}
#[test]
fn on_error_break_stops_at_a_failed_db_command() {
let shell = make_shell();
let dir = std::env::temp_dir().join(format!("iocsh_ui105_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("st.cmd");
std::fs::write(
&path,
"on error break\ndbCreateRecord pdbbase nonexistent X\ndbCreateRecord pdbbase ai SHOULD_NOT_EXIST\n",
)
.unwrap();
let result = shell.execute_script(path.to_str().unwrap());
assert!(result.is_err(), "script must surface the db failure");
assert!(
shell.ctx.db().get_record("SHOULD_NOT_EXIST").is_none(),
"on error break must stop before the next line creates the record"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[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 tmpdir = tempfile::tempdir().expect("fixture root");
let tmp = tmpdir.path().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 tmpdir = tempfile::tempdir().expect("fixture root");
let tmp = tmpdir.path().join("iocsh_load_macro_cmd.cmd");
std::fs::write(&tmp, "$(CMD)\n").unwrap();
let line = format!("iocshLoad {} CMD=dbl", script_token(&tmp));
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 tmpdir = tempfile::tempdir().expect("fixture root");
let tmp = tmpdir.path().join("iocsh_load_no_macros.cmd");
std::fs::write(&tmp, "dbl\n").unwrap();
let line = format!("iocshLoad {}", script_token(&tmp));
let result = shell.execute_line(&line);
std::fs::remove_file(&tmp).ok();
assert!(matches!(result, Ok(CommandOutcome::Continue)));
}
#[test]
#[serial_test::serial(epics_env)]
fn one_pass_resolves_env_and_iocsh_load_macros_together() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("R3_TOP", "/opt/myioc") };
let sub = dir.path().join("sub");
std::fs::create_dir(&sub).unwrap();
std::fs::write(
sub.join("inner.cmd"),
"epicsEnvSet(\"R3_INNER\", \"$(R3_TOP)/db/$(PORT).db\")\n",
)
.unwrap();
let loaded = dir.path().join("loaded.cmd");
std::fs::write(
&loaded,
format!(
"epicsEnvSet(\"R3_BOTH\", \"$(R3_TOP)/db/$(PORT).db\")\n\
epicsEnvSet(\"R3_MACRO_ONLY\", \"$(PORT)\")\n\
< {}/$(SUBDIR)/inner.cmd\n",
script_token(dir.path())
),
)
.unwrap();
shell
.execute_line(&format!(
"iocshLoad {} \"PORT=L0,SUBDIR=sub\"",
script_token(&loaded)
))
.expect("iocshLoad with macros must not break env references");
assert_eq!(std::env::var("R3_BOTH").unwrap(), "/opt/myioc/db/L0.db");
assert_eq!(std::env::var("R3_MACRO_ONLY").unwrap(), "L0");
assert_eq!(std::env::var("R3_INNER").unwrap(), "/opt/myioc/db/L0.db");
let env_only = dir.path().join("env_only.cmd");
std::fs::write(
&env_only,
"epicsEnvSet(\"R3_ENV_ONLY\", \"$(R3_TOP)/db\")\n",
)
.unwrap();
shell
.execute_line(&format!("iocshLoad {}", script_token(&env_only)))
.unwrap();
assert_eq!(std::env::var("R3_ENV_ONLY").unwrap(), "/opt/myioc/db");
shell
.execute_line(&format!("< {}", script_token(&env_only)))
.unwrap();
assert_eq!(std::env::var("R3_ENV_ONLY").unwrap(), "/opt/myioc/db");
let after = dir.path().join("after.cmd");
std::fs::write(&after, "epicsEnvSet(\"R3_AFTER\", \"$(PORT)\")\n").unwrap();
assert!(
matches!(
shell.execute_line("epicsEnvSet(\"R3_AFTER\", \"$(PORT)\")"),
Ok(CommandOutcome::Failed)
),
"PORT must not survive the iocshLoad scope"
);
assert!(
std::env::var("R3_AFTER").is_err(),
"the refused line must install nothing"
);
shell
.execute_script(&after.display().to_string())
.expect("a skipped line leaves C's ret at 0");
assert!(std::env::var("R3_AFTER").is_err());
unsafe {
std::env::remove_var("R3_TOP");
std::env::remove_var("R3_BOTH");
std::env::remove_var("R3_MACRO_ONLY");
std::env::remove_var("R3_INNER");
std::env::remove_var("R3_ENV_ONLY");
}
}
#[test]
#[serial_test::serial(epics_env)]
fn an_undefined_macro_refuses_the_iocsh_line() {
let shell = make_shell();
unsafe { std::env::remove_var("R3_UNSET") };
unsafe { std::env::remove_var("R3_PREFIX") };
assert!(
matches!(
shell.execute_line("epicsEnvSet(\"R3_PREFIX\", \"$(R3_UNSET)\")"),
Ok(CommandOutcome::Failed)
),
"an undefined macro must refuse the line, and say so without a \
second copy of macLib's sentence"
);
assert!(
std::env::var("R3_PREFIX").is_err(),
"the refused line must install nothing"
);
shell
.execute_line("epicsEnvSet(\"R3_PREFIX\", \"$(R3_UNSET=fallback)\")")
.unwrap();
assert_eq!(std::env::var("R3_PREFIX").unwrap(), "fallback");
unsafe { std::env::remove_var("R3_PREFIX") };
}
#[test]
#[serial_test::serial(epics_env)]
fn a_macro_value_with_separators_is_split_into_words() {
let shell = make_shell();
unsafe { std::env::set_var("R3_MULTIWORD", "dbpr TEST_REC 2") };
let expanded = shell.expand_line("$(R3_MULTIWORD) EXTRA").unwrap();
assert_eq!(
registry::tokenize(&expanded),
vec!["dbpr", "TEST_REC", "2", "EXTRA"]
);
unsafe { std::env::remove_var("R3_MULTIWORD") };
}
#[test]
fn a_bare_iocsh_load_runs_the_nested_shell_rather_than_refusing_the_line() {
let shell = make_shell();
assert!(matches!(
shell.execute_line("iocshLoad"),
Ok(CommandOutcome::Continue)
));
}
#[test]
fn test_db_load_records_different_type_duplicate_propagates() {
let shell = make_shell();
let tmpdir = tempfile::tempdir().expect("fixture root");
let db_path = tmpdir.path().join("iocsh_dup_load.db");
std::fs::write(&db_path, "record(mbbo, \"TEST_REC\") {}\n").unwrap();
let script_path = tmpdir.path().join("iocsh_dup_load.cmd");
std::fs::write(
&script_path,
format!("dbLoadRecords {}\n", script_token(&db_path)),
)
.unwrap();
let line_result =
shell.execute_line_reported(&format!("dbLoadRecords {}", script_token(&db_path)));
let script_result = shell.execute_script(script_path.to_str().unwrap());
let _ = std::fs::remove_file(&db_path);
let _ = std::fs::remove_file(&script_path);
assert!(
line_result.is_err(),
"dbLoadRecords with type-mismatched duplicate must fail the line"
);
assert!(
script_result.is_ok(),
"a failed line under `on error continue` leaves C's ret at 0"
);
}
#[test]
fn test_iocsh_load_cpp_paren_syntax() {
let shell = make_shell();
let tmpdir = tempfile::tempdir().expect("fixture root");
let tmp = tmpdir.path().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_only_break_propagates() {
let shell = make_shell();
let tmpdir = tempfile::tempdir().expect("fixture root");
let tmp = tmpdir.path().join("iocsh_load_err.cmd");
std::fs::write(&tmp, "nonexistent_cmd\ndbl\n").unwrap();
let result = shell.execute_line(&format!("iocshLoad {}", script_token(&tmp)));
std::fs::remove_file(&tmp).ok();
assert!(
result.is_ok(),
"a failing line under `continue` leaves iocshLoad's status 0"
);
let brk = tmpdir.path().join("iocsh_load_err_break.cmd");
std::fs::write(&brk, "on error break\nnonexistent_cmd\ndbl\n").unwrap();
let result = shell.execute_line(&format!("iocshLoad {}", script_token(&brk)));
std::fs::remove_file(&brk).ok();
assert!(
matches!(result, Ok(CommandOutcome::Failed)),
"`on error break` is what makes iocshLoad fail the line"
);
}
#[test]
fn a_failed_include_does_not_repeat_the_inner_summary() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let missing = script_token(&dir.path().join("no-such-include.cmd"));
let outer = dir.path().join("outer.cmd");
std::fs::write(&outer, format!("on error break\n< {missing}\n")).unwrap();
let err = shell
.execute_script(&script_token(&outer))
.expect_err("break turns the failed include into the script's result");
assert_eq!(err, format!("{}:2", script_token(&outer)));
}
#[test]
fn test_execute_line_db_create_record_missing_args() {
let shell = make_shell();
match shell.execute_line("dbCreateRecord pdbbase ai") {
Err(msg) => assert_eq!(msg, "33554465 Record name is required"),
Ok(_) => panic!("a missing record name must fail the command"),
}
}
#[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_one_command_line_and_does_not_split_on_semicolon() {
let shell = make_shell();
assert!(matches!(
shell.execute_line(r#"iocshRun("dbl")"#),
Ok(CommandOutcome::Continue)
));
assert!(
matches!(
shell.execute_line(r#"iocshRun("dbl; pwd")"#),
Ok(CommandOutcome::Failed)
),
"`;` is not a command separator in C, so `dbl;` must be unregistered"
);
}
#[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 tmpdir = tempfile::tempdir().expect("fixture root");
let tmp = tmpdir.path().join("iocsh_dbsr_report.txt");
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_eq!(
content.trim_end(),
"No server layers registered with IOC",
"dbsr with no layer registered is C's one line and nothing else"
);
assert!(!content.contains("Records served"));
assert!(!content.contains("Total"));
std::fs::remove_file(&tmp).ok();
}
#[test]
fn an_unregistered_command_does_not_fail_the_script() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("a.cmd");
std::fs::write(
&script,
"nonexistent_cmd\ndbCreateRecord pdbbase ai AFTER_THE_MISS\n",
)
.unwrap();
let result = shell.execute_script(script.to_str().unwrap());
assert!(
result.is_ok(),
"C's `ret` stays 0 through a registry miss: {result:?}"
);
assert!(
shell.ctx.db().get_record("AFTER_THE_MISS").is_some(),
"C runs the line after the miss"
);
}
#[test]
fn test_on_error_break_stops_script() {
let shell = make_shell();
let tmpdir = tempfile::tempdir().expect("fixture root");
let tmp = tmpdir.path().join("iocsh_on_error_break.cmd");
std::fs::write(
&tmp,
"on error break\nnonexistent_cmd\ndbCreateRecord pdbbase 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.ctx.db().get_record("SHOULD_NOT_EXIST").is_none(),
"on error break must stop before line 3 runs"
);
}
#[test]
fn on_error_break_in_an_included_script_dies_with_that_script() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let inner = dir.path().join("inner.cmd");
std::fs::write(&inner, "on error break\n").unwrap();
let out = dir.path().join("after.txt");
let outer = dir.path().join("outer.cmd");
std::fs::write(
&outer,
format!(
"< {}\nnonexistent_cmd\ndbl > {}\n",
inner.display(),
out.display()
),
)
.unwrap();
let result = shell.execute_script(&outer.display().to_string());
assert!(
result.is_ok(),
"the caller's own scope is `continue`, so C's ret stays 0"
);
assert!(
out.exists(),
"the include's 'on error break' must not stop the caller's script"
);
assert!(shell.scopes.borrow().is_empty(), "scope ticket released");
}
#[test]
fn iocsh_run_breaks_without_leaking_the_mode() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let after = dir.path().join("after.txt");
let script = dir.path().join("st.cmd");
std::fs::write(
&script,
format!(
"iocshRun \"nonexistent_cmd\"\nnonexistent_cmd\ndbl > {}\n",
after.display()
),
)
.unwrap();
let result = shell.execute_script(&script.display().to_string());
assert!(
result.is_ok(),
"the enclosing script's scope is `continue`, so C's ret stays 0"
);
assert!(
after.exists(),
"the implied break must not outlive the iocshRun scope"
);
}
#[test]
fn on_error_wait_takes_a_fractional_delay_and_continues() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("after.txt");
let script = dir.path().join("st.cmd");
std::fs::write(
&script,
format!(
"on error wait 0.25\nnonexistent_cmd\ndbl > {}\n",
out.display()
),
)
.unwrap();
let start = std::time::Instant::now();
let result = shell.execute_script(&script.display().to_string());
let elapsed = start.elapsed();
assert!(result.is_err(), "the failing line must still be reported");
assert!(
elapsed >= std::time::Duration::from_millis(200),
"a 0.25 s wait must actually stall: {elapsed:?}"
);
assert!(out.exists(), "'wait' continues the script after the stall");
}
const WAIT_UNIT: f64 = 0.4;
fn timed_script(shell: &IocShell, dir: &std::path::Path, body: &str) -> std::time::Duration {
let script = dir.join("st.cmd");
std::fs::write(&script, body).unwrap();
let start = std::time::Instant::now();
let _ = shell.execute_script(&script.display().to_string());
start.elapsed()
}
#[test]
fn on_error_wait_stalls_once_per_line_that_dispatches_nothing() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let elapsed = timed_script(
&shell,
dir.path(),
&format!("on error wait {WAIT_UNIT}\nnonexistent_cmd\n# one\n# two\n"),
);
assert!(
elapsed.as_secs_f64() >= WAIT_UNIT * 2.0,
"two comments and the loop's last pass must each stall again: {elapsed:?}"
);
}
#[test]
fn a_command_that_runs_clears_the_pending_error() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("after.txt");
let elapsed = timed_script(
&shell,
dir.path(),
&format!(
"on error wait {WAIT_UNIT}\nnonexistent_cmd\ndbl > {}\n# one\n",
out.display()
),
);
assert!(out.exists(), "'wait' continues the script after the stall");
assert!(
elapsed.as_secs_f64() < WAIT_UNIT * 2.0,
"the `dbl` clears the failure, so nothing after it stalls: {elapsed:?}"
);
}
#[test]
fn an_include_that_succeeds_does_not_clear_the_callers_error() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("inner.txt");
let inner = dir.path().join("inner.cmd");
std::fs::write(&inner, format!("dbl > {}\n", out.display())).unwrap();
let elapsed = timed_script(
&shell,
dir.path(),
&format!(
"on error wait {WAIT_UNIT}\nnonexistent_cmd\n< {}\n",
inner.display()
),
);
assert!(out.exists(), "the include itself must have run");
assert!(
elapsed.as_secs_f64() >= WAIT_UNIT * 2.0,
"the include line dispatched nothing of the caller's own: {elapsed:?}"
);
}
#[test]
fn the_on_command_clears_a_pending_error() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let elapsed = timed_script(
&shell,
dir.path(),
&format!(
"on error wait {WAIT_UNIT}\nnonexistent_cmd\non error wait {WAIT_UNIT}\n# one\n"
),
);
assert!(
elapsed.as_secs_f64() < WAIT_UNIT * 2.0,
"the second `on error` cleared the failure: {elapsed:?}"
);
}
#[test]
fn a_failure_on_the_last_line_still_reaches_the_reaction() {
let shell = make_shell();
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("st.cmd");
std::fs::write(&script, "on error break\nnonexistent_cmd\n").unwrap();
assert!(
shell.execute_script(&script.display().to_string()).is_err(),
"`break` on the file's last line must still set C's ret = -1"
);
}
#[test]
fn on_error_halt_suspends_the_shell_thread() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("st.cmd");
std::fs::write(&script, "on error halt\nnonexistent_cmd\n").unwrap();
let path = script.display().to_string();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let shell = make_shell();
let _ = shell.execute_script(&path);
let _ = tx.send(());
});
assert!(
rx.recv_timeout(std::time::Duration::from_millis(750))
.is_err(),
"'on error halt' must leave the shell thread suspended"
);
let halted = crate::runtime::task::thread_report()
.into_iter()
.find(|t| t.is_suspended())
.expect("the halted shell must be a SUSPEND row, not an OK one");
assert!(
halted.show_line().ends_with(" SUSPEND"),
"C's STATE column reads SUSPEND \
(os/Linux/osdThreadExtra.c:49-54), got {:?}",
halted.show_line()
);
assert!(halted.resume(), "epicsThreadResume must find it suspended");
rx.recv_timeout(std::time::Duration::from_secs(5))
.expect("epicsThreadResume must release the halted shell");
}
#[test]
fn on_error_command_boundaries_match_c() {
let shell = make_shell();
shell
.execute_line("on error break")
.expect("outside iocshBody the command does nothing");
assert!(shell.scopes.borrow().is_empty());
{
let _scope = shell.enter_scope(IocshScope::interactive());
shell
.execute_line("on error break")
.expect("an interactive shell ignores it without failing");
assert_eq!(
shell.current_scope().unwrap().on_error,
OnError::Continue,
"'on error' must not take effect in an interactive shell"
);
}
let _scope = shell.enter_scope(IocshScope::script("st.cmd"));
assert_eq!(shell.current_scope().unwrap().on_error, OnError::Continue);
shell.execute_line("on error halt").unwrap();
assert_eq!(
shell.current_scope().unwrap().on_error,
OnError::Halt { timeout: 0.0 }
);
shell.execute_line("on error wait 1.5").unwrap();
assert_eq!(
shell.current_scope().unwrap().on_error,
OnError::Halt { timeout: 1.5 }
);
shell
.execute_line("on error wait bogus")
.expect("C prints the usage and keeps the line's status clean");
assert_eq!(
shell.current_scope().unwrap().on_error,
OnError::Halt { timeout: 5.0 },
"an unparseable delay falls back to C's 5.0 s"
);
shell
.execute_line("on error wait")
.expect("usage only, not an error");
assert_eq!(
shell.current_scope().unwrap().on_error,
OnError::Halt { timeout: 0.0 },
"'wait' with no delay is C's plain halt"
);
assert!(
shell.execute_line("on error bogus").is_err(),
"an unrecognised mode is the one case C flags as an error"
);
}
#[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) };
}
}
}