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
55/// Parse the command line and run the selected rspyts command.
56///
57/// # Errors
58///
59/// Returns an error when command input is invalid or a requested operation fails.
60pub fn run() -> Result<()> {
61    run_from(Cli::parse())
62}
63
64fn run_from(cli: Cli) -> Result<()> {
65    match cli.command {
66        Command::Init(args) => {
67            let report = init::create(&args.path)?;
68            println!("{}", serde_json::to_string_pretty(&report)?);
69        }
70        Command::Build(args) => {
71            let project = Project::read(&args.manifest_path)?;
72            let report = build(&project)?;
73            println!("{}", serde_json::to_string_pretty(&report)?);
74        }
75        Command::Check(args) => {
76            let project = Project::read(&args.manifest_path)?;
77            check(&project)?;
78            println!(
79                "{}",
80                serde_json::to_string_pretty(&json!({
81                    "status": "ok",
82                    "output": project.output(),
83                }))?
84            );
85        }
86        Command::Watch(args) => {
87            let project = Project::read(&args.manifest_path)?;
88            build(&project)?;
89            println!("rspyts is watching {}", project.workspace_root.display());
90            let mut state = source_state(&project.workspace_root)?;
91            loop {
92                thread::sleep(Duration::from_millis(500));
93                let next = source_state(&project.workspace_root)?;
94                if next != state {
95                    match build(&project) {
96                        Ok(_) => {
97                            state = next;
98                            println!("rspyts rebuilt {}", project.output().display());
99                        }
100                        Err(error) => eprintln!("rspyts build failed: {error:#}"),
101                    }
102                }
103            }
104        }
105    }
106    Ok(())
107}
108
109#[cfg(test)]
110mod tests {
111    use std::path::Path;
112
113    use rspyts::ir::{Manifest, TypeRef};
114
115    use super::{output, project, typescript};
116
117    #[test]
118    fn validates_package_names() {
119        assert!(project::validate_python_package("example.client").is_ok());
120        assert!(project::validate_python_package("example-client").is_err());
121        assert!(project::validate_typescript_package("@example/client").is_ok());
122        assert!(project::validate_typescript_package("Example").is_err());
123    }
124
125    #[test]
126    fn rejects_duplicate_public_names() {
127        assert!(project::unique_public_names("Python", ["Thing", "Other"].into_iter()).is_ok());
128        assert!(project::unique_public_names("Python", ["Thing", "Thing"].into_iter()).is_err());
129    }
130
131    #[test]
132    fn uses_bigint_for_wide_types() {
133        let manifest = Manifest {
134            ir_version: 1,
135            package_name: "fixture".into(),
136            package_version: "1.0.0".into(),
137            module_name: "native".into(),
138            types: vec![],
139            errors: vec![],
140            functions: vec![],
141            resources: vec![],
142            constants: vec![],
143        };
144        assert_eq!(
145            typescript::type_ref(
146                &TypeRef::Int {
147                    signed: false,
148                    bits: 64,
149                },
150                &manifest,
151            )
152            .unwrap(),
153            "bigint"
154        );
155    }
156
157    #[test]
158    fn ignores_python_cache_files_during_sync_checks() {
159        let directory = tempfile::tempdir().unwrap();
160        output::write(&directory.path().join("package.py"), "value = 1\n").unwrap();
161        output::write(&directory.path().join("__pycache__/package.pyc"), "cache").unwrap();
162        output::write(&directory.path().join("build/package.py"), "build").unwrap();
163        output::write(
164            &directory.path().join("package.egg-info/PKG-INFO"),
165            "metadata",
166        )
167        .unwrap();
168
169        let files = output::file_tree(directory.path()).unwrap();
170        assert_eq!(files.keys().collect::<Vec<_>>(), [Path::new("package.py")]);
171    }
172
173    #[test]
174    fn fingerprints_only_rust_and_cargo_sources() {
175        let directory = tempfile::tempdir().unwrap();
176        output::write(&directory.path().join("Cargo.toml"), "[workspace]\n").unwrap();
177        let initial = output::source_fingerprint(directory.path()).unwrap();
178
179        output::write(&directory.path().join("dist/generated.js"), "generated\n").unwrap();
180        assert_eq!(
181            output::source_fingerprint(directory.path()).unwrap(),
182            initial
183        );
184
185        output::write(
186            &directory.path().join("src/lib.rs"),
187            "pub fn changed() {}\n",
188        )
189        .unwrap();
190        assert_ne!(
191            output::source_fingerprint(directory.path()).unwrap(),
192            initial
193        );
194    }
195}