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 PathAppearsInString,
313}
314
315impl Reason {
316 pub fn penalty(self) -> u8 {
318 match self {
319 Self::DynamicAccess => 30,
320 Self::NameAppearsInString => 35,
321 Self::Decorated => 40,
322 Self::PackageSurface => 20,
323 Self::PublicApi => 60,
324 Self::Overrides => 45,
325 Self::Abstract => 50,
326 Self::UsedOnlyByTests => 25,
327 Self::InTestFile => 15,
328 Self::UnparsedModule => 20,
329 Self::AmbiguousName => 25,
330 Self::WildcardReExport => 30,
331 Self::NameReadAsAttribute => 35,
332 Self::PathAppearsInString => 40,
336 }
337 }
338
339 pub fn explain(self) -> &'static str {
341 match self {
342 Self::DynamicAccess => "file resolves names at runtime",
343 Self::NameAppearsInString => "name appears in a string literal",
344 Self::Decorated => "carries an unrecognised decorator",
345 Self::PackageSurface => "re-exported from a package index",
346 Self::PublicApi => "part of the package's published API",
347 Self::Overrides => "overrides an inherited member",
348 Self::Abstract => "abstract declaration",
349 Self::UsedOnlyByTests => "only referenced by tests",
350 Self::InTestFile => "declared in a test file",
351 Self::UnparsedModule => "a file in the scan did not parse",
352 Self::AmbiguousName => "the name is declared more than once",
353 Self::WildcardReExport => "reached through a wildcard re-export",
354 Self::NameReadAsAttribute => "the name is read as an attribute elsewhere",
355 Self::PathAppearsInString => "its path appears in a string literal",
356 }
357 }
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
362#[serde(rename_all = "camelCase")]
363pub struct Report {
364 pub findings: Vec<Finding>,
365 pub statistics: Stats,
366}
367
368#[derive(Debug, Clone, Default, Serialize, Deserialize)]
370#[serde(rename_all = "camelCase")]
371pub struct Stats {
372 pub files: u32,
374 pub unparsed: u32,
376 #[serde(default, skip_serializing_if = "Vec::is_empty")]
378 pub unparsed_files: Vec<String>,
379 pub reachable_files: u32,
381 pub symbols: u32,
383 pub entry_points: u32,
385 #[serde(default)]
387 pub by_category: Vec<CategoryCount>,
388 pub dead_lines: u32,
390 pub total_lines: u32,
392 pub percentage: f64,
394 pub detection_date: String,
396}
397
398impl Stats {
399 pub fn total_findings(&self) -> u32 {
401 self.by_category.iter().map(|c| c.count).sum()
402 }
403
404 pub fn count_of(&self, category: Category) -> u32 {
405 self.by_category
406 .iter()
407 .find(|c| c.category == category)
408 .map_or(0, |c| c.count)
409 }
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
413#[serde(rename_all = "camelCase")]
414pub struct CategoryCount {
415 #[serde(with = "category_serde")]
416 pub category: Category,
417 pub count: u32,
418 pub lines: u32,
419}
420
421mod category_serde {
425 use super::Category;
426 use serde::{Deserialize, Deserializer, Serializer};
427
428 pub fn serialize<S: Serializer>(c: &Category, s: S) -> Result<S::Ok, S::Error> {
429 s.serialize_str(c.as_str())
430 }
431
432 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Category, D::Error> {
433 let raw = String::deserialize(d)?;
434 raw.parse().map_err(serde::de::Error::custom)
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 fn finding(category: Category, confidence: u8) -> Finding {
443 Finding {
444 category,
445 path: "src/a.ts".into(),
446 name: "foo".into(),
447 exported_as: None,
448 symbol_kind: Some(SymbolKind::Function),
449 parent: None,
450 language: "js".into(),
451 start: Location {
452 line: 3,
453 column: 0,
454 offset: 20,
455 },
456 end: Location {
457 line: 6,
458 column: 1,
459 offset: 60,
460 },
461 lines: 4,
462 confidence,
463 reasons: Vec::new(),
464 message: "message".into(),
465 }
466 }
467
468 #[test]
469 fn confidence_buckets_have_no_gaps() {
470 assert_eq!(ConfidenceLevel::of(100), ConfidenceLevel::Certain);
471 assert_eq!(ConfidenceLevel::of(90), ConfidenceLevel::Certain);
472 assert_eq!(ConfidenceLevel::of(89), ConfidenceLevel::High);
473 assert_eq!(ConfidenceLevel::of(75), ConfidenceLevel::High);
474 assert_eq!(ConfidenceLevel::of(74), ConfidenceLevel::Medium);
475 assert_eq!(ConfidenceLevel::of(50), ConfidenceLevel::Medium);
476 assert_eq!(ConfidenceLevel::of(49), ConfidenceLevel::Low);
477 assert_eq!(ConfidenceLevel::of(0), ConfidenceLevel::Low);
478 }
479
480 #[test]
481 fn fingerprint_ignores_line_numbers() {
482 let mut a = finding(Category::UnusedExport, 90);
483 let mut b = a.clone();
484 b.start.line = 400;
485 b.end.line = 410;
486 assert_eq!(a.fingerprint(), b.fingerprint());
487 a.name = "bar".into();
488 assert_ne!(a.fingerprint(), b.fingerprint());
489 }
490
491 #[test]
492 fn category_round_trips_through_json() {
493 let f = finding(Category::UnusedMember, 80);
494 let json = serde_json::to_string(&f).unwrap();
495 assert!(json.contains("\"unused-member\""), "{json}");
496 let back: Finding = serde_json::from_str(&json).unwrap();
497 assert_eq!(back.category, Category::UnusedMember);
498 }
499
500 #[test]
501 fn stats_sum_findings_across_categories() {
502 let stats = Stats {
503 by_category: vec![
504 CategoryCount {
505 category: Category::UnusedExport,
506 count: 3,
507 lines: 30,
508 },
509 CategoryCount {
510 category: Category::UnusedImport,
511 count: 5,
512 lines: 5,
513 },
514 ],
515 ..Stats::default()
516 };
517 assert_eq!(stats.total_findings(), 8);
518 assert_eq!(stats.count_of(Category::UnusedExport), 3);
519 assert_eq!(stats.count_of(Category::UnusedFile), 0);
520 }
521}