mod engine;
mod runtime;
mod script;
#[cfg(feature = "server")]
mod serve;
use anyhow::{Context, Result, bail};
use clap::{CommandFactory, Parser, Subcommand, ValueHint};
use clap_complete::{
engine::{ArgValueCandidates, CompletionCandidate},
env::CompleteEnv,
};
use std::collections::HashMap;
use std::path::PathBuf;
fn scenario_candidates() -> Vec<CompletionCandidate> {
let args: Vec<String> = std::env::args().collect();
let Some(file) = scenario_file_from_args(&args) else {
return Vec::new();
};
script::scenario_names(&file)
.into_iter()
.map(CompletionCandidate::new)
.collect()
}
fn scenario_file_from_args(args: &[String]) -> Option<PathBuf> {
let mut rest = args.iter().skip_while(|a| *a != "run");
rest.next()?; let mut rest = rest.peekable();
while let Some(a) = rest.next() {
match a.as_str() {
"--set" | "--scenario" => {
rest.next();
}
_ if a.starts_with('-') => {}
_ => return Some(PathBuf::from(a)),
}
}
None
}
#[derive(Parser)]
#[command(
name = "ringo-flow",
version,
about = "Telephony scenario test runner for baresip (Rhai scenarios)"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Run {
#[arg(required = true, num_args = 1.., value_hint = ValueHint::AnyPath)]
paths: Vec<PathBuf>,
#[arg(long = "set", value_name = "KEY=VALUE")]
set: Vec<String>,
#[arg(long = "env-file", value_name = "FILE", value_hint = ValueHint::FilePath)]
env_file: Vec<PathBuf>,
#[arg(long = "scenario", value_name = "PATTERN", add = ArgValueCandidates::new(scenario_candidates))]
scenario: Option<String>,
#[arg(long = "tag", value_name = "TAG", value_delimiter = ',')]
tag: Vec<String>,
#[arg(long = "exclude-tag", value_name = "TAG", value_delimiter = ',')]
exclude_tag: Vec<String>,
#[arg(long, value_name = "FILE", num_args = 0..=1)]
log: Option<Option<PathBuf>>,
#[arg(long = "sip-trace", value_name = "FILE", num_args = 0..=1)]
sip_trace: Option<Option<PathBuf>>,
#[arg(long)]
save_audio: bool,
#[arg(long)]
json: bool,
#[arg(long)]
metrics: bool,
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
#[arg(short, long)]
quiet: bool,
#[arg(long, visible_alias = "no-ansi")]
no_color: bool,
#[arg(long)]
insecure_http: bool,
},
Check {
#[arg(value_hint = ValueHint::FilePath)]
file: PathBuf,
},
Definitions {
#[arg(default_value = "docs/src/ringo-flow/ringo-flow.d.rhai", value_hint = ValueHint::FilePath)]
out: PathBuf,
},
Docs {
#[arg(default_value = "docs/src/ringo-flow/api", value_hint = ValueHint::DirPath)]
out: PathBuf,
},
#[cfg(feature = "server")]
Serve {
#[arg(value_hint = ValueHint::FilePath)]
config: PathBuf,
#[arg(long, env = "RINGO_FLOW_SERVE_LISTEN")]
listen: Option<String>,
#[arg(long, env = "RINGO_FLOW_SERVE_PORT")]
port: Option<u16>,
#[arg(long, env = "RINGO_FLOW_SERVE_TIMEOUT")]
timeout: Option<String>,
#[arg(long, env = "RINGO_FLOW_SERVE_SCHEDULER")]
scheduler: Option<bool>,
#[arg(long, env = "RINGO_FLOW_SERVE_METRICS")]
metrics: Option<bool>,
#[arg(long, env = "RINGO_FLOW_SERVE_BINARY", value_hint = ValueHint::FilePath)]
binary: Option<PathBuf>,
#[arg(long, env = "RINGO_FLOW_SERVE_LOG_LEVEL", default_value = "info")]
log_level: String,
#[arg(long, env = "RINGO_FLOW_SERVE_LOG_FORMAT", default_value = "text")]
log_format: String,
},
#[command(hide = true)]
Agent,
}
fn log_env(opt: &Option<Option<PathBuf>>) -> Option<String> {
match opt {
None => None,
Some(None) => Some("-".to_string()),
Some(Some(path)) => Some(path.to_string_lossy().into_owned()),
}
}
fn parse_overrides(set: &[String]) -> Result<HashMap<String, String>> {
let mut out = HashMap::new();
for s in set {
let (k, v) = s
.split_once('=')
.with_context(|| format!("--set expects key=value, got `{s}`"))?;
if k.is_empty() {
bail!("--set key must not be empty in `{s}`");
}
out.insert(k.to_string(), v.to_string());
}
Ok(out)
}
fn main() -> Result<()> {
CompleteEnv::with_factory(Cli::command).complete();
let cli = Cli::parse();
match cli.command {
Commands::Run {
paths,
set,
env_file,
scenario,
tag,
exclude_tag,
log,
sip_trace,
save_audio,
json,
metrics,
verbose,
quiet,
no_color,
insecure_http,
} => {
if matches!(log, Some(None)) {
ringo_core::log::init_stderr();
}
unsafe {
if let Some(v) = log_env(&log) {
std::env::set_var("RINGO_AGENT_LOG", v);
}
if let Some(v) = log_env(&sip_trace) {
std::env::set_var("RINGO_AGENT_SIPTRACE", v);
}
}
let color = !no_color && std::env::var_os("NO_COLOR").is_none();
runtime::report::set_ansi_enabled(color);
let overrides = parse_overrides(&set)?;
let insecure_http =
insecure_http || std::env::var_os("RINGO_FLOW_INSECURE_HTTP").is_some();
let output = runtime::Output {
json,
quiet,
verbose: verbose > 0,
save_audio,
insecure_http,
metrics,
};
let filters = engine::Filters {
name: scenario,
tags: tag,
exclude_tags: exclude_tag,
};
let result = script::run(&paths, output, overrides, filters, &env_file);
ringo_core::shutdown();
result
}
Commands::Check { file } => script::check(&file),
Commands::Definitions { out } => script::write_definitions(&out),
Commands::Docs { out } => script::write_book_api(&out),
#[cfg(feature = "server")]
Commands::Serve {
config,
listen,
port,
timeout,
scheduler,
metrics,
binary,
log_level,
log_format,
} => {
serve::init_logging(&log_level, &log_format)?;
let overrides = serve::Overrides {
listen,
port,
timeout,
scheduler,
metrics_enabled: metrics,
metrics_token: std::env::var("RINGO_FLOW_SERVE_METRICS_TOKEN")
.ok()
.filter(|s| !s.is_empty()),
binary,
};
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(serve::serve(&config, overrides))
}
Commands::Agent => ringo_agent::worker::run(),
}
}