Skip to main content

rulewright/
registry.rs

1// #rw(file: rust_inline_test_module_size) registry validation fixtures exercise private preflight paths
2
3use std::collections::{BTreeMap, HashSet};
4
5use crate::{AstCtx, FileCtx, Fix, TomlCtx, Violation, languages::workspace::WorkspaceCtx};
6
7/// A rule entry enriched with resolved configuration for machine-readable reporting.
8#[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/// JSON-compatible resolved configuration value.
23#[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/// The language and analysis context a rule operates on.
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41// #rw(rust_non_exhaustive_on_public) internal enum, all variants matched within this crate
42pub 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/// Unified metadata for a registered rule (line or AST).
70#[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/// Collect all registered rules sorted by name.
84#[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/// A statically linked downstream rule pack.
98#[derive(Clone, Copy, Debug)]
99pub struct RulePack {
100    /// Stable pack identifier used in diagnostics and cache identity.
101    pub name: &'static str,
102    /// Pack version used in cache identity.
103    pub version: &'static str,
104    /// Caller-controlled implementation fingerprint for embedded use.
105    pub implementation_fingerprint: &'static str,
106    /// Rules contributed by this pack.
107    pub rules: &'static [Rule],
108}
109
110/// A deterministic collection of built-in and downstream rules.
111#[derive(Debug, Default)]
112pub struct RuleRegistry {
113    rules: Vec<&'static Rule>,
114    packs: Vec<RulePack>,
115}
116
117impl RuleRegistry {
118    /// Create an empty registry.
119    #[must_use]
120    pub const fn new() -> Self {
121        Self {
122            rules: Vec::new(),
123            packs: Vec::new(),
124        }
125    }
126
127    /// Create a registry containing Rulewright's generic built-in rules.
128    ///
129    /// # Errors
130    ///
131    /// Returns a duplicate-ID error if the compiled built-in inventory is invalid.
132    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    /// Add one statically linked downstream pack.
143    ///
144    /// # Errors
145    ///
146    /// Returns a duplicate-ID error without partially registering the pack.
147    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    /// Return registered rules in stable ID order.
185    #[must_use]
186    pub fn rules(&self) -> &[&'static Rule] {
187        &self.rules
188    }
189
190    /// Return registered downstream packs in stable name order.
191    #[must_use]
192    pub fn packs(&self) -> &[RulePack] {
193        &self.packs
194    }
195
196    /// Return public metadata for every registered rule.
197    #[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/// Failure to construct a deterministic rule registry.
231#[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/// A code example that demonstrates a rule — used in `--detail` output and tests.
262#[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)]
270// #rw(rust_non_exhaustive_on_public) internal enum, all variants matched within this crate
271pub 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)]
287// #rw(rust_non_exhaustive_on_public) internal enum, all variants matched within this crate
288pub 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)]
302// #rw(rust_non_exhaustive_on_public) severity levels are a fixed set
303pub 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/// Shared metadata fields common to both line and AST rules.
327#[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    /// Construct rule metadata for a statically linked rule.
340    #[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    /// Keep the rule available while leaving it disabled in generated configurations.
361    #[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 (&parameter.param_type, &parameter.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/// Language-specific rule check function stored in the unified registry.
492#[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/// Language-specific fix function corresponding to [`RuleCheck`].
527#[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/// One lint rule in the language-neutral registry.
536#[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    /// Construct a Rust line rule.
545    #[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    /// Construct a Rust line rule that sees complete source, including test-only items.
562    ///
563    /// The check must distinguish code from marker lookalikes in strings or comments.
564    /// Prefer [`Self::rust_line`] unless the rule is explicitly opted into within source.
565    #[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    /// Construct a Rust AST rule.
582    #[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    /// Construct a coordinated Rust AST-tree fix rule.
599    #[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    /// Construct a Rust workspace rule.
613    #[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    /// Construct a language-neutral workspace rule.
626    #[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    /// Construct a TOML rule.
636    #[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    /// Return this rule's stable identifier.
653    #[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            // #rw:aligned
950            (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}