jscpd-rs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/Cargo.lock +1323 -0
  3. package/Cargo.toml +54 -0
  4. package/LICENSE +21 -0
  5. package/README.md +372 -0
  6. package/docs/api-parity.md +49 -0
  7. package/docs/cloning-plan.md +281 -0
  8. package/docs/compat-baseline.md +535 -0
  9. package/docs/format-porting.md +86 -0
  10. package/docs/junior-task-template.md +62 -0
  11. package/docs/junior-workflow.md +87 -0
  12. package/docs/migrating-from-jscpd.md +193 -0
  13. package/docs/npm-release.md +116 -0
  14. package/docs/public-benchmark-suite.md +81 -0
  15. package/docs/release-checklist.md +200 -0
  16. package/docs/release-decisions.md +103 -0
  17. package/docs/release-readiness.md +51 -0
  18. package/docs/upstream-bugs.md +501 -0
  19. package/docs/upstream-issue-drafts.md +393 -0
  20. package/docs/user-guide.md +309 -0
  21. package/examples/dump_oxc_tokens.rs +112 -0
  22. package/examples/library_api.rs +42 -0
  23. package/npm/bin/jscpd-rs.js +6 -0
  24. package/npm/bin/jscpd-server.js +6 -0
  25. package/npm/lib/run-binary.js +68 -0
  26. package/npm/scripts/postinstall.js +50 -0
  27. package/package.json +53 -0
  28. package/skills/dry-refactoring/SKILL.md +63 -0
  29. package/skills/jscpd/SKILL.md +85 -0
  30. package/src/app.rs +512 -0
  31. package/src/bin/jscpd-server.rs +429 -0
  32. package/src/blame.rs +130 -0
  33. package/src/cli/config.rs +543 -0
  34. package/src/cli/parsing.rs +301 -0
  35. package/src/cli/tests.rs +543 -0
  36. package/src/cli.rs +671 -0
  37. package/src/detector/matching/secondary.rs +387 -0
  38. package/src/detector/matching.rs +274 -0
  39. package/src/detector/model.rs +190 -0
  40. package/src/detector/prepare.rs +71 -0
  41. package/src/detector/skip_local.rs +40 -0
  42. package/src/detector/statistics.rs +138 -0
  43. package/src/detector/store.rs +96 -0
  44. package/src/detector/tests.rs +238 -0
  45. package/src/detector.rs +265 -0
  46. package/src/files/discovery.rs +508 -0
  47. package/src/files/gitignore.rs +203 -0
  48. package/src/files/paths.rs +68 -0
  49. package/src/files/shebang.rs +106 -0
  50. package/src/files/tests.rs +523 -0
  51. package/src/files.rs +25 -0
  52. package/src/formats.rs +570 -0
  53. package/src/lib.rs +433 -0
  54. package/src/main.rs +26 -0
  55. package/src/report/ai.rs +125 -0
  56. package/src/report/badge.rs +238 -0
  57. package/src/report/console.rs +180 -0
  58. package/src/report/console_common.rs +37 -0
  59. package/src/report/console_full.rs +139 -0
  60. package/src/report/csv.rs +65 -0
  61. package/src/report/escape.rs +8 -0
  62. package/src/report/file_output.rs +28 -0
  63. package/src/report/html/assets.rs +47 -0
  64. package/src/report/html.rs +336 -0
  65. package/src/report/json.rs +119 -0
  66. package/src/report/markdown.rs +125 -0
  67. package/src/report/sarif.rs +302 -0
  68. package/src/report/silent.rs +22 -0
  69. package/src/report/source.rs +38 -0
  70. package/src/report/summary.rs +50 -0
  71. package/src/report/test_support.rs +133 -0
  72. package/src/report/threshold.rs +76 -0
  73. package/src/report/xcode.rs +90 -0
  74. package/src/report/xml.rs +119 -0
  75. package/src/report.rs +250 -0
  76. package/src/server/mcp.rs +942 -0
  77. package/src/server.rs +1081 -0
  78. package/src/tokenizer/apex.rs +97 -0
  79. package/src/tokenizer/blocks.rs +532 -0
  80. package/src/tokenizer/embedded.rs +106 -0
  81. package/src/tokenizer/generic.rs +511 -0
  82. package/src/tokenizer/hash.rs +27 -0
  83. package/src/tokenizer/ignore.rs +33 -0
  84. package/src/tokenizer/line_index.rs +33 -0
  85. package/src/tokenizer/markdown.rs +289 -0
  86. package/src/tokenizer/markup_attrs.rs +289 -0
  87. package/src/tokenizer/oxc/fallback.rs +275 -0
  88. package/src/tokenizer/oxc/jsx.rs +168 -0
  89. package/src/tokenizer/oxc/kind.rs +177 -0
  90. package/src/tokenizer/oxc/lexical.rs +67 -0
  91. package/src/tokenizer/oxc.rs +659 -0
  92. package/src/tokenizer/scan.rs +88 -0
  93. package/src/tokenizer/tap.rs +150 -0
  94. package/src/tokenizer/tests.rs +915 -0
  95. package/src/tokenizer.rs +328 -0
  96. package/src/verbose.rs +195 -0
