1use super::structured::escape_xml;
5use super::{DeadCodeContext, DeadCodeReporter, reasons_clause, severity};
6use crate::reporter::{ReporterError, ReporterOptions};
7use crate::shared::{Style, write_report_file};
8use cpd_core::deadcode::{Category, Finding};
9use serde_json::{Value, json};
10use std::path::Path;
11
12pub struct SarifReporter {
14 style: Style,
15 tool_version: String,
16}
17
18impl SarifReporter {
19 pub fn new(options: &ReporterOptions) -> Self {
20 Self {
21 style: Style::new(options.no_colors),
22 tool_version: options.tool_version.clone(),
23 }
24 }
25}
26
27impl DeadCodeReporter for SarifReporter {
28 fn name(&self) -> &str {
29 "sarif"
30 }
31
32 fn report(
33 &self,
34 findings: &[Finding],
35 _ctx: &DeadCodeContext<'_>,
36 output_dir: &Path,
37 ) -> Result<(), ReporterError> {
38 let rules: Vec<Value> = Category::ALL
42 .iter()
43 .map(|category| {
44 json!({
45 "id": category.as_str(),
46 "name": category.title(),
47 "shortDescription": { "text": category.title() },
48 "fullDescription": { "text": rule_description(*category) },
49 "defaultConfiguration": { "level": "warning" },
50 "properties": { "tags": ["dead-code", "maintainability"] }
51 })
52 })
53 .collect();
54
55 let results: Vec<Value> = findings
56 .iter()
57 .map(|finding| {
58 let mut result = json!({
59 "ruleId": finding.category.as_str(),
60 "level": severity(finding).sarif(),
61 "message": { "text": finding.message },
62 "locations": [{
63 "physicalLocation": {
64 "artifactLocation": { "uri": finding.path },
65 "region": {
66 "startLine": finding.start.line.max(1),
67 "startColumn": finding.start.column + 1,
68 "endLine": finding.end.line.max(finding.start.line).max(1)
69 }
70 }
71 }],
72 "partialFingerprints": { "bastaFingerprint/v1": finding.fingerprint() },
73 "properties": {
74 "confidence": finding.confidence,
75 "level": finding.level().as_str()
76 }
77 });
78 let reasons = reasons_clause(finding);
79 if !reasons.is_empty() {
80 result["properties"]["reasons"] = json!(reasons);
81 }
82 result
83 })
84 .collect();
85
86 let sarif = json!({
87 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
88 "version": "2.1.0",
89 "runs": [{
90 "tool": { "driver": {
91 "name": "basta",
92 "informationUri": "https://jscpd.dev",
93 "version": self.tool_version,
94 "rules": rules
95 }},
96 "results": results
97 }]
98 });
99 let json = serde_json::to_string_pretty(&sarif)
100 .map_err(|e| ReporterError::Format(e.to_string()))?;
101 write_report_file(
102 output_dir,
103 "basta-report.sarif",
104 json,
105 &self.style,
106 "Dead code SARIF",
107 )?;
108 Ok(())
109 }
110}
111
112fn rule_description(category: Category) -> &'static str {
113 match category {
114 Category::UnusedFile => {
115 "A file that no entry point reaches through the import graph. Nothing runs it."
116 }
117 Category::UnusedExport => "An exported declaration that no module in the project imports.",
118 Category::UnusedSymbol => {
119 "A module-private declaration that nothing in its own module reaches."
120 }
121 Category::UnusedImport => "An import binding with no references in the file that made it.",
122 Category::UnusedMember => {
123 "A class or enum member whose name is never read anywhere in the project."
124 }
125 }
126}
127
128pub struct CodeClimateReporter {
130 style: Style,
131}
132
133impl CodeClimateReporter {
134 pub fn new(options: &ReporterOptions) -> Self {
135 Self {
136 style: Style::new(options.no_colors),
137 }
138 }
139}
140
141impl DeadCodeReporter for CodeClimateReporter {
142 fn name(&self) -> &str {
143 "codeclimate"
144 }
145
146 fn report(
147 &self,
148 findings: &[Finding],
149 _ctx: &DeadCodeContext<'_>,
150 output_dir: &Path,
151 ) -> Result<(), ReporterError> {
152 let issues: Vec<Value> = findings
153 .iter()
154 .map(|finding| {
155 json!({
156 "type": "issue",
157 "check_name": format!("basta/{}", finding.category.as_str()),
158 "description": finding.message,
159 "categories": ["Complexity"],
160 "fingerprint": finding.fingerprint(),
161 "severity": severity(finding).codeclimate(),
162 "location": {
163 "path": finding.path,
164 "lines": {
165 "begin": finding.start.line.max(1),
166 "end": finding.end.line.max(finding.start.line).max(1)
167 }
168 }
169 })
170 })
171 .collect();
172 let json = serde_json::to_string_pretty(&issues)
173 .map_err(|e| ReporterError::Format(e.to_string()))?;
174 write_report_file(
175 output_dir,
176 "basta-codeclimate.json",
177 json,
178 &self.style,
179 "Dead code Code Climate",
180 )?;
181 Ok(())
182 }
183}
184
185pub struct OpenMetricsReporter {
187 style: Style,
188}
189
190impl OpenMetricsReporter {
191 pub fn new(options: &ReporterOptions) -> Self {
192 Self {
193 style: Style::new(options.no_colors),
194 }
195 }
196}
197
198impl DeadCodeReporter for OpenMetricsReporter {
199 fn name(&self) -> &str {
200 "openmetrics"
201 }
202
203 fn report(
204 &self,
205 findings: &[Finding],
206 ctx: &DeadCodeContext<'_>,
207 output_dir: &Path,
208 ) -> Result<(), ReporterError> {
209 let stats = ctx.stats;
210 let mut out = String::new();
211 out.push_str("# TYPE basta_findings gauge\n");
212 out.push_str("# HELP basta_findings Dead code findings by category.\n");
213 for category in Category::ALL {
214 out.push_str(&format!(
215 "basta_findings{{category=\"{}\"}} {}\n",
216 category.as_str(),
217 stats.count_of(*category)
218 ));
219 }
220 for (name, help, value) in [
221 (
222 "basta_findings_total",
223 "Dead code findings.",
224 findings.len() as f64,
225 ),
226 ("basta_files", "Files analyzed.", f64::from(stats.files)),
227 (
228 "basta_files_reachable",
229 "Files an entry point reaches.",
230 f64::from(stats.reachable_files),
231 ),
232 (
233 "basta_files_unparsed",
234 "Files that could not be parsed.",
235 f64::from(stats.unparsed),
236 ),
237 (
238 "basta_entry_points",
239 "Entry points detected.",
240 f64::from(stats.entry_points),
241 ),
242 (
243 "basta_dead_lines",
244 "Lines covered by findings.",
245 f64::from(stats.dead_lines),
246 ),
247 (
248 "basta_total_lines",
249 "Lines analyzed.",
250 f64::from(stats.total_lines),
251 ),
252 (
253 "basta_dead_percentage",
254 "Dead lines as a percentage of lines analyzed.",
255 stats.percentage,
256 ),
257 ] {
258 out.push_str(&format!(
259 "# TYPE {name} gauge\n# HELP {name} {help}\n{name} {value}\n"
260 ));
261 }
262 out.push_str("# EOF\n");
263 write_report_file(
264 output_dir,
265 "basta-report.txt",
266 out,
267 &self.style,
268 "Dead code OpenMetrics",
269 )?;
270 Ok(())
271 }
272}
273
274pub struct BadgeReporter {
276 style: Style,
277}
278
279impl BadgeReporter {
280 pub fn new(options: &ReporterOptions) -> Self {
281 Self {
282 style: Style::new(options.no_colors),
283 }
284 }
285}
286
287impl DeadCodeReporter for BadgeReporter {
288 fn name(&self) -> &str {
289 "badge"
290 }
291
292 fn report(
293 &self,
294 findings: &[Finding],
295 _ctx: &DeadCodeContext<'_>,
296 output_dir: &Path,
297 ) -> Result<(), ReporterError> {
298 let count = findings.len();
299 let value = if count == 0 {
300 "none".to_string()
301 } else {
302 count.to_string()
303 };
304 let color = match count {
305 0 => "#4c1",
306 1..=10 => "#dfb317",
307 _ => "#e05d44",
308 };
309 let label = "dead code";
310 let label_width = label.len() * 6 + 10;
313 let value_width = value.len() * 7 + 10;
314 let total = label_width + value_width;
315 let svg = format!(
316 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20" role="img" aria-label="{label}: {value}">
317 <title>{label}: {value}</title>
318 <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
319 <clipPath id="r"><rect width="{total}" height="20" rx="3" fill="#fff"/></clipPath>
320 <g clip-path="url(#r)">
321 <rect width="{label_width}" height="20" fill="#555"/>
322 <rect x="{label_width}" width="{value_width}" height="20" fill="{color}"/>
323 <rect width="{total}" height="20" fill="url(#s)"/>
324 </g>
325 <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
326 <text x="{label_x}" y="15" fill="#010101" fill-opacity=".3">{label}</text>
327 <text x="{label_x}" y="14">{label}</text>
328 <text x="{value_x}" y="15" fill="#010101" fill-opacity=".3">{value}</text>
329 <text x="{value_x}" y="14">{value}</text>
330 </g>
331</svg>
332"##,
333 label = escape_xml(label),
334 value = escape_xml(&value),
335 label_x = label_width / 2,
336 value_x = label_width + value_width / 2,
337 );
338 write_report_file(
339 output_dir,
340 "basta-badge.svg",
341 svg,
342 &self.style,
343 "Dead code badge",
344 )?;
345 Ok(())
346 }
347}
348
349pub struct XcodeReporter;
351
352impl XcodeReporter {
353 pub fn new(_options: &ReporterOptions) -> Self {
354 Self
355 }
356}
357
358impl DeadCodeReporter for XcodeReporter {
359 fn name(&self) -> &str {
360 "xcode"
361 }
362
363 fn report(
364 &self,
365 findings: &[Finding],
366 _ctx: &DeadCodeContext<'_>,
367 _output_dir: &Path,
368 ) -> Result<(), ReporterError> {
369 for finding in findings {
370 println!(
371 "{}:{}:{}: {}: {} [basta.{}]",
372 finding.path,
373 finding.start.line.max(1),
374 finding.start.column + 1,
375 severity(finding).xcode(),
376 finding.message,
377 finding.category.as_str(),
378 );
379 }
380 Ok(())
381 }
382}
383
384pub struct ThresholdReporter {
386 threshold: Option<f64>,
387}
388
389impl ThresholdReporter {
390 pub fn new(options: &ReporterOptions) -> Self {
391 Self {
392 threshold: options.threshold,
393 }
394 }
395}
396
397impl DeadCodeReporter for ThresholdReporter {
398 fn name(&self) -> &str {
399 "threshold"
400 }
401
402 fn report(
403 &self,
404 _findings: &[Finding],
405 ctx: &DeadCodeContext<'_>,
406 _output_dir: &Path,
407 ) -> Result<(), ReporterError> {
408 let Some(threshold) = self.threshold else {
409 return Ok(());
410 };
411 if ctx.stats.percentage > threshold {
412 return Err(ReporterError::ThresholdExceeded {
413 actual: ctx.stats.percentage,
414 threshold,
415 });
416 }
417 Ok(())
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::super::fixtures;
424 use super::*;
425 use std::path::PathBuf;
426 use std::time::Duration;
427
428 fn write(name: &str, reporter: &dyn DeadCodeReporter, file: &str) -> String {
429 let dir = fixtures::unique_dir(name);
430 let stats = fixtures::stats();
431 let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
432 reporter
433 .report(&fixtures::findings(), &ctx, &dir)
434 .expect("report");
435 let content = std::fs::read_to_string(dir.join(file)).unwrap();
436 std::fs::remove_dir_all(&dir).ok();
437 content
438 }
439
440 fn options() -> ReporterOptions {
441 ReporterOptions::new(PathBuf::from("/tmp"))
442 }
443
444 #[test]
445 fn sarif_declares_every_rule_and_one_result_per_finding() {
446 let sarif = write(
447 "sarif",
448 &SarifReporter::new(&options()),
449 "basta-report.sarif",
450 );
451 let value: Value = serde_json::from_str(&sarif).expect("valid JSON");
452 assert_eq!(value["version"], "2.1.0");
453 let run = &value["runs"][0];
454 assert_eq!(run["tool"]["driver"]["name"], "basta");
455 assert_eq!(
456 run["tool"]["driver"]["rules"].as_array().unwrap().len(),
457 Category::ALL.len(),
458 "a category with no findings still needs its rule, or its alerts never resolve"
459 );
460 let results = run["results"].as_array().unwrap();
461 assert_eq!(results.len(), 3);
462 assert_eq!(results[0]["ruleId"], "unused-file");
463 assert!(results[0]["partialFingerprints"]["bastaFingerprint/v1"].is_string());
464 }
465
466 #[test]
467 fn sarif_regions_are_one_based_and_never_zero() {
468 let mut finding = fixtures::finding(Category::UnusedFile, "a.ts", "", 95);
469 finding.start = fixtures::location(0);
470 finding.end = fixtures::location(0);
471 let dir = fixtures::unique_dir("sarif-zero");
472 let stats = fixtures::stats();
473 let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
474 SarifReporter::new(&options())
475 .report(&[finding], &ctx, &dir)
476 .unwrap();
477 let value: Value =
478 serde_json::from_str(&std::fs::read_to_string(dir.join("basta-report.sarif")).unwrap())
479 .unwrap();
480 let region = &value["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"];
481 assert_eq!(region["startLine"], 1, "SARIF lines start at 1");
482 assert_eq!(region["startColumn"], 1);
483 std::fs::remove_dir_all(&dir).ok();
484 }
485
486 #[test]
487 fn codeclimate_issues_carry_a_stable_fingerprint() {
488 let json = write(
489 "cc",
490 &CodeClimateReporter::new(&options()),
491 "basta-codeclimate.json",
492 );
493 let issues: Vec<Value> = serde_json::from_str(&json).unwrap();
494 assert_eq!(issues.len(), 3);
495 assert_eq!(issues[0]["type"], "issue");
496 assert!(
497 issues[0]["check_name"]
498 .as_str()
499 .unwrap()
500 .starts_with("basta/")
501 );
502 assert!(issues[0]["fingerprint"].is_string());
503 }
504
505 #[test]
506 fn openmetrics_emits_one_series_per_category_and_ends_with_eof() {
507 let text = write(
508 "om",
509 &OpenMetricsReporter::new(&options()),
510 "basta-report.txt",
511 );
512 for category in Category::ALL {
513 assert!(
514 text.contains(&format!("category=\"{}\"", category.as_str())),
515 "{text}"
516 );
517 }
518 assert!(text.contains("basta_findings_total 3"), "{text}");
519 assert!(
520 text.ends_with("# EOF\n"),
521 "OpenMetrics requires the EOF marker"
522 );
523 }
524
525 #[test]
526 fn the_badge_changes_colour_with_the_count() {
527 let clean = badge_for(&[]);
528 assert!(
529 clean.contains("#4c1") && clean.contains(">none<"),
530 "{clean}"
531 );
532 let some = badge_for(&fixtures::findings());
533 assert!(some.contains("#dfb317") && some.contains(">3<"), "{some}");
534 }
535
536 fn badge_for(findings: &[Finding]) -> String {
537 let dir = fixtures::unique_dir("badge");
538 let stats = fixtures::stats();
539 let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
540 BadgeReporter::new(&options())
541 .report(findings, &ctx, &dir)
542 .unwrap();
543 let svg = std::fs::read_to_string(dir.join("basta-badge.svg")).unwrap();
544 std::fs::remove_dir_all(&dir).ok();
545 svg
546 }
547
548 #[test]
549 fn the_threshold_reporter_fails_only_above_its_limit() {
550 let stats = fixtures::stats(); let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
552 let mut options = options();
553
554 options.threshold = Some(5.0);
555 assert!(
556 ThresholdReporter::new(&options)
557 .report(&[], &ctx, Path::new("/tmp"))
558 .is_ok()
559 );
560
561 options.threshold = Some(1.0);
562 let error = ThresholdReporter::new(&options)
563 .report(&[], &ctx, Path::new("/tmp"))
564 .unwrap_err();
565 assert!(matches!(error, ReporterError::ThresholdExceeded { .. }));
566
567 options.threshold = None;
568 assert!(
569 ThresholdReporter::new(&options)
570 .report(&[], &ctx, Path::new("/tmp"))
571 .is_ok(),
572 "without a threshold there is nothing to exceed"
573 );
574 }
575
576 #[test]
577 fn xcode_output_does_not_panic_on_a_zero_line_finding() {
578 let mut finding = fixtures::finding(Category::UnusedFile, "a.ts", "", 95);
579 finding.start = fixtures::location(0);
580 let stats = fixtures::stats();
581 let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
582 assert!(
583 XcodeReporter
584 .report(&[finding], &ctx, Path::new("/tmp"))
585 .is_ok()
586 );
587 }
588}