1mod build;
2mod config;
3mod diff;
4mod emit;
5mod load;
6mod resolve;
7mod validate;
8
9use std::collections::BTreeMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result, bail};
14use clap::{Args, Parser, Subcommand, ValueEnum};
15use serde::Serialize;
16use sha2::{Digest, Sha256};
17
18use crate::build::{BuildOptions, BuildReport};
19use crate::config::Project;
20use crate::diff::ContractDiff;
21use crate::load::load_contract;
22
23const LOCK_VERSION: u32 = 2;
24
25#[derive(Debug, Parser)]
26#[command(
27 name = "rspyts",
28 version,
29 about = "Compile one Rust API for Python and TypeScript"
30)]
31pub struct Cli {
32 #[command(subcommand)]
33 command: Command,
34}
35
36#[derive(Debug, Subcommand)]
37enum Command {
38 Build(BuildArgs),
40 Check(CheckArgs),
42 Lock(ProjectArgs),
44 Inspect(ProjectArgs),
46 Clean(ProjectArgs),
48}
49
50#[derive(Debug, Args)]
51struct ProjectArgs {
52 #[arg(long, default_value = "rspyts.toml")]
54 config: PathBuf,
55}
56
57#[derive(Debug, Args)]
58struct BuildArgs {
59 #[command(flatten)]
60 project: ProjectArgs,
61 #[arg(long)]
63 staging: Option<PathBuf>,
64 #[arg(long, value_enum, default_value_t = BuildTarget::All)]
66 target: BuildTarget,
67}
68
69#[derive(Debug, Args)]
70struct CheckArgs {
71 #[command(flatten)]
72 project: ProjectArgs,
73 #[arg(long)]
75 locked: bool,
76 #[arg(long, value_enum, default_value_t = BuildTarget::All)]
78 target: BuildTarget,
79}
80
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
82pub(crate) enum BuildTarget {
83 Python,
84 Typescript,
85 #[default]
86 All,
87}
88
89impl BuildTarget {
90 pub(crate) fn includes_python(self) -> bool {
91 matches!(self, Self::Python | Self::All)
92 }
93
94 pub(crate) fn includes_typescript(self) -> bool {
95 matches!(self, Self::Typescript | Self::All)
96 }
97}
98
99#[derive(Debug, Serialize)]
100#[serde(rename_all = "camelCase")]
101struct Inspection<'a> {
102 schema_version: u32,
103 fingerprint: String,
104 manifest: &'a rspyts::ir::Manifest,
105 dependencies: &'a BTreeMap<String, LockedDependency>,
106 hosts: &'a LockedHosts,
107}
108
109#[derive(Debug, Serialize, serde::Deserialize)]
110#[serde(rename_all = "camelCase", deny_unknown_fields)]
111pub(crate) struct ContractLock {
112 schema_version: u32,
113 fingerprint: String,
114 hosts: LockedHosts,
115 dependencies: BTreeMap<String, LockedDependency>,
116 manifest: rspyts::ir::Manifest,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
120#[serde(rename_all = "camelCase", deny_unknown_fields)]
121pub(crate) struct LockedDependency {
122 #[serde(rename = "crate")]
123 pub owner: rspyts::ir::CargoPackageId,
124 pub fingerprint: String,
125 pub python: Option<String>,
126 pub typescript: Option<String>,
127 pub types: Vec<rspyts::ir::TypeDef>,
128 pub errors: Vec<rspyts::ir::ErrorDef>,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133pub(crate) struct LockedHosts {
134 pub python: Option<String>,
135 pub typescript: Option<LockedTypeScriptHost>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
139#[serde(rename_all = "camelCase", deny_unknown_fields)]
140pub(crate) struct LockedTypeScriptHost {
141 pub package: String,
142 pub mode: crate::config::TypeScriptMode,
143}
144
145pub fn run() -> Result<()> {
146 run_from(Cli::parse())
147}
148
149fn run_from(cli: Cli) -> Result<()> {
150 match cli.command {
151 Command::Build(args) => {
152 let project = Project::read(&args.project.config)?;
153 let report = build::build(
154 &project,
155 BuildOptions {
156 staging: args.staging,
157 target: args.target,
158 },
159 )?;
160 print_json(&report)
161 }
162 Command::Check(args) => {
163 let project = Project::read(&args.project.config)?;
164 let report = build::build(
165 &project,
166 BuildOptions {
167 staging: None,
168 target: args.target,
169 },
170 )?;
171 if args.locked {
172 check_lock(&project, &report)?;
173 }
174 print_json(&report)
175 }
176 Command::Lock(args) => {
177 let project = Project::read(&args.config)?;
178 let loaded = load_contract(&project)?;
179 validate::manifest(&loaded.manifest)?;
180 let resolved = resolve::contract(&project, loaded.manifest)?;
181 let lock = create_lock(resolved)?;
182 write_atomic_file(&project.lock_path(), &pretty_json_line(&lock)?)?;
183 print_json(&lock)
184 }
185 Command::Inspect(args) => {
186 let project = Project::read(&args.config)?;
187 let loaded = load_contract(&project)?;
188 validate::manifest(&loaded.manifest)?;
189 let resolved = resolve::contract(&project, loaded.manifest)?;
190 let inspection = Inspection {
191 schema_version: LOCK_VERSION,
192 fingerprint: fingerprint(
193 &resolved.manifest,
194 &resolved.hosts,
195 &resolved.dependencies,
196 )?,
197 manifest: &resolved.manifest,
198 dependencies: &resolved.dependencies,
199 hosts: &resolved.hosts,
200 };
201 let rendered = serde_json::to_string_pretty(&inspection)?;
202 println!("{rendered}");
203 Ok(())
204 }
205 Command::Clean(args) => {
206 let project = Project::read(&args.config)?;
207 let output = project.output_dir();
208 if output.exists() {
209 fs::remove_dir_all(&output)
210 .with_context(|| format!("failed to remove {}", output.display()))?;
211 }
212 print_json(&CleanReport {
213 schema_version: 1,
214 removed: output,
215 })
216 }
217 }
218}
219
220fn create_lock(resolved: resolve::ResolvedContract) -> Result<ContractLock> {
221 Ok(ContractLock {
222 schema_version: LOCK_VERSION,
223 fingerprint: fingerprint(&resolved.manifest, &resolved.hosts, &resolved.dependencies)?,
224 hosts: resolved.hosts,
225 dependencies: resolved.dependencies,
226 manifest: resolved.manifest,
229 })
230}
231
232#[derive(Debug, Serialize)]
233#[serde(rename_all = "camelCase")]
234struct CleanReport {
235 schema_version: u32,
236 removed: PathBuf,
237}
238
239fn check_lock(project: &Project, report: &BuildReport) -> Result<()> {
240 let path = project.lock_path();
241 let metadata = fs::symlink_metadata(&path)
242 .with_context(|| format!("locked check requires {}", path.display()))?;
243 if metadata.file_type().is_symlink() || !metadata.is_file() {
244 bail!(
245 "contract lock must be a regular non-symlink file: {}",
246 path.display()
247 );
248 }
249 let source = fs::read_to_string(&path)
250 .with_context(|| format!("locked check requires {}", path.display()))?;
251 let lock: ContractLock = serde_json::from_str(&source)
252 .with_context(|| format!("invalid contract lock {}", path.display()))?;
253 if lock.schema_version != LOCK_VERSION {
254 bail!(
255 "unsupported rspyts.lock schema {}; expected {LOCK_VERSION}",
256 lock.schema_version
257 );
258 }
259 validate::manifest(&lock.manifest).context("rspyts.lock contains an invalid manifest")?;
260 let locked_fingerprint = fingerprint(&lock.manifest, &lock.hosts, &lock.dependencies)?;
261 if locked_fingerprint != lock.fingerprint {
262 bail!(
263 "contract lock fingerprint mismatch: recorded {}, computed {locked_fingerprint}",
264 lock.fingerprint
265 );
266 }
267 let semantic_current = semantic_manifest(&report.manifest);
268 if semantic_manifest(&lock.manifest) == semantic_current
269 && lock.dependencies == report.dependencies
270 && lock.hosts == report.hosts
271 && lock.fingerprint == report.fingerprint
272 {
273 return Ok(());
274 }
275
276 let diff = ContractDiff::between(&lock.manifest, &report.manifest);
277 bail!(
278 "compiled contract does not match {}\n{}",
279 path.display(),
280 diff
281 )
282}
283
284#[derive(Serialize)]
285#[serde(rename_all = "camelCase")]
286struct FingerprintInput<'a> {
287 schema_version: u32,
288 hosts: &'a LockedHosts,
289 manifest: rspyts::ir::Manifest,
290 dependencies: &'a BTreeMap<String, LockedDependency>,
291}
292
293pub(crate) fn fingerprint(
294 manifest: &rspyts::ir::Manifest,
295 hosts: &LockedHosts,
296 dependencies: &BTreeMap<String, LockedDependency>,
297) -> Result<String> {
298 let canonical = serde_json::to_vec(&FingerprintInput {
299 schema_version: LOCK_VERSION,
300 hosts,
301 manifest: semantic_manifest(manifest),
302 dependencies,
303 })?;
304 Ok(format!("sha256:{:x}", Sha256::digest(canonical)))
305}
306
307fn semantic_manifest(manifest: &rspyts::ir::Manifest) -> rspyts::ir::Manifest {
308 let mut semantic = manifest.clone();
309 semantic.crate_version.clear();
310 semantic.types = semantic.types.iter().map(semantic_type_def).collect();
311 semantic.errors = semantic.errors.iter().map(semantic_error_def).collect();
312 for import in &mut semantic.imports {
313 import.types = import.types.iter().map(semantic_type_def).collect();
314 import.errors = import.errors.iter().map(semantic_error_def).collect();
315 import
316 .types
317 .sort_by(|left, right| (&left.owner, &left.id).cmp(&(&right.owner, &right.id)));
318 import
319 .errors
320 .sort_by(|left, right| (&left.owner, &left.id).cmp(&(&right.owner, &right.id)));
321 }
322 for function in &mut semantic.functions {
323 function.docs = None;
324 }
325 for resource in &mut semantic.resources {
326 resource.docs = None;
327 for constructor in &mut resource.constructors {
328 constructor.docs = None;
329 }
330 for method in &mut resource.methods {
331 method.docs = None;
332 }
333 }
334 for constant in &mut semantic.constants {
335 constant.docs = None;
336 canonicalize_json(&mut constant.value);
337 }
338 semantic
339 .imports
340 .sort_by(|left, right| left.owner.cmp(&right.owner));
341 semantic
342 .types
343 .sort_by(|left, right| (&left.owner, &left.id).cmp(&(&right.owner, &right.id)));
344 semantic
345 .errors
346 .sort_by(|left, right| (&left.owner, &left.id).cmp(&(&right.owner, &right.id)));
347 semantic.functions.sort_by(|left, right| {
348 (&left.owner, &left.host_name, &left.rust_name).cmp(&(
349 &right.owner,
350 &right.host_name,
351 &right.rust_name,
352 ))
353 });
354 semantic
355 .resources
356 .sort_by(|left, right| (&left.owner, &left.id).cmp(&(&right.owner, &right.id)));
357 semantic.constants.sort_by(|left, right| {
358 (&left.owner, &left.host_name, &left.rust_name).cmp(&(
359 &right.owner,
360 &right.host_name,
361 &right.rust_name,
362 ))
363 });
364 semantic
365}
366
367pub(crate) fn semantic_type_def(item: &rspyts::ir::TypeDef) -> rspyts::ir::TypeDef {
368 let mut item = item.clone();
369 item.docs = None;
370 match &mut item.shape {
371 rspyts::ir::TypeShape::Struct { fields } => clear_field_docs(fields),
372 rspyts::ir::TypeShape::StringEnum { variants }
373 | rspyts::ir::TypeShape::TaggedEnum { variants, .. } => {
374 for variant in variants {
375 variant.docs = None;
376 clear_field_docs(&mut variant.fields);
377 }
378 }
379 rspyts::ir::TypeShape::Alias { .. } => {}
380 }
381 item
382}
383
384pub(crate) fn semantic_error_def(item: &rspyts::ir::ErrorDef) -> rspyts::ir::ErrorDef {
385 let mut item = item.clone();
386 item.docs = None;
387 for variant in &mut item.variants {
388 variant.docs = None;
389 clear_field_docs(&mut variant.fields);
390 }
391 item
392}
393
394fn canonicalize_json(value: &mut serde_json::Value) {
395 match value {
396 serde_json::Value::Array(items) => {
397 for item in items {
398 canonicalize_json(item);
399 }
400 }
401 serde_json::Value::Object(items) => {
402 let mut sorted = std::mem::take(items).into_iter().collect::<Vec<_>>();
403 sorted.sort_by(|left, right| left.0.cmp(&right.0));
404 for (_, value) in &mut sorted {
405 canonicalize_json(value);
406 }
407 items.extend(sorted);
408 }
409 _ => {}
410 }
411}
412
413fn clear_field_docs(fields: &mut [rspyts::ir::FieldDef]) {
414 for field in fields {
415 field.docs = None;
416 }
417}
418
419fn pretty_json_line<T: Serialize>(value: &T) -> Result<Vec<u8>> {
420 let mut bytes = serde_json::to_vec_pretty(value)?;
421 bytes.push(b'\n');
422 Ok(bytes)
423}
424
425fn print_json<T: Serialize>(value: &T) -> Result<()> {
426 println!("{}", serde_json::to_string(value)?);
427 Ok(())
428}
429
430fn write_atomic_file(path: &Path, bytes: &[u8]) -> Result<()> {
431 let parent = path
432 .parent()
433 .with_context(|| format!("{} has no parent directory", path.display()))?;
434 fs::create_dir_all(parent)?;
435 let temporary = parent.join(format!(
436 ".{}.tmp-{}",
437 path.file_name()
438 .and_then(|name| name.to_str())
439 .unwrap_or("rspyts"),
440 std::process::id()
441 ));
442 fs::write(&temporary, bytes)
443 .with_context(|| format!("failed to write {}", temporary.display()))?;
444 let backup = parent.join(format!(
445 ".{}.old-{}",
446 path.file_name()
447 .and_then(|name| name.to_str())
448 .unwrap_or("rspyts"),
449 std::process::id()
450 ));
451 if backup.exists() {
452 fs::remove_file(&backup)?;
453 }
454 let had_existing = path.exists();
455 if had_existing {
456 fs::rename(path, &backup)
457 .with_context(|| format!("failed to stage replacement of {}", path.display()))?;
458 }
459 if let Err(error) = fs::rename(&temporary, path) {
460 if had_existing {
461 let _ = fs::rename(&backup, path);
462 }
463 let _ = fs::remove_file(&temporary);
464 return Err(error).with_context(|| format!("failed to replace {}", path.display()));
465 }
466 if had_existing {
467 fs::remove_file(backup)?;
468 }
469 Ok(())
470}
471
472#[cfg(test)]
473mod tests {
474 use std::time::{SystemTime, UNIX_EPOCH};
475
476 use rspyts::ir::Manifest;
477
478 use super::*;
479
480 fn no_hosts() -> LockedHosts {
481 LockedHosts {
482 python: None,
483 typescript: None,
484 }
485 }
486
487 fn empty_dependencies() -> BTreeMap<String, LockedDependency> {
488 BTreeMap::new()
489 }
490
491 fn test_fingerprint(manifest: &Manifest) -> String {
492 fingerprint(manifest, &no_hosts(), &empty_dependencies()).unwrap()
493 }
494
495 #[test]
496 fn build_and_check_parse_host_targets() {
497 let build = Cli::try_parse_from(["rspyts", "build", "--target", "python"])
498 .expect("python build target should parse");
499 assert!(matches!(
500 build.command,
501 Command::Build(BuildArgs {
502 target: BuildTarget::Python,
503 ..
504 })
505 ));
506
507 let check = Cli::try_parse_from(["rspyts", "check", "--target", "typescript"])
508 .expect("TypeScript check target should parse");
509 assert!(matches!(
510 check.command,
511 Command::Check(CheckArgs {
512 target: BuildTarget::Typescript,
513 ..
514 })
515 ));
516
517 let default = Cli::try_parse_from(["rspyts", "build"]).unwrap();
518 assert!(matches!(
519 default.command,
520 Command::Build(BuildArgs {
521 target: BuildTarget::All,
522 ..
523 })
524 ));
525 }
526
527 #[test]
528 fn fingerprints_are_stable() {
529 let manifest = Manifest {
530 ir_version: 4,
531 crate_name: "sample".into(),
532 crate_version: "1.0.0".into(),
533 module_name: "sample".into(),
534 imports: vec![],
535 types: vec![],
536 errors: vec![],
537 functions: vec![],
538 resources: vec![],
539 constants: vec![],
540 };
541 assert_eq!(test_fingerprint(&manifest), test_fingerprint(&manifest));
542 assert!(test_fingerprint(&manifest).starts_with("sha256:"));
543 }
544
545 #[test]
546 fn lock_retains_the_compiled_package_version() {
547 let manifest = Manifest {
548 ir_version: 4,
549 crate_name: "sample".into(),
550 crate_version: "1.2.3".into(),
551 module_name: "sample".into(),
552 imports: vec![],
553 types: vec![],
554 errors: vec![],
555 functions: vec![],
556 resources: vec![],
557 constants: vec![],
558 };
559 let lock = create_lock(resolve::ResolvedContract {
560 manifest,
561 dependencies: BTreeMap::new(),
562 hosts: no_hosts(),
563 foreign_types: BTreeMap::new(),
564 foreign_errors: BTreeMap::new(),
565 })
566 .unwrap();
567
568 assert_eq!(lock.manifest.crate_version, "1.2.3");
569 let encoded = serde_json::to_value(&lock).unwrap();
570 assert_eq!(encoded["manifest"]["crateVersion"], "1.2.3");
571
572 let first = pretty_json_line(&lock).unwrap();
573 let second = pretty_json_line(&lock).unwrap();
574 assert_eq!(first, second);
575 assert!(first.ends_with(b"\n"));
576 assert!(String::from_utf8_lossy(&first).contains("\n \"schemaVersion\""));
577 let decoded: ContractLock = serde_json::from_slice(&first).unwrap();
578 assert_eq!(serde_json::to_value(decoded).unwrap(), encoded);
579 }
580
581 #[test]
582 fn documentation_and_package_version_do_not_change_semantic_fingerprint() {
583 let mut before = Manifest {
584 ir_version: 4,
585 crate_name: "sample".into(),
586 crate_version: "1.0.0".into(),
587 module_name: "sample".into(),
588 imports: vec![],
589 types: vec![rspyts::ir::TypeDef {
590 owner: rspyts::ir::CargoPackageId::new("sample"),
591 id: "sample::Value".into(),
592 name: "Value".into(),
593 docs: None,
594 shape: rspyts::ir::TypeShape::Struct { fields: vec![] },
595 }],
596 errors: vec![],
597 functions: vec![],
598 resources: vec![],
599 constants: vec![],
600 };
601 let expected = test_fingerprint(&before);
602 before.crate_version = "1.1.0".into();
603 before.types[0].docs = Some("Better docs".into());
604 assert_eq!(test_fingerprint(&before), expected);
605 }
606
607 #[test]
608 fn locked_check_accepts_documentation_and_package_version_changes() {
609 let root = std::env::temp_dir().join(format!(
610 "rspyts-lock-semantic-{}-{}",
611 std::process::id(),
612 SystemTime::now()
613 .duration_since(UNIX_EPOCH)
614 .unwrap()
615 .as_nanos()
616 ));
617 fs::create_dir_all(root.join("rust/src")).unwrap();
618 fs::write(
619 root.join("rust/Cargo.toml"),
620 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
621 )
622 .unwrap();
623 fs::write(root.join("rust/src/lib.rs"), "").unwrap();
624 fs::write(
625 root.join("rspyts.toml"),
626 "[crate]\npath = \"rust\"\n\n[typescript]\npackage = \"fixture\"\nmode = \"static\"\n",
627 )
628 .unwrap();
629 let project = Project::read(&root.join("rspyts.toml")).unwrap();
630 let mut manifest = Manifest {
631 ir_version: 4,
632 crate_name: "fixture".into(),
633 crate_version: "1.0.0".into(),
634 module_name: "fixture".into(),
635 imports: vec![],
636 types: vec![rspyts::ir::TypeDef {
637 owner: rspyts::ir::CargoPackageId::new("fixture"),
638 id: "fixture::Value".into(),
639 name: "Value".into(),
640 docs: None,
641 shape: rspyts::ir::TypeShape::Struct { fields: vec![] },
642 }],
643 errors: vec![],
644 functions: vec![],
645 resources: vec![],
646 constants: vec![],
647 };
648 let hosts = LockedHosts {
649 python: None,
650 typescript: Some(LockedTypeScriptHost {
651 package: "fixture".into(),
652 mode: crate::config::TypeScriptMode::Static,
653 }),
654 };
655 let dependencies = empty_dependencies();
656 let mut lock = ContractLock {
657 schema_version: LOCK_VERSION,
658 fingerprint: fingerprint(&manifest, &hosts, &dependencies).unwrap(),
659 hosts: hosts.clone(),
660 dependencies: dependencies.clone(),
661 manifest: manifest.clone(),
662 };
663 fs::write(
664 project.lock_path(),
665 pretty_json_line(&lock).expect("serialize lock"),
666 )
667 .unwrap();
668
669 manifest.crate_version = "2.0.0".into();
670 manifest.types[0].docs = Some("New documentation".into());
671 let report = BuildReport {
672 schema_version: 1,
673 status: "ok",
674 fingerprint: fingerprint(&manifest, &hosts, &dependencies).unwrap(),
675 contract: root.join(".rspyts/contract.json"),
676 staging: root.join(".rspyts"),
677 python: None,
678 typescript: None,
679 manifest,
680 dependencies,
681 hosts,
682 };
683 check_lock(&project, &report).unwrap();
684
685 lock.fingerprint = "sha256:tampered".into();
686 fs::write(
687 project.lock_path(),
688 pretty_json_line(&lock).expect("serialize tampered lock"),
689 )
690 .unwrap();
691 assert!(
692 check_lock(&project, &report)
693 .unwrap_err()
694 .to_string()
695 .contains("fingerprint mismatch")
696 );
697 fs::remove_dir_all(root).unwrap();
698 }
699}