lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

output.rs (17687B)


      1 use dto_bindgen_backend_ts::{DtoTypesModule, TypeScriptImport, TypeScriptModule};
      2 use sha2::{Digest, Sha256};
      3 
      4 use crate::sdk_generation::{
      5     dto_roots,
      6     manifest::manifest_relative_path,
      7     manifest::package_manifest,
      8     package_matrix::{PackageSpec, package_specs},
      9     ts::{generated_constants_file, generated_header, generated_kinds_file, generated_types_file},
     10 };
     11 
     12 pub struct PackageOutput {
     13     pub spec: PackageSpec,
     14     pub types_ts: Option<TsSource>,
     15     pub types_imports: Vec<TypeScriptImport>,
     16     pub constants_ts: Option<TsSource>,
     17     pub kinds_ts: Option<TsSource>,
     18 }
     19 
     20 pub struct GeneratedFile {
     21     pub relative_path: String,
     22     pub contents: String,
     23 }
     24 
     25 pub enum TsSource {
     26     DtoRegistry(DtoTypesModule),
     27     Module(TypeScriptModule),
     28     CanonicalNativeSnapshot { contents: &'static str },
     29 }
     30 
     31 impl TsSource {
     32     fn canonical_native_snapshot(
     33         contents: &'static str,
     34         sha256: &'static str,
     35         source_packages: &'static [&'static str],
     36     ) -> Result<Self, String> {
     37         let actual = format!("{:x}", Sha256::digest(contents.as_bytes()));
     38         if actual != sha256 {
     39             return Err(format!(
     40                 "canonical native type snapshot drifted: expected {sha256}, found {actual}; regenerate from and review against {}",
     41                 source_packages.join(", ")
     42             ));
     43         }
     44         Ok(Self::CanonicalNativeSnapshot { contents })
     45     }
     46 
     47     fn render(&self) -> String {
     48         match self {
     49             Self::DtoRegistry(module) => module.body_ts().to_owned(),
     50             Self::Module(module) => module.render_source(),
     51             Self::CanonicalNativeSnapshot { contents } => (*contents).to_owned(),
     52         }
     53     }
     54 
     55     fn imports(&self) -> Option<&str> {
     56         match self {
     57             Self::DtoRegistry(module) => module.imports_ts(),
     58             Self::Module(_) | Self::CanonicalNativeSnapshot { .. } => None,
     59         }
     60     }
     61 }
     62 
     63 const EVENT_BINDINGS_TYPES_TS: &str =
     64     include_str!("../../../../crates/event_bindings/tests/fixtures/generated_types.ts");
     65 const EVENT_BINDINGS_TYPES_SHA256: &str =
     66     "fe4496950852715c573ff2abc7df8edede7161de75d9c68c3f4d6836e67f829a";
     67 const TRADE_BINDINGS_TYPES_TS: &str =
     68     include_str!("../../../../crates/trade_bindings/tests/fixtures/generated_types.ts");
     69 const TRADE_BINDINGS_TYPES_SHA256: &str =
     70     "66e6a378231e5341a59cf331b23b3164b27a43fa00411e889d634e4d9d19b53d";
     71 const EVENT_NATIVE_SOURCES: &[&str] = &["radroots_event", "radroots_core"];
     72 const TRADE_NATIVE_SOURCES: &[&str] = &["radroots_trade", "radroots_event", "radroots_core"];
     73 
     74 impl PackageOutput {
     75     pub fn files(&self) -> Vec<GeneratedFile> {
     76         let mut files = Vec::new();
     77         if let Some(types_ts) = &self.types_ts {
     78             let imports = combined_imports(
     79                 structured_imports_ts(&self.types_imports).as_deref(),
     80                 types_ts.imports(),
     81             );
     82             files.push(GeneratedFile {
     83                 relative_path: format!("src/generated/{}", generated_types_file()),
     84                 contents: render_ts(types_ts, imports.as_deref()),
     85             });
     86         }
     87         if let Some(constants_ts) = &self.constants_ts {
     88             files.push(GeneratedFile {
     89                 relative_path: format!("src/generated/{}", generated_constants_file()),
     90                 contents: render_ts(constants_ts, None),
     91             });
     92         }
     93         if let Some(kinds_ts) = &self.kinds_ts {
     94             files.push(GeneratedFile {
     95                 relative_path: format!("src/generated/{}", generated_kinds_file()),
     96                 contents: render_ts(kinds_ts, None),
     97             });
     98         }
     99         files
    100     }
    101 
    102     pub fn provenance_file(&self) -> GeneratedFile {
    103         GeneratedFile {
    104             relative_path: manifest_relative_path(self.spec),
    105             contents: render_manifest(self),
    106         }
    107     }
    108 }
    109 
    110 pub fn package_outputs() -> Result<Vec<PackageOutput>, String> {
    111     Ok(vec![
    112         PackageOutput {
    113             spec: spec_by_key("core"),
    114             types_ts: Some(TsSource::DtoRegistry(dto_roots::core_types_module()?)),
    115             types_imports: Vec::new(),
    116             constants_ts: None,
    117             kinds_ts: None,
    118         },
    119         PackageOutput {
    120             spec: spec_by_key("event"),
    121             types_ts: Some(TsSource::canonical_native_snapshot(
    122                 EVENT_BINDINGS_TYPES_TS,
    123                 EVENT_BINDINGS_TYPES_SHA256,
    124                 EVENT_NATIVE_SOURCES,
    125             )?),
    126             types_imports: Vec::new(),
    127             constants_ts: Some(TsSource::Module(radroots_event_bindings::constants_module())),
    128             kinds_ts: Some(TsSource::Module(radroots_event_bindings::kinds_module())),
    129         },
    130         PackageOutput {
    131             spec: spec_by_key("identity"),
    132             types_ts: None,
    133             types_imports: Vec::new(),
    134             constants_ts: Some(TsSource::Module(
    135                 radroots_identity_bindings::constants_module(),
    136             )),
    137             kinds_ts: None,
    138         },
    139         PackageOutput {
    140             spec: spec_by_key("replica_schema"),
    141             types_ts: Some(TsSource::DtoRegistry(
    142                 dto_roots::replica_schema_types_module()?,
    143             )),
    144             types_imports: Vec::new(),
    145             constants_ts: None,
    146             kinds_ts: None,
    147         },
    148         PackageOutput {
    149             spec: spec_by_key("trade"),
    150             types_ts: Some(TsSource::canonical_native_snapshot(
    151                 TRADE_BINDINGS_TYPES_TS,
    152                 TRADE_BINDINGS_TYPES_SHA256,
    153                 TRADE_NATIVE_SOURCES,
    154             )?),
    155             types_imports: Vec::new(),
    156             constants_ts: None,
    157             kinds_ts: None,
    158         },
    159     ])
    160 }
    161 
    162 fn spec_by_key(key: &str) -> PackageSpec {
    163     package_specs()
    164         .iter()
    165         .copied()
    166         .find(|spec| spec.key == key)
    167         .unwrap_or_else(|| panic!("missing package spec for {key}"))
    168 }
    169 
    170 fn render_ts(source: &TsSource, imports: Option<&str>) -> String {
    171     if matches!(source, TsSource::CanonicalNativeSnapshot { .. }) {
    172         return source.render();
    173     }
    174     let body = source.render();
    175     let imports = imports.unwrap_or("");
    176     let mut rendered = format!("{}{}{}", generated_header(), imports, body.trim_start());
    177     if !rendered.ends_with('\n') {
    178         rendered.push('\n');
    179     }
    180     rendered
    181 }
    182 
    183 fn combined_imports(first: Option<&str>, second: Option<&str>) -> Option<String> {
    184     match (first, second) {
    185         (Some(first), Some(second)) => Some(format!("{first}{second}")),
    186         (Some(first), None) => Some(first.to_owned()),
    187         (None, Some(second)) => Some(second.to_owned()),
    188         (None, None) => None,
    189     }
    190 }
    191 
    192 fn structured_imports_ts(imports: &[TypeScriptImport]) -> Option<String> {
    193     if imports.is_empty() {
    194         return None;
    195     }
    196     Some(
    197         imports
    198             .iter()
    199             .cloned()
    200             .fold(TypeScriptModule::new("types.ts"), |module, import| {
    201                 module.with_import(import)
    202             })
    203             .render_source(),
    204     )
    205 }
    206 
    207 fn render_manifest(output: &PackageOutput) -> String {
    208     let spec = output.spec;
    209     let mut value = package_manifest(spec);
    210     value["generated"] = serde_json::Value::Bool(true);
    211     value["outputs"] = serde_json::Value::Object(
    212         output
    213             .files()
    214             .into_iter()
    215             .map(|file| {
    216                 (
    217                     file.relative_path,
    218                     serde_json::Value::String(format!(
    219                         "{:x}",
    220                         Sha256::digest(file.contents.as_bytes())
    221                     )),
    222                 )
    223             })
    224             .collect(),
    225     );
    226     if matches!(spec.key, "event" | "trade") {
    227         let (sha256, source_packages) = match spec.key {
    228             "event" => (EVENT_BINDINGS_TYPES_SHA256, EVENT_NATIVE_SOURCES),
    229             "trade" => (TRADE_BINDINGS_TYPES_SHA256, TRADE_NATIVE_SOURCES),
    230             _ => unreachable!(),
    231         };
    232         value["types_source"] =
    233             serde_json::Value::String("canonical_native_type_snapshot".to_owned());
    234         value["types_sha256"] = serde_json::Value::String(sha256.to_owned());
    235         value["types_source_packages"] = serde_json::Value::Array(
    236             source_packages
    237                 .iter()
    238                 .map(|package| serde_json::Value::String((*package).to_owned()))
    239                 .collect(),
    240         );
    241     }
    242     format!(
    243         "{}\n",
    244         serde_json::to_string_pretty(&value).expect("manifest json serializes")
    245     )
    246 }
    247 
    248 #[cfg(test)]
    249 mod tests {
    250     use super::{PackageOutput, TsSource, package_outputs, render_ts};
    251     use crate::sdk_generation::package_matrix::package_specs;
    252     use dto_bindgen_backend_ts::{
    253         DtoTypesModule, TypeScriptDeclaration, TypeScriptImport, TypeScriptModule, TypeScriptType,
    254     };
    255 
    256     const TRADE_BINDINGS_TYPES_TS: &str =
    257         include_str!("../../../../crates/trade_bindings/tests/fixtures/generated_types.ts");
    258     const REPLICA_SCHEMA_BINDINGS_TYPES_TS: &str = include_str!(
    259         "../../../../crates/replica_schema_bindings/tests/fixtures/generated_types.ts"
    260     );
    261     const EVENT_BINDINGS_CONSTANTS_TS: &str =
    262         include_str!("../../../../crates/event_bindings/tests/fixtures/generated_constants.ts");
    263     const EVENT_BINDINGS_KINDS_TS: &str =
    264         include_str!("../../../../crates/event_bindings/tests/fixtures/generated_kinds.ts");
    265     const IDENTITY_BINDINGS_CONSTANTS_TS: &str =
    266         include_str!("../../../../crates/identity_bindings/tests/fixtures/generated_constants.ts");
    267 
    268     #[test]
    269     fn renders_sdk_header() {
    270         let output = render_ts(&test_module(), None);
    271         assert!(output.starts_with("// @generated by cargo xtask generate ts"));
    272         assert!(output.contains("export type A = string;"));
    273     }
    274 
    275     #[test]
    276     fn renders_import_prelude_after_header() {
    277         let output = render_ts(&test_module(), Some("import type { B } from \"b\";\n\n"));
    278         assert!(output.starts_with(
    279             "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nimport type"
    280         ));
    281         assert!(output.contains("export type A = string;"));
    282     }
    283 
    284     #[test]
    285     fn renders_module_sources() {
    286         let output = render_ts(&test_module(), None);
    287         assert_eq!(
    288             output,
    289             "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nexport type A = string;\n"
    290         );
    291     }
    292 
    293     #[test]
    294     fn canonical_native_snapshot_rejects_digest_drift() {
    295         let error = match TsSource::canonical_native_snapshot(
    296             "drifted snapshot\n",
    297             super::EVENT_BINDINGS_TYPES_SHA256,
    298             super::EVENT_NATIVE_SOURCES,
    299         ) {
    300             Ok(_) => panic!("digest drift must fail closed"),
    301             Err(error) => error,
    302         };
    303 
    304         assert!(error.contains("canonical native type snapshot drifted"));
    305         assert!(error.contains(super::EVENT_BINDINGS_TYPES_SHA256));
    306         assert!(error.contains("radroots_event"));
    307     }
    308 
    309     #[test]
    310     fn includes_core_and_schema_outputs() {
    311         let package_names = package_outputs()
    312             .expect("package outputs")
    313             .into_iter()
    314             .map(|output| output.spec.package_name)
    315             .collect::<Vec<_>>();
    316         assert!(package_names.contains(&"@radroots/core-bindings"));
    317         assert!(package_names.contains(&"@radroots/event-bindings"));
    318         assert!(package_names.contains(&"@radroots/identity-bindings"));
    319         assert!(package_names.contains(&"@radroots/replica-schema-bindings"));
    320         assert!(package_names.contains(&"@radroots/trade-bindings"));
    321     }
    322 
    323     #[test]
    324     fn dto_registry_source_uses_generated_package_files() {
    325         let output = PackageOutput {
    326             spec: package_specs()[0],
    327             types_ts: Some(TsSource::DtoRegistry(DtoTypesModule::new(
    328                 "import type { ExternalThing } from \"@radroots/external-bindings\";\n\n",
    329                 "export type SyntheticThing = { external: ExternalThing, };",
    330             ))),
    331             types_imports: vec![TypeScriptImport::type_only(
    332                 ["LocalPrelude"],
    333                 "@radroots/local",
    334             )],
    335             constants_ts: None,
    336             kinds_ts: None,
    337         };
    338         let files = output.files();
    339         let types = files
    340             .iter()
    341             .find(|file| file.relative_path == "src/generated/types.ts")
    342             .expect("types file");
    343         let manifest = output.provenance_file();
    344 
    345         assert_eq!(
    346             types.contents,
    347             "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nimport type { LocalPrelude } from \"@radroots/local\";\nimport type { ExternalThing } from \"@radroots/external-bindings\";\n\nexport type SyntheticThing = { external: ExternalThing, };\n"
    348         );
    349         assert_eq!(
    350             manifest.relative_path,
    351             "contracts/provenance/typescript/core.json"
    352         );
    353         assert!(manifest.contents.contains("\"generated\": true"));
    354         assert!(
    355             !files
    356                 .iter()
    357                 .any(|file| file.relative_path == "src/index.ts")
    358         );
    359     }
    360 
    361     #[test]
    362     fn package_outputs_do_not_generate_package_indices() {
    363         for output in package_outputs().expect("package outputs") {
    364             assert!(
    365                 !output
    366                     .files()
    367                     .iter()
    368                     .any(|file| file.relative_path == "src/index.ts"),
    369                 "{} index must remain handwritten source",
    370                 output.spec.package_name
    371             );
    372         }
    373     }
    374 
    375     #[test]
    376     fn trade_output_uses_canonical_native_snapshot_and_matches_checked_in_types() {
    377         let output = package_outputs()
    378             .expect("package outputs")
    379             .into_iter()
    380             .find(|output| output.spec.key == "trade")
    381             .expect("trade output");
    382 
    383         assert!(matches!(
    384             output.types_ts,
    385             Some(TsSource::CanonicalNativeSnapshot { .. })
    386         ));
    387         assert!(output.types_imports.is_empty());
    388 
    389         let types = output
    390             .files()
    391             .into_iter()
    392             .find(|file| file.relative_path == "src/generated/types.ts")
    393             .expect("types file");
    394 
    395         assert_eq!(types.contents, TRADE_BINDINGS_TYPES_TS);
    396     }
    397 
    398     #[test]
    399     fn event_output_uses_canonical_native_snapshot_and_matches_checked_in_types() {
    400         let output = package_outputs()
    401             .expect("package outputs")
    402             .into_iter()
    403             .find(|output| output.spec.key == "event")
    404             .expect("event output");
    405 
    406         assert!(matches!(
    407             output.types_ts,
    408             Some(TsSource::CanonicalNativeSnapshot { .. })
    409         ));
    410         let types = output
    411             .files()
    412             .into_iter()
    413             .find(|file| file.relative_path == "src/generated/types.ts")
    414             .expect("types file");
    415         assert_eq!(types.contents, super::EVENT_BINDINGS_TYPES_TS);
    416 
    417         let manifest = output.provenance_file();
    418         assert!(manifest.contents.contains("canonical_native_type_snapshot"));
    419         assert!(manifest.contents.contains("radroots_event"));
    420         assert!(manifest.contents.contains("radroots_core"));
    421     }
    422 
    423     #[test]
    424     fn replica_schema_output_uses_dto_registry_and_matches_checked_in_types() {
    425         let output = package_outputs()
    426             .expect("package outputs")
    427             .into_iter()
    428             .find(|output| output.spec.key == "replica_schema")
    429             .expect("replica_schema output");
    430 
    431         assert!(matches!(output.types_ts, Some(TsSource::DtoRegistry(_))));
    432         assert!(output.types_imports.is_empty());
    433 
    434         let types = output
    435             .files()
    436             .into_iter()
    437             .find(|file| file.relative_path == "src/generated/types.ts")
    438             .expect("types file");
    439 
    440         assert_eq!(types.contents, REPLICA_SCHEMA_BINDINGS_TYPES_TS);
    441     }
    442 
    443     #[test]
    444     fn events_constants_and_kinds_use_modules_and_match_checked_in_files() {
    445         let output = package_outputs()
    446             .expect("package outputs")
    447             .into_iter()
    448             .find(|output| output.spec.key == "event")
    449             .expect("event output");
    450 
    451         assert!(matches!(output.constants_ts, Some(TsSource::Module(_))));
    452         assert!(matches!(output.kinds_ts, Some(TsSource::Module(_))));
    453 
    454         let files = output.files();
    455         let constants = files
    456             .iter()
    457             .find(|file| file.relative_path == "src/generated/constants.ts")
    458             .expect("constants file");
    459         let kinds = files
    460             .iter()
    461             .find(|file| file.relative_path == "src/generated/kinds.ts")
    462             .expect("kinds file");
    463 
    464         assert_eq!(constants.contents, EVENT_BINDINGS_CONSTANTS_TS);
    465         assert_eq!(kinds.contents, EVENT_BINDINGS_KINDS_TS);
    466     }
    467 
    468     #[test]
    469     fn identity_constants_use_module_and_match_checked_in_file() {
    470         let output = package_outputs()
    471             .expect("package outputs")
    472             .into_iter()
    473             .find(|output| output.spec.key == "identity")
    474             .expect("identity output");
    475 
    476         assert!(matches!(output.constants_ts, Some(TsSource::Module(_))));
    477 
    478         let constants = output
    479             .files()
    480             .into_iter()
    481             .find(|file| file.relative_path == "src/generated/constants.ts")
    482             .expect("constants file");
    483 
    484         assert_eq!(constants.contents, IDENTITY_BINDINGS_CONSTANTS_TS);
    485     }
    486 
    487     fn test_module() -> TsSource {
    488         TsSource::Module(
    489             TypeScriptModule::new("src/generated/test.ts").with_declaration(
    490                 TypeScriptDeclaration::type_alias("A", TypeScriptType::String),
    491             ),
    492         )
    493     }
    494 }