1use std::path::PathBuf;
2use std::thread;
3use std::time::Duration;
4
5use anyhow::Result;
6use clap::{Args, Parser, Subcommand};
7use serde_json::json;
8
9mod contract;
10mod init;
11mod output;
12mod project;
13mod python;
14mod typescript;
15
16use output::source_state;
17use project::{Project, build, check};
18
19#[derive(Debug, Parser)]
20#[command(
21 name = "rspyts",
22 version,
23 about = "Build one Rust API for Python and TypeScript"
24)]
25struct Cli {
26 #[command(subcommand)]
27 command: Command,
28}
29
30#[derive(Debug, Subcommand)]
31enum Command {
32 Init(InitArgs),
34 Build(ProjectArgs),
36 Watch(ProjectArgs),
38 Check(ProjectArgs),
40}
41
42#[derive(Debug, Args)]
43struct InitArgs {
44 path: PathBuf,
46}
47
48#[derive(Debug, Args)]
49struct ProjectArgs {
50 #[arg(long, default_value = "Cargo.toml")]
52 manifest_path: PathBuf,
53
54 #[arg(long)]
56 output: Option<PathBuf>,
57}
58
59pub fn run() -> Result<()> {
65 run_from(Cli::parse())
66}
67
68fn run_from(cli: Cli) -> Result<()> {
69 match cli.command {
70 Command::Init(args) => {
71 let report = init::create(&args.path)?;
72 println!("{}", serde_json::to_string_pretty(&report)?);
73 }
74 Command::Build(args) => {
75 let project = Project::read(&args.manifest_path, args.output.as_deref())?;
76 let report = build(&project)?;
77 println!("{}", serde_json::to_string_pretty(&report)?);
78 }
79 Command::Check(args) => {
80 let project = Project::read(&args.manifest_path, args.output.as_deref())?;
81 check(&project)?;
82 println!(
83 "{}",
84 serde_json::to_string_pretty(&json!({
85 "status": "ok",
86 "output": project.output(),
87 }))?
88 );
89 }
90 Command::Watch(args) => {
91 let project = Project::read(&args.manifest_path, args.output.as_deref())?;
92 build(&project)?;
93 println!("rspyts is watching {}", project.workspace_root.display());
94 let mut state = source_state(&project.workspace_root)?;
95 loop {
96 thread::sleep(Duration::from_millis(500));
97 let next = source_state(&project.workspace_root)?;
98 if next != state {
99 match build(&project) {
100 Ok(_) => {
101 state = next;
102 println!("rspyts rebuilt {}", project.output().display());
103 }
104 Err(error) => eprintln!("rspyts build failed: {error:#}"),
105 }
106 }
107 }
108 }
109 }
110 Ok(())
111}
112
113#[cfg(test)]
114mod tests {
115 use std::path::Path;
116
117 use rspyts::ir::{Manifest, TypeRef};
118
119 use super::{output, project, typescript};
120
121 #[test]
122 fn validates_package_names() {
123 assert!(project::validate_python_package("example.client").is_ok());
124 assert!(project::validate_python_package("example-client").is_err());
125 assert!(project::validate_typescript_package("@example/client").is_ok());
126 assert!(project::validate_typescript_package("Example").is_err());
127 }
128
129 #[test]
130 fn rejects_duplicate_public_names() {
131 assert!(project::unique_public_names("Python", ["Thing", "Other"].into_iter()).is_ok());
132 assert!(project::unique_public_names("Python", ["Thing", "Thing"].into_iter()).is_err());
133 }
134
135 #[test]
136 fn uses_bigint_for_wide_types() {
137 let manifest = Manifest {
138 package_name: "fixture".into(),
139 package_version: "1.0.0".into(),
140 module_name: "native".into(),
141 types: vec![],
142 errors: vec![],
143 functions: vec![],
144 resources: vec![],
145 constants: vec![],
146 };
147 assert_eq!(
148 typescript::test_type_ref(
149 &TypeRef::Int {
150 signed: false,
151 bits: 64,
152 },
153 &manifest,
154 )
155 .unwrap(),
156 "bigint"
157 );
158 }
159
160 #[test]
161 fn uses_null_for_unit_values() {
162 let manifest = Manifest {
163 package_name: "fixture".into(),
164 package_version: "1.0.0".into(),
165 module_name: "native".into(),
166 types: vec![],
167 errors: vec![],
168 functions: vec![],
169 resources: vec![],
170 constants: vec![],
171 };
172 assert_eq!(
173 typescript::test_type_ref(&TypeRef::Unit, &manifest).unwrap(),
174 "null"
175 );
176 }
177
178 #[test]
179 fn ignores_python_cache_files_during_sync_checks() {
180 let directory = tempfile::tempdir().unwrap();
181 output::write(&directory.path().join("package.py"), "value = 1\n").unwrap();
182 output::write(&directory.path().join("__pycache__/package.pyc"), "cache").unwrap();
183 output::write(&directory.path().join("build/package.py"), "build").unwrap();
184 output::write(
185 &directory.path().join("package.egg-info/PKG-INFO"),
186 "metadata",
187 )
188 .unwrap();
189
190 let files = output::file_tree(directory.path()).unwrap();
191 assert_eq!(files.keys().collect::<Vec<_>>(), [Path::new("package.py")]);
192 }
193
194 #[test]
195 fn fingerprints_only_rust_and_cargo_sources() {
196 let directory = tempfile::tempdir().unwrap();
197 output::write(&directory.path().join("Cargo.toml"), "[workspace]\n").unwrap();
198 let initial = output::source_fingerprint(directory.path()).unwrap();
199
200 output::write(&directory.path().join("dist/generated.js"), "generated\n").unwrap();
201 assert_eq!(
202 output::source_fingerprint(directory.path()).unwrap(),
203 initial
204 );
205
206 output::write(
207 &directory.path().join("src/lib.rs"),
208 "pub fn changed() {}\n",
209 )
210 .unwrap();
211 assert_ne!(
212 output::source_fingerprint(directory.path()).unwrap(),
213 initial
214 );
215 }
216}