lib

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

dto_roots.rs (32702B)


      1 #![cfg_attr(not(test), allow(dead_code))]
      2 
      3 use std::collections::{BTreeMap, BTreeSet};
      4 
      5 use dto_bindgen_backend_ts::{
      6     DtoRegistryRenderOptions, DtoTypesModule, TypeScriptDeclaration, TypeScriptModule,
      7     TypeScriptType, render_registry_types,
      8 };
      9 use dto_bindgen_core::{Registry, RootDescriptor, build_registry};
     10 
     11 #[derive(Clone, Copy, Debug)]
     12 pub struct DtoPackageRootSet {
     13     pub package_key: &'static str,
     14     roots: fn() -> Vec<RootDescriptor>,
     15 }
     16 
     17 impl DtoPackageRootSet {
     18     pub fn roots(&self) -> Vec<RootDescriptor> {
     19         (self.roots)()
     20     }
     21 
     22     pub fn registry(&self) -> Registry {
     23         build_registry(self.roots())
     24     }
     25 }
     26 
     27 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     28 pub struct ManualDescriptorFamily {
     29     pub package_key: &'static str,
     30     pub source_family: &'static str,
     31     pub reason: &'static str,
     32 }
     33 
     34 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     35 pub struct SdkLocalWrapperAllowance {
     36     pub package_key: &'static str,
     37     pub shape_family: &'static str,
     38     pub reason: &'static str,
     39 }
     40 
     41 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     42 struct DtoExternalOverride {
     43     target_type: &'static str,
     44     import_name: &'static str,
     45     from_package_key: &'static str,
     46     from: &'static str,
     47 }
     48 
     49 const CORE_BINDINGS_PACKAGE_KEY: &str = "core";
     50 const CORE_BINDINGS_PACKAGE_NAME: &str = "@radroots/core-bindings";
     51 const EVENT_BINDINGS_PACKAGE_KEY: &str = "event";
     52 const EVENT_BINDINGS_PACKAGE_NAME: &str = "@radroots/event-bindings";
     53 
     54 pub const DTO_PACKAGE_ROOTS: &[DtoPackageRootSet] = &[DtoPackageRootSet {
     55     package_key: "core",
     56     roots: core_roots,
     57 }];
     58 
     59 pub const MANUAL_DESCRIPTOR_FAMILIES: &[ManualDescriptorFamily] = &[
     60     ManualDescriptorFamily {
     61         package_key: "core",
     62         source_family: "decimal, currency, money, quantity, percent, quantity price, unit, and discount value families",
     63         reason: "custom serde, string-backed newtypes, aliases, and tagged enum wire forms require source-owned manual descriptors",
     64     },
     65     ManualDescriptorFamily {
     66         package_key: "event",
     67         source_family: "event timestamps, counters, and optional metadata fields",
     68         reason: "large integers and source-specific optional/null policy must be explicit",
     69     },
     70     ManualDescriptorFamily {
     71         package_key: "event",
     72         source_family: "GeoJSON coordinate arrays",
     73         reason: "fixed-size Rust arrays must preserve tuple semantics in TypeScript",
     74     },
     75     ManualDescriptorFamily {
     76         package_key: "trade",
     77         source_family: "trade listing roots and package projection count fields",
     78         reason: "core aliases, source-owned event imports, and count-family numeric policy require explicit descriptors",
     79     },
     80     ManualDescriptorFamily {
     81         package_key: "replica_schema",
     82         source_family: "untagged query wrappers and serde_json value fields",
     83         reason: "schema query shapes are generated and not all source fields map to derive-supported DTOs",
     84     },
     85 ];
     86 
     87 pub const SDK_LOCAL_WRAPPER_ALLOWANCES: &[SdkLocalWrapperAllowance] = &[
     88     SdkLocalWrapperAllowance {
     89         package_key: "core",
     90         shape_family: "Currency and Decimal package aliases",
     91         reason: "string-backed source descriptors require canonical named TypeScript package aliases",
     92     },
     93     SdkLocalWrapperAllowance {
     94         package_key: "replica_schema",
     95         shape_family: "generated query argument wrappers",
     96         reason: "schema operation inputs are generated package shapes rather than source-owned public DTO structs",
     97     },
     98     SdkLocalWrapperAllowance {
     99         package_key: "trade",
    100         shape_family: "marketplace, query, projection, sort, review, and backoffice DTO shapes",
    101         reason: "these are SDK package contract shapes layered over source-owned trade, events, and core DTOs",
    102     },
    103 ];
    104 
    105 const TRADE_REQUIRED_EXTERNAL_PACKAGE_IMPORTS: &[&str] =
    106     &[CORE_BINDINGS_PACKAGE_NAME, EVENT_BINDINGS_PACKAGE_NAME];
    107 const EVENT_BINDINGS_TYPES_TS: &str =
    108     include_str!("../../../../crates/event_bindings/tests/fixtures/generated_types.ts");
    109 
    110 pub fn package_root_set(package_key: &str) -> Option<&'static DtoPackageRootSet> {
    111     DTO_PACKAGE_ROOTS
    112         .iter()
    113         .find(|root_set| root_set.package_key == package_key)
    114 }
    115 
    116 pub fn core_types_module() -> Result<DtoTypesModule, String> {
    117     let root_set = package_root_set("core").ok_or_else(|| "missing core DTO roots".to_owned())?;
    118     let rendered =
    119         render_registry_types(&root_set.registry(), &DtoRegistryRenderOptions::default())?;
    120     Ok(with_type_aliases_sorted(
    121         rendered,
    122         [
    123             type_alias("Currency", TypeScriptType::String),
    124             type_alias("Decimal", TypeScriptType::String),
    125         ],
    126     ))
    127 }
    128 
    129 pub fn replica_schema_types_module() -> Result<DtoTypesModule, String> {
    130     render_registry_types(
    131         &radroots_replica_schema_bindings::dto_registry(),
    132         &DtoRegistryRenderOptions::default(),
    133     )
    134 }
    135 
    136 fn core_roots() -> Vec<RootDescriptor> {
    137     radroots_core_bindings::dto_roots()
    138 }
    139 
    140 fn generated_external_package_exports() -> Result<BTreeMap<&'static str, BTreeSet<String>>, String>
    141 {
    142     Ok(BTreeMap::from([
    143         (
    144             CORE_BINDINGS_PACKAGE_KEY,
    145             type_exports(core_types_module()?.body_ts()),
    146         ),
    147         (
    148             EVENT_BINDINGS_PACKAGE_KEY,
    149             type_exports(EVENT_BINDINGS_TYPES_TS),
    150         ),
    151     ]))
    152 }
    153 
    154 fn validate_external_override_target(
    155     override_target: DtoExternalOverride,
    156     package_exports: &BTreeMap<&'static str, BTreeSet<String>>,
    157 ) -> Result<(), String> {
    158     let exports = package_exports
    159         .get(override_target.from_package_key)
    160         .ok_or_else(|| {
    161             format!(
    162                 "external DTO override `{}` references unknown package key `{}`",
    163                 override_target.target_type, override_target.from_package_key
    164             )
    165         })?;
    166     if exports.contains(override_target.import_name) {
    167         return Ok(());
    168     }
    169     Err(format!(
    170         "external DTO override `{}` imports `{}` from `{}`, but package `{}` does not export it",
    171         override_target.target_type,
    172         override_target.import_name,
    173         override_target.from,
    174         override_target.from_package_key
    175     ))
    176 }
    177 
    178 fn validate_external_override_usage(
    179     module: &DtoTypesModule,
    180     overrides: &[DtoExternalOverride],
    181 ) -> Result<(), String> {
    182     let imports = imported_type_inventory(module.imports_ts().unwrap_or_default());
    183     let package_exports = generated_external_package_exports()?;
    184     for package_name in TRADE_REQUIRED_EXTERNAL_PACKAGE_IMPORTS {
    185         if !imports.contains_key(*package_name) {
    186             return Err(format!(
    187                 "expected generated DTO imports from `{package_name}` but none were emitted"
    188             ));
    189         }
    190     }
    191 
    192     for override_target in overrides {
    193         if type_exports(module.body_ts()).contains(override_target.target_type) {
    194             return Err(format!(
    195                 "external DTO override `{}` from `{}` was emitted locally instead of imported",
    196                 override_target.target_type, override_target.from
    197             ));
    198         }
    199         if imports
    200             .get(override_target.from)
    201             .is_some_and(|names| names.contains(override_target.import_name))
    202         {
    203             validate_external_override_target(*override_target, &package_exports)?;
    204         }
    205     }
    206     Ok(())
    207 }
    208 
    209 fn imported_type_inventory(imports_ts: &str) -> BTreeMap<String, BTreeSet<String>> {
    210     let mut imports = BTreeMap::new();
    211     let mut pending_names: Option<Vec<String>> = None;
    212 
    213     for line in imports_ts.lines() {
    214         let line = line.trim();
    215         if let Some(single) = line.strip_prefix("import type { ") {
    216             if let Some((name, from)) = single.split_once(" } from ") {
    217                 insert_import_names(&mut imports, from, name);
    218             }
    219         } else if line == "import type {" {
    220             pending_names = Some(Vec::new());
    221         } else if let Some(from) = line.strip_prefix("} from ") {
    222             if let Some(names) = pending_names.take() {
    223                 for name in names {
    224                     insert_import(&mut imports, from, &name);
    225                 }
    226             }
    227         } else if let Some(names) = pending_names.as_mut()
    228             && let Some(name) = line.strip_suffix(',')
    229         {
    230             names.push(name.to_owned());
    231         }
    232     }
    233 
    234     imports
    235 }
    236 
    237 fn with_additional_type_imports(
    238     module: DtoTypesModule,
    239     from: &str,
    240     names: impl IntoIterator<Item = &'static str>,
    241 ) -> DtoTypesModule {
    242     let mut imports = imported_type_inventory(module.imports_ts().unwrap_or_default());
    243     imports
    244         .entry(from.to_owned())
    245         .or_default()
    246         .extend(names.into_iter().map(str::to_owned));
    247     let imports_ts = imports
    248         .into_iter()
    249         .map(|(from, names)| {
    250             format!(
    251                 "import type {{ {} }} from \"{from}\";",
    252                 names.into_iter().collect::<Vec<_>>().join(", ")
    253             )
    254         })
    255         .collect::<Vec<_>>()
    256         .join("\n");
    257     let imports_ts = if imports_ts.is_empty() {
    258         imports_ts
    259     } else {
    260         format!("{imports_ts}\n\n")
    261     };
    262     DtoTypesModule::new(imports_ts, module.body_ts())
    263 }
    264 
    265 fn insert_import_names(imports: &mut BTreeMap<String, BTreeSet<String>>, from: &str, names: &str) {
    266     for name in names.split(',') {
    267         insert_import(imports, from, name);
    268     }
    269 }
    270 
    271 fn insert_import(imports: &mut BTreeMap<String, BTreeSet<String>>, from: &str, name: &str) {
    272     let from = from.trim_end_matches(';').trim_matches('"');
    273     imports
    274         .entry(from.to_owned())
    275         .or_default()
    276         .insert(name.trim().to_owned());
    277 }
    278 
    279 fn type_exports(types_ts: &str) -> BTreeSet<String> {
    280     types_ts
    281         .lines()
    282         .filter_map(|line| line.strip_prefix("export type "))
    283         .filter_map(|rest| rest.split([' ', '<']).next())
    284         .map(str::to_owned)
    285         .collect()
    286 }
    287 
    288 const fn event_override(export_name: &'static str) -> DtoExternalOverride {
    289     DtoExternalOverride {
    290         target_type: export_name,
    291         import_name: export_name,
    292         from_package_key: EVENT_BINDINGS_PACKAGE_KEY,
    293         from: EVENT_BINDINGS_PACKAGE_NAME,
    294     }
    295 }
    296 
    297 fn declaration_name(declaration: &str) -> &str {
    298     declaration
    299         .strip_prefix("export type ")
    300         .and_then(|rest| rest.split([' ', '<']).next())
    301         .unwrap_or(declaration)
    302 }
    303 
    304 fn with_type_aliases_sorted(
    305     module: DtoTypesModule,
    306     aliases: impl IntoIterator<Item = String>,
    307 ) -> DtoTypesModule {
    308     let mut declarations = module
    309         .body_ts()
    310         .split("\n\n")
    311         .filter(|declaration| !declaration.trim().is_empty())
    312         .map(str::to_owned)
    313         .collect::<Vec<_>>();
    314     declarations.extend(aliases);
    315     declarations.sort_by(|left, right| declaration_name(left).cmp(declaration_name(right)));
    316     DtoTypesModule::new(
    317         module.imports_ts().unwrap_or_default(),
    318         declarations.join("\n\n"),
    319     )
    320 }
    321 
    322 fn type_alias(name: impl Into<String>, type_expr: TypeScriptType) -> String {
    323     TypeScriptModule::new("types.ts")
    324         .with_declaration(TypeScriptDeclaration::type_alias(name, type_expr))
    325         .render_source()
    326         .trim()
    327         .to_owned()
    328 }
    329 
    330 #[cfg(test)]
    331 mod tests {
    332     use std::collections::{BTreeMap, BTreeSet};
    333 
    334     use super::{
    335         CORE_BINDINGS_PACKAGE_KEY, CORE_BINDINGS_PACKAGE_NAME, DTO_PACKAGE_ROOTS,
    336         DtoExternalOverride, MANUAL_DESCRIPTOR_FAMILIES, SDK_LOCAL_WRAPPER_ALLOWANCES,
    337         event_override, imported_type_inventory, package_root_set, type_exports,
    338         validate_external_override_target, validate_external_override_usage,
    339         with_additional_type_imports,
    340     };
    341     use dto_bindgen_backend_ts::DtoTypesModule;
    342 
    343     const CORE_BINDINGS_TYPES_TS: &str =
    344         include_str!("../../../../crates/core_bindings/tests/fixtures/generated_types.ts");
    345     const EVENT_BINDINGS_TYPES_TS: &str =
    346         include_str!("../../../../crates/event_bindings/tests/fixtures/generated_types.ts");
    347     const REPLICA_SCHEMA_BINDINGS_TYPES_TS: &str = include_str!(
    348         "../../../../crates/replica_schema_bindings/tests/fixtures/generated_types.ts"
    349     );
    350     const TRADE_BINDINGS_TYPES_TS: &str =
    351         include_str!("../../../../crates/trade_bindings/tests/fixtures/generated_types.ts");
    352     const REPLICA_SCHEMA_MODEL_SOURCES: &[&str] = &[
    353         include_str!("../../../../crates/replica_schema/src/models/farm.rs"),
    354         include_str!("../../../../crates/replica_schema/src/models/farm_gcs_location.rs"),
    355         include_str!("../../../../crates/replica_schema/src/models/farm_member.rs"),
    356         include_str!("../../../../crates/replica_schema/src/models/farm_member_claim.rs"),
    357         include_str!("../../../../crates/replica_schema/src/models/farm_tag.rs"),
    358         include_str!("../../../../crates/replica_schema/src/models/gcs_location.rs"),
    359         include_str!("../../../../crates/replica_schema/src/models/log_error.rs"),
    360         include_str!("../../../../crates/replica_schema/src/models/media_image.rs"),
    361         include_str!("../../../../crates/replica_schema/src/models/nostr_event_head.rs"),
    362         include_str!("../../../../crates/replica_schema/src/models/nostr_profile.rs"),
    363         include_str!("../../../../crates/replica_schema/src/models/nostr_profile_relay.rs"),
    364         include_str!("../../../../crates/replica_schema/src/models/nostr_relay.rs"),
    365         include_str!("../../../../crates/replica_schema/src/models/plot.rs"),
    366         include_str!("../../../../crates/replica_schema/src/models/plot_gcs_location.rs"),
    367         include_str!("../../../../crates/replica_schema/src/models/plot_tag.rs"),
    368         include_str!("../../../../crates/replica_schema/src/models/trade_product.rs"),
    369         include_str!("../../../../crates/replica_schema/src/models/trade_product_location.rs"),
    370         include_str!("../../../../crates/replica_schema/src/models/trade_product_media.rs"),
    371     ];
    372     const CORE_TYPE_INVENTORY: &[&str] = &[
    373         "Currency",
    374         "Decimal",
    375         "Discount",
    376         "DiscountScope",
    377         "DiscountThreshold",
    378         "DiscountValue",
    379         "Money",
    380         "Percent",
    381         "Quantity",
    382         "QuantityPrice",
    383         "Unit",
    384         "UnitDimension",
    385     ];
    386     const EVENT_TYPE_INVENTORY: &[&str] = &[
    387         "JobFeedbackStatus",
    388         "JobInputType",
    389         "JobPaymentRequest",
    390         "RadrootsAccountClaim",
    391         "RadrootsAddressableRef",
    392         "RadrootsAppData",
    393         "RadrootsCommercialDomain",
    394         "RadrootsContributionAttestation",
    395         "RadrootsCoop",
    396         "RadrootsCoopLocation",
    397         "RadrootsCoopRef",
    398         "RadrootsDocument",
    399         "RadrootsDocumentSubject",
    400         "RadrootsEventEnvelopeDto",
    401         "RadrootsEventPtr",
    402         "RadrootsEventRef",
    403         "RadrootsEvidenceBounty",
    404         "RadrootsFarm",
    405         "RadrootsFarmPublicLocation",
    406         "RadrootsFarmRef",
    407         "RadrootsFollow",
    408         "RadrootsFollowProfile",
    409         "RadrootsGcsLocation",
    410         "RadrootsGeoChat",
    411         "RadrootsGeoJsonPoint",
    412         "RadrootsGeoJsonPolygon",
    413         "RadrootsGiftWrap",
    414         "RadrootsGiftWrapRecipient",
    415         "RadrootsJobFeedback",
    416         "RadrootsJobInput",
    417         "RadrootsJobParam",
    418         "RadrootsJobRequest",
    419         "RadrootsJobResult",
    420         "RadrootsKnowledgeChangeProposal",
    421         "RadrootsKnowledgeCitationSpan",
    422         "RadrootsKnowledgeClaim",
    423         "RadrootsKnowledgeFieldContext",
    424         "RadrootsKnowledgeFieldReport",
    425         "RadrootsKnowledgeLocation",
    426         "RadrootsKnowledgeLocationPrecision",
    427         "RadrootsKnowledgeNodeRef",
    428         "RadrootsKnowledgeObservation",
    429         "RadrootsKnowledgeObservationValue",
    430         "RadrootsKnowledgeRelation",
    431         "RadrootsKnowledgeReview",
    432         "RadrootsKnowledgeReviewScope",
    433         "RadrootsKnowledgeReviewScore",
    434         "RadrootsKnowledgeReviewTarget",
    435         "RadrootsKnowledgeSource",
    436         "RadrootsList",
    437         "RadrootsListEntry",
    438         "RadrootsListSet",
    439         "RadrootsMessage",
    440         "RadrootsMessageFile",
    441         "RadrootsMessageFileDimensions",
    442         "RadrootsMessageRecipient",
    443         "RadrootsNip01EventWireDto",
    444         "RadrootsOperationalListing",
    445         "RadrootsOperationalListingAvailability",
    446         "RadrootsOperationalListingBin",
    447         "RadrootsOperationalListingDeliveryMethod",
    448         "RadrootsOperationalListingImage",
    449         "RadrootsOperationalListingImageSize",
    450         "RadrootsOperationalListingParseError",
    451         "RadrootsOperationalListingProduct",
    452         "RadrootsOperationalListingProductTagKeys",
    453         "RadrootsOperationalListingPublicLocation",
    454         "RadrootsOperationalListingStatus",
    455         "RadrootsOperationalListingValidationError",
    456         "RadrootsPlot",
    457         "RadrootsPlotLocation",
    458         "RadrootsPlotRef",
    459         "RadrootsPost",
    460         "RadrootsProfileType",
    461         "RadrootsReaction",
    462         "RadrootsRelayDocument",
    463         "RadrootsResourceArea",
    464         "RadrootsResourceAreaLocation",
    465         "RadrootsResourceAreaRef",
    466         "RadrootsResourceHarvestCap",
    467         "RadrootsResourceHarvestProduct",
    468         "RadrootsRightsAssertion",
    469         "RadrootsSeal",
    470         "RadrootsSignedEventDto",
    471         "RadrootsSignedEventVerificationStateDto",
    472         "RadrootsSocialFarmAnchor",
    473         "RadrootsSocialLocation",
    474         "RadrootsSocialMediaDimensions",
    475         "RadrootsSocialMediaMetadata",
    476         "RadrootsSocialMediaThumbnail",
    477         "RadrootsSocialTarget",
    478         "RadrootsVerifiedSignedEventDto",
    479         "RadrootsVerifiedSignedEventVerificationStateDto",
    480         "RadrootsWikiArticle",
    481         "RadrootsWikiArticleVersionRef",
    482         "RadrootsWikiMergeRequest",
    483         "RadrootsWikiRedirect",
    484     ];
    485     const TRADE_TYPE_INVENTORY: &[&str] = &[
    486         "RadrootsOperationalListingSubtotal",
    487         "RadrootsOperationalListingTotal",
    488         "RadrootsOperationalListingTradeProjection",
    489         "RadrootsTradeAgreementClaimV1",
    490         "RadrootsTradeAgreementStateV1",
    491         "RadrootsTradeAttestationRecordV1",
    492         "RadrootsTradeAttestationResultV1",
    493         "RadrootsTradeAttestationStateV1",
    494         "RadrootsTradeConflictStateV1",
    495         "RadrootsTradeEvidenceStateV1",
    496         "RadrootsTradeEvidenceCoverageV1",
    497         "RadrootsTradeEvidenceOutcomeV1",
    498         "RadrootsTradeEvidenceManifestV1",
    499         "RadrootsRhiEvidenceSupersessionV1",
    500         "RadrootsRhiEvidenceReportV1",
    501         "RadrootsRhiEvidenceAttestationV1",
    502         "RadrootsTypedEvidenceEventPlanV1",
    503         "RadrootsTradeFacetCount",
    504         "RadrootsTradeFulfillmentStateV1",
    505         "RadrootsTradeListingBackofficeOverlay",
    506         "RadrootsTradeListingBackofficeQuery",
    507         "RadrootsTradeListingBackofficeView",
    508         "RadrootsTradeListingBinProjection",
    509         "RadrootsTradeListingFacets",
    510         "RadrootsTradeListingMarketStatus",
    511         "RadrootsTradeListingProjection",
    512         "RadrootsTradeListingQuery",
    513         "RadrootsTradeListingSort",
    514         "RadrootsTradeListingSortField",
    515         "RadrootsTradeMarketplaceListingSummary",
    516         "RadrootsTradeModerationFlag",
    517         "RadrootsTradeModerationSeverity",
    518         "RadrootsTradeModerationStatus",
    519         "RadrootsTradeNegotiationStateV1",
    520         "RadrootsTradePaymentStateV1",
    521         "RadrootsTradePrivateTermsStateV1",
    522         "RadrootsTradeProjectionV1",
    523         "RadrootsTradeReducerIssueV1",
    524         "RadrootsTradeReviewPriority",
    525         "RadrootsTradeReviewQueueEntry",
    526         "RadrootsTradeReviewStatus",
    527         "RadrootsTradeSortDirection",
    528     ];
    529 
    530     #[test]
    531     fn approved_source_roots_build_registries() {
    532         for root_set in DTO_PACKAGE_ROOTS {
    533             let registry = root_set.registry();
    534             assert!(
    535                 !registry.has_errors(),
    536                 "registry for {} has diagnostics: {:?}",
    537                 root_set.package_key,
    538                 registry.diagnostics
    539             );
    540             assert!(!registry.roots.is_empty());
    541         }
    542     }
    543 
    544     #[test]
    545     fn package_roots_are_explicit_not_discovered() {
    546         assert!(package_root_set("core").is_some());
    547         assert!(package_root_set("event").is_none());
    548         assert!(package_root_set("trade").is_none());
    549     }
    550 
    551     #[test]
    552     fn manual_descriptor_catalog_covers_known_review_families() {
    553         assert!(
    554             MANUAL_DESCRIPTOR_FAMILIES
    555                 .iter()
    556                 .any(|family| family.source_family.contains("GeoJSON"))
    557         );
    558         assert!(SDK_LOCAL_WRAPPER_ALLOWANCES.iter().any(|allowance| {
    559             allowance
    560                 .shape_family
    561                 .contains("marketplace, query, projection")
    562         }));
    563     }
    564 
    565     #[test]
    566     fn external_override_target_validation_rejects_missing_generated_export() {
    567         let package_exports = BTreeMap::from([(
    568             CORE_BINDINGS_PACKAGE_KEY,
    569             BTreeSet::from(["ExistingType".to_owned()]),
    570         )]);
    571         let error = validate_external_override_target(
    572             DtoExternalOverride {
    573                 target_type: "MissingType",
    574                 import_name: "MissingType",
    575                 from_package_key: CORE_BINDINGS_PACKAGE_KEY,
    576                 from: CORE_BINDINGS_PACKAGE_NAME,
    577             },
    578             &package_exports,
    579         )
    580         .expect_err("missing target package export must fail");
    581 
    582         assert_eq!(
    583             error,
    584             "external DTO override `MissingType` imports `MissingType` from `@radroots/core-bindings`, but package `core` does not export it"
    585         );
    586     }
    587 
    588     #[test]
    589     fn external_override_usage_rejects_absent_required_package_import() {
    590         let module = DtoTypesModule::new("", "export type RadrootsTradeListing = string;");
    591         let error = validate_external_override_usage(&module, &[])
    592             .expect_err("missing required package import must fail");
    593 
    594         assert_eq!(
    595             error,
    596             "expected generated DTO imports from `@radroots/core-bindings` but none were emitted"
    597         );
    598     }
    599 
    600     #[test]
    601     fn external_override_usage_rejects_local_emission_for_imported_type() {
    602         let module = DtoTypesModule::new(
    603             "import type { Decimal } from \"@radroots/core-bindings\";\n\nimport type { RadrootsFarmRef } from \"@radroots/event-bindings\";\n\n",
    604             "export type RadrootsFarmRef = { pubkey: string, d_tag: string, };",
    605         );
    606         let error = validate_external_override_usage(&module, &[event_override("RadrootsFarmRef")])
    607             .expect_err("local emission of imported type must fail");
    608 
    609         assert_eq!(
    610             error,
    611             "external DTO override `RadrootsFarmRef` from `@radroots/event-bindings` was emitted locally instead of imported"
    612         );
    613     }
    614 
    615     #[test]
    616     fn generated_import_inventory_parses_single_and_multiline_imports() {
    617         let imports = imported_type_inventory(
    618             "import type { One, Four } from \"@radroots/one\";\nimport type {\n    Two,\n    Three,\n} from \"@radroots/many\";\n\n",
    619         );
    620 
    621         assert_eq!(
    622             imports.get("@radroots/one").expect("single import package"),
    623             &BTreeSet::from(["Four".to_owned(), "One".to_owned()])
    624         );
    625         assert_eq!(
    626             imports.get("@radroots/many").expect("multi import package"),
    627             &BTreeSet::from(["Three".to_owned(), "Two".to_owned()])
    628         );
    629     }
    630 
    631     #[test]
    632     fn additional_type_imports_merge_with_generated_package_imports() {
    633         let module = DtoTypesModule::new(
    634             "import type { Decimal, Money } from \"@radroots/core-bindings\";\n\n",
    635             "export type Listing = { discounts: Array<Discount>, };",
    636         );
    637         let merged = with_additional_type_imports(module, CORE_BINDINGS_PACKAGE_NAME, ["Discount"]);
    638 
    639         assert_eq!(
    640             merged.imports_ts(),
    641             Some("import type { Decimal, Discount, Money } from \"@radroots/core-bindings\";\n\n")
    642         );
    643     }
    644 
    645     #[test]
    646     fn generated_type_export_inventory_parses_type_aliases() {
    647         assert_eq!(
    648             type_exports("export type Alpha = string;\nexport type Beta<T> = T;\n"),
    649             BTreeSet::from(["Alpha".to_owned(), "Beta".to_owned()])
    650         );
    651     }
    652 
    653     #[test]
    654     fn core_type_inventory_matches_current_package_surface() {
    655         let actual = type_inventory(CORE_BINDINGS_TYPES_TS);
    656 
    657         assert_eq!(actual, CORE_TYPE_INVENTORY);
    658         assert!(!CORE_BINDINGS_TYPES_TS.contains("RadrootsCore"));
    659     }
    660 
    661     #[test]
    662     fn event_type_inventory_matches_current_package_surface() {
    663         let actual = type_inventory(EVENT_BINDINGS_TYPES_TS);
    664 
    665         assert_eq!(actual, EVENT_TYPE_INVENTORY);
    666         assert!(!EVENT_BINDINGS_TYPES_TS.contains("RadrootsOrder"));
    667     }
    668 
    669     #[test]
    670     fn event_generated_types_expose_current_signed_event_dto_shapes() {
    671         let wire = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsNip01EventWireDto");
    672         let envelope = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsEventEnvelopeDto");
    673         let signed = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsSignedEventDto");
    674         let verified = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsVerifiedSignedEventDto");
    675 
    676         assert!(wire.contains("pubkey: string"));
    677         assert!(wire.contains("extra: { [key: string]: unknown }"));
    678         assert!(envelope.contains("author: string"));
    679         assert!(!envelope.contains("pubkey"));
    680         assert!(!envelope.contains("extra"));
    681         assert!(signed.contains("state: RadrootsSignedEventVerificationStateDto"));
    682         assert!(signed.contains("envelope: RadrootsEventEnvelopeDto"));
    683         assert!(signed.contains("wire: RadrootsNip01EventWireDto"));
    684         assert!(signed.contains("raw_json: string"));
    685         assert!(verified.contains("state: RadrootsVerifiedSignedEventVerificationStateDto"));
    686         assert!(verified.contains("signed_event: RadrootsSignedEventDto"));
    687         assert!(
    688             !EVENT_BINDINGS_TYPES_TS.contains("export type RadrootsEventEnvelope ="),
    689             "event package must not export the retired raw envelope type name"
    690         );
    691     }
    692 
    693     #[test]
    694     fn trade_type_inventory_matches_current_package_surface() {
    695         let actual = type_inventory(TRADE_BINDINGS_TYPES_TS);
    696 
    697         assert_eq!(actual, TRADE_TYPE_INVENTORY);
    698     }
    699 
    700     #[test]
    701     fn replica_schema_generated_types_preserve_source_schema_contracts() {
    702         let actual = type_inventory(REPLICA_SCHEMA_BINDINGS_TYPES_TS);
    703         let trade_product_filter = type_declaration(
    704             REPLICA_SCHEMA_BINDINGS_TYPES_TS,
    705             "ITradeProductFieldsFilter",
    706         );
    707         let trade_product_partial = type_declaration(
    708             REPLICA_SCHEMA_BINDINGS_TYPES_TS,
    709             "ITradeProductFieldsPartial",
    710         );
    711 
    712         assert!(actual.contains(&"Farm"));
    713         assert!(actual.contains(&"GcsLocation"));
    714         assert!(actual.contains(&"NostrEventHead"));
    715         assert!(actual.contains(&"ReplicaStoreJsonValue"));
    716         assert!(actual.contains(&"ITradeProductFieldsPartial"));
    717         assert!(!actual.contains(&"NostrEventState"));
    718         assert!(REPLICA_SCHEMA_BINDINGS_TYPES_TS.contains(
    719             "export type ReplicaStoreJsonValue = null | boolean | number | string | Array<ReplicaStoreJsonValue> | { [key: string]: ReplicaStoreJsonValue };"
    720         ));
    721         assert!(
    722             REPLICA_SCHEMA_BINDINGS_TYPES_TS
    723                 .contains("export type IFarmFindOneResolve = ReplicaSchemaResult<Farm | null>;")
    724         );
    725         assert!(actual.contains(&"ReplicaSchemaResult"));
    726         assert!(actual.contains(&"ReplicaSchemaResultList"));
    727         assert!(actual.contains(&"ReplicaSchemaResultPass"));
    728         assert!(actual.contains(&"ReplicaSchemaError"));
    729         assert!(trade_product_filter.contains("year?: bigint"));
    730         assert!(trade_product_filter.contains("qty_avail?: bigint"));
    731         assert!(trade_product_partial.contains("year?: ReplicaStoreJsonValue | null"));
    732         assert!(trade_product_partial.contains("qty_avail?: ReplicaStoreJsonValue | null"));
    733     }
    734 
    735     #[test]
    736     fn replica_schema_generated_types_match_source_public_inventory() {
    737         let actual = type_inventory(REPLICA_SCHEMA_BINDINGS_TYPES_TS)
    738             .into_iter()
    739             .collect::<BTreeSet<_>>();
    740         let missing = source_public_schema_type_inventory()
    741             .into_iter()
    742             .filter(|name| !actual.contains(name))
    743             .collect::<Vec<_>>();
    744 
    745         assert!(
    746             missing.is_empty(),
    747             "missing generated replica schema exports: {missing:?}"
    748         );
    749     }
    750 
    751     #[test]
    752     fn trade_package_imports_source_owned_support_types() {
    753         assert!(TRADE_BINDINGS_TYPES_TS.contains("from \"@radroots/core-bindings\""));
    754         assert!(TRADE_BINDINGS_TYPES_TS.contains("from \"@radroots/event-bindings\""));
    755 
    756         let imports = imported_type_inventory(TRADE_BINDINGS_TYPES_TS);
    757         assert!(
    758             imports
    759                 .get("@radroots/event-bindings")
    760                 .is_some_and(|names| names.contains("RadrootsFarmRef"))
    761         );
    762         assert!(
    763             imports
    764                 .get("@radroots/event-bindings")
    765                 .is_some_and(|names| names.contains("RadrootsOperationalListing"))
    766         );
    767         assert!(
    768             imports
    769                 .get("@radroots/event-bindings")
    770                 .is_some_and(|names| names.contains("RadrootsOperationalListingBin"))
    771         );
    772 
    773         for duplicate in [
    774             "export type RadrootsOperationalListing = ",
    775             "export type RadrootsFarmRef = ",
    776             "export type RadrootsOrderEconomics = ",
    777             "export type RadrootsOrderInventoryCommitment = ",
    778             "export type RadrootsCommercialMessagePayload = ",
    779             "export type RadrootsCommercialMessageType = ",
    780             "export type RadrootsOrderStatus = ",
    781             "export type RadrootsTradeOrderWorkflowMessage = ",
    782             "export type RadrootsOrderWorkflowProjection = ",
    783             "export type RadrootsTradeMarketplaceOrderSummary = ",
    784             "export type RadrootsTradeOrderQuery = ",
    785         ] {
    786             assert!(!TRADE_BINDINGS_TYPES_TS.contains(duplicate));
    787         }
    788 
    789         let projection = type_declaration(TRADE_BINDINGS_TYPES_TS, "RadrootsTradeProjectionV1");
    790         assert!(projection.contains("agreement_state: RadrootsTradeAgreementStateV1"));
    791         assert!(projection.contains("negotiation_state: RadrootsTradeNegotiationStateV1"));
    792         assert!(projection.contains("agreement_claims: Array<RadrootsTradeAgreementClaimV1>"));
    793         assert!(projection.contains("issues: Array<RadrootsTradeReducerIssueV1>"));
    794         assert!(projection.contains("projection_digest: string"));
    795         for stale_counter in [
    796             "root_event_id",
    797             "last_message_type",
    798             "last_discount_request",
    799             "last_discount_offer",
    800             "accepted_discount",
    801             "last_discount_decline_reason",
    802             "has_requested_discounts",
    803             "question_count",
    804             "answer_count",
    805             "discount_request_count",
    806             "discount_offer_count",
    807             "discount_accept_count",
    808             "discount_decline_count",
    809         ] {
    810             assert!(!projection.contains(stale_counter));
    811         }
    812     }
    813 
    814     fn type_inventory(types_ts: &str) -> Vec<&str> {
    815         types_ts
    816             .lines()
    817             .filter_map(|line| line.strip_prefix("export type "))
    818             .map(|rest| rest.split([' ', '<']).next().expect("type name"))
    819             .collect()
    820     }
    821 
    822     fn source_public_schema_type_inventory() -> Vec<&'static str> {
    823         let mut names = BTreeSet::new();
    824 
    825         for source in REPLICA_SCHEMA_MODEL_SOURCES {
    826             for line in source.lines() {
    827                 if let Some(name) = public_rust_type_name(line)
    828                     && !name.ends_with("Ts")
    829                 {
    830                     names.insert(name);
    831                 }
    832             }
    833         }
    834 
    835         names.into_iter().collect()
    836     }
    837 
    838     fn public_rust_type_name(line: &'static str) -> Option<&'static str> {
    839         let line = line.trim_start();
    840 
    841         ["pub struct ", "pub enum ", "pub type "]
    842             .into_iter()
    843             .find_map(|prefix| {
    844                 line.strip_prefix(prefix).map(|rest| {
    845                     rest.split(|char: char| !(char == '_' || char.is_ascii_alphanumeric()))
    846                         .next()
    847                         .expect("type name")
    848                 })
    849             })
    850     }
    851 
    852     fn type_declaration<'a>(types_ts: &'a str, name: &str) -> &'a str {
    853         types_ts
    854             .lines()
    855             .find(|line| line.starts_with(&format!("export type {name} = ")))
    856             .unwrap_or_else(|| panic!("missing type declaration for {name}"))
    857     }
    858 }