use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
const SHELLS: &[(&str, &[&str])] = &[
("sh", &["/bin/sh"]),
(
"bash",
&[
"/bin/bash",
"/usr/bin/bash",
"/usr/local/bin/bash",
"/opt/homebrew/bin/bash",
],
),
("zsh", &["/bin/zsh", "/usr/bin/zsh"]),
("dash", &["/bin/dash", "/usr/bin/dash"]),
("ksh", &["/bin/ksh", "/usr/bin/ksh"]),
];
pub(crate) const SCRIPT_NAME: &str = "bnto";
pub(crate) fn script(params: &Map<String, Value>) -> Option<&str> {
params.get("script").and_then(Value::as_str)
}
pub(crate) fn shell_name(params: &Map<String, Value>) -> &str {
params.get("shell").and_then(Value::as_str).unwrap_or("sh")
}
pub(crate) fn script_problems(params: &Map<String, Value>) -> Vec<String> {
let mut problems = Vec::new();
if params.contains_key("command") {
problems.push("set 'command' or 'script', not both".to_string());
}
if script(params).is_some_and(|s| s.trim().is_empty()) {
problems.push("'script' must not be empty".to_string());
}
let shell = shell_name(params);
if !SHELLS.iter().any(|(name, _)| *name == shell) {
let names: Vec<&str> = SHELLS.iter().map(|(name, _)| *name).collect();
problems.push(format!(
"'shell' must be one of {}, not '{shell}'",
names.join(", ")
));
}
if params.contains_key("searchPath") {
problems.push("'searchPath' doesn't apply to a script; the shell is fixed".to_string());
}
problems
}
pub(crate) fn raw_script_problems(raw: &Map<String, Value>) -> Vec<String> {
["script", "shell"]
.iter()
.filter_map(|key| {
let text = raw.get(*key)?.as_str()?;
let templated =
bnto_core::placeholders::mentions_data(text) || text.contains("{{vars.");
templated.then(|| {
format!(
"'{key}' must be written literally — templates would write the script. \
Pass values in 'args' and read them as \"$1\", \"$2\"…"
)
})
})
.collect()
}
pub(crate) fn invocation(shell: &str, script: &str) -> Result<(PathBuf, Vec<String>), String> {
if cfg!(windows) {
return Err(
"script mode runs a POSIX shell, which Windows doesn't have; \
use 'command' there"
.to_string(),
);
}
let path = shell_path(shell, Path::exists)?;
let mut args: Vec<String> = startup_off_flag(shell)
.iter()
.map(|f| f.to_string())
.collect();
args.extend([
"-c".to_string(),
script.to_string(),
SCRIPT_NAME.to_string(),
]);
Ok((path, args))
}
fn shell_path(shell: &str, exists: impl Fn(&Path) -> bool) -> Result<PathBuf, String> {
let (_, candidates) = SHELLS
.iter()
.find(|(name, _)| *name == shell)
.ok_or_else(|| format!("unknown shell '{shell}'"))?;
candidates
.iter()
.map(PathBuf::from)
.find(|p| exists(p))
.ok_or_else(|| {
format!(
"{shell} isn't installed (looked in {})",
candidates.join(", ")
)
})
}
fn startup_off_flag(shell: &str) -> &'static [&'static str] {
match shell {
"bash" => &["-p"],
"zsh" => &["-f"],
_ => &[],
}
}
#[cfg(test)]
mod tests;