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.__version__").is_ok());
125 assert!(project::validate_python_package("example-client").is_err());
126 assert!(project::validate_python_package("example.__path__").is_err());
127 assert!(project::validate_typescript_package("@example/client").is_ok());
128 assert!(project::validate_typescript_package("Example").is_err());
129 }
130
131 #[test]
132 fn rejects_duplicate_public_names() {
133 assert!(project::unique_public_names("Python", ["Thing", "Other"].into_iter()).is_ok());
134 assert!(project::unique_public_names("Python", ["Thing", "Thing"].into_iter()).is_err());
135 }
136
137 #[test]
138 fn uses_bigint_for_wide_types() {
139 let manifest = Manifest {
140 package_name: "fixture".into(),
141 package_version: "1.0.0".into(),
142 module_name: "native".into(),
143 types: vec![],
144 errors: vec![],
145 functions: vec![],
146 resources: vec![],
147 constants: vec![],
148 };
149 assert_eq!(
150 typescript::test_type_ref(
151 &TypeRef::Int {
152 signed: false,
153 bits: 64,
154 },
155 &manifest,
156 )
157 .unwrap(),
158 "bigint"
159 );
160 }
161
162 #[test]
163 fn uses_null_for_unit_values() {
164 let manifest = Manifest {
165 package_name: "fixture".into(),
166 package_version: "1.0.0".into(),
167 module_name: "native".into(),
168 types: vec![],
169 errors: vec![],
170 functions: vec![],
171 resources: vec![],
172 constants: vec![],
173 };
174 assert_eq!(
175 typescript::test_type_ref(&TypeRef::Unit, &manifest).unwrap(),
176 "null"
177 );
178 }
179
180 #[test]
181 fn ignores_python_cache_files_during_sync_checks() {
182 let directory = tempfile::tempdir().unwrap();
183 output::write(&directory.path().join("package.py"), "value = 1\n").unwrap();
184 output::write(&directory.path().join("__pycache__/package.pyc"), "cache").unwrap();
185 output::write(&directory.path().join("build/package.py"), "build").unwrap();
186 output::write(
187 &directory.path().join("package.egg-info/PKG-INFO"),
188 "metadata",
189 )
190 .unwrap();
191
192 let files = output::file_tree(directory.path()).unwrap();
193 assert_eq!(files.keys().collect::<Vec<_>>(), [Path::new("package.py")]);
194 }
195
196 #[test]
197 fn fingerprints_only_rust_and_cargo_sources() {
198 let directory = tempfile::tempdir().unwrap();
199 output::write(&directory.path().join("Cargo.toml"), "[workspace]\n").unwrap();
200 let initial = output::source_fingerprint(directory.path()).unwrap();
201
202 output::write(&directory.path().join("dist/generated.js"), "generated\n").unwrap();
203 assert_eq!(
204 output::source_fingerprint(directory.path()).unwrap(),
205 initial
206 );
207
208 output::write(
209 &directory.path().join("src/lib.rs"),
210 "pub fn changed() {}\n",
211 )
212 .unwrap();
213 assert_ne!(
214 output::source_fingerprint(directory.path()).unwrap(),
215 initial
216 );
217 }
218}