Skip to main content

rspyts_cli/
lib.rs

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 every configured host package below .rspyts.
56    Build(BuildArgs),
57    /// Build and validate the contract, optionally against rspyts.lock.
58    Check(CheckArgs),
59    /// Accept the compiled contract as rspyts.lock.
60    Lock(ProjectArgs),
61    /// Print the compiled contract and its fingerprint.
62    Inspect(ProjectArgs),
63    /// Remove the generated .rspyts directory.
64    Clean(ProjectArgs),
65}
66
67#[derive(Debug, Args)]
68struct ProjectArgs {
69    /// Path to rspyts.toml.
70    #[arg(long, default_value = "rspyts.toml")]
71    config: PathBuf,
72}
73
74#[derive(Debug, Args)]
75struct BuildArgs {
76    #[command(flatten)]
77    project: ProjectArgs,
78    /// Build only one configured host package.
79    #[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    /// Require the compiled contract to exactly match rspyts.lock.
88    #[arg(long)]
89    locked: bool,
90    /// Check only one configured host package.
91    #[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        // Keep the complete compiler manifest in the lock. Package versions and
238        // documentation are excluded only while hashing/comparing semantics.
239        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}