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