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 }