use std::path::PathBuf;
use std::thread;
use std::time::Duration;
use anyhow::Result;
use clap::{Args, Parser, Subcommand};
use serde_json::json;
mod contract;
mod init;
mod output;
mod project;
mod python;
mod typescript;
use output::source_state;
use project::{Project, build, check};
#[derive(Debug, Parser)]
#[command(
name = "rspyts",
version,
about = "Build one Rust API for Python and TypeScript"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Init(InitArgs),
Build(ProjectArgs),
Watch(ProjectArgs),
Check(ProjectArgs),
}
#[derive(Debug, Args)]
struct InitArgs {
path: PathBuf,
}
#[derive(Debug, Args)]
struct ProjectArgs {
#[arg(long, default_value = "Cargo.toml")]
manifest_path: PathBuf,
#[arg(long)]
output: Option<PathBuf>,
}
pub fn run() -> Result<()> {
run_from(Cli::parse())
}
fn run_from(cli: Cli) -> Result<()> {
match cli.command {
Command::Init(args) => {
let report = init::create(&args.path)?;
println!("{}", serde_json::to_string_pretty(&report)?);
}
Command::Build(args) => {
let project = Project::read(&args.manifest_path, args.output.as_deref())?;
let report = build(&project)?;
println!("{}", serde_json::to_string_pretty(&report)?);
}
Command::Check(args) => {
let project = Project::read(&args.manifest_path, args.output.as_deref())?;
check(&project)?;
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": "ok",
"output": project.output(),
}))?
);
}
Command::Watch(args) => {
let project = Project::read(&args.manifest_path, args.output.as_deref())?;
build(&project)?;
println!("rspyts is watching {}", project.workspace_root.display());
let mut state = source_state(&project.workspace_root)?;
loop {
thread::sleep(Duration::from_millis(500));
let next = source_state(&project.workspace_root)?;
if next != state {
match build(&project) {
Ok(_) => {
state = next;
println!("rspyts rebuilt {}", project.output().display());
}
Err(error) => eprintln!("rspyts build failed: {error:#}"),
}
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::path::Path;
use rspyts::ir::{Manifest, TypeRef};
use super::{output, project, typescript};
#[test]
fn validates_package_names() {
assert!(project::validate_python_package("example.client").is_ok());
assert!(project::validate_python_package("example.__version__").is_ok());
assert!(project::validate_python_package("example-client").is_err());
assert!(project::validate_python_package("example.__path__").is_err());
assert!(project::validate_typescript_package("@example/client").is_ok());
assert!(project::validate_typescript_package("Example").is_err());
}
#[test]
fn rejects_duplicate_public_names() {
assert!(project::unique_public_names("Python", ["Thing", "Other"].into_iter()).is_ok());
assert!(project::unique_public_names("Python", ["Thing", "Thing"].into_iter()).is_err());
}
#[test]
fn uses_bigint_for_wide_types() {
let manifest = Manifest {
package_name: "fixture".into(),
package_version: "1.0.0".into(),
module_name: "native".into(),
types: vec![],
errors: vec![],
functions: vec![],
resources: vec![],
constants: vec![],
};
assert_eq!(
typescript::test_type_ref(
&TypeRef::Int {
signed: false,
bits: 64,
},
&manifest,
)
.unwrap(),
"bigint"
);
}
#[test]
fn uses_null_for_unit_values() {
let manifest = Manifest {
package_name: "fixture".into(),
package_version: "1.0.0".into(),
module_name: "native".into(),
types: vec![],
errors: vec![],
functions: vec![],
resources: vec![],
constants: vec![],
};
assert_eq!(
typescript::test_type_ref(&TypeRef::Unit, &manifest).unwrap(),
"null"
);
}
#[test]
fn ignores_python_cache_files_during_sync_checks() {
let directory = tempfile::tempdir().unwrap();
output::write(&directory.path().join("package.py"), "value = 1\n").unwrap();
output::write(&directory.path().join("__pycache__/package.pyc"), "cache").unwrap();
output::write(&directory.path().join("build/package.py"), "build").unwrap();
output::write(
&directory.path().join("package.egg-info/PKG-INFO"),
"metadata",
)
.unwrap();
let files = output::file_tree(directory.path()).unwrap();
assert_eq!(files.keys().collect::<Vec<_>>(), [Path::new("package.py")]);
}
#[test]
fn fingerprints_only_rust_and_cargo_sources() {
let directory = tempfile::tempdir().unwrap();
output::write(&directory.path().join("Cargo.toml"), "[workspace]\n").unwrap();
let initial = output::source_fingerprint(directory.path()).unwrap();
output::write(&directory.path().join("dist/generated.js"), "generated\n").unwrap();
assert_eq!(
output::source_fingerprint(directory.path()).unwrap(),
initial
);
output::write(
&directory.path().join("src/lib.rs"),
"pub fn changed() {}\n",
)
.unwrap();
assert_ne!(
output::source_fingerprint(directory.path()).unwrap(),
initial
);
}
}