use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::sync::Arc;
use crate::check_config::{CheckConfig, FeatureBoundaryRule};
use crate::violation::{Violation, ViolationType};
use super::ProjectContext;
use super::cargo_meta::{CargoDependency, CargoMetadata, CargoPackage};
pub(super) fn check_feature_boundary(
ctx: &ProjectContext<'_>,
config: &CheckConfig,
violations: &mut Vec<Violation>,
) {
if !config.check_feature_boundary {
return;
}
let Some(metadata) = ctx.metadata else {
return;
};
for rule in config.feature_boundaries.iter() {
match rule.rule.as_ref() {
"dev-only" => check_dev_only(metadata, rule, violations),
"no-default" => check_no_default(metadata, rule, violations),
_ => {}
}
}
}
fn emit(violations: &mut Vec<Violation>, message: String) {
violations.push(Violation::new(
ViolationType::FeatureBoundary,
Arc::from("Cargo.toml"),
1,
1,
message,
));
}
fn check_dev_only(
meta: &CargoMetadata,
rule: &FeatureBoundaryRule,
violations: &mut Vec<Violation>,
) {
let Some(target) = meta
.packages
.iter()
.find(|p| p.name.as_str() == &*rule.package)
else {
return;
};
for pkg in &meta.packages {
let offending = pkg
.dependencies
.iter()
.filter(|dep| is_constrained_normal_dep(dep, rule))
.filter(|dep| edge_enables(target, dep, &rule.feature));
for dep in offending {
emit_dev_only(violations, pkg, dep, rule);
}
}
}
fn is_constrained_normal_dep(dep: &CargoDependency, rule: &FeatureBoundaryRule) -> bool {
dep.name.as_str() == &*rule.package && dep.kind.as_deref() != Some("dev")
}
fn emit_dev_only(
violations: &mut Vec<Violation>,
pkg: &CargoPackage,
dep: &CargoDependency,
rule: &FeatureBoundaryRule,
) {
let kind = dep.kind.as_deref().unwrap_or("normal");
emit(
violations,
format!(
"`{}` enables feature `{}` on `{}` through a {kind} dependency; `{}` must be enabled only via dev-dependencies",
pkg.name, rule.feature, rule.package, rule.feature
),
);
}
fn edge_enables(target: &CargoPackage, dep: &CargoDependency, feature: &str) -> bool {
let mut seeds: Vec<&str> = dep.features.iter().map(String::as_str).collect();
if dep.uses_default_features {
seeds.push("default");
}
feature_closure_contains(&target.features, &seeds, feature)
}
fn feature_closure_contains(
features: &BTreeMap<String, Vec<String>>,
seeds: &[&str],
target: &str,
) -> bool {
let mut stack: Vec<&str> = seeds.to_vec();
let mut seen: BTreeSet<&str> = BTreeSet::new();
while let Some(feature) = stack.pop() {
if feature == target {
return true;
}
if !seen.insert(feature) {
continue;
}
let Some(items) = features.get(feature) else {
continue;
};
stack.extend(
items
.iter()
.filter(|i| is_plain_feature(i))
.map(String::as_str),
);
}
false
}
fn is_plain_feature(item: &str) -> bool {
!item.contains('/') && !item.contains(':')
}
fn check_no_default(
meta: &CargoMetadata,
rule: &FeatureBoundaryRule,
violations: &mut Vec<Violation>,
) {
if default_reaches(meta, &rule.package, &rule.feature) {
emit(
violations,
format!(
"feature `{}` on `{}` is reachable from a crate default feature; it must not be default-enabled",
rule.feature, rule.package
),
);
}
}
fn default_reaches(meta: &CargoMetadata, target_pkg: &str, target_feat: &str) -> bool {
let by_name: BTreeMap<&str, &CargoPackage> =
meta.packages.iter().map(|p| (p.name.as_str(), p)).collect();
let mut queue: VecDeque<(&str, &str)> = VecDeque::new();
for id in root_member_ids(meta) {
let Some(pkg) = meta.packages.iter().find(|p| &p.id == id) else {
continue;
};
queue.push_back((pkg.name.as_str(), ""));
queue.push_back((pkg.name.as_str(), "default"));
}
let mut active: BTreeSet<(&str, &str)> = BTreeSet::new();
while let Some((pkg_name, feature)) = queue.pop_front() {
if pkg_name == target_pkg && feature == target_feat {
return true;
}
if !active.insert((pkg_name, feature)) {
continue;
}
let Some(pkg) = by_name.get(pkg_name) else {
continue;
};
match feature.is_empty() {
true => enqueue_active_deps(pkg, &mut queue),
false => enqueue_feature_items(pkg, feature, &mut queue),
}
}
false
}
fn root_member_ids(meta: &CargoMetadata) -> &[String] {
match meta.workspace_default_members.is_empty() {
false => &meta.workspace_default_members,
true => &meta.workspace_members,
}
}
fn enqueue_active_deps<'a>(pkg: &'a CargoPackage, queue: &mut VecDeque<(&'a str, &'a str)>) {
for dep in &pkg.dependencies {
if dep.kind.as_deref() == Some("dev") || dep.optional {
continue;
}
enqueue_dep(dep, queue);
}
}
fn enqueue_feature_items<'a>(
pkg: &'a CargoPackage,
feature: &str,
queue: &mut VecDeque<(&'a str, &'a str)>,
) {
let Some(items) = pkg.features.get(feature) else {
return;
};
for item in items {
enqueue_feature_item(pkg, item, queue);
}
}
fn enqueue_feature_item<'a>(
pkg: &'a CargoPackage,
item: &'a str,
queue: &mut VecDeque<(&'a str, &'a str)>,
) {
match parse_feature_item(item) {
FeatureItem::Plain(name) => queue.push_back((pkg.name.as_str(), name)),
FeatureItem::DepFeature { dep, feature } => enqueue_dep_feature(pkg, dep, feature, queue),
FeatureItem::DepOnly(dep) => enqueue_dep_only(pkg, dep, queue),
}
}
fn enqueue_dep_feature<'a>(
pkg: &'a CargoPackage,
dep: &str,
feature: &'a str,
queue: &mut VecDeque<(&'a str, &'a str)>,
) {
let Some(declaration) = resolve_dep(pkg, dep) else {
return;
};
enqueue_dep(declaration, queue);
queue.push_back((declaration.name.as_str(), feature));
}
fn enqueue_dep_only<'a>(
pkg: &'a CargoPackage,
dep: &str,
queue: &mut VecDeque<(&'a str, &'a str)>,
) {
let Some(declaration) = resolve_dep(pkg, dep) else {
return;
};
enqueue_dep(declaration, queue);
}
fn enqueue_dep<'a>(dep: &'a CargoDependency, queue: &mut VecDeque<(&'a str, &'a str)>) {
queue.push_back((dep.name.as_str(), ""));
if dep.uses_default_features {
queue.push_back((dep.name.as_str(), "default"));
}
for feature in &dep.features {
queue.push_back((dep.name.as_str(), feature));
}
}
fn resolve_dep<'a>(pkg: &'a CargoPackage, local: &str) -> Option<&'a CargoDependency> {
pkg.dependencies.iter().find(|d| d.local_name() == local)
}
enum FeatureItem<'a> {
Plain(&'a str),
DepOnly(&'a str),
DepFeature { dep: &'a str, feature: &'a str },
}
fn parse_feature_item(item: &str) -> FeatureItem<'_> {
if let Some(rest) = item.strip_prefix("dep:") {
return FeatureItem::DepOnly(rest);
}
match item.split_once('/') {
Some((dep, feature)) => FeatureItem::DepFeature {
dep: dep.trim_end_matches('?'),
feature,
},
None => FeatureItem::Plain(item),
}
}