use std::process::ExitCode;
use camino::{Utf8Path, Utf8PathBuf};
use clap::{Parser, Subcommand, ValueEnum};
use rpc_gen_rust::{GenerateNotificationBackend, GenerateOptions, GenerateSide, generate_to_dir};
use rpc_gen_typescript::{
GenerateOptions as TypeScriptGenerateOptions, generate_to_dir as generate_typescript_to_dir,
};
use rpc_idl_compat::compare;
use rpc_idl_formatter::format_file;
use rpc_idl_ir::{build_ir, to_pretty_json};
use rpc_idl_loader::{LoadOptions, load_graph};
use rpc_idl_semantic::analyze;
use serde_json::json;
#[derive(Debug, Parser)]
#[command(name = "tripley-rpc")]
#[command(about = "Tripley RPC command line tools")]
#[command(version)]
struct Cli {
#[arg(long, global = true, default_value = "text")]
format: OutputFormat,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Validate {
#[arg(long = "in")]
input: Utf8PathBuf,
#[arg(long = "import-root")]
import_roots: Vec<Utf8PathBuf>,
},
Fmt {
#[arg(long = "in")]
input: Utf8PathBuf,
#[arg(long)]
check: bool,
},
EmitIr {
#[arg(long = "in")]
input: Utf8PathBuf,
#[arg(long)]
out: Utf8PathBuf,
#[arg(long = "import-root")]
import_roots: Vec<Utf8PathBuf>,
},
Compat {
#[arg(long)]
old: Utf8PathBuf,
#[arg(long)]
new: Utf8PathBuf,
#[arg(long = "old-import-root")]
old_import_roots: Vec<Utf8PathBuf>,
#[arg(long = "new-import-root")]
new_import_roots: Vec<Utf8PathBuf>,
#[arg(long)]
allow_breaking: bool,
},
Generate {
#[command(subcommand)]
target: GenerateCommand,
},
Doctor,
}
#[derive(Debug, Subcommand)]
enum GenerateCommand {
Rust {
#[arg(long = "in")]
input: Utf8PathBuf,
#[arg(long)]
out: Utf8PathBuf,
#[arg(long = "import-root")]
import_roots: Vec<Utf8PathBuf>,
#[arg(long = "crate")]
crate_name: Option<String>,
#[arg(long, default_value = "both")]
side: RustGenerateSide,
#[arg(long = "notification-backend", default_value = "tokio-broadcast")]
notification_backend: RustNotificationBackend,
#[arg(long = "notification-buffer", default_value_t = 128)]
notification_buffer: usize,
},
#[command(name = "typescript")]
TypeScript {
#[arg(long = "in")]
input: Utf8PathBuf,
#[arg(long)]
out: Utf8PathBuf,
#[arg(long = "import-root")]
import_roots: Vec<Utf8PathBuf>,
#[arg(long = "runtime-package", default_value = "@tripley-kit/xrpc-runtime")]
runtime_package: String,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum OutputFormat {
Text,
Json,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum RustGenerateSide {
Both,
Client,
Server,
}
impl From<RustGenerateSide> for GenerateSide {
fn from(value: RustGenerateSide) -> Self {
match value {
RustGenerateSide::Both => Self::Both,
RustGenerateSide::Client => Self::Client,
RustGenerateSide::Server => Self::Server,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum RustNotificationBackend {
TokioBroadcast,
TokioMpsc,
}
impl From<RustNotificationBackend> for GenerateNotificationBackend {
fn from(value: RustNotificationBackend) -> Self {
match value {
RustNotificationBackend::TokioBroadcast => Self::TokioBroadcast,
RustNotificationBackend::TokioMpsc => Self::TokioMpsc,
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
let format = cli.format;
match run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
match format {
OutputFormat::Text => eprintln!("{}", error.message),
OutputFormat::Json => eprintln!("{}", error.to_json()),
}
ExitCode::FAILURE
}
}
}
fn run(cli: Cli) -> Result<(), CliError> {
let format = cli.format;
match cli.command {
Command::Validate {
input,
import_roots,
} => {
compile(&input, import_roots)?;
match format {
OutputFormat::Text => println!("validated {input}"),
OutputFormat::Json => print_json(json!({
"ok": true,
"command": "validate",
"input": input,
}))?,
}
}
Command::Fmt { input, check } => {
let files = format_inputs(&input, check)?;
match format {
OutputFormat::Text => {}
OutputFormat::Json => print_json(json!({
"ok": true,
"command": "fmt",
"input": input,
"check": check,
"files": files,
}))?,
}
}
Command::EmitIr {
input,
out,
import_roots,
} => {
let ir = compile(&input, import_roots)?;
write_text(&out, &to_pretty_json(&ir).map_err(CliError::from_error)?)?;
match format {
OutputFormat::Text => {}
OutputFormat::Json => print_json(json!({
"ok": true,
"command": "emit-ir",
"input": input,
"out": out,
"types": ir.types.len(),
"services": ir.services.len(),
}))?,
}
}
Command::Compat {
old,
new,
old_import_roots,
new_import_roots,
allow_breaking,
} => {
let old_ir = load_ir_input(&old, old_import_roots)?;
let new_ir = load_ir_input(&new, new_import_roots)?;
let report = compare(&old_ir, &new_ir);
let output = report.to_text();
if report.is_breaking() && !allow_breaking {
return Err(CliError::new(output).with_json(json!({
"ok": false,
"command": "compat",
"report": report,
})));
}
match format {
OutputFormat::Text => print!("{output}"),
OutputFormat::Json => print_json(json!({
"ok": true,
"command": "compat",
"report": report,
}))?,
}
}
Command::Generate { target } => match target {
GenerateCommand::Rust {
input,
out,
import_roots,
crate_name,
side,
notification_backend,
notification_buffer,
} => {
if notification_buffer == 0 {
return Err(CliError::new(
"--notification-buffer must be greater than zero",
));
}
let ir = compile(&input, import_roots)?;
generate_to_dir(
&ir,
&out,
GenerateOptions {
crate_name,
side: side.into(),
notification_backend: notification_backend.into(),
notification_buffer,
},
)
.map_err(CliError::from_error)?;
match format {
OutputFormat::Text => println!("generated rust {out}"),
OutputFormat::Json => print_json(json!({
"ok": true,
"command": "generate rust",
"input": input,
"out": out,
"side": rust_side_name(side),
"notification_backend": rust_notification_backend_name(notification_backend),
"notification_buffer": notification_buffer,
}))?,
}
}
GenerateCommand::TypeScript {
input,
out,
import_roots,
runtime_package,
} => {
if runtime_package.trim().is_empty() {
return Err(CliError::new("--runtime-package must not be empty"));
}
let ir = compile(&input, import_roots)?;
generate_typescript_to_dir(
&ir,
&out,
TypeScriptGenerateOptions { runtime_package },
)
.map_err(CliError::from_error)?;
match format {
OutputFormat::Text => println!("generated typescript {out}"),
OutputFormat::Json => print_json(json!({
"ok": true,
"command": "generate typescript",
"input": input,
"out": out,
}))?,
}
}
},
Command::Doctor => {
let report = doctor()?;
match format {
OutputFormat::Text => {
println!("tripley-rpc {}", report.version);
println!("target {}", report.target);
for check in &report.checks {
println!("check {} ok", check);
}
println!("doctor ok");
}
OutputFormat::Json => print_json(json!({
"ok": true,
"command": "doctor",
"version": report.version,
"target": report.target,
"checks": report.checks,
}))?,
}
}
}
Ok(())
}
fn compile(
input: &Utf8Path,
import_roots: Vec<Utf8PathBuf>,
) -> Result<rpc_idl_ir::CanonicalIr, CliError> {
let graph =
load_graph(input, LoadOptions { import_roots }).map_err(CliError::from_diagnostics)?;
let model = analyze(&graph).map_err(CliError::from_diagnostics)?;
Ok(build_ir(&model))
}
fn load_ir_input(
input: &Utf8Path,
import_roots: Vec<Utf8PathBuf>,
) -> Result<rpc_idl_ir::CanonicalIr, CliError> {
if input.as_str().ends_with(".json") {
let text = std::fs::read_to_string(input)
.map_err(|error| CliError::new(format!("failed to read IR `{input}`: {error}")))?;
serde_json::from_str(&text)
.map_err(|error| CliError::new(format!("failed to parse IR `{input}`: {error}")))
} else {
compile(input, import_roots)
}
}
#[derive(Debug)]
struct DoctorReport {
version: &'static str,
target: String,
checks: Vec<String>,
}
fn doctor() -> Result<DoctorReport, CliError> {
let mut checks = Vec::new();
if env!("CARGO_PKG_VERSION").is_empty() {
return Err(CliError::new("CLI version is empty"));
}
checks.push("version".to_string());
let executable = std::env::current_exe()
.map_err(|error| CliError::new(format!("failed to resolve executable path: {error}")))?;
if !executable.is_file() {
return Err(CliError::new(format!(
"current executable is not a file: {}",
executable.display()
)));
}
checks.push("native_binary".to_string());
let cwd = std::env::current_dir()
.map_err(|error| CliError::new(format!("failed to get cwd: {error}")))?;
let test_file = cwd.join(".tripley-rpc-doctor.tmp");
std::fs::write(&test_file, b"ok")
.map_err(|error| CliError::new(format!("current directory is not writable: {error}")))?;
std::fs::remove_file(&test_file)
.map_err(|error| CliError::new(format!("failed to clean doctor temp file: {error}")))?;
checks.push("cwd_writable".to_string());
let temp = tempfile::tempdir()
.map_err(|error| CliError::new(format!("failed to create temp dir: {error}")))?;
let idl = Utf8PathBuf::from_path_buf(temp.path().join("doctor.rpc.yaml"))
.map_err(|path| CliError::new(format!("temp path is not UTF-8: {}", path.display())))?;
let import_root = Utf8PathBuf::from_path_buf(temp.path().join("imports"))
.map_err(|path| CliError::new(format!("temp path is not UTF-8: {}", path.display())))?;
std::fs::create_dir_all(&import_root)
.map_err(|error| CliError::new(format!("failed to create import root: {error}")))?;
let common_idl = import_root.join("doctor_common.rpc.yaml");
std::fs::write(&common_idl, DOCTOR_COMMON_IDL)
.map_err(|error| CliError::new(format!("failed to write doctor import IDL: {error}")))?;
std::fs::write(&idl, DOCTOR_IDL)
.map_err(|error| CliError::new(format!("failed to write doctor IDL: {error}")))?;
let ir = compile(&idl, vec![import_root])?;
checks.push("idl_import_root".to_string());
checks.push("idl_parse".to_string());
let out = Utf8PathBuf::from_path_buf(temp.path().join("generated"))
.map_err(|path| CliError::new(format!("temp path is not UTF-8: {}", path.display())))?;
std::fs::create_dir_all(&out)
.map_err(|error| CliError::new(format!("failed to create output dir: {error}")))?;
let output_probe = out.join(".write-test");
write_text(&output_probe, "ok")?;
std::fs::remove_file(&output_probe)
.map_err(|error| CliError::new(format!("failed to clean output write probe: {error}")))?;
checks.push("output_dir_writable".to_string());
generate_to_dir(&ir, &out, GenerateOptions::default())
.map_err(|error| CliError::new(format!("rust generator failed: {error}")))?;
for file in [
"activation.rs",
"client.rs",
"mod.rs",
"server.rs",
"services.rs",
"types.rs",
] {
if !out.join(file).exists() {
return Err(CliError::new(format!(
"rust generator did not emit `{file}`"
)));
}
}
checks.push("rust_generator".to_string());
let typescript_out = out.join("typescript");
generate_typescript_to_dir(&ir, &typescript_out, TypeScriptGenerateOptions::default())
.map_err(|error| CliError::new(format!("typescript generator failed: {error}")))?;
for file in ["client.ts", "index.ts", "services.ts", "types.ts"] {
if !typescript_out.join(file).exists() {
return Err(CliError::new(format!(
"typescript generator did not emit `{file}`"
)));
}
}
checks.push("typescript_generator".to_string());
if let Ok(wrapper) = std::env::var("TRIPLEY_RPC_NPM_WRAPPER") {
let wrapper_path = std::path::Path::new(&wrapper);
if !wrapper_path.is_file() {
return Err(CliError::new(format!(
"npm wrapper is not a file: {}",
wrapper_path.display()
)));
}
checks.push("npm_wrapper".to_string());
}
Ok(DoctorReport {
version: env!("CARGO_PKG_VERSION"),
target: format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH),
checks,
})
}
trait HasDiagnostics {
fn diagnostics(&self) -> &[rpc_idl_ast::Diagnostic];
}
impl HasDiagnostics for rpc_idl_loader::LoadError {
fn diagnostics(&self) -> &[rpc_idl_ast::Diagnostic] {
&self.diagnostics
}
}
impl HasDiagnostics for rpc_idl_semantic::SemanticError {
fn diagnostics(&self) -> &[rpc_idl_ast::Diagnostic] {
&self.diagnostics
}
}
#[derive(Debug)]
struct CliError {
message: String,
diagnostics: Vec<rpc_idl_ast::Diagnostic>,
json: Option<serde_json::Value>,
}
impl CliError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
diagnostics: Vec::new(),
json: None,
}
}
fn from_error(error: impl std::error::Error) -> Self {
Self::new(error.to_string())
}
fn from_diagnostics(error: impl HasDiagnostics) -> Self {
let diagnostics = error.diagnostics().to_vec();
Self {
message: format_diagnostics_slice(&diagnostics),
diagnostics,
json: None,
}
}
fn with_json(mut self, json: serde_json::Value) -> Self {
self.json = Some(json);
self
}
fn to_json(&self) -> serde_json::Value {
self.json.clone().unwrap_or_else(|| {
json!({
"ok": false,
"message": self.message,
"diagnostics": self.diagnostics,
})
})
}
}
fn format_diagnostics_slice(diagnostics: &[rpc_idl_ast::Diagnostic]) -> String {
if diagnostics.is_empty() {
return "unknown error".to_string();
}
diagnostics
.iter()
.map(|diag| {
let location = match (&diag.path, &diag.span) {
(Some(path), Some(span)) => format!("{path}:{}:{}: ", span.line, span.column),
(Some(path), None) => format!("{path}: "),
(None, _) => String::new(),
};
format!("{location}{}", diag.message)
})
.collect::<Vec<_>>()
.join("\n")
}
fn print_json(value: serde_json::Value) -> Result<(), CliError> {
let mut text = serde_json::to_string_pretty(&value).map_err(CliError::from_error)?;
text.push('\n');
print!("{text}");
Ok(())
}
fn rust_side_name(side: RustGenerateSide) -> &'static str {
match side {
RustGenerateSide::Both => "both",
RustGenerateSide::Client => "client",
RustGenerateSide::Server => "server",
}
}
fn rust_notification_backend_name(backend: RustNotificationBackend) -> &'static str {
match backend {
RustNotificationBackend::TokioBroadcast => "tokio-broadcast",
RustNotificationBackend::TokioMpsc => "tokio-mpsc",
}
}
fn format_inputs(input: &Utf8Path, check: bool) -> Result<Vec<Utf8PathBuf>, CliError> {
let files = collect_idl_files(input)?;
for file in &files {
let formatted = format_file(&file).map_err(|error| {
CliError::new(
error
.diagnostics
.iter()
.map(|diag| {
format!(
"{}: {}",
diag.path.as_deref().unwrap_or(&file),
diag.message
)
})
.collect::<Vec<_>>()
.join("\n"),
)
})?;
let current = std::fs::read_to_string(&file)
.map_err(|error| CliError::new(format!("failed to read `{file}`: {error}")))?;
if check {
if current != formatted {
return Err(CliError::new(format!("`{file}` is not formatted")));
}
} else if current != formatted {
write_text(&file, &formatted)?;
}
}
Ok(files)
}
fn collect_idl_files(input: &Utf8Path) -> Result<Vec<Utf8PathBuf>, CliError> {
if input.is_file() {
return Ok(vec![input.to_owned()]);
}
if !input.is_dir() {
return Err(CliError::new(format!(
"`{input}` is not a file or directory"
)));
}
let mut files = Vec::new();
collect_dir(input, &mut files)?;
files.sort();
Ok(files)
}
fn collect_dir(dir: &Utf8Path, files: &mut Vec<Utf8PathBuf>) -> Result<(), CliError> {
for entry in std::fs::read_dir(dir)
.map_err(|error| CliError::new(format!("failed to read `{dir}`: {error}")))?
{
let entry =
entry.map_err(|error| CliError::new(format!("failed to read `{dir}`: {error}")))?;
let path = Utf8PathBuf::from_path_buf(entry.path()).map_err(|path| {
CliError::new(format!("path is not valid UTF-8: {}", path.display()))
})?;
if path.is_dir() {
collect_dir(&path, files)?;
} else if path.as_str().ends_with(".rpc.yaml") {
files.push(path);
}
}
Ok(())
}
fn write_text(path: &Utf8Path, text: &str) -> Result<(), CliError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| CliError::new(format!("failed to create `{parent}`: {error}")))?;
}
std::fs::write(path, text)
.map_err(|error| CliError::new(format!("failed to write `{path}`: {error}")))
}
const DOCTOR_IDL: &str = r#"
idl_version: 1
package: doctor
imports:
- doctor_common.rpc.yaml
services:
Probe:
guid: 11342b38-6e4e-40ab-bb2c-a2e3b5d77bb1
methods:
ping:
id: 1
request: doctor.common.Empty
response: doctor.common.Empty
"#;
const DOCTOR_COMMON_IDL: &str = r#"
idl_version: 1
package: doctor.common
types:
Empty:
kind: struct
fields: []
"#;