use super::ctx::Ctx;
use crate::runtime::Output;
use crate::runtime::report::{Event, Human, Json, Level, Reporter};
use crate::runtime::session::AgentSession;
use anyhow::{Result, anyhow};
use regex::Regex;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
pub trait ScriptHost {
fn run_top_level(&mut self) -> TopLevel;
fn run_scenario(&mut self, name: &str) -> ScenarioResult;
}
#[derive(Clone, Default)]
pub struct ScenarioInfo {
pub name: String,
pub tags: Vec<String>,
pub skip: bool,
pub skip_reason: Option<String>,
pub only: bool,
}
pub enum ScenarioResult {
Passed,
Skipped(Option<String>),
Failed(String),
}
pub enum TopLevel {
Single(std::result::Result<(), String>),
Suite {
scenarios: Vec<ScenarioInfo>,
top_error: Option<String>,
},
}
#[derive(Default)]
pub struct Filters {
pub name: Option<String>,
pub tags: Vec<String>,
pub exclude_tags: Vec<String>,
}
impl Filters {
fn is_active(&self) -> bool {
self.name.is_some() || !self.tags.is_empty() || !self.exclude_tags.is_empty()
}
}
struct Selector {
matcher: Option<ScenarioMatcher>,
include_tags: Vec<String>,
exclude_tags: Vec<String>,
}
impl Selector {
fn selects(&self, info: &ScenarioInfo) -> bool {
self.matcher.as_ref().is_none_or(|m| m(&info.name))
&& (self.include_tags.is_empty()
|| info.tags.iter().any(|t| self.include_tags.contains(t)))
&& !info.tags.iter().any(|t| self.exclude_tags.contains(t))
}
}
pub fn run<H, F>(
programs: Vec<(String, F)>,
output: Output,
default_timeout: Duration,
filters: Filters,
) -> Result<()>
where
H: ScriptHost + Send + 'static,
F: FnOnce(Arc<Ctx>) -> Result<H> + Send + 'static,
{
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let level = if output.quiet {
Level::Quiet
} else if output.verbose {
Level::Verbose
} else {
Level::Normal
};
let reporter: Box<dyn Reporter + Send> = if output.json {
Box::new(Json)
} else {
Box::new(Human::new(level))
};
let matcher = filters.name.as_deref().map(build_matcher).transpose()?;
let selector = Selector {
matcher,
include_tags: filters.tags.clone(),
exclude_tags: filters.exclude_tags.clone(),
};
let ctx = Arc::new(Ctx::new(rt.handle().clone(), reporter, default_timeout));
if output.insecure_http {
ctx.set_http_insecure(true);
eprintln!(
"⚠ ringo-flow: TLS certificate verification is DISABLED for http(...) (--insecure-http)"
);
}
let (logs, save_audio) = (output.logs, output.save_audio);
let multi = programs.len() > 1;
let c = ctx.clone();
let join = rt.block_on(async move {
tokio::task::spawn_blocking(move || {
run_files(programs, &c, &selector, logs, save_audio, multi)
})
.await
});
let agg = match join {
Ok(agg) => agg,
Err(e) => {
teardown(&ctx, &rt);
let msg = format!("script task panicked: {e}");
ctx.emit(&Event::Finished {
passed: false,
error: Some(msg.clone()),
});
return Err(anyhow!(msg));
}
};
teardown(&ctx, &rt);
if filters.is_active() && agg.scenarios == 0 {
return Err(anyhow!(
"no scenario matched the given filters (--scenario / --tag / --exclude-tag)"
));
}
if multi {
ctx.emit(&Event::RunFinished {
files: agg.files,
passed_files: agg.passed_files,
scenarios: agg.scenarios,
passed_scenarios: agg.passed_scenarios,
skipped_scenarios: agg.skipped_scenarios,
});
}
if agg.passed_files == agg.files {
Ok(())
} else {
Err(anyhow!(
"{}/{} files failed",
agg.files - agg.passed_files,
agg.files
))
}
}
#[derive(Default)]
struct Aggregate {
files: usize,
passed_files: usize,
scenarios: usize,
passed_scenarios: usize,
skipped_scenarios: usize,
}
struct Deferred<H> {
label: String,
host: H,
scenarios: Vec<ScenarioInfo>,
}
fn run_files<H, F>(
programs: Vec<(String, F)>,
ctx: &Arc<Ctx>,
selector: &Selector,
logs: bool,
save_audio: bool,
multi: bool,
) -> Aggregate
where
H: ScriptHost,
F: FnOnce(Arc<Ctx>) -> Result<H>,
{
let mut agg = Aggregate::default();
let mut deferred: Vec<Deferred<H>> = Vec::new();
let mut focus = false;
for (label, build) in programs {
agg.files += 1;
let mut host = match build(ctx.clone()) {
Ok(h) => h,
Err(e) => {
if multi {
ctx.emit(&Event::FileStarted { path: &label });
}
ctx.emit(&Event::Finished {
passed: false,
error: Some(format!("{e}")),
});
continue;
}
};
match host.run_top_level() {
TopLevel::Single(r) => {
if multi {
ctx.emit(&Event::FileStarted { path: &label });
}
dump_artifacts(ctx, logs, save_audio);
ctx.reset_sessions();
let ok = r.is_ok();
ctx.emit(&Event::Finished {
passed: ok,
error: r.err(),
});
agg.scenarios += 1;
agg.passed_scenarios += usize::from(ok);
if ok {
agg.passed_files += 1;
}
}
TopLevel::Suite {
top_error: Some(e), ..
} => {
if multi {
ctx.emit(&Event::FileStarted { path: &label });
}
ctx.emit(&Event::Finished {
passed: false,
error: Some(e),
});
}
TopLevel::Suite { scenarios, .. } => {
focus |= scenarios.iter().any(|s| s.only);
deferred.push(Deferred {
label,
host,
scenarios,
});
}
}
}
if focus {
eprintln!("⚠ ringo-flow: `only` focus is active — running only the focused scenario(s)");
}
for mut d in deferred {
if multi {
ctx.emit(&Event::FileStarted { path: &d.label });
}
let (total, passed, skipped) = run_suite(
&mut d.host,
&d.scenarios,
ctx,
selector,
focus,
logs,
save_audio,
);
ctx.reset_sessions(); ctx.emit(&Event::SuiteFinished {
total,
passed,
skipped,
});
agg.scenarios += total;
agg.passed_scenarios += passed;
agg.skipped_scenarios += skipped;
if passed + skipped == total {
agg.passed_files += 1;
}
}
agg
}
type ScenarioMatcher = Box<dyn Fn(&str) -> bool + Send>;
fn build_matcher(pattern: &str) -> Result<ScenarioMatcher> {
if let Some(src) = pattern.strip_prefix("re:") {
let re = Regex::new(src).map_err(|e| anyhow!("invalid --scenario regex `{src}`: {e}"))?;
Ok(Box::new(move |name| re.is_match(name)))
} else {
let needle = pattern.to_lowercase();
Ok(Box::new(move |name| name.to_lowercase().contains(&needle)))
}
}
fn run_suite<H: ScriptHost>(
host: &mut H,
scenarios: &[ScenarioInfo],
ctx: &Arc<Ctx>,
selector: &Selector,
focus: bool,
logs: bool,
save_audio: bool,
) -> (usize, usize, usize) {
let (mut total, mut passed, mut skipped) = (0, 0, 0);
for info in scenarios {
if !selector.selects(info) || (focus && !info.only) {
continue;
}
total += 1;
if info.skip {
skipped += 1;
ctx.emit(&Event::ScenarioSkipped {
name: &info.name,
reason: info.skip_reason.as_deref(),
});
continue;
}
ctx.emit(&Event::ScenarioStarted { name: &info.name });
let result = host.run_scenario(&info.name);
dump_artifacts(ctx, logs, save_audio); ctx.reset_sessions(); match result {
ScenarioResult::Passed => {
passed += 1;
ctx.emit(&Event::ScenarioFinished {
name: &info.name,
passed: true,
error: None,
});
}
ScenarioResult::Skipped(reason) => {
skipped += 1;
ctx.emit(&Event::ScenarioSkipped {
name: &info.name,
reason: reason.as_deref(),
});
}
ScenarioResult::Failed(msg) => ctx.emit(&Event::ScenarioFinished {
name: &info.name,
passed: false,
error: Some(msg),
}),
}
}
(total, passed, skipped)
}
fn dump_artifacts(ctx: &Arc<Ctx>, logs: bool, save_audio: bool) {
if !logs && !save_audio {
return;
}
let sessions = ctx.sessions.lock().unwrap_or_else(|e| e.into_inner());
if logs {
dump_logs(&sessions);
}
if save_audio {
save_recordings(&sessions);
}
}
fn teardown(ctx: &Arc<Ctx>, rt: &tokio::runtime::Runtime) {
let sessions: Vec<AgentSession> = ctx
.sessions
.lock()
.unwrap_or_else(|e| e.into_inner())
.drain()
.map(|(_, s)| s)
.collect();
for s in &sessions {
s.hangup_all();
}
rt.block_on(async { tokio::time::sleep(Duration::from_millis(200)).await });
drop(sessions);
}
fn dump_logs(sessions: &HashMap<String, AgentSession>) {
let mut names: Vec<&String> = sessions.keys().collect();
names.sort();
for name in names {
eprintln!("\n── baresip log: {name} ──");
match std::fs::read_to_string(sessions[name].log_path()) {
Ok(content) => eprint!("{content}"),
Err(e) => eprintln!(
"(could not read {}: {e})",
sessions[name].log_path().display()
),
}
}
}
fn save_recordings(sessions: &HashMap<String, AgentSession>) {
let run_ts = chrono::Local::now().format("%Y%m%d-%H%M%S");
let mut names: Vec<&String> = sessions.keys().collect();
names.sort();
for name in names {
let dir = sessions[name].recording_dir();
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
let mut wavs: Vec<std::path::PathBuf> = entries
.filter_map(std::result::Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("dump-") && n.ends_with(".wav"))
})
.collect();
wavs.sort();
for src in wavs {
let dir_tag = if src.to_string_lossy().ends_with("-enc.wav") {
"sent"
} else {
"recv"
};
let dst =
std::path::PathBuf::from(format!("ringo-audio-{run_ts}-{name}-{dir_tag}.wav"));
match std::fs::copy(&src, &dst) {
Ok(_) => eprintln!("saved recording: {} ({name} {dir_tag})", dst.display()),
Err(e) => eprintln!("(could not save {}: {e})", dst.display()),
}
}
}
}
#[cfg(test)]
mod tests {
use super::{ScenarioInfo, Selector, build_matcher};
fn info(name: &str, tags: &[&str]) -> ScenarioInfo {
ScenarioInfo {
name: name.to_string(),
tags: tags.iter().map(|s| s.to_string()).collect(),
..Default::default()
}
}
fn selector(name: Option<&str>, include: &[&str], exclude: &[&str]) -> Selector {
Selector {
matcher: name.map(|n| build_matcher(n).unwrap()),
include_tags: include.iter().map(|s| s.to_string()).collect(),
exclude_tags: exclude.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn empty_selector_selects_everything() {
let s = selector(None, &[], &[]);
assert!(s.selects(&info("anything", &[])));
assert!(s.selects(&info("tagged", &["x"])));
}
#[test]
fn tag_include_requires_one_match() {
let s = selector(None, &["smoke"], &[]);
assert!(s.selects(&info("a", &["smoke", "fast"])));
assert!(!s.selects(&info("b", &["slow"])));
assert!(!s.selects(&info("c", &[]))); }
#[test]
fn tag_exclude_wins_over_include() {
let s = selector(None, &["smoke"], &["slow"]);
assert!(s.selects(&info("a", &["smoke"])));
assert!(!s.selects(&info("b", &["smoke", "slow"])));
}
#[test]
fn name_and_tags_combine() {
let s = selector(Some("call"), &["smoke"], &[]);
assert!(s.selects(&info("answered call", &["smoke"])));
assert!(!s.selects(&info("answered call", &["slow"]))); assert!(!s.selects(&info("registration", &["smoke"]))); }
#[test]
fn substring_is_default_and_case_insensitive() {
let m = build_matcher("blind refer").unwrap();
assert!(m("transfer (blind refer): target accepts"));
assert!(!m("simple call"));
assert!(build_matcher("TRANSFER").unwrap()("transfer: rejects"));
assert!(build_matcher("(blind refer)").unwrap()("x (blind refer) y"));
}
#[test]
fn re_prefix_is_a_regex() {
let m = build_matcher("re:^transfer").unwrap();
assert!(m("transfer: accepts"));
assert!(!m("a transfer")); assert!(build_matcher("re:tra(").is_err()); }
}