slopguard-core 0.1.16

Core analysis engine for slopguard
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Configuration presets used by `slopguard init`.
//!
//! A preset renders a complete `slopguard.toml`. The rule id lists are derived
//! from the builtin ruleset at render time, so a preset can never name a rule
//! that no longer ships.

use std::collections::HashSet;

use strum::{Display, EnumIter, EnumString, IntoEnumIterator};

use crate::rule::{load_builtin_rules, Category, RuleError, Severity};

/// A named starting configuration for a project.
#[derive(Debug, Display, EnumString, EnumIter, Default, Clone, Copy, PartialEq, Eq)]
#[strum(serialize_all = "lowercase")]
pub enum Preset {
    /// Every ruleset on, opt-in rules off.
    #[default]
    Default,
    /// Every ruleset on plus every opt-in rule.
    Strict,
    /// Security plus correctness errors only.
    Relaxed,
    /// Default rules with the AI confirmation pass switched on.
    Ai,
}

/// `[rulesets]` with every ruleset on. Shared by the `default`, `ai` and
/// `strict` presets.
const RULESETS_ALL_ON: &str = "[rulesets]\nslop = true\nsecurity = true\ncorrectness = true\n";

/// The `[output]` block. Identical in every preset.
const OUTPUT_BLOCK: &str = "[output]\nformat = \"text\"\ncolors = true\n";

/// The `[rules]` and `[scan]` sections of the `default` and `ai` presets:
/// opt-in rules off, nothing disabled, the standard scan comments.
const DEFAULT_RULES_AND_SCAN: &str = r#"[rules]
disable = []
# Opt-in rules (disabled by default): pub-fn-needs-tracing, test-needs-timeout
enable = []
# custom_dirs = ["./my-rules"]

[scan]
ignores = []
# cache_dir = ".slopguard-cache"
"#;

/// The `[rulesets]`, `[rules]`, `[scan]` and `[output]` sections shared by the
/// `default` and `ai` presets.
fn common_body() -> String {
    format!("{RULESETS_ALL_ON}\n{DEFAULT_RULES_AND_SCAN}\n{OUTPUT_BLOCK}")
}

/// The commented-out `[ai]` block shipped by every preset that leaves AI off.
const COMMENTED_AI_BODY: &str = r#"
# [ai]
# enabled = false
# provider = "api"        # "api" (HTTP) | "cli" (local claude)
# vendor = "anthropic"    # "anthropic" | "openai" (for provider = "api")
# model = "claude-haiku-4-5"
# concurrency = 4
# api_key via ANTHROPIC_API_KEY / OPENAI_API_KEY env, or ai.api_key
"#;

/// The live `[ai]` block of the `ai` preset. The model is the same default the
/// AI pipeline uses; `slopguard-core` must not depend on `slopguard-ai`.
const LIVE_AI_BODY: &str = r#"
[ai]
enabled = true
provider = "api"        # "api" (HTTP) | "cli" (local claude)
vendor = "anthropic"    # "anthropic" | "openai"
model = "claude-haiku-4-5"
concurrency = 4
# api_key via ANTHROPIC_API_KEY / OPENAI_API_KEY env, or ai.api_key
"#;

/// The commented-out `[escalation]` block shipped by every preset. Escalation
/// is opt-in, so no preset turns it on.
const COMMENTED_ESCALATION_BODY: &str = r#"
# [escalation]
# enabled = false
# threshold = 5          # same rule firing N times in one file becomes an error
#
# [escalation.rules]
# no-magic-number = 3    # per-rule override
"#;

impl Preset {
    /// One-line summary shown by `slopguard init --preset` and written into
    /// the header of the generated file.
    pub fn description(&self) -> &'static str {
        match self {
            Preset::Default => {
                "All rulesets on, opt-in rules off. Same as running init with no preset."
            }
            Preset::Strict => "Everything on, including every opt-in rule. Warnings fail the scan.",
            Preset::Relaxed => {
                "Security plus correctness errors only. Slop and correctness warnings off."
            }
            Preset::Ai => {
                "Default rules plus the AI confirmation pass (api provider, haiku model)."
            }
        }
    }

    /// Render the preset as the full text of a `slopguard.toml`.
    pub fn render(&self) -> Result<String, RuleError> {
        let mut out = match self {
            Preset::Default => render_default(),
            Preset::Strict => render_strict()?,
            Preset::Relaxed => render_relaxed()?,
            Preset::Ai => render_ai(),
        };
        out.push_str(COMMENTED_ESCALATION_BODY);
        Ok(out)
    }
}

/// The two comment lines that open every generated file.
fn header(preset: Preset) -> String {
    format!(
        "# Generated by: slopguard init --preset {preset}\n# {}\n\n",
        preset.description()
    )
}

