lib

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

dependency_boundary.rs (24116B)


      1 use std::{
      2     collections::{BTreeMap, BTreeSet},
      3     fs,
      4     path::Path,
      5     process::Command,
      6 };
      7 
      8 use serde::Deserialize;
      9 
     10 const PACKAGE_TIERS_RELATIVE: &str = "contracts/releases/package_tiers.toml";
     11 const SPEC_ID: &str = "radroots.crates.release.v1";
     12 const DIRECTION: &str = "same_or_lower";
     13 const POLICY_SCHEMA_VERSION: u16 = 1;
     14 const CURRENT_STEP: u16 = 24;
     15 const EXPECTED_TIERS: &[(&str, u8)] = &[
     16     ("foundation", 0),
     17     ("domain", 1),
     18     ("spi", 2),
     19     ("adapter", 3),
     20     ("orchestration", 4),
     21     ("sdk", 5),
     22     ("facade", 6),
     23 ];
     24 const EXPECTED_DEPENDENCY_KINDS: &[&str] = &["build", "dev", "normal"];
     25 
     26 #[derive(Debug, Deserialize)]
     27 #[serde(deny_unknown_fields)]
     28 struct DependencyBoundaryPolicy {
     29     schema_version: u16,
     30     spec_id: String,
     31     direction: String,
     32     enforced_dependency_kinds: Vec<String>,
     33     tier: Vec<PackageTier>,
     34     #[serde(default)]
     35     temporary_exception: Vec<TemporaryException>,
     36 }
     37 
     38 #[derive(Debug, Deserialize)]
     39 #[serde(deny_unknown_fields)]
     40 struct PackageTier {
     41     id: String,
     42     rank: u8,
     43     packages: Vec<String>,
     44 }
     45 
     46 #[derive(Debug, Deserialize)]
     47 #[serde(deny_unknown_fields)]
     48 struct TemporaryException {
     49     owner: String,
     50     dependency: String,
     51     kind: String,
     52     target: String,
     53     features: Vec<String>,
     54     uses_default_features: bool,
     55     removal_step: u16,
     56     rationale: String,
     57 }
     58 
     59 #[derive(Debug, Deserialize)]
     60 struct CargoMetadata {
     61     packages: Vec<CargoPackage>,
     62     resolve: Option<CargoResolve>,
     63 }
     64 
     65 #[derive(Debug, Deserialize)]
     66 struct CargoPackage {
     67     id: String,
     68     name: String,
     69     #[serde(default)]
     70     dependencies: Vec<CargoDependency>,
     71 }
     72 
     73 #[derive(Debug, Deserialize)]
     74 struct CargoDependency {
     75     name: String,
     76     #[serde(default)]
     77     kind: Option<String>,
     78     #[serde(default)]
     79     features: Vec<String>,
     80     #[serde(default)]
     81     target: Option<String>,
     82     #[serde(default = "default_true")]
     83     uses_default_features: bool,
     84 }
     85 
     86 #[derive(Debug, Deserialize)]
     87 struct CargoResolve {
     88     nodes: Vec<CargoNode>,
     89 }
     90 
     91 #[derive(Debug, Deserialize)]
     92 struct CargoNode {
     93     id: String,
     94     #[serde(default)]
     95     deps: Vec<CargoNodeDependency>,
     96 }
     97 
     98 #[derive(Debug, Deserialize)]
     99 struct CargoNodeDependency {
    100     name: String,
    101     pkg: String,
    102     #[serde(default)]
    103     dep_kinds: Vec<CargoDependencyKind>,
    104 }
    105 
    106 #[derive(Debug, Deserialize)]
    107 struct CargoDependencyKind {
    108     #[serde(default)]
    109     kind: Option<String>,
    110     #[serde(default)]
    111     target: Option<String>,
    112 }
    113 
    114 #[derive(Clone, Copy)]
    115 struct PackagePlacement<'a> {
    116     tier: &'a str,
    117     rank: u8,
    118 }
    119 
    120 fn default_true() -> bool {
    121     true
    122 }
    123 
    124 pub(super) fn validate_policy_catalog(
    125     workspace_root: &Path,
    126     expected_packages: &BTreeSet<String>,
    127 ) -> Result<(), String> {
    128     let policy = load_policy(workspace_root)?;
    129     validate_policy(&policy, expected_packages).map(|_| ())
    130 }
    131 
    132 pub(super) fn validate_resolved_boundaries(workspace_root: &Path) -> Result<(), String> {
    133     let policy = load_policy(workspace_root)?;
    134     let expected_packages = policy
    135         .tier
    136         .iter()
    137         .flat_map(|tier| tier.packages.iter().cloned())
    138         .collect::<BTreeSet<_>>();
    139     let placements = validate_policy(&policy, &expected_packages)?;
    140     let metadata = load_metadata(workspace_root)?;
    141     validate_metadata(&policy, &placements, &metadata)
    142 }
    143 
    144 fn load_policy(workspace_root: &Path) -> Result<DependencyBoundaryPolicy, String> {
    145     let path = workspace_root.join(PACKAGE_TIERS_RELATIVE);
    146     let raw =
    147         fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?;
    148     toml::from_str(&raw).map_err(|error| format!("parse {}: {error}", path.display()))
    149 }
    150 
    151 fn validate_policy<'a>(
    152     policy: &'a DependencyBoundaryPolicy,
    153     expected_packages: &BTreeSet<String>,
    154 ) -> Result<BTreeMap<&'a str, PackagePlacement<'a>>, String> {
    155     if policy.schema_version != POLICY_SCHEMA_VERSION {
    156         return Err(format!(
    157             "{PACKAGE_TIERS_RELATIVE} schema_version must be {POLICY_SCHEMA_VERSION}"
    158         ));
    159     }
    160     if policy.spec_id != SPEC_ID {
    161         return Err(format!(
    162             "{PACKAGE_TIERS_RELATIVE} spec_id must be {SPEC_ID}"
    163         ));
    164     }
    165     if policy.direction != DIRECTION {
    166         return Err(format!(
    167             "{PACKAGE_TIERS_RELATIVE} direction must be {DIRECTION}"
    168         ));
    169     }
    170 
    171     let actual_kinds = unique_strings(
    172         "enforced_dependency_kinds",
    173         &policy.enforced_dependency_kinds,
    174     )?;
    175     let expected_kinds = EXPECTED_DEPENDENCY_KINDS
    176         .iter()
    177         .copied()
    178         .collect::<BTreeSet<_>>();
    179     if actual_kinds != expected_kinds {
    180         return Err(format!(
    181             "{PACKAGE_TIERS_RELATIVE} enforced_dependency_kinds must be build, dev, normal"
    182         ));
    183     }
    184 
    185     let actual_tiers = policy
    186         .tier
    187         .iter()
    188         .map(|tier| (tier.id.as_str(), tier.rank))
    189         .collect::<Vec<_>>();
    190     if actual_tiers != EXPECTED_TIERS {
    191         return Err(format!(
    192             "{PACKAGE_TIERS_RELATIVE} tiers must match the approved ordered architecture"
    193         ));
    194     }
    195 
    196     let mut placements = BTreeMap::new();
    197     for tier in &policy.tier {
    198         if tier.packages.is_empty() {
    199             return Err(format!(
    200                 "{PACKAGE_TIERS_RELATIVE} tier {} must allocate at least one package",
    201                 tier.id
    202             ));
    203         }
    204         for package in &tier.packages {
    205             if placements
    206                 .insert(
    207                     package.as_str(),
    208                     PackagePlacement {
    209                         tier: &tier.id,
    210                         rank: tier.rank,
    211                     },
    212                 )
    213                 .is_some()
    214             {
    215                 return Err(format!(
    216                     "{PACKAGE_TIERS_RELATIVE} package {package} is allocated more than once"
    217                 ));
    218             }
    219         }
    220     }
    221 
    222     let actual_packages = placements
    223         .keys()
    224         .map(|package| (*package).to_owned())
    225         .collect::<BTreeSet<_>>();
    226     if &actual_packages != expected_packages {
    227         return Err(package_set_mismatch(expected_packages, &actual_packages));
    228     }
    229 
    230     let mut exception_keys = BTreeSet::new();
    231     for exception in &policy.temporary_exception {
    232         let owner = placements.get(exception.owner.as_str()).ok_or_else(|| {
    233             format!(
    234                 "{PACKAGE_TIERS_RELATIVE} exception owner {} is not an approved package",
    235                 exception.owner
    236             )
    237         })?;
    238         let dependency = placements
    239             .get(exception.dependency.as_str())
    240             .ok_or_else(|| {
    241                 format!(
    242                     "{PACKAGE_TIERS_RELATIVE} exception dependency {} is not an approved package",
    243                     exception.dependency
    244                 )
    245             })?;
    246         if dependency.rank <= owner.rank {
    247             return Err(format!(
    248                 "{PACKAGE_TIERS_RELATIVE} exception {} -> {} does not describe an upward edge",
    249                 exception.owner, exception.dependency
    250             ));
    251         }
    252         if !expected_kinds.contains(exception.kind.as_str()) {
    253             return Err(format!(
    254                 "{PACKAGE_TIERS_RELATIVE} exception {} -> {} uses unknown dependency kind {}",
    255                 exception.owner, exception.dependency, exception.kind
    256             ));
    257         }
    258         if exception.target.trim().is_empty() {
    259             return Err(format!(
    260                 "{PACKAGE_TIERS_RELATIVE} exception {} -> {} must name its exact target",
    261                 exception.owner, exception.dependency
    262             ));
    263         }
    264         let normalized_features = normalized_features(&exception.features);
    265         if normalized_features.len() != exception.features.len()
    266             || normalized_features.iter().ne(exception.features.iter())
    267         {
    268             return Err(format!(
    269                 "{PACKAGE_TIERS_RELATIVE} exception {} -> {} features must be sorted and unique",
    270                 exception.owner, exception.dependency
    271             ));
    272         }
    273         if exception.removal_step <= CURRENT_STEP {
    274             return Err(format!(
    275                 "{PACKAGE_TIERS_RELATIVE} exception {} -> {} expired at Step {}",
    276                 exception.owner, exception.dependency, exception.removal_step
    277             ));
    278         }
    279         if exception.rationale.trim().is_empty() {
    280             return Err(format!(
    281                 "{PACKAGE_TIERS_RELATIVE} exception {} -> {} must include a rationale",
    282                 exception.owner, exception.dependency
    283             ));
    284         }
    285         let key = (
    286             exception.owner.as_str(),
    287             exception.dependency.as_str(),
    288             exception.kind.as_str(),
    289             exception.target.as_str(),
    290             exception.features.clone(),
    291             exception.uses_default_features,
    292         );
    293         if !exception_keys.insert(key) {
    294             return Err(format!(
    295                 "{PACKAGE_TIERS_RELATIVE} contains a duplicate exception for {} -> {}",
    296                 exception.owner, exception.dependency
    297             ));
    298         }
    299     }
    300 
    301     Ok(placements)
    302 }
    303 
    304 fn unique_strings<'a>(label: &str, values: &'a [String]) -> Result<BTreeSet<&'a str>, String> {
    305     let unique = values.iter().map(String::as_str).collect::<BTreeSet<_>>();
    306     if unique.len() != values.len() {
    307         return Err(format!(
    308             "{PACKAGE_TIERS_RELATIVE} {label} must not contain duplicates"
    309         ));
    310     }
    311     Ok(unique)
    312 }
    313 
    314 fn package_set_mismatch(expected: &BTreeSet<String>, actual: &BTreeSet<String>) -> String {
    315     let missing = expected
    316         .difference(actual)
    317         .cloned()
    318         .collect::<Vec<_>>()
    319         .join(", ");
    320     let extra = actual
    321         .difference(expected)
    322         .cloned()
    323         .collect::<Vec<_>>()
    324         .join(", ");
    325     format!("{PACKAGE_TIERS_RELATIVE} package catalog mismatch; missing: {missing}; extra: {extra}")
    326 }
    327 
    328 fn load_metadata(workspace_root: &Path) -> Result<CargoMetadata, String> {
    329     let output = Command::new("cargo")
    330         .args([
    331             "metadata",
    332             "--format-version",
    333             "1",
    334             "--locked",
    335             "--all-features",
    336         ])
    337         .current_dir(workspace_root)
    338         .output()
    339         .map_err(|error| format!("run cargo metadata: {error}"))?;
    340     if !output.status.success() {
    341         return Err(format!(
    342             "cargo metadata failed while checking dependency boundaries: {}",
    343             String::from_utf8_lossy(&output.stderr).trim()
    344         ));
    345     }
    346     serde_json::from_slice(&output.stdout)
    347         .map_err(|error| format!("parse cargo metadata for dependency boundaries: {error}"))
    348 }
    349 
    350 fn validate_metadata(
    351     policy: &DependencyBoundaryPolicy,
    352     placements: &BTreeMap<&str, PackagePlacement<'_>>,
    353     metadata: &CargoMetadata,
    354 ) -> Result<(), String> {
    355     let resolve = metadata
    356         .resolve
    357         .as_ref()
    358         .ok_or_else(|| "cargo metadata did not include a resolved dependency graph".to_owned())?;
    359     let mut packages_by_id = BTreeMap::new();
    360     for package in &metadata.packages {
    361         if packages_by_id
    362             .insert(package.id.as_str(), package)
    363             .is_some()
    364         {
    365             return Err(format!(
    366                 "cargo metadata contains duplicate package id {}",
    367                 package.id
    368             ));
    369         }
    370     }
    371 
    372     let mut nodes_by_id = BTreeMap::new();
    373     for node in &resolve.nodes {
    374         if nodes_by_id.insert(node.id.as_str(), node).is_some() {
    375             return Err(format!(
    376                 "cargo metadata contains duplicate resolve node {}",
    377                 node.id
    378             ));
    379         }
    380     }
    381 
    382     let enforced_kinds = policy
    383         .enforced_dependency_kinds
    384         .iter()
    385         .map(String::as_str)
    386         .collect::<BTreeSet<_>>();
    387     let mut violations = Vec::new();
    388 
    389     for node in &resolve.nodes {
    390         let Some(owner_package) = packages_by_id.get(node.id.as_str()).copied() else {
    391             return Err(format!(
    392                 "cargo metadata resolve node {} has no package record",
    393                 node.id
    394             ));
    395         };
    396         let Some(owner_placement) = placements.get(owner_package.name.as_str()).copied() else {
    397             continue;
    398         };
    399 
    400         for resolved_dependency in &node.deps {
    401             let dependency_package = packages_by_id
    402                 .get(resolved_dependency.pkg.as_str())
    403                 .copied()
    404                 .ok_or_else(|| {
    405                     format!(
    406                         "cargo metadata resolved dependency {} has no package record",
    407                         resolved_dependency.pkg
    408                     )
    409                 })?;
    410             let Some(dependency_placement) =
    411                 placements.get(dependency_package.name.as_str()).copied()
    412             else {
    413                 continue;
    414             };
    415             if resolved_dependency.dep_kinds.is_empty() {
    416                 return Err(format!(
    417                     "cargo metadata edge {} -> {} has no dependency kind",
    418                     owner_package.name, dependency_package.name
    419                 ));
    420             }
    421 
    422             for dependency_kind in &resolved_dependency.dep_kinds {
    423                 let kind = normalized_kind(dependency_kind.kind.as_deref());
    424                 if !enforced_kinds.contains(kind) {
    425                     continue;
    426                 }
    427                 let declaration =
    428                     resolve_declaration(owner_package, dependency_package, dependency_kind)?;
    429                 if dependency_placement.rank <= owner_placement.rank {
    430                     continue;
    431                 }
    432                 if exception_matches(
    433                     &policy.temporary_exception,
    434                     owner_package,
    435                     dependency_package,
    436                     dependency_kind,
    437                     declaration,
    438                 ) {
    439                     continue;
    440                 }
    441                 violations.push(format_violation(
    442                     owner_package,
    443                     owner_placement,
    444                     dependency_package,
    445                     dependency_placement,
    446                     resolved_dependency,
    447                     dependency_kind,
    448                     declaration,
    449                 ));
    450             }
    451         }
    452     }
    453 
    454     if violations.is_empty() {
    455         Ok(())
    456     } else {
    457         violations.sort();
    458         Err(format!(
    459             "forbidden package dependency direction(s):\n{}",
    460             violations.join("\n")
    461         ))
    462     }
    463 }
    464 
    465 fn resolve_declaration<'a>(
    466     owner: &'a CargoPackage,
    467     dependency: &CargoPackage,
    468     dependency_kind: &CargoDependencyKind,
    469 ) -> Result<&'a CargoDependency, String> {
    470     let kind = normalized_kind(dependency_kind.kind.as_deref());
    471     let candidates = owner
    472         .dependencies
    473         .iter()
    474         .filter(|candidate| {
    475             candidate.name == dependency.name
    476                 && normalized_kind(candidate.kind.as_deref()) == kind
    477                 && candidate.target == dependency_kind.target
    478         })
    479         .collect::<Vec<_>>();
    480     match candidates.as_slice() {
    481         [declaration] => Ok(*declaration),
    482         [] => Err(format!(
    483             "cargo metadata edge {} -> {} kind={kind} target={} has no matching package dependency declaration",
    484             owner.name,
    485             dependency.name,
    486             target_label(dependency_kind.target.as_deref())
    487         )),
    488         _ => Err(format!(
    489             "cargo metadata edge {} -> {} kind={kind} target={} has ambiguous package dependency declarations",
    490             owner.name,
    491             dependency.name,
    492             target_label(dependency_kind.target.as_deref())
    493         )),
    494     }
    495 }
    496 
    497 fn exception_matches(
    498     exceptions: &[TemporaryException],
    499     owner: &CargoPackage,
    500     dependency: &CargoPackage,
    501     dependency_kind: &CargoDependencyKind,
    502     declaration: &CargoDependency,
    503 ) -> bool {
    504     let kind = normalized_kind(dependency_kind.kind.as_deref());
    505     let target = target_label(dependency_kind.target.as_deref());
    506     let features = normalized_features(&declaration.features);
    507     exceptions.iter().any(|exception| {
    508         exception.owner == owner.name
    509             && exception.dependency == dependency.name
    510             && exception.kind == kind
    511             && exception.target == target
    512             && exception.features == features
    513             && exception.uses_default_features == declaration.uses_default_features
    514     })
    515 }
    516 
    517 fn format_violation(
    518     owner: &CargoPackage,
    519     owner_placement: PackagePlacement<'_>,
    520     dependency: &CargoPackage,
    521     dependency_placement: PackagePlacement<'_>,
    522     resolved_dependency: &CargoNodeDependency,
    523     dependency_kind: &CargoDependencyKind,
    524     declaration: &CargoDependency,
    525 ) -> String {
    526     let features = normalized_features(&declaration.features);
    527     format!(
    528         "{} (tier={} rank={}) -> {} (tier={} rank={}); owner_id={}; dependency_id={}; alias={}; kind={}; target={}; features=[{}]; default_features={}",
    529         owner.name,
    530         owner_placement.tier,
    531         owner_placement.rank,
    532         dependency.name,
    533         dependency_placement.tier,
    534         dependency_placement.rank,
    535         owner.id,
    536         dependency.id,
    537         resolved_dependency.name,
    538         normalized_kind(dependency_kind.kind.as_deref()),
    539         target_label(dependency_kind.target.as_deref()),
    540         features.join(","),
    541         declaration.uses_default_features,
    542     )
    543 }
    544 
    545 fn normalized_kind(kind: Option<&str>) -> &str {
    546     kind.unwrap_or("normal")
    547 }
    548 
    549 fn target_label(target: Option<&str>) -> &str {
    550     target.unwrap_or("all")
    551 }
    552 
    553 fn normalized_features(features: &[String]) -> Vec<String> {
    554     features
    555         .iter()
    556         .cloned()
    557         .collect::<BTreeSet<_>>()
    558         .into_iter()
    559         .collect()
    560 }
    561 
    562 #[cfg(test)]
    563 mod tests {
    564     use super::{
    565         CargoMetadata, DependencyBoundaryPolicy, exception_matches, validate_metadata,
    566         validate_policy,
    567     };
    568     use serde::Deserialize;
    569     use std::collections::BTreeSet;
    570 
    571     const POLICY: &str = include_str!("../../../../contracts/releases/package_tiers.toml");
    572     const ARCHITECTURE: &str =
    573         include_str!("../../../../contracts/crates/release_v1/radroots_crates_release_v1.toml");
    574     const DOMAIN_TO_STORAGE: &str =
    575         include_str!("../../tests/fixtures/dependency-boundaries/domain-to-storage.json");
    576     const SPI_TO_ADAPTER: &str =
    577         include_str!("../../tests/fixtures/dependency-boundaries/spi-to-adapter.json");
    578     const SERVICE_TO_SDK: &str =
    579         include_str!("../../tests/fixtures/dependency-boundaries/service-to-sdk.json");
    580     const VALID_DOWNWARD: &str =
    581         include_str!("../../tests/fixtures/dependency-boundaries/valid-downward.json");
    582 
    583     #[derive(Deserialize)]
    584     struct ArchitectureCatalog {
    585         package: Vec<ArchitecturePackage>,
    586     }
    587 
    588     #[derive(Deserialize)]
    589     struct ArchitecturePackage {
    590         name: String,
    591     }
    592 
    593     fn policy() -> DependencyBoundaryPolicy {
    594         toml::from_str(POLICY).expect("package tier policy")
    595     }
    596 
    597     fn metadata(raw: &str) -> CargoMetadata {
    598         serde_json::from_str(raw).expect("Cargo metadata fixture")
    599     }
    600 
    601     fn placements<'a>(
    602         policy: &'a DependencyBoundaryPolicy,
    603     ) -> std::collections::BTreeMap<&'a str, super::PackagePlacement<'a>> {
    604         let architecture =
    605             toml::from_str::<ArchitectureCatalog>(ARCHITECTURE).expect("architecture catalog");
    606         let expected = architecture
    607             .package
    608             .into_iter()
    609             .map(|package| package.name)
    610             .collect::<BTreeSet<_>>();
    611         validate_policy(policy, &expected).expect("valid package tier policy")
    612     }
    613 
    614     #[test]
    615     fn policy_covers_the_exact_architecture_catalog() {
    616         let policy = policy();
    617         let placements = placements(&policy);
    618         assert_eq!(placements.len(), 19);
    619     }
    620 
    621     #[test]
    622     fn domain_to_storage_reports_resolved_alias_kind_target_and_features() {
    623         let policy = policy();
    624         let error = validate_metadata(&policy, &placements(&policy), &metadata(DOMAIN_TO_STORAGE))
    625             .expect_err("domain must not depend on storage");
    626         assert!(error.contains("radroots_event (tier=domain rank=1)"));
    627         assert!(error.contains("radroots_storage (tier=spi rank=2)"));
    628         assert!(error.contains("alias=storage_alias"));
    629         assert!(error.contains("kind=normal"));
    630         assert!(error.contains("target=cfg(unix)"));
    631         assert!(error.contains("features=[memory]"));
    632     }
    633 
    634     #[test]
    635     fn spi_to_adapter_reports_build_and_target_specific_edge() {
    636         let policy = policy();
    637         let error = validate_metadata(&policy, &placements(&policy), &metadata(SPI_TO_ADAPTER))
    638             .expect_err("SPI must not depend on adapter");
    639         assert!(error.contains("radroots_transport (tier=spi rank=2)"));
    640         assert!(error.contains("radroots_nostr (tier=adapter rank=3)"));
    641         assert!(error.contains("alias=protocol_adapter"));
    642         assert!(error.contains("kind=build"));
    643         assert!(error.contains("target=cfg(target_arch = \"wasm32\")"));
    644         assert!(error.contains("features=[events]"));
    645     }
    646 
    647     #[test]
    648     fn service_orchestration_to_sdk_is_forbidden() {
    649         let policy = policy();
    650         let error = validate_metadata(&policy, &placements(&policy), &metadata(SERVICE_TO_SDK))
    651             .expect_err("service orchestration must not depend on SDK");
    652         assert!(error.contains("radroots_sync (tier=orchestration rank=4)"));
    653         assert!(error.contains("radroots_sdk (tier=sdk rank=5)"));
    654         assert!(error.contains("alias=client_engine"));
    655     }
    656 
    657     #[test]
    658     fn valid_downward_dev_target_edge_passes() {
    659         let policy = policy();
    660         validate_metadata(&policy, &placements(&policy), &metadata(VALID_DOWNWARD))
    661             .expect("adapter dev dependency on SPI points downward");
    662     }
    663 
    664     #[test]
    665     fn temporary_exception_is_exact_about_features() {
    666         let policy = policy();
    667         assert!(
    668             policy.temporary_exception.is_empty(),
    669             "expired dependency exceptions must not remain in production policy"
    670         );
    671         let exceptions = [super::TemporaryException {
    672             owner: "radroots_trade".to_owned(),
    673             dependency: "radroots_nostr".to_owned(),
    674             kind: "dev".to_owned(),
    675             target: "all".to_owned(),
    676             features: vec!["events".to_owned(), "std".to_owned()],
    677             uses_default_features: false,
    678             removal_step: 88,
    679             rationale: "synthetic exact-match fixture".to_owned(),
    680         }];
    681         let owner = super::CargoPackage {
    682             id: "owner".to_owned(),
    683             name: "radroots_trade".to_owned(),
    684             dependencies: Vec::new(),
    685         };
    686         let dependency = super::CargoPackage {
    687             id: "dependency".to_owned(),
    688             name: "radroots_nostr".to_owned(),
    689             dependencies: Vec::new(),
    690         };
    691         let kind = super::CargoDependencyKind {
    692             kind: Some("dev".to_owned()),
    693             target: None,
    694         };
    695         let exact = super::CargoDependency {
    696             name: "radroots_nostr".to_owned(),
    697             kind: Some("dev".to_owned()),
    698             features: vec!["std".to_owned(), "events".to_owned()],
    699             target: None,
    700             uses_default_features: false,
    701         };
    702         assert!(exception_matches(
    703             &exceptions,
    704             &owner,
    705             &dependency,
    706             &kind,
    707             &exact
    708         ));
    709 
    710         let broadened = super::CargoDependency {
    711             features: vec!["client".to_owned(), "events".to_owned(), "std".to_owned()],
    712             ..exact
    713         };
    714         assert!(!exception_matches(
    715             &exceptions,
    716             &owner,
    717             &dependency,
    718             &kind,
    719             &broadened
    720         ));
    721     }
    722 
    723     #[test]
    724     fn metadata_without_resolve_graph_fails_closed() {
    725         let policy = policy();
    726         let mut fixture = metadata(VALID_DOWNWARD);
    727         fixture.resolve = None;
    728         let error = validate_metadata(&policy, &placements(&policy), &fixture)
    729             .expect_err("missing resolved graph must fail");
    730         assert!(error.contains("did not include a resolved dependency graph"));
    731     }
    732 }