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