/// Render a TOML array of rule ids, one per line, or `name = []` when empty.
fn toml_array(name: &str, ids: &[String]) -> String {
    if ids.is_empty() {
        return format!("{name} = []\n");
    }
    let mut out = format!("{name} = [\n");
    for id in ids {
        out.push_str(&format!("  \"{id}\",\n"));
    }
    out.push_str("]\n");
    out
}

/// Ids of builtin rules that are opt-in (`enabled: false` in their YAML).
/// Sorted and deduped: the same id can exist for both Rust and TypeScript.
fn opt_in_rule_ids() -> Result<Vec<String>, RuleError> {
    let mut ids: Vec<String> = load_builtin_rules()?
        .iter()
        .filter(|r| !r.enabled)
        .map(|r| r.id.to_string())
        .collect();
    ids.sort();
    ids.dedup();
    Ok(ids)
}

/// Ids of correctness rules that are on by default and only ever warnings.
/// An id that is an error in any language variant is kept active, so the
/// relaxed preset never silences a correctness error.
fn correctness_warning_rule_ids() -> Result<Vec<String>, RuleError> {
    let rules = load_builtin_rules()?;
    let error_ids: HashSet<&str> = rules
        .iter()
        .filter(|r| r.severity == Severity::Error)
        .map(|r| r.id.as_str())
        .collect();
    let mut ids: Vec<String> = rules
        .iter()
        .filter(|r| {
            r.enabled
                && r.effective_category() == Category::Correctness
                && r.severity == Severity::Warning
                && !error_ids.contains(r.id.as_str())
        })
        .map(|r| r.id.to_string())
        .collect();
    ids.sort();
    ids.dedup();
    Ok(ids)
}

fn render_default() -> String {
    format!(
        "{}{}{COMMENTED_AI_BODY}",
        header(Preset::Default),
        common_body()
    )
}

fn render_ai() -> String {
    format!("{}{}{LIVE_AI_BODY}", header(Preset::Ai), common_body())
}

fn render_strict() -> Result<String, RuleError> {
    let mut out = header(Preset::Strict);
    out.push_str(RULESETS_ALL_ON);
    out.push_str(
        "\n[rules]\ndisable = []\n# Opt-in rules, all switched on by the strict preset.\n",
    );
    out.push_str(&toml_array("enable", &opt_in_rule_ids()?));
    out.push_str("# custom_dirs = [\"./my-rules\"]\n\n[scan]\nignores = []\n\n");
    out.push_str(OUTPUT_BLOCK);
    out.push_str(
        "\n# Severity threshold is a scan flag, not a config key. Strict keeps the\n\
         # default (warning), so any finding fails the run:\n\
         #   slopguard scan --severity-threshold warning\n",
    );
    out.push_str(COMMENTED_AI_BODY);
    Ok(out)
}

fn render_relaxed() -> Result<String, RuleError> {
    let mut out = header(Preset::Relaxed);
    out.push_str(
        "[rulesets]\nslop = false\nsecurity = true\ncorrectness = true\n\n\
         [rules]\n# Correctness warnings are off: only correctness errors and \
         security rules fire.\n",
    );
    out.push_str(&toml_array("disable", &correctness_warning_rule_ids()?));
    out.push_str("enable = []\n# custom_dirs = [\"./my-rules\"]\n\n[scan]\nignores = []\n\n");
    out.push_str(OUTPUT_BLOCK);
    Ok(out)
}

/// Parse a preset name (the `--preset <name>` value), returning an error
/// message that lists the valid names. Keeps the name list on the core enum
/// so the CLI never maintains its own copy.
pub fn parse_preset(value: &str) -> Result<Preset, String> {
    value.parse().map_err(|_| {
        let names: Vec<String> = Preset::iter().map(|p| p.to_string()).collect();
        format!(
            "unknown preset '{value}' (expected one of: {})",
            names.join(", ")
        )
    })
}

