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