use crate::cli::toml_config::TomlRule;
use std::collections::HashMap;
use std::fmt;
#[derive(Debug)]
pub enum PresetError {
UnknownPreset {
name: String,
available: Vec<&'static str>,
},
}
impl fmt::Display for PresetError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PresetError::UnknownPreset { name, available } => {
write!(
f,
"unknown preset '{}'. available presets: {}",
name,
available.join(", ")
)
}
}
}
}
impl std::error::Error for PresetError {}
#[derive(Debug, Clone, Copy)]
enum Preset {
ShadcnStrict,
ShadcnMigrate,
AiSafety,
}
pub fn available_presets() -> &'static [&'static str] {
&["shadcn-strict", "shadcn-migrate", "ai-safety"]
}
fn resolve_preset(name: &str) -> Option<Preset> {
match name {
"shadcn-strict" => Some(Preset::ShadcnStrict),
"shadcn-migrate" => Some(Preset::ShadcnMigrate),
"ai-safety" => Some(Preset::AiSafety),
_ => None,
}
}
fn preset_rules(preset: Preset) -> Vec<TomlRule> {
match preset {
Preset::ShadcnStrict => vec![
TomlRule {
id: "enforce-dark-mode".into(),
rule_type: "tailwind-dark-mode".into(),
severity: "error".into(),
glob: Some("**/*.{tsx,jsx}".into()),
message: "Missing dark: variant for color class".into(),
suggest: Some(
"Use a shadcn semantic token class or add an explicit dark: counterpart"
.into(),
),
..Default::default()
},
TomlRule {
id: "use-theme-tokens".into(),
rule_type: "tailwind-theme-tokens".into(),
severity: "error".into(),
glob: Some("**/*.{tsx,jsx}".into()),
message: "Use shadcn semantic token instead of raw color".into(),
..Default::default()
},
TomlRule {
id: "no-inline-styles".into(),
rule_type: "banned-pattern".into(),
severity: "warning".into(),
glob: Some("**/*.{tsx,jsx}".into()),
pattern: Some("style={{".into()),
message: "Avoid inline styles — use Tailwind utility classes instead".into(),
suggest: Some("Replace style={{ ... }} with Tailwind classes".into()),
..Default::default()
},
TomlRule {
id: "no-css-in-js".into(),
rule_type: "banned-import".into(),
severity: "error".into(),
packages: vec![
"styled-components".into(),
"@emotion/styled".into(),
"@emotion/css".into(),
"@emotion/react".into(),
],
message: "CSS-in-JS libraries conflict with Tailwind — use utility classes instead"
.into(),
..Default::default()
},
TomlRule {
id: "no-competing-frameworks".into(),
rule_type: "banned-dependency".into(),
severity: "error".into(),
packages: vec![
"bootstrap".into(),
"bulma".into(),
"@mui/material".into(),
"antd".into(),
],
message:
"Competing CSS framework detected — this project uses Tailwind + shadcn/ui"
.into(),
..Default::default()
},
],
Preset::ShadcnMigrate => vec![
TomlRule {
id: "enforce-dark-mode".into(),
rule_type: "tailwind-dark-mode".into(),
severity: "error".into(),
glob: Some("**/*.{tsx,jsx}".into()),
message: "Missing dark: variant for color class".into(),
suggest: Some(
"Use a shadcn semantic token class or add an explicit dark: counterpart"
.into(),
),
..Default::default()
},
TomlRule {
id: "use-theme-tokens".into(),
rule_type: "tailwind-theme-tokens".into(),
severity: "warning".into(),
glob: Some("**/*.{tsx,jsx}".into()),
message: "Use shadcn semantic token instead of raw color".into(),
..Default::default()
},
],
Preset::AiSafety => vec![
TomlRule {
id: "no-moment".into(),
rule_type: "banned-dependency".into(),
severity: "error".into(),
packages: vec!["moment".into(), "moment-timezone".into()],
message: "moment.js is deprecated — use date-fns or Temporal API".into(),
..Default::default()
},
TomlRule {
id: "no-lodash".into(),
rule_type: "banned-dependency".into(),
severity: "error".into(),
packages: vec!["lodash".into()],
message: "lodash is unnecessary — use native JS methods".into(),
..Default::default()
},
TomlRule {
id: "no-deprecated-request".into(),
rule_type: "banned-dependency".into(),
severity: "error".into(),
packages: vec!["request".into(), "request-promise".into()],
message: "The 'request' package is deprecated — use 'node-fetch' or 'undici'".into(),
..Default::default()
},
],
}
}
fn merge_rules(preset_rules: Vec<TomlRule>, user_rules: &[TomlRule]) -> Vec<TomlRule> {
let mut merged = preset_rules;
let mut id_to_index: HashMap<String, usize> = HashMap::new();
for (i, rule) in merged.iter().enumerate() {
id_to_index.insert(rule.id.clone(), i);
}
for user_rule in user_rules {
if let Some(&idx) = id_to_index.get(&user_rule.id) {
merged[idx] = user_rule.clone();
} else {
merged.push(user_rule.clone());
}
}
merged
}
pub fn resolve_rules(
extends: &[String],
user_rules: &[TomlRule],
) -> Result<Vec<TomlRule>, PresetError> {
if extends.is_empty() {
return Ok(user_rules.to_vec());
}
let mut all_preset_rules: Vec<TomlRule> = Vec::new();
let mut seen: HashMap<String, usize> = HashMap::new();
for preset_name in extends {
let preset = resolve_preset(preset_name).ok_or_else(|| PresetError::UnknownPreset {
name: preset_name.clone(),
available: available_presets().to_vec(),
})?;
for rule in preset_rules(preset) {
if let Some(&idx) = seen.get(&rule.id) {
all_preset_rules[idx] = rule;
} else {
seen.insert(rule.id.clone(), all_preset_rules.len());
all_preset_rules.push(rule);
}
}
}
Ok(merge_rules(all_preset_rules, user_rules))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shadcn_strict_has_five_rules() {
let rules = preset_rules(Preset::ShadcnStrict);
assert_eq!(rules.len(), 5);
let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
assert!(ids.contains(&"enforce-dark-mode"));
assert!(ids.contains(&"use-theme-tokens"));
assert!(ids.contains(&"no-inline-styles"));
assert!(ids.contains(&"no-css-in-js"));
assert!(ids.contains(&"no-competing-frameworks"));
}
#[test]
fn shadcn_migrate_has_two_rules() {
let rules = preset_rules(Preset::ShadcnMigrate);
assert_eq!(rules.len(), 2);
assert_eq!(rules[0].id, "enforce-dark-mode");
assert_eq!(rules[1].id, "use-theme-tokens");
assert_eq!(rules[1].severity, "warning");
}
#[test]
fn ai_safety_has_three_rules() {
let rules = preset_rules(Preset::AiSafety);
assert_eq!(rules.len(), 3);
let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
assert!(ids.contains(&"no-moment"));
assert!(ids.contains(&"no-lodash"));
assert!(ids.contains(&"no-deprecated-request"));
}
#[test]
fn resolve_unknown_preset_errors() {
let result = resolve_rules(&["unknown-preset".to_string()], &[]);
assert!(result.is_err());
let err = result.unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("unknown preset 'unknown-preset'"));
assert!(msg.contains("shadcn-strict"));
}
#[test]
fn resolve_empty_extends_returns_user_rules() {
let user_rules = vec![TomlRule {
id: "custom-rule".into(),
rule_type: "banned-pattern".into(),
pattern: Some("TODO".into()),
message: "No TODOs".into(),
..Default::default()
}];
let result = resolve_rules(&[], &user_rules).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, "custom-rule");
}
#[test]
fn user_rule_overrides_preset() {
let user_rules = vec![TomlRule {
id: "use-theme-tokens".into(),
rule_type: "tailwind-theme-tokens".into(),
severity: "warning".into(),
glob: Some("**/*.{tsx,jsx}".into()),
message: "Custom message".into(),
..Default::default()
}];
let result = resolve_rules(&["shadcn-strict".to_string()], &user_rules).unwrap();
assert_eq!(result.len(), 5);
let token_rule = result.iter().find(|r| r.id == "use-theme-tokens").unwrap();
assert_eq!(token_rule.severity, "warning");
assert_eq!(token_rule.message, "Custom message");
}
#[test]
fn user_rule_appended_after_preset() {
let user_rules = vec![TomlRule {
id: "my-custom".into(),
rule_type: "banned-pattern".into(),
pattern: Some("foo".into()),
message: "no foo".into(),
..Default::default()
}];
let result = resolve_rules(&["shadcn-strict".to_string()], &user_rules).unwrap();
assert_eq!(result.len(), 6);
assert_eq!(result[5].id, "my-custom");
}
#[test]
fn later_preset_overrides_earlier() {
let result = resolve_rules(
&["shadcn-strict".to_string(), "shadcn-migrate".to_string()],
&[],
)
.unwrap();
let token_rule = result.iter().find(|r| r.id == "use-theme-tokens").unwrap();
assert_eq!(token_rule.severity, "warning");
assert_eq!(result.len(), 5);
}
#[test]
fn multiple_presets_combine() {
let result = resolve_rules(
&["shadcn-migrate".to_string(), "ai-safety".to_string()],
&[],
)
.unwrap();
assert_eq!(result.len(), 5);
}
#[test]
fn all_preset_names_resolve() {
for name in available_presets() {
assert!(
resolve_preset(name).is_some(),
"preset '{}' should resolve",
name
);
}
}
}