/// The preset listing printed by `slopguard init --preset` with no value.
pub fn presets_help() -> String {
    let name_w = Preset::iter()
        .map(|p| p.to_string().len())
        .max()
        .unwrap_or(7);
    let mut out = String::from("Available presets:\n\n");
    for preset in Preset::iter() {
        let name = preset.to_string();
        out.push_str(&format!("  {name:<name_w$}  {}\n", preset.description()));
    }
    out.push_str("\nUsage: slopguard init --preset <name>\n");
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    use crate::config::{AiTransport, AiVendor, Config};
    use crate::rule::{is_rule_active, Rule};

    fn parse(preset: Preset) -> Config {
        let rendered = preset.render().unwrap();
        toml::from_str(&rendered)
            .unwrap_or_else(|e| panic!("preset {preset} should render valid TOML: {e}\n{rendered}"))
    }

    fn rule_with_id(id: &str) -> Rule {
        load_builtin_rules()
            .unwrap()
            .into_iter()
            .find(|r| r.id.as_str() == id)
            .unwrap_or_else(|| panic!("builtin rule '{id}' should exist"))
    }

    #[test]
    fn every_preset_renders_valid_config() {
        for preset in Preset::iter() {
            let _config = parse(preset);
        }
    }

    #[test]
    fn every_preset_leaves_escalation_off() {
        for preset in Preset::iter() {
            let config = parse(preset);
            assert!(
                !config.escalation.enabled,
                "preset {preset} should leave escalation off"
            );
            assert_eq!(config.escalation.threshold, 5);
        }
    }

    #[test]
    fn every_preset_ends_with_one_escalation_block() {
        for preset in Preset::iter() {
            let rendered = preset.render().unwrap();
            assert!(rendered.ends_with(COMMENTED_ESCALATION_BODY));
            assert_eq!(rendered.matches("# [escalation]\n").count(), 1);
        }
    }

    #[test]
    fn every_preset_has_generated_by_header() {
        for preset in Preset::iter() {
            let rendered = preset.render().unwrap();
            let expected = format!("# Generated by: slopguard init --preset {preset}\n");
            assert!(
                rendered.starts_with(&expected),
                "preset {preset} should start with its header, got:\n{rendered}"
            );
        }
    }

    #[test]
    fn strict_enables_opt_in_rules() {
        let config = parse(Preset::Strict);
        assert!(config.rulesets.slop);
        assert!(config.rulesets.security);
        assert!(config.rulesets.correctness);
        assert!(config.rules.disable.is_empty());
        let enable = &config.rules.enable;
        assert!(enable.iter().any(|id| id == "pub-fn-needs-tracing"));
        assert!(enable.iter().any(|id| id == "test-needs-timeout"));
    }

    #[test]
    fn strict_activates_opt_in_rules() {
        let config = parse(Preset::Strict);
        let rule = rule_with_id("pub-fn-needs-tracing");
        assert!(
            is_rule_active(&rule, &config),
            "strict should activate opt-in rules"
        );
    }

    #[test]
    fn relaxed_disables_slop_and_correctness_warnings() {
        let config = parse(Preset::Relaxed);
        assert!(!config.rulesets.slop);
        assert!(config.rulesets.security);
        assert!(config.rulesets.correctness);
        let disable = &config.rules.disable;
        assert!(disable.iter().any(|id| id == "no-swallowed-error"));
        // Correctness errors and every security rule stay active.
        assert!(!disable.iter().any(|id| id == "no-unwrap-in-prod"));
        assert!(!disable.iter().any(|id| id == "no-hardcoded-secret"));
    }

    #[test]
    fn relaxed_keeps_correctness_errors_active() {
        let config = parse(Preset::Relaxed);
        assert!(!is_rule_active(
            &rule_with_id("no-swallowed-error"),
            &config
        ));
        assert!(is_rule_active(&rule_with_id("no-unwrap-in-prod"), &config));
    }

    #[test]
    fn ai_preset_enables_ai() {
        let config = parse(Preset::Ai);
        assert!(config.ai.enabled);
        assert_eq!(config.ai.provider, AiTransport::Api);
        assert_eq!(config.ai.vendor, AiVendor::Anthropic);
        assert_eq!(config.ai.model.as_deref(), Some("claude-haiku-4-5"));
    }

    #[test]
    fn default_preset_matches_defaults() {
        let config = parse(Preset::Default);
        let defaults = Config::default();
        assert_eq!(config.rulesets, defaults.rulesets);
        assert!(config.rules.enable.is_empty());
        assert!(config.rules.disable.is_empty());
        assert_eq!(config.output, defaults.output);
        assert!(!config.ai.enabled);
    }

    #[test]
    fn no_duplicate_ids_in_rendered_lists() {
        let strict = parse(Preset::Strict).rules.enable;
        let mut deduped = strict.clone();
        deduped.sort();
        deduped.dedup();
        assert_eq!(strict.len(), deduped.len(), "strict enable has duplicates");

        let relaxed = parse(Preset::Relaxed).rules.disable;
        let mut deduped = relaxed.clone();
        deduped.sort();
        deduped.dedup();
        assert_eq!(
            relaxed.len(),
            deduped.len(),
            "relaxed disable has duplicates"
        );
    }

    #[test]
    fn preset_from_str_roundtrip() {
        assert_eq!("strict".parse::<Preset>().unwrap(), Preset::Strict);
        assert_eq!(Preset::Strict.to_string(), "strict");
        assert_eq!(Preset::Default.to_string(), "default");
        assert_eq!(Preset::Relaxed.to_string(), "relaxed");
        assert_eq!(Preset::Ai.to_string(), "ai");
        assert_eq!(Preset::default(), Preset::Default);
    }

    #[test]
    fn presets_help_lists_every_preset() {
        let help = presets_help();
        for preset in Preset::iter() {
            assert!(
                help.contains(&preset.to_string()),
                "help should name {preset}"
            );
            assert!(
                help.contains(preset.description()),
                "help should describe {preset}"
            );
        }
    }
}