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