use std::io;
use std::num::NonZeroUsize;
use std::process::ExitCode;
use thiserror::Error;
use tokio::runtime::{Builder, Runtime};
use super::app::{App, RustStreamError};
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum CliError {
#[error("failed to build the async runtime: {0}")]
Runtime(#[source] io::Error),
#[error(transparent)]
Run(#[from] RustStreamError),
#[error("unknown command {0:?}; expected `run` or `asyncapi gen`")]
UnknownCommand(String),
#[error("unknown `asyncapi` subcommand {0:?}; expected `gen`")]
UnknownAsyncApi(String),
#[error("unknown option {0:?} for `asyncapi gen`")]
UnknownOption(String),
#[error("`asyncapi gen` requires the crate to enable ruststream's `asyncapi` feature")]
AsyncApiDisabled,
#[error("could not write the spec to {path}: {source}")]
WriteSpec {
path: String,
#[source]
source: io::Error,
},
#[cfg(feature = "asyncapi")]
#[error("could not serialize the AsyncAPI spec to JSON: {0}")]
SerializeJson(#[from] serde_json::Error),
#[cfg(feature = "asyncapi")]
#[error("could not serialize the AsyncAPI spec to YAML: {0}")]
SerializeYaml(#[source] serde_norway::Error),
}
enum Command {
Run,
AsyncApiGen {
out: Option<String>,
yaml: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum AppRuntime {
#[default]
MultiThread,
MultiThreadWorkers(NonZeroUsize),
CurrentThread,
}
impl AppRuntime {
fn build(self) -> io::Result<Runtime> {
let mut builder = match self {
Self::MultiThread => Builder::new_multi_thread(),
Self::MultiThreadWorkers(count) => {
let mut builder = Builder::new_multi_thread();
builder.worker_threads(count.get());
builder
}
Self::CurrentThread => Builder::new_current_thread(),
};
builder.enable_all().build()
}
}
#[must_use]
pub fn run_main<A, F>(build: F) -> ExitCode
where
A: App,
F: FnOnce() -> A,
{
run_main_on(AppRuntime::default(), build)
}
#[must_use]
pub fn run_main_on<A, F>(runtime: AppRuntime, build: F) -> ExitCode
where
A: App,
F: FnOnce() -> A,
{
let args: Vec<String> = std::env::args().skip(1).collect();
report(execute(&args, runtime, build))
}
fn report(outcome: Result<(), CliError>) -> ExitCode {
match outcome {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("ruststream: {err}");
ExitCode::FAILURE
}
}
}
fn execute<A, F>(args: &[String], runtime: AppRuntime, build: F) -> Result<(), CliError>
where
A: App,
F: FnOnce() -> A,
{
match parse(args)? {
Command::Run => {
#[cfg(feature = "logging")]
let _ = crate::logging::init();
let app = build();
let runtime = runtime.build().map_err(CliError::Runtime)?;
runtime.block_on(app.run())?;
Ok(())
}
Command::AsyncApiGen { out, yaml } => generate_spec(&build(), out.as_deref(), yaml),
}
}
fn parse(args: &[String]) -> Result<Command, CliError> {
match args.first().map(String::as_str) {
None | Some("run") => Ok(Command::Run),
Some("asyncapi") => parse_asyncapi(&args[1..]),
Some(other) => Err(CliError::UnknownCommand(other.to_owned())),
}
}
fn parse_asyncapi(args: &[String]) -> Result<Command, CliError> {
if args.first().map(String::as_str) != Some("gen") {
let found = args.first().cloned().unwrap_or_default();
return Err(CliError::UnknownAsyncApi(found));
}
let mut out = None;
let mut yaml = false;
let mut rest = args[1..].iter();
while let Some(arg) = rest.next() {
match arg.as_str() {
"-o" | "--out" => out = rest.next().cloned(),
"--yaml" => yaml = true,
other => return Err(CliError::UnknownOption(other.to_owned())),
}
}
Ok(Command::AsyncApiGen { out, yaml })
}
#[cfg(feature = "asyncapi")]
fn generate_spec<A: App>(app: &A, out: Option<&str>, yaml: bool) -> Result<(), CliError> {
let spec = crate::asyncapi::build_spec(app);
let text = if yaml {
spec.to_yaml().map_err(CliError::SerializeYaml)?
} else {
spec.to_json()?
};
if let Some(path) = out {
std::fs::write(path, text).map_err(|source| CliError::WriteSpec {
path: path.to_owned(),
source,
})
} else {
println!("{text}");
Ok(())
}
}
#[cfg(not(feature = "asyncapi"))]
fn generate_spec<A: App>(_app: &A, _out: Option<&str>, _yaml: bool) -> Result<(), CliError> {
Err(CliError::AsyncApiDisabled)
}
#[cfg(test)]
mod tests {
#[cfg(feature = "memory")]
use std::io;
use std::process::ExitCode;
#[cfg(feature = "memory")]
use crate::memory::MemoryBroker;
#[cfg(feature = "memory")]
use crate::runtime::{App, AppInfo, RustStream, RustStreamError};
#[cfg(feature = "memory")]
use super::execute;
use super::{CliError, Command, parse, report};
#[cfg(feature = "memory")]
fn execute_default<A, F>(args: &[String], build: F) -> Result<(), CliError>
where
A: App,
F: FnOnce() -> A,
{
execute(args, super::AppRuntime::default(), build)
}
fn args(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| (*s).to_owned()).collect()
}
fn code_of(outcome: Result<(), CliError>) -> String {
format!("{:?}", report(outcome))
}
#[test]
fn empty_args_default_to_run() {
assert!(matches!(parse(&args(&[])).unwrap(), Command::Run));
assert!(matches!(parse(&args(&["run"])).unwrap(), Command::Run));
}
#[test]
fn asyncapi_gen_parses_output_and_format() {
let cmd = parse(&args(&["asyncapi", "gen", "-o", "spec.yaml", "--yaml"])).unwrap();
let Command::AsyncApiGen { out, yaml } = cmd else {
panic!("expected asyncapi gen");
};
assert_eq!(out.as_deref(), Some("spec.yaml"));
assert!(yaml);
}
#[test]
fn asyncapi_gen_defaults_to_json_stdout() {
let cmd = parse(&args(&["asyncapi", "gen"])).unwrap();
let Command::AsyncApiGen { out, yaml } = cmd else {
panic!("expected asyncapi gen");
};
assert!(out.is_none());
assert!(!yaml);
}
#[test]
fn unknown_command_is_rejected() {
assert!(parse(&args(&["frobnicate"])).is_err());
assert!(parse(&args(&["asyncapi", "lint"])).is_err());
assert!(parse(&args(&["asyncapi", "gen", "--nope"])).is_err());
}
#[test]
fn the_exit_code_follows_the_dispatch_outcome() {
assert_eq!(code_of(Ok(())), format!("{:?}", ExitCode::SUCCESS));
assert_eq!(
code_of(Err(CliError::UnknownCommand("frobnicate".to_owned()))),
format!("{:?}", ExitCode::FAILURE),
);
}
#[cfg(feature = "memory")]
fn demo_app() -> impl App {
RustStream::new(AppInfo::new("cli-demo", "0.1.0")).register_broker(MemoryBroker::new())
}
#[cfg(feature = "memory")]
#[test]
fn an_unknown_command_surfaces_before_the_service_runs() {
let outcome = execute_default(&args(&["frobnicate"]), demo_app);
let err = outcome.expect_err("an unrecognized command must not run the service");
assert!(matches!(err, CliError::UnknownCommand(cmd) if cmd == "frobnicate"));
assert_eq!(
code_of(execute_default(&args(&["asyncapi", "lint"]), demo_app)),
format!("{:?}", ExitCode::FAILURE),
);
}
#[cfg(feature = "memory")]
#[test]
fn run_surfaces_a_startup_failure() {
let build = || {
RustStream::new(AppInfo::new("cli-broken", "0.1.0"))
.on_startup(async move |()| {
Err::<(), _>(io::Error::other("state producer refused"))
})
.with_broker(MemoryBroker::new(), |_b| {})
};
let err = execute_default(&args(&["run"]), build).expect_err("the failing state producer");
assert!(
matches!(err, CliError::Run(RustStreamError::Startup(_))),
"got: {err:?}",
);
}
#[cfg(all(feature = "memory", feature = "asyncapi"))]
#[test]
fn asyncapi_gen_writes_json_and_yaml_to_a_file() {
let dir = std::env::temp_dir();
let json_path = dir.join(format!("ruststream-cli-{}-spec.json", std::process::id()));
let json_out = json_path.to_str().expect("utf-8 temp path").to_owned();
execute_default(
&args(&["asyncapi", "gen", "-o", json_out.as_str()]),
demo_app,
)
.expect("the spec must be written");
let text = std::fs::read_to_string(&json_path).expect("the spec file exists");
let spec: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
assert_eq!(spec["info"]["title"], "cli-demo");
std::fs::remove_file(&json_path).expect("cleanup");
let yaml_path = dir.join(format!("ruststream-cli-{}-spec.yaml", std::process::id()));
let yaml_out = yaml_path.to_str().expect("utf-8 temp path").to_owned();
execute_default(
&args(&["asyncapi", "gen", "-o", yaml_out.as_str(), "--yaml"]),
demo_app,
)
.expect("the spec must be written");
let text = std::fs::read_to_string(&yaml_path).expect("the spec file exists");
let spec: serde_norway::Value = serde_norway::from_str(&text).expect("valid YAML");
assert_eq!(spec["info"]["title"].as_str(), Some("cli-demo"));
std::fs::remove_file(&yaml_path).expect("cleanup");
}
#[cfg(all(feature = "memory", feature = "asyncapi"))]
#[test]
fn asyncapi_gen_defaults_to_stdout() {
execute_default(&args(&["asyncapi", "gen"]), demo_app).expect("the spec must reach stdout");
}
#[cfg(all(feature = "memory", feature = "asyncapi"))]
#[test]
fn asyncapi_gen_reports_the_path_it_could_not_write() {
let missing = std::env::temp_dir()
.join("ruststream-cli-absent-directory")
.join("spec.json");
let out = missing.to_str().expect("utf-8 temp path").to_owned();
let err = execute_default(&args(&["asyncapi", "gen", "-o", out.as_str()]), demo_app)
.expect_err("a spec cannot be written into a missing directory");
let CliError::WriteSpec { path, .. } = err else {
panic!("expected a write error, got: {err:?}");
};
assert_eq!(path, out, "the error names the output path");
}
#[cfg(all(feature = "memory", not(feature = "asyncapi")))]
#[test]
fn asyncapi_gen_without_the_feature_is_rejected() {
let err = execute_default(&args(&["asyncapi", "gen"]), demo_app)
.expect_err("the spec needs the asyncapi feature");
assert!(matches!(err, CliError::AsyncApiDisabled), "got: {err:?}");
}
mod runtimes {
use std::sync::{Arc, Barrier, mpsc};
use std::time::Duration;
use tokio::runtime::RuntimeFlavor;
use super::super::AppRuntime;
use crate::nonzero;
#[test]
fn the_default_is_multi_threaded() {
let runtime = AppRuntime::default().build().expect("the runtime builds");
assert_eq!(
runtime.handle().runtime_flavor(),
RuntimeFlavor::MultiThread
);
}
#[test]
fn a_current_thread_flavor_builds_a_current_thread_runtime() {
let runtime = AppRuntime::CurrentThread
.build()
.expect("the runtime builds");
assert_eq!(
runtime.handle().runtime_flavor(),
RuntimeFlavor::CurrentThread
);
}
#[test]
fn a_worker_count_starts_that_many_workers() {
let runtime = AppRuntime::MultiThreadWorkers(nonzero!(3))
.build()
.expect("the runtime builds");
assert_eq!(
runtime.handle().runtime_flavor(),
RuntimeFlavor::MultiThread
);
let barrier = Arc::new(Barrier::new(3));
let (passed, arrivals) = mpsc::channel();
for _ in 0..3 {
let (barrier, passed) = (Arc::clone(&barrier), passed.clone());
runtime.spawn(async move {
barrier.wait();
let _ = passed.send(());
});
}
let arrived = (0..3).all(|_| arrivals.recv_timeout(Duration::from_secs(5)).is_ok());
runtime.shutdown_background();
assert!(arrived, "three workers hold the barrier at once");
}
}
}