Skip to main content

lex_config/
rule_config.rs

1//! User-facing severity for a diagnostic rule.
2//!
3//! [`RuleConfig`] is the value type for entries in `[diagnostics.rules]` in
4//! `.lex.toml`. It accepts two shapes on disk: a bare severity string
5//! (`"warn"`) or an array carrying severity plus rule-specific options
6//! (`["warn", { max = 100 }]`). The options table is forwarded as-is to
7//! the rule's emission code; no type-checking happens here.
8
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::fmt;
12
13/// User-facing severity for a diagnostic rule.
14///
15/// Semantics (applied by the registry once that surface lands; this type
16/// currently only carries the configuration value):
17///
18/// - [`Severity::Allow`] is intended to suppress emission entirely.
19/// - [`Severity::Warn`] is intended to emit at the diagnostic's intrinsic
20///   LSP severity.
21/// - [`Severity::Deny`] is intended to emit at LSP `Error` severity
22///   regardless of the intrinsic value, and to be the level CI tooling
23///   treats as fatal.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum Severity {
27    Allow,
28    Warn,
29    Deny,
30}
31
32impl Default for Severity {
33    /// Tests and ad-hoc construction of [`RuleConfig`] default to
34    /// [`Severity::Warn`]. The *real* per-rule intrinsic defaults are
35    /// declared as `#[clapfig(value, default = "...")]` on each
36    /// [`crate::DiagnosticsRulesConfig`] field and applied by clapfig
37    /// during config load.
38    fn default() -> Self {
39        Severity::Warn
40    }
41}
42
43impl Default for RuleConfig {
44    fn default() -> Self {
45        RuleConfig::Bare(Severity::default())
46    }
47}
48
49impl fmt::Display for Severity {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.write_str(match self {
52            Severity::Allow => "allow",
53            Severity::Warn => "warn",
54            Severity::Deny => "deny",
55        })
56    }
57}
58
59/// Free-form options table forwarded to a rule's emission code.
60///
61/// Values are kept as raw `toml::Value` so each rule can deserialize the
62/// keys it cares about without the registry having to know the shape.
63pub type RuleOptions = BTreeMap<String, toml::Value>;
64
65/// One entry in a `[diagnostics.rules]` block.
66///
67/// Two on-disk shapes parse into the same logical record:
68///
69/// - `"missing-footnote" = "warn"` — bare severity, no options.
70/// - `"line-too-long" = ["warn", { max = 100 }]` — severity + options.
71///
72/// The single-line form is the common case; the array form exists so
73/// rules with numeric thresholds (line length, nesting depth) can plug
74/// in without changing the schema. No rule in lex today carries
75/// options.
76///
77/// `Eq` is not derived because [`RuleOptions`] embeds `toml::Value`,
78/// which contains `Float` and therefore is not `Eq`.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80#[serde(untagged)]
81pub enum RuleConfig {
82    /// Bare severity, no options.
83    Bare(Severity),
84    /// Severity plus a free-form options table.
85    WithOptions(Severity, RuleOptions),
86}
87
88impl RuleConfig {
89    /// The configured severity, regardless of which form was used.
90    pub fn severity(&self) -> Severity {
91        match self {
92            RuleConfig::Bare(s) | RuleConfig::WithOptions(s, _) => *s,
93        }
94    }
95
96    /// Rule-specific options, or `None` if the bare form was used.
97    pub fn options(&self) -> Option<&RuleOptions> {
98        match self {
99            RuleConfig::Bare(_) => None,
100            RuleConfig::WithOptions(_, opts) => Some(opts),
101        }
102    }
103}
104
105impl From<Severity> for RuleConfig {
106    fn from(s: Severity) -> Self {
107        RuleConfig::Bare(s)
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[derive(Debug, Deserialize, Serialize)]
116    struct Wrap {
117        rule: RuleConfig,
118    }
119
120    fn parse(toml: &str) -> RuleConfig {
121        toml::from_str::<Wrap>(toml).expect("parse").rule
122    }
123
124    #[test]
125    fn bare_string_warn() {
126        let r = parse(r#"rule = "warn""#);
127        assert_eq!(r.severity(), Severity::Warn);
128        assert!(r.options().is_none());
129    }
130
131    #[test]
132    fn bare_string_allow() {
133        let r = parse(r#"rule = "allow""#);
134        assert_eq!(r.severity(), Severity::Allow);
135        assert!(r.options().is_none());
136    }
137
138    #[test]
139    fn bare_string_deny() {
140        let r = parse(r#"rule = "deny""#);
141        assert_eq!(r.severity(), Severity::Deny);
142        assert!(r.options().is_none());
143    }
144
145    #[test]
146    fn array_form_with_options() {
147        let r = parse(r#"rule = ["warn", { max = 100 }]"#);
148        assert_eq!(r.severity(), Severity::Warn);
149        let opts = r.options().expect("options present");
150        assert_eq!(opts.get("max"), Some(&toml::Value::Integer(100)));
151    }
152
153    #[test]
154    fn array_form_multiple_options() {
155        let r = parse(r#"rule = ["deny", { max = 80, indent = "tabs" }]"#);
156        assert_eq!(r.severity(), Severity::Deny);
157        let opts = r.options().unwrap();
158        assert_eq!(opts.get("max"), Some(&toml::Value::Integer(80)));
159        assert_eq!(
160            opts.get("indent"),
161            Some(&toml::Value::String("tabs".into()))
162        );
163    }
164
165    #[test]
166    fn array_form_empty_options() {
167        let r = parse(r#"rule = ["warn", {}]"#);
168        assert_eq!(r.severity(), Severity::Warn);
169        assert!(r.options().unwrap().is_empty());
170    }
171
172    #[test]
173    fn rejects_invalid_severity_string() {
174        // Behaviour we own: an unrecognised severity must not deserialize.
175        // Exact wording is owned by serde/toml and not asserted.
176        assert!(toml::from_str::<Wrap>(r#"rule = "error""#).is_err());
177    }
178
179    #[test]
180    fn rejects_invalid_array_severity() {
181        assert!(toml::from_str::<Wrap>(r#"rule = ["error", {}]"#).is_err());
182    }
183
184    #[test]
185    fn round_trip_bare() {
186        let r = parse(r#"rule = "warn""#);
187        let s = toml::to_string(&Wrap { rule: r.clone() }).unwrap();
188        let back = toml::from_str::<Wrap>(&s).unwrap().rule;
189        assert_eq!(back, r);
190    }
191
192    #[test]
193    fn round_trip_with_options() {
194        // The array form is part of the on-disk contract — round-trip
195        // it through serialization to catch regressions in the
196        // tuple-variant emit shape.
197        let r = parse(r#"rule = ["warn", { max = 100, indent = "tabs" }]"#);
198        let s = toml::to_string(&Wrap { rule: r.clone() }).unwrap();
199        let back = toml::from_str::<Wrap>(&s).unwrap().rule;
200        assert_eq!(back, r);
201        assert_eq!(back.severity(), Severity::Warn);
202        let opts = back.options().expect("options preserved");
203        assert_eq!(opts.get("max"), Some(&toml::Value::Integer(100)));
204        assert_eq!(
205            opts.get("indent"),
206            Some(&toml::Value::String("tabs".into()))
207        );
208    }
209
210    #[test]
211    fn severity_display() {
212        assert_eq!(Severity::Allow.to_string(), "allow");
213        assert_eq!(Severity::Warn.to_string(), "warn");
214        assert_eq!(Severity::Deny.to_string(), "deny");
215    }
216
217    #[test]
218    fn severity_into_rule_config() {
219        let r: RuleConfig = Severity::Warn.into();
220        assert_eq!(r.severity(), Severity::Warn);
221        assert!(r.options().is_none());
222    }
223}