1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
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;
/// Completion candidates for `--scenario`: the scenarios registered in the flow
/// file already on the command line. During completion the shell passes the words
/// being completed as our process args, so we recover the file from there and scan
/// it (no eval, so no baresip is started).
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()
}
/// The `run` positional (the scenario file) out of a partial command line: the
/// first non-flag word after `run`, skipping value-taking flags and their values.
fn scenario_file_from_args(args: &[String]) -> Option<PathBuf> {
let mut rest = args.iter().skip_while(|a| *a != "run");
rest.next()?; // consume "run"
let mut rest = rest.peekable();
while let Some(a) = rest.next() {
match a.as_str() {
// These take a separate value word; skip it so it isn't read as the file.
"--set" | "--scenario" => {
rest.next();
}
// `--flag` / `--flag=value` (and the lone `--` separator): not the file.
_ 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 a scenario file
Run {
/// Scenario files and/or directories (a directory runs its `*.rhai`,
/// recursively)
#[arg(required = true, num_args = 1.., value_hint = ValueHint::AnyPath)]
paths: Vec<PathBuf>,
/// Override a variable (repeatable): --set key=value
#[arg(long = "set", value_name = "KEY=VALUE")]
set: Vec<String>,
/// Load env vars from a dotenv file for `env(...)` (repeatable; later wins).
/// A sibling `<scenario>.env` is layered on top, per file.
#[arg(long = "env-file", value_name = "FILE", value_hint = ValueHint::FilePath)]
env_file: Vec<PathBuf>,
/// Run only scenarios whose name contains this (case-insensitive); prefix
/// with `re:` for a regex, e.g. `re:^transfer`
#[arg(long = "scenario", value_name = "PATTERN", add = ArgValueCandidates::new(scenario_candidates))]
scenario: Option<String>,
/// Run only scenarios carrying one of these tags (repeatable; comma-separated)
#[arg(long = "tag", value_name = "TAG", value_delimiter = ',')]
tag: Vec<String>,
/// Skip scenarios carrying any of these tags (repeatable; comma-separated)
#[arg(long = "exclude-tag", value_name = "TAG", value_delimiter = ',')]
exclude_tag: Vec<String>,
/// Write the backend log (SIP signaling etc.). Off by default — nothing
/// is written. `--log` → stderr; `--log <FILE>` → that file.
#[arg(long, value_name = "FILE", num_args = 0..=1)]
log: Option<Option<PathBuf>>,
/// Trace every SIP request/response to its own destination (separate from
/// `--log`). Off by default. `--sip-trace` → stderr; `--sip-trace <FILE>`
/// → that file.
#[arg(long = "sip-trace", value_name = "FILE", num_args = 0..=1)]
sip_trace: Option<Option<PathBuf>>,
/// Save each agent's call recordings (sent/received WAV) to the cwd
#[arg(long)]
save_audio: bool,
/// Emit machine-readable NDJSON events instead of the human log
#[arg(long)]
json: bool,
/// Print per-agent media-quality metrics (MOS/jitter/loss) at each
/// scenario's end: a compact human line on its own, or `metric` NDJSON
/// events when combined with `--json` (what `serve` reads).
#[arg(long)]
metrics: bool,
/// More detail (shows observed state on every assertion)
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
/// Only print failures and the final result
#[arg(short, long)]
quiet: bool,
/// Disable ANSI colors/bold in the human log (also honors `NO_COLOR`)
#[arg(long, visible_alias = "no-ansi")]
no_color: bool,
/// Skip TLS certificate verification for `http(...)` (DANGER; also via
/// `RINGO_FLOW_INSECURE_HTTP`). Prefer mounting the CA — see the README.
#[arg(long)]
insecure_http: bool,
/// Scripting frontend; inferred from the file extension (`.js` → js) unless set.
#[arg(long, value_enum)]
lang: Option<script::Lang>,
},
/// Syntax-check a scenario without running it (no baresip)
Check {
/// Path to the scenario file
#[arg(value_hint = ValueHint::FilePath)]
file: PathBuf,
/// Scripting frontend; inferred from the file extension (`.js` → js) unless set.
#[arg(long, value_enum)]
lang: Option<script::Lang>,
},
/// Write an editor definition file for completion/hover: rhai's `.d.rhai` or,
/// with `--lang js`, the TypeScript `.d.ts`.
Definitions {
/// Output path (defaults to the docs copy for the chosen language)
#[arg(value_hint = ValueHint::FilePath)]
out: Option<PathBuf>,
/// Scripting frontend (`rhai` default, or `js`)
#[arg(long, value_enum, default_value_t = script::Lang::Rhai)]
lang: script::Lang,
},
/// Generate the scenario API reference (mdBook): rhai writes one page per
/// section into a directory; `--lang js` writes a single page to a file.
Docs {
/// Output path (defaults to the docs location for the chosen language)
#[arg(value_hint = ValueHint::AnyPath)]
out: Option<PathBuf>,
/// Scripting frontend (`rhai` default, or `js`)
#[arg(long, value_enum, default_value_t = script::Lang::Rhai)]
lang: script::Lang,
},
/// Run as a monitor: scheduled scenario runs + Prometheus metrics over HTTP
#[cfg(feature = "server")]
Serve {
/// Path to the `monitor.toml` config
#[arg(value_hint = ValueHint::FilePath)]
config: PathBuf,
/// Override the listen address (host:port)
#[arg(long, env = "RINGO_FLOW_SERVE_LISTEN")]
listen: Option<String>,
/// Override just the listen port (keeps the host); wins over --listen
#[arg(long, env = "RINGO_FLOW_SERVE_PORT")]
port: Option<u16>,
/// Override the default per-run timeout (e.g. `120s`)
#[arg(long, env = "RINGO_FLOW_SERVE_TIMEOUT")]
timeout: Option<String>,
/// Run the cron schedulers (default `true`); `false` serves the HTTP API
/// without firing any schedules
#[arg(long, env = "RINGO_FLOW_SERVE_SCHEDULER")]
scheduler: Option<bool>,
/// Enable (`true`) or disable (`false`) the `/metrics` endpoint
#[arg(long, env = "RINGO_FLOW_SERVE_METRICS")]
metrics: Option<bool>,
/// Override the ringo-flow binary spawned per run
#[arg(long, env = "RINGO_FLOW_SERVE_BINARY", value_hint = ValueHint::FilePath)]
binary: Option<PathBuf>,
/// Log level: trace/debug/info/warn/error (or a RUST_LOG-style directive)
#[arg(long, env = "RINGO_FLOW_SERVE_LOG_LEVEL", default_value = "info")]
log_level: String,
/// Log format: `text` (human) or `json`
#[arg(long, env = "RINGO_FLOW_SERVE_LOG_FORMAT", default_value = "text")]
log_format: String,
// The /metrics bearer token is read only from RINGO_FLOW_SERVE_METRICS_TOKEN
// (a secret — kept out of the CLI args / process list).
},
/// Internal: run a single agent as a worker process driven over stdio
/// (spawned by `run`). The agent config arrives as the first stdin line, so
/// this takes no arguments. Not for direct use.
#[command(hide = true)]
Agent,
}
/// Encode a `--log`/`--sip-trace` value for the worker env: `None` = off (no
/// var), `Some(None)` = stderr (`-`), `Some(Some(path))` = that file.
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()),
}
}
/// Parse repeated `--set key=value` flags into a map.
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<()> {
// Shell completion: when invoked by the completion script (COMPLETE env set),
// emit candidates and exit; otherwise fall through to normal parsing.
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,
lang,
} => {
// Logging is produced by the per-agent worker processes (baresip runs
// there, not in this parent). They inherit the destination via env and
// configure their own sinks; see the ringo-agent worker. Off unless
// `--log`/`--sip-trace` is given. The parent's own diagnostics
// (e.g. ProcessClient warnings) go to stderr only when `--log` is on.
if matches!(log, Some(None)) {
ringo_core::log::init_stderr();
}
// SAFETY: set once at startup, before any threads/agents are spawned.
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);
}
}
// Color off if `--no-color`/`--no-ansi` or the `NO_COLOR` env var is set
// (https://no-color.org); the reporter additionally requires a TTY.
let color = !no_color && std::env::var_os("NO_COLOR").is_none();
runtime::report::set_ansi_enabled(color);
let overrides = parse_overrides(&set)?;
// `--insecure-http` or the env var disables TLS verification for `http(...)`.
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 lang = lang.unwrap_or_else(|| script::detect_lang(&paths));
let result = script::run(lang, &paths, output, overrides, filters, &env_file);
ringo_core::shutdown();
result
}
Commands::Check { file, lang } => {
let lang = lang.unwrap_or_else(|| script::detect_lang(std::slice::from_ref(&file)));
script::check(lang, &file)
}
Commands::Definitions { out, lang } => {
let out = out.unwrap_or_else(|| {
let name = match lang {
script::Lang::Rhai => "ringo-flow.d.rhai",
script::Lang::Js => "ringo-flow.d.ts",
};
PathBuf::from("docs/src/ringo-flow").join(name)
});
script::write_definitions(lang, &out)
}
Commands::Docs { out, lang } => {
let out = out.unwrap_or_else(|| match lang {
script::Lang::Rhai => PathBuf::from("docs/src/ringo-flow/api"),
script::Lang::Js => PathBuf::from("docs/src/ringo-flow/js-api"),
});
script::write_book_api(lang, &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,
// Secret: env only, never a CLI flag.
metrics_token: std::env::var("RINGO_FLOW_SERVE_METRICS_TOKEN")
.ok()
.filter(|s| !s.is_empty()),
binary,
};
// The monitor is async (HTTP + schedulers); the other subcommands are
// sync, so build a runtime just for this one.
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(serve::serve(&config, overrides))
}
Commands::Agent => ringo_agent::worker::run(),
}
}