1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3
4use anyhow::{Result, bail};
5
6use crate::model::{FeatureManifest, FeatureRef, LintLevel, LintPreset};
7
8pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub struct LintDoc {
130 pub code: &'static str,
132 pub default_severity: Severity,
134 pub summary: &'static str,
136 pub guidance: &'static str,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
142pub enum Severity {
143 Warning,
145 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
160pub struct ValidateOptions {
161 pub cli_lints: BTreeMap<String, LintLevel>,
163 pub cli_preset: Option<LintPreset>,
165}
166
167impl ValidateOptions {
168 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 pub fn with_cli_preset(mut self, preset: Option<LintPreset>) -> Self {
178 self.cli_preset = preset;
179 self
180 }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct Issue {
186 pub severity: Severity,
188 pub default_severity: Severity,
190 pub code: &'static str,
192 pub feature: Option<String>,
194 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
239pub struct ValidationReport {
240 pub issues: Vec<Issue>,
242}
243
244impl ValidationReport {
245 pub fn has_errors(&self) -> bool {
247 self.issues
248 .iter()
249 .any(|issue| issue.severity == Severity::Error)
250 }
251
252 pub fn error_count(&self) -> usize {
254 self.issues
255 .iter()
256 .filter(|issue| issue.severity == Severity::Error)
257 .count()
258 }
259
260 pub fn warning_count(&self) -> usize {
262 self.issues
263 .iter()
264 .filter(|issue| issue.severity == Severity::Warning)
265 .count()
266 }
267
268 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
278pub 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
295pub fn known_lint_codes() -> &'static [&'static str] {
297 KNOWN_LINT_CODES
298}
299
300pub fn lint_docs() -> &'static [LintDoc] {
302 LINT_DOCS
303}
304
305pub fn validate(manifest: &FeatureManifest) -> ValidationReport {
307 validate_with_options(manifest, &ValidateOptions::default())
308}
309
310pub 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}