macro_rules! reg {
($engine:expr, $name:expr, [$($pi:literal),* $(,)?], $doc:expr, $func:expr $(,)?) => {
rhai::FuncRegistration::new($name)
.with_params_info([$($pi),*])
.with_comments([$doc])
.register_into_engine($engine, $func);
};
}
mod bindings;
mod convert;
mod docs;
mod host;
mod types;
pub use docs::{write_book_api, write_definitions};
pub use host::scenario_names;
use crate::engine::{self, Ctx};
use crate::runtime::Output;
use anyhow::{Context, Result, anyhow, bail};
use rhai::Engine;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
pub fn run(
paths: &[PathBuf],
output: Output,
overrides: HashMap<String, String>,
filters: engine::Filters,
env_files: &[PathBuf],
) -> Result<()> {
let files = collect_files(paths)?;
let shared_env = load_env_files(env_files)?;
let programs: Vec<(String, _)> = files
.into_iter()
.map(|path| {
let overrides = overrides.clone();
let mut env = shared_env.clone();
let sibling = path.with_extension("env");
let sibling = if sibling != path && sibling.is_file() {
Some(sibling)
} else {
None
};
let label = path.display().to_string();
let build = move |ctx: Arc<Ctx>| -> Result<host::RhaiHost> {
if let Some(sibling) = &sibling {
merge_dotenv(sibling, &mut env)?;
}
let env = Arc::new(std::sync::Mutex::new(env));
let base = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let source = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
let registry = Arc::new(host::Registry::default());
let mut engine = bindings::build_engine(ctx, registry.clone(), env, base.clone());
engine.set_module_resolver(
rhai::module_resolvers::FileModuleResolver::new_with_path(base),
);
let ast = engine
.compile(&source)
.map_err(|e| anyhow!("in {}: {e}", path.display()))?;
let engine = Arc::new(engine);
let ast = Arc::new(ast);
registry.set_exec(Arc::downgrade(&engine), ast.clone());
Ok(host::RhaiHost::new(engine, ast, registry, overrides))
};
(label, build)
})
.collect();
engine::run(programs, output, DEFAULT_TIMEOUT, filters)
}
fn collect_files(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for p in paths {
if p.is_dir() {
let mut found = Vec::new();
collect_dir(p, &mut found, 0)
.with_context(|| format!("scan directory {}", p.display()))?;
found.retain(|f| host::dir_should_run(f));
files.extend(found);
} else {
files.push(p.clone()); }
}
files.sort();
files.dedup();
if files.is_empty() {
bail!("no .rhai scenario files found");
}
Ok(files)
}
fn load_env_files(paths: &[PathBuf]) -> Result<HashMap<String, String>> {
let mut env = HashMap::new();
for p in paths {
merge_dotenv(p, &mut env)?;
}
Ok(env)
}
fn merge_dotenv(path: &Path, env: &mut HashMap<String, String>) -> Result<()> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("read env file {}", path.display()))?;
for (i, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let (key, value) = line
.split_once('=')
.with_context(|| format!("{}:{}: expected KEY=VALUE", path.display(), i + 1))?;
let value = value.trim();
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
.unwrap_or(value);
env.insert(key.trim().to_string(), value.to_string());
}
Ok(())
}
const MAX_DIR_DEPTH: usize = 32;
fn collect_dir(dir: &Path, out: &mut Vec<PathBuf>, depth: usize) -> std::io::Result<()> {
if depth >= MAX_DIR_DEPTH {
return Ok(());
}
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let ft = entry.file_type()?;
if ft.is_symlink() {
continue;
}
let path = entry.path();
if ft.is_dir() {
collect_dir(&path, out, depth + 1)?;
} else if path.extension().is_some_and(|e| e == "rhai") {
out.push(path);
}
}
Ok(())
}
pub fn check(path: &Path) -> Result<()> {
let src = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
Engine::new()
.compile(&src)
.map_err(|e| anyhow!("in {}: {e}", path.display()))?;
println!("{}: syntax ok", path.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::merge_dotenv;
#[test]
fn dotenv_parses_comments_quotes_export_and_overrides() {
let path = std::env::temp_dir().join("ringo_flow_dotenv_test.env");
std::fs::write(
&path,
"# a comment\n\
\n\
RF_USER=alice\n\
export RF_PASS=\"s e cret\"\n\
RF_DOM='example.com'\n\
RF_USER=bob\n", )
.unwrap();
let mut env = std::collections::HashMap::new();
env.insert("KEEP".into(), "yes".into());
merge_dotenv(&path, &mut env).unwrap();
assert_eq!(env["RF_USER"], "bob"); assert_eq!(env["RF_PASS"], "s e cret"); assert_eq!(env["RF_DOM"], "example.com"); assert_eq!(env["KEEP"], "yes"); }
}