code_baseline/rules/
banned_import.rs1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use regex::Regex;
4
5#[derive(Debug)]
16pub struct BannedImportRule {
17 id: String,
18 severity: Severity,
19 message: String,
20 suggest: Option<String>,
21 glob: Option<String>,
22 #[allow(dead_code)]
23 packages: Vec<String>,
24 import_re: Regex,
25}
26
27impl BannedImportRule {
28 pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
29 if config.packages.is_empty() {
30 return Err(RuleBuildError::MissingField(
31 config.id.clone(),
32 "packages",
33 ));
34 }
35
36 let escaped: Vec<String> = config
39 .packages
40 .iter()
41 .map(|p| regex::escape(p))
42 .collect();
43 let pkg_group = escaped.join("|");
44
45 let pattern = format!(
52 r#"(?:import\s+.*?\s+from\s+|import\s+|export\s+.*?\s+from\s+|require\s*\(\s*)['"]({})(?:/[^'"]*)?['"]"#,
53 pkg_group
54 );
55
56 let import_re = Regex::new(&pattern)
57 .map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
58
59 let default_glob = "**/*.{ts,tsx,js,jsx,mjs,cjs}".to_string();
60
61 Ok(Self {
62 id: config.id.clone(),
63 severity: config.severity,
64 message: config.message.clone(),
65 suggest: config.suggest.clone(),
66 glob: config.glob.clone().or(Some(default_glob)),
67 packages: config.packages.clone(),
68 import_re,
69 })
70 }
71}
72
73impl Rule for BannedImportRule {
74 fn id(&self) -> &str {
75 &self.id
76 }
77
78 fn severity(&self) -> Severity {
79 self.severity
80 }
81
82 fn file_glob(&self) -> Option<&str> {
83 self.glob.as_deref()
84 }
85
86 fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
87 let mut violations = Vec::new();
88
89 for (line_idx, line) in ctx.content.lines().enumerate() {
90 for cap in self.import_re.captures_iter(line) {
91 let matched_pkg = cap.get(1).unwrap().as_str();
92 let full_match = cap.get(0).unwrap();
93
94 violations.push(Violation {
95 rule_id: self.id.clone(),
96 severity: self.severity,
97 file: ctx.file_path.to_path_buf(),
98 line: Some(line_idx + 1),
99 column: Some(full_match.start() + 1),
100 message: format!("{}: '{}'", self.message, matched_pkg),
101 suggest: self.suggest.clone(),
102 source_line: Some(line.to_string()),
103 fix: None,
104 });
105 }
106 }
107
108 violations
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use std::path::Path;
116
117 fn make_rule(packages: Vec<&str>) -> BannedImportRule {
118 let config = RuleConfig {
119 id: "test-banned-import".into(),
120 severity: Severity::Error,
121 message: "banned import".into(),
122 suggest: Some("use an alternative".into()),
123 packages: packages.into_iter().map(|s| s.to_string()).collect(),
124 ..Default::default()
125 };
126 BannedImportRule::new(&config).unwrap()
127 }
128
129 fn check(rule: &BannedImportRule, content: &str) -> Vec<Violation> {
130 let ctx = ScanContext {
131 file_path: Path::new("test.ts"),
132 content,
133 };
134 rule.check_file(&ctx)
135 }
136
137 #[test]
138 fn detects_named_import() {
139 let rule = make_rule(vec!["moment"]);
140 let violations = check(&rule, r#"import moment from 'moment';"#);
141 assert_eq!(violations.len(), 1);
142 assert!(violations[0].message.contains("moment"));
143 }
144
145 #[test]
146 fn detects_destructured_import() {
147 let rule = make_rule(vec!["moment"]);
148 let violations = check(&rule, r#"import { format } from "moment";"#);
149 assert_eq!(violations.len(), 1);
150 }
151
152 #[test]
153 fn detects_side_effect_import() {
154 let rule = make_rule(vec!["styled-components"]);
155 let violations = check(&rule, r#"import 'styled-components';"#);
156 assert_eq!(violations.len(), 1);
157 }
158
159 #[test]
160 fn detects_require() {
161 let rule = make_rule(vec!["moment"]);
162 let violations = check(&rule, r#"const moment = require('moment');"#);
163 assert_eq!(violations.len(), 1);
164 }
165
166 #[test]
167 fn detects_subpath_import() {
168 let rule = make_rule(vec!["lodash"]);
169 let violations = check(&rule, r#"import debounce from 'lodash/debounce';"#);
170 assert_eq!(violations.len(), 1);
171 assert!(violations[0].message.contains("lodash"));
172 }
173
174 #[test]
175 fn no_false_positive_on_similar_names() {
176 let rule = make_rule(vec!["moment"]);
177 let violations = check(&rule, r#"import momentum from 'momentum';"#);
178 assert!(violations.is_empty(), "should not match 'momentum' when banning 'moment'");
179 }
180
181 #[test]
182 fn detects_scoped_package() {
183 let rule = make_rule(vec!["@emotion/styled"]);
184 let violations = check(&rule, r#"import styled from '@emotion/styled';"#);
185 assert_eq!(violations.len(), 1);
186 }
187
188 #[test]
189 fn detects_export_from() {
190 let rule = make_rule(vec!["moment"]);
191 let violations = check(&rule, r#"export { default } from 'moment';"#);
192 assert_eq!(violations.len(), 1);
193 }
194
195 #[test]
196 fn no_match_on_safe_imports() {
197 let rule = make_rule(vec!["moment"]);
198 let violations = check(&rule, r#"import { format } from 'date-fns';"#);
199 assert!(violations.is_empty());
200 }
201
202 #[test]
203 fn multiple_banned_packages() {
204 let rule = make_rule(vec!["styled-components", "@emotion/styled", "@emotion/css"]);
205 let content = r#"import styled from 'styled-components';
206import { css } from '@emotion/css';
207import { jsx } from '@emotion/react';"#;
208 let violations = check(&rule, content);
209 assert_eq!(violations.len(), 2);
210 }
211
212 #[test]
213 fn missing_packages_error() {
214 let config = RuleConfig {
215 id: "test".into(),
216 severity: Severity::Error,
217 message: "test".into(),
218 ..Default::default()
219 };
220 let err = BannedImportRule::new(&config).unwrap_err();
221 assert!(matches!(err, RuleBuildError::MissingField(_, "packages")));
222 }
223
224 #[test]
225 fn default_glob_set() {
226 let rule = make_rule(vec!["moment"]);
227 assert_eq!(rule.file_glob(), Some("**/*.{ts,tsx,js,jsx,mjs,cjs}"));
228 }
229}