1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
9#[serde(rename_all = "lowercase")]
10pub enum Severity {
11 #[default]
13 Error,
14 Warn,
16 Off,
18}
19
20impl Severity {
21 const fn default_warn() -> Self {
23 Self::Warn
24 }
25}
26
27impl std::fmt::Display for Severity {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 match self {
30 Self::Error => write!(f, "error"),
31 Self::Warn => write!(f, "warn"),
32 Self::Off => write!(f, "off"),
33 }
34 }
35}
36
37impl std::str::FromStr for Severity {
38 type Err = String;
39
40 fn from_str(s: &str) -> Result<Self, Self::Err> {
41 match s.to_lowercase().as_str() {
42 "error" => Ok(Self::Error),
43 "warn" | "warning" => Ok(Self::Warn),
44 "off" | "none" => Ok(Self::Off),
45 other => Err(format!(
46 "unknown severity: '{other}' (expected error, warn, or off)"
47 )),
48 }
49 }
50}
51
52#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
59#[serde(rename_all = "kebab-case")]
60pub struct RulesConfig {
61 #[serde(default)]
62 pub unused_files: Severity,
63 #[serde(default)]
64 pub unused_exports: Severity,
65 #[serde(default)]
66 pub unused_types: Severity,
67 #[serde(default)]
68 pub unused_dependencies: Severity,
69 #[serde(default = "Severity::default_warn")]
70 pub unused_dev_dependencies: Severity,
71 #[serde(default = "Severity::default_warn")]
72 pub unused_optional_dependencies: Severity,
73 #[serde(default)]
74 pub unused_enum_members: Severity,
75 #[serde(default)]
76 pub unused_class_members: Severity,
77 #[serde(default)]
78 pub unresolved_imports: Severity,
79 #[serde(default)]
80 pub unlisted_dependencies: Severity,
81 #[serde(default)]
82 pub duplicate_exports: Severity,
83 #[serde(default = "Severity::default_warn")]
84 pub type_only_dependencies: Severity,
85 #[serde(default = "Severity::default_warn")]
86 pub test_only_dependencies: Severity,
87 #[serde(default)]
88 pub circular_dependencies: Severity,
89 #[serde(default)]
90 pub boundary_violation: Severity,
91 #[serde(default)]
92 pub coverage_gaps: Severity,
93}
94
95impl Default for RulesConfig {
96 fn default() -> Self {
97 Self {
98 unused_files: Severity::Error,
99 unused_exports: Severity::Error,
100 unused_types: Severity::Error,
101 unused_dependencies: Severity::Error,
102 unused_dev_dependencies: Severity::Warn,
103 unused_optional_dependencies: Severity::Warn,
104 unused_enum_members: Severity::Error,
105 unused_class_members: Severity::Error,
106 unresolved_imports: Severity::Error,
107 unlisted_dependencies: Severity::Error,
108 duplicate_exports: Severity::Error,
109 type_only_dependencies: Severity::Warn,
110 test_only_dependencies: Severity::Warn,
111 circular_dependencies: Severity::Error,
112 boundary_violation: Severity::Error,
113 coverage_gaps: Severity::Off,
114 }
115 }
116}
117
118impl RulesConfig {
119 pub const fn apply_partial(&mut self, partial: &PartialRulesConfig) {
121 if let Some(s) = partial.unused_files {
122 self.unused_files = s;
123 }
124 if let Some(s) = partial.unused_exports {
125 self.unused_exports = s;
126 }
127 if let Some(s) = partial.unused_types {
128 self.unused_types = s;
129 }
130 if let Some(s) = partial.unused_dependencies {
131 self.unused_dependencies = s;
132 }
133 if let Some(s) = partial.unused_dev_dependencies {
134 self.unused_dev_dependencies = s;
135 }
136 if let Some(s) = partial.unused_optional_dependencies {
137 self.unused_optional_dependencies = s;
138 }
139 if let Some(s) = partial.unused_enum_members {
140 self.unused_enum_members = s;
141 }
142 if let Some(s) = partial.unused_class_members {
143 self.unused_class_members = s;
144 }
145 if let Some(s) = partial.unresolved_imports {
146 self.unresolved_imports = s;
147 }
148 if let Some(s) = partial.unlisted_dependencies {
149 self.unlisted_dependencies = s;
150 }
151 if let Some(s) = partial.duplicate_exports {
152 self.duplicate_exports = s;
153 }
154 if let Some(s) = partial.type_only_dependencies {
155 self.type_only_dependencies = s;
156 }
157 if let Some(s) = partial.test_only_dependencies {
158 self.test_only_dependencies = s;
159 }
160 if let Some(s) = partial.circular_dependencies {
161 self.circular_dependencies = s;
162 }
163 if let Some(s) = partial.boundary_violation {
164 self.boundary_violation = s;
165 }
166 if let Some(s) = partial.coverage_gaps {
167 self.coverage_gaps = s;
168 }
169 }
170}
171
172#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
174#[serde(rename_all = "kebab-case")]
175pub struct PartialRulesConfig {
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub unused_files: Option<Severity>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub unused_exports: Option<Severity>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub unused_types: Option<Severity>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub unused_dependencies: Option<Severity>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub unused_dev_dependencies: Option<Severity>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub unused_optional_dependencies: Option<Severity>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub unused_enum_members: Option<Severity>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub unused_class_members: Option<Severity>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub unresolved_imports: Option<Severity>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub unlisted_dependencies: Option<Severity>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub duplicate_exports: Option<Severity>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub type_only_dependencies: Option<Severity>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub test_only_dependencies: Option<Severity>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub circular_dependencies: Option<Severity>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub boundary_violation: Option<Severity>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub coverage_gaps: Option<Severity>,
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn rules_default_all_error_except_type_only() {
216 let rules = RulesConfig::default();
217 assert_eq!(rules.unused_files, Severity::Error);
218 assert_eq!(rules.unused_exports, Severity::Error);
219 assert_eq!(rules.unused_types, Severity::Error);
220 assert_eq!(rules.unused_dependencies, Severity::Error);
221 assert_eq!(rules.unused_dev_dependencies, Severity::Warn);
222 assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
223 assert_eq!(rules.unused_enum_members, Severity::Error);
224 assert_eq!(rules.unused_class_members, Severity::Error);
225 assert_eq!(rules.unresolved_imports, Severity::Error);
226 assert_eq!(rules.unlisted_dependencies, Severity::Error);
227 assert_eq!(rules.duplicate_exports, Severity::Error);
228 assert_eq!(rules.type_only_dependencies, Severity::Warn);
229 assert_eq!(rules.test_only_dependencies, Severity::Warn);
230 assert_eq!(rules.circular_dependencies, Severity::Error);
231 assert_eq!(rules.boundary_violation, Severity::Error);
232 assert_eq!(rules.coverage_gaps, Severity::Off);
233 }
234
235 #[test]
236 fn rules_deserialize_kebab_case() {
237 let json_str = r#"{
238 "unused-files": "error",
239 "unused-exports": "warn",
240 "unused-types": "off"
241 }"#;
242 let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
243 assert_eq!(rules.unused_files, Severity::Error);
244 assert_eq!(rules.unused_exports, Severity::Warn);
245 assert_eq!(rules.unused_types, Severity::Off);
246 assert_eq!(rules.unresolved_imports, Severity::Error);
248 }
249
250 #[test]
251 fn severity_from_str() {
252 assert_eq!("error".parse::<Severity>().unwrap(), Severity::Error);
253 assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
254 assert_eq!("warning".parse::<Severity>().unwrap(), Severity::Warn);
255 assert_eq!("off".parse::<Severity>().unwrap(), Severity::Off);
256 assert_eq!("none".parse::<Severity>().unwrap(), Severity::Off);
257 assert!("invalid".parse::<Severity>().is_err());
258 }
259
260 #[test]
261 fn apply_partial_only_some_fields() {
262 let mut rules = RulesConfig::default();
263 let partial = PartialRulesConfig {
264 unused_files: Some(Severity::Warn),
265 unused_exports: Some(Severity::Off),
266 ..Default::default()
267 };
268 rules.apply_partial(&partial);
269 assert_eq!(rules.unused_files, Severity::Warn);
270 assert_eq!(rules.unused_exports, Severity::Off);
271 assert_eq!(rules.unused_types, Severity::Error);
273 assert_eq!(rules.unresolved_imports, Severity::Error);
274 }
275
276 #[test]
277 fn severity_display() {
278 assert_eq!(Severity::Error.to_string(), "error");
279 assert_eq!(Severity::Warn.to_string(), "warn");
280 assert_eq!(Severity::Off.to_string(), "off");
281 }
282
283 #[test]
284 fn apply_partial_all_none_changes_nothing() {
285 let mut rules = RulesConfig::default();
286 let original = rules.clone();
287 let partial = PartialRulesConfig::default(); rules.apply_partial(&partial);
289 assert_eq!(rules.unused_files, original.unused_files);
290 assert_eq!(rules.unused_exports, original.unused_exports);
291 assert_eq!(
292 rules.type_only_dependencies,
293 original.type_only_dependencies
294 );
295 }
296
297 #[test]
298 fn apply_partial_all_fields_set() {
299 let mut rules = RulesConfig::default();
300 let partial = PartialRulesConfig {
301 unused_files: Some(Severity::Off),
302 unused_exports: Some(Severity::Off),
303 unused_types: Some(Severity::Off),
304 unused_dependencies: Some(Severity::Off),
305 unused_dev_dependencies: Some(Severity::Off),
306 unused_optional_dependencies: Some(Severity::Off),
307 unused_enum_members: Some(Severity::Off),
308 unused_class_members: Some(Severity::Off),
309 unresolved_imports: Some(Severity::Off),
310 unlisted_dependencies: Some(Severity::Off),
311 duplicate_exports: Some(Severity::Off),
312 type_only_dependencies: Some(Severity::Off),
313 test_only_dependencies: Some(Severity::Off),
314 circular_dependencies: Some(Severity::Off),
315 boundary_violation: Some(Severity::Off),
316 coverage_gaps: Some(Severity::Off),
317 };
318 rules.apply_partial(&partial);
319 assert_eq!(rules.unused_files, Severity::Off);
320 assert_eq!(rules.circular_dependencies, Severity::Off);
321 assert_eq!(rules.type_only_dependencies, Severity::Off);
322 assert_eq!(rules.test_only_dependencies, Severity::Off);
323 assert_eq!(rules.boundary_violation, Severity::Off);
324 assert_eq!(rules.coverage_gaps, Severity::Off);
325 }
326
327 #[test]
328 fn rules_config_defaults_include_optional_deps() {
329 let rules = RulesConfig::default();
330 assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
331 }
332
333 #[test]
334 fn severity_from_str_case_insensitive() {
335 assert_eq!("ERROR".parse::<Severity>().unwrap(), Severity::Error);
336 assert_eq!("Warn".parse::<Severity>().unwrap(), Severity::Warn);
337 assert_eq!("OFF".parse::<Severity>().unwrap(), Severity::Off);
338 assert_eq!("Warning".parse::<Severity>().unwrap(), Severity::Warn);
339 assert_eq!("NONE".parse::<Severity>().unwrap(), Severity::Off);
340 }
341
342 #[test]
343 fn severity_from_str_invalid_returns_error() {
344 let result = "critical".parse::<Severity>();
345 assert!(result.is_err());
346 let err = result.unwrap_err();
347 assert!(
348 err.contains("unknown severity"),
349 "Expected descriptive error, got: {err}"
350 );
351 }
352
353 #[test]
356 fn partial_rules_empty_json() {
357 let partial: PartialRulesConfig = serde_json::from_str("{}").unwrap();
358 assert!(partial.unused_files.is_none());
359 assert!(partial.unused_exports.is_none());
360 assert!(partial.unused_types.is_none());
361 assert!(partial.unused_dependencies.is_none());
362 assert!(partial.circular_dependencies.is_none());
363 assert!(partial.boundary_violation.is_none());
364 assert!(partial.coverage_gaps.is_none());
365 }
366
367 #[test]
368 fn partial_rules_subset_json() {
369 let json = r#"{
370 "unused-files": "warn",
371 "circular-dependencies": "off"
372 }"#;
373 let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
374 assert_eq!(partial.unused_files, Some(Severity::Warn));
375 assert_eq!(partial.circular_dependencies, Some(Severity::Off));
376 assert!(partial.unused_exports.is_none());
377 }
378
379 #[test]
380 fn partial_rules_all_fields_json() {
381 let json = r#"{
382 "unused-files": "error",
383 "unused-exports": "warn",
384 "unused-types": "off",
385 "unused-dependencies": "error",
386 "unused-dev-dependencies": "warn",
387 "unused-optional-dependencies": "off",
388 "unused-enum-members": "error",
389 "unused-class-members": "warn",
390 "unresolved-imports": "off",
391 "unlisted-dependencies": "error",
392 "duplicate-exports": "warn",
393 "type-only-dependencies": "off",
394 "test-only-dependencies": "error",
395 "circular-dependencies": "warn",
396 "boundary-violation": "off",
397 "coverage-gaps": "warn"
398 }"#;
399 let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
400 assert_eq!(partial.unused_files, Some(Severity::Error));
401 assert_eq!(partial.unused_exports, Some(Severity::Warn));
402 assert_eq!(partial.unused_types, Some(Severity::Off));
403 assert_eq!(partial.unused_dependencies, Some(Severity::Error));
404 assert_eq!(partial.unused_dev_dependencies, Some(Severity::Warn));
405 assert_eq!(partial.unused_optional_dependencies, Some(Severity::Off));
406 assert_eq!(partial.unused_enum_members, Some(Severity::Error));
407 assert_eq!(partial.unused_class_members, Some(Severity::Warn));
408 assert_eq!(partial.unresolved_imports, Some(Severity::Off));
409 assert_eq!(partial.unlisted_dependencies, Some(Severity::Error));
410 assert_eq!(partial.duplicate_exports, Some(Severity::Warn));
411 assert_eq!(partial.type_only_dependencies, Some(Severity::Off));
412 assert_eq!(partial.test_only_dependencies, Some(Severity::Error));
413 assert_eq!(partial.circular_dependencies, Some(Severity::Warn));
414 assert_eq!(partial.boundary_violation, Some(Severity::Off));
415 assert_eq!(partial.coverage_gaps, Some(Severity::Warn));
416 }
417
418 #[test]
421 fn partial_rules_none_fields_not_serialized() {
422 let partial = PartialRulesConfig::default();
423 let json = serde_json::to_string(&partial).unwrap();
424 assert_eq!(
425 json, "{}",
426 "all-None partial should serialize to empty object"
427 );
428 }
429
430 #[test]
431 fn partial_rules_some_fields_serialized() {
432 let partial = PartialRulesConfig {
433 unused_files: Some(Severity::Warn),
434 ..Default::default()
435 };
436 let json = serde_json::to_string(&partial).unwrap();
437 assert!(json.contains("unused-files"));
438 assert!(!json.contains("unused-exports"));
439 }
440
441 #[test]
444 fn severity_json_deserialization() {
445 let error: Severity = serde_json::from_str(r#""error""#).unwrap();
446 assert_eq!(error, Severity::Error);
447
448 let warn: Severity = serde_json::from_str(r#""warn""#).unwrap();
449 assert_eq!(warn, Severity::Warn);
450
451 let off: Severity = serde_json::from_str(r#""off""#).unwrap();
452 assert_eq!(off, Severity::Off);
453 }
454
455 #[test]
456 fn severity_invalid_json_value_rejected() {
457 let result: Result<Severity, _> = serde_json::from_str(r#""critical""#);
458 assert!(result.is_err());
459 }
460
461 #[test]
464 fn severity_default_is_error() {
465 assert_eq!(Severity::default(), Severity::Error);
466 }
467
468 #[test]
471 fn rules_config_json_roundtrip() {
472 let rules = RulesConfig {
473 unused_files: Severity::Warn,
474 unused_exports: Severity::Off,
475 type_only_dependencies: Severity::Error,
476 ..RulesConfig::default()
477 };
478 let json = serde_json::to_string(&rules).unwrap();
479 let restored: RulesConfig = serde_json::from_str(&json).unwrap();
480 assert_eq!(restored.unused_files, Severity::Warn);
481 assert_eq!(restored.unused_exports, Severity::Off);
482 assert_eq!(restored.type_only_dependencies, Severity::Error);
483 assert_eq!(restored.unused_dependencies, Severity::Error); }
485
486 #[test]
489 fn apply_partial_preserves_type_only_default() {
490 let mut rules = RulesConfig::default();
491 let partial = PartialRulesConfig {
492 unused_files: Some(Severity::Off),
493 ..Default::default()
494 };
495 rules.apply_partial(&partial);
496 assert_eq!(rules.type_only_dependencies, Severity::Warn);
498 assert_eq!(rules.test_only_dependencies, Severity::Warn);
499 }
500}