sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

contracts.rs (13239B)


      1 use std::{
      2     collections::{BTreeMap, BTreeSet},
      3     fs,
      4     path::{Path, PathBuf},
      5 };
      6 
      7 use serde::Deserialize;
      8 
      9 #[derive(Debug, Deserialize)]
     10 #[serde(deny_unknown_fields)]
     11 struct ExportContract {
     12     language: LanguageContract,
     13     packages: BTreeMap<String, String>,
     14     artifacts: Option<ExportArtifacts>,
     15     runtime: RuntimeContract,
     16 }
     17 
     18 #[derive(Debug, Deserialize)]
     19 #[serde(deny_unknown_fields)]
     20 struct PackageContract {
     21     language: LanguageContract,
     22     sdk: SdkPackageContract,
     23     rollout: RolloutContract,
     24     operations: BTreeMap<String, String>,
     25     shared_types: BTreeMap<String, String>,
     26     artifacts: Option<SdkArtifacts>,
     27 }
     28 
     29 #[derive(Debug, Deserialize)]
     30 #[serde(deny_unknown_fields)]
     31 struct LanguageContract {
     32     id: String,
     33     repository: String,
     34 }
     35 
     36 #[derive(Debug, Deserialize)]
     37 #[serde(deny_unknown_fields)]
     38 struct RuntimeContract {
     39     networking: String,
     40     signing: String,
     41     deterministic_codec: String,
     42 }
     43 
     44 #[derive(Debug, Deserialize)]
     45 #[serde(deny_unknown_fields)]
     46 struct ExportArtifacts {
     47     models_dir: String,
     48     constants_dir: String,
     49     wasm_dist_dir: Option<String>,
     50     manifest_file: String,
     51 }
     52 
     53 #[derive(Debug, Deserialize)]
     54 #[serde(deny_unknown_fields)]
     55 struct SdkPackageContract {
     56     package: String,
     57     module_format: Option<String>,
     58     deterministic_codec: String,
     59     signing: String,
     60     networking: String,
     61 }
     62 
     63 #[derive(Debug, Deserialize)]
     64 #[serde(deny_unknown_fields)]
     65 struct RolloutContract {
     66     stage: String,
     67     order: u32,
     68 }
     69 
     70 #[derive(Debug, Deserialize)]
     71 #[serde(deny_unknown_fields)]
     72 struct SdkArtifacts {
     73     models_dir: String,
     74     runtime_dir: String,
     75     wasm_dist_dir: String,
     76     manifest_file: String,
     77 }
     78 
     79 pub fn validate_sdk_contracts(root: &Path) -> Result<(), String> {
     80     let exports = load_contract_dir::<ExportContract>(&root.join("contracts").join("exports"))?;
     81     let packages = load_contract_dir::<PackageContract>(&root.join("contracts").join("packages"))?;
     82     if exports.is_empty() {
     83         return Err("contracts/exports must define at least one language".to_owned());
     84     }
     85     if packages.is_empty() {
     86         return Err("contracts/packages must define at least one language".to_owned());
     87     }
     88 
     89     let mut export_packages = BTreeMap::new();
     90     let mut export_languages = BTreeSet::new();
     91     for export in &exports {
     92         validate_language(&export.language, "exports")?;
     93         validate_non_empty_map(&export.packages, "exports packages")?;
     94         validate_runtime(
     95             &export.runtime.networking,
     96             &export.runtime.signing,
     97             &export.runtime.deterministic_codec,
     98             &format!("exports {}", export.language.id),
     99         )?;
    100         let artifacts = export
    101             .artifacts
    102             .as_ref()
    103             .ok_or_else(|| format!("exports {} artifacts are required", export.language.id))?;
    104         validate_non_empty(&artifacts.models_dir, "exports artifacts.models_dir")?;
    105         validate_non_empty(&artifacts.constants_dir, "exports artifacts.constants_dir")?;
    106         validate_non_empty(&artifacts.manifest_file, "exports artifacts.manifest_file")?;
    107         if export.language.id == "ts" {
    108             validate_non_empty(
    109                 artifacts.wasm_dist_dir.as_deref().unwrap_or(""),
    110                 "exports ts artifacts.wasm_dist_dir",
    111             )?;
    112         }
    113         if !export_languages.insert(export.language.id.clone()) {
    114             return Err(format!("duplicate exports language {}", export.language.id));
    115         }
    116         let packages = export
    117             .packages
    118             .values()
    119             .cloned()
    120             .collect::<BTreeSet<String>>();
    121         if packages.len() != 1 {
    122             return Err(format!(
    123                 "exports {} must resolve to one curated package",
    124                 export.language.id
    125             ));
    126         }
    127         export_packages.insert(export.language.id.clone(), packages);
    128     }
    129 
    130     let mut package_languages = BTreeSet::new();
    131     let mut operation_keys: Option<BTreeSet<String>> = None;
    132     let mut shared_type_keys: Option<BTreeSet<String>> = None;
    133     let mut rollout_orders = BTreeMap::new();
    134     for package in &packages {
    135         validate_language(&package.language, "packages")?;
    136         validate_non_empty(&package.sdk.package, "packages sdk.package")?;
    137         validate_runtime(
    138             &package.sdk.networking,
    139             &package.sdk.signing,
    140             &package.sdk.deterministic_codec,
    141             &format!("packages {}", package.language.id),
    142         )?;
    143         if let Some(module_format) = package.sdk.module_format.as_deref() {
    144             validate_non_empty(module_format, "packages sdk.module_format")?;
    145         }
    146         validate_rollout(&package.language.id, &package.rollout)?;
    147         validate_non_empty_map(&package.operations, "packages operations")?;
    148         validate_non_empty_map(&package.shared_types, "packages shared_types")?;
    149         if package.language.id == "ts" {
    150             let artifacts = package
    151                 .artifacts
    152                 .as_ref()
    153                 .ok_or_else(|| "packages ts artifacts are required".to_owned())?;
    154             validate_non_empty(&artifacts.models_dir, "packages ts artifacts.models_dir")?;
    155             validate_non_empty(&artifacts.runtime_dir, "packages ts artifacts.runtime_dir")?;
    156             validate_non_empty(
    157                 &artifacts.wasm_dist_dir,
    158                 "packages ts artifacts.wasm_dist_dir",
    159             )?;
    160             validate_non_empty(
    161                 &artifacts.manifest_file,
    162                 "packages ts artifacts.manifest_file",
    163             )?;
    164         }
    165         if !package_languages.insert(package.language.id.clone()) {
    166             return Err(format!(
    167                 "duplicate packages language {}",
    168                 package.language.id
    169             ));
    170         }
    171         let Some(packages_for_language) = export_packages.get(&package.language.id) else {
    172             return Err(format!(
    173                 "packages {} is missing a matching export contract",
    174                 package.language.id
    175             ));
    176         };
    177         let expected = [package.sdk.package.clone()]
    178             .into_iter()
    179             .collect::<BTreeSet<_>>();
    180         if packages_for_language != &expected {
    181             return Err(format!(
    182                 "exports {} must resolve to package {}",
    183                 package.language.id, package.sdk.package
    184             ));
    185         }
    186         let current_operations = package.operations.keys().cloned().collect::<BTreeSet<_>>();
    187         match &operation_keys {
    188             Some(expected) if expected != &current_operations => {
    189                 return Err(format!(
    190                     "packages {} operations must match the shared operation set",
    191                     package.language.id
    192                 ));
    193             }
    194             None => operation_keys = Some(current_operations),
    195             _ => {}
    196         }
    197         let current_shared_types = package
    198             .shared_types
    199             .keys()
    200             .cloned()
    201             .collect::<BTreeSet<_>>();
    202         match &shared_type_keys {
    203             Some(expected) if expected != &current_shared_types => {
    204                 return Err(format!(
    205                     "packages {} shared_types must match the shared type set",
    206                     package.language.id
    207                 ));
    208             }
    209             None => shared_type_keys = Some(current_shared_types),
    210             _ => {}
    211         }
    212         rollout_orders.insert(package.language.id.clone(), package.rollout.order);
    213     }
    214 
    215     if export_languages != package_languages {
    216         return Err("contracts/exports and contracts/packages languages must match".to_owned());
    217     }
    218     if rollout_orders.get("ts") != Some(&1) {
    219         return Err("packages ts rollout.order must be 1".to_owned());
    220     }
    221     Ok(())
    222 }
    223 
    224 fn load_contract_dir<T>(dir: &Path) -> Result<Vec<T>, String>
    225 where
    226     T: for<'de> Deserialize<'de>,
    227 {
    228     let read_dir =
    229         fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?;
    230     let mut entries = read_dir
    231         .collect::<Result<Vec<_>, _>>()
    232         .map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?;
    233     entries.sort_by_key(|entry| entry.file_name());
    234     let mut contracts = Vec::new();
    235     for entry in entries {
    236         let path = entry.path();
    237         if path.extension().and_then(|extension| extension.to_str()) != Some("toml") {
    238             continue;
    239         }
    240         contracts.push(parse_toml(&path)?);
    241     }
    242     Ok(contracts)
    243 }
    244 
    245 fn parse_toml<T>(path: &PathBuf) -> Result<T, String>
    246 where
    247     T: for<'de> Deserialize<'de>,
    248 {
    249     let raw = fs::read_to_string(path)
    250         .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
    251     toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display()))
    252 }
    253 
    254 fn validate_language(language: &LanguageContract, family: &str) -> Result<(), String> {
    255     validate_non_empty(&language.id, &format!("{family} language.id"))?;
    256     validate_non_empty(
    257         &language.repository,
    258         &format!("{family} language.repository"),
    259     )
    260 }
    261 
    262 fn validate_runtime(
    263     networking: &str,
    264     signing: &str,
    265     deterministic_codec: &str,
    266     family: &str,
    267 ) -> Result<(), String> {
    268     validate_non_empty(networking, &format!("{family} networking"))?;
    269     validate_non_empty(signing, &format!("{family} signing"))?;
    270     validate_non_empty(
    271         deterministic_codec,
    272         &format!("{family} deterministic_codec"),
    273     )
    274 }
    275 
    276 fn validate_rollout(language: &str, rollout: &RolloutContract) -> Result<(), String> {
    277     validate_non_empty(&rollout.stage, "packages rollout.stage")?;
    278     if !matches!(rollout.stage.as_str(), "active" | "next" | "deferred") {
    279         return Err(format!("packages {language} rollout.stage is invalid"));
    280     }
    281     if rollout.order == 0 {
    282         return Err(format!(
    283             "packages {language} rollout.order must be greater than zero"
    284         ));
    285     }
    286     Ok(())
    287 }
    288 
    289 fn validate_non_empty(value: &str, field: &str) -> Result<(), String> {
    290     if value.trim().is_empty() || value.trim() != value {
    291         return Err(format!("{field} must be non-empty"));
    292     }
    293     Ok(())
    294 }
    295 
    296 fn validate_non_empty_map(map: &BTreeMap<String, String>, field: &str) -> Result<(), String> {
    297     if map.is_empty() {
    298         return Err(format!("{field} must not be empty"));
    299     }
    300     for (key, value) in map {
    301         validate_non_empty(key, field)?;
    302         validate_non_empty(value, field)?;
    303     }
    304     Ok(())
    305 }
    306 
    307 #[cfg(test)]
    308 mod tests {
    309     use std::{
    310         fs,
    311         time::{SystemTime, UNIX_EPOCH},
    312     };
    313 
    314     use super::validate_sdk_contracts;
    315 
    316     #[test]
    317     fn current_sdk_contracts_validate() {
    318         let root = crate::fs::workspace_root().expect("workspace root");
    319         validate_sdk_contracts(&root).expect("sdk contracts validate");
    320     }
    321 
    322     #[test]
    323     fn rejects_mismatched_language_sets() {
    324         let root = test_root("language_mismatch");
    325         write_contract(
    326             &root,
    327             "contracts/exports/ts.toml",
    328             EXPORT_TS.replace("@radroots/sdk", "@radroots/sdk").as_str(),
    329         );
    330         let error = validate_sdk_contracts(&root).expect_err("missing packages should fail");
    331         assert!(error.contains("contracts/packages"));
    332         let _ = fs::remove_dir_all(root);
    333     }
    334 
    335     #[test]
    336     fn rejects_package_export_mismatch() {
    337         let root = test_root("package_mismatch");
    338         write_contract(&root, "contracts/exports/ts.toml", EXPORT_TS);
    339         write_contract(
    340             &root,
    341             "contracts/packages/ts.toml",
    342             PACKAGE_TS
    343                 .replace("@radroots/sdk", "@radroots/other")
    344                 .as_str(),
    345         );
    346         let error = validate_sdk_contracts(&root).expect_err("mismatch should fail");
    347         assert!(error.contains("exports ts must resolve"));
    348         let _ = fs::remove_dir_all(root);
    349     }
    350 
    351     fn test_root(name: &str) -> std::path::PathBuf {
    352         let stamp = SystemTime::now()
    353             .duration_since(UNIX_EPOCH)
    354             .expect("time")
    355             .as_nanos();
    356         std::env::temp_dir().join(format!("radroots_sdk_contracts_{name}_{stamp}"))
    357     }
    358 
    359     fn write_contract(root: &std::path::Path, relative: &str, contents: &str) {
    360         let path = root.join(relative);
    361         fs::create_dir_all(path.parent().expect("parent")).expect("create parent");
    362         fs::write(path, contents).expect("write contract");
    363     }
    364 
    365     const EXPORT_TS: &str = r#"[language]
    366 id = "ts"
    367 repository = "sdk-typescript"
    368 
    369 [packages]
    370 "radroots_core" = "@radroots/sdk"
    371 
    372 [artifacts]
    373 models_dir = "src/generated"
    374 constants_dir = "src/generated"
    375 wasm_dist_dir = "dist"
    376 manifest_file = "export-manifest.json"
    377 
    378 [runtime]
    379 networking = "native"
    380 signing = "native"
    381 deterministic_codec = "wasm"
    382 "#;
    383 
    384     const PACKAGE_TS: &str = r#"[language]
    385 id = "ts"
    386 repository = "sdk-typescript"
    387 
    388 [sdk]
    389 package = "@radroots/sdk"
    390 module_format = "esm"
    391 deterministic_codec = "wasm"
    392 signing = "native"
    393 networking = "native"
    394 
    395 [rollout]
    396 stage = "active"
    397 order = 1
    398 
    399 [operations]
    400 "profile.build_draft" = "profile.buildDraft"
    401 
    402 [shared_types]
    403 "WireEventParts" = "WireEventParts"
    404 
    405 [artifacts]
    406 models_dir = "src/generated"
    407 runtime_dir = "src/runtime"
    408 wasm_dist_dir = "dist"
    409 manifest_file = "export-manifest.json"
    410 "#;
    411 }