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, to_pretty_json as compat_to_pretty_json};
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;
#[derive(Debug, Parser)]
#[command(name = "tripley-rpc")]
#[command(about = "Tripley RPC command line tools")]
#[command(version)]
struct Cli {
#[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, default_value = "text")]
format: CompatOutputFormat,
#[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 CompatOutputFormat {
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 {
match run(Cli::parse()) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("{error}");
ExitCode::FAILURE
}
}
}
fn run(cli: Cli) -> Result<(), String> {
match cli.command {
Command::Validate {
input,
import_roots,
} => {
compile(&input, import_roots)?;
println!("validated {input}");
}
Command::Fmt { input, check } => format_inputs(&input, check)?,
Command::EmitIr {
input,
out,
import_roots,
} => {
let ir = compile(&input, import_roots)?;
write_text(
&out,
&to_pretty_json(&ir).map_err(|error| error.to_string())?,
)?;
}
Command::Compat {
old,
new,
old_import_roots,
new_import_roots,
format,
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 = match format {
CompatOutputFormat::Text => report.to_text(),
CompatOutputFormat::Json => {
compat_to_pretty_json(&report).map_err(|error| error.to_string())?
}
};
if report.is_breaking() && !allow_breaking {
return Err(output);
}
print!("{output}");
}
Command::Generate { target } => match target {
GenerateCommand::Rust {
input,
out,
import_roots,
crate_name,
side,
notification_backend,
notification_buffer,
} => {
if notification_buffer == 0 {
return Err("--notification-buffer must be greater than zero".to_string());
}
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(|error| error.to_string())?;
println!("generated rust {out}");
}
GenerateCommand::TypeScript {
input,
out,
import_roots,
runtime_package,
} => {
if runtime_package.trim().is_empty() {
return Err("--runtime-package must not be empty".to_string());
}
let ir = compile(&input, import_roots)?;
generate_typescript_to_dir(
&ir,
&out,
TypeScriptGenerateOptions { runtime_package },
)
.map_err(|error| error.to_string())?;
println!("generated typescript {out}");
}
},
Command::Doctor => doctor()?,
}
Ok(())
}
fn compile(
input: &Utf8Path,
import_roots: Vec<Utf8PathBuf>,
) -> Result<rpc_idl_ir::CanonicalIr, String> {
let graph = load_graph(input, LoadOptions { import_roots }).map_err(format_diagnostics)?;
let model = analyze(&graph).map_err(format_diagnostics)?;
Ok(build_ir(&model))
}
fn load_ir_input(
input: &Utf8Path,
import_roots: Vec<Utf8PathBuf>,
) -> Result<rpc_idl_ir::CanonicalIr, String> {
if input.as_str().ends_with(".json") {
let text = std::fs::read_to_string(input)
.map_err(|error| format!("failed to read IR `{input}`: {error}"))?;
serde_json::from_str(&text)
.map_err(|error| format!("failed to parse IR `{input}`: {error}"))
} else {
compile(input, import_roots)
}
}
fn doctor() -> Result<(), String> {
println!("tripley-rpc {}", env!("CARGO_PKG_VERSION"));
println!("target {}-{}", std::env::consts::OS, std::env::consts::ARCH);
let cwd = std::env::current_dir().map_err(|error| 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| format!("current directory is not writable: {error}"))?;
std::fs::remove_file(&test_file)
.map_err(|error| format!("failed to clean doctor temp file: {error}"))?;
println!("check cwd_writable ok");
let temp =
tempfile::tempdir().map_err(|error| format!("failed to create temp dir: {error}"))?;
let idl = Utf8PathBuf::from_path_buf(temp.path().join("doctor.rpc.yaml"))
.map_err(|path| format!("temp path is not UTF-8: {}", path.display()))?;
std::fs::write(&idl, DOCTOR_IDL)
.map_err(|error| format!("failed to write doctor IDL: {error}"))?;
let ir = compile(&idl, Vec::new())?;
println!("check idl_parse ok");
let out = Utf8PathBuf::from_path_buf(temp.path().join("generated"))
.map_err(|path| format!("temp path is not UTF-8: {}", path.display()))?;
generate_to_dir(&ir, &out, GenerateOptions::default())
.map_err(|error| 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(format!("rust generator did not emit `{file}`"));
}
}
println!("check rust_generator ok");
println!("doctor ok");
Ok(())
}
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
}
}
fn format_diagnostics(error: impl HasDiagnostics) -> String {
error
.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 format_inputs(input: &Utf8Path, check: bool) -> Result<(), String> {
let files = collect_idl_files(input)?;
for file in files {
let formatted = format_file(&file).map_err(|error| {
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| format!("failed to read `{file}`: {error}"))?;
if check {
if current != formatted {
return Err(format!("`{file}` is not formatted"));
}
} else if current != formatted {
write_text(&file, &formatted)?;
}
}
Ok(())
}
fn collect_idl_files(input: &Utf8Path) -> Result<Vec<Utf8PathBuf>, String> {
if input.is_file() {
return Ok(vec![input.to_owned()]);
}
if !input.is_dir() {
return Err(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<(), String> {
for entry in
std::fs::read_dir(dir).map_err(|error| format!("failed to read `{dir}`: {error}"))?
{
let entry = entry.map_err(|error| format!("failed to read `{dir}`: {error}"))?;
let path = Utf8PathBuf::from_path_buf(entry.path())
.map_err(|path| 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<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("failed to create `{parent}`: {error}"))?;
}
std::fs::write(path, text).map_err(|error| format!("failed to write `{path}`: {error}"))
}
const DOCTOR_IDL: &str = r#"
idl_version: 1
package: doctor
types:
Empty:
kind: struct
fields: []
services:
Probe:
guid: 11342b38-6e4e-40ab-bb2c-a2e3b5d77bb1
methods:
ping:
id: 1
request: Empty
response: Empty
"#;