1use std::collections::HashSet;
8
9use strum::{Display, EnumIter, EnumString, IntoEnumIterator};
10
11use crate::rule::{load_builtin_rules, Category, RuleError, Severity};
12
13#[derive(Debug, Display, EnumString, EnumIter, Default, Clone, Copy, PartialEq, Eq)]
15#[strum(serialize_all = "lowercase")]
16pub enum Preset {
17 #[default]
19 Default,
20 Strict,
22 Relaxed,
24 Ai,
26}
27
28const RULESETS_ALL_ON: &str = "[rulesets]\nslop = true\nsecurity = true\ncorrectness = true\n";
31
32const OUTPUT_BLOCK: &str = "[output]\nformat = \"text\"\ncolors = true\n";
34
35const 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
48fn common_body() -> String {
51 format!("{RULESETS_ALL_ON}\n{DEFAULT_RULES_AND_SCAN}\n{OUTPUT_BLOCK}")
52}
53
54const 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
65const LIVE_AI_BODY: &str = r#"
68[ai]
69enabled = true
70provider = "api" # "api" (HTTP) | "cli" (local claude)
71vendor = "anthropic" # "anthropic" | "openai"
72model = "claude-haiku-4-5"
73concurrency = 4
74# api_key via ANTHROPIC_API_KEY / OPENAI_API_KEY env, or ai.api_key
75"#;
76
77const COMMENTED_ESCALATION_BODY: &str = r#"
80# [escalation]
81# enabled = false
82# threshold = 5 # same rule firing N times in one file becomes an error
83#
84# [escalation.rules]
85# no-magic-number = 3 # per-rule override
86"#;
87
88impl Preset {
89 pub fn description(&self) -> &'static str {
92 match self {
93 Preset::Default => {
94 "All rulesets on, opt-in rules off. Same as running init with no preset."
95 }
96 Preset::Strict => "Everything on, including every opt-in rule. Warnings fail the scan.",
97 Preset::Relaxed => {
98 "Security plus correctness errors only. Slop and correctness warnings off."
99 }
100 Preset::Ai => {
101 "Default rules plus the AI confirmation pass (api provider, haiku model)."
102 }
103 }
104 }
105
106 pub fn render(&self) -> Result<String, RuleError> {
108 let mut out = match self {
109 Preset::Default => render_default(),
110 Preset::Strict => render_strict()?,
111 Preset::Relaxed => render_relaxed()?,
112 Preset::Ai => render_ai(),
113 };
114 out.push_str(COMMENTED_ESCALATION_BODY);
115 Ok(out)
116 }
117}
118
119fn header(preset: Preset) -> String {
121 format!(
122 "# Generated by: slopguard init --preset {preset}\n# {}\n\n",
123 preset.description()
124 )
125}
126
127fn toml_array(name: &str, ids: &[String]) -> String {
129 if ids.is_empty() {
130 return format!("{name} = []\n");
131 }
132 let mut out = format!("{name} = [\n");
133 for id in ids {
134 out.push_str(&format!(" \"{id}\",\n"));
135 }
136 out.push_str("]\n");
137 out
138}
139
140fn opt_in_rule_ids() -> Result<Vec<String>, RuleError> {
143 let mut ids: Vec<String> = load_builtin_rules()?
144 .iter()
145 .filter(|r| !r.enabled)
146 .map(|r| r.id.to_string())
147 .collect();
148 ids.sort();
149 ids.dedup();
150 Ok(ids)
151}
152
153fn correctness_warning_rule_ids() -> Result<Vec<String>, RuleError> {
157 let rules = load_builtin_rules()?;
158 let error_ids: HashSet<&str> = rules
159 .iter()
160 .filter(|r| r.severity == Severity::Error)
161 .map(|r| r.id.as_str())
162 .collect();
163 let mut ids: Vec<String> = rules
164 .iter()
165 .filter(|r| {
166 r.enabled
167 && r.effective_category() == Category::Correctness
168 && r.severity == Severity::Warning
169 && !error_ids.contains(r.id.as_str())
170 })
171 .map(|r| r.id.to_string())
172 .collect();
173 ids.sort();
174 ids.dedup();
175 Ok(ids)
176}
177
178fn render_default() -> String {
179 format!(
180 "{}{}{COMMENTED_AI_BODY}",
181 header(Preset::Default),
182 common_body()
183 )
184}
185
186fn render_ai() -> String {
187 format!("{}{}{LIVE_AI_BODY}", header(Preset::Ai), common_body())
188}
189
190fn render_strict() -> Result<String, RuleError> {
191 let mut out = header(Preset::Strict);
192 out.push_str(RULESETS_ALL_ON);
193 out.push_str(
194 "\n[rules]\ndisable = []\n# Opt-in rules, all switched on by the strict preset.\n",
195 );
196 out.push_str(&toml_array("enable", &opt_in_rule_ids()?));
197 out.push_str("# custom_dirs = [\"./my-rules\"]\n\n[scan]\nignores = []\n\n");
198 out.push_str(OUTPUT_BLOCK);
199 out.push_str(
200 "\n# Severity threshold is a scan flag, not a config key. Strict keeps the\n\
201 # default (warning), so any finding fails the run:\n\
202 # slopguard scan --severity-threshold warning\n",
203 );
204 out.push_str(COMMENTED_AI_BODY);
205 Ok(out)
206}
207
208fn render_relaxed() -> Result<String, RuleError> {
209 let mut out = header(Preset::Relaxed);
210 out.push_str(
211 "[rulesets]\nslop = false\nsecurity = true\ncorrectness = true\n\n\
212 [rules]\n# Correctness warnings are off: only correctness errors and \
213 security rules fire.\n",
214 );
215 out.push_str(&toml_array("disable", &correctness_warning_rule_ids()?));
216 out.push_str("enable = []\n# custom_dirs = [\"./my-rules\"]\n\n[scan]\nignores = []\n\n");
217 out.push_str(OUTPUT_BLOCK);
218 Ok(out)
219}
220
221pub fn parse_preset(value: &str) -> Result<Preset, String> {
225 value.parse().map_err(|_| {
226 let names: Vec<String> = Preset::iter().map(|p| p.to_string()).collect();
227 format!(
228 "unknown preset '{value}' (expected one of: {})",
229 names.join(", ")
230 )
231 })
232}
233
234pub fn presets_help() -> String {
236 let name_w = Preset::iter()
237 .map(|p| p.to_string().len())
238 .max()
239 .unwrap_or(7);
240 let mut out = String::from("Available presets:\n\n");
241 for preset in Preset::iter() {
242 let name = preset.to_string();
243 out.push_str(&format!(" {name:<name_w$} {}\n", preset.description()));
244 }
245 out.push_str("\nUsage: slopguard init --preset <name>\n");
246 out
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 use crate::config::{AiTransport, AiVendor, Config};
254 use crate::rule::{is_rule_active, Rule};
255
256 fn parse(preset: Preset) -> Config {
257 let rendered = preset.render().unwrap();
258 toml::from_str(&rendered)
259 .unwrap_or_else(|e| panic!("preset {preset} should render valid TOML: {e}\n{rendered}"))
260 }
261
262 fn rule_with_id(id: &str) -> Rule {
263 load_builtin_rules()
264 .unwrap()
265 .into_iter()
266 .find(|r| r.id.as_str() == id)
267 .unwrap_or_else(|| panic!("builtin rule '{id}' should exist"))
268 }
269
270 #[test]
271 fn every_preset_renders_valid_config() {
272 for preset in Preset::iter() {
273 let _config = parse(preset);
274 }
275 }
276
277 #[test]
278 fn every_preset_leaves_escalation_off() {
279 for preset in Preset::iter() {
280 let config = parse(preset);
281 assert!(
282 !config.escalation.enabled,
283 "preset {preset} should leave escalation off"
284 );
285 assert_eq!(config.escalation.threshold, 5);
286 }
287 }
288
289 #[test]
290 fn every_preset_ends_with_one_escalation_block() {
291 for preset in Preset::iter() {
292 let rendered = preset.render().unwrap();
293 assert!(rendered.ends_with(COMMENTED_ESCALATION_BODY));
294 assert_eq!(rendered.matches("# [escalation]\n").count(), 1);
295 }
296 }
297
298 #[test]
299 fn every_preset_has_generated_by_header() {
300 for preset in Preset::iter() {
301 let rendered = preset.render().unwrap();
302 let expected = format!("# Generated by: slopguard init --preset {preset}\n");
303 assert!(
304 rendered.starts_with(&expected),
305 "preset {preset} should start with its header, got:\n{rendered}"
306 );
307 }
308 }
309
310 #[test]
311 fn strict_enables_opt_in_rules() {
312 let config = parse(Preset::Strict);
313 assert!(config.rulesets.slop);
314 assert!(config.rulesets.security);
315 assert!(config.rulesets.correctness);
316 assert!(config.rules.disable.is_empty());
317 let enable = &config.rules.enable;
318 assert!(enable.iter().any(|id| id == "pub-fn-needs-tracing"));
319 assert!(enable.iter().any(|id| id == "test-needs-timeout"));
320 }
321
322 #[test]
323 fn strict_activates_opt_in_rules() {
324 let config = parse(Preset::Strict);
325 let rule = rule_with_id("pub-fn-needs-tracing");
326 assert!(
327 is_rule_active(&rule, &config),
328 "strict should activate opt-in rules"
329 );
330 }
331
332 #[test]
333 fn relaxed_disables_slop_and_correctness_warnings() {
334 let config = parse(Preset::Relaxed);
335 assert!(!config.rulesets.slop);
336 assert!(config.rulesets.security);
337 assert!(config.rulesets.correctness);
338 let disable = &config.rules.disable;
339 assert!(disable.iter().any(|id| id == "no-swallowed-error"));
340 assert!(!disable.iter().any(|id| id == "no-unwrap-in-prod"));
342 assert!(!disable.iter().any(|id| id == "no-hardcoded-secret"));
343 }
344
345 #[test]
346 fn relaxed_keeps_correctness_errors_active() {
347 let config = parse(Preset::Relaxed);
348 assert!(!is_rule_active(
349 &rule_with_id("no-swallowed-error"),
350 &config
351 ));
352 assert!(is_rule_active(&rule_with_id("no-unwrap-in-prod"), &config));
353 }
354
355 #[test]
356 fn ai_preset_enables_ai() {
357 let config = parse(Preset::Ai);
358 assert!(config.ai.enabled);
359 assert_eq!(config.ai.provider, AiTransport::Api);
360 assert_eq!(config.ai.vendor, AiVendor::Anthropic);
361 assert_eq!(config.ai.model.as_deref(), Some("claude-haiku-4-5"));
362 }
363
364 #[test]
365 fn default_preset_matches_defaults() {
366 let config = parse(Preset::Default);
367 let defaults = Config::default();
368 assert_eq!(config.rulesets, defaults.rulesets);
369 assert!(config.rules.enable.is_empty());
370 assert!(config.rules.disable.is_empty());
371 assert_eq!(config.output, defaults.output);
372 assert!(!config.ai.enabled);
373 }
374
375 #[test]
376 fn no_duplicate_ids_in_rendered_lists() {
377 let strict = parse(Preset::Strict).rules.enable;
378 let mut deduped = strict.clone();
379 deduped.sort();
380 deduped.dedup();
381 assert_eq!(strict.len(), deduped.len(), "strict enable has duplicates");
382
383 let relaxed = parse(Preset::Relaxed).rules.disable;
384 let mut deduped = relaxed.clone();
385 deduped.sort();
386 deduped.dedup();
387 assert_eq!(
388 relaxed.len(),
389 deduped.len(),
390 "relaxed disable has duplicates"
391 );
392 }
393
394 #[test]
395 fn preset_from_str_roundtrip() {
396 assert_eq!("strict".parse::<Preset>().unwrap(), Preset::Strict);
397 assert_eq!(Preset::Strict.to_string(), "strict");
398 assert_eq!(Preset::Default.to_string(), "default");
399 assert_eq!(Preset::Relaxed.to_string(), "relaxed");
400 assert_eq!(Preset::Ai.to_string(), "ai");
401 assert_eq!(Preset::default(), Preset::Default);
402 }
403
404 #[test]
405 fn presets_help_lists_every_preset() {
406 let help = presets_help();
407 for preset in Preset::iter() {
408 assert!(
409 help.contains(&preset.to_string()),
410 "help should name {preset}"
411 );
412 assert!(
413 help.contains(preset.description()),
414 "help should describe {preset}"
415 );
416 }
417 }
418}