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.__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}