1mod build;
2mod config;
3mod diff;
4mod emit;
5mod load;
6mod resolve;
7mod validate;
8
9use std::collections::BTreeMap;
10use std::fs;
11use std::fs::OpenOptions;
12use std::io::{ErrorKind, Write};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15
16use anyhow::{Context, Result, bail};
17use atomicwrites::replace_atomic;
18use clap::{Args, Parser, Subcommand, ValueEnum};
19use serde::Serialize;
20use sha2::{Digest, Sha256};
21
22use crate::build::{BuildOptions, BuildReport};
23use crate::config::Project;
24use crate::diff::ContractDiff;
25use crate::load::load_contract;
26
27const LOCK_VERSION: u32 = 3;
28static ATOMIC_FILE_ID: AtomicU64 = AtomicU64::new(0);
29
30fn atomic_sibling(path: &Path, suffix: &str) -> Result<PathBuf> {
31 let parent = path
32 .parent()
33 .with_context(|| format!("{} has no parent directory", path.display()))?;
34 let name = path
35 .file_name()
36 .and_then(|name| name.to_str())
37 .with_context(|| format!("{} has no valid UTF-8 file name", path.display()))?;
38 let hidden_prefix = if name.starts_with('.') { "" } else { "." };
39 Ok(parent.join(format!("{hidden_prefix}{name}.{suffix}")))
40}
41
42#[derive(Debug, Parser)]
43#[command(
44 name = "rspyts",
45 version,
46 about = "Compile one Rust API for Python and TypeScript"
47)]
48pub struct Cli {
49 #[command(subcommand)]
50 command: Command,
51}
52
53#[derive(Debug, Subcommand)]
54enum Command {
55 Build(BuildArgs),
57 Check(CheckArgs),
59 Lock(ProjectArgs),
61 Inspect(ProjectArgs),
63 Clean(ProjectArgs),
65}
66
67#[derive(Debug, Args)]
68struct ProjectArgs {
69 #[arg(long, default_value = "rspyts.toml")]
71 config: PathBuf,
72}
73
74#[derive(Debug, Args)]
75struct BuildArgs {
76 #[command(flatten)]
77 project: ProjectArgs,
78 #[arg(long, value_enum, default_value_t = BuildTarget::All)]
80 target: BuildTarget,
81}
82
83#[derive(Debug, Args)]
84struct CheckArgs {
85 #[command(flatten)]
86 project: ProjectArgs,
87 #[arg(long)]
89 locked: bool,
90 #[arg(long, value_enum, default_value_t = BuildTarget::All)]
92 target: BuildTarget,
93}
94
95#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
96pub(crate) enum BuildTarget {
97 Python,
98 Typescript,
99 #[default]
100 All,
101}
102
103impl BuildTarget {
104 pub(crate) fn includes_python(self) -> bool {
105 matches!(self, Self::Python | Self::All)
106 }
107
108 pub(crate) fn includes_typescript(self) -> bool {
109 matches!(self, Self::Typescript | Self::All)
110 }
111}
112
113#[derive(Debug, Serialize)]
114#[serde(rename_all = "camelCase")]
115struct Inspection<'a> {
116 schema_version: u32,
117 fingerprint: String,
118 manifest: &'a rspyts::ir::Manifest,
119 dependencies: &'a BTreeMap<String, LockedDependency>,
120 hosts: &'a LockedHosts,
121}
122
123#[derive(Debug, Serialize, serde::Deserialize)]
124#[serde(rename_all = "camelCase", deny_unknown_fields)]
125pub(crate) struct ContractLock {
126 schema_version: u32,
127 fingerprint: String,
128 hosts: LockedHosts,
129 dependencies: BTreeMap<String, LockedDependency>,
130 manifest: rspyts::ir::Manifest,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
134#[serde(rename_all = "camelCase", deny_unknown_fields)]
135pub(crate) struct LockedDependency {
136 #[serde(rename = "crate")]
137 pub owner: rspyts::ir::CargoPackageId,
138 pub crate_version: String,
139 pub fingerprint: String,
140 pub python: Option<String>,
141 pub typescript: Option<LockedTypeScriptHost>,
142 pub types: Vec<rspyts::ir::TypeDef>,
143 pub errors: Vec<rspyts::ir::ErrorDef>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
147#[serde(rename_all = "camelCase", deny_unknown_fields)]
148pub(crate) struct LockedHosts {
149 pub python: Option<String>,
150 pub typescript: Option<LockedTypeScriptHost>,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
154#[serde(rename_all = "camelCase", deny_unknown_fields)]
155pub(crate) struct LockedTypeScriptHost {
156 pub package: String,
157 pub mode: crate::config::TypeScriptMode,
158}
159
160pub fn run() -> Result<()> {
161 run_from(Cli::parse())
162}
163
164fn run_from(cli: Cli) -> Result<()> {
165 match cli.command {
166 Command::Build(args) => {
167 let project = Project::read(&args.project.config)?;
168 let report = build::build(
169 &project,
170 BuildOptions {
171 target: args.target,
172 },
173 )?;
174 print_json(&report)
175 }
176 Command::Check(args) => {
177 let project = Project::read(&args.project.config)?;
178 let prepared = build::prepare(
179 &project,
180 BuildOptions {
181 target: args.target,
182 },
183 )?;
184 if args.locked {
185 check_lock(&project, prepared.report())?;
186 }
187 let report = prepared.commit()?;
188 print_json(&report)
189 }
190 Command::Lock(args) => {
191 let project = Project::read(&args.config)?;
192 let _lock = build::lock_project(&project)?;
193 let loaded = load_contract(&project)?;
194 validate::manifest(&loaded.manifest)?;
195 let resolved = resolve::contract(&project, loaded.manifest)?;
196 let lock = create_lock(resolved)?;
197 write_atomic_file(&project.lock_path(), &compact_json_line(&lock)?)?;
198 print_json(&lock)
199 }
200 Command::Inspect(args) => {
201 let project = Project::read(&args.config)?;
202 let loaded = load_contract(&project)?;
203 validate::manifest(&loaded.manifest)?;
204 let resolved = resolve::contract(&project, loaded.manifest)?;
205 let inspection = Inspection {
206 schema_version: LOCK_VERSION,
207 fingerprint: fingerprint(
208 &resolved.manifest,
209 &resolved.hosts,
210 &resolved.dependencies,
211 )?,
212 manifest: &resolved.manifest,
213 dependencies: &resolved.dependencies,
214 hosts: &resolved.hosts,
215 };
216 let rendered = serde_json::to_string_pretty(&inspection)?;
217 println!("{rendered}");
218 Ok(())
219 }
220 Command::Clean(args) => {
221 let project = Project::read(&args.config)?;
222 let output = build::clean(&project)?;
223 print_json(&CleanReport {
224 schema_version: 1,
225 removed: output,
226 })
227 }
228 }
229}
230
231fn create_lock(resolved: resolve::ResolvedContract) -> Result<ContractLock> {
232 Ok(ContractLock {
233 schema_version: LOCK_VERSION,
234 fingerprint: fingerprint(&resolved.manifest, &resolved.hosts, &resolved.dependencies)?,
235 hosts: resolved.hosts,
236 dependencies: resolved.dependencies,
237 manifest: resolved.manifest,
240 })
241}
242
243#[derive(Debug, Serialize)]
244#[serde(rename_all = "camelCase")]
245struct CleanReport {
246 schema_version: u32,
247 removed: PathBuf,
248}
249
250fn check_lock(project: &Project, report: &BuildReport) -> Result<()> {
251 let path = project.lock_path();
252 let metadata = fs::symlink_metadata(&path)
253 .with_context(|| format!("locked check requires {}", path.display()))?;
254 if metadata.file_type().is_symlink() || !metadata.is_file() {
255 bail!(
256 "contract lock must be a regular non-symlink file: {}",
257 path.display()
258 );
259 }
260 let source = fs::read_to_string(&path)
261 .with_context(|| format!("locked check requires {}", path.display()))?;
262 let lock: ContractLock = serde_json::from_str(&source)
263 .with_context(|| format!("invalid contract lock {}", path.display()))?;
264 if lock.schema_version != LOCK_VERSION {
265 bail!(
266 "unsupported rspyts.lock schema {}; expected {LOCK_VERSION}",
267 lock.schema_version
268 );
269 }
270 validate::manifest(&lock.manifest).context("rspyts.lock contains an invalid manifest")?;
271 let locked_fingerprint = fingerprint(&lock.manifest, &lock.hosts, &lock.dependencies)?;
272 if locked_fingerprint != lock.fingerprint {
273 bail!(
274 "contract lock fingerprint mismatch: recorded {}, computed {locked_fingerprint}",
275 lock.fingerprint
276 );
277 }
278 if lock.manifest.crate_version != report.manifest.crate_version {
279 bail!(
280 "compiled contract crate version `{}` does not match locked version `{}`",
281 report.manifest.crate_version,
282 lock.manifest.crate_version
283 );
284 }
285 let semantic_locked = semantic_manifest(&lock.manifest);
286 let semantic_current = semantic_manifest(&report.manifest);
287 if semantic_locked == semantic_current {
288 let metadata_changes = lock_metadata_changes(&lock, report);
289 if !metadata_changes.is_empty() {
290 bail!(
291 "compiled contract lock metadata does not match {}\n{}",
292 path.display(),
293 metadata_changes
294 .iter()
295 .map(|change| format!(" - {change}"))
296 .collect::<Vec<_>>()
297 .join("\n")
298 );
299 }
300 if lock.fingerprint != report.fingerprint {
301 bail!(
302 "compiled contract fingerprint {} does not match locked fingerprint {}",
303 report.fingerprint,
304 lock.fingerprint
305 );
306 }
307 return Ok(());
308 }
309
310 let diff = ContractDiff::between(&lock.manifest, &report.manifest);
311 bail!(
312 "compiled contract does not match {}\n{}",
313 path.display(),
314 diff
315 )
316}
317
318fn lock_metadata_changes(lock: &ContractLock, report: &BuildReport) -> Vec<String> {
319 let mut changes = Vec::new();
320 if lock.hosts.python != report.hosts.python {
321 changes.push(format!(
322 "root Python host changed from {:?} to {:?}",
323 lock.hosts.python, report.hosts.python
324 ));
325 }
326 if lock.hosts.typescript != report.hosts.typescript {
327 changes.push(format!(
328 "root TypeScript host changed from {:?} to {:?}",
329 lock.hosts.typescript, report.hosts.typescript
330 ));
331 }
332
333 let aliases = lock
334 .dependencies
335 .keys()
336 .chain(report.dependencies.keys())
337 .collect::<std::collections::BTreeSet<_>>();
338 for alias in aliases {
339 match (lock.dependencies.get(alias), report.dependencies.get(alias)) {
340 (None, Some(_)) => changes.push(format!("added dependency `{alias}`")),
341 (Some(_), None) => changes.push(format!("removed dependency `{alias}`")),
342 (Some(locked), Some(current)) => {
343 if locked.owner != current.owner {
344 changes.push(format!(
345 "dependency `{alias}` Cargo owner changed from `{}` to `{}`",
346 locked.owner, current.owner
347 ));
348 }
349 if locked.crate_version != current.crate_version {
350 changes.push(format!(
351 "dependency `{alias}` crate version changed from `{}` to `{}`",
352 locked.crate_version, current.crate_version
353 ));
354 }
355 if locked.fingerprint != current.fingerprint {
356 changes.push(format!(
357 "dependency `{alias}` fingerprint changed from `{}` to `{}`",
358 locked.fingerprint, current.fingerprint
359 ));
360 }
361 if locked.python != current.python {
362 changes.push(format!(
363 "dependency `{alias}` Python host changed from {:?} to {:?}",
364 locked.python, current.python
365 ));
366 }
367 if locked.typescript != current.typescript {
368 changes.push(format!(
369 "dependency `{alias}` TypeScript host changed from {:?} to {:?}",
370 locked.typescript, current.typescript
371 ));
372 }
373 if locked.types != current.types {
374 changes.push(format!("dependency `{alias}` type snapshot changed"));
375 }
376 if locked.errors != current.errors {
377 changes.push(format!("dependency `{alias}` error snapshot changed"));
378 }
379 }
380 (None, None) => unreachable!(),
381 }
382 }
383 changes
384}
385
386#[derive(Serialize)]
387#[serde(rename_all = "camelCase")]
388struct FingerprintInput<'a> {
389 schema_version: u32,
390 hosts: &'a LockedHosts,
391 manifest: rspyts::ir::Manifest,
392 dependencies: &'a BTreeMap<String, LockedDependency>,
393}
394
395pub(crate) fn fingerprint(
396 manifest: &rspyts::ir::Manifest,
397 hosts: &LockedHosts,
398 dependencies: &BTreeMap<String, LockedDependency>,
399) -> Result<String> {
400 let canonical = serde_json::to_vec(&FingerprintInput {
401 schema_version: LOCK_VERSION,
402 hosts,
403 manifest: semantic_manifest(manifest),
404 dependencies,
405 })?;
406 Ok(format!("sha256:{:x}", Sha256::digest(canonical)))
407}
408
409fn semantic_manifest(manifest: &rspyts::ir::Manifest) -> rspyts::ir::Manifest {
410 let mut semantic = manifest.clone();
411 semantic.crate_version.clear();
412 semantic.types = semantic.types.iter().map(semantic_type_def).collect();
413 semantic.errors = semantic.errors.iter().map(semantic_error_def).collect();
414 for import in &mut semantic.imports {
415 import.types = import.types.iter().map(semantic_type_def).collect();
416 import.errors = import.errors.iter().map(semantic_error_def).collect();
417 import
418 .types
419 .sort_by(|left, right| (&left.owner, &left.id).cmp(&(&right.owner, &right.id)));
420 import
421 .errors
422 .sort_by(|left, right| (&left.owner, &left.id).cmp(&(&right.owner, &right.id)));
423 }
424 for function in &mut semantic.functions {
425 function.docs = None;
426 }
427 for resource in &mut semantic.resources {
428 resource.docs = None;
429 for constructor in &mut resource.constructors {
430 constructor.docs = None;
431 }
432 for method in &mut resource.methods {
433 method.docs = None;
434 }
435 }
436 for constant in &mut semantic.constants {
437 constant.docs = None;
438 canonicalize_json(&mut constant.value);
439 }
440 semantic
441 .imports
442 .sort_by(|left, right| left.owner.cmp(&right.owner));
443 semantic
444 .types
445 .sort_by(|left, right| (&left.owner, &left.id).cmp(&(&right.owner, &right.id)));
446 semantic
447 .errors
448 .sort_by(|left, right| (&left.owner, &left.id).cmp(&(&right.owner, &right.id)));
449 semantic.functions.sort_by(|left, right| {
450 (
451 &left.owner,
452 &left.host_name,
453 &left.rust_name,
454 semantic_target_rank(left.target),
455 )
456 .cmp(&(
457 &right.owner,
458 &right.host_name,
459 &right.rust_name,
460 semantic_target_rank(right.target),
461 ))
462 });
463 semantic
464 .resources
465 .sort_by(|left, right| (&left.owner, &left.id).cmp(&(&right.owner, &right.id)));
466 semantic.constants.sort_by(|left, right| {
467 (
468 &left.owner,
469 &left.host_name,
470 &left.rust_name,
471 semantic_target_rank(left.target),
472 )
473 .cmp(&(
474 &right.owner,
475 &right.host_name,
476 &right.rust_name,
477 semantic_target_rank(right.target),
478 ))
479 });
480 semantic
481}
482
483const fn semantic_target_rank(target: rspyts::ir::Target) -> u8 {
484 match target {
485 rspyts::ir::Target::Both => 0,
486 rspyts::ir::Target::Python => 1,
487 rspyts::ir::Target::Typescript => 2,
488 rspyts::ir::Target::Static => 3,
489 }
490}
491
492pub(crate) fn semantic_type_def(item: &rspyts::ir::TypeDef) -> rspyts::ir::TypeDef {
493 let mut item = item.clone();
494 item.docs = None;
495 match &mut item.shape {
496 rspyts::ir::TypeShape::Struct { fields } => clear_field_docs(fields),
497 rspyts::ir::TypeShape::StringEnum { variants }
498 | rspyts::ir::TypeShape::TaggedEnum { variants, .. } => {
499 for variant in variants {
500 variant.docs = None;
501 clear_field_docs(&mut variant.fields);
502 }
503 }
504 rspyts::ir::TypeShape::Alias { .. } => {}
505 }
506 item
507}
508
509pub(crate) fn semantic_error_def(item: &rspyts::ir::ErrorDef) -> rspyts::ir::ErrorDef {
510 let mut item = item.clone();
511 item.docs = None;
512 for variant in &mut item.variants {
513 variant.docs = None;
514 clear_field_docs(&mut variant.fields);
515 }
516 item
517}
518
519fn canonicalize_json(value: &mut serde_json::Value) {
520 match value {
521 serde_json::Value::Array(items) => {
522 for item in items {
523 canonicalize_json(item);
524 }
525 }
526 serde_json::Value::Object(items) => {
527 let mut sorted = std::mem::take(items).into_iter().collect::<Vec<_>>();
528 sorted.sort_by(|left, right| left.0.cmp(&right.0));
529 for (_, value) in &mut sorted {
530 canonicalize_json(value);
531 }
532 items.extend(sorted);
533 }
534 _ => {}
535 }
536}
537
538fn clear_field_docs(fields: &mut [rspyts::ir::FieldDef]) {
539 for field in fields {
540 field.docs = None;
541 }
542}
543
544fn compact_json_line<T: Serialize>(value: &T) -> Result<Vec<u8>> {
545 let mut bytes = serde_json::to_vec(value)?;
546 bytes.push(b'\n');
547 Ok(bytes)
548}
549
550fn print_json<T: Serialize>(value: &T) -> Result<()> {
551 println!("{}", serde_json::to_string(value)?);
552 Ok(())
553}
554
555fn write_atomic_file(path: &Path, bytes: &[u8]) -> Result<()> {
556 let parent = path
557 .parent()
558 .with_context(|| format!("{} has no parent directory", path.display()))?;
559 fs::create_dir_all(parent)?;
560 validate_atomic_destination(path)?;
561 let (temporary, mut file) = create_atomic_sibling_file(path)?;
562 if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
563 drop(file);
564 let _ = fs::remove_file(&temporary);
565 return Err(error)
566 .with_context(|| format!("failed to write atomic output {}", path.display()));
567 }
568 drop(file);
569 if let Err(error) = validate_atomic_destination(path) {
570 let _ = fs::remove_file(&temporary);
571 return Err(error);
572 }
573 if let Err(error) = replace_atomic_with_retry(&temporary, path) {
574 let _ = fs::remove_file(&temporary);
575 return Err(error)
576 .with_context(|| format!("failed to commit atomic output {}", path.display()));
577 }
578 Ok(())
579}
580
581fn replace_atomic_with_retry(source: &Path, destination: &Path) -> std::io::Result<()> {
582 let mut retries = 0;
583 loop {
584 match replace_atomic(source, destination) {
585 Ok(()) => return Ok(()),
586 Err(error)
587 if cfg!(windows)
588 && retries < 250
589 && matches!(
590 error.kind(),
591 ErrorKind::PermissionDenied | ErrorKind::AlreadyExists
592 ) =>
593 {
594 retries += 1;
595 std::thread::sleep(std::time::Duration::from_millis(1));
596 }
597 Err(error) => return Err(error),
598 }
599 }
600}
601
602fn create_atomic_sibling_file(path: &Path) -> Result<(PathBuf, fs::File)> {
603 for _ in 0..1024 {
604 let id = ATOMIC_FILE_ID.fetch_add(1, Ordering::Relaxed);
605 let candidate = atomic_sibling(path, &format!("tmp-{}-{id}", std::process::id()))?;
606 match OpenOptions::new()
607 .write(true)
608 .create_new(true)
609 .open(&candidate)
610 {
611 Ok(file) => return Ok((candidate, file)),
612 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
613 Err(error) => {
614 return Err(error)
615 .with_context(|| format!("failed to reserve {}", candidate.display()));
616 }
617 }
618 }
619 bail!("failed to reserve a temporary file for {}", path.display())
620}
621
622fn validate_atomic_destination(path: &Path) -> Result<()> {
623 match fs::symlink_metadata(path) {
624 Ok(metadata) if metadata.file_type().is_symlink() => {
625 bail!("refusing to replace symlink {}", path.display())
626 }
627 Ok(metadata) if !metadata.is_file() => {
628 bail!("refusing to replace non-file {}", path.display())
629 }
630 Ok(_) => Ok(()),
631 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
632 Err(error) => Err(error)
633 .with_context(|| format!("failed to inspect atomic output {}", path.display())),
634 }
635}
636
637#[cfg(test)]
638mod tests {
639 use std::time::{SystemTime, UNIX_EPOCH};
640
641 use rspyts::ir::Manifest;
642
643 use super::*;
644
645 fn no_hosts() -> LockedHosts {
646 LockedHosts {
647 python: None,
648 typescript: None,
649 }
650 }
651
652 fn empty_dependencies() -> BTreeMap<String, LockedDependency> {
653 BTreeMap::new()
654 }
655
656 fn test_fingerprint(manifest: &Manifest) -> String {
657 fingerprint(manifest, &no_hosts(), &empty_dependencies()).unwrap()
658 }
659
660 fn test_lock(crate_version: &str) -> ContractLock {
661 let manifest = Manifest {
662 ir_version: rspyts::ir::IR_VERSION,
663 crate_name: "sample".into(),
664 crate_version: crate_version.into(),
665 module_name: "sample".into(),
666 imports: vec![],
667 types: vec![],
668 errors: vec![],
669 functions: vec![],
670 resources: vec![],
671 constants: vec![],
672 };
673 create_lock(resolve::ResolvedContract {
674 manifest,
675 dependencies: BTreeMap::new(),
676 hosts: no_hosts(),
677 foreign_types: BTreeMap::new(),
678 foreign_errors: BTreeMap::new(),
679 })
680 .unwrap()
681 }
682
683 #[test]
684 fn lock_replacement_preserves_existing_temporary_siblings() {
685 let root = std::env::temp_dir().join(format!(
686 "rspyts-lock-atomic-{}-{}",
687 std::process::id(),
688 SystemTime::now()
689 .duration_since(UNIX_EPOCH)
690 .unwrap()
691 .as_nanos()
692 ));
693 fs::create_dir_all(&root).unwrap();
694 let lock = root.join(".rspyts.lock");
695 fs::write(&lock, "old").unwrap();
696 let first_collision = root.join(".rspyts.lock.tmp-123-456");
697 let second_collision = root.join(".rspyts.lock.tmp-123-457");
698 fs::write(&first_collision, "authored temporary collision").unwrap();
699 fs::write(&second_collision, "another temporary collision").unwrap();
700 write_atomic_file(&lock, b"new").unwrap();
701
702 assert_eq!(fs::read_to_string(&lock).unwrap(), "new");
703 assert_eq!(
704 fs::read_to_string(&first_collision).unwrap(),
705 "authored temporary collision"
706 );
707 assert_eq!(
708 fs::read_to_string(&second_collision).unwrap(),
709 "another temporary collision"
710 );
711 fs::remove_dir_all(root).unwrap();
712 }
713
714 #[test]
715 fn atomic_lock_rejects_a_directory_without_mutating_it() {
716 let root = std::env::temp_dir().join(format!(
717 "rspyts-lock-directory-{}-{}",
718 std::process::id(),
719 SystemTime::now()
720 .duration_since(UNIX_EPOCH)
721 .unwrap()
722 .as_nanos()
723 ));
724 let lock = root.join("rspyts.lock");
725 fs::create_dir_all(&lock).unwrap();
726 fs::write(lock.join("authored"), "keep").unwrap();
727
728 let error = write_atomic_file(&lock, b"new").unwrap_err();
729 assert!(error.to_string().contains("non-file"));
730 assert_eq!(fs::read_to_string(lock.join("authored")).unwrap(), "keep");
731
732 fs::remove_dir_all(root).unwrap();
733 }
734
735 #[cfg(unix)]
736 #[test]
737 fn atomic_lock_rejects_a_symlink_without_mutating_its_target() {
738 use std::os::unix::fs::symlink;
739
740 let root = std::env::temp_dir().join(format!(
741 "rspyts-lock-symlink-{}-{}",
742 std::process::id(),
743 SystemTime::now()
744 .duration_since(UNIX_EPOCH)
745 .unwrap()
746 .as_nanos()
747 ));
748 fs::create_dir_all(&root).unwrap();
749 let target = root.join("authored");
750 let lock = root.join("rspyts.lock");
751 fs::write(&target, "keep").unwrap();
752 symlink(&target, &lock).unwrap();
753
754 let error = write_atomic_file(&lock, b"new").unwrap_err();
755 assert!(error.to_string().contains("symlink"));
756 assert!(
757 fs::symlink_metadata(&lock)
758 .unwrap()
759 .file_type()
760 .is_symlink()
761 );
762 assert_eq!(fs::read_to_string(&target).unwrap(), "keep");
763
764 fs::remove_dir_all(root).unwrap();
765 }
766
767 #[test]
768 fn concurrent_atomic_lock_writes_remain_complete_regular_files() {
769 use std::sync::{Arc, Barrier};
770
771 let root = std::env::temp_dir().join(format!(
772 "rspyts-lock-concurrent-{}-{}",
773 std::process::id(),
774 SystemTime::now()
775 .duration_since(UNIX_EPOCH)
776 .unwrap()
777 .as_nanos()
778 ));
779 fs::create_dir_all(&root).unwrap();
780 let lock = Arc::new(root.join("rspyts.lock"));
781 let barrier = Arc::new(Barrier::new(8));
782 let writers = (0..8)
783 .map(|index| {
784 let lock = Arc::clone(&lock);
785 let barrier = Arc::clone(&barrier);
786 std::thread::spawn(move || {
787 let value = format!("complete-{index}\n");
788 barrier.wait();
789 write_atomic_file(&lock, value.as_bytes()).unwrap();
790 })
791 })
792 .collect::<Vec<_>>();
793 for writer in writers {
794 writer.join().unwrap();
795 }
796
797 let value = fs::read_to_string(lock.as_ref()).unwrap();
798 assert!(
799 (0..8).any(|index| value == format!("complete-{index}\n")),
800 "unexpected partial lock: {value:?}"
801 );
802 assert!(fs::symlink_metadata(lock.as_ref()).unwrap().is_file());
803 assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
804
805 fs::remove_dir_all(root).unwrap();
806 }
807
808 #[test]
809 fn visible_atomic_outputs_are_hidden_siblings() {
810 let path = Path::new("generated");
811 assert_eq!(
812 atomic_sibling(path, "tmp-123").unwrap(),
813 PathBuf::from(".generated.tmp-123")
814 );
815 assert_eq!(
816 atomic_sibling(path, "old-123").unwrap(),
817 PathBuf::from(".generated.old-123")
818 );
819 }
820
821 #[test]
822 fn build_and_check_parse_host_targets() {
823 let build = Cli::try_parse_from(["rspyts", "build", "--target", "python"])
824 .expect("python build target should parse");
825 assert!(matches!(
826 build.command,
827 Command::Build(BuildArgs {
828 target: BuildTarget::Python,
829 ..
830 })
831 ));
832
833 let check = Cli::try_parse_from(["rspyts", "check", "--target", "typescript"])
834 .expect("TypeScript check target should parse");
835 assert!(matches!(
836 check.command,
837 Command::Check(CheckArgs {
838 target: BuildTarget::Typescript,
839 ..
840 })
841 ));
842
843 let default = Cli::try_parse_from(["rspyts", "build"]).unwrap();
844 assert!(matches!(
845 default.command,
846 Command::Build(BuildArgs {
847 target: BuildTarget::All,
848 ..
849 })
850 ));
851 }
852
853 #[test]
854 fn fingerprints_are_stable() {
855 let manifest = Manifest {
856 ir_version: 4,
857 crate_name: "sample".into(),
858 crate_version: "1.0.0".into(),
859 module_name: "sample".into(),
860 imports: vec![],
861 types: vec![],
862 errors: vec![],
863 functions: vec![],
864 resources: vec![],
865 constants: vec![],
866 };
867 assert_eq!(test_fingerprint(&manifest), test_fingerprint(&manifest));
868 assert!(test_fingerprint(&manifest).starts_with("sha256:"));
869 }
870
871 #[test]
872 fn fingerprints_ignore_order_for_disjoint_same_name_host_exports() {
873 let owner = rspyts::ir::CargoPackageId::new("sample");
874 let function = |target| rspyts::ir::FunctionDef {
875 owner: owner.clone(),
876 rust_name: "shared".into(),
877 host_name: "shared".into(),
878 docs: None,
879 target,
880 params: vec![],
881 returns: rspyts::ir::TypeRef::Unit,
882 error: None,
883 };
884 let constant = |target| rspyts::ir::ConstantDef {
885 owner: owner.clone(),
886 rust_name: "SHARED".into(),
887 host_name: "SHARED".into(),
888 docs: None,
889 target,
890 ty: rspyts::ir::TypeRef::String,
891 value: serde_json::Value::String("value".into()),
892 };
893 let mut left = Manifest {
894 ir_version: rspyts::ir::IR_VERSION,
895 crate_name: "sample".into(),
896 crate_version: "1.0.0".into(),
897 module_name: "sample".into(),
898 imports: vec![],
899 types: vec![],
900 errors: vec![],
901 functions: vec![
902 function(rspyts::ir::Target::Typescript),
903 function(rspyts::ir::Target::Python),
904 ],
905 resources: vec![],
906 constants: vec![
907 constant(rspyts::ir::Target::Typescript),
908 constant(rspyts::ir::Target::Python),
909 ],
910 };
911 let mut right = left.clone();
912 right.functions.reverse();
913 right.constants.reverse();
914
915 assert_eq!(test_fingerprint(&left), test_fingerprint(&right));
916 left.functions.reverse();
917 left.constants.reverse();
918 assert_eq!(test_fingerprint(&left), test_fingerprint(&right));
919 }
920
921 #[test]
922 fn fingerprints_distinguish_dynamic_bytes_and_every_fixed_length() {
923 let manifest = |target| Manifest {
924 ir_version: rspyts::ir::IR_VERSION,
925 crate_name: "sample".into(),
926 crate_version: "1.0.0".into(),
927 module_name: "sample".into(),
928 imports: vec![],
929 types: vec![rspyts::ir::TypeDef {
930 owner: rspyts::ir::CargoPackageId::new("sample"),
931 id: "sample::Digest".into(),
932 name: "Digest".into(),
933 docs: None,
934 shape: rspyts::ir::TypeShape::Alias { target },
935 }],
936 errors: vec![],
937 functions: vec![],
938 resources: vec![],
939 constants: vec![],
940 };
941 let dynamic = test_fingerprint(&manifest(rspyts::ir::TypeRef::Bytes));
942 let fixed_four = test_fingerprint(&manifest(rspyts::ir::TypeRef::FixedBytes { length: 4 }));
943 let fixed_eight =
944 test_fingerprint(&manifest(rspyts::ir::TypeRef::FixedBytes { length: 8 }));
945
946 assert_ne!(dynamic, fixed_four);
947 assert_ne!(fixed_four, fixed_eight);
948 assert_ne!(dynamic, fixed_eight);
949 }
950
951 #[test]
952 fn lock_retains_the_compiled_package_version() {
953 let lock = test_lock("1.2.3");
954
955 assert_eq!(lock.manifest.crate_version, "1.2.3");
956 let encoded = serde_json::to_value(&lock).unwrap();
957 assert_eq!(encoded["manifest"]["crateVersion"], "1.2.3");
958 }
959
960 #[test]
961 fn lock_serialization_is_compact_deterministic_and_roundtrips() {
962 let lock = test_lock("1.2.3");
963 let first = compact_json_line(&lock).unwrap();
964 let second = compact_json_line(&lock).unwrap();
965 assert_eq!(first, second);
966 assert!(first.ends_with(b"\n"));
967 assert_eq!(first.iter().filter(|byte| **byte == b'\n').count(), 1);
968 assert_eq!(
969 &first[..first.len() - 1],
970 serde_json::to_vec(&lock).unwrap()
971 );
972 let decoded: ContractLock = serde_json::from_slice(&first).unwrap();
973 assert_eq!(
974 serde_json::to_value(decoded).unwrap(),
975 serde_json::to_value(lock).unwrap()
976 );
977 }
978
979 #[test]
980 fn documentation_and_package_version_do_not_change_semantic_fingerprint() {
981 let mut before = Manifest {
982 ir_version: 4,
983 crate_name: "sample".into(),
984 crate_version: "1.0.0".into(),
985 module_name: "sample".into(),
986 imports: vec![],
987 types: vec![rspyts::ir::TypeDef {
988 owner: rspyts::ir::CargoPackageId::new("sample"),
989 id: "sample::Value".into(),
990 name: "Value".into(),
991 docs: None,
992 shape: rspyts::ir::TypeShape::Struct { fields: vec![] },
993 }],
994 errors: vec![],
995 functions: vec![],
996 resources: vec![],
997 constants: vec![],
998 };
999 let expected = test_fingerprint(&before);
1000 before.crate_version = "1.1.0".into();
1001 before.types[0].docs = Some("Better docs".into());
1002 assert_eq!(test_fingerprint(&before), expected);
1003 }
1004
1005 #[test]
1006 fn dependency_package_version_changes_the_root_fingerprint() {
1007 let manifest = Manifest {
1008 ir_version: 4,
1009 crate_name: "sample".into(),
1010 crate_version: "1.0.0".into(),
1011 module_name: "sample".into(),
1012 imports: vec![],
1013 types: vec![],
1014 errors: vec![],
1015 functions: vec![],
1016 resources: vec![],
1017 constants: vec![],
1018 };
1019 let mut dependency = LockedDependency {
1020 owner: rspyts::ir::CargoPackageId::new("dependency"),
1021 crate_version: "1.0.0".into(),
1022 fingerprint: "sha256:dependency".into(),
1023 python: Some("example.dependency".into()),
1024 typescript: Some(LockedTypeScriptHost {
1025 package: "@example/dependency".into(),
1026 mode: crate::config::TypeScriptMode::Static,
1027 }),
1028 types: vec![],
1029 errors: vec![],
1030 };
1031 let before = fingerprint(
1032 &manifest,
1033 &no_hosts(),
1034 &BTreeMap::from([("dependency".into(), dependency.clone())]),
1035 )
1036 .unwrap();
1037
1038 dependency.crate_version = "1.0.1".into();
1039 let after = fingerprint(
1040 &manifest,
1041 &no_hosts(),
1042 &BTreeMap::from([("dependency".into(), dependency)]),
1043 )
1044 .unwrap();
1045
1046 assert_ne!(before, after);
1047 }
1048
1049 #[test]
1050 fn locked_check_accepts_documentation_but_rejects_package_version_changes() {
1051 let root = std::env::temp_dir().join(format!(
1052 "rspyts-lock-semantic-{}-{}",
1053 std::process::id(),
1054 SystemTime::now()
1055 .duration_since(UNIX_EPOCH)
1056 .unwrap()
1057 .as_nanos()
1058 ));
1059 fs::create_dir_all(root.join("rust/src")).unwrap();
1060 fs::write(
1061 root.join("rust/Cargo.toml"),
1062 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
1063 )
1064 .unwrap();
1065 fs::write(root.join("rust/src/lib.rs"), "").unwrap();
1066 fs::write(
1067 root.join("rspyts.toml"),
1068 "[crate]\npath = \"rust\"\n\n[typescript]\npackage = \"fixture\"\nmode = \"static\"\n",
1069 )
1070 .unwrap();
1071 let project = Project::read(&root.join("rspyts.toml")).unwrap();
1072 let mut manifest = Manifest {
1073 ir_version: rspyts::ir::IR_VERSION,
1074 crate_name: "fixture".into(),
1075 crate_version: "1.0.0".into(),
1076 module_name: "fixture".into(),
1077 imports: vec![],
1078 types: vec![rspyts::ir::TypeDef {
1079 owner: rspyts::ir::CargoPackageId::new("fixture"),
1080 id: "fixture::Value".into(),
1081 name: "Value".into(),
1082 docs: None,
1083 shape: rspyts::ir::TypeShape::Struct { fields: vec![] },
1084 }],
1085 errors: vec![],
1086 functions: vec![],
1087 resources: vec![],
1088 constants: vec![],
1089 };
1090 let hosts = LockedHosts {
1091 python: None,
1092 typescript: Some(LockedTypeScriptHost {
1093 package: "fixture".into(),
1094 mode: crate::config::TypeScriptMode::Static,
1095 }),
1096 };
1097 let dependencies = empty_dependencies();
1098 let mut lock = ContractLock {
1099 schema_version: LOCK_VERSION,
1100 fingerprint: fingerprint(&manifest, &hosts, &dependencies).unwrap(),
1101 hosts: hosts.clone(),
1102 dependencies: dependencies.clone(),
1103 manifest: manifest.clone(),
1104 };
1105 fs::write(
1106 project.lock_path(),
1107 compact_json_line(&lock).expect("serialize lock"),
1108 )
1109 .unwrap();
1110
1111 manifest.crate_version = "2.0.0".into();
1112 manifest.types[0].docs = Some("New documentation".into());
1113 let report = BuildReport {
1114 schema_version: 1,
1115 status: "ok",
1116 fingerprint: fingerprint(&manifest, &hosts, &dependencies).unwrap(),
1117 contract: root.join(".rspyts/contract.json"),
1118 output: root.join(".rspyts"),
1119 python: None,
1120 typescript: None,
1121 manifest,
1122 dependencies,
1123 hosts,
1124 };
1125 assert_eq!(lock.fingerprint, report.fingerprint);
1126 let error = check_lock(&project, &report).unwrap_err();
1127 assert!(
1128 error
1129 .to_string()
1130 .contains("crate version `2.0.0` does not match locked version `1.0.0`")
1131 );
1132
1133 let mut report = report;
1134 report.manifest.crate_version = lock.manifest.crate_version.clone();
1135 check_lock(&project, &report).unwrap();
1136
1137 let locked_dependency = LockedDependency {
1138 owner: rspyts::ir::CargoPackageId::new("dependency"),
1139 crate_version: "1.0.0".into(),
1140 fingerprint: "sha256:dependency".into(),
1141 python: Some("example.dependency".into()),
1142 typescript: Some(LockedTypeScriptHost {
1143 package: "@example/dependency".into(),
1144 mode: crate::config::TypeScriptMode::Static,
1145 }),
1146 types: vec![],
1147 errors: vec![],
1148 };
1149 lock.dependencies
1150 .insert("dependency".into(), locked_dependency.clone());
1151 lock.fingerprint = fingerprint(&lock.manifest, &lock.hosts, &lock.dependencies).unwrap();
1152 fs::write(
1153 project.lock_path(),
1154 compact_json_line(&lock).expect("serialize lock with dependency"),
1155 )
1156 .unwrap();
1157
1158 let mut current_dependency = locked_dependency;
1159 current_dependency.crate_version = "2.0.0".into();
1160 report
1161 .dependencies
1162 .insert("dependency".into(), current_dependency.clone());
1163 report.fingerprint =
1164 fingerprint(&report.manifest, &report.hosts, &report.dependencies).unwrap();
1165 let error = check_lock(&project, &report).unwrap_err().to_string();
1166 assert!(error.contains("dependency `dependency` crate version changed"));
1167 assert!(!error.contains("no semantic changes"));
1168
1169 current_dependency.crate_version = "1.0.0".into();
1170 current_dependency.python = Some("example.renamed".into());
1171 current_dependency.typescript = Some(LockedTypeScriptHost {
1172 package: "@example/renamed".into(),
1173 mode: crate::config::TypeScriptMode::Wasm,
1174 });
1175 report
1176 .dependencies
1177 .insert("dependency".into(), current_dependency);
1178 report.fingerprint =
1179 fingerprint(&report.manifest, &report.hosts, &report.dependencies).unwrap();
1180 let error = check_lock(&project, &report).unwrap_err().to_string();
1181 assert!(error.contains("dependency `dependency` Python host changed"));
1182 assert!(error.contains("dependency `dependency` TypeScript host changed"));
1183 assert!(!error.contains("no semantic changes"));
1184
1185 lock.fingerprint = "sha256:tampered".into();
1186 fs::write(
1187 project.lock_path(),
1188 compact_json_line(&lock).expect("serialize tampered lock"),
1189 )
1190 .unwrap();
1191 assert!(
1192 check_lock(&project, &report)
1193 .unwrap_err()
1194 .to_string()
1195 .contains("fingerprint mismatch")
1196 );
1197 fs::remove_dir_all(root).unwrap();
1198 }
1199}