@@ -0,0 +1,302 @@
1
+ use std::collections::HashMap;
2
+
3
+ use anyhow::Result;
4
+ use serde::Serialize;
5
+
6
+ use super::file_output::write_file_report;
7
+ use super::source::source_location;
8
+ use crate::cli::Options;
9
+ use crate::detector::DetectionResult;
10
+
11
+ pub(super) fn write(result: &DetectionResult, options: &Options) -> Result<()> {
12
+ let sarif = SarifReport::from_detection(result, options);
13
+ let json = serde_json::to_string(&sarif)?;
14
+ write_file_report(options, "jscpd-sarif.json", "SARIF report", json)
15
+ }
16
+
17
+ #[derive(Serialize)]
18
+ struct SarifReport {
19
+ #[serde(rename = "$schema")]
20
+ schema: &'static str,
21
+ version: &'static str,
22
+ runs: Vec<SarifRun>,
23
+ }
24
+
25
+ #[derive(Serialize)]
26
+ struct SarifRun {
27
+ tool: SarifTool,
28
+ results: Vec<SarifResult>,
29
+ artifacts: Vec<SarifArtifact>,
30
+ }
31
+
32
+ #[derive(Serialize)]
33
+ struct SarifTool {
34
+ driver: SarifDriver,
35
+ }
36
+
37
+ #[derive(Serialize)]
38
+ #[serde(rename_all = "camelCase")]
39
+ struct SarifDriver {
40
+ name: &'static str,
41
+ rules: Vec<SarifRule>,
42
+ version: &'static str,
43
+ information_uri: &'static str,
44
+ }
45
+
46
+ #[derive(Serialize)]
47
+ #[serde(rename_all = "camelCase")]
48
+ struct SarifRule {
49
+ id: &'static str,
50
+ short_description: SarifMessage,
51
+ help_uri: &'static str,
52
+ }
53
+
54
+ #[derive(Serialize)]
55
+ #[serde(rename_all = "camelCase")]
56
+ struct SarifResult {
57
+ level: &'static str,
58
+ message: SarifMessage,
59
+ rule_id: &'static str,
60
+ #[serde(skip_serializing_if = "Vec::is_empty")]
61
+ locations: Vec<SarifLocation>,
62
+ rule_index: usize,
63
+ }
64
+
65
+ #[derive(Serialize)]
66
+ struct SarifMessage {
67
+ text: String,
68
+ }
69
+
70
+ #[derive(Serialize)]
71
+ #[serde(rename_all = "camelCase")]
72
+ struct SarifLocation {
73
+ physical_location: SarifPhysicalLocation,
74
+ }
75
+
76
+ #[derive(Serialize)]
77
+ #[serde(rename_all = "camelCase")]
78
+ struct SarifPhysicalLocation {
79
+ artifact_location: SarifArtifactLocation,
80
+ region: SarifRegion,
81
+ }
82
+
83
+ #[derive(Serialize)]
84
+ struct SarifArtifactLocation {
85
+ uri: String,
86
+ #[serde(skip_serializing_if = "Option::is_none")]
87
+ index: Option<usize>,
88
+ }
89
+
90
+ #[derive(Serialize)]
91
+ #[serde(rename_all = "camelCase")]
92
+ struct SarifRegion {
93
+ start_line: usize,
94
+ start_column: usize,
95
+ end_line: usize,
96
+ end_column: usize,
97
+ }
98
+
99
+ #[derive(Serialize)]
100
+ #[serde(rename_all = "camelCase")]
101
+ struct SarifArtifact {
102
+ source_language: String,
103
+ location: SarifArtifactLocation,
104
+ }
105
+
106
+ impl SarifReport {
107
+ fn from_detection(result: &DetectionResult, options: &Options) -> Self {
108
+ const URL: &str = "https://github.com/kucherenko/jscpd/";
109
+
110
+ let mut artifacts = Vec::new();
111
+ let mut artifact_indices = HashMap::new();
112
+ let mut results = Vec::new();
113
+
114
+ for clone in &result.clones {
115
+ let uri = clone.duplication_a.source_id.clone();
116
+ let artifact_index = *artifact_indices.entry(uri.clone()).or_insert_with(|| {
117
+ let index = artifacts.len();
118
+ artifacts.push(SarifArtifact {
119
+ source_language: sarif_source_language(&clone.format),
120
+ location: SarifArtifactLocation {
121
+ uri: uri.clone(),
122
+ index: None,
123
+ },
124
+ });
125
+ index
126
+ });
127
+
128
+ results.push(SarifResult {
129
+ level: "warning",
130
+ message: SarifMessage {
131
+ text: format!(
132
+ "Clone detected in {}, - {}[{}] and {}[{}]",
133
+ clone.format,
134
+ clone.duplication_a.source_id,
135
+ source_location(&clone.duplication_a.start, &clone.duplication_a.end),
136
+ clone.duplication_b.source_id,
137
+ source_location(&clone.duplication_b.start, &clone.duplication_b.end),
138
+ ),
139
+ },
140
+ rule_id: "duplication",
141
+ locations: vec![SarifLocation {
142
+ physical_location: SarifPhysicalLocation {
143
+ artifact_location: SarifArtifactLocation {
144
+ uri,
145
+ index: Some(artifact_index),
146
+ },
147
+ region: SarifRegion {
148
+ start_line: clone.duplication_a.start.line,
149
+ start_column: clone.duplication_a.start.column,
150
+ end_line: clone.duplication_a.end.line,
151
+ end_column: clone.duplication_a.end.column,
152
+ },
153
+ },
154
+ }],
155
+ rule_index: 0,
156
+ });
157
+ }
158
+
159
+ if result.statistics.total.percentage >= options.threshold.unwrap_or(100.0) {
160
+ results.push(SarifResult {
161
+ level: "error",
162
+ message: SarifMessage {
163
+ text: format!(
164
+ "The duplication level ({}%) is bigger than threshold ({}%)",
165
+ result.statistics.total.percentage,
166
+ options
167
+ .threshold
168
+ .map(|threshold| threshold.to_string())
169
+ .unwrap_or_else(|| "undefined".to_string()),
170
+ ),
171
+ },
172
+ rule_id: "duplications-threshold",
173
+ locations: Vec::new(),
174
+ rule_index: 1,
175
+ });
176
+ }
177
+
178
+ Self {
179
+ schema: "http://json.schemastore.org/sarif-2.1.0.json",
180
+ version: "2.1.0",
181
+ runs: vec![SarifRun {
182
+ tool: SarifTool {
183
+ driver: SarifDriver {
184
+ name: "jscpd",
185
+ rules: vec![
186
+ SarifRule {
187
+ id: "duplication",
188
+ short_description: SarifMessage {
189
+ text: "Found code duplication".to_string(),
190
+ },
191
+ help_uri: URL,
192
+ },
193
+ SarifRule {
194
+ id: "duplications-threshold",
195
+ short_description: SarifMessage {
196
+ text: "Level of duplication is too high".to_string(),
197
+ },
198
+ help_uri: URL,
199
+ },
200
+ ],
201
+ version: "4.2.4",
202
+ information_uri: URL,
203
+ },
204
+ },
205
+ results,
206
+ artifacts,
207
+ }],
208
+ }
209
+ }
210
+ }
211
+
212
+ fn sarif_source_language(format: &str) -> String {
213
+ match format {
214
+ "javascript" => "JavaScript".to_string(),
215
+ "typescript" => "TypeScript".to_string(),
216
+ "jsx" => "JSX".to_string(),
217
+ "tsx" => "TSX".to_string(),
218
+ "css" => "CSS".to_string(),
219
+ "html" | "markup" => "HTML".to_string(),
220
+ "json" => "JSON".to_string(),
221
+ "markdown" => "Markdown".to_string(),
222
+ "rust" => "Rust".to_string(),
223
+ "python" => "Python".to_string(),
224
+ "ruby" => "Ruby".to_string(),
225
+ "go" => "Go".to_string(),
226
+ "java" => "Java".to_string(),
227
+ "csharp" => "C#".to_string(),
228
+ "cpp" => "C++".to_string(),
229
+ "c" => "C".to_string(),
230
+ other => {
231
+ let mut chars = other.chars();
232
+ let Some(first) = chars.next() else {
233
+ return String::new();
234
+ };
235
+ format!("{}{}", first.to_uppercase(), chars.as_str())
236
+ }
237
+ }
238
+ }
239
+
240
+ #[cfg(test)]
241
+ mod tests {
242
+ use super::*;
243
+ use crate::report::test_support::{make_test_result_with_clone, write_test_report};
244
+
245
+ #[test]
246
+ fn sarif_report_matches_upstream_shape() {
247
+ let result = make_test_result_with_clone("src/a.js", "src/b.js");
248
+ let report = SarifReport::from_detection(&result, &Options::default());
249
+ let json = serde_json::to_value(report).unwrap();
250
+
251
+ assert_eq!(
252
+ json["$schema"],
253
+ "http://json.schemastore.org/sarif-2.1.0.json"
254
+ );
255
+ assert_eq!(json["version"], "2.1.0");
256
+ assert_eq!(json["runs"][0]["tool"]["driver"]["name"], "jscpd");
257
+ assert_eq!(
258
+ json["runs"][0]["tool"]["driver"]["rules"][0]["id"],
259
+ "duplication"
260
+ );
261
+ assert_eq!(
262
+ json["runs"][0]["results"][0]["message"]["text"],
263
+ "Clone detected in javascript, - src/a.js[2:3 - 5:1] and src/b.js[8:1 - 11:1]"
264
+ );
265
+ assert_eq!(
266
+ json["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]["index"],
267
+ 0
268
+ );
269
+ assert_eq!(
270
+ json["runs"][0]["artifacts"][0]["sourceLanguage"],
271
+ "JavaScript"
272
+ );
273
+ }
274
+
275
+ #[test]
276
+ fn sarif_report_includes_threshold_result_like_upstream() {
277
+ let mut result = make_test_result_with_clone("src/a.js", "src/b.js");
278
+ result.statistics.total.percentage = 25.0;
279
+ let options = Options {
280
+ threshold: Some(10.0),
281
+ ..Options::default()
282
+ };
283
+ let report = SarifReport::from_detection(&result, &options);
284
+ let json = serde_json::to_value(report).unwrap();
285
+
286
+ assert_eq!(json["runs"][0]["results"][1]["level"], "error");
287
+ assert_eq!(
288
+ json["runs"][0]["results"][1]["message"]["text"],
289
+ "The duplication level (25%) is bigger than threshold (10%)"
290
+ );
291
+ assert!(json["runs"][0]["results"][1]["locations"].is_null());
292
+ }
293
+
294
+ #[test]
295
+ fn write_reports_writes_sarif_report() {
296
+ let sarif = write_test_report("sarif", "sarif-report", &["jscpd-sarif.json"]);
297
+ let json: serde_json::Value = serde_json::from_str(&sarif).unwrap();
298
+
299
+ assert_eq!(json["version"], "2.1.0");
300
+ assert_eq!(json["runs"][0]["results"][0]["ruleId"], "duplication");
301
+ }
302
+ }
@@ -0,0 +1,22 @@
1
+ use super::summary::silent_summary;
2
+ use crate::detector::DetectionResult;
3
+
4
+ pub(super) fn write(result: &DetectionResult) {
5
+ println!("{}", silent_summary(result));
6
+ }
7
+
8
+ #[cfg(test)]
9
+ mod tests {
10
+ use super::*;
11
+ use crate::report::test_support::make_test_result_with_clone;
12
+
13
+ #[test]
14
+ fn silent_reporter_matches_upstream_summary_shape() {
15
+ let result = make_test_result_with_clone("src/a.js", "src/b.js");
16
+
17
+ assert_eq!(
18
+ silent_summary(&result),
19
+ "Duplications detection: Found 1 exact clones with 5(25%) duplicated lines in 2 (1 formats) files."
20
+ );
21
+ }
22
+ }
@@ -0,0 +1,38 @@
1
+ use std::path::Path;
2
+
3
+ use crate::detector::{DetectionResult, Fragment};
4
+ use crate::tokenizer::Location;
5
+
6
+ pub(super) fn slice_range(content: &str, range: [usize; 2]) -> String {
7
+ let start = range[0].min(content.len());
8
+ let end = range[1].min(content.len());
9
+ content.get(start..end).unwrap_or_default().to_string()
10
+ }
11
+
12
+ pub(super) fn clone_fragment(result: &DetectionResult, fragment: &Fragment) -> String {
13
+ result
14
+ .source_contents
15
+ .get(&fragment.source_id)
16
+ .map(|content| slice_range(content, fragment.range))
17
+ .unwrap_or_default()
18
+ }
19
+
20
+ pub(super) fn source_location(start: &Location, end: &Location) -> String {
21
+ format!(
22
+ "{}:{} - {}:{}",
23
+ start.line, start.column, end.line, end.column
24
+ )
25
+ }
26
+
27
+ pub(super) fn absolute_report_path(source_id: &str) -> String {
28
+ let path = Path::new(source_id);
29
+ if path.is_absolute() {
30
+ source_id.to_string()
31
+ } else {
32
+ std::env::current_dir()
33
+ .unwrap_or_else(|_| Path::new(".").to_path_buf())
34
+ .join(path)
35
+ .display()
36
+ .to_string()
37
+ }
38
+ }
@@ -0,0 +1,50 @@
1
+ use crate::detector::{DetectionResult, StatisticRow, Statistics};
2
+
3
+ pub(super) fn summary_rows(statistics: &Statistics) -> Vec<[String; 7]> {
4
+ let mut rows = vec![summary_header_row()];
5
+ let mut formats = statistics.formats.iter().collect::<Vec<_>>();
6
+ formats.sort_by_key(|(format, _)| *format);
7
+ for (format, statistic) in formats {
8
+ rows.push(statistic_to_summary_row(format, &statistic.total));
9
+ }
10
+ rows.push(statistic_to_summary_row("Total:", &statistics.total));
11
+ rows
12
+ }
13
+
14
+ fn summary_header_row() -> [String; 7] {
15
+ [
16
+ "Format".to_string(),
17
+ "Files analyzed".to_string(),
18
+ "Total lines".to_string(),
19
+ "Total tokens".to_string(),
20
+ "Clones found".to_string(),
21
+ "Duplicated lines".to_string(),
22
+ "Duplicated tokens".to_string(),
23
+ ]
24
+ }
25
+
26
+ pub(super) fn statistic_to_summary_row(format: &str, statistic: &StatisticRow) -> [String; 7] {
27
+ [
28
+ format.to_string(),
29
+ statistic.sources.to_string(),
30
+ statistic.lines.to_string(),
31
+ statistic.tokens.to_string(),
32
+ statistic.clones.to_string(),
33
+ format!("{} ({}%)", statistic.duplicated_lines, statistic.percentage),
34
+ format!(
35
+ "{} ({}%)",
36
+ statistic.duplicated_tokens, statistic.percentage_tokens
37
+ ),
38
+ ]
39
+ }
40
+
41
+ pub(super) fn silent_summary(result: &DetectionResult) -> String {
42
+ format!(
43
+ "Duplications detection: Found {} exact clones with {}({}%) duplicated lines in {} ({} formats) files.",
44
+ result.clones.len(),
45
+ result.statistics.total.duplicated_lines,
46
+ result.statistics.total.percentage,
47
+ result.statistics.total.sources,
48
+ result.statistics.formats.len(),
49
+ )
50
+ }
@@ -0,0 +1,133 @@
1
+ use std::collections::{BTreeMap, HashMap};
2
+ use std::path::PathBuf;
3
+
4
+ use crate::cli::Options;
5
+ use crate::detector::{
6
+ BlamedLine, CloneMatch, DetectionResult, FormatStatistic, Fragment, StatisticRow,
7
+ };
8
+ use crate::report::write_reports;
9
+ use crate::tokenizer::Location;
10
+
11
+ pub(super) fn make_test_statistics() -> crate::detector::Statistics {
12
+ let mut formats = HashMap::new();
13
+ formats.insert(
14
+ "javascript".to_string(),
15
+ FormatStatistic {
16
+ sources: HashMap::new(),
17
+ total: test_statistic_row(),
18
+ },
19
+ );
20
+ crate::detector::Statistics {
21
+ total: test_statistic_row(),
22
+ formats,
23
+ }
24
+ }
25
+
26
+ fn test_statistic_row() -> StatisticRow {
27
+ StatisticRow {
28
+ sources: 2,
29
+ lines: 20,
30
+ tokens: 100,
31
+ clones: 1,
32
+ duplicated_lines: 5,
33
+ duplicated_tokens: 30,
34
+ percentage: 25.0,
35
+ percentage_tokens: 30.0,
36
+ new_duplicated_lines: 0,
37
+ new_clones: 0,
38
+ }
39
+ }
40
+
41
+ pub(super) fn make_test_clone(source_a: &str, source_b: &str) -> CloneMatch {
42
+ CloneMatch {
43
+ format: "javascript".to_string(),
44
+ duplication_a: Fragment {
45
+ source_id: source_a.to_string(),
46
+ start: location(2, 3, 0),
47
+ end: location(5, 1, 18),
48
+ range: [0, 18],
49
+ blame: None,
50
+ },
51
+ duplication_b: Fragment {
52
+ source_id: source_b.to_string(),
53
+ start: location(8, 1, 0),
54
+ end: location(11, 1, 18),
55
+ range: [0, 18],
56
+ blame: None,
57
+ },
58
+ tokens: 6,
59
+ }
60
+ }
61
+
62
+ pub(super) fn make_test_result_with_clone(source_a: &str, source_b: &str) -> DetectionResult {
63
+ let mut source_contents = HashMap::new();
64
+ source_contents.insert(source_a.to_string(), "alpha <beta> ]]>\n".to_string());
65
+ source_contents.insert(source_b.to_string(), "alpha & beta\nxxxx\n".to_string());
66
+
67
+ DetectionResult {
68
+ clones: vec![make_test_clone(source_a, source_b)],
69
+ skipped_clones: Vec::new(),
70
+ statistics: make_test_statistics(),
71
+ sources: Vec::new(),
72
+ source_contents,
73
+ }
74
+ }
75
+
76
+ pub(super) fn temp_output(label: &str) -> PathBuf {
77
+ let nonce = std::time::SystemTime::now()
78
+ .duration_since(std::time::UNIX_EPOCH)
79
+ .unwrap()
80
+ .as_nanos();
81
+ std::env::temp_dir().join(format!("jscpd-rs-{label}-{}-{nonce}", std::process::id()))
82
+ }
83
+
84
+ pub(super) fn write_test_report(reporter: &str, label: &str, path: &[&str]) -> String {
85
+ let output = write_test_report_output(reporter, label);
86
+ let report_path = path
87
+ .iter()
88
+ .fold(output.clone(), |path, segment| path.join(segment));
89
+ let report = std::fs::read_to_string(report_path).unwrap();
90
+ let _ = std::fs::remove_dir_all(output);
91
+ report
92
+ }
93
+
94
+ pub(super) fn write_test_report_output(reporter: &str, label: &str) -> PathBuf {
95
+ let output = temp_output(label);
96
+ let options = Options {
97
+ output: output.clone(),
98
+ reporters: vec![reporter.to_string()],
99
+ silent: true,
100
+ ..Options::default()
101
+ };
102
+ let result = make_test_result_with_clone("src/a.js", "src/b.js");
103
+
104
+ write_reports(&result, &options).unwrap();
105
+ output
106
+ }
107
+
108
+ pub(super) fn single_line_blame(
109
+ line: &str,
110
+ rev: &str,
111
+ author: &str,
112
+ date: &str,
113
+ ) -> BTreeMap<String, BlamedLine> {
114
+ [(
115
+ line.to_string(),
116
+ BlamedLine {
117
+ rev: rev.to_string(),
118
+ author: author.to_string(),
119
+ date: date.to_string(),
120
+ line: line.to_string(),
121
+ },
122
+ )]
123
+ .into_iter()
124
+ .collect()
125
+ }
126
+
127
+ fn location(line: usize, column: usize, position: usize) -> Location {
128
+ Location {
129
+ line,
130
+ column,
131
+ position,
132
+ }
133
+ }
@@ -0,0 +1,76 @@
1
+ use std::error::Error;
2
+ use std::fmt;
3
+
4
+ use anyhow::Result;
5
+
6
+ use crate::cli::Options;
7
+ use crate::detector::DetectionResult;
8
+
9
+ pub(super) fn write(result: &DetectionResult, options: &Options) -> Result<()> {
10
+ let Some(threshold) = options.threshold else {
11
+ return Ok(());
12
+ };
13
+ if threshold < result.statistics.total.percentage {
14
+ return Err(ThresholdExceeded::new(format!(
15
+ "ERROR: jscpd found too many duplicates ({}%) over threshold ({}%)",
16
+ result.statistics.total.percentage, threshold,
17
+ ))
18
+ .into());
19
+ }
20
+ Ok(())
21
+ }
22
+
23
+ #[derive(Debug)]
24
+ pub struct ThresholdExceeded {
25
+ message: String,
26
+ }
27
+
28
+ impl ThresholdExceeded {
29
+ fn new(message: String) -> Self {
30
+ Self { message }
31
+ }
32
+
33
+ pub fn message(&self) -> &str {
34
+ &self.message
35
+ }
36
+ }
37
+
38
+ impl fmt::Display for ThresholdExceeded {
39
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40
+ formatter.write_str(&self.message)
41
+ }
42
+ }
43
+
44
+ impl Error for ThresholdExceeded {}
45
+
46
+ #[cfg(test)]
47
+ mod tests {
48
+ use super::*;
49
+ use crate::report::test_support::make_test_result_with_clone;
50
+ use crate::report::write_reports;
51
+
52
+ #[test]
53
+ fn threshold_reporter_uses_strictly_greater_percentage_like_upstream() {
54
+ let mut result = make_test_result_with_clone("src/a.js", "src/b.js");
55
+ result.statistics.total.percentage = 25.0;
56
+
57
+ let equal = Options {
58
+ threshold: Some(25.0),
59
+ reporters: vec!["threshold".to_string()],
60
+ ..Options::default()
61
+ };
62
+ assert!(write_reports(&result, &equal).is_ok());
63
+
64
+ let below = Options {
65
+ threshold: Some(24.9),
66
+ reporters: vec!["threshold".to_string()],
67
+ ..Options::default()
68
+ };
69
+ let error = write_reports(&result, &below).unwrap_err();
70
+ assert!(error.downcast_ref::<ThresholdExceeded>().is_some());
71
+ assert_eq!(
72
+ error.to_string(),
73
+ "ERROR: jscpd found too many duplicates (25%) over threshold (24.9%)"
74
+ );
75
+ }
76
+ }