use super::{bindings, host};
use crate::engine::Ctx;
use anyhow::{Context, Result};
use rhai::Engine;
use std::path::{Path, PathBuf};
use std::sync::Arc;
fn doc_engine() -> Result<(tokio::runtime::Runtime, Engine)> {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let ctx = Arc::new(Ctx::new(
rt.handle().clone(),
Box::new(crate::runtime::report::Json),
super::DEFAULT_TIMEOUT,
));
let engine = bindings::build_engine(
ctx,
Arc::new(host::Registry::default()),
Arc::default(),
PathBuf::from("."),
);
Ok((rt, engine))
}
pub fn write_definitions(out: &Path) -> Result<()> {
let (_rt, engine) = doc_engine()?;
let scope = rhai::Scope::new();
engine
.definitions_with_scope(&scope)
.write_to_file(out)
.with_context(|| format!("write {}", out.display()))?;
println!("wrote {}", out.display());
Ok(())
}
const RECEIVERS: &[&str] = &[
"Agent",
"Peer",
"CallQuality",
"Assertion",
"HttpResponse",
"HttpMock",
"MockRequest",
"PathPattern",
"AudioSpec",
];
#[derive(Default)]
struct Entry {
receiver: Option<String>,
sigs: Vec<String>,
returns: Option<String>,
doc_returns: Option<String>,
summaries: Vec<String>,
examples: Vec<String>,
}
type ApiIndex = std::collections::BTreeMap<((u8, &'static str), u8, String), Entry>;
fn api_entries(engine: &Engine) -> Result<ApiIndex> {
let json = engine
.gen_fn_metadata_to_json(false)
.context("generate function metadata")?;
let meta: serde_json::Value = serde_json::from_str(&json).context("parse metadata JSON")?;
let mut map: ApiIndex = std::collections::BTreeMap::new();
let mut funcs: Vec<&serde_json::Value> =
meta["functions"].as_array().into_iter().flatten().collect();
funcs.sort_by_key(|f| f["signature"].as_str().unwrap_or("").to_string());
for f in funcs {
let comments: Vec<&str> = f["docComments"]
.as_array()
.into_iter()
.flatten()
.filter_map(|d| d.as_str())
.collect();
if comments.is_empty() {
continue;
}
let sig = f["signature"].as_str().unwrap_or("");
if sig.is_empty() {
continue;
}
let (name, params, ret) = parse_sig(sig);
if name == "to_string" {
continue; }
let receiver = params
.first()
.and_then(|p| p.split_once(": "))
.map(|(_, t)| t.trim())
.filter(|t| RECEIVERS.contains(t));
let (summary, examples, doc_ret) = parse_doc(&comments);
let primary = matches!(
ret.as_deref(),
Some("Agent" | "Assertion" | "HttpResponse" | "HttpMock")
);
let (sec, rank) = if receiver.is_some() {
let kind = if name.starts_with("get$") { 2 } else { 1 };
(section(receiver, &name), kind)
} else if primary {
(entity_section(ret.as_deref().unwrap()).unwrap(), 0)
} else {
(section(None, &name), 3)
};
let cf = call_form(&name, receiver, ¶ms);
let e = map.entry((sec, rank, cf.clone())).or_default();
if !e.sigs.contains(&cf) {
e.sigs.push(cf);
}
if e.receiver.is_none() {
e.receiver = receiver.map(String::from);
}
if e.returns.is_none() {
e.returns = ret;
}
if e.doc_returns.is_none() {
e.doc_returns = doc_ret;
}
if !summary.is_empty() && !e.summaries.contains(&summary) {
e.summaries.push(summary);
}
for ex in examples {
if !e.examples.contains(&ex) {
e.examples.push(ex);
}
}
}
Ok(map)
}
fn parse_sig(sig: &str) -> (String, Vec<String>, Option<String>) {
let (head, ret) = match sig.split_once(" -> ") {
Some((h, r)) => (h, Some(r.trim().to_string())),
None => (sig, None),
};
let (name, params) = match head.split_once('(') {
Some((n, rest)) => {
let rest = rest.trim_end().trim_end_matches(')');
let params = if rest.trim().is_empty() {
Vec::new()
} else {
rest.split(',').map(|p| p.trim().to_string()).collect()
};
(n.trim().to_string(), params)
}
None => (head.trim().to_string(), Vec::new()),
};
(name, params, ret)
}
fn call_form(name: &str, receiver: Option<&str>, params: &[String]) -> String {
if let Some(prop) = name.strip_prefix("get$") {
let recv = receiver.map(recv_var).unwrap_or("value");
format!("{recv}.{prop}")
} else if let Some(r) = receiver {
format!(
"{}.{name}({})",
recv_var(r),
params.get(1..).unwrap_or(&[]).join(", ")
)
} else {
format!("{name}({})", params.join(", "))
}
}
fn recv_var(ty: &str) -> &'static str {
match ty {
"Agent" => "agent",
"Peer" => "peer",
"CallQuality" => "quality",
"Assertion" => "assertion",
"HttpResponse" => "resp",
"HttpMock" => "mock",
"MockRequest" => "req",
"AudioSpec" => "spec",
"PathPattern" => "pattern",
_ => "value",
}
}
fn section(receiver: Option<&str>, name: &str) -> (u8, &'static str) {
let base = name.strip_prefix("get$").unwrap_or(name);
match receiver {
Some("Agent") => (2, "Agents"),
Some("Peer") => (3, "Peer"),
Some("CallQuality") => (3, "CallQuality"),
_ if matches!(base, "tone" | "file" | "silent") => (4, "AudioSpec"),
Some("Assertion") => (5, "Assertions and matchers"),
Some("HttpResponse") => (6, "HTTP"),
Some("HttpMock") | Some("PathPattern") => (7, "HTTP mock server"),
Some("MockRequest") => (8, "Mock request"),
_ if matches!(
base,
"mock_server" | "json_response" | "text_response" | "regex"
) =>
{
(7, "HTTP mock server")
}
_ if base == "http" => (6, "HTTP"),
_ if matches!(base, "scenario" | "setup" | "teardown" | "skip") => {
(0, "Scenario structure")
}
_ if matches!(
base,
"await_until" | "wait" | "parallel" | "default_timeout"
) =>
{
(1, "Flow and timing")
}
_ if matches!(base, "env" | "load_env") => (9, "Environment"),
_ => (10, "Utilities"), }
}
fn entity_section(ret: &str) -> Option<(u8, &'static str)> {
match ret {
"Agent" => Some((2, "Agents")),
"Peer" => Some((3, "Peer")),
"Assertion" => Some((5, "Assertions and matchers")),
"HttpResponse" => Some((6, "HTTP")),
"HttpMock" | "PathPattern" => Some((7, "HTTP mock server")),
"MockRequest" => Some((8, "Mock request")),
_ => None,
}
}
fn parse_doc(comments: &[&str]) -> (String, Vec<String>, Option<String>) {
let lines: Vec<String> = comments
.iter()
.flat_map(|c| c.lines())
.map(strip_marker)
.collect();
let mut summary: Vec<String> = Vec::new();
let mut examples: Vec<String> = Vec::new();
let mut doc_returns: Option<String> = None;
let mut i = 0;
while i < lines.len() {
if lines[i].trim_start().starts_with("```") {
let mut block: Vec<String> = Vec::new();
i += 1;
while i < lines.len() && !lines[i].trim_start().starts_with("```") {
block.push(lines[i].clone());
i += 1;
}
i += 1; examples.push(block.join("\n").trim_end().to_string());
} else {
let t = lines[i].trim();
let bare = t.trim_start_matches('#').trim();
if let Some(ret) = bare
.strip_prefix("Returns:")
.or_else(|| bare.strip_prefix("returns:"))
{
doc_returns = Some(ret.trim().to_string());
} else if !bare.eq_ignore_ascii_case("example") {
summary.push(lines[i].clone());
}
i += 1;
}
}
(summary.join("\n").trim().to_string(), examples, doc_returns)
}
fn strip_marker(line: &str) -> String {
let t = line.trim_start();
let t = t
.strip_prefix("///")
.or_else(|| t.strip_prefix("/**"))
.unwrap_or(t);
let t = t.strip_suffix("*/").unwrap_or(t);
let t = t.strip_prefix('*').unwrap_or(t);
t.strip_prefix(' ').unwrap_or(t).to_string()
}
fn slug(s: &str) -> String {
s.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
' '
}
})
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join("-")
}
fn ret_display(ret: &str) -> &str {
if ret == "?" { "any" } else { ret }
}
fn type_page(ty: &str) -> Option<&'static str> {
Some(match ty {
"Agent" => "agents.md",
"Peer" => "peer.md",
"Assertion" => "assertions-and-matchers.md",
"HttpResponse" => "http.md",
"HttpMock" => "http-mock-server.md",
"MockRequest" => "mock-request.md",
"PathPattern" => "http-mock-server.md#regex",
"AudioSpec" => "audiospec.md",
"CallState" => "call-state.md",
_ => return None,
})
}
fn type_md(ty: &str) -> String {
match type_page(ty) {
Some(page) => format!("[`{ty}`]({page})"),
None => format!("`{ty}`"),
}
}
fn type_re() -> regex::Regex {
regex::Regex::new(
r"\b(Agent|Peer|Assertion|HttpResponse|HttpMock|MockRequest|PathPattern|AudioSpec)\b",
)
.expect("valid type regex")
}
fn sig_types(sig: &str) -> Vec<&str> {
let mut out: Vec<&str> = Vec::new();
for m in type_re().find_iter(sig) {
if !out.contains(&m.as_str()) {
out.push(m.as_str());
}
}
out
}
fn anchor_id(sig: &str) -> &str {
let after_dot = sig.rsplit_once('.').map_or(sig, |(_, r)| r);
after_dot.split('(').next().unwrap_or(after_dot).trim()
}
fn render_entry(md: &mut String, level: &str, e: &Entry) {
let sig = e.sigs.first().map_or("", String::as_str);
md.push_str(&format!("<a id=\"{}\"></a>\n\n", anchor_id(sig)));
md.push_str(&format!("{level} {sig}\n\n"));
let mut meta: Vec<String> = Vec::new();
if let Some(r) = &e.receiver {
meta.push(format!("**Receiver** {}", type_md(r)));
}
let takes = sig_types(sig);
if !takes.is_empty() {
let links: Vec<String> = takes.iter().map(|t| type_md(t)).collect();
meta.push(format!("**Takes** {}", links.join(", ")));
}
let ret = e.doc_returns.clone().or_else(|| {
e.returns
.as_deref()
.map(ret_display)
.filter(|d| *d != "any")
.map(str::to_string)
});
if let Some(ret) = ret {
meta.push(format!("**Returns** {}", type_md(&ret)));
}
if !meta.is_empty() {
md.push_str(&meta.join(" · "));
md.push_str("\n\n");
}
for s in &e.summaries {
md.push_str(s);
md.push_str("\n\n");
}
for ex in &e.examples {
md.push_str("**Example**\n\n```rust\n");
md.push_str(ex);
md.push_str("\n```\n\n");
}
}
fn book_sections(engine: &Engine) -> Result<Vec<(String, &'static str, String)>> {
let entries = api_entries(engine)?;
type Item<'a> = (u8, &'a String, &'a Entry);
let mut sections: Vec<(&'static str, Vec<Item>)> = Vec::new();
for ((sec, rank, display), e) in &entries {
let title = sec.1;
if sections.last().map(|(t, _)| *t) != Some(title) {
sections.push((title, Vec::new()));
}
sections.last_mut().unwrap().1.push((*rank, display, e));
}
let mut pages = Vec::new();
for (title, items) in sections {
let mut body = format!("# {title}\n\n");
let mixed = items.first().map(|(r, _, _)| r) != items.last().map(|(r, _, _)| r);
if mixed {
for (rank, label) in [
(0u8, "Constructor"),
(1, "Methods"),
(2, "Fields"),
(3, "Helpers"),
] {
let group: Vec<_> = items.iter().filter(|(r, _, _)| *r == rank).collect();
if group.is_empty() {
continue;
}
body.push_str(&format!("## {label}\n\n"));
for (_, _, e) in group {
render_entry(&mut body, "###", e);
}
}
} else {
for (_, _, e) in &items {
render_entry(&mut body, "##", e);
}
}
pages.push((slug(title), title, body));
}
pages.push(call_state_section());
Ok(pages)
}
const CALL_STATES: &[(&str, &str)] = &[
("Idle", "No active call."),
(
"Ringing",
"A call is ringing — incoming or outgoing — but not yet answered.",
),
("Established", "The call is connected and media is flowing."),
];
fn call_state_section() -> (String, &'static str, String) {
let mut body = String::from(
"# Call state\n\n\
`agent.state` returns a **`CallState`** — a call's current phase. Compare it \
against the `State::*` constants, usually inside `await_until`:\n\n\
```rust\n\
await_until(|| assert(callee.state).equals(State::Ringing));\n\
```\n\n",
);
for (name, desc) in CALL_STATES {
body.push_str(&format!("- `State::{name}` — {desc}\n"));
}
body.push('\n');
("call-state".to_string(), "Call state", body)
}
const API_NESTING: &[(&str, &[&str])] = &[
("Agents", &["Peer", "Call state", "AudioSpec"]),
("HTTP mock server", &["Mock request"]),
];
fn section_blurb(title: &str) -> &'static str {
match title {
"Scenario structure" => {
"defining and isolating tests: `scenario`, `setup`, `teardown`, `skip`."
}
"Flow and timing" => "`await_until`, `wait`, `parallel`, `default_timeout`.",
"Agents" => {
"create SIP endpoints and drive calls: register, dial, accept, transfer, DTMF, audio."
}
"Peer" => "the remote party of the active call.",
"Call state" => "the `State::*` phases for `agent.state`.",
"AudioSpec" => "audio sources for `send_audio` (`tone`, `file`, `silent`).",
"Assertions and matchers" => {
"the fluent `assert(x).<matcher>(…)`, used inside `await_until`."
}
"HTTP" => "`http(…)` requests and the response.",
"HTTP mock server" => "`mock_server(…)`, routes and responders for webhook-driven flows.",
"Mock request" => "the recorded request a responder/assertion sees.",
"Environment" => "`env`, `load_env` — credentials stay out of scripts.",
"Utilities" => "`log`, `uuid`.",
_ => "",
}
}
fn api_index_body(engine: &Engine) -> Result<String> {
use std::collections::{HashMap, HashSet};
let sections = book_sections(engine)?;
let slug_of: HashMap<&str, &str> = sections.iter().map(|(s, t, _)| (*t, s.as_str())).collect();
let children: HashSet<&str> = API_NESTING
.iter()
.flat_map(|(_, cs)| cs.iter().copied())
.collect();
let line = |indent: &str, title: &str, slug: &str| {
let b = section_blurb(title);
let suffix = if b.is_empty() {
String::new()
} else {
format!(" — {b}")
};
format!("{indent}- [{title}]({slug}.md){suffix}\n")
};
let mut out = String::from(
"# API reference\n\n\
The complete scenario vocabulary, generated from the engine (so it never \
drifts from the code) — organized by the thing you're working with:\n\n",
);
for (slug, title, _) in §ions {
if children.contains(title) {
continue; }
out.push_str(&line("", title, slug));
if let Some((_, cs)) = API_NESTING.iter().find(|(p, _)| p == title) {
for child in *cs {
if let Some(cslug) = slug_of.get(child) {
out.push_str(&line(" ", child, cslug));
}
}
}
}
out.push_str(
"\nNew to it? Start with [Your first scenario](../your-first-scenario.md), \
then [Writing scenarios](../writing-scenarios.md).\n\n\
For editors and agents, the whole API is also available as \
[Rhai type definitions](../ringo-flow.d.rhai) (`.d.rhai`) — point the Rhai \
language server at it for completion and hover.\n",
);
Ok(out)
}
pub fn write_book_api(dir: &Path) -> Result<()> {
let (_rt, engine) = doc_engine()?;
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
for (slug, _title, body) in book_sections(&engine)? {
let path = dir.join(format!("{slug}.md"));
std::fs::write(&path, body).with_context(|| format!("write {}", path.display()))?;
}
let index = dir.join("index.md");
std::fs::write(&index, api_index_body(&engine)?)
.with_context(|| format!("write {}", index.display()))?;
println!("wrote API pages to {}", dir.display());
Ok(())
}
#[cfg(test)]
mod tests {
#[test]
fn book_api_pages_are_current() {
let (_rt, engine) = super::doc_engine().unwrap();
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/src/ringo-flow/api");
for (slug, _title, body) in super::book_sections(&engine).unwrap() {
let path = format!("{dir}/{slug}.md");
let committed = std::fs::read_to_string(&path).unwrap_or_default();
assert_eq!(
body, committed,
"{path} is stale — regenerate with \
`cargo run -p ringo-flow -- docs docs/src/ringo-flow/api`"
);
}
let index = format!("{dir}/index.md");
assert_eq!(
super::api_index_body(&engine).unwrap(),
std::fs::read_to_string(&index).unwrap_or_default(),
"{index} is stale — regenerate with \
`cargo run -p ringo-flow -- docs docs/src/ringo-flow/api`"
);
}
#[test]
fn rhai_definitions_are_current() {
let tmp = std::env::temp_dir().join("ringo-flow-defs.d.rhai");
super::write_definitions(&tmp).unwrap();
let generated = std::fs::read_to_string(&tmp).unwrap();
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../docs/src/ringo-flow/ringo-flow.d.rhai"
);
let committed = std::fs::read_to_string(path).unwrap_or_default();
assert_eq!(
generated, committed,
"{path} is stale — regenerate with \
`cargo run -p ringo-flow -- definitions docs/src/ringo-flow/ringo-flow.d.rhai`"
);
}
}