#![allow(clippy::similar_names)]
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use chrono;
use serde::{Deserialize, Serialize};
use toml::Value;
use crate::{OutputType, matches_pattern, should_skip_feature};
type WorkspacePackages = BTreeSet<String>;
type PackagePaths = BTreeMap<String, String>;
type PackageCargoValues = BTreeMap<String, Value>;
type WorkspaceData = (WorkspacePackages, PackagePaths, PackageCargoValues);
pub const DEFAULT_SKIP_FEATURES: &[&str] = &["default", "_*"];
#[must_use]
pub fn resolve_skip_features(configured: &Option<Vec<String>>) -> Vec<String> {
configured.as_ref().map_or_else(
|| {
DEFAULT_SKIP_FEATURES
.iter()
.map(|s| (*s).to_string())
.collect()
},
Clone::clone,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum OverrideSource {
Cli = 0,
PackageClippierToml = 1,
CargoTomlMetadata = 2,
WorkspaceClippierToml = 3,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum OverrideType {
AllowMissing,
AllowIncorrect,
Suppress,
}
#[derive(Debug, Clone)]
pub struct ValidationOverride {
pub feature: String,
pub dependency: String,
pub package: Option<String>,
pub override_type: OverrideType,
pub reason: Option<String>,
pub expires: Option<String>,
pub source: OverrideSource,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum StringOrArray {
Single(String),
Multiple(Vec<String>),
}
impl StringOrArray {
#[must_use]
pub fn to_vec(&self) -> Vec<String> {
match self {
Self::Single(s) => vec![s.clone()],
Self::Multiple(v) => v.clone(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct OverrideConfigEntry {
pub feature: String,
#[serde(alias = "dependencies")]
pub dependency: StringOrArray,
#[serde(rename = "type")]
pub override_type: OverrideType,
pub reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct FeatureValidationConfig {
#[serde(default, rename = "override")]
pub overrides: Vec<OverrideConfigEntry>,
#[serde(default)]
pub parent: Option<ParentPackageConfig>,
#[serde(default)]
pub parent_packages: Vec<WorkspaceParentPackage>,
#[serde(default)]
pub parent_prefix: Vec<PrefixOverride>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ParentPackageConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub depth: Option<u8>,
#[serde(default)]
pub skip_features: Option<Vec<String>>,
#[serde(default)]
pub prefix: Vec<PrefixOverride>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct WorkspaceParentPackage {
pub package: String,
#[serde(default)]
pub depth: Option<u8>,
#[serde(default)]
pub skip_features: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct PrefixOverride {
pub dependency: String,
pub prefix: String,
}
#[derive(Debug, Serialize)]
pub struct ParentValidationResult {
pub package: String,
pub missing_exposures: Vec<MissingFeatureExposure>,
pub features_checked: usize,
pub features_exposed: usize,
}
#[derive(Debug, Serialize)]
pub struct MissingFeatureExposure {
pub parent_package: String,
pub dependency: String,
pub dependency_feature: String,
pub expected_parent_feature: String,
pub expected_propagation: String,
pub depth: u8,
pub chain: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct ValidationResult {
pub total_packages: usize,
pub valid_packages: usize,
pub errors: Vec<PackageValidationError>,
pub warnings: Vec<PackageValidationWarning>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub overridden_errors: Vec<OverriddenError>,
#[serde(skip_serializing_if = "Option::is_none")]
pub override_summary: Option<OverrideSummary>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub parent_results: Vec<ParentValidationResult>,
}
#[derive(Debug, Serialize)]
pub struct OverriddenError {
pub package: String,
pub feature: String,
pub dependency: String,
pub expected: String,
pub original_reason: String,
pub override_info: OverrideInfo,
}
#[derive(Debug, Serialize)]
pub struct OverrideInfo {
#[serde(rename = "type")]
pub override_type: OverrideType,
pub reason: Option<String>,
pub source: OverrideSource,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct OverrideSummary {
pub total_applied: usize,
pub by_source: BTreeMap<String, usize>,
pub by_type: BTreeMap<String, usize>,
pub expired: usize,
}
#[derive(Debug, Serialize)]
pub struct PackageValidationError {
pub package: String,
pub errors: Vec<FeatureError>,
}
#[derive(Debug, Serialize)]
pub struct FeatureError {
pub feature: String,
pub missing_propagations: Vec<MissingPropagation>,
pub incorrect_propagations: Vec<IncorrectPropagation>,
}
#[derive(Debug, Serialize, Clone)]
pub struct MissingPropagation {
pub dependency: String,
pub expected: String,
pub reason: String,
}
#[derive(Debug, Serialize, Clone)]
pub struct IncorrectPropagation {
pub entry: String,
pub reason: String,
}
#[derive(Debug, Serialize)]
pub struct PackageValidationWarning {
pub package: String,
pub message: String,
}
#[derive(Debug, Clone, Default)]
#[allow(clippy::struct_excessive_bools)]
pub struct OverrideOptions {
pub use_config_overrides: bool,
pub use_cargo_metadata_overrides: bool,
pub warn_expired: bool,
pub fail_on_expired: bool,
pub verbose_overrides: bool,
}
impl OverrideOptions {
#[must_use]
pub const fn enabled() -> Self {
Self {
use_config_overrides: true,
use_cargo_metadata_overrides: true,
warn_expired: true,
fail_on_expired: false,
verbose_overrides: false,
}
}
}
pub struct ValidatorConfig {
pub features: Option<Vec<String>>,
pub skip_features: Option<Vec<String>>,
pub workspace_only: bool,
pub output_format: OutputType,
pub strict_optional_propagation: bool,
pub cli_overrides: Vec<ValidationOverride>,
pub override_options: OverrideOptions,
pub ignore_packages: Vec<String>,
pub ignore_features: Vec<String>,
pub parent_config: ParentValidationConfig,
}
#[derive(Debug, Clone)]
pub struct ParentValidationConfig {
pub cli_packages: Vec<String>,
pub cli_depth: Option<u8>,
pub cli_skip_features: Vec<String>,
pub cli_prefix_overrides: Vec<PrefixOverride>,
pub use_config: bool,
}
impl Default for ValidatorConfig {
fn default() -> Self {
Self {
features: None,
skip_features: None,
workspace_only: true,
output_format: OutputType::Raw,
strict_optional_propagation: false,
cli_overrides: Vec::new(),
override_options: OverrideOptions::enabled(),
ignore_packages: Vec::new(),
ignore_features: Vec::new(),
parent_config: ParentValidationConfig::default(),
}
}
}
impl Default for ParentValidationConfig {
fn default() -> Self {
Self {
cli_packages: Vec::new(),
cli_depth: None,
cli_skip_features: Vec::new(),
cli_prefix_overrides: Vec::new(),
use_config: true,
}
}
}
impl ValidatorConfig {
#[must_use]
pub fn test_default() -> Self {
Self {
features: None,
skip_features: None,
workspace_only: true,
output_format: OutputType::Raw,
strict_optional_propagation: false,
cli_overrides: Vec::new(),
override_options: OverrideOptions::default(),
ignore_packages: Vec::new(),
ignore_features: Vec::new(),
parent_config: ParentValidationConfig {
use_config: false,
..ParentValidationConfig::default()
},
}
}
}
pub struct FeatureValidator {
workspace_packages: BTreeSet<String>,
package_cargo_values: BTreeMap<String, Value>,
package_paths: BTreeMap<String, String>,
workspace_root: PathBuf,
config: ValidatorConfig,
}
#[derive(Debug, Default)]
struct OverrideStats {
total_applied: usize,
by_source: BTreeMap<String, usize>,
by_type: BTreeMap<String, usize>,
expired: usize,
}
impl FeatureValidator {
pub fn new(path: Option<PathBuf>, config: ValidatorConfig) -> Result<Self> {
let workspace_root = find_workspace_root(path)?;
let (workspace_packages, package_paths, package_cargo_values) =
load_workspace_data(&workspace_root)?;
Ok(Self {
workspace_packages,
package_cargo_values,
package_paths,
workspace_root,
config,
})
}
pub fn validate(&self) -> Result<ValidationResult> {
let mut errors = Vec::new();
let mut warnings = Vec::new();
let mut overridden_errors = Vec::new();
let mut valid_count = 0;
let all_overrides = self.collect_all_overrides();
let mut override_stats = OverrideStats::default();
for override_rule in &all_overrides {
if let Some(ref expires) = override_rule.expires
&& Self::is_expired(expires)
{
override_stats.expired += 1;
if self.config.override_options.warn_expired {
warnings.push(PackageValidationWarning {
package: override_rule
.package
.as_ref()
.map_or_else(|| "*".to_string(), Clone::clone),
message: format!(
"Override expired: {}:{} (expired {})",
override_rule.feature, override_rule.dependency, expires
),
});
}
}
}
let packages_to_check: Vec<(&String, &Value)> = if self.config.workspace_only {
self.package_cargo_values
.iter()
.filter(|(name, _)| self.workspace_packages.contains(*name))
.collect()
} else {
self.package_cargo_values.iter().collect()
};
for (package_name, cargo_value) in packages_to_check {
if self.should_ignore_package(package_name) {
valid_count += 1;
continue;
}
match self.validate_package_with_overrides(
package_name,
cargo_value,
&all_overrides,
&mut override_stats,
) {
Ok((maybe_error, package_overridden)) => {
if let Some(error) = maybe_error {
errors.push(error);
} else {
valid_count += 1;
}
overridden_errors.extend(package_overridden);
}
Err(e) => warnings.push(PackageValidationWarning {
package: package_name.clone(),
message: format!("Failed to validate: {e}"),
}),
}
}
let override_summary = if !all_overrides.is_empty() || !overridden_errors.is_empty() {
Some(OverrideSummary {
total_applied: override_stats.total_applied,
by_source: override_stats.by_source,
by_type: override_stats.by_type,
expired: override_stats.expired,
})
} else {
None
};
let parent_results = self.validate_parent_packages(&mut warnings);
Ok(ValidationResult {
total_packages: valid_count + errors.len(),
valid_packages: valid_count,
errors,
warnings,
overridden_errors,
override_summary,
parent_results,
})
}
fn validate_package_with_overrides(
&self,
package_name: &str,
cargo_value: &Value,
overrides: &[ValidationOverride],
stats: &mut OverrideStats,
) -> Result<(Option<PackageValidationError>, Vec<OverriddenError>)> {
let features_to_check = self.get_features_to_check(package_name, cargo_value);
if features_to_check.is_empty() {
return Ok((None, Vec::new()));
}
let mut feature_errors = Vec::new();
let mut overridden_errors = Vec::new();
for feature in features_to_check {
if self.should_ignore_feature(&feature) {
continue;
}
let (missing, incorrect) =
self.validate_feature(package_name, &feature, cargo_value)?;
let (filtered_missing, overridden_missing) = Self::filter_missing_with_overrides(
package_name,
&feature,
missing,
overrides,
stats,
);
let (filtered_incorrect, overridden_incorrect) = Self::filter_incorrect_with_overrides(
package_name,
&feature,
incorrect,
overrides,
stats,
);
overridden_errors.extend(overridden_missing);
overridden_errors.extend(overridden_incorrect);
if !filtered_missing.is_empty() || !filtered_incorrect.is_empty() {
feature_errors.push(FeatureError {
feature: feature.clone(),
missing_propagations: filtered_missing,
incorrect_propagations: filtered_incorrect,
});
}
}
let error = if feature_errors.is_empty() {
None
} else {
Some(PackageValidationError {
package: package_name.to_string(),
errors: feature_errors,
})
};
Ok((error, overridden_errors))
}
fn get_features_to_check(&self, _package_name: &str, cargo_value: &Value) -> Vec<String> {
let Some(features_table) = cargo_value.get("features").and_then(|f| f.as_table()) else {
return Vec::new();
};
let skip_features_vec = resolve_skip_features(&self.config.skip_features);
self.config.features.as_ref().map_or_else(
|| {
let mut features_to_check = Vec::new();
for feature_name in features_table.keys() {
if should_skip_feature(feature_name, &skip_features_vec) {
continue;
}
if self.any_dependency_has_feature(cargo_value, feature_name) {
features_to_check.push(feature_name.clone());
}
}
features_to_check
},
|specific_features| {
specific_features
.iter()
.filter(|f| !should_skip_feature(f, &skip_features_vec))
.filter(|f| features_table.contains_key(*f))
.cloned()
.collect()
},
)
}
fn any_dependency_has_feature(&self, cargo_value: &Value, feature_name: &str) -> bool {
let deps = extract_all_dependencies(cargo_value, false);
for (dep_name, _) in deps {
if self.config.workspace_only && !self.workspace_packages.contains(&dep_name) {
continue;
}
if self.dependency_has_feature(&dep_name, feature_name) {
return true;
}
}
false
}
fn dependency_has_feature(&self, dep_name: &str, feature_name: &str) -> bool {
self.package_cargo_values
.get(dep_name)
.and_then(|v| v.get("features"))
.and_then(|f| f.as_table())
.is_some_and(|t| t.contains_key(feature_name))
}
fn validate_feature(
&self,
_package_name: &str,
feature_name: &str,
cargo_value: &Value,
) -> Result<(Vec<MissingPropagation>, Vec<IncorrectPropagation>)> {
let mut missing = Vec::new();
let mut incorrect = Vec::new();
let feature_def = cargo_value
.get("features")
.and_then(|f| f.get(feature_name))
.and_then(|f| f.as_array())
.ok_or_else(|| anyhow!("Feature {feature_name} not found"))?;
let expected = self.get_expected_propagations(cargo_value, feature_name);
let actual = parse_feature_propagations(feature_def);
for (dep_name, expected_entry) in &expected {
let is_propagated = if self.config.strict_optional_propagation {
actual.contains(expected_entry)
} else {
if expected_entry.contains('?') {
let without_question = expected_entry.replace("?/", "/");
actual.contains(expected_entry) || actual.contains(&without_question)
} else {
actual.contains(expected_entry)
}
};
if !is_propagated {
missing.push(MissingPropagation {
dependency: dep_name.clone(),
expected: expected_entry.clone(),
reason: format!(
"Dependency '{dep_name}' has feature '{feature_name}' but it's not propagated"
),
});
}
}
for entry in &actual {
if let Some(dep_name) = extract_dependency_name(entry) {
if self.config.workspace_only && !self.workspace_packages.contains(&dep_name) {
continue;
}
let entry_feature = entry.split('/').nth(1).unwrap_or(feature_name);
if !expected.values().any(|e| e == entry) {
let all_deps = extract_all_dependencies(cargo_value, true);
let is_direct_dep = all_deps.iter().any(|(n, _)| n == &dep_name);
if !is_direct_dep {
incorrect.push(IncorrectPropagation {
entry: entry.clone(),
reason: format!(
"'{dep_name}' is not a direct dependency of this package"
),
});
} else if !self.dependency_has_feature(&dep_name, entry_feature) {
incorrect.push(IncorrectPropagation {
entry: entry.clone(),
reason: format!(
"Dependency '{dep_name}' doesn't have feature '{entry_feature}'"
),
});
}
}
}
}
Ok((missing, incorrect))
}
fn collect_all_overrides(&self) -> Vec<ValidationOverride> {
let mut overrides = Vec::new();
if self.config.override_options.use_config_overrides
&& let Ok(workspace_overrides) = self.load_workspace_clippier_overrides()
{
overrides.extend(workspace_overrides);
}
if self.config.override_options.use_cargo_metadata_overrides {
for (package_name, cargo_value) in &self.package_cargo_values {
let metadata_overrides =
Self::load_cargo_metadata_overrides(package_name, cargo_value);
overrides.extend(metadata_overrides);
}
}
if self.config.override_options.use_config_overrides {
for package_name in &self.workspace_packages {
if let Ok(package_overrides) = self.load_package_clippier_overrides(package_name) {
overrides.extend(package_overrides);
}
}
}
overrides.extend(self.config.cli_overrides.clone());
overrides.sort_by_key(|o| o.source);
overrides
}
fn load_workspace_clippier_overrides(&self) -> Result<Vec<ValidationOverride>> {
let config_path = self.workspace_root.join("clippier.toml");
if !switchy_fs::exists(&config_path) {
return Ok(Vec::new());
}
let content = switchy_fs::sync::read_to_string(&config_path)?;
let value: Value = toml::from_str(&content)?;
let mut overrides = Vec::new();
if let Some(feature_validation) = value.get("feature-validation")
&& let Some(config) = feature_validation
.get("override")
.and_then(|v| v.as_array())
{
for entry in config {
if let Ok(override_entry) = entry.clone().try_into::<OverrideConfigEntry>() {
for dependency in override_entry.dependency.to_vec() {
overrides.push(ValidationOverride {
feature: override_entry.feature.clone(),
dependency,
package: None,
override_type: override_entry.override_type,
reason: Some(override_entry.reason.clone()),
expires: override_entry.expires.clone(),
source: OverrideSource::WorkspaceClippierToml,
});
}
}
}
}
Ok(overrides)
}
fn load_package_clippier_overrides(
&self,
package_name: &str,
) -> Result<Vec<ValidationOverride>> {
let package_path = self
.package_paths
.get(package_name)
.ok_or_else(|| anyhow!("Package path not found for {package_name}"))?;
let config_path = self.workspace_root.join(package_path).join("clippier.toml");
if !switchy_fs::exists(&config_path) {
return Ok(Vec::new());
}
let content = switchy_fs::sync::read_to_string(&config_path)?;
let value: Value = toml::from_str(&content)?;
let mut overrides = Vec::new();
if let Some(feature_validation) = value.get("feature-validation")
&& let Some(config) = feature_validation
.get("override")
.and_then(|v| v.as_array())
{
for entry in config {
if let Ok(override_entry) = entry.clone().try_into::<OverrideConfigEntry>() {
for dependency in override_entry.dependency.to_vec() {
overrides.push(ValidationOverride {
feature: override_entry.feature.clone(),
dependency,
package: Some(package_name.to_string()),
override_type: override_entry.override_type,
reason: Some(override_entry.reason.clone()),
expires: override_entry.expires.clone(),
source: OverrideSource::PackageClippierToml,
});
}
}
}
}
Ok(overrides)
}
fn load_cargo_metadata_overrides(
package_name: &str,
cargo_value: &Value,
) -> Vec<ValidationOverride> {
let mut overrides = Vec::new();
if let Some(metadata) = cargo_value
.get("package")
.and_then(|p| p.get("metadata"))
.and_then(|m| m.get("clippier"))
.and_then(|c| c.get("feature-validation"))
&& let Some(config) = metadata.get("override").and_then(|v| v.as_array())
{
for entry in config {
if let Ok(override_entry) = entry.clone().try_into::<OverrideConfigEntry>() {
for dependency in override_entry.dependency.to_vec() {
overrides.push(ValidationOverride {
feature: override_entry.feature.clone(),
dependency,
package: Some(package_name.to_string()),
override_type: override_entry.override_type,
reason: Some(override_entry.reason.clone()),
expires: override_entry.expires.clone(),
source: OverrideSource::CargoTomlMetadata,
});
}
}
}
}
overrides
}
fn matches_override(
override_rule: &ValidationOverride,
package: &str,
feature: &str,
dependency: &str,
) -> bool {
if let Some(ref pkg_pattern) = override_rule.package
&& !matches_pattern(package, pkg_pattern)
{
return false;
}
matches_pattern(feature, &override_rule.feature)
&& matches_pattern(dependency, &override_rule.dependency)
}
fn find_override_for_missing<'a>(
package: &str,
feature: &str,
dependency: &str,
overrides: &'a [ValidationOverride],
) -> Option<&'a ValidationOverride> {
overrides.iter().find(|override_rule| {
(override_rule.override_type == OverrideType::AllowMissing
|| override_rule.override_type == OverrideType::Suppress)
&& Self::matches_override(override_rule, package, feature, dependency)
})
}
fn find_override_for_incorrect<'a>(
package: &str,
feature: &str,
entry: &str,
overrides: &'a [ValidationOverride],
) -> Option<&'a ValidationOverride> {
let dependency = entry
.split('/')
.next()
.unwrap_or(entry)
.trim_end_matches('?');
overrides.iter().find(|override_rule| {
(override_rule.override_type == OverrideType::AllowIncorrect
|| override_rule.override_type == OverrideType::Suppress)
&& Self::matches_override(override_rule, package, feature, dependency)
})
}
fn should_ignore_package(&self, package: &str) -> bool {
self.config
.ignore_packages
.iter()
.any(|pattern| matches_pattern(package, pattern))
}
fn should_ignore_feature(&self, feature: &str) -> bool {
self.config
.ignore_features
.iter()
.any(|pattern| matches_pattern(feature, pattern))
}
fn filter_missing_with_overrides(
package: &str,
feature: &str,
missing: Vec<MissingPropagation>,
overrides: &[ValidationOverride],
stats: &mut OverrideStats,
) -> (Vec<MissingPropagation>, Vec<OverriddenError>) {
let mut filtered = Vec::new();
let mut overridden = Vec::new();
for prop in missing {
if let Some(override_rule) =
Self::find_override_for_missing(package, feature, &prop.dependency, overrides)
{
Self::record_override_stat(override_rule, stats);
overridden.push(OverriddenError {
package: package.to_string(),
feature: feature.to_string(),
dependency: prop.dependency.clone(),
expected: prop.expected.clone(),
original_reason: prop.reason.clone(),
override_info: OverrideInfo {
override_type: override_rule.override_type,
reason: override_rule.reason.clone(),
source: override_rule.source,
expires: override_rule.expires.clone(),
},
});
} else {
filtered.push(prop);
}
}
(filtered, overridden)
}
fn filter_incorrect_with_overrides(
package: &str,
feature: &str,
incorrect: Vec<IncorrectPropagation>,
overrides: &[ValidationOverride],
stats: &mut OverrideStats,
) -> (Vec<IncorrectPropagation>, Vec<OverriddenError>) {
let mut filtered = Vec::new();
let mut overridden = Vec::new();
for prop in incorrect {
if let Some(override_rule) =
Self::find_override_for_incorrect(package, feature, &prop.entry, overrides)
{
Self::record_override_stat(override_rule, stats);
let dependency = prop
.entry
.split('/')
.next()
.unwrap_or(&prop.entry)
.trim_end_matches('?')
.to_string();
overridden.push(OverriddenError {
package: package.to_string(),
feature: feature.to_string(),
dependency,
expected: prop.entry.clone(),
original_reason: prop.reason.clone(),
override_info: OverrideInfo {
override_type: override_rule.override_type,
reason: override_rule.reason.clone(),
source: override_rule.source,
expires: override_rule.expires.clone(),
},
});
} else {
filtered.push(prop);
}
}
(filtered, overridden)
}
fn record_override_stat(override_rule: &ValidationOverride, stats: &mut OverrideStats) {
stats.total_applied += 1;
let source_key = match override_rule.source {
OverrideSource::Cli => "cli",
OverrideSource::PackageClippierToml => "package-clippier-toml",
OverrideSource::CargoTomlMetadata => "cargo-toml-metadata",
OverrideSource::WorkspaceClippierToml => "workspace-clippier-toml",
};
*stats.by_source.entry(source_key.to_string()).or_insert(0) += 1;
let type_key = match override_rule.override_type {
OverrideType::AllowMissing => "allow-missing",
OverrideType::AllowIncorrect => "allow-incorrect",
OverrideType::Suppress => "suppress",
};
*stats.by_type.entry(type_key.to_string()).or_insert(0) += 1;
}
fn is_expired(expires: &str) -> bool {
if let Ok(expiry_date) = chrono::DateTime::parse_from_rfc3339(expires) {
return chrono::Utc::now() > expiry_date;
}
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(expires, "%Y-%m-%d") {
let expiry_datetime = naive_date
.and_hms_opt(23, 59, 59)
.unwrap()
.and_local_timezone(chrono::Utc)
.unwrap();
return chrono::Utc::now() > expiry_datetime;
}
false
}
fn get_expected_propagations(
&self,
cargo_value: &Value,
feature_name: &str,
) -> BTreeMap<String, String> {
let mut expected = BTreeMap::new();
let deps = extract_all_dependencies(cargo_value, false);
for (dep_name, is_optional) in deps {
if self.config.workspace_only && !self.workspace_packages.contains(&dep_name) {
continue;
}
if self.dependency_has_feature(&dep_name, feature_name) {
let propagation = if is_optional {
format!("{dep_name}?/{feature_name}")
} else {
format!("{dep_name}/{feature_name}")
};
expected.insert(dep_name.clone(), propagation);
}
}
expected
}
fn validate_parent_packages(
&self,
warnings: &mut Vec<PackageValidationWarning>,
) -> Vec<ParentValidationResult> {
let parent_configs = self.collect_parent_configs(warnings);
if parent_configs.is_empty() {
return Vec::new();
}
let mut results = Vec::new();
for (package_name, config) in parent_configs {
if let Some(cargo_value) = self.package_cargo_values.get(&package_name) {
let result = self.validate_single_parent_package(
&package_name,
cargo_value,
&config,
warnings,
);
results.push(result);
} else {
warnings.push(PackageValidationWarning {
package: package_name.clone(),
message: format!("Parent package '{package_name}' not found in workspace"),
});
}
}
results
}
fn collect_parent_configs(
&self,
warnings: &mut Vec<PackageValidationWarning>,
) -> BTreeMap<String, ResolvedParentConfig> {
let mut configs: BTreeMap<String, ResolvedParentConfig> = BTreeMap::new();
if self.config.parent_config.use_config
&& let Ok(workspace_configs) = self.load_workspace_parent_configs()
{
for (pkg_name, pkg_config) in workspace_configs {
configs.insert(pkg_name, pkg_config);
}
}
if self.config.parent_config.use_config {
for package_name in &self.workspace_packages {
if let Ok(Some(pkg_config)) = self.load_package_parent_config(package_name) {
configs.insert(package_name.clone(), pkg_config);
}
}
}
for pkg_name in &self.config.parent_config.cli_packages {
let existing = configs.get(pkg_name);
let resolved = ResolvedParentConfig {
depth: self
.config
.parent_config
.cli_depth
.or_else(|| existing.and_then(|e| e.depth)),
skip_features: if self.config.parent_config.cli_skip_features.is_empty() {
existing.and_then(|e| e.skip_features.clone())
} else {
let mut merged = existing
.and_then(|e| e.skip_features.clone())
.unwrap_or_else(|| {
DEFAULT_SKIP_FEATURES
.iter()
.map(|s| (*s).to_string())
.collect()
});
merged.extend(self.config.parent_config.cli_skip_features.clone());
Some(merged)
},
prefix_overrides: {
let mut merged =
existing.map_or_else(BTreeMap::new, |e| e.prefix_overrides.clone());
for po in &self.config.parent_config.cli_prefix_overrides {
merged.insert(po.dependency.clone(), po.prefix.clone());
}
merged
},
};
configs.insert(pkg_name.clone(), resolved);
}
for pkg_name in configs.keys() {
if !self.workspace_packages.contains(pkg_name) {
warnings.push(PackageValidationWarning {
package: pkg_name.clone(),
message: format!("Parent package '{pkg_name}' not found in workspace"),
});
}
}
configs
}
fn load_workspace_parent_configs(&self) -> Result<BTreeMap<String, ResolvedParentConfig>> {
let config_path = self.workspace_root.join("clippier.toml");
if !switchy_fs::exists(&config_path) {
return Ok(BTreeMap::new());
}
let content = switchy_fs::sync::read_to_string(&config_path)?;
let value: Value = toml::from_str(&content)?;
let mut configs = BTreeMap::new();
if let Some(feature_validation) = value.get("feature-validation") {
let global_prefixes: BTreeMap<String, String> = feature_validation
.get("parent-prefix")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|entry| {
let dep = entry.get("dependency")?.as_str()?;
let prefix = entry.get("prefix")?.as_str()?;
Some((dep.to_string(), prefix.to_string()))
})
.collect()
})
.unwrap_or_default();
if let Some(parent_packages) = feature_validation
.get("parent-packages")
.and_then(|v| v.as_array())
{
for entry in parent_packages {
if let Some(pkg_name) = entry.get("package").and_then(|v| v.as_str()) {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let depth = entry
.get("depth")
.and_then(toml::Value::as_integer)
.map(|d| d as u8);
let skip_features = entry
.get("skip-features")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
});
configs.insert(
pkg_name.to_string(),
ResolvedParentConfig {
depth,
skip_features,
prefix_overrides: global_prefixes.clone(),
},
);
}
}
}
}
Ok(configs)
}
fn load_package_parent_config(
&self,
package_name: &str,
) -> Result<Option<ResolvedParentConfig>> {
let package_path = self
.package_paths
.get(package_name)
.ok_or_else(|| anyhow!("Package path not found for {package_name}"))?;
let config_path = self.workspace_root.join(package_path).join("clippier.toml");
if !switchy_fs::exists(&config_path) {
return Ok(None);
}
let content = switchy_fs::sync::read_to_string(&config_path)?;
let value: Value = toml::from_str(&content)?;
if let Some(feature_validation) = value.get("feature-validation")
&& let Some(parent) = feature_validation.get("parent")
{
let enabled = parent
.get("enabled")
.and_then(toml::Value::as_bool)
.unwrap_or(false);
if !enabled {
return Ok(None);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let depth = parent
.get("depth")
.and_then(toml::Value::as_integer)
.map(|d| d as u8);
let skip_features = parent
.get("skip-features")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
});
let prefix_overrides = parent
.get("prefix")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|entry| {
let dep = entry.get("dependency")?.as_str()?;
let prefix = entry.get("prefix")?.as_str()?;
Some((dep.to_string(), prefix.to_string()))
})
.collect()
})
.unwrap_or_default();
return Ok(Some(ResolvedParentConfig {
depth,
skip_features,
prefix_overrides,
}));
}
Ok(None)
}
fn validate_single_parent_package(
&self,
parent_name: &str,
parent_cargo: &Value,
config: &ResolvedParentConfig,
_warnings: &mut Vec<PackageValidationWarning>,
) -> ParentValidationResult {
let mut missing_exposures = Vec::new();
let mut features_checked = 0;
let mut features_exposed = 0;
let parent_features = get_all_feature_names(parent_cargo);
let skip_features = resolve_skip_features(&config.skip_features);
let deps = extract_all_dependencies(parent_cargo, false);
let workspace_deps: Vec<(String, bool)> = deps
.into_iter()
.filter(|(name, _)| self.workspace_packages.contains(name))
.collect();
let mut visited = BTreeSet::new();
visited.insert(parent_name.to_string());
for (dep_name, is_optional) in &workspace_deps {
if let Some(dep_cargo) = self.package_cargo_values.get(dep_name) {
self.validate_parent_dependency(
parent_name,
&parent_features,
dep_name,
dep_cargo,
*is_optional,
config,
&skip_features,
1,
&mut vec![parent_name.to_string(), dep_name.clone()],
&mut visited,
&mut missing_exposures,
&mut features_checked,
&mut features_exposed,
);
}
}
ParentValidationResult {
package: parent_name.to_string(),
missing_exposures,
features_checked,
features_exposed,
}
}
#[allow(clippy::too_many_arguments)]
fn validate_parent_dependency(
&self,
parent_name: &str,
parent_features: &BTreeSet<String>,
dep_name: &str,
dep_cargo: &Value,
is_optional: bool,
config: &ResolvedParentConfig,
skip_features: &[String],
current_depth: u8,
chain: &mut Vec<String>,
visited: &mut BTreeSet<String>,
missing_exposures: &mut Vec<MissingFeatureExposure>,
features_checked: &mut usize,
features_exposed: &mut usize,
) {
let prefix = config
.prefix_overrides
.get(dep_name)
.cloned()
.unwrap_or_else(|| infer_prefix(parent_name, dep_name));
let dep_features = get_all_feature_names(dep_cargo);
for dep_feature in &dep_features {
if should_skip_feature(dep_feature, skip_features) {
continue;
}
*features_checked += 1;
let expected_parent_feature = format!("{prefix}-{dep_feature}");
if parent_features.contains(&expected_parent_feature)
|| parent_features.contains(dep_feature)
{
*features_exposed += 1;
} else {
let expected_propagation = if is_optional {
format!("{dep_name}?/{dep_feature}")
} else {
format!("{dep_name}/{dep_feature}")
};
missing_exposures.push(MissingFeatureExposure {
parent_package: parent_name.to_string(),
dependency: dep_name.to_string(),
dependency_feature: dep_feature.clone(),
expected_parent_feature,
expected_propagation,
depth: current_depth,
chain: chain.clone(),
});
}
}
let should_recurse = config.depth.is_none_or(|max| current_depth < max);
if should_recurse {
let nested_deps = extract_all_dependencies(dep_cargo, false);
let nested_workspace_deps: Vec<(String, bool)> = nested_deps
.into_iter()
.filter(|(name, _)| {
self.workspace_packages.contains(name) && !visited.contains(name)
})
.collect();
for (nested_dep_name, nested_is_optional) in nested_workspace_deps {
if visited.insert(nested_dep_name.clone())
&& let Some(nested_cargo) = self.package_cargo_values.get(&nested_dep_name)
{
chain.push(nested_dep_name.clone());
self.validate_parent_dependency(
parent_name,
parent_features,
&nested_dep_name,
nested_cargo,
nested_is_optional,
config,
skip_features,
current_depth + 1,
chain,
visited,
missing_exposures,
features_checked,
features_exposed,
);
chain.pop();
}
}
}
}
}
#[derive(Debug, Clone, Default)]
struct ResolvedParentConfig {
depth: Option<u8>,
skip_features: Option<Vec<String>>,
prefix_overrides: BTreeMap<String, String>,
}
fn infer_prefix(parent: &str, dep: &str) -> String {
let parent_prefix = format!("{parent}_");
if dep.starts_with(&parent_prefix) {
dep[parent_prefix.len()..].replace('_', "-")
} else {
dep.replace('_', "-")
}
}
fn get_all_feature_names(cargo_value: &Value) -> BTreeSet<String> {
cargo_value
.get("features")
.and_then(|f| f.as_table())
.map(|t| t.keys().cloned().collect())
.unwrap_or_default()
}
fn find_workspace_root(path: Option<PathBuf>) -> Result<PathBuf> {
let start_dir = path.unwrap_or_else(|| std::env::current_dir().unwrap());
let mut current = start_dir.as_path();
loop {
let cargo_toml = current.join("Cargo.toml");
if switchy_fs::exists(&cargo_toml) {
let content = switchy_fs::sync::read_to_string(&cargo_toml)?;
let value: Value = toml::from_str(&content)?;
if value.get("workspace").is_some() {
return Ok(current.to_path_buf());
}
}
match current.parent() {
Some(parent) => current = parent,
None => break,
}
}
let cargo_toml = start_dir.join("Cargo.toml");
if switchy_fs::exists(&cargo_toml) {
Ok(start_dir)
} else {
Err(anyhow!("Could not find Cargo.toml or workspace root"))
}
}
fn load_workspace_data(workspace_root: &Path) -> Result<WorkspaceData> {
let workspace_cargo_path = workspace_root.join("Cargo.toml");
let workspace_source = switchy_fs::sync::read_to_string(&workspace_cargo_path)?;
let workspace_value: Value = toml::from_str(&workspace_source)?;
let workspace_members = workspace_value
.get("workspace")
.and_then(|w| w.get("members"))
.and_then(|m| m.as_array())
.and_then(|a| a.iter().map(|v| v.as_str()).collect::<Option<Vec<_>>>())
.unwrap_or_else(|| vec!["."]);
let mut workspace_packages = BTreeSet::new();
let mut package_paths = BTreeMap::new();
let mut package_cargo_values = BTreeMap::new();
for member_path in workspace_members {
let full_path = if member_path == "." {
workspace_root.to_path_buf()
} else {
workspace_root.join(member_path)
};
let cargo_path = full_path.join("Cargo.toml");
if !switchy_fs::exists(&cargo_path) {
continue;
}
let source = switchy_fs::sync::read_to_string(&cargo_path)?;
let value: Value = toml::from_str(&source)?;
if let Some(package_name) = value
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
{
workspace_packages.insert(package_name.to_string());
package_paths.insert(package_name.to_string(), member_path.to_string());
package_cargo_values.insert(package_name.to_string(), value);
}
}
Ok((workspace_packages, package_paths, package_cargo_values))
}
fn extract_all_dependencies(cargo_value: &Value, include_dev: bool) -> Vec<(String, bool)> {
let mut deps = Vec::new();
let extract_from_section = |section: &Value| -> Vec<(String, bool)> {
let mut section_deps = Vec::new();
if let Some(dependencies) = section.as_table() {
for (dep_name, dep_value) in dependencies {
let is_optional = if let Value::Table(table) = dep_value {
table.get("optional") == Some(&Value::Boolean(true))
} else {
false
};
section_deps.push((dep_name.clone(), is_optional));
}
}
section_deps
};
if let Some(dependencies) = cargo_value.get("dependencies") {
deps.extend(extract_from_section(dependencies));
}
if let Some(build_dependencies) = cargo_value.get("build-dependencies") {
deps.extend(extract_from_section(build_dependencies));
}
if include_dev && let Some(dev_dependencies) = cargo_value.get("dev-dependencies") {
deps.extend(extract_from_section(dev_dependencies));
}
let mut deduped = BTreeMap::new();
for (name, is_optional) in deps {
deduped
.entry(name)
.and_modify(|opt| *opt = *opt && is_optional)
.or_insert(is_optional);
}
deduped.into_iter().collect()
}
fn parse_feature_propagations(feature_def: &[Value]) -> BTreeSet<String> {
feature_def
.iter()
.filter_map(|v| v.as_str())
.filter(|s| s.contains('/'))
.map(std::string::ToString::to_string)
.collect()
}
fn extract_dependency_name(entry: &str) -> Option<String> {
if entry.contains('/') {
entry
.split('/')
.next()
.map(|s| s.trim_end_matches('?').to_string())
} else {
None
}
}
#[allow(clippy::too_many_lines)]
pub fn print_human_output(result: &ValidationResult) {
println!("🔍 Feature Propagation Validation Results");
println!("=========================================");
println!("Total packages checked: {}", result.total_packages);
println!("Valid packages: {}", result.valid_packages);
if let Some(ref summary) = result.override_summary {
println!("\n📋 Override Summary:");
println!(" Applied: {} overrides", summary.total_applied);
if !summary.by_source.is_empty() {
for (source, count) in &summary.by_source {
println!(" - {source}: {count}");
}
}
if summary.expired > 0 {
println!(" ⚠️ Expired: {} overrides", summary.expired);
}
}
if !result.warnings.is_empty() {
println!("\n⚠️ Warnings:");
for warning in &result.warnings {
println!(" - {}: {}", warning.package, warning.message);
}
}
if !result.overridden_errors.is_empty() {
println!(
"\n🔕 Overridden Errors ({}):",
result.overridden_errors.len()
);
for overridden in &result.overridden_errors {
println!(
" 📦 {}:{}:{}",
overridden.package, overridden.feature, overridden.dependency
);
if let Some(ref reason) = overridden.override_info.reason {
println!(" Reason: {reason}");
}
println!(" Source: {:?}", overridden.override_info.source);
if let Some(ref expires) = overridden.override_info.expires {
println!(" Expires: {expires}");
}
}
}
if result.errors.is_empty() {
let override_msg = if result.overridden_errors.is_empty() {
String::new()
} else {
format!(" (with {} overrides)", result.overridden_errors.len())
};
println!("\n✅ All packages correctly propagate features{override_msg}!");
} else {
println!(
"\n❌ Found {} packages with incorrect feature propagation:",
result.errors.len()
);
for error in &result.errors {
println!("\n📦 Package: {}", error.package);
for feature_error in &error.errors {
println!(" Feature: {}", feature_error.feature);
if !feature_error.missing_propagations.is_empty() {
println!(" Missing propagations:");
for missing in &feature_error.missing_propagations {
println!(" - {} ({})", missing.expected, missing.reason);
}
}
if !feature_error.incorrect_propagations.is_empty() {
println!(" Incorrect entries:");
for incorrect in &feature_error.incorrect_propagations {
println!(" - {} ({})", incorrect.entry, incorrect.reason);
}
}
}
}
}
if !result.parent_results.is_empty() {
println!("\n🔍 Parent Package Validation Results");
println!("=====================================");
for parent_result in &result.parent_results {
let status = if parent_result.missing_exposures.is_empty() {
"✅"
} else {
"❌"
};
println!(
"\n{status} Parent Package: {} ({}/{} features exposed)",
parent_result.package,
parent_result.features_exposed,
parent_result.features_checked
);
if !parent_result.missing_exposures.is_empty() {
let mut by_dep: BTreeMap<&str, Vec<&MissingFeatureExposure>> = BTreeMap::new();
for exposure in &parent_result.missing_exposures {
by_dep
.entry(&exposure.dependency)
.or_default()
.push(exposure);
}
for (dep_name, exposures) in by_dep {
println!(" 📦 {dep_name}:");
for exposure in exposures {
println!(
" - {} → expected \"{}\" with [\"{}\"]",
exposure.dependency_feature,
exposure.expected_parent_feature,
exposure.expected_propagation
);
if exposure.depth > 1 {
println!(" (chain: {})", exposure.chain.join(" → "));
}
}
}
}
}
}
}
pub fn print_github_output(result: &ValidationResult) {
for error in &result.errors {
for feature_error in &error.errors {
for missing in &feature_error.missing_propagations {
println!(
"::error file=packages/{}/Cargo.toml::Missing feature propagation '{}' for feature '{}'",
error.package, missing.expected, feature_error.feature
);
}
for incorrect in &feature_error.incorrect_propagations {
println!(
"::error file=packages/{}/Cargo.toml::Incorrect feature propagation '{}' for feature '{}'",
error.package, incorrect.entry, feature_error.feature
);
}
}
}
for parent_result in &result.parent_results {
for exposure in &parent_result.missing_exposures {
println!(
"::error file=packages/{}/Cargo.toml::Missing feature exposure '{}' for dependency '{}' feature '{}'",
parent_result.package,
exposure.expected_parent_feature,
exposure.dependency,
exposure.dependency_feature
);
}
}
for warning in &result.warnings {
println!(
"::warning file=packages/{}/Cargo.toml::{}",
warning.package, warning.message
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use switchy_fs::TempDir;
#[allow(clippy::similar_names)]
fn create_test_workspace() -> TempDir {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["pkg_a", "pkg_b", "pkg_c"]
[workspace.dependencies]
anyhow = "1.0"
serde = "1.0"
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_a")).unwrap();
let package_a_cargo = r#"[package]
name = "pkg_a"
version = "0.1.0"
[dependencies]
pkg_b = { path = "../pkg_b" }
anyhow = { workspace = true }
[features]
fail-on-warnings = ["pkg_b/fail-on-warnings"]
test-feature = ["pkg_b/test-feature"]
"#;
switchy_fs::sync::write(root_path.join("pkg_a/Cargo.toml"), package_a_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_b")).unwrap();
let package_b_cargo = r#"[package]
name = "pkg_b"
version = "0.1.0"
[dependencies]
pkg_c = { path = "../pkg_c", optional = true }
serde = { workspace = true }
[features]
fail-on-warnings = ["pkg_c?/fail-on-warnings"]
test-feature = []
"#;
switchy_fs::sync::write(root_path.join("pkg_b/Cargo.toml"), package_b_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_c")).unwrap();
let package_c_cargo = r#"[package]
name = "pkg_c"
version = "0.1.0"
[dependencies]
anyhow = { workspace = true }
[features]
fail-on-warnings = []
other-feature = []
"#;
switchy_fs::sync::write(root_path.join("pkg_c/Cargo.toml"), package_c_cargo).unwrap();
temp_dir
}
fn create_test_workspace_with_errors() -> TempDir {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["pkg_error"]
[workspace.dependencies]
anyhow = "1.0"
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_error")).unwrap();
let pkg_error_cargo = r#"[package]
name = "pkg_error"
version = "0.1.0"
[dependencies]
anyhow = { workspace = true }
external_dep = "1.0"
[features]
# Missing propagation to anyhow
fail-on-warnings = ["external_dep/nonexistent-feature"]
# Has feature but anyhow doesn't
test-feature = []
"#;
switchy_fs::sync::write(root_path.join("pkg_error/Cargo.toml"), pkg_error_cargo).unwrap();
temp_dir
}
#[test]
fn test_find_workspace_root_valid() {
let temp_workspace = create_test_workspace();
let root_path = temp_workspace.path().to_path_buf();
let found_root = find_workspace_root(Some(root_path.clone())).unwrap();
assert_eq!(found_root, root_path);
}
#[test]
fn test_find_workspace_root_from_subdirectory() {
let temp_workspace = create_test_workspace();
let root_path = temp_workspace.path().to_path_buf();
let subdir = root_path.join("pkg_a");
let found_root = find_workspace_root(Some(subdir)).unwrap();
assert_eq!(found_root, root_path);
}
#[test]
fn test_find_workspace_root_no_workspace() {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let cargo_toml = r#"[package]
name = "single_package"
version = "0.1.0"
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), cargo_toml).unwrap();
let found_root = find_workspace_root(Some(root_path.to_path_buf())).unwrap();
assert_eq!(found_root, root_path);
}
#[test]
fn test_load_workspace_data() {
let temp_workspace = create_test_workspace();
let root_path = temp_workspace.path();
let (workspace_packages, package_paths, package_cargo_values) =
load_workspace_data(root_path).unwrap();
assert_eq!(workspace_packages.len(), 3);
assert!(workspace_packages.contains("pkg_a"));
assert!(workspace_packages.contains("pkg_b"));
assert!(workspace_packages.contains("pkg_c"));
assert_eq!(package_paths.len(), 3);
assert_eq!(package_paths.get("pkg_a").unwrap(), "pkg_a");
assert_eq!(package_cargo_values.len(), 3);
let package_a_cargo = package_cargo_values.get("pkg_a").unwrap();
assert_eq!(
package_a_cargo
.get("package")
.unwrap()
.get("name")
.unwrap()
.as_str()
.unwrap(),
"pkg_a"
);
}
#[test]
fn test_extract_all_dependencies() {
let cargo_toml = r#"[package]
name = "test_pkg"
version = "0.1.0"
[dependencies]
regular_dep = "1.0"
optional_dep = { version = "1.0", optional = true }
[build-dependencies]
build_dep = "1.0"
[dev-dependencies]
dev_dep = "1.0"
"#;
let value: Value = toml::from_str(cargo_toml).unwrap();
let deps = extract_all_dependencies(&value, false);
assert_eq!(deps.len(), 3);
let deps_map: BTreeMap<String, bool> = deps.into_iter().collect();
assert_eq!(deps_map.get("regular_dep"), Some(&false));
assert_eq!(deps_map.get("optional_dep"), Some(&true));
assert_eq!(deps_map.get("build_dep"), Some(&false));
assert!(!deps_map.contains_key("dev_dep"));
let deps_with_dev = extract_all_dependencies(&value, true);
assert_eq!(deps_with_dev.len(), 4);
let deps_with_dev_map: BTreeMap<String, bool> = deps_with_dev.into_iter().collect();
assert!(deps_with_dev_map.contains_key("dev_dep"));
}
#[test]
fn test_parse_feature_propagations() {
let feature_def = vec![
Value::String("dep1/feature1".to_string()),
Value::String("dep2?/feature2".to_string()),
Value::String("standalone_feature".to_string()),
Value::String("dep3/feature3".to_string()),
];
let propagations = parse_feature_propagations(&feature_def);
assert_eq!(propagations.len(), 3);
assert!(propagations.contains("dep1/feature1"));
assert!(propagations.contains("dep2?/feature2"));
assert!(propagations.contains("dep3/feature3"));
assert!(!propagations.contains("standalone_feature"));
}
#[test]
fn test_extract_dependency_name() {
assert_eq!(
extract_dependency_name("dep1/feature1"),
Some("dep1".to_string())
);
assert_eq!(
extract_dependency_name("dep2?/feature2"),
Some("dep2".to_string())
);
assert_eq!(
extract_dependency_name("dep3/feature3/extra"),
Some("dep3".to_string())
);
assert_eq!(extract_dependency_name("standalone"), None);
}
#[test]
fn test_validator_creation() {
let temp_workspace = create_test_workspace();
let root_path = temp_workspace.path().to_path_buf();
let config = ValidatorConfig {
features: None,
skip_features: None,
workspace_only: true,
output_format: OutputType::Raw,
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path), config).unwrap();
assert_eq!(validator.workspace_packages.len(), 3);
assert_eq!(validator.package_cargo_values.len(), 3);
}
#[test]
fn test_validator_validation_success() {
let temp_workspace = create_test_workspace();
let root_path = temp_workspace.path().to_path_buf();
let config = ValidatorConfig {
features: Some(vec!["fail-on-warnings".to_string()]),
skip_features: None,
workspace_only: true,
output_format: OutputType::Raw,
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path), config).unwrap();
let result = validator.validate().unwrap();
assert_eq!(result.errors.len(), 0);
assert!(result.valid_packages > 0);
}
#[test]
fn test_validator_validation_with_errors() {
let temp_workspace = create_test_workspace_with_errors();
let root_path = temp_workspace.path().to_path_buf();
let config = ValidatorConfig {
features: Some(vec!["fail-on-warnings".to_string()]),
skip_features: None,
workspace_only: false, output_format: OutputType::Raw,
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path), config).unwrap();
let result = validator.validate().unwrap();
assert!(!result.errors.is_empty());
let pkg_error = result
.errors
.iter()
.find(|e| e.package == "pkg_error")
.expect("Should find pkg_error");
assert!(!pkg_error.errors.is_empty());
let fail_on_warnings_error = pkg_error
.errors
.iter()
.find(|e| e.feature == "fail-on-warnings")
.expect("Should find fail-on-warnings error");
assert!(!fail_on_warnings_error.incorrect_propagations.is_empty());
}
#[test]
#[allow(clippy::similar_names)]
fn test_get_features_to_check_specific() {
let temp_workspace = create_test_workspace();
let root_path = temp_workspace.path().to_path_buf();
let config = ValidatorConfig {
features: Some(vec!["fail-on-warnings".to_string()]),
skip_features: None,
workspace_only: true,
output_format: OutputType::Raw,
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path), config).unwrap();
let package_a_cargo = validator.package_cargo_values.get("pkg_a").unwrap();
let features = validator.get_features_to_check("pkg_a", package_a_cargo);
assert_eq!(features.len(), 1);
assert_eq!(features[0], "fail-on-warnings");
}
#[test]
#[allow(clippy::similar_names)]
fn test_get_features_to_check_auto_detect() {
let temp_workspace = create_test_workspace();
let root_path = temp_workspace.path().to_path_buf();
let config = ValidatorConfig {
features: None, skip_features: None,
workspace_only: true,
output_format: OutputType::Raw,
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path), config).unwrap();
let package_a_cargo = validator.package_cargo_values.get("pkg_a").unwrap();
let features = validator.get_features_to_check("pkg_a", package_a_cargo);
assert!(!features.is_empty());
assert!(features.contains(&"fail-on-warnings".to_string()));
assert!(features.contains(&"test-feature".to_string()));
}
#[test]
fn test_dependency_has_feature() {
let temp_workspace = create_test_workspace();
let root_path = temp_workspace.path().to_path_buf();
let config = ValidatorConfig {
features: None,
skip_features: None,
workspace_only: true,
output_format: OutputType::Raw,
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path), config).unwrap();
assert!(validator.dependency_has_feature("pkg_b", "fail-on-warnings"));
assert!(validator.dependency_has_feature("pkg_b", "test-feature"));
assert!(!validator.dependency_has_feature("pkg_b", "nonexistent-feature"));
assert!(validator.dependency_has_feature("pkg_c", "fail-on-warnings"));
assert!(validator.dependency_has_feature("pkg_c", "other-feature"));
assert!(!validator.dependency_has_feature("pkg_c", "nonexistent-feature"));
}
#[test]
#[allow(clippy::similar_names)]
fn test_any_dependency_has_feature() {
let temp_workspace = create_test_workspace();
let root_path = temp_workspace.path().to_path_buf();
let config = ValidatorConfig {
features: None,
skip_features: None,
workspace_only: true,
output_format: OutputType::Raw,
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path), config).unwrap();
let package_a_cargo = validator.package_cargo_values.get("pkg_a").unwrap();
assert!(validator.any_dependency_has_feature(package_a_cargo, "fail-on-warnings"));
assert!(validator.any_dependency_has_feature(package_a_cargo, "test-feature"));
assert!(!validator.any_dependency_has_feature(package_a_cargo, "nonexistent-feature"));
}
#[test]
#[allow(clippy::similar_names)]
fn test_get_expected_propagations() {
let temp_workspace = create_test_workspace();
let root_path = temp_workspace.path().to_path_buf();
let config = ValidatorConfig {
features: None,
skip_features: None,
workspace_only: true,
output_format: OutputType::Raw,
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path), config).unwrap();
let package_a_cargo = validator.package_cargo_values.get("pkg_a").unwrap();
let expected = validator.get_expected_propagations(package_a_cargo, "fail-on-warnings");
assert_eq!(
expected.get("pkg_b"),
Some(&"pkg_b/fail-on-warnings".to_string())
);
let package_b_cargo = validator.package_cargo_values.get("pkg_b").unwrap();
let expected_b = validator.get_expected_propagations(package_b_cargo, "fail-on-warnings");
assert_eq!(
expected_b.get("pkg_c"),
Some(&"pkg_c?/fail-on-warnings".to_string())
);
}
#[test]
fn test_validation_result_serialization() {
let result = ValidationResult {
total_packages: 3,
valid_packages: 2,
errors: vec![PackageValidationError {
package: "test_pkg".to_string(),
errors: vec![FeatureError {
feature: "test-feature".to_string(),
missing_propagations: vec![MissingPropagation {
dependency: "dep1".to_string(),
expected: "dep1/test-feature".to_string(),
reason: "Test reason".to_string(),
}],
incorrect_propagations: vec![IncorrectPropagation {
entry: "nonexistent/feature".to_string(),
reason: "Test incorrect reason".to_string(),
}],
}],
}],
warnings: vec![PackageValidationWarning {
package: "warn_pkg".to_string(),
message: "Test warning".to_string(),
}],
overridden_errors: vec![],
override_summary: None,
parent_results: vec![],
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("test_pkg"));
assert!(json.contains("test-feature"));
assert!(json.contains("warn_pkg"));
}
fn create_test_workspace_with_default_feature() -> TempDir {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["test_pkg"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("test_pkg")).unwrap();
switchy_fs::sync::create_dir(root_path.join("test_pkg/src")).unwrap();
switchy_fs::sync::write(root_path.join("test_pkg/src/lib.rs"), "").unwrap();
let pkg_cargo = r#"[package]
name = "test_pkg"
version = "0.1.0"
[features]
default = []
fail-on-warnings = []
"#;
switchy_fs::sync::write(root_path.join("test_pkg/Cargo.toml"), pkg_cargo).unwrap();
temp_dir
}
fn create_test_workspace_with_multiple_features() -> TempDir {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["test_pkg"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("test_pkg")).unwrap();
switchy_fs::sync::create_dir(root_path.join("test_pkg/src")).unwrap();
switchy_fs::sync::write(root_path.join("test_pkg/src/lib.rs"), "").unwrap();
let pkg_cargo = r#"[package]
name = "test_pkg"
version = "0.1.0"
[features]
default = []
fail-on-warnings = []
test-utils = []
"#;
switchy_fs::sync::write(root_path.join("test_pkg/Cargo.toml"), pkg_cargo).unwrap();
temp_dir
}
fn create_test_workspace_with_underscore_features() -> TempDir {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["test_pkg"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("test_pkg")).unwrap();
switchy_fs::sync::create_dir(root_path.join("test_pkg/src")).unwrap();
switchy_fs::sync::write(root_path.join("test_pkg/src/lib.rs"), "").unwrap();
let pkg_cargo = r#"[package]
name = "test_pkg"
version = "0.1.0"
[features]
default = []
fail-on-warnings = []
"_internal" = []
"_private" = []
"_debug" = []
"#;
switchy_fs::sync::write(root_path.join("test_pkg/Cargo.toml"), pkg_cargo).unwrap();
temp_dir
}
#[test]
fn test_skip_features_default_behavior() {
let temp_workspace = create_test_workspace_with_underscore_features();
let config = ValidatorConfig {
features: Some(vec![
"default".to_string(),
"fail-on-warnings".to_string(),
"_internal".to_string(),
]),
skip_features: None, workspace_only: true,
output_format: OutputType::Raw,
..ValidatorConfig::test_default()
};
let validator =
FeatureValidator::new(Some(temp_workspace.path().to_path_buf()), config).unwrap();
let pkg_cargo = validator.package_cargo_values.get("test_pkg").unwrap();
let features = validator.get_features_to_check("test_pkg", pkg_cargo);
assert!(!features.contains(&"default".to_string()));
assert!(!features.contains(&"_internal".to_string()));
assert!(features.contains(&"fail-on-warnings".to_string()));
}
#[test]
fn test_skip_features_empty_validates_all() {
let temp_workspace = create_test_workspace_with_default_feature();
let config = ValidatorConfig {
features: Some(vec!["default".to_string(), "fail-on-warnings".to_string()]),
skip_features: Some(vec![]),
workspace_only: true,
output_format: OutputType::Raw,
..ValidatorConfig::test_default()
};
let validator =
FeatureValidator::new(Some(temp_workspace.path().to_path_buf()), config).unwrap();
let pkg_cargo = validator.package_cargo_values.get("test_pkg").unwrap();
let features = validator.get_features_to_check("test_pkg", pkg_cargo);
assert!(features.contains(&"default".to_string()));
assert!(features.contains(&"fail-on-warnings".to_string()));
}
#[test]
fn test_skip_features_explicit_list() {
let temp_workspace = create_test_workspace_with_multiple_features();
let config = ValidatorConfig {
features: Some(vec![
"default".to_string(),
"test-utils".to_string(),
"fail-on-warnings".to_string(),
]),
skip_features: Some(vec!["default".to_string(), "test-utils".to_string()]),
workspace_only: true,
output_format: OutputType::Raw,
..ValidatorConfig::test_default()
};
let validator =
FeatureValidator::new(Some(temp_workspace.path().to_path_buf()), config).unwrap();
let pkg_cargo = validator.package_cargo_values.get("test_pkg").unwrap();
let features = validator.get_features_to_check("test_pkg", pkg_cargo);
assert!(!features.contains(&"default".to_string()));
assert!(!features.contains(&"test-utils".to_string()));
assert!(features.contains(&"fail-on-warnings".to_string()));
}
#[test]
fn test_skip_features_with_specific_features_list() {
let temp_workspace = create_test_workspace_with_default_feature();
let config = ValidatorConfig {
features: Some(vec!["default".to_string(), "fail-on-warnings".to_string()]),
skip_features: Some(vec!["default".to_string()]),
workspace_only: true,
output_format: OutputType::Raw,
..ValidatorConfig::test_default()
};
let validator =
FeatureValidator::new(Some(temp_workspace.path().to_path_buf()), config).unwrap();
let pkg_cargo = validator.package_cargo_values.get("test_pkg").unwrap();
let features = validator.get_features_to_check("test_pkg", pkg_cargo);
assert_eq!(features.len(), 1);
assert_eq!(features[0], "fail-on-warnings");
}
#[test]
fn test_underscore_features_skipped_by_default() {
let temp_workspace = create_test_workspace_with_underscore_features();
let config = ValidatorConfig {
features: Some(vec![
"_internal".to_string(),
"_private".to_string(),
"_debug".to_string(),
"fail-on-warnings".to_string(),
]),
skip_features: None, workspace_only: true,
output_format: OutputType::Raw,
..ValidatorConfig::test_default()
};
let validator =
FeatureValidator::new(Some(temp_workspace.path().to_path_buf()), config).unwrap();
let pkg_cargo = validator.package_cargo_values.get("test_pkg").unwrap();
let features = validator.get_features_to_check("test_pkg", pkg_cargo);
assert!(!features.contains(&"_internal".to_string()));
assert!(!features.contains(&"_private".to_string()));
assert!(!features.contains(&"_debug".to_string()));
assert!(features.contains(&"fail-on-warnings".to_string()));
}
#[test]
fn test_skip_features_glob_patterns() {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["test_pkg"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("test_pkg")).unwrap();
switchy_fs::sync::create_dir(root_path.join("test_pkg/src")).unwrap();
switchy_fs::sync::write(root_path.join("test_pkg/src/lib.rs"), "").unwrap();
let pkg_cargo = r#"[package]
name = "test_pkg"
version = "0.1.0"
[features]
default = []
test-utils = []
test-fixtures = []
fail-on-warnings = []
"#;
switchy_fs::sync::write(root_path.join("test_pkg/Cargo.toml"), pkg_cargo).unwrap();
let config = ValidatorConfig {
features: Some(vec![
"default".to_string(),
"test-utils".to_string(),
"test-fixtures".to_string(),
"fail-on-warnings".to_string(),
]),
skip_features: Some(vec!["test-*".to_string()]),
workspace_only: true,
output_format: OutputType::Raw,
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path.to_path_buf()), config).unwrap();
let pkg_cargo = validator.package_cargo_values.get("test_pkg").unwrap();
let features = validator.get_features_to_check("test_pkg", pkg_cargo);
assert!(!features.contains(&"test-utils".to_string()));
assert!(!features.contains(&"test-fixtures".to_string()));
assert!(features.contains(&"default".to_string()));
assert!(features.contains(&"fail-on-warnings".to_string()));
}
#[test]
fn test_lenient_optional_propagation_accepts_both_styles() {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["pkg_a", "pkg_b"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_b")).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_b/src")).unwrap();
switchy_fs::sync::write(root_path.join("pkg_b/src/lib.rs"), "").unwrap();
let pkg_b_cargo = r#"[package]
name = "pkg_b"
version = "0.1.0"
[features]
test-feature = []
"#;
switchy_fs::sync::write(root_path.join("pkg_b/Cargo.toml"), pkg_b_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_a")).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_a/src")).unwrap();
switchy_fs::sync::write(root_path.join("pkg_a/src/lib.rs"), "").unwrap();
let pkg_a_cargo = r#"[package]
name = "pkg_a"
version = "0.1.0"
[dependencies]
pkg_b = { path = "../pkg_b", optional = true }
[features]
test-feature = ["pkg_b/test-feature"]
"#;
switchy_fs::sync::write(root_path.join("pkg_a/Cargo.toml"), pkg_a_cargo).unwrap();
let config = ValidatorConfig {
features: Some(vec!["test-feature".to_string()]),
skip_features: None,
workspace_only: true, output_format: OutputType::Raw,
strict_optional_propagation: false, ..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path.to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
assert_eq!(
result.errors.len(),
0,
"Lenient mode should accept dep/feature syntax"
);
}
#[test]
fn test_strict_optional_propagation_requires_question_mark() {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["pkg_a", "pkg_b"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_b")).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_b/src")).unwrap();
switchy_fs::sync::write(root_path.join("pkg_b/src/lib.rs"), "").unwrap();
let pkg_b_cargo = r#"[package]
name = "pkg_b"
version = "0.1.0"
[features]
test-feature = []
"#;
switchy_fs::sync::write(root_path.join("pkg_b/Cargo.toml"), pkg_b_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_a")).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_a/src")).unwrap();
switchy_fs::sync::write(root_path.join("pkg_a/src/lib.rs"), "").unwrap();
let pkg_a_cargo = r#"[package]
name = "pkg_a"
version = "0.1.0"
[dependencies]
pkg_b = { path = "../pkg_b", optional = true }
[features]
test-feature = ["pkg_b/test-feature"]
"#;
switchy_fs::sync::write(root_path.join("pkg_a/Cargo.toml"), pkg_a_cargo).unwrap();
let config = ValidatorConfig {
features: Some(vec!["test-feature".to_string()]),
skip_features: None,
workspace_only: true, output_format: OutputType::Raw,
strict_optional_propagation: true, ..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path.to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
assert!(
!result.errors.is_empty(),
"Strict mode should reject dep/feature syntax"
);
assert_eq!(result.errors[0].package, "pkg_a");
assert!(!result.errors[0].errors[0].missing_propagations.is_empty());
}
#[test]
fn test_strict_mode_accepts_question_mark_syntax() {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["pkg_a", "pkg_b"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_b")).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_b/src")).unwrap();
switchy_fs::sync::write(root_path.join("pkg_b/src/lib.rs"), "").unwrap();
let pkg_b_cargo = r#"[package]
name = "pkg_b"
version = "0.1.0"
[features]
test-feature = []
"#;
switchy_fs::sync::write(root_path.join("pkg_b/Cargo.toml"), pkg_b_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_a")).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_a/src")).unwrap();
switchy_fs::sync::write(root_path.join("pkg_a/src/lib.rs"), "").unwrap();
let pkg_a_cargo = r#"[package]
name = "pkg_a"
version = "0.1.0"
[dependencies]
pkg_b = { path = "../pkg_b", optional = true }
[features]
test-feature = ["dep:pkg_b", "pkg_b?/test-feature"]
"#;
switchy_fs::sync::write(root_path.join("pkg_a/Cargo.toml"), pkg_a_cargo).unwrap();
let config = ValidatorConfig {
features: Some(vec!["test-feature".to_string()]),
skip_features: None,
workspace_only: true, output_format: OutputType::Raw,
strict_optional_propagation: true, ..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path.to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
assert_eq!(
result.errors.len(),
0,
"Strict mode should accept dep?/feature syntax"
);
}
#[test]
fn test_string_or_array_to_vec_single() {
let single = StringOrArray::Single("test".to_string());
assert_eq!(single.to_vec(), vec!["test".to_string()]);
}
#[test]
fn test_string_or_array_to_vec_multiple() {
let multiple = StringOrArray::Multiple(vec![
"dep1".to_string(),
"dep2".to_string(),
"dep3".to_string(),
]);
assert_eq!(
multiple.to_vec(),
vec!["dep1".to_string(), "dep2".to_string(), "dep3".to_string()]
);
}
#[test]
fn test_string_or_array_to_vec_empty() {
let empty = StringOrArray::Multiple(vec![]);
assert_eq!(empty.to_vec(), Vec::<String>::new());
}
#[test]
fn test_override_config_entry_single_dependency() {
let toml_str = r#"
feature = "test-feature"
dependency = "single_dep"
type = "allow-missing"
reason = "Test reason"
"#;
let parsed: OverrideConfigEntry = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.feature, "test-feature");
assert_eq!(parsed.dependency.to_vec(), vec!["single_dep".to_string()]);
assert!(matches!(parsed.override_type, OverrideType::AllowMissing));
assert_eq!(parsed.reason, "Test reason");
assert!(parsed.expires.is_none());
}
#[test]
fn test_override_config_entry_array_dependencies() {
let toml_str = r#"
feature = "test-feature"
dependencies = ["dep1", "dep2", "dep3"]
type = "allow-missing"
reason = "Test reason for multiple deps"
"#;
let parsed: OverrideConfigEntry = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.feature, "test-feature");
assert_eq!(
parsed.dependency.to_vec(),
vec!["dep1".to_string(), "dep2".to_string(), "dep3".to_string()]
);
assert!(matches!(parsed.override_type, OverrideType::AllowMissing));
assert_eq!(parsed.reason, "Test reason for multiple deps");
}
#[test]
fn test_override_config_entry_alias_support() {
let toml_single = r#"
feature = "feat"
dependency = "dep"
type = "allow-missing"
reason = "reason"
"#;
let parsed_single: OverrideConfigEntry = toml::from_str(toml_single).unwrap();
assert_eq!(parsed_single.dependency.to_vec(), vec!["dep".to_string()]);
let toml_plural = r#"
feature = "feat"
dependencies = ["dep"]
type = "allow-missing"
reason = "reason"
"#;
let parsed_plural: OverrideConfigEntry = toml::from_str(toml_plural).unwrap();
assert_eq!(parsed_plural.dependency.to_vec(), vec!["dep".to_string()]);
}
#[test]
fn test_override_config_entry_with_expiration() {
let toml_str = r#"
feature = "test-feature"
dependency = "dep"
type = "allow-incorrect"
reason = "Temporary override"
expires = "2025-12-31T23:59:59Z"
"#;
let parsed: OverrideConfigEntry = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.feature, "test-feature");
assert!(matches!(parsed.override_type, OverrideType::AllowIncorrect));
assert_eq!(parsed.expires, Some("2025-12-31T23:59:59Z".to_string()));
}
#[test]
fn test_override_config_entry_suppress_type() {
let toml_str = r#"
feature = "*"
dependencies = ["dep1", "dep2"]
type = "suppress"
reason = "Suppress all validation"
"#;
let parsed: OverrideConfigEntry = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.feature, "*");
assert_eq!(parsed.dependency.to_vec().len(), 2);
assert!(matches!(parsed.override_type, OverrideType::Suppress));
}
#[test]
fn test_override_config_entry_wildcards_in_array() {
let toml_str = r#"
feature = "profiling-*"
dependencies = ["dep_*", "other_dep"]
type = "allow-missing"
reason = "Wildcard pattern test"
"#;
let parsed: OverrideConfigEntry = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.feature, "profiling-*");
let deps = parsed.dependency.to_vec();
assert_eq!(deps.len(), 2);
assert_eq!(deps[0], "dep_*");
assert_eq!(deps[1], "other_dep");
}
#[test]
fn test_infer_prefix_with_matching_parent() {
assert_eq!(infer_prefix("switchy", "switchy_database"), "database");
assert_eq!(infer_prefix("switchy", "switchy_async"), "async");
assert_eq!(
infer_prefix("hyperchad", "hyperchad_renderer_html"),
"renderer-html"
);
assert_eq!(
infer_prefix("moosicbox", "moosicbox_audio_decoder"),
"audio-decoder"
);
}
#[test]
fn test_infer_prefix_without_matching_parent() {
assert_eq!(infer_prefix("switchy", "other_package"), "other-package");
assert_eq!(infer_prefix("hyperchad", "some_lib"), "some-lib");
}
#[test]
fn test_get_all_feature_names() {
let cargo_toml = r#"[package]
name = "test_pkg"
version = "0.1.0"
[features]
default = []
api = []
serde = []
test-feature = []
"#;
let value: Value = toml::from_str(cargo_toml).unwrap();
let features = get_all_feature_names(&value);
assert_eq!(features.len(), 4);
assert!(features.contains("default"));
assert!(features.contains("api"));
assert!(features.contains("serde"));
assert!(features.contains("test-feature"));
}
#[test]
fn test_get_all_feature_names_empty() {
let cargo_toml = r#"[package]
name = "test_pkg"
version = "0.1.0"
"#;
let value: Value = toml::from_str(cargo_toml).unwrap();
let features = get_all_feature_names(&value);
assert!(features.is_empty());
}
fn create_parent_test_workspace() -> TempDir {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["parent", "child_a", "child_b"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("parent")).unwrap();
let parent_cargo = r#"[package]
name = "parent"
version = "0.1.0"
[dependencies]
parent_child_a = { path = "../child_a", optional = true }
parent_child_b = { path = "../child_b", optional = true }
[features]
default = []
child-a = ["dep:parent_child_a"]
child-a-api = ["child-a", "parent_child_a?/api"]
child-b = ["dep:parent_child_b"]
# Missing: child-a-serde, child-b-api, child-b-serde
"#;
switchy_fs::sync::write(root_path.join("parent/Cargo.toml"), parent_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("child_a")).unwrap();
let child_a_cargo = r#"[package]
name = "parent_child_a"
version = "0.1.0"
[features]
default = []
api = []
serde = []
"#;
switchy_fs::sync::write(root_path.join("child_a/Cargo.toml"), child_a_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("child_b")).unwrap();
let child_b_cargo = r#"[package]
name = "parent_child_b"
version = "0.1.0"
[features]
default = []
api = []
serde = []
"#;
switchy_fs::sync::write(root_path.join("child_b/Cargo.toml"), child_b_cargo).unwrap();
temp_dir
}
#[test]
fn test_parent_validation_detects_missing_features() {
let temp_workspace = create_parent_test_workspace();
let config = ValidatorConfig {
features: None,
skip_features: None,
workspace_only: true,
output_format: OutputType::Raw,
parent_config: ParentValidationConfig {
cli_packages: vec!["parent".to_string()],
cli_depth: Some(1),
cli_skip_features: vec![],
cli_prefix_overrides: vec![],
use_config: false,
},
..ValidatorConfig::test_default()
};
let validator =
FeatureValidator::new(Some(temp_workspace.path().to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
assert_eq!(result.parent_results.len(), 1);
let parent_result = &result.parent_results[0];
assert_eq!(parent_result.package, "parent");
assert!(!parent_result.missing_exposures.is_empty());
let missing_serde_a = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency == "parent_child_a" && e.dependency_feature == "serde");
assert!(missing_serde_a.is_some());
let missing_api_b = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency == "parent_child_b" && e.dependency_feature == "api");
assert!(missing_api_b.is_some());
let missing_serde_b = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency == "parent_child_b" && e.dependency_feature == "serde");
assert!(missing_serde_b.is_some());
}
#[test]
fn test_parent_validation_respects_skip_features() {
let temp_workspace = create_parent_test_workspace();
let config = ValidatorConfig {
features: None,
skip_features: None,
workspace_only: true,
output_format: OutputType::Raw,
parent_config: ParentValidationConfig {
cli_packages: vec!["parent".to_string()],
cli_depth: Some(1),
cli_skip_features: vec!["serde".to_string()], cli_prefix_overrides: vec![],
use_config: false,
},
..ValidatorConfig::test_default()
};
let validator =
FeatureValidator::new(Some(temp_workspace.path().to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
let parent_result = &result.parent_results[0];
let missing_serde = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency_feature == "serde");
assert!(missing_serde.is_none());
let missing_api_b = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency == "parent_child_b" && e.dependency_feature == "api");
assert!(missing_api_b.is_some());
}
#[test]
fn test_resolve_skip_features_none_returns_defaults() {
let result = resolve_skip_features(&None);
assert_eq!(result.len(), 2);
assert!(result.contains(&"default".to_string()));
assert!(result.contains(&"_*".to_string()));
}
#[test]
fn test_resolve_skip_features_empty_vec_returns_empty() {
let result = resolve_skip_features(&Some(vec![]));
assert!(result.is_empty());
}
#[test]
fn test_resolve_skip_features_custom_patterns() {
let patterns = vec!["test-*".to_string(), "internal".to_string()];
let result = resolve_skip_features(&Some(patterns.clone()));
assert_eq!(result, patterns);
}
fn create_workspace_with_parent_config() -> TempDir {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["parent_pkg", "child_pkg"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
let workspace_clippier = r#"
[[feature-validation.parent-packages]]
package = "parent_pkg"
depth = 2
skip-features = ["workspace-skip"]
[[feature-validation.parent-prefix]]
dependency = "parent_pkg_child_pkg"
prefix = "workspace-prefix"
"#;
switchy_fs::sync::write(root_path.join("clippier.toml"), workspace_clippier).unwrap();
switchy_fs::sync::create_dir(root_path.join("parent_pkg")).unwrap();
let parent_cargo = r#"[package]
name = "parent_pkg"
version = "0.1.0"
[dependencies]
parent_pkg_child_pkg = { path = "../child_pkg", optional = true }
[features]
default = []
child = ["dep:parent_pkg_child_pkg"]
"#;
switchy_fs::sync::write(root_path.join("parent_pkg/Cargo.toml"), parent_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("child_pkg")).unwrap();
let child_cargo = r#"[package]
name = "parent_pkg_child_pkg"
version = "0.1.0"
[features]
default = []
api = []
serde = []
"#;
switchy_fs::sync::write(root_path.join("child_pkg/Cargo.toml"), child_cargo).unwrap();
temp_dir
}
#[test]
fn test_parent_config_from_workspace_clippier_toml() {
let temp_workspace = create_workspace_with_parent_config();
let config = ValidatorConfig {
parent_config: ParentValidationConfig {
cli_packages: vec![],
cli_depth: None,
cli_skip_features: vec![],
cli_prefix_overrides: vec![],
use_config: true,
},
..ValidatorConfig::test_default()
};
let validator =
FeatureValidator::new(Some(temp_workspace.path().to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
assert_eq!(result.parent_results.len(), 1);
assert_eq!(result.parent_results[0].package, "parent_pkg");
}
#[test]
fn test_parent_config_from_package_clippier_toml() {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["parent_pkg", "child_pkg"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("parent_pkg")).unwrap();
let parent_cargo = r#"[package]
name = "parent_pkg"
version = "0.1.0"
[dependencies]
parent_pkg_child_pkg = { path = "../child_pkg", optional = true }
[features]
default = []
child = ["dep:parent_pkg_child_pkg"]
"#;
switchy_fs::sync::write(root_path.join("parent_pkg/Cargo.toml"), parent_cargo).unwrap();
let pkg_clippier = r#"
[feature-validation.parent]
enabled = true
depth = 1
skip-features = ["pkg-skip"]
"#;
switchy_fs::sync::write(root_path.join("parent_pkg/clippier.toml"), pkg_clippier).unwrap();
switchy_fs::sync::create_dir(root_path.join("child_pkg")).unwrap();
let child_cargo = r#"[package]
name = "parent_pkg_child_pkg"
version = "0.1.0"
[features]
default = []
api = []
"#;
switchy_fs::sync::write(root_path.join("child_pkg/Cargo.toml"), child_cargo).unwrap();
let config = ValidatorConfig {
parent_config: ParentValidationConfig {
cli_packages: vec![],
cli_depth: None,
cli_skip_features: vec![],
cli_prefix_overrides: vec![],
use_config: true,
},
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path.to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
assert_eq!(result.parent_results.len(), 1);
assert_eq!(result.parent_results[0].package, "parent_pkg");
}
#[test]
fn test_parent_config_precedence_cli_over_config() {
let temp_workspace = create_workspace_with_parent_config();
let config = ValidatorConfig {
parent_config: ParentValidationConfig {
cli_packages: vec!["parent_pkg".to_string()],
cli_depth: Some(1), cli_skip_features: vec![],
cli_prefix_overrides: vec![],
use_config: true,
},
..ValidatorConfig::test_default()
};
let validator =
FeatureValidator::new(Some(temp_workspace.path().to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
assert_eq!(result.parent_results.len(), 1);
}
fn create_nested_deps_workspace() -> TempDir {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["parent", "level1", "level2", "level3"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("parent")).unwrap();
let parent_cargo = r#"[package]
name = "parent"
version = "0.1.0"
[dependencies]
parent_level1 = { path = "../level1", optional = true }
[features]
default = []
level1 = ["dep:parent_level1"]
"#;
switchy_fs::sync::write(root_path.join("parent/Cargo.toml"), parent_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("level1")).unwrap();
let level1_cargo = r#"[package]
name = "parent_level1"
version = "0.1.0"
[dependencies]
parent_level2 = { path = "../level2", optional = true }
[features]
default = []
api = []
level2 = ["dep:parent_level2"]
"#;
switchy_fs::sync::write(root_path.join("level1/Cargo.toml"), level1_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("level2")).unwrap();
let level2_cargo = r#"[package]
name = "parent_level2"
version = "0.1.0"
[dependencies]
parent_level3 = { path = "../level3", optional = true }
[features]
default = []
api = []
level3 = ["dep:parent_level3"]
"#;
switchy_fs::sync::write(root_path.join("level2/Cargo.toml"), level2_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("level3")).unwrap();
let level3_cargo = r#"[package]
name = "parent_level3"
version = "0.1.0"
[features]
default = []
api = []
deep-feature = []
"#;
switchy_fs::sync::write(root_path.join("level3/Cargo.toml"), level3_cargo).unwrap();
temp_dir
}
#[test]
fn test_parent_validation_depth_one_only_direct_deps() {
let temp_workspace = create_nested_deps_workspace();
let config = ValidatorConfig {
parent_config: ParentValidationConfig {
cli_packages: vec!["parent".to_string()],
cli_depth: Some(1),
cli_skip_features: vec![],
cli_prefix_overrides: vec![],
use_config: false,
},
..ValidatorConfig::test_default()
};
let validator =
FeatureValidator::new(Some(temp_workspace.path().to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
let parent_result = &result.parent_results[0];
assert!(
parent_result
.missing_exposures
.iter()
.any(|e| e.dependency == "parent_level1")
);
assert!(
!parent_result
.missing_exposures
.iter()
.any(|e| e.dependency == "parent_level2")
);
assert!(
!parent_result
.missing_exposures
.iter()
.any(|e| e.dependency == "parent_level3")
);
}
#[test]
fn test_parent_validation_depth_two_includes_nested() {
let temp_workspace = create_nested_deps_workspace();
let config = ValidatorConfig {
parent_config: ParentValidationConfig {
cli_packages: vec!["parent".to_string()],
cli_depth: Some(2),
cli_skip_features: vec![],
cli_prefix_overrides: vec![],
use_config: false,
},
..ValidatorConfig::test_default()
};
let validator =
FeatureValidator::new(Some(temp_workspace.path().to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
let parent_result = &result.parent_results[0];
assert!(
parent_result
.missing_exposures
.iter()
.any(|e| e.dependency == "parent_level1")
);
assert!(
parent_result
.missing_exposures
.iter()
.any(|e| e.dependency == "parent_level2")
);
assert!(
!parent_result
.missing_exposures
.iter()
.any(|e| e.dependency == "parent_level3")
);
}
#[test]
fn test_parent_validation_depth_unlimited() {
let temp_workspace = create_nested_deps_workspace();
let config = ValidatorConfig {
parent_config: ParentValidationConfig {
cli_packages: vec!["parent".to_string()],
cli_depth: None, cli_skip_features: vec![],
cli_prefix_overrides: vec![],
use_config: false,
},
..ValidatorConfig::test_default()
};
let validator =
FeatureValidator::new(Some(temp_workspace.path().to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
let parent_result = &result.parent_results[0];
assert!(
parent_result
.missing_exposures
.iter()
.any(|e| e.dependency == "parent_level1")
);
assert!(
parent_result
.missing_exposures
.iter()
.any(|e| e.dependency == "parent_level2")
);
assert!(
parent_result
.missing_exposures
.iter()
.any(|e| e.dependency == "parent_level3")
);
let deep_feature = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency_feature == "deep-feature");
assert!(deep_feature.is_some());
}
#[test]
fn test_parent_validation_cycle_detection() {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["parent", "pkg_a", "pkg_b"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("parent")).unwrap();
let parent_cargo = r#"[package]
name = "parent"
version = "0.1.0"
[dependencies]
parent_pkg_a = { path = "../pkg_a", optional = true }
[features]
default = []
pkg-a = ["dep:parent_pkg_a"]
"#;
switchy_fs::sync::write(root_path.join("parent/Cargo.toml"), parent_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_a")).unwrap();
let pkg_a_cargo = r#"[package]
name = "parent_pkg_a"
version = "0.1.0"
[dependencies]
parent_pkg_b = { path = "../pkg_b", optional = true }
[features]
default = []
api = []
pkg-b = ["dep:parent_pkg_b"]
"#;
switchy_fs::sync::write(root_path.join("pkg_a/Cargo.toml"), pkg_a_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("pkg_b")).unwrap();
let pkg_b_cargo = r#"[package]
name = "parent_pkg_b"
version = "0.1.0"
[dependencies]
parent_pkg_a = { path = "../pkg_a", optional = true }
[features]
default = []
api = []
pkg-a = ["dep:parent_pkg_a"]
"#;
switchy_fs::sync::write(root_path.join("pkg_b/Cargo.toml"), pkg_b_cargo).unwrap();
let config = ValidatorConfig {
parent_config: ParentValidationConfig {
cli_packages: vec!["parent".to_string()],
cli_depth: None, cli_skip_features: vec![],
cli_prefix_overrides: vec![],
use_config: false,
},
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path.to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
assert_eq!(result.parent_results.len(), 1);
let parent_result = &result.parent_results[0];
assert!(
parent_result
.missing_exposures
.iter()
.any(|e| e.dependency == "parent_pkg_a")
);
assert!(
parent_result
.missing_exposures
.iter()
.any(|e| e.dependency == "parent_pkg_b")
);
}
#[test]
fn test_parent_prefix_override_from_cli() {
let temp_workspace = create_parent_test_workspace();
let config = ValidatorConfig {
parent_config: ParentValidationConfig {
cli_packages: vec!["parent".to_string()],
cli_depth: Some(1),
cli_skip_features: vec![],
cli_prefix_overrides: vec![PrefixOverride {
dependency: "parent_child_a".to_string(),
prefix: "custom-a".to_string(),
}],
use_config: false,
},
..ValidatorConfig::test_default()
};
let validator =
FeatureValidator::new(Some(temp_workspace.path().to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
let parent_result = &result.parent_results[0];
let child_a_api = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency == "parent_child_a" && e.dependency_feature == "api");
if let Some(exposure) = child_a_api {
assert_eq!(exposure.expected_parent_feature, "custom-a-api");
}
let child_b_api = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency == "parent_child_b" && e.dependency_feature == "api");
if let Some(exposure) = child_b_api {
assert_eq!(exposure.expected_parent_feature, "child-b-api");
}
}
#[test]
fn test_parent_validation_default_skips_underscore_features() {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["parent", "child"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("parent")).unwrap();
let parent_cargo = r#"[package]
name = "parent"
version = "0.1.0"
[dependencies]
parent_child = { path = "../child", optional = true }
[features]
default = []
child = ["dep:parent_child"]
"#;
switchy_fs::sync::write(root_path.join("parent/Cargo.toml"), parent_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("child")).unwrap();
let child_cargo = r#"[package]
name = "parent_child"
version = "0.1.0"
[features]
default = []
api = []
_internal = []
_private_feature = []
"#;
switchy_fs::sync::write(root_path.join("child/Cargo.toml"), child_cargo).unwrap();
let config = ValidatorConfig {
parent_config: ParentValidationConfig {
cli_packages: vec!["parent".to_string()],
cli_depth: Some(1),
cli_skip_features: vec![], cli_prefix_overrides: vec![],
use_config: false,
},
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path.to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
let parent_result = &result.parent_results[0];
let api_missing = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency_feature == "api");
assert!(api_missing.is_some());
let internal_missing = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency_feature == "_internal");
assert!(internal_missing.is_none());
let private_missing = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency_feature == "_private_feature");
assert!(private_missing.is_none());
let default_missing = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency_feature == "default");
assert!(default_missing.is_none());
}
#[test]
fn test_parent_cli_skip_features_merge_with_defaults() {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["parent", "child"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("parent")).unwrap();
let parent_cargo = r#"[package]
name = "parent"
version = "0.1.0"
[dependencies]
parent_child = { path = "../child", optional = true }
[features]
default = []
child = ["dep:parent_child"]
"#;
switchy_fs::sync::write(root_path.join("parent/Cargo.toml"), parent_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("child")).unwrap();
let child_cargo = r#"[package]
name = "parent_child"
version = "0.1.0"
[features]
default = []
api = []
serde = []
_internal = []
"#;
switchy_fs::sync::write(root_path.join("child/Cargo.toml"), child_cargo).unwrap();
let config = ValidatorConfig {
parent_config: ParentValidationConfig {
cli_packages: vec!["parent".to_string()],
cli_depth: Some(1),
cli_skip_features: vec!["serde".to_string()], cli_prefix_overrides: vec![],
use_config: false,
},
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path.to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
let parent_result = &result.parent_results[0];
let api_missing = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency_feature == "api");
assert!(api_missing.is_some());
let serde_missing = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency_feature == "serde");
assert!(serde_missing.is_none());
let internal_missing = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency_feature == "_internal");
assert!(internal_missing.is_none());
let default_missing = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency_feature == "default");
assert!(default_missing.is_none());
}
#[test]
fn test_parent_validation_no_workspace_deps() {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["parent"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("parent")).unwrap();
let parent_cargo = r#"[package]
name = "parent"
version = "0.1.0"
[dependencies]
serde = "1.0"
[features]
default = []
"#;
switchy_fs::sync::write(root_path.join("parent/Cargo.toml"), parent_cargo).unwrap();
let config = ValidatorConfig {
parent_config: ParentValidationConfig {
cli_packages: vec!["parent".to_string()],
cli_depth: Some(1),
cli_skip_features: vec![],
cli_prefix_overrides: vec![],
use_config: false,
},
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path.to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
assert_eq!(result.parent_results.len(), 1);
assert!(result.parent_results[0].missing_exposures.is_empty());
assert_eq!(result.parent_results[0].features_checked, 0);
}
#[test]
fn test_parent_validation_all_features_exposed() {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["parent", "child"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("parent")).unwrap();
let parent_cargo = r#"[package]
name = "parent"
version = "0.1.0"
[dependencies]
parent_child = { path = "../child", optional = true }
[features]
default = []
child = ["dep:parent_child"]
child-api = ["child", "parent_child?/api"]
child-serde = ["child", "parent_child?/serde"]
"#;
switchy_fs::sync::write(root_path.join("parent/Cargo.toml"), parent_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("child")).unwrap();
let child_cargo = r#"[package]
name = "parent_child"
version = "0.1.0"
[features]
default = []
api = []
serde = []
"#;
switchy_fs::sync::write(root_path.join("child/Cargo.toml"), child_cargo).unwrap();
let config = ValidatorConfig {
parent_config: ParentValidationConfig {
cli_packages: vec!["parent".to_string()],
cli_depth: Some(1),
cli_skip_features: vec![],
cli_prefix_overrides: vec![],
use_config: false,
},
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path.to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
assert!(result.parent_results[0].missing_exposures.is_empty());
assert_eq!(result.parent_results[0].features_exposed, 2); }
#[test]
fn test_parent_validation_non_optional_dependency() {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["parent", "child"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("parent")).unwrap();
let parent_cargo = r#"[package]
name = "parent"
version = "0.1.0"
[dependencies]
parent_child = { path = "../child" }
[features]
default = []
"#;
switchy_fs::sync::write(root_path.join("parent/Cargo.toml"), parent_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("child")).unwrap();
let child_cargo = r#"[package]
name = "parent_child"
version = "0.1.0"
[features]
default = []
api = []
"#;
switchy_fs::sync::write(root_path.join("child/Cargo.toml"), child_cargo).unwrap();
let config = ValidatorConfig {
parent_config: ParentValidationConfig {
cli_packages: vec!["parent".to_string()],
cli_depth: Some(1),
cli_skip_features: vec![],
cli_prefix_overrides: vec![],
use_config: false,
},
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path.to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
let parent_result = &result.parent_results[0];
let api_missing = parent_result
.missing_exposures
.iter()
.find(|e| e.dependency_feature == "api");
assert!(api_missing.is_some());
let exposure = api_missing.unwrap();
assert_eq!(exposure.expected_propagation, "parent_child/api");
}
#[test]
fn test_parent_package_not_found_warning() {
let temp_dir = switchy_fs::tempdir().unwrap();
let root_path = temp_dir.path();
let workspace_cargo = r#"[workspace]
members = ["real_pkg"]
"#;
switchy_fs::sync::write(root_path.join("Cargo.toml"), workspace_cargo).unwrap();
switchy_fs::sync::create_dir(root_path.join("real_pkg")).unwrap();
let pkg_cargo = r#"[package]
name = "real_pkg"
version = "0.1.0"
[features]
default = []
"#;
switchy_fs::sync::write(root_path.join("real_pkg/Cargo.toml"), pkg_cargo).unwrap();
let config = ValidatorConfig {
parent_config: ParentValidationConfig {
cli_packages: vec!["nonexistent_pkg".to_string()],
cli_depth: Some(1),
cli_skip_features: vec![],
cli_prefix_overrides: vec![],
use_config: false,
},
..ValidatorConfig::test_default()
};
let validator = FeatureValidator::new(Some(root_path.to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
let warning = result
.warnings
.iter()
.find(|w| w.message.contains("nonexistent_pkg") && w.message.contains("not found"));
assert!(warning.is_some());
}
#[test]
fn test_parent_validation_json_output() {
let temp_workspace = create_parent_test_workspace();
let config = ValidatorConfig {
output_format: OutputType::Json,
parent_config: ParentValidationConfig {
cli_packages: vec!["parent".to_string()],
cli_depth: Some(1),
cli_skip_features: vec![],
cli_prefix_overrides: vec![],
use_config: false,
},
..ValidatorConfig::test_default()
};
let validator =
FeatureValidator::new(Some(temp_workspace.path().to_path_buf()), config).unwrap();
let result = validator.validate().unwrap();
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("parent_results"));
assert!(json.contains("missing_exposures"));
assert!(json.contains("expected_parent_feature"));
assert!(json.contains("expected_propagation"));
let _parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
}
#[test]
fn test_parent_validation_result_serialization() {
let result = ParentValidationResult {
package: "test_parent".to_string(),
missing_exposures: vec![MissingFeatureExposure {
parent_package: "test_parent".to_string(),
dependency: "test_child".to_string(),
dependency_feature: "api".to_string(),
expected_parent_feature: "child-api".to_string(),
expected_propagation: "test_child?/api".to_string(),
depth: 1,
chain: vec!["test_parent".to_string(), "test_child".to_string()],
}],
features_checked: 5,
features_exposed: 4,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("test_parent"));
assert!(json.contains("test_child"));
assert!(json.contains("child-api"));
assert!(json.contains("features_checked"));
assert!(json.contains("features_exposed"));
}
}