1use crate::cargo_metadata::{
8 CargoMetadata, CargoMetadataDependency, CargoMetadataNode, CargoMetadataNodeDependency,
9 CargoMetadataPackage, cargo_metadata, cargo_metadata_for_manifest,
10};
11use canic_core::{
12 bootstrap::parse_config_model,
13 ids::CanisterRole,
14 role_contract::{
15 BuiltInRoleKind, CanicFeatureKey, RoleContractFinding,
16 catalog::{default_features, feature_definitions, implied_features, validate_catalog},
17 },
18};
19use std::{
20 collections::{BTreeMap, BTreeSet},
21 fs,
22 path::{Path, PathBuf},
23};
24
25const WASM_TARGET: &str = "wasm32-unknown-unknown";
26const CANIC_PACKAGE: &str = "canic";
27const CANIC_CORE_PACKAGE: &str = "canic-core";
28
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub enum PackageValidationMode {
31 Build,
32 Passive,
33}
34
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct RolePackageEvidence {
37 pub fleet: String,
38 pub role: CanisterRole,
39 pub role_package_name: String,
40 pub role_package_id: String,
41 pub role_manifest_path: PathBuf,
42 pub canic_package_id: String,
43 pub canic_version: String,
44 pub canic_source: Option<String>,
45 pub canic_manifest_path: PathBuf,
46 pub dependency_key: String,
47 pub default_features_enabled: bool,
48 pub direct_features: BTreeSet<CanicFeatureKey>,
49}
50
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub enum RolePackageValidation {
53 Supported(RolePackageEvidence),
54 Unsupported(RoleContractFinding),
55}
56
57pub fn declared_role_manifest_path(
58 config_path: &Path,
59 role: &CanisterRole,
60) -> Result<PathBuf, RoleContractFinding> {
61 let config_source = fs::read_to_string(config_path)
62 .map_err(|_| RoleContractFinding::PackageMissing { role: role.clone() })?;
63 let config = parse_config_model(&config_source).map_err(|error| {
64 RoleContractFinding::DependencyShapeUnsupported {
65 reason: format!(
66 "invalid role configuration {}: {error}",
67 config_path.display()
68 ),
69 }
70 })?;
71 let declaration = config
72 .roles
73 .get(role)
74 .ok_or_else(|| RoleContractFinding::RoleUnknown { role: role.clone() })?;
75 let manifest_path = package_manifest_path(config_path, &declaration.package);
76 if !manifest_path.is_file() {
77 return Err(RoleContractFinding::PackageMissing { role: role.clone() });
78 }
79 Ok(manifest_path)
80}
81
82#[must_use]
83pub fn validate_declared_role_package(
84 config_path: &Path,
85 role: &CanisterRole,
86 mode: PackageValidationMode,
87) -> RolePackageValidation {
88 let Ok(config_source) = fs::read_to_string(config_path) else {
89 return RolePackageValidation::Unsupported(RoleContractFinding::PackageMissing {
90 role: role.clone(),
91 });
92 };
93 let config = match parse_config_model(&config_source) {
94 Ok(config) => config,
95 Err(error) => {
96 return unsupported_shape(format!(
97 "invalid role configuration {}: {error}",
98 config_path.display()
99 ));
100 }
101 };
102 validate_declared_role_package_from_config(config_path, &config, role, mode)
103}
104
105#[must_use]
106pub fn validate_declared_role_package_from_config(
107 config_path: &Path,
108 config: &canic_core::bootstrap::compiled::ConfigModel,
109 role: &CanisterRole,
110 mode: PackageValidationMode,
111) -> RolePackageValidation {
112 let Some(declaration) = config.roles.get(role) else {
113 return RolePackageValidation::Unsupported(RoleContractFinding::RoleUnknown {
114 role: role.clone(),
115 });
116 };
117 let Some(fleet) = config.fleet_name() else {
118 return unsupported_shape(format!("missing [fleet].name in {}", config_path.display()));
119 };
120 let manifest_path = package_manifest_path(config_path, &declaration.package);
121 if !manifest_path.is_file() {
122 return RolePackageValidation::Unsupported(RoleContractFinding::PackageMissing {
123 role: role.clone(),
124 });
125 }
126
127 validate_package_manifest(&manifest_path, fleet, role, mode, false)
128}
129
130#[must_use]
131pub fn validate_built_in_wasm_store_package(
132 manifest_path: &Path,
133 mode: PackageValidationMode,
134) -> RolePackageValidation {
135 if !manifest_path.is_file() {
136 return RolePackageValidation::Unsupported(
137 RoleContractFinding::BuiltInPackageUnavailable {
138 role: BuiltInRoleKind::WasmStore,
139 },
140 );
141 }
142
143 validate_package_manifest(
144 manifest_path,
145 "wasm_store",
146 &CanisterRole::WASM_STORE,
147 mode,
148 true,
149 )
150}
151
152#[doc(hidden)]
157pub fn validate_internal_test_wasm_packages(
158 workspace_root: &Path,
159 package_names: &[&str],
160) -> Result<(), RoleContractFinding> {
161 let metadata = cargo_metadata(workspace_root, false).map_err(|error| {
162 unsupported_finding(format!("unable to inspect internal test packages: {error}"))
163 })?;
164
165 for package_name in package_names {
166 let matches = metadata
167 .packages
168 .iter()
169 .filter(|package| package.name == *package_name)
170 .collect::<Vec<_>>();
171 let [package] = matches.as_slice() else {
172 return Err(unsupported_finding(format!(
173 "internal test package `{package_name}` did not resolve exactly once"
174 )));
175 };
176 let Some((fleet, role)) = package_canic_identity(package)? else {
177 continue;
178 };
179 let config_path = package_role_config_path(package, &fleet, &role).ok_or_else(|| {
180 unsupported_finding(format!(
181 "internal test package `{package_name}` has no matching ancestor canic.toml"
182 ))
183 })?;
184 let evidence =
185 match validate_declared_role_package(&config_path, &role, PackageValidationMode::Build)
186 {
187 RolePackageValidation::Supported(evidence) => evidence,
188 RolePackageValidation::Unsupported(finding) => return Err(finding),
189 };
190 if evidence.role_package_name != *package_name
191 || normalized_manifest_path(&evidence.role_manifest_path)
192 != normalized_manifest_path(&package.manifest_path)
193 {
194 return Err(unsupported_finding(format!(
195 "internal test package `{package_name}` does not match the package selected by {}",
196 config_path.display()
197 )));
198 }
199 match super::resolve_declared_role_package_contract(&config_path, &evidence) {
200 canic_core::role_contract::RoleContractResolution::Resolved { .. } => {}
201 canic_core::role_contract::RoleContractResolution::Rejected { errors } => {
202 return Err(errors
203 .into_iter()
204 .next()
205 .expect("rejected role contract has a blocking finding"));
206 }
207 }
208 }
209
210 Ok(())
211}
212
213fn package_canic_identity(
214 package: &CargoMetadataPackage,
215) -> Result<Option<(String, CanisterRole)>, RoleContractFinding> {
216 let canic = package
217 .metadata
218 .as_ref()
219 .and_then(|metadata| metadata.get("canic"));
220 let fleet = canic
221 .and_then(|metadata| metadata.get("fleet"))
222 .and_then(serde_json::Value::as_str);
223 let role = canic
224 .and_then(|metadata| metadata.get("role"))
225 .and_then(serde_json::Value::as_str);
226 match (canic, fleet, role) {
227 (None, _, _) => Ok(None),
228 (Some(_), Some(fleet), Some(role)) => Ok(Some((
229 fleet.to_string(),
230 CanisterRole::owned(role.to_string()),
231 ))),
232 (Some(_), _, _) => Err(unsupported_finding(format!(
233 "internal test package `{}` has incomplete Canic package metadata",
234 package.name
235 ))),
236 }
237}
238
239fn package_role_config_path(
240 package: &CargoMetadataPackage,
241 expected_fleet: &str,
242 expected_role: &CanisterRole,
243) -> Option<PathBuf> {
244 let manifest_dir = package.manifest_path.parent()?;
245 for ancestor in manifest_dir.ancestors() {
246 let candidate = ancestor.join("canic.toml");
247 let Ok(source) = fs::read_to_string(&candidate) else {
248 continue;
249 };
250 let Ok(config) = parse_config_model(&source) else {
251 continue;
252 };
253 if config.fleet_name() != Some(expected_fleet) {
254 continue;
255 }
256 let Some(declaration) = config.roles.get(expected_role) else {
257 continue;
258 };
259 if normalized_manifest_path(&package_manifest_path(&candidate, &declaration.package))
260 == normalized_manifest_path(&package.manifest_path)
261 {
262 return Some(candidate);
263 }
264 }
265 None
266}
267
268fn normalized_manifest_path(path: &Path) -> PathBuf {
269 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
270}
271
272fn validate_package_manifest(
273 manifest_path: &Path,
274 expected_fleet: &str,
275 expected_role: &CanisterRole,
276 mode: PackageValidationMode,
277 built_in: bool,
278) -> RolePackageValidation {
279 let metadata = match cargo_metadata_for_manifest(
280 manifest_path,
281 WASM_TARGET,
282 mode == PackageValidationMode::Passive,
283 ) {
284 Ok(metadata) => metadata,
285 Err(error) => {
286 let finding = if built_in {
287 RoleContractFinding::BuiltInPackageUnavailable {
288 role: BuiltInRoleKind::WasmStore,
289 }
290 } else {
291 RoleContractFinding::DependencyShapeUnsupported {
292 reason: format!(
293 "unable to inspect the local wasm runtime graph for {}: {error}",
294 manifest_path.display()
295 ),
296 }
297 };
298 return RolePackageValidation::Unsupported(finding);
299 }
300 };
301
302 match validate_metadata_package(&metadata, manifest_path, expected_fleet, expected_role) {
303 Ok(evidence) => RolePackageValidation::Supported(evidence),
304 Err(finding) => RolePackageValidation::Unsupported(finding),
305 }
306}
307
308fn validate_metadata_package(
309 metadata: &CargoMetadata,
310 manifest_path: &Path,
311 expected_fleet: &str,
312 expected_role: &CanisterRole,
313) -> Result<RolePackageEvidence, RoleContractFinding> {
314 validate_catalog()?;
315 let selected = exact_manifest_package(metadata, manifest_path, expected_role)?;
316 validate_package_metadata(selected, expected_fleet, expected_role)?;
317
318 let direct_dependency = direct_canic_dependency(selected, expected_role)?;
319 let dependency_key = direct_dependency
320 .rename
321 .as_deref()
322 .unwrap_or(CANIC_PACKAGE)
323 .to_string();
324 reject_package_feature_forwarding(selected, &dependency_key)?;
325
326 let package_by_id = metadata
327 .packages
328 .iter()
329 .map(|package| (package.id.as_str(), package))
330 .collect::<BTreeMap<_, _>>();
331 let node_by_id = metadata
332 .resolve
333 .as_ref()
334 .ok_or_else(|| unsupported_finding("cargo metadata omitted the resolved graph"))?
335 .nodes
336 .iter()
337 .map(|node| (node.id.as_str(), node))
338 .collect::<BTreeMap<_, _>>();
339 let selected_node = node_by_id
340 .get(selected.id.as_str())
341 .copied()
342 .ok_or_else(|| unsupported_finding("selected role package is absent from the graph"))?;
343 let dependency_edge_name = cargo_dependency_edge_name(&dependency_key);
344 let direct_edges = normal_dependencies(selected_node)
345 .filter(|dependency| dependency.name == dependency_edge_name)
346 .collect::<Vec<_>>();
347 let [direct_edge] = direct_edges.as_slice() else {
348 return Err(unsupported_finding(
349 "the direct Canic dependency did not resolve to exactly one wasm runtime edge",
350 ));
351 };
352 let canic_package = package_by_id
353 .get(direct_edge.pkg.as_str())
354 .copied()
355 .ok_or_else(|| unsupported_finding("resolved Canic package metadata is missing"))?;
356 if canic_package.name != CANIC_PACKAGE {
357 return Err(unsupported_finding(
358 "the direct runtime dependency does not resolve to package `canic`",
359 ));
360 }
361
362 validate_runtime_graph(selected_node, direct_edge, &package_by_id, &node_by_id)?;
363 if canic_package.version != env!("CARGO_PKG_VERSION") {
364 return Err(RoleContractFinding::CanicVersionMismatch {
365 expected: env!("CARGO_PKG_VERSION").to_string(),
366 actual: canic_package.version.clone(),
367 });
368 }
369 validate_cargo_catalog_parity(canic_package, &package_by_id, &node_by_id)?;
370
371 let mut default_features_enabled = direct_dependency.uses_default_features;
372 let mut direct_features = BTreeSet::new();
373 for feature_name in &direct_dependency.features {
374 if feature_name == "default" {
375 default_features_enabled = true;
376 continue;
377 }
378 let Some(feature) = CanicFeatureKey::from_cargo_name(feature_name) else {
379 return Err(RoleContractFinding::CargoCatalogDrift {
380 reason: format!(
381 "direct Canic dependency enables unclassified public feature `{feature_name}`"
382 ),
383 });
384 };
385 direct_features.insert(feature);
386 }
387
388 Ok(RolePackageEvidence {
389 fleet: expected_fleet.to_string(),
390 role: expected_role.clone(),
391 role_package_name: selected.name.clone(),
392 role_package_id: selected.id.clone(),
393 role_manifest_path: selected.manifest_path.clone(),
394 canic_package_id: canic_package.id.clone(),
395 canic_version: canic_package.version.clone(),
396 canic_source: canic_package.source.clone(),
397 canic_manifest_path: canic_package.manifest_path.clone(),
398 dependency_key,
399 default_features_enabled,
400 direct_features,
401 })
402}
403
404fn exact_manifest_package<'a>(
405 metadata: &'a CargoMetadata,
406 manifest_path: &Path,
407 role: &CanisterRole,
408) -> Result<&'a CargoMetadataPackage, RoleContractFinding> {
409 let expected = manifest_path
410 .canonicalize()
411 .unwrap_or_else(|_| manifest_path.to_path_buf());
412 let matches = metadata
413 .packages
414 .iter()
415 .filter(|package| {
416 package
417 .manifest_path
418 .canonicalize()
419 .unwrap_or_else(|_| package.manifest_path.clone())
420 == expected
421 })
422 .collect::<Vec<_>>();
423
424 match matches.as_slice() {
425 [package] => Ok(*package),
426 [] => Err(RoleContractFinding::PackageMissing { role: role.clone() }),
427 _ => Err(RoleContractFinding::PackageAmbiguous { role: role.clone() }),
428 }
429}
430
431fn validate_package_metadata(
432 package: &CargoMetadataPackage,
433 expected_fleet: &str,
434 expected_role: &CanisterRole,
435) -> Result<(), RoleContractFinding> {
436 let canic = package
437 .metadata
438 .as_ref()
439 .and_then(|metadata| metadata.get("canic"));
440 let actual_fleet = canic
441 .and_then(|metadata| metadata.get("fleet"))
442 .and_then(serde_json::Value::as_str)
443 .map(ToString::to_string);
444 let actual_role = canic
445 .and_then(|metadata| metadata.get("role"))
446 .and_then(serde_json::Value::as_str)
447 .map(ToString::to_string);
448
449 if actual_fleet.as_deref() == Some(expected_fleet)
450 && actual_role.as_deref() == Some(expected_role.as_str())
451 {
452 return Ok(());
453 }
454
455 Err(RoleContractFinding::PackageMetadataMismatch {
456 expected_fleet: expected_fleet.to_string(),
457 expected_role: expected_role.clone(),
458 actual_fleet,
459 actual_role,
460 })
461}
462
463fn direct_canic_dependency<'a>(
464 package: &'a CargoMetadataPackage,
465 role: &CanisterRole,
466) -> Result<&'a CargoMetadataDependency, RoleContractFinding> {
467 let dependencies = package
468 .dependencies
469 .iter()
470 .filter(|dependency| dependency.name == CANIC_PACKAGE && dependency.kind.is_none())
471 .collect::<Vec<_>>();
472 let [dependency] = dependencies.as_slice() else {
473 return if dependencies.is_empty() {
474 Err(RoleContractFinding::RuntimeCanicDependencyMissing { role: role.clone() })
475 } else {
476 Err(unsupported_finding(
477 "the role package declares more than one normal Canic dependency",
478 ))
479 };
480 };
481 if dependency.optional {
482 return Err(unsupported_finding(
483 "the direct normal Canic dependency must not be optional",
484 ));
485 }
486 if dependency.target.is_some() {
487 return Err(unsupported_finding(
488 "the direct normal Canic dependency must be unconditional",
489 ));
490 }
491 Ok(*dependency)
492}
493
494fn reject_package_feature_forwarding(
495 package: &CargoMetadataPackage,
496 dependency_key: &str,
497) -> Result<(), RoleContractFinding> {
498 let strong_prefix = format!("{dependency_key}/");
499 let weak_prefix = format!("{dependency_key}?/");
500 let optional_dependency = format!("dep:{dependency_key}");
501 if package.features.values().flatten().any(|member| {
502 member.starts_with(&strong_prefix)
503 || member.starts_with(&weak_prefix)
504 || member == &optional_dependency
505 }) {
506 return Err(unsupported_finding(
507 "package features must not forward features into the Canic dependency",
508 ));
509 }
510 Ok(())
511}
512
513fn validate_runtime_graph(
514 selected_node: &CargoMetadataNode,
515 direct_edge: &CargoMetadataNodeDependency,
516 package_by_id: &BTreeMap<&str, &CargoMetadataPackage>,
517 node_by_id: &BTreeMap<&str, &CargoMetadataNode>,
518) -> Result<(), RoleContractFinding> {
519 let reachable = reachable_normal_packages(selected_node, node_by_id);
520 let mut canic_package_ids = reachable
521 .iter()
522 .filter_map(|package_id| package_by_id.get(package_id.as_str()))
523 .filter(|package| package.name == CANIC_PACKAGE)
524 .map(|package| package.id.clone())
525 .collect::<BTreeSet<_>>();
526 canic_package_ids.insert(direct_edge.pkg.clone());
527 if canic_package_ids.len() != 1 {
528 return Err(RoleContractFinding::MultipleCanicPackages {
529 package_ids: canic_package_ids.into_iter().collect(),
530 });
531 }
532
533 for dependency in normal_dependencies(selected_node) {
534 if dependency.name == direct_edge.name && dependency.pkg == direct_edge.pkg {
535 continue;
536 }
537 if dependency.pkg == direct_edge.pkg {
538 return Err(unsupported_finding(
539 "the role package has more than one normal runtime path to Canic",
540 ));
541 }
542 let Some(node) = node_by_id.get(dependency.pkg.as_str()).copied() else {
543 continue;
544 };
545 if reachable_normal_packages(node, node_by_id).contains(&direct_edge.pkg) {
546 return Err(unsupported_finding(
547 "a transitive normal runtime dependency also contributes Canic",
548 ));
549 }
550 }
551
552 Ok(())
553}
554
555fn reachable_normal_packages(
556 start: &CargoMetadataNode,
557 node_by_id: &BTreeMap<&str, &CargoMetadataNode>,
558) -> BTreeSet<String> {
559 let mut reachable = BTreeSet::new();
560 let mut frontier = normal_dependencies(start)
561 .map(|dependency| dependency.pkg.clone())
562 .collect::<Vec<_>>();
563 while let Some(package_id) = frontier.pop() {
564 if !reachable.insert(package_id.clone()) {
565 continue;
566 }
567 if let Some(node) = node_by_id.get(package_id.as_str()) {
568 frontier.extend(normal_dependencies(node).map(|dependency| dependency.pkg.clone()));
569 }
570 }
571 reachable
572}
573
574fn normal_dependencies(
575 node: &CargoMetadataNode,
576) -> impl Iterator<Item = &CargoMetadataNodeDependency> {
577 node.deps
578 .iter()
579 .filter(|dependency| dependency.dep_kinds.iter().any(|kind| kind.kind.is_none()))
580}
581
582fn validate_cargo_catalog_parity(
583 canic_package: &CargoMetadataPackage,
584 package_by_id: &BTreeMap<&str, &CargoMetadataPackage>,
585 node_by_id: &BTreeMap<&str, &CargoMetadataNode>,
586) -> Result<(), RoleContractFinding> {
587 let cargo_public_features = canic_package
588 .features
589 .keys()
590 .filter(|name| name.as_str() != "default")
591 .cloned()
592 .collect::<BTreeSet<_>>();
593 let catalog_public_features = feature_definitions()
594 .iter()
595 .map(|definition| definition.cargo_name.to_string())
596 .collect::<BTreeSet<_>>();
597 if cargo_public_features != catalog_public_features {
598 return Err(RoleContractFinding::CargoCatalogDrift {
599 reason: "resolved Canic public features differ from the role-contract catalog"
600 .to_string(),
601 });
602 }
603
604 let cargo_defaults = canic_package
605 .features
606 .get("default")
607 .cloned()
608 .unwrap_or_default()
609 .into_iter()
610 .collect::<BTreeSet<_>>();
611 let catalog_defaults = default_features()
612 .iter()
613 .map(|feature| feature.cargo_name().to_string())
614 .collect::<BTreeSet<_>>();
615 if cargo_defaults != catalog_defaults {
616 return Err(RoleContractFinding::CargoCatalogDrift {
617 reason: "resolved Canic default features differ from the role-contract catalog"
618 .to_string(),
619 });
620 }
621
622 let canic_node = node_by_id
623 .get(canic_package.id.as_str())
624 .copied()
625 .ok_or_else(|| cargo_catalog_drift("resolved Canic graph node is missing"))?;
626 let core_dependency = canic_package
627 .dependencies
628 .iter()
629 .find(|dependency| {
630 dependency.name == CANIC_CORE_PACKAGE
631 && dependency.kind.is_none()
632 && dependency.target.is_none()
633 })
634 .ok_or_else(|| cargo_catalog_drift("resolved Canic core dependency is missing"))?;
635 let core_key = core_dependency
636 .rename
637 .as_deref()
638 .unwrap_or(CANIC_CORE_PACKAGE);
639 let core_edge_name = cargo_dependency_edge_name(core_key);
640 let core_edge = normal_dependencies(canic_node)
641 .find(|dependency| dependency.name == core_edge_name)
642 .ok_or_else(|| cargo_catalog_drift("resolved Canic core graph edge is missing"))?;
643 let core_package = package_by_id
644 .get(core_edge.pkg.as_str())
645 .copied()
646 .ok_or_else(|| cargo_catalog_drift("resolved Canic core package is missing"))?;
647
648 let cargo_implications = cargo_public_implications(
649 &canic_package.features,
650 &core_package.features,
651 core_key,
652 &cargo_public_features,
653 );
654 let catalog_implications = CanicFeatureKey::ALL
655 .iter()
656 .flat_map(|feature| {
657 implied_features(*feature).map(|implied| {
658 (
659 feature.cargo_name().to_string(),
660 implied.cargo_name().to_string(),
661 )
662 })
663 })
664 .collect::<BTreeSet<_>>();
665 if cargo_implications != catalog_implications {
666 return Err(RoleContractFinding::CargoCatalogDrift {
667 reason:
668 "resolved Canic public feature implications differ from the role-contract catalog"
669 .to_string(),
670 });
671 }
672
673 Ok(())
674}
675
676fn cargo_public_implications(
677 canic_features: &BTreeMap<String, Vec<String>>,
678 core_features: &BTreeMap<String, Vec<String>>,
679 core_dependency_key: &str,
680 public_features: &BTreeSet<String>,
681) -> BTreeSet<(String, String)> {
682 let mut implications = BTreeSet::new();
683 let core_prefix = format!("{core_dependency_key}/");
684
685 for feature in public_features {
686 for member in canic_features.get(feature).into_iter().flatten() {
687 if public_features.contains(member) {
688 implications.insert((feature.clone(), member.clone()));
689 continue;
690 }
691 let Some(core_feature) = member.strip_prefix(&core_prefix) else {
692 continue;
693 };
694 for core_member in core_features.get(core_feature).into_iter().flatten() {
695 if public_features.contains(core_member) {
696 implications.insert((feature.clone(), core_member.clone()));
697 }
698 }
699 }
700 }
701
702 implications
703}
704
705fn package_manifest_path(config_path: &Path, package: &str) -> PathBuf {
706 let package_path = PathBuf::from(package);
707 let path = if package_path.is_absolute() {
708 package_path
709 } else {
710 config_path
711 .parent()
712 .map_or_else(|| PathBuf::from(package), |parent| parent.join(package))
713 };
714 if path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml") {
715 path
716 } else {
717 path.join("Cargo.toml")
718 }
719}
720
721fn cargo_dependency_edge_name(dependency_key: &str) -> String {
722 dependency_key.replace('-', "_")
723}
724
725const fn unsupported_shape(reason: String) -> RolePackageValidation {
726 RolePackageValidation::Unsupported(RoleContractFinding::DependencyShapeUnsupported { reason })
727}
728
729fn unsupported_finding(reason: impl Into<String>) -> RoleContractFinding {
730 RoleContractFinding::DependencyShapeUnsupported {
731 reason: reason.into(),
732 }
733}
734
735fn cargo_catalog_drift(reason: impl Into<String>) -> RoleContractFinding {
736 RoleContractFinding::CargoCatalogDrift {
737 reason: reason.into(),
738 }
739}
740
741#[cfg(test)]
742mod tests;