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