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,543 @@
1
+ use super::{
2
+ BARE_CONFIG_VALUE, Cli, ExitCode, FileConfig, Mode, Options, apply_config,
3
+ apply_gitignore_patterns_from, normalize_reporters, parse_format_mappings, parse_js_number,
4
+ parse_js_usize, parse_size, resolve_config_ignore, resolve_node_exit_code, store_warning,
5
+ };
6
+ use clap::{CommandFactory, Parser};
7
+
8
+ #[test]
9
+ fn parses_size_suffixes() {
10
+ assert_eq!(parse_size("1b").unwrap(), 1);
11
+ assert_eq!(parse_size("100kb").unwrap(), 102400);
12
+ assert_eq!(parse_size("100KB").unwrap(), 102400);
13
+ assert_eq!(parse_size("2mb").unwrap(), 2 * 1024 * 1024);
14
+ assert_eq!(parse_size("1024").unwrap(), 1024);
15
+ assert_eq!(parse_size("1.5kb").unwrap(), 1536);
16
+ assert_eq!(parse_size("1.5 kb").unwrap(), 1536);
17
+ assert_eq!(parse_size("1.1kb").unwrap(), 1126);
18
+ assert_eq!(parse_size("+1kb").unwrap(), 1024);
19
+ assert_eq!(parse_size("1tb").unwrap(), 1024_u64.pow(4));
20
+ assert_eq!(
21
+ parse_size("1.5tb").unwrap(),
22
+ 1024_u64.pow(4) + 1024_u64.pow(4) / 2
23
+ );
24
+ assert_eq!(parse_size("1pb").unwrap(), 1024_u64.pow(5));
25
+ assert_eq!(parse_size("1k").unwrap(), 1);
26
+ assert_eq!(parse_size("1m").unwrap(), 1);
27
+ assert_eq!(parse_size("1 kb extra").unwrap(), 1);
28
+ assert_eq!(parse_size(".5kb").unwrap(), 0);
29
+ assert_eq!(parse_size("nope").unwrap(), 0);
30
+ assert_eq!(parse_size("-1mb").unwrap(), 0);
31
+ }
32
+
33
+ #[test]
34
+ fn parses_cli_integer_flags_like_upstream_parse_int() {
35
+ assert_eq!(parse_js_usize("1.5").unwrap(), 1);
36
+ assert_eq!(parse_js_usize("20.9tokens").unwrap(), 20);
37
+ assert_eq!(parse_js_usize("+1000.9").unwrap(), 1000);
38
+ assert_eq!(parse_js_usize("0x10").unwrap(), 16);
39
+ assert!(parse_js_usize(".5").is_err());
40
+ assert!(parse_js_usize("nope").is_err());
41
+ assert!(parse_js_usize("-1").is_err());
42
+ }
43
+
44
+ #[test]
45
+ fn parses_threshold_like_upstream_number() {
46
+ assert_eq!(parse_js_number("7").unwrap(), 7.0);
47
+ assert_eq!(parse_js_number("7.5").unwrap(), 7.5);
48
+ assert_eq!(parse_js_number("0x10").unwrap(), 16.0);
49
+ assert_eq!(parse_js_number("0b10").unwrap(), 2.0);
50
+ assert_eq!(parse_js_number("").unwrap(), 0.0);
51
+ assert!(parse_js_number("nope").unwrap().is_nan());
52
+ assert!(parse_js_number("true").unwrap().is_nan());
53
+ }
54
+
55
+ #[test]
56
+ fn parses_exit_code_values_like_node_process_exit() {
57
+ assert_eq!(
58
+ resolve_node_exit_code(&ExitCode::String("7".to_string())).unwrap(),
59
+ 7
60
+ );
61
+ assert_eq!(
62
+ resolve_node_exit_code(&ExitCode::String("007".to_string())).unwrap(),
63
+ 7
64
+ );
65
+ assert_eq!(
66
+ resolve_node_exit_code(&ExitCode::String("0x10".to_string())).unwrap(),
67
+ 16
68
+ );
69
+ assert_eq!(
70
+ resolve_node_exit_code(&ExitCode::String("0b10".to_string())).unwrap(),
71
+ 2
72
+ );
73
+ assert_eq!(
74
+ resolve_node_exit_code(&ExitCode::String("-1".to_string())).unwrap(),
75
+ -1
76
+ );
77
+ assert_eq!(
78
+ resolve_node_exit_code(&ExitCode::Boolean(false)).unwrap(),
79
+ 0
80
+ );
81
+ assert_eq!(
82
+ resolve_node_exit_code(&ExitCode::String("7.5".to_string())).unwrap_err(),
83
+ "RangeError [ERR_OUT_OF_RANGE]: The value of \"code\" is out of range. It must be an integer. Received 7.5"
84
+ );
85
+ assert_eq!(
86
+ resolve_node_exit_code(&ExitCode::String("nope".to_string())).unwrap_err(),
87
+ "TypeError [ERR_INVALID_ARG_TYPE]: The \"code\" argument must be of type number. Received type string ('nope')"
88
+ );
89
+ assert_eq!(
90
+ resolve_node_exit_code(&ExitCode::Boolean(true)).unwrap_err(),
91
+ "TypeError [ERR_INVALID_ARG_TYPE]: The \"code\" argument must be of type number. Received type boolean (true)"
92
+ );
93
+ }
94
+
95
+ #[test]
96
+ fn accepts_missing_cli_integer_values_like_upstream() {
97
+ let cli = Cli::parse_from([
98
+ "jscpd-rs",
99
+ ".",
100
+ "--min-lines",
101
+ "--min-tokens",
102
+ "--max-lines",
103
+ ]);
104
+ let options = Options::from_cli(cli).unwrap();
105
+
106
+ assert_eq!(options.min_lines, 0);
107
+ assert_eq!(options.min_tokens, 50);
108
+ assert_eq!(options.max_lines, usize::MAX);
109
+ }
110
+
111
+ #[test]
112
+ fn accepts_bare_optional_cli_values_that_upstream_continues_with() {
113
+ let cli = Cli::parse_from([
114
+ "jscpd-rs",
115
+ ".",
116
+ "--threshold",
117
+ "--max-size",
118
+ "--output",
119
+ "--pattern",
120
+ "--store",
121
+ "--store-path",
122
+ "--exitCode",
123
+ ]);
124
+ let options = Options::from_cli(cli).unwrap();
125
+
126
+ assert_eq!(options.threshold, Some(1.0));
127
+ assert_eq!(options.max_size_bytes, 0);
128
+ assert_eq!(options.output, std::path::PathBuf::from("true"));
129
+ assert!(options.output_is_bare);
130
+ assert_eq!(options.pattern, "true");
131
+ assert_eq!(options.store.as_deref(), Some("true"));
132
+ assert_eq!(
133
+ options.store_path.as_deref(),
134
+ Some(std::path::Path::new("true"))
135
+ );
136
+ assert_eq!(options.exit_code, ExitCode::Boolean(true));
137
+ }
138
+
139
+ #[test]
140
+ fn accepts_bare_config_during_cli_parse_then_matches_upstream_runtime_error() {
141
+ let cli = Cli::parse_from(["jscpd-rs", "--config"]);
142
+ assert_eq!(
143
+ cli.config.as_deref(),
144
+ Some(std::path::Path::new(BARE_CONFIG_VALUE))
145
+ );
146
+
147
+ let error = Options::from_cli(cli).unwrap_err();
148
+ assert_eq!(
149
+ error.to_string(),
150
+ "TypeError [ERR_INVALID_ARG_TYPE]: The \"paths[0]\" argument must be of type string. Received type boolean (true)"
151
+ );
152
+ }
153
+
154
+ #[test]
155
+ fn bare_optional_string_flags_match_upstream_runtime_errors() {
156
+ for (flag, expected) in [
157
+ ("--ignore", "TypeError: cli.ignore.split is not a function"),
158
+ (
159
+ "--ignore-pattern",
160
+ "TypeError: cli.ignorePattern.split is not a function",
161
+ ),
162
+ (
163
+ "--reporters",
164
+ "TypeError: cli.reporters.split is not a function",
165
+ ),
166
+ ("--mode", "TypeError: mode is not a function"),
167
+ ("--format", "TypeError: cli.format.split is not a function"),
168
+ (
169
+ "--formats-exts",
170
+ "TypeError: extensions.split is not a function",
171
+ ),
172
+ (
173
+ "--formats-names",
174
+ "TypeError: extensions.split is not a function",
175
+ ),
176
+ ] {
177
+ let cli = Cli::parse_from(["jscpd-rs", ".", flag]);
178
+ let error = Options::from_cli(cli).unwrap_err();
179
+ assert_eq!(error.to_string(), expected, "{flag}");
180
+ }
181
+ }
182
+
183
+ #[test]
184
+ fn repeated_value_flags_match_upstream_last_value_wins_behavior() {
185
+ let cli = Cli::parse_from([
186
+ "jscpd-rs",
187
+ ".",
188
+ "--ignore",
189
+ "first/**",
190
+ "--ignore",
191
+ "second/**",
192
+ "--reporters",
193
+ "json",
194
+ "--reporters",
195
+ "silent",
196
+ ]);
197
+ let options = Options::from_cli(cli).unwrap();
198
+
199
+ assert!(options.ignore.contains(&"second/**".to_string()));
200
+ assert!(!options.ignore.contains(&"first/**".to_string()));
201
+ assert_eq!(options.reporters, vec!["silent"]);
202
+ }
203
+
204
+ #[test]
205
+ fn malformed_cli_format_mappings_match_upstream_runtime_error() {
206
+ for flag in ["--formats-exts", "--formats-names"] {
207
+ let cli = Cli::parse_from(["jscpd-rs", ".", flag, "javascript"]);
208
+ let error = Options::from_cli(cli).unwrap_err();
209
+
210
+ assert_eq!(
211
+ error.to_string(),
212
+ "TypeError: Cannot read properties of undefined (reading 'split')",
213
+ "{flag}"
214
+ );
215
+ }
216
+ }
217
+
218
+ #[test]
219
+ fn help_output_keeps_upstream_cli_contract_text() {
220
+ let mut command = Cli::command();
221
+ let mut output = Vec::new();
222
+ command.write_long_help(&mut output).unwrap();
223
+ let help = String::from_utf8(output).unwrap();
224
+
225
+ assert!(help.contains("detector of copy/paste in files"));
226
+ assert!(help.contains("Usage: jscpd [options] <path ...>"));
227
+ assert!(help.contains("min size of duplication in code lines (Default is 5)"));
228
+ assert!(help.contains("reporters or list of reporters separated with comma"));
229
+ assert!(help.contains("ignore comments during detection (alias for --mode weak)"));
230
+ assert!(help.contains("output the version number"));
231
+ assert!(!help.contains("[possible values: strict, mild, weak]"));
232
+ }
233
+
234
+ #[test]
235
+ fn parses_version_flag_for_upstream_output_shape() {
236
+ let cli = Cli::parse_from(["jscpd-rs", "--version"]);
237
+
238
+ assert!(cli.version);
239
+ }
240
+
241
+ #[test]
242
+ fn parses_format_mappings() {
243
+ let mappings = parse_format_mappings("javascript:js,ts;python:py");
244
+ assert_eq!(mappings.find_format_for_value("ts"), Some("javascript"));
245
+ assert_eq!(mappings.find_format_for_value("py"), Some("python"));
246
+ assert_eq!(mappings.find_format_for_value("rs"), None);
247
+ }
248
+
249
+ #[test]
250
+ fn preserves_cli_format_order_for_debug_output_like_upstream() {
251
+ let cli = Cli::parse_from(["jscpd-rs", "--format", "typescript,javascript", "."]);
252
+ let options = Options::from_cli(cli).unwrap();
253
+
254
+ assert_eq!(
255
+ options.format_order.as_deref(),
256
+ Some(["typescript".to_string(), "javascript".to_string()].as_slice())
257
+ );
258
+ assert!(options.formats.as_ref().unwrap().contains("typescript"));
259
+ assert!(options.formats.as_ref().unwrap().contains("javascript"));
260
+ }
261
+
262
+ #[test]
263
+ fn appends_cwd_gitignore_patterns_to_debug_option_surface_like_upstream() {
264
+ let nonce = std::time::SystemTime::now()
265
+ .duration_since(std::time::UNIX_EPOCH)
266
+ .unwrap()
267
+ .as_nanos();
268
+ let dir = std::env::temp_dir().join(format!(
269
+ "jscpd-rs-cli-cwd-gitignore-{}-{nonce}",
270
+ std::process::id()
271
+ ));
272
+ std::fs::create_dir_all(&dir).unwrap();
273
+ std::fs::write(dir.join(".gitignore"), "/target/\nreport\n").unwrap();
274
+
275
+ let mut options = Options::default();
276
+ apply_gitignore_patterns_from(&mut options, &dir);
277
+
278
+ assert_eq!(
279
+ options.ignore,
280
+ vec![
281
+ "target".to_string(),
282
+ "target/**".to_string(),
283
+ "**/report".to_string(),
284
+ "**/report/**".to_string()
285
+ ]
286
+ );
287
+
288
+ std::fs::remove_dir_all(dir).unwrap();
289
+ }
290
+
291
+ #[test]
292
+ fn config_format_mappings_preserve_upstream_object_order() {
293
+ let config: FileConfig = serde_json::from_str(
294
+ r#"{
295
+ "formatsExts": {
296
+ "first": ["dup"],
297
+ "second": ["dup"]
298
+ },
299
+ "formatsNames": {
300
+ "name-first": ["Samefile"],
301
+ "name-second": ["Samefile"]
302
+ }
303
+ }"#,
304
+ )
305
+ .unwrap();
306
+ let mut options = Options::default();
307
+
308
+ apply_config(&mut options, config, std::path::Path::new(".")).unwrap();
309
+
310
+ assert_eq!(
311
+ options.formats_exts.find_format_for_value("dup"),
312
+ Some("first")
313
+ );
314
+ assert_eq!(
315
+ options.formats_names.find_format_for_value("Samefile"),
316
+ Some("name-first")
317
+ );
318
+ }
319
+
320
+ #[test]
321
+ fn config_format_order_is_preserved_for_debug_output_like_upstream() {
322
+ let config: FileConfig =
323
+ serde_json::from_str(r#"{ "format": "typescript,javascript" }"#).unwrap();
324
+ let mut options = Options::default();
325
+
326
+ apply_config(&mut options, config, std::path::Path::new(".")).unwrap();
327
+
328
+ assert_eq!(
329
+ options.format_order.as_deref(),
330
+ Some(["typescript".to_string(), "javascript".to_string()].as_slice())
331
+ );
332
+ }
333
+
334
+ #[test]
335
+ fn config_accepts_string_numbers_that_upstream_coerces() {
336
+ let config: FileConfig = serde_json::from_str(
337
+ r#"{
338
+ "minLines": "0x3",
339
+ "maxLines": "1000",
340
+ "threshold": "0x10"
341
+ }"#,
342
+ )
343
+ .unwrap();
344
+ let mut options = Options::default();
345
+
346
+ apply_config(&mut options, config, std::path::Path::new(".")).unwrap();
347
+
348
+ assert_eq!(options.min_lines, 3);
349
+ assert_eq!(options.max_lines, 1000);
350
+ assert_eq!(options.threshold, Some(16.0));
351
+ }
352
+
353
+ #[test]
354
+ fn default_execution_id_matches_upstream_shape() {
355
+ let options = Options::default();
356
+ let execution_id = options.execution_id.as_deref().unwrap();
357
+
358
+ assert!(execution_id.ends_with('Z'));
359
+ assert!(
360
+ regex::Regex::new(r"^\d{4}-\d{2}-\d{2}T")
361
+ .unwrap()
362
+ .is_match(execution_id)
363
+ );
364
+ }
365
+
366
+ #[test]
367
+ fn default_path_matches_upstream_cwd() {
368
+ let options = Options::default();
369
+
370
+ assert_eq!(options.paths, vec![std::env::current_dir().unwrap()]);
371
+ }
372
+
373
+ #[test]
374
+ fn normalizes_silent_reporter_like_upstream() {
375
+ let mut options = Options {
376
+ silent: true,
377
+ reporters: vec!["console".to_string(), "json".to_string()],
378
+ ..Options::default()
379
+ };
380
+
381
+ normalize_reporters(&mut options);
382
+
383
+ assert_eq!(options.reporters, vec!["json", "silent"]);
384
+
385
+ let mut duplicate = Options {
386
+ silent: true,
387
+ reporters: vec!["silent".to_string()],
388
+ ..Options::default()
389
+ };
390
+ normalize_reporters(&mut duplicate);
391
+ assert_eq!(duplicate.reporters, vec!["silent", "silent"]);
392
+ }
393
+
394
+ #[test]
395
+ fn normalizes_threshold_reporter_like_upstream() {
396
+ let mut options = Options {
397
+ threshold: Some(10.0),
398
+ reporters: vec!["json".to_string()],
399
+ ..Options::default()
400
+ };
401
+
402
+ normalize_reporters(&mut options);
403
+
404
+ assert_eq!(options.reporters, vec!["json", "threshold"]);
405
+
406
+ let mut duplicate = Options {
407
+ threshold: Some(10.0),
408
+ reporters: vec!["threshold".to_string()],
409
+ ..Options::default()
410
+ };
411
+ normalize_reporters(&mut duplicate);
412
+ assert_eq!(duplicate.reporters, vec!["threshold", "threshold"]);
413
+ }
414
+
415
+ #[test]
416
+ fn parses_upstream_workflow_options() {
417
+ let cli = Cli::parse_from([
418
+ "jscpd-rs",
419
+ "--blame",
420
+ "--store",
421
+ "leveldb",
422
+ "--store-path",
423
+ ".jscpd-cache",
424
+ "--noTips",
425
+ ".",
426
+ ]);
427
+ let options = Options::from_cli(cli).unwrap();
428
+
429
+ assert!(options.blame);
430
+ assert_eq!(options.store.as_deref(), Some("leveldb"));
431
+ assert_eq!(
432
+ options.store_path.as_deref(),
433
+ Some(std::path::Path::new(".jscpd-cache"))
434
+ );
435
+ assert!(options.no_tips);
436
+
437
+ let config: FileConfig = serde_json::from_str(
438
+ r#"{
439
+ "executionId": "run-1",
440
+ "store": "leveldb",
441
+ "storePath": "cache",
442
+ "blame": true,
443
+ "cache": false,
444
+ "noTips": true,
445
+ "listeners": ["console"],
446
+ "tokensToSkip": ["comment", "block-comment"],
447
+ "exitCode": "0x10",
448
+ "reportersOptions": {
449
+ "badge": {
450
+ "subject": "Duplication"
451
+ }
452
+ }
453
+ }"#,
454
+ )
455
+ .unwrap();
456
+ let mut options = Options::default();
457
+ apply_config(&mut options, config, std::path::Path::new(".")).unwrap();
458
+
459
+ assert_eq!(options.execution_id.as_deref(), Some("run-1"));
460
+ assert_eq!(options.store.as_deref(), Some("leveldb"));
461
+ assert_eq!(
462
+ options.store_path.as_deref(),
463
+ Some(std::path::Path::new("cache"))
464
+ );
465
+ assert!(options.blame);
466
+ assert!(!options.cache);
467
+ assert!(options.no_tips);
468
+ assert_eq!(options.exit_code, ExitCode::String("0x10".to_string()));
469
+ assert_eq!(options.listeners, vec!["console"]);
470
+ assert_eq!(options.tokens_to_skip, vec!["comment", "block-comment"]);
471
+ assert_eq!(
472
+ options.reporters_options["badge"]["subject"].as_str(),
473
+ Some("Duplication")
474
+ );
475
+ }
476
+
477
+ #[test]
478
+ fn store_warning_matches_upstream_missing_store_fallback() {
479
+ let options = Options {
480
+ store: Some("leveldb".to_string()),
481
+ ..Options::default()
482
+ };
483
+
484
+ assert_eq!(
485
+ store_warning(&options).as_deref(),
486
+ Some("store name leveldb not installed.")
487
+ );
488
+
489
+ assert!(store_warning(&Options::default()).is_none());
490
+ }
491
+
492
+ #[test]
493
+ fn resolves_config_ignore_relative_to_config_dir() {
494
+ let cwd = std::env::current_dir().unwrap();
495
+ let config_dir = cwd.join("configs").join("nested");
496
+
497
+ assert_eq!(
498
+ resolve_config_ignore(&config_dir, "dist/**".to_string()).unwrap(),
499
+ "configs/nested/dist/**"
500
+ );
501
+ assert_eq!(
502
+ resolve_config_ignore(&config_dir, "**/generated/**".to_string()).unwrap(),
503
+ "**/generated/**"
504
+ );
505
+ }
506
+
507
+ #[test]
508
+ fn config_output_stays_cwd_relative_like_upstream() {
509
+ let config: FileConfig = serde_json::from_str(r#"{ "output": "nested-report" }"#).unwrap();
510
+ let mut options = Options::default();
511
+
512
+ apply_config(
513
+ &mut options,
514
+ config,
515
+ std::path::Path::new("/repo/configs/nested"),
516
+ )
517
+ .unwrap();
518
+
519
+ assert_eq!(options.output, std::path::PathBuf::from("nested-report"));
520
+ }
521
+
522
+ #[test]
523
+ fn skip_comments_does_not_override_explicit_mode() {
524
+ let cli = Cli::parse_from(["jscpd-rs", "--skipComments", "."]);
525
+ let options = Options::from_cli(cli).unwrap();
526
+ assert_eq!(options.mode, Mode::Weak);
527
+
528
+ let cli = Cli::parse_from(["jscpd-rs", "--mode", "strict", "--skipComments", "."]);
529
+ let options = Options::from_cli(cli).unwrap();
530
+ assert_eq!(options.mode, Mode::Strict);
531
+
532
+ let cli = Cli::parse_from(["jscpd-rs", "--mode", "mild", "--skipComments", "."]);
533
+ let options = Options::from_cli(cli).unwrap();
534
+ assert_eq!(options.mode, Mode::Mild);
535
+ }
536
+
537
+ #[test]
538
+ fn invalid_mode_reports_upstream_error_after_cli_parsing() {
539
+ let cli = Cli::parse_from(["jscpd-rs", "--mode", "zzz", "."]);
540
+ let error = Options::from_cli(cli).unwrap_err();
541
+
542
+ assert_eq!(error.to_string(), "Mode zzz does not supported yet.");
543
+ }