1use std::collections::{BTreeMap, HashSet};
4
5use crate::{AstCtx, FileCtx, Fix, TomlCtx, Violation, languages::workspace::WorkspaceCtx};
6
7#[derive(Debug, serde::Deserialize, serde::Serialize)]
9pub struct ConfigRule {
10 pub name: String,
11 pub description: String,
12 pub severity: String,
13 pub category: String,
14 pub fixable: bool,
15 pub enabled: bool,
16 #[serde(default, skip_serializing_if = "Vec::is_empty")]
17 pub ignore: Vec<String>,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub params: Option<BTreeMap<String, ConfigValue>>,
20}
21
22#[derive(Debug, serde::Deserialize, serde::Serialize)]
24#[serde(transparent)]
25pub struct ConfigValue(serde_json::Value);
26
27impl From<serde_json::Value> for ConfigValue {
28 fn from(value: serde_json::Value) -> Self {
29 Self(value)
30 }
31}
32
33impl PartialEq<i32> for ConfigValue {
34 fn eq(&self, other: &i32) -> bool {
35 self.0.as_i64() == Some(i64::from(*other))
36 }
37}
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum RuleKind {
43 RustLine,
44 RustAst,
45 RustWorkspace,
46 Workspace,
47 Toml,
48}
49
50impl RuleKind {
51 #[must_use]
52 pub fn as_str(&self) -> &'static str {
53 match self {
54 RuleKind::RustLine => "rust-line",
55 RuleKind::RustAst => "rust-ast",
56 RuleKind::RustWorkspace => "rust-workspace",
57 RuleKind::Workspace => "workspace",
58 RuleKind::Toml => "toml",
59 }
60 }
61}
62
63impl std::fmt::Display for RuleKind {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.write_str(self.as_str())
66 }
67}
68
69#[derive(Debug)]
71pub struct RuleMeta {
72 pub name: &'static str,
73 pub description: &'static str,
74 pub justification: &'static str,
75 pub severity: Severity,
76 pub kind: RuleKind,
77 pub examples: &'static [Example],
78 pub params: &'static [RuleParam],
79 pub fixable: bool,
80 pub default_enabled: bool,
81}
82
83#[must_use]
85#[doc(alias = "lint registry", alias = "rulewright rules")]
86pub fn all_rules() -> Vec<RuleMeta> {
87 let mut rules: Vec<RuleMeta> = inventory::iter::<Rule>
88 .into_iter()
89 .map(|rule| rule.info.to_meta(rule.check.kind(), rule.fix.is_some()))
90 .collect();
91
92 rules.sort_by_key(|r| r.name);
93
94 rules
95}
96
97#[derive(Clone, Copy, Debug)]
99pub struct RulePack {
100 pub name: &'static str,
102 pub version: &'static str,
104 pub implementation_fingerprint: &'static str,
106 pub rules: &'static [Rule],
108}
109
110#[derive(Debug, Default)]
112pub struct RuleRegistry {
113 rules: Vec<&'static Rule>,
114 packs: Vec<RulePack>,
115}
116
117impl RuleRegistry {
118 #[must_use]
120 pub const fn new() -> Self {
121 Self {
122 rules: Vec::new(),
123 packs: Vec::new(),
124 }
125 }
126
127 pub fn with_builtins() -> Result<Self, RegistryError> {
133 let mut registry = Self::new();
134 let mut rules: Vec<&'static Rule> = inventory::iter::<Rule>.into_iter().collect();
135
136 rules.sort_by_key(|rule| rule.info.name);
137 registry.insert_rules("rulewright", &rules)?;
138
139 Ok(registry)
140 }
141
142 pub fn extend(&mut self, pack: RulePack) -> Result<(), RegistryError> {
148 if !valid_pack_name(pack.name) {
149 return Err(RegistryError::InvalidPackName);
150 }
151
152 if pack.name == "rulewright" {
153 return Err(RegistryError::ReservedPackName);
154 }
155
156 if self
157 .packs
158 .iter()
159 .any(|registered| registered.name == pack.name)
160 {
161 return Err(RegistryError::DuplicatePack(pack.name));
162 }
163
164 if semver::Version::parse(pack.version).is_err() {
165 return Err(RegistryError::InvalidPackVersion {
166 pack: pack.name,
167 version: pack.version,
168 });
169 }
170
171 if pack.implementation_fingerprint.trim().is_empty() {
172 return Err(RegistryError::MissingFingerprint(pack.name));
173 }
174
175 let rules: Vec<&'static Rule> = pack.rules.iter().collect();
176
177 self.insert_rules(pack.name, &rules)?;
178 self.packs.push(pack);
179 self.packs.sort_by_key(|registered| registered.name);
180
181 Ok(())
182 }
183
184 #[must_use]
186 pub fn rules(&self) -> &[&'static Rule] {
187 &self.rules
188 }
189
190 #[must_use]
192 pub fn packs(&self) -> &[RulePack] {
193 &self.packs
194 }
195
196 #[must_use]
198 pub fn metadata(&self) -> Vec<RuleMeta> {
199 self.rules
200 .iter()
201 .map(|rule| rule.info.to_meta(rule.check.kind(), rule.fix.is_some()))
202 .collect()
203 }
204
205 fn insert_rules(
206 &mut self,
207 pack_name: &'static str,
208 rules: &[&'static Rule],
209 ) -> Result<(), RegistryError> {
210 let mut known: HashSet<&str> = self.rules.iter().map(|rule| rule.info.name).collect();
211
212 for rule in rules {
213 validate_rule(pack_name, rule)?;
214
215 if !known.insert(rule.info.name) {
216 return Err(RegistryError::DuplicateRule {
217 pack: pack_name,
218 rule: rule.info.name,
219 });
220 }
221 }
222
223 self.rules.extend_from_slice(rules);
224 self.rules.sort_by_key(|rule| rule.info.name);
225
226 Ok(())
227 }
228}
229
230#[derive(Debug, thiserror::Error)]
232pub enum RegistryError {
233 #[error(
234 "rule pack name must start with a lowercase letter and contain only lowercase letters, digits, `-`, or `_`"
235 )]
236 InvalidPackName,
237 #[error("rule pack name `rulewright` is reserved for built-in rules")]
238 ReservedPackName,
239 #[error("rule pack `{0}` is already registered")]
240 DuplicatePack(&'static str),
241 #[error("rule pack `{pack}` has invalid semantic version `{version}`")]
242 InvalidPackVersion {
243 pack: &'static str,
244 version: &'static str,
245 },
246 #[error("rule pack `{0}` must provide an implementation fingerprint")]
247 MissingFingerprint(&'static str),
248 #[error("rule pack `{pack}` registers duplicate rule ID `{rule}`")]
249 DuplicateRule {
250 pack: &'static str,
251 rule: &'static str,
252 },
253 #[error("rule pack `{pack}` has invalid rule `{rule}`: {reason}")]
254 InvalidRule {
255 pack: &'static str,
256 rule: &'static str,
257 reason: &'static str,
258 },
259}
260
261#[derive(Clone, Copy, Debug)]
263pub struct Example {
264 pub label: &'static str,
265 pub code: &'static str,
266 pub pass: bool,
267}
268
269#[derive(Clone, Copy, Debug, Eq, PartialEq)]
270pub enum ParamType {
272 Int,
273 StringArray,
274}
275
276impl ParamType {
277 #[must_use]
278 pub const fn as_str(self) -> &'static str {
279 match self {
280 Self::Int => "i64",
281 Self::StringArray => "string-array",
282 }
283 }
284}
285
286#[derive(Clone, Copy, Debug)]
287pub enum ParamDefault {
289 Int(i64),
290 StringArray(&'static [&'static str]),
291}
292
293#[derive(Clone, Copy, Debug)]
294pub struct RuleParam {
295 pub name: &'static str,
296 pub param_type: ParamType,
297 pub default: ParamDefault,
298 pub allowed_values: &'static [&'static str],
299}
300
301#[derive(Clone, Copy, Debug, Eq, PartialEq)]
302pub enum Severity {
304 Low,
305 Medium,
306 High,
307}
308
309impl Severity {
310 #[must_use]
311 pub fn as_str(&self) -> &'static str {
312 match self {
313 Severity::Low => "low",
314 Severity::Medium => "medium",
315 Severity::High => "high",
316 }
317 }
318}
319
320impl std::fmt::Display for Severity {
321 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322 f.write_str(self.as_str())
323 }
324}
325
326#[derive(Clone, Copy, Debug)]
328pub struct RuleInfo {
329 pub name: &'static str,
330 pub description: &'static str,
331 pub justification: &'static str,
332 pub severity: Severity,
333 pub examples: &'static [Example],
334 pub params: &'static [RuleParam],
335 pub default_enabled: bool,
336}
337
338impl RuleInfo {
339 #[must_use]
341 pub const fn new(
342 name: &'static str,
343 description: &'static str,
344 justification: &'static str,
345 severity: Severity,
346 examples: &'static [Example],
347 params: &'static [RuleParam],
348 ) -> Self {
349 Self {
350 name,
351 description,
352 justification,
353 severity,
354 examples,
355 params,
356 default_enabled: true,
357 }
358 }
359
360 #[must_use]
362 pub const fn disabled_by_default(mut self) -> Self {
363 self.default_enabled = false;
364
365 self
366 }
367
368 fn to_meta(self, kind: RuleKind, fixable: bool) -> RuleMeta {
369 RuleMeta {
370 name: self.name,
371 description: self.description,
372 justification: self.justification,
373 severity: self.severity,
374 kind,
375 examples: self.examples,
376 params: self.params,
377 fixable,
378 default_enabled: self.default_enabled,
379 }
380 }
381}
382
383fn valid_identifier(value: &str) -> bool {
384 let mut bytes = value.bytes();
385
386 bytes.next().is_some_and(|first| first.is_ascii_lowercase())
387 && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
388}
389
390fn valid_pack_name(value: &str) -> bool {
391 let mut bytes = value.bytes();
392
393 bytes.next().is_some_and(|first| first.is_ascii_lowercase())
394 && bytes.all(|byte| {
395 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
396 })
397}
398
399fn validate_rule(pack: &'static str, rule: &Rule) -> Result<(), RegistryError> {
400 let invalid = |reason| RegistryError::InvalidRule {
401 pack,
402 rule: rule.info.name,
403 reason,
404 };
405
406 if !valid_identifier(rule.info.name) {
407 return Err(invalid(
408 "ID must start with a lowercase letter and contain only lowercase letters, digits, or `_`",
409 ));
410 }
411
412 if rule.info.description.trim().is_empty() || rule.info.justification.trim().is_empty() {
413 return Err(invalid("description and justification must not be empty"));
414 }
415
416 if rule
417 .info
418 .examples
419 .iter()
420 .any(|example| example.label.trim().is_empty() || example.code.trim().is_empty())
421 {
422 return Err(invalid("example labels and source must not be empty"));
423 }
424
425 let mut params = HashSet::new();
426
427 for parameter in rule.info.params {
428 if !valid_identifier(parameter.name) {
429 return Err(invalid(
430 "parameter names must be valid lowercase identifiers",
431 ));
432 }
433
434 if !params.insert(parameter.name) {
435 return Err(invalid("parameter names must be unique"));
436 }
437
438 match (¶meter.param_type, ¶meter.default) {
439 (ParamType::Int, ParamDefault::Int(value)) if *value < 0 => {
440 return Err(invalid("integer parameter defaults must be non-negative"));
441 }
442
443 (ParamType::Int, ParamDefault::Int(_)) => {}
444
445 (ParamType::StringArray, ParamDefault::StringArray(defaults)) => {
446 let mut unique_defaults = HashSet::new();
447
448 if defaults.iter().any(|value| !unique_defaults.insert(*value)) {
449 return Err(invalid(
450 "string-array parameter defaults must not contain duplicates",
451 ));
452 }
453
454 if !parameter.allowed_values.is_empty()
455 && defaults
456 .iter()
457 .any(|value| !parameter.allowed_values.contains(value))
458 {
459 return Err(invalid("string-array defaults must use allowed values"));
460 }
461 }
462
463 _ => return Err(invalid("parameter type does not match its default value")),
464 }
465 }
466
467 let compatible = match rule.check {
468 RuleCheck::RustLine(_) | RuleCheck::RustLineFull(_) => {
469 matches!(rule.fix, None | Some(RuleFix::RustLine(_)))
470 }
471
472 RuleCheck::RustAst(_) => {
473 matches!(
474 rule.fix,
475 None | Some(RuleFix::RustAst(_) | RuleFix::RustAstTree(_))
476 )
477 }
478
479 RuleCheck::RustWorkspace(_) | RuleCheck::Workspace(_) => rule.fix.is_none(),
480
481 RuleCheck::Toml(_) => matches!(rule.fix, None | Some(RuleFix::Toml(_))),
482 };
483
484 if !compatible {
485 return Err(invalid("fix kind does not match check kind"));
486 }
487
488 Ok(())
489}
490
491#[derive(Clone, Copy, Debug)]
493pub enum RuleCheck {
494 RustLine(fn(&FileCtx<'_>) -> Vec<Violation>),
495 RustLineFull(fn(&FileCtx<'_>) -> Vec<Violation>),
496 RustAst(fn(&AstCtx<'_>) -> Vec<Violation>),
497 RustWorkspace(fn(&WorkspaceCtx<'_>) -> Vec<Violation>),
498 Workspace(fn(&WorkspaceCtx<'_>) -> Vec<Violation>),
499 Toml(fn(&TomlCtx<'_>) -> Vec<Violation>),
500}
501
502impl RuleCheck {
503 #[must_use]
504 pub const fn kind(self) -> RuleKind {
505 match self {
506 Self::RustLine(_) | Self::RustLineFull(_) => RuleKind::RustLine,
507 Self::RustAst(_) => RuleKind::RustAst,
508 Self::RustWorkspace(_) => RuleKind::RustWorkspace,
509 Self::Workspace(_) => RuleKind::Workspace,
510 Self::Toml(_) => RuleKind::Toml,
511 }
512 }
513
514 pub(crate) const fn extensions(self) -> &'static [&'static str] {
515 match self {
516 Self::RustLine(_)
517 | Self::RustLineFull(_)
518 | Self::RustAst(_)
519 | Self::RustWorkspace(_) => &["rs"],
520 Self::Workspace(_) => &["rs", "toml"],
521 Self::Toml(_) => &["toml"],
522 }
523 }
524}
525
526#[derive(Clone, Copy, Debug)]
528pub enum RuleFix {
529 RustLine(fn(&FileCtx<'_>, &Violation) -> Option<Fix>),
530 RustAst(fn(&AstCtx<'_>, &Violation) -> Option<Fix>),
531 RustAstTree(fn(&AstCtx<'_>, &[Violation]) -> Option<String>),
532 Toml(fn(&TomlCtx<'_>, &Violation) -> Option<Fix>),
533}
534
535#[derive(Clone, Copy, Debug)]
537pub struct Rule {
538 pub info: RuleInfo,
539 pub check: RuleCheck,
540 pub fix: Option<RuleFix>,
541}
542
543impl Rule {
544 #[must_use]
546 pub const fn rust_line(
547 info: RuleInfo,
548 check: fn(&FileCtx<'_>) -> Vec<Violation>,
549 fix: Option<fn(&FileCtx<'_>, &Violation) -> Option<Fix>>,
550 ) -> Self {
551 Self {
552 info,
553 check: RuleCheck::RustLine(check),
554 fix: match fix {
555 Some(fix) => Some(RuleFix::RustLine(fix)),
556 None => None,
557 },
558 }
559 }
560
561 #[must_use]
566 pub const fn rust_line_full(
567 info: RuleInfo,
568 check: fn(&FileCtx<'_>) -> Vec<Violation>,
569 fix: Option<fn(&FileCtx<'_>, &Violation) -> Option<Fix>>,
570 ) -> Self {
571 Self {
572 info,
573 check: RuleCheck::RustLineFull(check),
574 fix: match fix {
575 Some(fix) => Some(RuleFix::RustLine(fix)),
576 None => None,
577 },
578 }
579 }
580
581 #[must_use]
583 pub const fn rust_ast(
584 info: RuleInfo,
585 check: fn(&AstCtx<'_>) -> Vec<Violation>,
586 fix: Option<fn(&AstCtx<'_>, &Violation) -> Option<Fix>>,
587 ) -> Self {
588 Self {
589 info,
590 check: RuleCheck::RustAst(check),
591 fix: match fix {
592 Some(fix) => Some(RuleFix::RustAst(fix)),
593 None => None,
594 },
595 }
596 }
597
598 #[must_use]
600 pub const fn rust_ast_tree(
601 info: RuleInfo,
602 check: fn(&AstCtx<'_>) -> Vec<Violation>,
603 fix: fn(&AstCtx<'_>, &[Violation]) -> Option<String>,
604 ) -> Self {
605 Self {
606 info,
607 check: RuleCheck::RustAst(check),
608 fix: Some(RuleFix::RustAstTree(fix)),
609 }
610 }
611
612 #[must_use]
614 pub const fn rust_workspace(
615 info: RuleInfo,
616 check: fn(&WorkspaceCtx<'_>) -> Vec<Violation>,
617 ) -> Self {
618 Self {
619 info,
620 check: RuleCheck::RustWorkspace(check),
621 fix: None,
622 }
623 }
624
625 #[must_use]
627 pub const fn workspace(info: RuleInfo, check: fn(&WorkspaceCtx<'_>) -> Vec<Violation>) -> Self {
628 Self {
629 info,
630 check: RuleCheck::Workspace(check),
631 fix: None,
632 }
633 }
634
635 #[must_use]
637 pub const fn toml(
638 info: RuleInfo,
639 check: fn(&TomlCtx<'_>) -> Vec<Violation>,
640 fix: Option<fn(&TomlCtx<'_>, &Violation) -> Option<Fix>>,
641 ) -> Self {
642 Self {
643 info,
644 check: RuleCheck::Toml(check),
645 fix: match fix {
646 Some(fix) => Some(RuleFix::Toml(fix)),
647 None => None,
648 },
649 }
650 }
651
652 #[must_use]
654 pub const fn id(&self) -> &'static str {
655 self.info.name
656 }
657}
658
659inventory::collect!(Rule);
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664
665 fn no_violations(_: &FileCtx<'_>) -> Vec<Violation> {
666 Vec::new()
667 }
668
669 fn no_toml_fix(_: &TomlCtx<'_>, _: &Violation) -> Option<Fix> {
670 None
671 }
672
673 const DUPLICATE_PARAMS: &[RuleParam] = &[
674 RuleParam {
675 name: "limit",
676 param_type: ParamType::Int,
677 default: ParamDefault::Int(1),
678 allowed_values: &[],
679 },
680 RuleParam {
681 name: "limit",
682 param_type: ParamType::Int,
683 default: ParamDefault::Int(2),
684 allowed_values: &[],
685 },
686 ];
687 const INVALID_NAME_PARAMS: &[RuleParam] = &[RuleParam {
688 name: "Invalid-Param",
689 param_type: ParamType::Int,
690 default: ParamDefault::Int(1),
691 allowed_values: &[],
692 }];
693 const NEGATIVE_DEFAULT_PARAMS: &[RuleParam] = &[RuleParam {
694 name: "limit",
695 param_type: ParamType::Int,
696 default: ParamDefault::Int(-1),
697 allowed_values: &[],
698 }];
699 const INT_WITH_ARRAY_DEFAULT: &[RuleParam] = &[RuleParam {
700 name: "limit",
701 param_type: ParamType::Int,
702 default: ParamDefault::StringArray(&[]),
703 allowed_values: &[],
704 }];
705 const ARRAY_WITH_INT_DEFAULT: &[RuleParam] = &[RuleParam {
706 name: "names",
707 param_type: ParamType::StringArray,
708 default: ParamDefault::Int(1),
709 allowed_values: &[],
710 }];
711 const DUPLICATE_ARRAY_DEFAULT_PARAMS: &[RuleParam] = &[RuleParam {
712 name: "names",
713 param_type: ParamType::StringArray,
714 default: ParamDefault::StringArray(&["duplicate", "duplicate"]),
715 allowed_values: &[],
716 }];
717 const EMPTY_EXAMPLES: &[Example] = &[Example {
718 label: "",
719 code: "fn fixture() {}",
720 pass: true,
721 }];
722
723 static DUPLICATE_RULES: &[Rule] = &[Rule::rust_line(
724 RuleInfo::new(
725 "rust_dbg",
726 "Duplicate fixture.",
727 "Duplicate IDs must be rejected before the registry changes.",
728 Severity::Low,
729 &[],
730 &[],
731 ),
732 no_violations,
733 None,
734 )];
735
736 static INVALID_ID_RULES: &[Rule] = &[Rule::rust_line(
737 RuleInfo::new(
738 "Invalid-Rule",
739 "Invalid fixture.",
740 "Invalid IDs must be rejected.",
741 Severity::Low,
742 &[],
743 &[],
744 ),
745 no_violations,
746 None,
747 )];
748
749 static EMPTY_METADATA_RULES: &[Rule] = &[Rule::rust_line(
750 RuleInfo::new(
751 "fixture_empty_metadata",
752 "",
753 "Missing descriptions must be rejected.",
754 Severity::Low,
755 &[],
756 &[],
757 ),
758 no_violations,
759 None,
760 )];
761
762 static DUPLICATE_PARAM_RULES: &[Rule] = &[Rule::rust_line(
763 RuleInfo::new(
764 "fixture_duplicate_params",
765 "Duplicate parameter fixture.",
766 "Duplicate parameter names make configuration ambiguous.",
767 Severity::Low,
768 &[],
769 DUPLICATE_PARAMS,
770 ),
771 no_violations,
772 None,
773 )];
774
775 static INCOMPATIBLE_FIX_RULES: &[Rule] = &[Rule {
776 info: RuleInfo::new(
777 "fixture_incompatible_fix",
778 "Incompatible fix fixture.",
779 "A TOML fix cannot edit a Rust line-rule violation.",
780 Severity::Low,
781 &[],
782 &[],
783 ),
784 check: RuleCheck::RustLine(no_violations),
785 fix: Some(RuleFix::Toml(no_toml_fix)),
786 }];
787
788 static INVALID_PARAM_NAME_RULES: &[Rule] = &[Rule::rust_line(
789 RuleInfo::new(
790 "fixture_invalid_param_name",
791 "Invalid parameter fixture.",
792 "Parameter names form stable configuration keys.",
793 Severity::Low,
794 &[],
795 INVALID_NAME_PARAMS,
796 ),
797 no_violations,
798 None,
799 )];
800
801 static NEGATIVE_DEFAULT_RULES: &[Rule] = &[Rule::rust_line(
802 RuleInfo::new(
803 "fixture_negative_default",
804 "Negative default fixture.",
805 "Unsigned threshold accessors cannot represent negative defaults.",
806 Severity::Low,
807 &[],
808 NEGATIVE_DEFAULT_PARAMS,
809 ),
810 no_violations,
811 None,
812 )];
813
814 static INT_WITH_ARRAY_DEFAULT_RULES: &[Rule] = &[Rule::rust_line(
815 RuleInfo::new(
816 "fixture_int_with_array_default",
817 "Mismatched parameter fixture.",
818 "Parameter types and defaults must agree.",
819 Severity::Low,
820 &[],
821 INT_WITH_ARRAY_DEFAULT,
822 ),
823 no_violations,
824 None,
825 )];
826
827 static ARRAY_WITH_INT_DEFAULT_RULES: &[Rule] = &[Rule::rust_line(
828 RuleInfo::new(
829 "fixture_array_with_int_default",
830 "Mismatched parameter fixture.",
831 "Parameter types and defaults must agree.",
832 Severity::Low,
833 &[],
834 ARRAY_WITH_INT_DEFAULT,
835 ),
836 no_violations,
837 None,
838 )];
839
840 static DUPLICATE_ARRAY_DEFAULT_RULES: &[Rule] = &[Rule::rust_line(
841 RuleInfo::new(
842 "fixture_duplicate_array_default",
843 "Duplicate array default fixture.",
844 "Generated configuration must not contain invalid duplicate values.",
845 Severity::Low,
846 &[],
847 DUPLICATE_ARRAY_DEFAULT_PARAMS,
848 ),
849 no_violations,
850 None,
851 )];
852
853 static EMPTY_EXAMPLE_RULES: &[Rule] = &[Rule::rust_line(
854 RuleInfo::new(
855 "fixture_empty_example",
856 "Empty example fixture.",
857 "Examples need labels and source to be useful.",
858 Severity::Low,
859 EMPTY_EXAMPLES,
860 &[],
861 ),
862 no_violations,
863 None,
864 )];
865
866 fn fixture_pack(name: &'static str, rules: &'static [Rule]) -> RulePack {
867 RulePack {
868 name,
869 version: "1.0.0",
870 implementation_fingerprint: "fixture:v1",
871 rules,
872 }
873 }
874
875 #[test]
876 fn builtin_inventory_is_sorted_and_spans_every_rule_kind() {
877 let metadata = RuleRegistry::with_builtins()
878 .expect("built-in rule IDs should be unique")
879 .metadata();
880
881 assert!(metadata.iter().any(|rule| rule.fixable));
882
883 for kind in [
884 RuleKind::RustLine,
885 RuleKind::RustAst,
886 RuleKind::RustWorkspace,
887 RuleKind::Workspace,
888 RuleKind::Toml,
889 ] {
890 assert!(metadata.iter().any(|rule| rule.kind == kind), "{kind:?}");
891 }
892
893 assert!(metadata.windows(2).all(|pair| pair[0].name < pair[1].name));
894 assert!(
895 metadata
896 .iter()
897 .all(|rule| !rule.justification.contains("M-"))
898 );
899 assert!(
900 metadata
901 .iter()
902 .find(|rule| rule.name == "rust_mutex_in_async")
903 .is_some_and(|rule| !rule.default_enabled)
904 );
905 }
906
907 #[test]
908 fn duplicate_external_rule_is_rejected_without_partial_registration() {
909 let mut registry =
910 RuleRegistry::with_builtins().expect("built-in rule IDs should be unique");
911 let original_rules = registry.rules().len();
912 let error = registry
913 .extend(RulePack {
914 name: "duplicate-fixture",
915 version: "1.0.0",
916 implementation_fingerprint: "duplicate-fixture:v1",
917 rules: DUPLICATE_RULES,
918 })
919 .expect_err("duplicate ID should be rejected");
920
921 assert_eq!(
922 error.to_string(),
923 "rule pack `duplicate-fixture` registers duplicate rule ID `rust_dbg`"
924 );
925 assert_eq!(registry.rules().len(), original_rules);
926 assert!(registry.packs().is_empty());
927 }
928
929 #[test]
930 fn invalid_pack_identity_is_rejected() {
931 let mut registry = RuleRegistry::new();
932
933 assert!(matches!(
934 registry.extend(fixture_pack("Bad Pack", &[])),
935 Err(RegistryError::InvalidPackName)
936 ));
937 assert!(matches!(
938 registry.extend(fixture_pack("rulewright", &[])),
939 Err(RegistryError::ReservedPackName)
940 ));
941 assert!(registry.rules().is_empty());
942 assert!(registry.packs().is_empty());
943 }
944
945 #[test]
946 fn malformed_rule_metadata_is_rejected_without_partial_registration() {
947 #[rustfmt::skip]
948 let cases = [
949 (INVALID_ID_RULES, "ID must start"),
951 (EMPTY_METADATA_RULES, "must not be empty"),
952 (DUPLICATE_PARAM_RULES, "parameter names must be unique"),
953 (INVALID_PARAM_NAME_RULES, "parameter names must be valid lowercase identifiers"),
954 (NEGATIVE_DEFAULT_RULES, "integer parameter defaults must be non-negative"),
955 (INT_WITH_ARRAY_DEFAULT_RULES, "parameter type does not match"),
956 (ARRAY_WITH_INT_DEFAULT_RULES, "parameter type does not match"),
957 (DUPLICATE_ARRAY_DEFAULT_RULES, "string-array parameter defaults must not contain duplicates"),
958 (EMPTY_EXAMPLE_RULES, "example labels and source"),
959 (INCOMPATIBLE_FIX_RULES, "fix kind does not match"),
960 ];
961
962 for (rules, expected) in cases {
963 let mut registry = RuleRegistry::new();
964 let error = registry
965 .extend(fixture_pack("fixture-pack", rules))
966 .expect_err("invalid rule should be rejected");
967
968 assert!(error.to_string().contains(expected), "{error}");
969 assert!(registry.rules().is_empty());
970 assert!(registry.packs().is_empty());
971 }
972 }
973}