use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use anyhow::{Result, bail};
use crate::model::{FeatureManifest, FeatureRef, LintLevel};
pub const KNOWN_LINT_CODES: &[&str] = &[
"missing-metadata",
"missing-description",
"sensitive-default",
"unknown-reference",
"unknown-metadata",
"unknown-default-member",
"unknown-default-reference",
"small-group",
"duplicate-group-member",
"unknown-group-member",
"mutually-exclusive-default",
"dependency-not-found",
"dependency-not-optional",
"private-enabled-by-public",
"feature-cycle",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Warning,
Error,
}
impl fmt::Display for Severity {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Warning => formatter.write_str("warning"),
Self::Error => formatter.write_str("error"),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ValidateOptions {
pub cli_lints: BTreeMap<String, LintLevel>,
}
impl ValidateOptions {
pub fn with_cli_lint_overrides(entries: impl IntoIterator<Item = (String, LintLevel)>) -> Self {
Self {
cli_lints: entries.into_iter().collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Issue {
pub severity: Severity,
pub default_severity: Severity,
pub code: &'static str,
pub feature: Option<String>,
pub message: String,
}
impl Issue {
fn error(code: &'static str, feature: Option<String>, message: impl Into<String>) -> Self {
Self {
severity: Severity::Error,
default_severity: Severity::Error,
code,
feature,
message: message.into(),
}
}
fn warning(code: &'static str, feature: Option<String>, message: impl Into<String>) -> Self {
Self {
severity: Severity::Warning,
default_severity: Severity::Warning,
code,
feature,
message: message.into(),
}
}
}
impl fmt::Display for Issue {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.feature {
Some(feature) => write!(
formatter,
"{}[{}] `{}`: {}",
self.severity, self.code, feature, self.message
),
None => write!(
formatter,
"{}[{}]: {}",
self.severity, self.code, self.message
),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ValidationReport {
pub issues: Vec<Issue>,
}
impl ValidationReport {
pub fn has_errors(&self) -> bool {
self.issues
.iter()
.any(|issue| issue.severity == Severity::Error)
}
pub fn error_count(&self) -> usize {
self.issues
.iter()
.filter(|issue| issue.severity == Severity::Error)
.count()
}
pub fn warning_count(&self) -> usize {
self.issues
.iter()
.filter(|issue| issue.severity == Severity::Warning)
.count()
}
pub fn summary(&self, feature_count: usize, group_count: usize) -> String {
format!(
"validated {feature_count} feature(s) and {group_count} group(s): {} error(s), {} warning(s)",
self.error_count(),
self.warning_count()
)
}
}
pub fn parse_lint_override(raw: &str) -> Result<(String, LintLevel)> {
let (code, level) = raw
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("expected `<lint>=<allow|warn|deny>`, found `{raw}`"))?;
let code = code.trim().to_owned();
if !KNOWN_LINT_CODES.contains(&code.as_str()) {
bail!(
"unknown lint code `{code}`; known codes: {}",
KNOWN_LINT_CODES.join(", ")
);
}
Ok((code, level.trim().parse()?))
}
pub fn known_lint_codes() -> &'static [&'static str] {
KNOWN_LINT_CODES
}
pub fn validate(manifest: &FeatureManifest) -> ValidationReport {
validate_with_options(manifest, &ValidateOptions::default())
}
pub fn validate_with_options(
manifest: &FeatureManifest,
options: &ValidateOptions,
) -> ValidationReport {
let mut issues = Vec::new();
for feature in manifest.ordered_features() {
if !feature.has_metadata {
issues.push(Issue::error(
"missing-metadata",
Some(feature.name.clone()),
"feature is defined in `[features]` but missing metadata; add an entry under `[package.metadata.feature-manifest]`.",
));
}
let missing_description = feature
.metadata
.description
.as_deref()
.map(str::trim)
.map(str::is_empty)
.unwrap_or(true);
if missing_description {
issues.push(Issue::error(
"missing-description",
Some(feature.name.clone()),
"feature metadata needs a non-empty `description`.",
));
}
if feature.default_enabled
&& (feature.metadata.unstable
|| feature.metadata.deprecated
|| !feature.metadata.public)
&& !feature.metadata.allow_default
{
issues.push(Issue::error(
"sensitive-default",
Some(feature.name.clone()),
"feature is enabled by default while marked unstable, deprecated, or private; set `allow_default = true` to acknowledge the default.",
));
}
for reference in &feature.enables {
if let FeatureRef::Unknown { raw } = reference {
issues.push(Issue::warning(
"unknown-reference",
Some(feature.name.clone()),
format!("feature contains an unrecognized reference syntax: `{raw}`."),
));
}
}
for reference in &feature.enables {
match reference {
FeatureRef::Dependency { name } if !manifest.dependencies.is_empty() => {
match manifest.dependencies.get(name) {
Some(dependency) => {
if !dependency.optional {
issues.push(Issue::error(
"dependency-not-optional",
Some(feature.name.clone()),
format!(
"`dep:{name}` requires `{name}` to be an optional dependency."
),
));
}
}
None => issues.push(Issue::error(
"dependency-not-found",
Some(feature.name.clone()),
format!("`dep:{name}` references a dependency that does not exist."),
)),
}
}
FeatureRef::DependencyFeature {
dependency,
feature: dependency_feature,
weak,
} if !manifest.dependencies.is_empty() => match manifest.dependencies.get(dependency) {
Some(info) => {
if *weak && !info.optional {
issues.push(Issue::error(
"dependency-not-optional",
Some(feature.name.clone()),
format!(
"`{dependency}?/{dependency_feature}` requires `{dependency}` to be optional."
),
));
}
}
None => issues.push(Issue::error(
"dependency-not-found",
Some(feature.name.clone()),
format!(
"`{dependency}{separator}{dependency_feature}` references a dependency that does not exist.",
separator = if *weak { "?/" } else { "/" }
),
)),
},
FeatureRef::Feature { name } => {
if feature.metadata.public
&& manifest
.features
.get(name)
.is_some_and(|target| !target.metadata.public)
{
issues.push(Issue::warning(
"private-enabled-by-public",
Some(feature.name.clone()),
format!(
"public feature enables private feature `{name}`, which may surprise downstream users."
),
));
}
}
FeatureRef::Dependency { .. }
| FeatureRef::DependencyFeature { .. }
| FeatureRef::Unknown { .. } => {}
}
}
}
for cycle in detect_feature_cycles(manifest) {
let cycle_summary = cycle.join(" -> ");
for feature_name in cycle {
issues.push(Issue::error(
"feature-cycle",
Some(feature_name),
format!("feature is part of a local cycle: {cycle_summary}."),
));
}
}
for (name, _) in &manifest.metadata_only {
issues.push(Issue::error(
"unknown-metadata",
Some(name.clone()),
"metadata exists for a feature that is not declared in `[features]`.",
));
}
for reference in &manifest.default_members {
match reference {
FeatureRef::Feature { name } => {
if !manifest.features.contains_key(name) {
issues.push(Issue::error(
"unknown-default-member",
Some(name.clone()),
"entry appears in `features.default` but is not a declared feature.",
));
}
}
FeatureRef::Unknown { raw } => {
issues.push(Issue::warning(
"unknown-default-reference",
Some(raw.clone()),
"default feature set contains an unrecognized reference syntax.",
));
}
_ => {}
}
}
for group in &manifest.groups {
if group.members.len() < 2 {
issues.push(Issue::warning(
"small-group",
Some(group.name.clone()),
"groups are most useful with at least two members.",
));
}
let mut seen = BTreeSet::new();
let mut default_members = Vec::new();
for member in &group.members {
if !seen.insert(member) {
issues.push(Issue::error(
"duplicate-group-member",
Some(group.name.clone()),
format!("group includes `{member}` more than once."),
));
}
let Some(feature) = manifest.features.get(member) else {
issues.push(Issue::error(
"unknown-group-member",
Some(group.name.clone()),
format!("group references missing feature `{member}`."),
));
continue;
};
if feature.default_enabled {
default_members.push(member.clone());
}
}
if group.mutually_exclusive && default_members.len() > 1 {
issues.push(Issue::error(
"mutually-exclusive-default",
Some(group.name.clone()),
format!(
"mutually exclusive group has multiple default-enabled members: {}.",
default_members
.iter()
.map(|member| format!("`{member}`"))
.collect::<Vec<_>>()
.join(", ")
),
));
}
}
ValidationReport {
issues: apply_lint_overrides(issues, manifest, options),
}
}
fn detect_feature_cycles(manifest: &FeatureManifest) -> Vec<Vec<String>> {
let mut cycles = Vec::new();
let mut path = Vec::new();
let mut path_set = BTreeSet::new();
let mut seen = BTreeSet::new();
for feature_name in manifest.features.keys() {
visit_feature(
manifest,
feature_name,
&mut seen,
&mut path,
&mut path_set,
&mut cycles,
);
}
cycles.sort();
cycles.dedup();
cycles
}
fn visit_feature(
manifest: &FeatureManifest,
feature_name: &str,
seen: &mut BTreeSet<String>,
path: &mut Vec<String>,
path_set: &mut BTreeSet<String>,
cycles: &mut Vec<Vec<String>>,
) {
if path_set.contains(feature_name) {
if let Some(position) = path.iter().position(|entry| entry == feature_name) {
let mut cycle = path[position..].to_vec();
cycle.push(feature_name.to_owned());
cycles.push(cycle);
}
return;
}
if !seen.insert(feature_name.to_owned()) {
return;
}
let Some(feature) = manifest.features.get(feature_name) else {
return;
};
path.push(feature_name.to_owned());
path_set.insert(feature_name.to_owned());
for reference in &feature.enables {
if let Some(next_feature) = reference.local_feature_name() {
visit_feature(manifest, next_feature, seen, path, path_set, cycles);
}
}
path.pop();
path_set.remove(feature_name);
}
fn apply_lint_overrides(
issues: Vec<Issue>,
manifest: &FeatureManifest,
options: &ValidateOptions,
) -> Vec<Issue> {
issues
.into_iter()
.filter_map(|mut issue| {
let override_level = options
.cli_lints
.get(issue.code)
.copied()
.or_else(|| manifest.lint_overrides.get(issue.code).copied());
match override_level {
Some(LintLevel::Allow) => None,
Some(LintLevel::Warn) => {
issue.severity = Severity::Warning;
Some(issue)
}
Some(LintLevel::Deny) => {
issue.severity = Severity::Error;
Some(issue)
}
None => Some(issue),
}
})
.collect()
}