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
77impl Preset {
78 pub fn description(&self) -> &'static str {
81 match self {
82 Preset::Default => {
83 "All rulesets on, opt-in rules off. Same as running init with no preset."
84 }
85 Preset::Strict => "Everything on, including every opt-in rule. Warnings fail the scan.",
86 Preset::Relaxed => {
87 "Security plus correctness errors only. Slop and correctness warnings off."
88 }
89 Preset::Ai => {
90 "Default rules plus the AI confirmation pass (api provider, haiku model)."
91 }
92 }
93 }
94
95 pub fn render(&self) -> Result<String, RuleError> {
97 match self {
98 Preset::Default => Ok(render_default()),
99 Preset::Strict => render_strict(),
100 Preset::Relaxed => render_relaxed(),
101 Preset::Ai => Ok(render_ai()),
102 }
103 }
104}
105
106fn header(preset: Preset) -> String {
108 format!(
109 "# Generated by: slopguard init --preset {preset}\n# {}\n\n",
110 preset.description()
111 )
112}
113
114fn toml_array(name: &str, ids: &[String]) -> String {
116 if ids.is_empty() {
117 return format!("{name} = []\n");
118 }
119 let mut out = format!("{name} = [\n");
120 for id in ids {
121 out.push_str(&format!(" \"{id}\",\n"));
122 }
123 out.push_str("]\n");
124 out
125}
126
127fn opt_in_rule_ids() -> Result<Vec<String>, RuleError> {
130 let mut ids: Vec<String> = load_builtin_rules()?
131 .iter()
132 .filter(|r| !r.enabled)
133 .map(|r| r.id.to_string())
134 .collect();
135 ids.sort();
136 ids.dedup();
137 Ok(ids)
138}
139
140fn correctness_warning_rule_ids() -> Result<Vec<String>, RuleError> {
144 let rules = load_builtin_rules()?;
145 let error_ids: HashSet<&str> = rules
146 .iter()
147 .filter(|r| r.severity == Severity::Error)
148 .map(|r| r.id.as_str())
149 .collect();
150 let mut ids: Vec<String> = rules
151 .iter()
152 .filter(|r| {
153 r.enabled
154 && r.effective_category() == Category::Correctness
155 && r.severity == Severity::Warning
156 && !error_ids.contains(r.id.as_str())
157 })
158 .map(|r| r.id.to_string())
159 .collect();
160 ids.sort();
161 ids.dedup();
162 Ok(ids)
163}
164
165fn render_default() -> String {
166 format!(
167 "{}{}{COMMENTED_AI_BODY}",
168 header(Preset::Default),
169 common_body()
170 )
171}
172
173fn render_ai() -> String {
174 format!("{}{}{LIVE_AI_BODY}", header(Preset::Ai), common_body())
175}
176
177fn render_strict() -> Result<String, RuleError> {
178 let mut out = header(Preset::Strict);
179 out.push_str(RULESETS_ALL_ON);
180 out.push_str(
181 "\n[rules]\ndisable = []\n# Opt-in rules, all switched on by the strict preset.\n",
182 );
183 out.push_str(&toml_array("enable", &opt_in_rule_ids()?));
184 out.push_str("# custom_dirs = [\"./my-rules\"]\n\n[scan]\nignores = []\n\n");
185 out.push_str(OUTPUT_BLOCK);
186 out.push_str(
187 "\n# Severity threshold is a scan flag, not a config key. Strict keeps the\n\
188 # default (warning), so any finding fails the run:\n\
189 # slopguard scan --severity-threshold warning\n",
190 );
191 out.push_str(COMMENTED_AI_BODY);
192 Ok(out)
193}
194
195fn render_relaxed() -> Result<String, RuleError> {
196 let mut out = header(Preset::Relaxed);
197 out.push_str(
198 "[rulesets]\nslop = false\nsecurity = true\ncorrectness = true\n\n\
199 [rules]\n# Correctness warnings are off: only correctness errors and \
200 security rules fire.\n",
201 );
202 out.push_str(&toml_array("disable", &correctness_warning_rule_ids()?));
203 out.push_str("enable = []\n# custom_dirs = [\"./my-rules\"]\n\n[scan]\nignores = []\n\n");
204 out.push_str(OUTPUT_BLOCK);
205 Ok(out)
206}
207
208pub fn parse_preset(value: &str) -> Result<Preset, String> {
212 value.parse().map_err(|_| {
213 let names: Vec<String> = Preset::iter().map(|p| p.to_string()).collect();
214 format!(
215 "unknown preset '{value}' (expected one of: {})",
216 names.join(", ")
217 )
218 })
219}
220
221pub fn presets_help() -> String {
223 let name_w = Preset::iter()
224 .map(|p| p.to_string().len())
225 .max()
226 .unwrap_or(7);
227 let mut out = String::from("Available presets:\n\n");
228 for preset in Preset::iter() {
229 let name = preset.to_string();
230 out.push_str(&format!(" {name:<name_w$} {}\n", preset.description()));
231 }
232 out.push_str("\nUsage: slopguard init --preset <name>\n");
233 out
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 use crate::config::{AiTransport, AiVendor, Config};
241 use crate::rule::{is_rule_active, Rule};
242
243 fn parse(preset: Preset) -> Config {
244 let rendered = preset.render().unwrap();
245 toml::from_str(&rendered)
246 .unwrap_or_else(|e| panic!("preset {preset} should render valid TOML: {e}\n{rendered}"))
247 }
248
249 fn rule_with_id(id: &str) -> Rule {
250 load_builtin_rules()
251 .unwrap()
252 .into_iter()
253 .find(|r| r.id.as_str() == id)
254 .unwrap_or_else(|| panic!("builtin rule '{id}' should exist"))
255 }
256
257 #[test]
258 fn every_preset_renders_valid_config() {
259 for preset in Preset::iter() {
260 let _config = parse(preset);
261 }
262 }
263
264 #[test]
265 fn every_preset_has_generated_by_header() {
266 for preset in Preset::iter() {
267 let rendered = preset.render().unwrap();
268 let expected = format!("# Generated by: slopguard init --preset {preset}\n");
269 assert!(
270 rendered.starts_with(&expected),
271 "preset {preset} should start with its header, got:\n{rendered}"
272 );
273 }
274 }
275
276 #[test]
277 fn strict_enables_opt_in_rules() {
278 let config = parse(Preset::Strict);
279 assert!(config.rulesets.slop);
280 assert!(config.rulesets.security);
281 assert!(config.rulesets.correctness);
282 assert!(config.rules.disable.is_empty());
283 let enable = &config.rules.enable;
284 assert!(enable.iter().any(|id| id == "pub-fn-needs-tracing"));
285 assert!(enable.iter().any(|id| id == "test-needs-timeout"));
286 }
287
288 #[test]
289 fn strict_activates_opt_in_rules() {
290 let config = parse(Preset::Strict);
291 let rule = rule_with_id("pub-fn-needs-tracing");
292 assert!(
293 is_rule_active(&rule, &config),
294 "strict should activate opt-in rules"
295 );
296 }
297
298 #[test]
299 fn relaxed_disables_slop_and_correctness_warnings() {
300 let config = parse(Preset::Relaxed);
301 assert!(!config.rulesets.slop);
302 assert!(config.rulesets.security);
303 assert!(config.rulesets.correctness);
304 let disable = &config.rules.disable;
305 assert!(disable.iter().any(|id| id == "no-swallowed-error"));
306 assert!(!disable.iter().any(|id| id == "no-unwrap-in-prod"));
308 assert!(!disable.iter().any(|id| id == "no-hardcoded-secret"));
309 }
310
311 #[test]
312 fn relaxed_keeps_correctness_errors_active() {
313 let config = parse(Preset::Relaxed);
314 assert!(!is_rule_active(
315 &rule_with_id("no-swallowed-error"),
316 &config
317 ));
318 assert!(is_rule_active(&rule_with_id("no-unwrap-in-prod"), &config));
319 }
320
321 #[test]
322 fn ai_preset_enables_ai() {
323 let config = parse(Preset::Ai);
324 assert!(config.ai.enabled);
325 assert_eq!(config.ai.provider, AiTransport::Api);
326 assert_eq!(config.ai.vendor, AiVendor::Anthropic);
327 assert_eq!(config.ai.model.as_deref(), Some("claude-haiku-4-5"));
328 }
329
330 #[test]
331 fn default_preset_matches_defaults() {
332 let config = parse(Preset::Default);
333 let defaults = Config::default();
334 assert_eq!(config.rulesets, defaults.rulesets);
335 assert!(config.rules.enable.is_empty());
336 assert!(config.rules.disable.is_empty());
337 assert_eq!(config.output, defaults.output);
338 assert!(!config.ai.enabled);
339 }
340
341 #[test]
342 fn no_duplicate_ids_in_rendered_lists() {
343 let strict = parse(Preset::Strict).rules.enable;
344 let mut deduped = strict.clone();
345 deduped.sort();
346 deduped.dedup();
347 assert_eq!(strict.len(), deduped.len(), "strict enable has duplicates");
348
349 let relaxed = parse(Preset::Relaxed).rules.disable;
350 let mut deduped = relaxed.clone();
351 deduped.sort();
352 deduped.dedup();
353 assert_eq!(
354 relaxed.len(),
355 deduped.len(),
356 "relaxed disable has duplicates"
357 );
358 }
359
360 #[test]
361 fn preset_from_str_roundtrip() {
362 assert_eq!("strict".parse::<Preset>().unwrap(), Preset::Strict);
363 assert_eq!(Preset::Strict.to_string(), "strict");
364 assert_eq!(Preset::Default.to_string(), "default");
365 assert_eq!(Preset::Relaxed.to_string(), "relaxed");
366 assert_eq!(Preset::Ai.to_string(), "ai");
367 assert_eq!(Preset::default(), Preset::Default);
368 }
369
370 #[test]
371 fn presets_help_lists_every_preset() {
372 let help = presets_help();
373 for preset in Preset::iter() {
374 assert!(
375 help.contains(&preset.to_string()),
376 "help should name {preset}"
377 );
378 assert!(
379 help.contains(preset.description()),
380 "help should describe {preset}"
381 );
382 }
383 }
384}