Skip to main content

rspyts_cli/
lib.rs

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    /// Create a Rust, Python, and TypeScript project.
33    Init(InitArgs),
34    /// Build the Python and TypeScript packages.
35    Build(ProjectArgs),
36    /// Rebuild when Rust or Cargo files change.
37    Watch(ProjectArgs),
38    /// Check that dist matches the Rust source.
39    Check(ProjectArgs),
40}
41
42#[derive(Debug, Args)]
43struct InitArgs {
44    /// New project directory. The final path component is the package name.
45    path: PathBuf,
46}
47
48#[derive(Debug, Args)]
49struct ProjectArgs {
50    /// Path to a workspace or binding Cargo.toml.
51    #[arg(long, default_value = "Cargo.toml")]
52    manifest_path: PathBuf,
53
54    /// Generated package directory. Defaults to `dist` beside the binding manifest.
55    #[arg(long)]
56    output: Option<PathBuf>,
57}
58
59/// Parse the command line and run the selected rspyts command.
60///
61/// # Errors
62///
63/// Returns an error when command input is invalid or a requested operation fails.
64pub 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}