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