Skip to main content

feature_manifest/
validate.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3
4use anyhow::{Result, bail};
5
6use crate::model::{FeatureManifest, FeatureRef, LintLevel, LintPreset};
7
8/// Lint codes recognized by validation and CLI override parsing.
9pub const KNOWN_LINT_CODES: &[&str] = &[
10    "missing-metadata",
11    "missing-description",
12    "sensitive-default",
13    "unknown-reference",
14    "unknown-feature-reference",
15    "unknown-metadata",
16    "unknown-default-member",
17    "unknown-default-reference",
18    "small-group",
19    "duplicate-group-member",
20    "unknown-group-member",
21    "mutually-exclusive-default",
22    "dependency-not-found",
23    "dependency-not-optional",
24    "private-enabled-by-public",
25    "feature-cycle",
26];
27
28pub const LINT_DOCS: &[LintDoc] = &[
29    LintDoc {
30        code: "missing-metadata",
31        default_severity: Severity::Error,
32        summary: "Feature exists in `[features]` but has no metadata entry.",
33        guidance: "Add an entry under `[package.metadata.feature-manifest.features]`, or run `cargo fm sync` to scaffold TODO descriptions.",
34    },
35    LintDoc {
36        code: "missing-description",
37        default_severity: Severity::Error,
38        summary: "Metadata exists but has no usable description.",
39        guidance: "Fill in `description` with text that explains why the feature exists.",
40    },
41    LintDoc {
42        code: "sensitive-default",
43        default_severity: Severity::Error,
44        summary: "A private, deprecated, or unstable feature is default-enabled without acknowledgement.",
45        guidance: "Remove the feature from `default`, or set `allow_default = true` when the default is intentional.",
46    },
47    LintDoc {
48        code: "unknown-reference",
49        default_severity: Severity::Warning,
50        summary: "A feature entry contains syntax that feature-manifest cannot classify.",
51        guidance: "Use local features, `dep:name`, `name/feature`, or `name?/feature` when possible.",
52    },
53    LintDoc {
54        code: "unknown-feature-reference",
55        default_severity: Severity::Error,
56        summary: "A feature enables a plain name that is neither a declared feature nor an optional dependency.",
57        guidance: "Add the missing feature, make the dependency optional, switch to `dep:name`, or remove the stale reference.",
58    },
59    LintDoc {
60        code: "unknown-metadata",
61        default_severity: Severity::Error,
62        summary: "Metadata exists for a feature that is not declared in `[features]`.",
63        guidance: "Delete the stale metadata, re-add the feature, or run `cargo fm sync --remove-stale`.",
64    },
65    LintDoc {
66        code: "unknown-default-member",
67        default_severity: Severity::Error,
68        summary: "`features.default` contains a missing default member.",
69        guidance: "Remove the missing default member, add the feature to `[features]`, or make the referenced dependency optional.",
70    },
71    LintDoc {
72        code: "unknown-default-reference",
73        default_severity: Severity::Warning,
74        summary: "`features.default` contains syntax that feature-manifest cannot classify.",
75        guidance: "Use local feature names in `default` when possible.",
76    },
77    LintDoc {
78        code: "small-group",
79        default_severity: Severity::Warning,
80        summary: "A group has fewer than two members.",
81        guidance: "Add at least one more member or remove the group.",
82    },
83    LintDoc {
84        code: "duplicate-group-member",
85        default_severity: Severity::Error,
86        summary: "A group repeats the same member more than once.",
87        guidance: "Deduplicate the `members` array for the group.",
88    },
89    LintDoc {
90        code: "unknown-group-member",
91        default_severity: Severity::Error,
92        summary: "A group references a feature that does not exist.",
93        guidance: "Remove the missing group member or add the feature to `[features]`.",
94    },
95    LintDoc {
96        code: "mutually-exclusive-default",
97        default_severity: Severity::Error,
98        summary: "A mutually exclusive group has multiple default-enabled members.",
99        guidance: "Keep at most one member of the group in the default feature set.",
100    },
101    LintDoc {
102        code: "dependency-not-found",
103        default_severity: Severity::Error,
104        summary: "A dependency-based feature reference points at a missing dependency.",
105        guidance: "Fix the dependency key, add the dependency, or remove the stale `dep:`/dependency-feature reference.",
106    },
107    LintDoc {
108        code: "dependency-not-optional",
109        default_severity: Severity::Error,
110        summary: "`dep:name` or `name?/feature` is used for a dependency that is not optional.",
111        guidance: "Mark the dependency `optional = true`, or use a plain dependency feature reference when the dependency is always enabled.",
112    },
113    LintDoc {
114        code: "private-enabled-by-public",
115        default_severity: Severity::Warning,
116        summary: "A public feature enables a private feature.",
117        guidance: "Make the dependency feature public too, or document why the public feature intentionally exposes that internal toggle.",
118    },
119    LintDoc {
120        code: "feature-cycle",
121        default_severity: Severity::Error,
122        summary: "Local features form a cycle.",
123        guidance: "Break the cycle by removing one local feature reference from the loop.",
124    },
125];
126
127/// Documentation for a supported lint code.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub struct LintDoc {
130    /// Stable lint code used in output formats and configuration.
131    pub code: &'static str,
132    /// Default severity before presets or overrides are applied.
133    pub default_severity: Severity,
134    /// Short lint description.
135    pub summary: &'static str,
136    /// Suggested fix or next action.
137    pub guidance: &'static str,
138}
139
140/// Severity level attached to a validation issue.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
142pub enum Severity {
143    /// Non-blocking validation finding.
144    Warning,
145    /// Blocking validation finding.
146    Error,
147}
148
149impl fmt::Display for Severity {
150    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151        match self {
152            Self::Warning => formatter.write_str("warning"),
153            Self::Error => formatter.write_str("error"),
154        }
155    }
156}
157
158/// Configuration applied during validation.
159#[derive(Debug, Clone, Default, PartialEq, Eq)]
160pub struct ValidateOptions {
161    /// Per-run lint level overrides keyed by lint code.
162    pub cli_lints: BTreeMap<String, LintLevel>,
163    /// Per-run lint preset.
164    pub cli_preset: Option<LintPreset>,
165}
166
167impl ValidateOptions {
168    /// Builds options from CLI-style lint overrides.
169    pub fn with_cli_lint_overrides(entries: impl IntoIterator<Item = (String, LintLevel)>) -> Self {
170        Self {
171            cli_lints: entries.into_iter().collect(),
172            cli_preset: None,
173        }
174    }
175
176    /// Applies a CLI-style preset to these options.
177    pub fn with_cli_preset(mut self, preset: Option<LintPreset>) -> Self {
178        self.cli_preset = preset;
179        self
180    }
181}
182
183/// A single validation finding produced by [`validate`].
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct Issue {
186    /// Effective severity after presets and overrides.
187    pub severity: Severity,
188    /// Severity before presets and overrides.
189    pub default_severity: Severity,
190    /// Stable lint code.
191    pub code: &'static str,
192    /// Associated feature or group name when applicable.
193    pub feature: Option<String>,
194    /// Human-readable explanation.
195    pub message: String,
196}
197
198impl Issue {
199    fn error(code: &'static str, feature: Option<String>, message: impl Into<String>) -> Self {
200        Self {
201            severity: Severity::Error,
202            default_severity: Severity::Error,
203            code,
204            feature,
205            message: message.into(),
206        }
207    }
208
209    fn warning(code: &'static str, feature: Option<String>, message: impl Into<String>) -> Self {
210        Self {
211            severity: Severity::Warning,
212            default_severity: Severity::Warning,
213            code,
214            feature,
215            message: message.into(),
216        }
217    }
218}
219
220impl fmt::Display for Issue {
221    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
222        match &self.feature {
223            Some(feature) => write!(
224                formatter,
225                "{}[{}] `{}`: {}",
226                self.severity, self.code, feature, self.message
227            ),
228            None => write!(
229                formatter,
230                "{}[{}]: {}",
231                self.severity, self.code, self.message
232            ),
233        }
234    }
235}
236
237/// Aggregated output from a validation run.
238#[derive(Debug, Clone, Default, PartialEq, Eq)]
239pub struct ValidationReport {
240    /// Validation issues in deterministic order.
241    pub issues: Vec<Issue>,
242}
243
244impl ValidationReport {
245    /// Returns `true` when any issue is an error.
246    pub fn has_errors(&self) -> bool {
247        self.issues
248            .iter()
249            .any(|issue| issue.severity == Severity::Error)
250    }
251
252    /// Counts error-level issues.
253    pub fn error_count(&self) -> usize {
254        self.issues
255            .iter()
256            .filter(|issue| issue.severity == Severity::Error)
257            .count()
258    }
259
260    /// Counts warning-level issues.
261    pub fn warning_count(&self) -> usize {
262        self.issues
263            .iter()
264            .filter(|issue| issue.severity == Severity::Warning)
265            .count()
266    }
267
268    /// Returns a compact summary line suitable for CLI output.
269    pub fn summary(&self, feature_count: usize, group_count: usize) -> String {
270        format!(
271            "validated {feature_count} feature(s) and {group_count} group(s): {} error(s), {} warning(s)",
272            self.error_count(),
273            self.warning_count()
274        )
275    }
276}
277
278/// Parses a lint override string like `missing-description=warn`.
279pub fn parse_lint_override(raw: &str) -> Result<(String, LintLevel)> {
280    let (code, level) = raw
281        .split_once('=')
282        .ok_or_else(|| anyhow::anyhow!("expected `<lint>=<allow|warn|deny>`, found `{raw}`"))?;
283
284    let code = code.trim().to_owned();
285    if !KNOWN_LINT_CODES.contains(&code.as_str()) {
286        bail!(
287            "unknown lint code `{code}`; known codes: {}",
288            KNOWN_LINT_CODES.join(", ")
289        );
290    }
291
292    Ok((code, level.trim().parse()?))
293}
294
295/// Returns the known lint codes in stable order.
296pub fn known_lint_codes() -> &'static [&'static str] {
297    KNOWN_LINT_CODES
298}
299
300/// Returns generated documentation for all known lint codes.
301pub fn lint_docs() -> &'static [LintDoc] {
302    LINT_DOCS
303}
304
305/// Validates a manifest for missing docs, stale metadata, and risky defaults.
306pub fn validate(manifest: &FeatureManifest) -> ValidationReport {
307    validate_with_options(manifest, &ValidateOptions::default())
308}
309
310/// Validates a manifest with CLI lint overrides applied on top of manifest config.
311pub fn validate_with_options(
312    manifest: &FeatureManifest,
313    options: &ValidateOptions,
314) -> ValidationReport {
315    let mut issues = Vec::new();
316
317    for feature in manifest.ordered_features() {
318        if !feature.has_metadata {
319            issues.push(Issue::error(
320                "missing-metadata",
321                Some(feature.name.clone()),
322                "feature is defined in `[features]` but missing metadata; add an entry under `[package.metadata.feature-manifest]`.",
323            ));
324        }
325
326        let missing_description = feature
327            .metadata
328            .description
329            .as_deref()
330            .map(str::trim)
331            .map(str::is_empty)
332            .unwrap_or(true);
333
334        if missing_description {
335            issues.push(Issue::error(
336                "missing-description",
337                Some(feature.name.clone()),
338                "feature metadata needs a non-empty `description`.",
339            ));
340        }
341
342        if feature.default_enabled
343            && (feature.metadata.unstable
344                || feature.metadata.deprecated
345                || !feature.metadata.public)
346            && !feature.metadata.allow_default
347        {
348            issues.push(Issue::error(
349                "sensitive-default",
350                Some(feature.name.clone()),
351                "feature is enabled by default while marked unstable, deprecated, or private; set `allow_default = true` to acknowledge the default.",
352            ));
353        }
354
355        for reference in &feature.enables {
356            if let FeatureRef::Unknown { raw } = reference {
357                issues.push(Issue::warning(
358                    "unknown-reference",
359                    Some(feature.name.clone()),
360                    format!("feature contains an unrecognized reference syntax: `{raw}`."),
361                ));
362            }
363        }
364
365        for reference in &feature.enables {
366            match reference {
367                FeatureRef::Dependency { name } if !manifest.dependencies.is_empty() => {
368                    validate_dependency_reference(
369                        manifest,
370                        &feature.name,
371                        name,
372                        None,
373                        false,
374                        &mut issues,
375                    );
376                }
377                FeatureRef::DependencyFeature {
378                    dependency,
379                    feature: dependency_feature,
380                    weak,
381                } if !manifest.dependencies.is_empty() => {
382                    validate_dependency_reference(
383                        manifest,
384                        &feature.name,
385                        dependency,
386                        Some(dependency_feature),
387                        *weak,
388                        &mut issues,
389                    );
390                }
391                FeatureRef::Feature { name } => {
392                    validate_plain_feature_reference(
393                        manifest,
394                        &feature.name,
395                        feature.metadata.public,
396                        name,
397                        &mut issues,
398                    );
399                }
400                FeatureRef::Dependency { .. }
401                | FeatureRef::DependencyFeature { .. }
402                | FeatureRef::Unknown { .. } => {}
403            }
404        }
405    }
406
407    for cycle in detect_feature_cycles(manifest) {
408        let cycle_summary = cycle.join(" -> ");
409        let cycle_features = cycle.into_iter().collect::<BTreeSet<_>>();
410        for feature_name in cycle_features {
411            issues.push(Issue::error(
412                "feature-cycle",
413                Some(feature_name),
414                format!("feature is part of a local cycle: {cycle_summary}."),
415            ));
416        }
417    }
418
419    for name in manifest.metadata_only.keys() {
420        issues.push(Issue::error(
421            "unknown-metadata",
422            Some(name.clone()),
423            "metadata exists for a feature that is not declared in `[features]`.",
424        ));
425    }
426
427    for reference in &manifest.default_members {
428        match reference {
429            FeatureRef::Feature { name }
430                if !declared_feature_or_optional_dependency(manifest, name) =>
431            {
432                issues.push(Issue::error(
433                    "unknown-default-member",
434                    Some(name.clone()),
435                    "entry appears in `features.default` but is not a declared feature or optional dependency.",
436                ));
437            }
438            FeatureRef::Feature { .. } => {}
439            FeatureRef::Dependency { name } if !manifest.dependencies.is_empty() => {
440                validate_dependency_reference(manifest, "default", name, None, false, &mut issues);
441            }
442            FeatureRef::DependencyFeature {
443                dependency,
444                feature,
445                weak,
446            } if !manifest.dependencies.is_empty() => {
447                validate_dependency_reference(
448                    manifest,
449                    "default",
450                    dependency,
451                    Some(feature),
452                    *weak,
453                    &mut issues,
454                );
455            }
456            FeatureRef::Unknown { raw } => {
457                issues.push(Issue::warning(
458                    "unknown-default-reference",
459                    Some(raw.clone()),
460                    "default feature set contains an unrecognized reference syntax.",
461                ));
462            }
463            _ => {}
464        }
465    }
466
467    for group in &manifest.groups {
468        if group.members.len() < 2 {
469            issues.push(Issue::warning(
470                "small-group",
471                Some(group.name.clone()),
472                "groups are most useful with at least two members.",
473            ));
474        }
475
476        let mut seen = BTreeSet::new();
477        let mut default_members = Vec::new();
478
479        for member in &group.members {
480            if !seen.insert(member) {
481                issues.push(Issue::error(
482                    "duplicate-group-member",
483                    Some(group.name.clone()),
484                    format!("group includes `{member}` more than once."),
485                ));
486            }
487
488            let Some(feature) = manifest.features.get(member) else {
489                issues.push(Issue::error(
490                    "unknown-group-member",
491                    Some(group.name.clone()),
492                    format!("group references missing feature `{member}`."),
493                ));
494                continue;
495            };
496
497            if feature.default_enabled {
498                default_members.push(member.clone());
499            }
500        }
501
502        if group.mutually_exclusive && default_members.len() > 1 {
503            issues.push(Issue::error(
504                "mutually-exclusive-default",
505                Some(group.name.clone()),
506                format!(
507                    "mutually exclusive group has multiple default-enabled members: {}.",
508                    default_members
509                        .iter()
510                        .map(|member| format!("`{member}`"))
511                        .collect::<Vec<_>>()
512                        .join(", ")
513                ),
514            ));
515        }
516    }
517
518    ValidationReport {
519        issues: apply_lint_overrides(issues, manifest, options),
520    }
521}
522
523fn validate_dependency_reference(
524    manifest: &FeatureManifest,
525    source_feature: &str,
526    dependency: &str,
527    dependency_feature: Option<&str>,
528    weak: bool,
529    issues: &mut Vec<Issue>,
530) {
531    match manifest.dependencies.get(dependency) {
532        Some(info) => {
533            if dependency_feature.is_none() && !info.optional {
534                issues.push(Issue::error(
535                    "dependency-not-optional",
536                    Some(source_feature.to_owned()),
537                    format!(
538                        "`dep:{dependency}` requires `{dependency}` to be an optional dependency."
539                    ),
540                ));
541            } else if weak && !info.optional {
542                let dependency_feature = dependency_feature.unwrap_or_default();
543                issues.push(Issue::error(
544                    "dependency-not-optional",
545                    Some(source_feature.to_owned()),
546                    format!(
547                        "`{dependency}?/{dependency_feature}` requires `{dependency}` to be optional."
548                    ),
549                ));
550            }
551        }
552        None => {
553            let reference = match dependency_feature {
554                Some(feature) => {
555                    let separator = if weak { "?/" } else { "/" };
556                    format!("`{dependency}{separator}{feature}`")
557                }
558                None => format!("`dep:{dependency}`"),
559            };
560            issues.push(Issue::error(
561                "dependency-not-found",
562                Some(source_feature.to_owned()),
563                format!("{reference} references a dependency that does not exist."),
564            ));
565        }
566    }
567}
568
569fn validate_plain_feature_reference(
570    manifest: &FeatureManifest,
571    source_feature: &str,
572    source_is_public: bool,
573    target_name: &str,
574    issues: &mut Vec<Issue>,
575) {
576    if let Some(target) = manifest.features.get(target_name) {
577        if source_is_public && !target.metadata.public {
578            issues.push(Issue::warning(
579                "private-enabled-by-public",
580                Some(source_feature.to_owned()),
581                format!(
582                    "public feature enables private feature `{target_name}`, which may surprise downstream users."
583                ),
584            ));
585        }
586        return;
587    }
588
589    if manifest
590        .dependencies
591        .get(target_name)
592        .is_some_and(|dependency| dependency.optional)
593    {
594        return;
595    }
596
597    issues.push(Issue::error(
598        "unknown-feature-reference",
599        Some(source_feature.to_owned()),
600        format!("`{target_name}` is not a declared feature or optional dependency."),
601    ));
602}
603
604fn declared_feature_or_optional_dependency(manifest: &FeatureManifest, name: &str) -> bool {
605    manifest.features.contains_key(name)
606        || manifest
607            .dependencies
608            .get(name)
609            .is_some_and(|dependency| dependency.optional)
610}
611
612fn detect_feature_cycles(manifest: &FeatureManifest) -> Vec<Vec<String>> {
613    let mut cycles = Vec::new();
614    let mut path = Vec::new();
615    let mut path_set = BTreeSet::new();
616    let mut seen = BTreeSet::new();
617
618    for feature_name in manifest.features.keys() {
619        visit_feature(
620            manifest,
621            feature_name,
622            &mut seen,
623            &mut path,
624            &mut path_set,
625            &mut cycles,
626        );
627    }
628
629    cycles.sort();
630    cycles.dedup();
631    cycles
632}
633
634fn visit_feature(
635    manifest: &FeatureManifest,
636    feature_name: &str,
637    seen: &mut BTreeSet<String>,
638    path: &mut Vec<String>,
639    path_set: &mut BTreeSet<String>,
640    cycles: &mut Vec<Vec<String>>,
641) {
642    if path_set.contains(feature_name) {
643        if let Some(position) = path.iter().position(|entry| entry == feature_name) {
644            let mut cycle = path[position..].to_vec();
645            cycle.push(feature_name.to_owned());
646            cycles.push(cycle);
647        }
648        return;
649    }
650
651    if !seen.insert(feature_name.to_owned()) {
652        return;
653    }
654
655    let Some(feature) = manifest.features.get(feature_name) else {
656        return;
657    };
658
659    path.push(feature_name.to_owned());
660    path_set.insert(feature_name.to_owned());
661
662    for reference in &feature.enables {
663        if let Some(next_feature) = reference.local_feature_name() {
664            visit_feature(manifest, next_feature, seen, path, path_set, cycles);
665        }
666    }
667
668    path.pop();
669    path_set.remove(feature_name);
670}
671
672fn apply_lint_overrides(
673    issues: Vec<Issue>,
674    manifest: &FeatureManifest,
675    options: &ValidateOptions,
676) -> Vec<Issue> {
677    issues
678        .into_iter()
679        .filter_map(|mut issue| {
680            let override_level = options
681                .cli_lints
682                .get(issue.code)
683                .copied()
684                .or_else(|| manifest.lint_overrides.get(issue.code).copied())
685                .or_else(|| preset_level(options.cli_preset, issue.code))
686                .or_else(|| preset_level(manifest.lint_preset, issue.code));
687
688            match override_level {
689                Some(LintLevel::Allow) => None,
690                Some(LintLevel::Warn) => {
691                    issue.severity = Severity::Warning;
692                    Some(issue)
693                }
694                Some(LintLevel::Deny) => {
695                    issue.severity = Severity::Error;
696                    Some(issue)
697                }
698                None => Some(issue),
699            }
700        })
701        .collect()
702}
703
704fn preset_level(preset: Option<LintPreset>, code: &str) -> Option<LintLevel> {
705    match preset {
706        Some(LintPreset::Adopt) => match code {
707            "missing-metadata"
708            | "missing-description"
709            | "unknown-metadata"
710            | "small-group"
711            | "private-enabled-by-public" => Some(LintLevel::Warn),
712            _ => None,
713        },
714        Some(LintPreset::Strict) => match code {
715            "unknown-reference"
716            | "unknown-default-reference"
717            | "small-group"
718            | "private-enabled-by-public" => Some(LintLevel::Deny),
719            _ => None,
720        },
721        None => None,
722    }
723}