lib

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

core_contract.rs (21477B)


      1 use std::{
      2     collections::{BTreeMap, BTreeSet},
      3     fs,
      4     path::{Path, PathBuf},
      5 };
      6 
      7 use syn::{Item, UseTree, Visibility};
      8 
      9 const ARCHITECTURE_RELATIVE: &str = "contracts/crates/release_v1/radroots_crates_release_v1.toml";
     10 const CORE_MANIFEST_RELATIVE: &str = "crates/core/Cargo.toml";
     11 const CORE_LIB_RELATIVE: &str = "crates/core/src/lib.rs";
     12 
     13 const CORE_RUNTIME_DEPENDENCIES: [&str; 2] = ["rust_decimal", "serde"];
     14 const CORE_DEV_DEPENDENCIES: [&str; 3] = ["dto_bindgen", "rust_decimal", "serde_json"];
     15 const CORE_ROOT_EXPORTS: [(&str, &str); 8] = [
     16     ("Currency", "currency::Currency"),
     17     ("Decimal", "decimal::Decimal"),
     18     ("Error", "error::Error"),
     19     ("Money", "money::Money"),
     20     ("Percent", "percent::Percent"),
     21     ("Quantity", "quantity::Quantity"),
     22     ("QuantityPrice", "pricing::QuantityPrice"),
     23     ("Unit", "unit::Unit"),
     24 ];
     25 pub(super) fn validate(workspace_root: &Path) -> Result<(), String> {
     26     let architecture = read_toml(workspace_root, ARCHITECTURE_RELATIVE)?;
     27     let core_spec = architecture
     28         .get("package")
     29         .and_then(toml::Value::as_array)
     30         .and_then(|packages| {
     31             packages.iter().find(|package| {
     32                 package.get("name").and_then(toml::Value::as_str) == Some("radroots_core")
     33             })
     34         })
     35         .and_then(toml::Value::as_table)
     36         .ok_or_else(|| format!("{ARCHITECTURE_RELATIVE} is missing radroots_core"))?;
     37 
     38     let manifest = read_toml(workspace_root, CORE_MANIFEST_RELATIVE)?;
     39     validate_manifest(core_spec, &manifest)?;
     40     validate_crate_root(workspace_root, core_spec)
     41 }
     42 
     43 fn validate_manifest(core_spec: &toml::value::Table, manifest: &toml::Value) -> Result<(), String> {
     44     let manifest = manifest
     45         .as_table()
     46         .ok_or_else(|| format!("{CORE_MANIFEST_RELATIVE} must be a TOML table"))?;
     47     let package = manifest
     48         .get("package")
     49         .and_then(toml::Value::as_table)
     50         .ok_or_else(|| format!("{CORE_MANIFEST_RELATIVE} is missing [package]"))?;
     51     if package.get("name").and_then(toml::Value::as_str) != Some("radroots_core") {
     52         return Err(format!(
     53             "{CORE_MANIFEST_RELATIVE} package.name must be radroots_core"
     54         ));
     55     }
     56     let library = manifest
     57         .get("lib")
     58         .and_then(toml::Value::as_table)
     59         .ok_or_else(|| format!("{CORE_MANIFEST_RELATIVE} is missing [lib]"))?;
     60     if library.get("name").and_then(toml::Value::as_str) != Some("radroots_core")
     61         || library.keys().map(String::as_str).collect::<BTreeSet<_>>() != BTreeSet::from(["name"])
     62     {
     63         return Err(format!(
     64             "{CORE_MANIFEST_RELATIVE} must use the conventional radroots_core library target"
     65         ));
     66     }
     67 
     68     let features = manifest
     69         .get("features")
     70         .and_then(toml::Value::as_table)
     71         .ok_or_else(|| format!("{CORE_MANIFEST_RELATIVE} is missing [features]"))?;
     72     let expected_features = spec_strings(core_spec, "features")?;
     73     let actual_features = features
     74         .keys()
     75         .filter(|name| name.as_str() != "default")
     76         .cloned()
     77         .collect::<BTreeSet<_>>();
     78     if actual_features != expected_features {
     79         return Err(format!(
     80             "radroots_core public features must be exactly {expected_features:?}, found {actual_features:?}"
     81         ));
     82     }
     83     let expected_default = spec_strings(core_spec, "default_features")?;
     84     let actual_default = value_strings(
     85         features.get("default"),
     86         &format!("{CORE_MANIFEST_RELATIVE} features.default"),
     87     )?;
     88     if actual_default != expected_default {
     89         return Err(format!(
     90             "radroots_core default features must be exactly {expected_default:?}, found {actual_default:?}"
     91         ));
     92     }
     93     let std_enables = value_strings(
     94         features.get("std"),
     95         &format!("{CORE_MANIFEST_RELATIVE} features.std"),
     96     )?;
     97     if !std_enables.is_empty() {
     98         return Err("radroots_core std must remain an empty additive marker".to_owned());
     99     }
    100     let serde_enables = value_strings(
    101         features.get("serde"),
    102         &format!("{CORE_MANIFEST_RELATIVE} features.serde"),
    103     )?;
    104     let expected_serde = BTreeSet::from(["dep:serde".to_owned(), "rust_decimal/serde".to_owned()]);
    105     if serde_enables != expected_serde {
    106         return Err(format!(
    107             "radroots_core serde feature must enable {expected_serde:?}, found {serde_enables:?}"
    108         ));
    109     }
    110 
    111     validate_dependency_names(
    112         manifest,
    113         "dependencies",
    114         BTreeSet::from(CORE_RUNTIME_DEPENDENCIES.map(str::to_owned)),
    115     )?;
    116     validate_dependency_names(
    117         manifest,
    118         "dev-dependencies",
    119         BTreeSet::from(CORE_DEV_DEPENDENCIES.map(str::to_owned)),
    120     )?;
    121     reject_nonempty_dependency_section(manifest, "build-dependencies")?;
    122     if manifest
    123         .get("target")
    124         .and_then(toml::Value::as_table)
    125         .is_some_and(|targets| {
    126             targets.values().any(|target| {
    127                 target.as_table().is_some_and(|table| {
    128                     ["dependencies", "dev-dependencies", "build-dependencies"]
    129                         .iter()
    130                         .any(|section| {
    131                             table
    132                                 .get(*section)
    133                                 .and_then(toml::Value::as_table)
    134                                 .is_some_and(|dependencies| !dependencies.is_empty())
    135                         })
    136                 })
    137             })
    138         })
    139     {
    140         return Err("radroots_core must not declare target-specific dependencies".to_owned());
    141     }
    142 
    143     let dependencies = manifest
    144         .get("dependencies")
    145         .and_then(toml::Value::as_table)
    146         .expect("validated dependencies");
    147     validate_dependency_shape(dependencies, "rust_decimal", false, false, &[])?;
    148     validate_dependency_shape(dependencies, "serde", true, false, &["alloc", "derive"])
    149 }
    150 
    151 fn validate_crate_root(
    152     workspace_root: &Path,
    153     core_spec: &toml::value::Table,
    154 ) -> Result<(), String> {
    155     let path = workspace_root.join(CORE_LIB_RELATIVE);
    156     let raw =
    157         fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?;
    158     let file =
    159         syn::parse_file(&raw).map_err(|error| format!("parse {CORE_LIB_RELATIVE}: {error}"))?;
    160     let mut modules = BTreeSet::new();
    161     let mut exports = BTreeMap::new();
    162     for item in &file.items {
    163         match item {
    164             Item::Mod(item) if matches!(item.vis, Visibility::Public(_)) => {
    165                 modules.insert(item.ident.to_string());
    166             }
    167             Item::Use(item) if matches!(item.vis, Visibility::Public(_)) => {
    168                 collect_use_exports(&item.tree, &mut Vec::new(), &mut exports)?;
    169             }
    170             Item::Macro(item)
    171                 if item
    172                     .attrs
    173                     .iter()
    174                     .any(|attribute| attribute.path().is_ident("macro_export")) =>
    175             {
    176                 return Err(format!(
    177                     "{CORE_LIB_RELATIVE} must not declare a root macro export"
    178                 ));
    179             }
    180             item if is_public(item) => {
    181                 return Err(format!(
    182                     "{CORE_LIB_RELATIVE} contains an unsupported public root item: {}",
    183                     public_item_kind(item)
    184                 ));
    185             }
    186             _ => {}
    187         }
    188     }
    189 
    190     let expected_modules = spec_strings(core_spec, "modules")?;
    191     if modules != expected_modules {
    192         return Err(format!(
    193             "radroots_core public modules must be exactly {expected_modules:?}, found {modules:?}"
    194         ));
    195     }
    196     let specified_exports = spec_strings(core_spec, "root_exports")?;
    197     let canonical_exports = CORE_ROOT_EXPORTS
    198         .iter()
    199         .map(|(name, _)| (*name).to_owned())
    200         .collect::<BTreeSet<_>>();
    201     if specified_exports != canonical_exports {
    202         return Err(format!(
    203             "radroots_core canonical root exports must be exactly {canonical_exports:?}, found {specified_exports:?}"
    204         ));
    205     }
    206     let expected_exports = CORE_ROOT_EXPORTS
    207         .into_iter()
    208         .map(|(name, source)| (name.to_owned(), source.to_owned()))
    209         .collect::<BTreeMap<_, _>>();
    210     if exports != expected_exports {
    211         return Err(format!(
    212             "radroots_core root exports must match the canonical contract; expected {expected_exports:?}, found {exports:?}"
    213         ));
    214     }
    215     Ok(())
    216 }
    217 
    218 fn validate_dependency_names(
    219     manifest: &toml::value::Table,
    220     section: &str,
    221     expected: BTreeSet<String>,
    222 ) -> Result<(), String> {
    223     let actual = manifest
    224         .get(section)
    225         .and_then(toml::Value::as_table)
    226         .map(|dependencies| dependencies.keys().cloned().collect::<BTreeSet<_>>())
    227         .unwrap_or_default();
    228     if actual != expected {
    229         return Err(format!(
    230             "radroots_core {section} must be exactly {expected:?}, found {actual:?}"
    231         ));
    232     }
    233     Ok(())
    234 }
    235 
    236 fn reject_nonempty_dependency_section(
    237     manifest: &toml::value::Table,
    238     section: &str,
    239 ) -> Result<(), String> {
    240     if manifest
    241         .get(section)
    242         .and_then(toml::Value::as_table)
    243         .is_some_and(|dependencies| !dependencies.is_empty())
    244     {
    245         return Err(format!("radroots_core must not declare {section}"));
    246     }
    247     Ok(())
    248 }
    249 
    250 fn validate_dependency_shape(
    251     dependencies: &toml::value::Table,
    252     name: &str,
    253     optional: bool,
    254     default_features: bool,
    255     expected_features: &[&str],
    256 ) -> Result<(), String> {
    257     let dependency = dependencies
    258         .get(name)
    259         .and_then(toml::Value::as_table)
    260         .ok_or_else(|| format!("radroots_core dependency {name} must use a dependency table"))?;
    261     if dependency.get("workspace").and_then(toml::Value::as_bool) != Some(true)
    262         || dependency
    263             .get("optional")
    264             .and_then(toml::Value::as_bool)
    265             .unwrap_or(false)
    266             != optional
    267         || dependency
    268             .get("default-features")
    269             .and_then(toml::Value::as_bool)
    270             .unwrap_or(true)
    271             != default_features
    272     {
    273         return Err(format!(
    274             "radroots_core dependency {name} has a noncanonical workspace/optional/default-features shape"
    275         ));
    276     }
    277     let mut expected_keys = BTreeSet::from(["workspace", "default-features"]);
    278     if optional {
    279         expected_keys.insert("optional");
    280     }
    281     if !expected_features.is_empty() {
    282         expected_keys.insert("features");
    283     }
    284     let actual_keys = dependency
    285         .keys()
    286         .map(String::as_str)
    287         .collect::<BTreeSet<_>>();
    288     if actual_keys != expected_keys {
    289         return Err(format!(
    290             "radroots_core dependency {name} keys must be exactly {expected_keys:?}, found {actual_keys:?}"
    291         ));
    292     }
    293     let actual_features = match dependency.get("features") {
    294         Some(features) => value_strings(
    295             Some(features),
    296             &format!("{CORE_MANIFEST_RELATIVE} dependencies.{name}.features"),
    297         )?,
    298         None => BTreeSet::new(),
    299     };
    300     let expected_features = expected_features
    301         .iter()
    302         .map(|feature| (*feature).to_owned())
    303         .collect::<BTreeSet<_>>();
    304     if actual_features != expected_features {
    305         return Err(format!(
    306             "radroots_core dependency {name} features must be {expected_features:?}, found {actual_features:?}"
    307         ));
    308     }
    309     Ok(())
    310 }
    311 
    312 fn spec_strings(package: &toml::value::Table, field: &str) -> Result<BTreeSet<String>, String> {
    313     value_strings(
    314         package.get(field),
    315         &format!("{ARCHITECTURE_RELATIVE} radroots_core.{field}"),
    316     )
    317 }
    318 
    319 fn value_strings(value: Option<&toml::Value>, label: &str) -> Result<BTreeSet<String>, String> {
    320     value
    321         .and_then(toml::Value::as_array)
    322         .ok_or_else(|| format!("{label} must be an array"))?
    323         .iter()
    324         .map(|value| {
    325             value
    326                 .as_str()
    327                 .map(str::to_owned)
    328                 .ok_or_else(|| format!("{label} must contain strings"))
    329         })
    330         .collect()
    331 }
    332 
    333 fn collect_use_exports(
    334     tree: &UseTree,
    335     prefix: &mut Vec<String>,
    336     exports: &mut BTreeMap<String, String>,
    337 ) -> Result<(), String> {
    338     match tree {
    339         UseTree::Path(path) => {
    340             prefix.push(path.ident.to_string());
    341             collect_use_exports(&path.tree, prefix, exports)?;
    342             prefix.pop();
    343         }
    344         UseTree::Name(name) => {
    345             let source_name = name.ident.to_string();
    346             let export_name = if source_name == "self" {
    347                 prefix.last().cloned().ok_or_else(|| {
    348                     format!("{CORE_LIB_RELATIVE} contains an invalid root self re-export")
    349                 })?
    350             } else {
    351                 source_name.clone()
    352             };
    353             let source = if source_name == "self" {
    354                 prefix.join("::")
    355             } else {
    356                 qualified_source(prefix, &source_name)
    357             };
    358             insert_export(exports, export_name, source)?;
    359         }
    360         UseTree::Rename(rename) if rename.rename != "_" => {
    361             insert_export(
    362                 exports,
    363                 rename.rename.to_string(),
    364                 qualified_source(prefix, &rename.ident.to_string()),
    365             )?;
    366         }
    367         UseTree::Rename(_) => {}
    368         UseTree::Glob(_) => {
    369             return Err(format!(
    370                 "{CORE_LIB_RELATIVE} must not use public glob re-exports"
    371             ));
    372         }
    373         UseTree::Group(group) => {
    374             for item in &group.items {
    375                 collect_use_exports(item, prefix, exports)?;
    376             }
    377         }
    378     }
    379     Ok(())
    380 }
    381 
    382 fn qualified_source(prefix: &[String], name: &str) -> String {
    383     if prefix.is_empty() {
    384         name.to_owned()
    385     } else {
    386         format!("{}::{name}", prefix.join("::"))
    387     }
    388 }
    389 
    390 fn insert_export(
    391     exports: &mut BTreeMap<String, String>,
    392     name: String,
    393     source: String,
    394 ) -> Result<(), String> {
    395     if let Some(previous) = exports.insert(name.clone(), source.clone()) {
    396         return Err(format!(
    397             "{CORE_LIB_RELATIVE} exports {name} more than once from {previous} and {source}"
    398         ));
    399     }
    400     Ok(())
    401 }
    402 
    403 fn is_public(item: &Item) -> bool {
    404     match item {
    405         Item::Const(item) => matches!(item.vis, Visibility::Public(_)),
    406         Item::Enum(item) => matches!(item.vis, Visibility::Public(_)),
    407         Item::ExternCrate(item) => matches!(item.vis, Visibility::Public(_)),
    408         Item::Fn(item) => matches!(item.vis, Visibility::Public(_)),
    409         Item::Static(item) => matches!(item.vis, Visibility::Public(_)),
    410         Item::Struct(item) => matches!(item.vis, Visibility::Public(_)),
    411         Item::Trait(item) => matches!(item.vis, Visibility::Public(_)),
    412         Item::TraitAlias(item) => matches!(item.vis, Visibility::Public(_)),
    413         Item::Type(item) => matches!(item.vis, Visibility::Public(_)),
    414         Item::Union(item) => matches!(item.vis, Visibility::Public(_)),
    415         _ => false,
    416     }
    417 }
    418 
    419 fn public_item_kind(item: &Item) -> &'static str {
    420     match item {
    421         Item::Const(_) => "const",
    422         Item::Enum(_) => "enum",
    423         Item::ExternCrate(_) => "extern crate",
    424         Item::Fn(_) => "function",
    425         Item::Static(_) => "static",
    426         Item::Struct(_) => "struct",
    427         Item::Trait(_) => "trait",
    428         Item::TraitAlias(_) => "trait alias",
    429         Item::Type(_) => "type alias",
    430         Item::Union(_) => "union",
    431         _ => "item",
    432     }
    433 }
    434 
    435 fn read_toml(workspace_root: &Path, relative: &str) -> Result<toml::Value, String> {
    436     let path = workspace_path(workspace_root, relative);
    437     let raw =
    438         fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?;
    439     raw.parse::<toml::Value>()
    440         .map_err(|error| format!("parse {}: {error}", path.display()))
    441 }
    442 
    443 fn workspace_path(workspace_root: &Path, relative: &str) -> PathBuf {
    444     workspace_root.join(relative)
    445 }
    446 
    447 #[cfg(test)]
    448 mod tests {
    449     use std::fs;
    450 
    451     use tempfile::tempdir;
    452 
    453     use super::validate;
    454 
    455     const SPEC: &str = r#"
    456 [[package]]
    457 name = "radroots_core"
    458 features = ["std", "serde"]
    459 default_features = ["std", "serde"]
    460 modules = ["currency", "decimal", "money", "percent", "pricing", "quantity", "unit"]
    461 root_exports = ["Currency", "Decimal", "Money", "Percent", "Quantity", "QuantityPrice", "Unit", "Error"]
    462 "#;
    463 
    464     const MANIFEST: &str = r#"
    465 [package]
    466 name = "radroots_core"
    467 
    468 [lib]
    469 name = "radroots_core"
    470 
    471 [features]
    472 default = ["std", "serde"]
    473 std = []
    474 serde = ["dep:serde", "rust_decimal/serde"]
    475 
    476 [dependencies]
    477 rust_decimal = { workspace = true, default-features = false }
    478 serde = { workspace = true, default-features = false, features = ["alloc", "derive"], optional = true }
    479 
    480 [dev-dependencies]
    481 dto_bindgen = { workspace = true }
    482 rust_decimal = { workspace = true }
    483 serde_json = { workspace = true }
    484 "#;
    485 
    486     fn fixture() -> tempfile::TempDir {
    487         let root = tempdir().expect("temporary workspace");
    488         fs::create_dir_all(root.path().join("contracts/crates/release_v1"))
    489             .expect("spec directory");
    490         fs::create_dir_all(root.path().join("crates/core/src")).expect("core directory");
    491         fs::write(
    492             root.path()
    493                 .join("contracts/crates/release_v1/radroots_crates_release_v1.toml"),
    494             SPEC,
    495         )
    496         .expect("architecture spec");
    497         fs::write(root.path().join("crates/core/Cargo.toml"), MANIFEST).expect("core manifest");
    498         let exports = super::CORE_ROOT_EXPORTS
    499             .into_iter()
    500             .map(|(_, source)| format!("pub use {source};"))
    501             .collect::<Vec<_>>()
    502             .join(" ");
    503         fs::write(
    504             root.path().join("crates/core/src/lib.rs"),
    505             format!(
    506                 "pub mod currency; pub mod decimal; pub mod money; pub mod percent; pub mod pricing; pub mod quantity; pub mod unit; {exports}"
    507             ),
    508         )
    509         .expect("core root");
    510         root
    511     }
    512 
    513     #[test]
    514     fn accepts_exact_core_contract() {
    515         let root = fixture();
    516         validate(root.path()).expect("exact core contract");
    517     }
    518 
    519     #[test]
    520     fn rejects_public_codegen_feature_and_runtime_dependency() {
    521         let root = fixture();
    522         let manifest_path = root.path().join("crates/core/Cargo.toml");
    523         let manifest = fs::read_to_string(&manifest_path).expect("manifest");
    524         fs::write(
    525             &manifest_path,
    526             manifest.replace("std = []", "std = []\ndto-bindgen = [\"dep:dto_bindgen\"]"),
    527         )
    528         .expect("mutated manifest");
    529         let error = validate(root.path()).expect_err("codegen feature must fail");
    530         assert!(error.contains("public features must be exactly"), "{error}");
    531 
    532         fs::write(
    533             &manifest_path,
    534             MANIFEST.replace(
    535                 "[dev-dependencies]",
    536                 "tokio = { workspace = true }\n\n[dev-dependencies]",
    537             ),
    538         )
    539         .expect("runtime dependency manifest");
    540         let error = validate(root.path()).expect_err("runtime dependency must fail");
    541         assert!(error.contains("dependencies must be exactly"), "{error}");
    542 
    543         fs::write(
    544             &manifest_path,
    545             MANIFEST.replace(
    546                 "rust_decimal = { workspace = true, default-features = false }",
    547                 "rust_decimal = { workspace = true, default-features = false, features = [] }",
    548             ),
    549         )
    550         .expect("noncanonical dependency shape");
    551         let error = validate(root.path()).expect_err("extra dependency keys must fail");
    552         assert!(error.contains("keys must be exactly"), "{error}");
    553     }
    554 
    555     #[test]
    556     fn rejects_extra_public_module_or_export() {
    557         let root = fixture();
    558         let lib_path = root.path().join("crates/core/src/lib.rs");
    559         let source = fs::read_to_string(&lib_path).expect("core root");
    560         fs::write(&lib_path, format!("{source} pub mod serde_ext;")).expect("extra module source");
    561         let error = validate(root.path()).expect_err("extra module must fail");
    562         assert!(error.contains("public modules must be exactly"), "{error}");
    563 
    564         fs::write(&lib_path, format!("{source} pub use fixture::Clock;"))
    565             .expect("extra export source");
    566         let error = validate(root.path()).expect_err("extra export must fail");
    567         assert!(error.contains("root exports must match"), "{error}");
    568     }
    569 
    570     #[test]
    571     fn rejects_rebound_duplicate_and_glob_exports() {
    572         let root = fixture();
    573         let lib_path = root.path().join("crates/core/src/lib.rs");
    574         let source = fs::read_to_string(&lib_path).expect("core root");
    575         fs::write(
    576             &lib_path,
    577             source.replace("pub use currency::Currency;", "pub use money::Currency;"),
    578         )
    579         .expect("rebound export source");
    580         let error = validate(root.path()).expect_err("rebound export must fail");
    581         assert!(error.contains("root exports must match"), "{error}");
    582 
    583         fs::write(&lib_path, format!("{source} pub use currency::*;")).expect("glob export source");
    584         let error = validate(root.path()).expect_err("glob export must fail");
    585         assert!(error.contains("must not use public glob"), "{error}");
    586 
    587         fs::write(&lib_path, format!("{source} pub use currency::Currency;"))
    588             .expect("duplicate export source");
    589         let error = validate(root.path()).expect_err("duplicate export must fail");
    590         assert!(error.contains("exports Currency more than once"), "{error}");
    591     }
    592 }