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