1use crate::models::Location;
9use serde::{Deserialize, Serialize};
10use std::str::FromStr;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum Category {
19 UnusedFile,
21 UnusedExport,
23 UnusedSymbol,
25 UnusedImport,
27 UnusedMember,
29}
30
31impl Category {
32 pub const ALL: &'static [Category] = &[
33 Category::UnusedFile,
34 Category::UnusedExport,
35 Category::UnusedSymbol,
36 Category::UnusedImport,
37 Category::UnusedMember,
38 ];
39
40 pub const DEFAULT: &'static [Category] = &[
43 Category::UnusedFile,
44 Category::UnusedExport,
45 Category::UnusedSymbol,
46 Category::UnusedImport,
47 ];
48
49 pub fn as_str(self) -> &'static str {
50 match self {
51 Self::UnusedFile => "unused-file",
52 Self::UnusedExport => "unused-export",
53 Self::UnusedSymbol => "unused-symbol",
54 Self::UnusedImport => "unused-import",
55 Self::UnusedMember => "unused-member",
56 }
57 }
58
59 pub fn title(self) -> &'static str {
61 match self {
62 Self::UnusedFile => "Unused files",
63 Self::UnusedExport => "Unused exports",
64 Self::UnusedSymbol => "Unused symbols",
65 Self::UnusedImport => "Unused imports",
66 Self::UnusedMember => "Unused members",
67 }
68 }
69}
70
71impl FromStr for Category {
72 type Err = String;
73
74 fn from_str(s: &str) -> Result<Self, Self::Err> {
75 match normalize(s).as_str() {
78 "unused-file" | "unused-files" | "files" | "file" => Ok(Self::UnusedFile),
79 "unused-export" | "unused-exports" | "exports" | "export" => Ok(Self::UnusedExport),
80 "unused-symbol" | "unused-symbols" | "symbols" | "symbol" => Ok(Self::UnusedSymbol),
81 "unused-import" | "unused-imports" | "imports" | "import" => Ok(Self::UnusedImport),
82 "unused-member" | "unused-members" | "members" | "member" => Ok(Self::UnusedMember),
83 other => Err(format!(
84 "unknown category '{other}': expected one of unused-file, unused-export, \
85 unused-symbol, unused-import, unused-member"
86 )),
87 }
88 }
89}
90
91fn normalize(s: &str) -> String {
94 let mut out = String::with_capacity(s.len() + 2);
95 let mut prev_lower = false;
96 for ch in s.trim().chars() {
97 match ch {
98 '_' | ' ' | '-' => {
99 if !out.ends_with('-') && !out.is_empty() {
100 out.push('-');
101 }
102 prev_lower = false;
103 }
104 c if c.is_ascii_uppercase() => {
105 if prev_lower {
106 out.push('-');
107 }
108 out.push(c.to_ascii_lowercase());
109 prev_lower = false;
110 }
111 c => {
112 out.push(c);
113 prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
114 }
115 }
116 }
117 out
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub enum SymbolKind {
126 Function,
127 Class,
128 Method,
129 Property,
131 Interface,
132 TypeAlias,
133 Enum,
134 EnumMember,
135 Variable,
136 Import,
138 ReExport,
141}
142
143impl SymbolKind {
144 pub fn noun(self) -> &'static str {
146 match self {
147 Self::Function => "function",
148 Self::Class => "class",
149 Self::Method => "method",
150 Self::Property => "property",
151 Self::Interface => "interface",
152 Self::TypeAlias => "type",
153 Self::Enum => "enum",
154 Self::EnumMember => "enum member",
155 Self::Variable => "variable",
156 Self::Import => "import",
157 Self::ReExport => "re-export",
158 }
159 }
160
161 pub fn is_member(self) -> bool {
165 matches!(self, Self::Method | Self::Property | Self::EnumMember)
166 }
167
168 pub fn is_type_only(self) -> bool {
172 matches!(self, Self::Interface | Self::TypeAlias)
173 }
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct Finding {
180 #[serde(with = "category_serde")]
182 pub category: Category,
183 pub path: String,
185 pub name: String,
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub exported_as: Option<String>,
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub symbol_kind: Option<SymbolKind>,
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub parent: Option<String>,
196 pub language: String,
200 pub start: Location,
201 pub end: Location,
202 pub lines: u32,
204 pub confidence: u8,
206 #[serde(default, skip_serializing_if = "Vec::is_empty")]
208 pub reasons: Vec<Reason>,
209 pub message: String,
211}
212
213impl Finding {
214 pub fn level(&self) -> ConfidenceLevel {
216 ConfidenceLevel::of(self.confidence)
217 }
218
219 pub fn fingerprint(&self) -> String {
223 let parent = self.parent.as_deref().unwrap_or("");
224 format!(
225 "{}:{}:{}:{}",
226 self.category.as_str(),
227 self.path,
228 parent,
229 if self.name.is_empty() {
230 "-"
231 } else {
232 &self.name
233 }
234 )
235 }
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
240#[serde(rename_all = "lowercase")]
241pub enum ConfidenceLevel {
242 Low,
243 Medium,
244 High,
245 Certain,
246}
247
248impl ConfidenceLevel {
249 pub fn of(score: u8) -> Self {
250 match score {
251 90..=u8::MAX => Self::Certain,
252 75..=89 => Self::High,
253 50..=74 => Self::Medium,
254 _ => Self::Low,
255 }
256 }
257
258 pub fn as_str(self) -> &'static str {
259 match self {
260 Self::Low => "low",
261 Self::Medium => "medium",
262 Self::High => "high",
263 Self::Certain => "certain",
264 }
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(rename_all = "kebab-case")]
273pub enum Reason {
274 DynamicAccess,
277 NameAppearsInString,
280 Decorated,
282 PackageSurface,
285 PublicApi,
287 Overrides,
289 Abstract,
291 UsedOnlyByTests,
294 InTestFile,
296 UnparsedModule,
298 AmbiguousName,
301 WildcardReExport,
304 NameReadAsAttribute,
308}
309
310impl Reason {
311 pub fn penalty(self) -> u8 {
313 match self {
314 Self::DynamicAccess => 30,
315 Self::NameAppearsInString => 35,
316 Self::Decorated => 40,
317 Self::PackageSurface => 20,
318 Self::PublicApi => 60,
319 Self::Overrides => 45,
320 Self::Abstract => 50,
321 Self::UsedOnlyByTests => 25,
322 Self::InTestFile => 15,
323 Self::UnparsedModule => 20,
324 Self::AmbiguousName => 25,
325 Self::WildcardReExport => 30,
326 Self::NameReadAsAttribute => 35,
327 }
328 }
329
330 pub fn explain(self) -> &'static str {
332 match self {
333 Self::DynamicAccess => "file resolves names at runtime",
334 Self::NameAppearsInString => "name appears in a string literal",
335 Self::Decorated => "carries an unrecognised decorator",
336 Self::PackageSurface => "re-exported from a package index",
337 Self::PublicApi => "part of the package's published API",
338 Self::Overrides => "overrides an inherited member",
339 Self::Abstract => "abstract declaration",
340 Self::UsedOnlyByTests => "only referenced by tests",
341 Self::InTestFile => "declared in a test file",
342 Self::UnparsedModule => "a file in the scan did not parse",
343 Self::AmbiguousName => "the name is declared more than once",
344 Self::WildcardReExport => "reached through a wildcard re-export",
345 Self::NameReadAsAttribute => "the name is read as an attribute elsewhere",
346 }
347 }
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
352#[serde(rename_all = "camelCase")]
353pub struct Report {
354 pub findings: Vec<Finding>,
355 pub statistics: Stats,
356}
357
358#[derive(Debug, Clone, Default, Serialize, Deserialize)]
360#[serde(rename_all = "camelCase")]
361pub struct Stats {
362 pub files: u32,
364 pub unparsed: u32,
366 #[serde(default, skip_serializing_if = "Vec::is_empty")]
368 pub unparsed_files: Vec<String>,
369 pub reachable_files: u32,
371 pub symbols: u32,
373 pub entry_points: u32,
375 #[serde(default)]
377 pub by_category: Vec<CategoryCount>,
378 pub dead_lines: u32,
380 pub total_lines: u32,
382 pub percentage: f64,
384 pub detection_date: String,
386}
387
388impl Stats {
389 pub fn total_findings(&self) -> u32 {
391 self.by_category.iter().map(|c| c.count).sum()
392 }
393
394 pub fn count_of(&self, category: Category) -> u32 {
395 self.by_category
396 .iter()
397 .find(|c| c.category == category)
398 .map_or(0, |c| c.count)
399 }
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize)]
403#[serde(rename_all = "camelCase")]
404pub struct CategoryCount {
405 #[serde(with = "category_serde")]
406 pub category: Category,
407 pub count: u32,
408 pub lines: u32,
409}
410
411mod category_serde {
415 use super::Category;
416 use serde::{Deserialize, Deserializer, Serializer};
417
418 pub fn serialize<S: Serializer>(c: &Category, s: S) -> Result<S::Ok, S::Error> {
419 s.serialize_str(c.as_str())
420 }
421
422 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Category, D::Error> {
423 let raw = String::deserialize(d)?;
424 raw.parse().map_err(serde::de::Error::custom)
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 fn finding(category: Category, confidence: u8) -> Finding {
433 Finding {
434 category,
435 path: "src/a.ts".into(),
436 name: "foo".into(),
437 exported_as: None,
438 symbol_kind: Some(SymbolKind::Function),
439 parent: None,
440 language: "js".into(),
441 start: Location {
442 line: 3,
443 column: 0,
444 offset: 20,
445 },
446 end: Location {
447 line: 6,
448 column: 1,
449 offset: 60,
450 },
451 lines: 4,
452 confidence,
453 reasons: Vec::new(),
454 message: "message".into(),
455 }
456 }
457
458 #[test]
459 fn confidence_buckets_have_no_gaps() {
460 assert_eq!(ConfidenceLevel::of(100), ConfidenceLevel::Certain);
461 assert_eq!(ConfidenceLevel::of(90), ConfidenceLevel::Certain);
462 assert_eq!(ConfidenceLevel::of(89), ConfidenceLevel::High);
463 assert_eq!(ConfidenceLevel::of(75), ConfidenceLevel::High);
464 assert_eq!(ConfidenceLevel::of(74), ConfidenceLevel::Medium);
465 assert_eq!(ConfidenceLevel::of(50), ConfidenceLevel::Medium);
466 assert_eq!(ConfidenceLevel::of(49), ConfidenceLevel::Low);
467 assert_eq!(ConfidenceLevel::of(0), ConfidenceLevel::Low);
468 }
469
470 #[test]
471 fn fingerprint_ignores_line_numbers() {
472 let mut a = finding(Category::UnusedExport, 90);
473 let mut b = a.clone();
474 b.start.line = 400;
475 b.end.line = 410;
476 assert_eq!(a.fingerprint(), b.fingerprint());
477 a.name = "bar".into();
478 assert_ne!(a.fingerprint(), b.fingerprint());
479 }
480
481 #[test]
482 fn category_round_trips_through_json() {
483 let f = finding(Category::UnusedMember, 80);
484 let json = serde_json::to_string(&f).unwrap();
485 assert!(json.contains("\"unused-member\""), "{json}");
486 let back: Finding = serde_json::from_str(&json).unwrap();
487 assert_eq!(back.category, Category::UnusedMember);
488 }
489
490 #[test]
491 fn stats_sum_findings_across_categories() {
492 let stats = Stats {
493 by_category: vec![
494 CategoryCount {
495 category: Category::UnusedExport,
496 count: 3,
497 lines: 30,
498 },
499 CategoryCount {
500 category: Category::UnusedImport,
501 count: 5,
502 lines: 5,
503 },
504 ],
505 ..Stats::default()
506 };
507 assert_eq!(stats.total_findings(), 8);
508 assert_eq!(stats.count_of(Category::UnusedExport), 3);
509 assert_eq!(stats.count_of(Category::UnusedFile), 0);
510 }
511}