Skip to main content

slopguard_core/
preset.rs

1//! Configuration presets used by `slopguard init`.
2//!
3//! A preset renders a complete `slopguard.toml`. The rule id lists are derived
4//! from the builtin ruleset at render time, so a preset can never name a rule
5//! that no longer ships.
6
7use std::collections::HashSet;
8
9use strum::{Display, EnumIter, EnumString, IntoEnumIterator};
10
11use crate::rule::{load_builtin_rules, Category, RuleError, Severity};
12
13/// A named starting configuration for a project.
14#[derive(Debug, Display, EnumString, EnumIter, Default, Clone, Copy, PartialEq, Eq)]
15#[strum(serialize_all = "lowercase")]
16pub enum Preset {
17    /// Every ruleset on, opt-in rules off.
18    #[default]
19    Default,
20    /// Every ruleset on plus every opt-in rule.
21    Strict,
22    /// Security plus correctness errors only.
23    Relaxed,
24    /// Default rules with the AI confirmation pass switched on.
25    Ai,
26}
27
28/// `[rulesets]` with every ruleset on. Shared by the `default`, `ai` and
29/// `strict` presets.
30const RULESETS_ALL_ON: &str = "[rulesets]\nslop = true\nsecurity = true\ncorrectness = true\n";
31
32/// The `[output]` block. Identical in every preset.
33const OUTPUT_BLOCK: &str = "[output]\nformat = \"text\"\ncolors = true\n";
34
35/// The `[rules]` and `[scan]` sections of the `default` and `ai` presets:
36/// opt-in rules off, nothing disabled, the standard scan comments.
37const DEFAULT_RULES_AND_SCAN: &str = r#"[rules]
38disable = []
39# Opt-in rules (disabled by default): pub-fn-needs-tracing, test-needs-timeout
40enable = []
41# custom_dirs = ["./my-rules"]
42
43[scan]
44ignores = []
45# cache_dir = ".slopguard-cache"
46"#;
47
48/// The `[rulesets]`, `[rules]`, `[scan]` and `[output]` sections shared by the
49/// `default` and `ai` presets.
50fn common_body() -> String {
51    format!("{RULESETS_ALL_ON}\n{DEFAULT_RULES_AND_SCAN}\n{OUTPUT_BLOCK}")
52}
53
54/// The commented-out `[ai]` block shipped by every preset that leaves AI off.
55const COMMENTED_AI_BODY: &str = r#"
56# [ai]
57# enabled = false
58# provider = "api"        # "api" (HTTP) | "cli" (local claude)
59# vendor = "anthropic"    # "anthropic" | "openai" (for provider = "api")
60# model = "claude-haiku-4-5"
61# concurrency = 4
62# api_key via ANTHROPIC_API_KEY / OPENAI_API_KEY env, or ai.api_key
63#
64# [ai.classifier]         # System One (Jev) pass, on a separate axis from the LLM
65# enabled = false
66# transport = "direct"    # "direct" (TYPESAFE_API_KEY) | "openrouter" (OPENROUTER_API_KEY)
67# threshold = 0.7
68# batch = true            # group overlapping candidates into one request
69# batch_max_questions = 8
70# batch_max_state_lines = 200
71"#;
72
73/// The live `[ai]` block of the `ai` preset. The model is the same default the
74/// AI pipeline uses; `slopguard-core` must not depend on `slopguard-ai`.
75const LIVE_AI_BODY: &str = r#"
76[ai]
77enabled = true
78provider = "api"        # "api" (HTTP) | "cli" (local claude)
79vendor = "anthropic"    # "anthropic" | "openai"
80model = "claude-haiku-4-5"
81concurrency = 4
82# api_key via ANTHROPIC_API_KEY / OPENAI_API_KEY env, or ai.api_key
83#
84# [ai.classifier]         # System One (Jev) pass, on a separate axis from the LLM
85# enabled = false
86# transport = "direct"    # "direct" (TYPESAFE_API_KEY) | "openrouter" (OPENROUTER_API_KEY)
87# threshold = 0.7
88# batch = true            # group overlapping candidates into one request
89# batch_max_questions = 8
90# batch_max_state_lines = 200
91"#;
92
93/// The commented-out `[escalation]` block shipped by every preset. Escalation
94/// is opt-in, so no preset turns it on.
95const COMMENTED_ESCALATION_BODY: &str = r#"
96# [escalation]
97# enabled = false
98# threshold = 5          # same rule firing N times in one file becomes an error
99#
100# [escalation.rules]
101# no-magic-number = 3    # per-rule override
102"#;
103
104impl Preset {
105    /// One-line summary shown by `slopguard init --preset` and written into
106    /// the header of the generated file.
107    pub fn description(&self) -> &'static str {
108        match self {
109            Preset::Default => {
110                "All rulesets on, opt-in rules off. Same as running init with no preset."
111            }
112            Preset::Strict => "Everything on, including every opt-in rule. Warnings fail the scan.",
113            Preset::Relaxed => {
114                "Security plus correctness errors only. Slop and correctness warnings off."
115            }
116            Preset::Ai => {
117                "Default rules plus the AI confirmation pass (api provider, haiku model)."
118            }
119        }
120    }
121
122    /// Render the preset as the full text of a `slopguard.toml`.
123    pub fn render(&self) -> Result<String, RuleError> {
124        let mut out = match self {
125            Preset::Default => render_default(),
126            Preset::Strict => render_strict()?,
127            Preset::Relaxed => render_relaxed()?,
128            Preset::Ai => render_ai(),
129        };
130        out.push_str(COMMENTED_ESCALATION_BODY);
131        Ok(out)
132    }
133}
134
135/// The two comment lines that open every generated file.
136fn header(preset: Preset) -> String {
137    format!(
138        "# Generated by: slopguard init --preset {preset}\n# {}\n\n",
139        preset.description()
140    )
141}
142
143/// Render a TOML array of rule ids, one per line, or `name = []` when empty.
144fn toml_array(name: &str, ids: &[String]) -> String {
145    if ids.is_empty() {
146        return format!("{name} = []\n");
147    }
148    let mut out = format!("{name} = [\n");
149    for id in ids {
150        out.push_str(&format!("  \"{id}\",\n"));
151    }
152    out.push_str("]\n");
153    out
154}
155
156/// Ids of builtin rules that are opt-in (`enabled: false` in their YAML).
157/// Sorted and deduped: the same id can exist for both Rust and TypeScript.
158fn opt_in_rule_ids() -> Result<Vec<String>, RuleError> {
159    let mut ids: Vec<String> = load_builtin_rules()?
160        .iter()
161        .filter(|r| !r.enabled)
162        .map(|r| r.id.to_string())
163        .collect();
164    ids.sort();
165    ids.dedup();
166    Ok(ids)
167}
168
169/// Ids of correctness rules that are on by default and only ever warnings.
170/// An id that is an error in any language variant is kept active, so the
171/// relaxed preset never silences a correctness error.
172fn correctness_warning_rule_ids() -> Result<Vec<String>, RuleError> {
173    let rules = load_builtin_rules()?;
174    let error_ids: HashSet<&str> = rules
175        .iter()
176        .filter(|r| r.severity == Severity::Error)
177        .map(|r| r.id.as_str())
178        .collect();
179    let mut ids: Vec<String> = rules
180        .iter()
181        .filter(|r| {
182            r.enabled
183                && r.effective_category() == Category::Correctness
184                && r.severity == Severity::Warning
185                && !error_ids.contains(r.id.as_str())
186        })
187        .map(|r| r.id.to_string())
188        .collect();
189    ids.sort();
190    ids.dedup();
191    Ok(ids)
192}
193
194fn render_default() -> String {
195    format!(
196        "{}{}{COMMENTED_AI_BODY}",
197        header(Preset::Default),
198        common_body()
199    )
200}
201
202fn render_ai() -> String {
203    format!("{}{}{LIVE_AI_BODY}", header(Preset::Ai), common_body())
204}
205
206fn render_strict() -> Result<String, RuleError> {
207    let mut out = header(Preset::Strict);
208    out.push_str(RULESETS_ALL_ON);
209    out.push_str(
210        "\n[rules]\ndisable = []\n# Opt-in rules, all switched on by the strict preset.\n",
211    );
212    out.push_str(&toml_array("enable", &opt_in_rule_ids()?));
213    out.push_str("# custom_dirs = [\"./my-rules\"]\n\n[scan]\nignores = []\n\n");
214    out.push_str(OUTPUT_BLOCK);
215    out.push_str(
216        "\n# Severity threshold is a scan flag, not a config key. Strict keeps the\n\
217         # default (warning), so any finding fails the run:\n\
218         #   slopguard scan --severity-threshold warning\n",
219    );
220    out.push_str(COMMENTED_AI_BODY);
221    Ok(out)
222}
223
224fn render_relaxed() -> Result<String, RuleError> {
225    let mut out = header(Preset::Relaxed);
226    out.push_str(
227        "[rulesets]\nslop = false\nsecurity = true\ncorrectness = true\n\n\
228         [rules]\n# Correctness warnings are off: only correctness errors and \
229         security rules fire.\n",
230    );
231    out.push_str(&toml_array("disable", &correctness_warning_rule_ids()?));
232    out.push_str("enable = []\n# custom_dirs = [\"./my-rules\"]\n\n[scan]\nignores = []\n\n");
233    out.push_str(OUTPUT_BLOCK);
234    Ok(out)
235}
236
237/// Parse a preset name (the `--preset <name>` value), returning an error
238/// message that lists the valid names. Keeps the name list on the core enum
239/// so the CLI never maintains its own copy.
240pub fn parse_preset(value: &str) -> Result<Preset, String> {
241    // strum's VariantNotFound carries no context; the message lists valid presets.
242    // slopguard-disable-next-line no-swallowed-error
243    value.parse().map_err(|_| {
244        let names: Vec<String> = Preset::iter().map(|p| p.to_string()).collect();
245        format!(
246            "unknown preset '{value}' (expected one of: {})",
247            names.join(", ")
248        )
249    })
250}
251
252/// The preset listing printed by `slopguard init --preset` with no value.
253pub fn presets_help() -> String {
254    let name_w = Preset::iter()
255        .map(|p| p.to_string().len())
256        .max()
257        .unwrap_or(7);
258    let mut out = String::from("Available presets:\n\n");
259    for preset in Preset::iter() {
260        let name = preset.to_string();
261        out.push_str(&format!("  {name:<name_w$}  {}\n", preset.description()));
262    }
263    out.push_str("\nUsage: slopguard init --preset <name>\n");
264    out
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    use crate::config::{AiTransport, AiVendor, Config};
272    use crate::rule::{is_rule_active, Rule};
273
274    fn parse(preset: Preset) -> Config {
275        let rendered = preset.render().unwrap();
276        toml::from_str(&rendered)
277            .unwrap_or_else(|e| panic!("preset {preset} should render valid TOML: {e}\n{rendered}"))
278    }
279
280    fn rule_with_id(id: &str) -> Rule {
281        load_builtin_rules()
282            .unwrap()
283            .into_iter()
284            .find(|r| r.id.as_str() == id)
285            .unwrap_or_else(|| panic!("builtin rule '{id}' should exist"))
286    }
287
288    #[test]
289    fn every_preset_renders_valid_config() {
290        for preset in Preset::iter() {
291            let _config = parse(preset);
292        }
293    }
294
295    #[test]
296    fn every_preset_leaves_escalation_off() {
297        for preset in Preset::iter() {
298            let config = parse(preset);
299            assert!(
300                !config.escalation.enabled,
301                "preset {preset} should leave escalation off"
302            );
303            assert_eq!(config.escalation.threshold, 5);
304        }
305    }
306
307    #[test]
308    fn every_preset_ends_with_one_escalation_block() {
309        for preset in Preset::iter() {
310            let rendered = preset.render().unwrap();
311            assert!(rendered.ends_with(COMMENTED_ESCALATION_BODY));
312            assert_eq!(rendered.matches("# [escalation]\n").count(), 1);
313        }
314    }
315
316    #[test]
317    fn every_preset_has_generated_by_header() {
318        for preset in Preset::iter() {
319            let rendered = preset.render().unwrap();
320            let expected = format!("# Generated by: slopguard init --preset {preset}\n");
321            assert!(
322                rendered.starts_with(&expected),
323                "preset {preset} should start with its header, got:\n{rendered}"
324            );
325        }
326    }
327
328    #[test]
329    fn strict_enables_opt_in_rules() {
330        let config = parse(Preset::Strict);
331        assert!(config.rulesets.slop);
332        assert!(config.rulesets.security);
333        assert!(config.rulesets.correctness);
334        assert!(config.rules.disable.is_empty());
335        let enable = &config.rules.enable;
336        assert!(enable.iter().any(|id| id == "pub-fn-needs-tracing"));
337        assert!(enable.iter().any(|id| id == "test-needs-timeout"));
338    }
339
340    #[test]
341    fn strict_activates_opt_in_rules() {
342        let config = parse(Preset::Strict);
343        let rule = rule_with_id("pub-fn-needs-tracing");
344        assert!(
345            is_rule_active(&rule, &config),
346            "strict should activate opt-in rules"
347        );
348    }
349
350    #[test]
351    fn relaxed_disables_slop_and_correctness_warnings() {
352        let config = parse(Preset::Relaxed);
353        assert!(!config.rulesets.slop);
354        assert!(config.rulesets.security);
355        assert!(config.rulesets.correctness);
356        let disable = &config.rules.disable;
357        assert!(disable.iter().any(|id| id == "no-swallowed-error"));
358        // Correctness errors and every security rule stay active.
359        assert!(!disable.iter().any(|id| id == "no-unwrap-in-prod"));
360        assert!(!disable.iter().any(|id| id == "no-hardcoded-secret"));
361    }
362
363    #[test]
364    fn relaxed_keeps_correctness_errors_active() {
365        let config = parse(Preset::Relaxed);
366        assert!(!is_rule_active(
367            &rule_with_id("no-swallowed-error"),
368            &config
369        ));
370        assert!(is_rule_active(&rule_with_id("no-unwrap-in-prod"), &config));
371    }
372
373    #[test]
374    fn ai_preset_enables_ai() {
375        let config = parse(Preset::Ai);
376        assert!(config.ai.enabled);
377        assert_eq!(config.ai.provider, AiTransport::Api);
378        assert_eq!(config.ai.vendor, AiVendor::Anthropic);
379        assert_eq!(config.ai.model.as_deref(), Some("claude-haiku-4-5"));
380    }
381
382    #[test]
383    fn default_preset_matches_defaults() {
384        let config = parse(Preset::Default);
385        let defaults = Config::default();
386        assert_eq!(config.rulesets, defaults.rulesets);
387        assert!(config.rules.enable.is_empty());
388        assert!(config.rules.disable.is_empty());
389        assert_eq!(config.output, defaults.output);
390        assert!(!config.ai.enabled);
391    }
392
393    #[test]
394    fn no_duplicate_ids_in_rendered_lists() {
395        let strict = parse(Preset::Strict).rules.enable;
396        let mut deduped = strict.clone();
397        deduped.sort();
398        deduped.dedup();
399        assert_eq!(strict.len(), deduped.len(), "strict enable has duplicates");
400
401        let relaxed = parse(Preset::Relaxed).rules.disable;
402        let mut deduped = relaxed.clone();
403        deduped.sort();
404        deduped.dedup();
405        assert_eq!(
406            relaxed.len(),
407            deduped.len(),
408            "relaxed disable has duplicates"
409        );
410    }
411
412    #[test]
413    fn preset_from_str_roundtrip() {
414        assert_eq!("strict".parse::<Preset>().unwrap(), Preset::Strict);
415        assert_eq!(Preset::Strict.to_string(), "strict");
416        assert_eq!(Preset::Default.to_string(), "default");
417        assert_eq!(Preset::Relaxed.to_string(), "relaxed");
418        assert_eq!(Preset::Ai.to_string(), "ai");
419        assert_eq!(Preset::default(), Preset::Default);
420    }
421
422    #[test]
423    fn presets_help_lists_every_preset() {
424        let help = presets_help();
425        for preset in Preset::iter() {
426            assert!(
427                help.contains(&preset.to_string()),
428                "help should name {preset}"
429            );
430            assert!(
431                help.contains(preset.description()),
432                "help should describe {preset}"
433            );
434        }
435    }
436}