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 std::fs;
2
+ use std::path::{Component, Path, PathBuf};
3
+
4
+ use anyhow::{Context, Result, bail};
5
+ use serde::Deserialize;
6
+ use serde::de::{Error as DeError, MapAccess, Visitor};
7
+
8
+ use super::parsing::{
9
+ compile_patterns, parse_format_mappings, parse_js_number, parse_size, split_csv,
10
+ };
11
+ use super::{ExitCode, FormatMappings, Options};
12
+
13
+ #[derive(Debug, Default, Deserialize)]
14
+ #[serde(rename_all = "camelCase")]
15
+ pub(super) struct FileConfig {
16
+ execution_id: Option<String>,
17
+ path: Option<OneOrMany>,
18
+ pattern: Option<String>,
19
+ ignore: Option<OneOrMany>,
20
+ reporters: Option<OneOrMany>,
21
+ listeners: Option<OneOrMany>,
22
+ reporters_options: Option<serde_json::Map<String, serde_json::Value>>,
23
+ output: Option<PathBuf>,
24
+ format: Option<OneOrMany>,
25
+ formats_exts: Option<FormatMappingsConfig>,
26
+ formats_names: Option<FormatMappingsConfig>,
27
+ ignore_pattern: Option<OneOrMany>,
28
+ #[serde(default, deserialize_with = "deserialize_optional_usize_or_string")]
29
+ min_lines: Option<usize>,
30
+ min_tokens: Option<usize>,
31
+ #[serde(default, deserialize_with = "deserialize_optional_usize_or_string")]
32
+ max_lines: Option<usize>,
33
+ max_size: Option<String>,
34
+ #[serde(default, deserialize_with = "deserialize_optional_f64_or_string")]
35
+ threshold: Option<f64>,
36
+ mode: Option<String>,
37
+ store: Option<String>,
38
+ store_path: Option<PathBuf>,
39
+ blame: Option<bool>,
40
+ cache: Option<bool>,
41
+ silent: Option<bool>,
42
+ absolute: Option<bool>,
43
+ no_symlinks: Option<bool>,
44
+ ignore_case: Option<bool>,
45
+ gitignore: Option<bool>,
46
+ debug: Option<bool>,
47
+ verbose: Option<bool>,
48
+ skip_local: Option<bool>,
49
+ exit_code: Option<ExitCodeConfig>,
50
+ no_tips: Option<bool>,
51
+ tokens_to_skip: Option<OneOrMany>,
52
+ }
53
+
54
+ #[derive(Debug, Deserialize)]
55
+ #[serde(untagged)]
56
+ enum OneOrMany {
57
+ One(String),
58
+ Many(Vec<String>),
59
+ }
60
+
61
+ impl OneOrMany {
62
+ fn into_vec(self) -> Vec<String> {
63
+ match self {
64
+ Self::One(value) => split_csv(&value),
65
+ Self::Many(values) => values,
66
+ }
67
+ }
68
+ }
69
+
70
+ #[derive(Debug, Deserialize)]
71
+ #[serde(untagged)]
72
+ enum ExitCodeConfig {
73
+ Boolean(bool),
74
+ Number(f64),
75
+ String(String),
76
+ }
77
+
78
+ impl From<ExitCodeConfig> for ExitCode {
79
+ fn from(value: ExitCodeConfig) -> Self {
80
+ match value {
81
+ ExitCodeConfig::Boolean(value) => Self::Boolean(value),
82
+ ExitCodeConfig::Number(value) => Self::Number(value),
83
+ ExitCodeConfig::String(value) => Self::String(value),
84
+ }
85
+ }
86
+ }
87
+
88
+ fn deserialize_optional_usize_or_string<'de, D>(
89
+ deserializer: D,
90
+ ) -> std::result::Result<Option<usize>, D::Error>
91
+ where
92
+ D: serde::Deserializer<'de>,
93
+ {
94
+ let value = Option::<serde_json::Value>::deserialize(deserializer)?;
95
+ match value {
96
+ None | Some(serde_json::Value::Null) => Ok(None),
97
+ Some(serde_json::Value::Number(number)) => number
98
+ .as_u64()
99
+ .and_then(|value| usize::try_from(value).ok())
100
+ .map(Some)
101
+ .ok_or_else(|| D::Error::custom("expected a non-negative integer")),
102
+ Some(serde_json::Value::String(value)) => parse_config_usize_string(&value)
103
+ .map(Some)
104
+ .map_err(D::Error::custom),
105
+ Some(value) => Err(D::Error::custom(format!(
106
+ "invalid type: {}, expected integer or string",
107
+ json_type_name(&value)
108
+ ))),
109
+ }
110
+ }
111
+
112
+ fn parse_config_usize_string(value: &str) -> std::result::Result<usize, String> {
113
+ let number = parse_js_number(value)?;
114
+ if !number.is_finite() || number < 0.0 || number.fract() != 0.0 {
115
+ return Err(format!("invalid integer `{value}`"));
116
+ }
117
+ if number > usize::MAX as f64 {
118
+ return Err(format!("integer `{value}` is too large"));
119
+ }
120
+ Ok(number as usize)
121
+ }
122
+
123
+ fn deserialize_optional_f64_or_string<'de, D>(
124
+ deserializer: D,
125
+ ) -> std::result::Result<Option<f64>, D::Error>
126
+ where
127
+ D: serde::Deserializer<'de>,
128
+ {
129
+ let value = Option::<serde_json::Value>::deserialize(deserializer)?;
130
+ match value {
131
+ None | Some(serde_json::Value::Null) => Ok(None),
132
+ Some(serde_json::Value::Number(number)) => number
133
+ .as_f64()
134
+ .map(Some)
135
+ .ok_or_else(|| D::Error::custom("expected a finite number")),
136
+ Some(serde_json::Value::String(value)) => {
137
+ parse_js_number(&value).map(Some).map_err(D::Error::custom)
138
+ }
139
+ Some(value) => Err(D::Error::custom(format!(
140
+ "invalid type: {}, expected number or string",
141
+ json_type_name(&value)
142
+ ))),
143
+ }
144
+ }
145
+
146
+ fn json_type_name(value: &serde_json::Value) -> &'static str {
147
+ match value {
148
+ serde_json::Value::Null => "null",
149
+ serde_json::Value::Bool(_) => "boolean",
150
+ serde_json::Value::Number(_) => "number",
151
+ serde_json::Value::String(_) => "string",
152
+ serde_json::Value::Array(_) => "array",
153
+ serde_json::Value::Object(_) => "object",
154
+ }
155
+ }
156
+
157
+ #[derive(Debug, Deserialize)]
158
+ #[serde(untagged)]
159
+ enum FormatMappingsConfig {
160
+ String(String),
161
+ Map(OrderedFormatMappings),
162
+ }
163
+
164
+ #[derive(Debug)]
165
+ struct OrderedFormatMappings(Vec<(String, Vec<String>)>);
166
+
167
+ impl<'de> Deserialize<'de> for OrderedFormatMappings {
168
+ fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
169
+ where
170
+ D: serde::Deserializer<'de>,
171
+ {
172
+ deserializer.deserialize_map(OrderedFormatMappingsVisitor)
173
+ }
174
+ }
175
+
176
+ struct OrderedFormatMappingsVisitor;
177
+
178
+ impl<'de> Visitor<'de> for OrderedFormatMappingsVisitor {
179
+ type Value = OrderedFormatMappings;
180
+
181
+ fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182
+ formatter.write_str("a format-to-values mapping object")
183
+ }
184
+
185
+ fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
186
+ where
187
+ A: MapAccess<'de>,
188
+ {
189
+ let mut items = Vec::with_capacity(map.size_hint().unwrap_or(0));
190
+ while let Some((format, values)) = map.next_entry::<String, Vec<String>>()? {
191
+ items.push((format, values));
192
+ }
193
+ Ok(OrderedFormatMappings(items))
194
+ }
195
+ }
196
+
197
+ impl FormatMappingsConfig {
198
+ fn into_mappings(self) -> FormatMappings {
199
+ match self {
200
+ Self::String(value) => parse_format_mappings(&value),
201
+ Self::Map(map) => FormatMappings(map.0),
202
+ }
203
+ }
204
+ }
205
+
206
+ pub(super) fn read_config(path: Option<&Path>) -> Result<Option<(FileConfig, PathBuf, PathBuf)>> {
207
+ let path = path
208
+ .map(Path::to_path_buf)
209
+ .unwrap_or_else(|| PathBuf::from(".jscpd.json"));
210
+ if !path.exists() {
211
+ return Ok(None);
212
+ }
213
+
214
+ let path = absolute_config_path(&path)
215
+ .with_context(|| format!("failed to resolve config path `{}`", path.display()))?;
216
+ let data = fs::read_to_string(&path)
217
+ .with_context(|| format!("failed to read config `{}`", path.display()))?;
218
+ let config = match serde_json::from_str::<FileConfig>(&data) {
219
+ Ok(config) => config,
220
+ Err(error)
221
+ if matches!(
222
+ error.classify(),
223
+ serde_json::error::Category::Syntax | serde_json::error::Category::Eof
224
+ ) =>
225
+ {
226
+ bail!("{}", config_syntax_error(&path, &data, &error));
227
+ }
228
+ Err(error) => {
229
+ return Err(error)
230
+ .with_context(|| format!("failed to parse config `{}`", path.display()));
231
+ }
232
+ };
233
+ let config_dir = path
234
+ .parent()
235
+ .unwrap_or_else(|| Path::new("."))
236
+ .to_path_buf();
237
+
238
+ Ok(Some((config, config_dir, path)))
239
+ }
240
+
241
+ fn absolute_config_path(path: &Path) -> Result<PathBuf> {
242
+ let path = if path.is_absolute() {
243
+ path.to_path_buf()
244
+ } else {
245
+ std::env::current_dir()
246
+ .context("failed to resolve current directory")?
247
+ .join(path)
248
+ };
249
+ Ok(clean_lexical_path(&path))
250
+ }
251
+
252
+ fn clean_lexical_path(path: &Path) -> PathBuf {
253
+ let mut clean = PathBuf::new();
254
+ for component in path.components() {
255
+ match component {
256
+ Component::Prefix(prefix) => clean.push(prefix.as_os_str()),
257
+ Component::RootDir => clean.push(component.as_os_str()),
258
+ Component::CurDir => {}
259
+ Component::ParentDir => {
260
+ if !clean.pop() && !clean.has_root() {
261
+ clean.push("..");
262
+ }
263
+ }
264
+ Component::Normal(value) => clean.push(value),
265
+ }
266
+ }
267
+ clean
268
+ }
269
+
270
+ pub(super) fn read_package_json_config() -> Result<Option<(FileConfig, PathBuf, PathBuf)>> {
271
+ let path = std::env::current_dir()?.join("package.json");
272
+ if !path.exists() {
273
+ return Ok(None);
274
+ }
275
+
276
+ let data = match fs::read_to_string(&path) {
277
+ Ok(data) => data,
278
+ Err(error) => {
279
+ eprintln!("Warning: Could not read {}: {error}", path.display());
280
+ return Ok(None);
281
+ }
282
+ };
283
+ let package = match serde_json::from_str::<PackageJson>(&data) {
284
+ Ok(package) => package,
285
+ Err(error) => {
286
+ if serde_json::from_str::<serde_json::Value>(&data).is_ok() {
287
+ return Err(error).with_context(|| {
288
+ format!("failed to parse jscpd config in `{}`", path.display())
289
+ });
290
+ }
291
+ eprintln!("Warning: Could not parse {}: {error}", path.display());
292
+ return Ok(None);
293
+ }
294
+ };
295
+ let Some(config) = package.jscpd else {
296
+ return Ok(None);
297
+ };
298
+ let config_dir = path
299
+ .parent()
300
+ .unwrap_or_else(|| Path::new("."))
301
+ .to_path_buf();
302
+ Ok(Some((config, config_dir, path)))
303
+ }
304
+
305
+ #[derive(Debug, Deserialize)]
306
+ struct PackageJson {
307
+ jscpd: Option<FileConfig>,
308
+ }
309
+
310
+ pub(super) fn apply_config(
311
+ options: &mut Options,
312
+ config: FileConfig,
313
+ config_dir: &Path,
314
+ ) -> Result<()> {
315
+ if let Some(execution_id) = config.execution_id {
316
+ options.execution_id = Some(execution_id);
317
+ }
318
+ if let Some(paths) = config.path {
319
+ options.paths = paths
320
+ .into_vec()
321
+ .into_iter()
322
+ .map(|path| resolve_config_path(config_dir, path))
323
+ .collect();
324
+ }
325
+ if let Some(pattern) = config.pattern {
326
+ options.pattern = pattern;
327
+ }
328
+ if let Some(ignore) = config.ignore {
329
+ options.ignore = ignore
330
+ .into_vec()
331
+ .into_iter()
332
+ .map(|pattern| resolve_config_ignore(config_dir, pattern))
333
+ .collect::<Result<Vec<_>>>()?;
334
+ }
335
+ if let Some(reporters) = config.reporters {
336
+ options.reporters = reporters.into_vec();
337
+ }
338
+ if let Some(listeners) = config.listeners {
339
+ options.listeners = listeners.into_vec();
340
+ }
341
+ if let Some(reporters_options) = config.reporters_options {
342
+ options.reporters_options = reporters_options;
343
+ }
344
+ if let Some(output) = config.output {
345
+ options.output = output;
346
+ }
347
+ if let Some(format) = config.format {
348
+ let formats = format.into_vec();
349
+ options.formats = Some(formats.iter().cloned().collect());
350
+ options.format_order = Some(formats);
351
+ }
352
+ if let Some(formats_exts) = config.formats_exts {
353
+ options.formats_exts = formats_exts.into_mappings();
354
+ }
355
+ if let Some(formats_names) = config.formats_names {
356
+ options.formats_names = formats_names.into_mappings();
357
+ }
358
+ if let Some(ignore_pattern) = config.ignore_pattern {
359
+ options.ignore_pattern = compile_patterns(ignore_pattern.into_vec())
360
+ .context("invalid ignorePattern in config")?;
361
+ }
362
+ if let Some(min_lines) = config.min_lines {
363
+ options.min_lines = min_lines;
364
+ }
365
+ if let Some(min_tokens) = config.min_tokens {
366
+ options.min_tokens = min_tokens;
367
+ }
368
+ if let Some(max_lines) = config.max_lines {
369
+ options.max_lines = max_lines;
370
+ }
371
+ if let Some(max_size) = config.max_size {
372
+ options.max_size_bytes = parse_size(&max_size)
373
+ .with_context(|| format!("invalid maxSize value `{max_size}` in config"))?;
374
+ }
375
+ if let Some(threshold) = config.threshold {
376
+ options.threshold = Some(threshold);
377
+ }
378
+ if let Some(mode) = config.mode {
379
+ options.mode = super::parse_mode(&mode)?;
380
+ }
381
+ if let Some(store) = config.store {
382
+ options.store = Some(store);
383
+ }
384
+ if let Some(store_path) = config.store_path {
385
+ options.store_path = Some(store_path);
386
+ }
387
+ if let Some(blame) = config.blame {
388
+ options.blame = blame;
389
+ }
390
+ if let Some(cache) = config.cache {
391
+ options.cache = cache;
392
+ }
393
+ if let Some(silent) = config.silent {
394
+ options.silent = silent;
395
+ }
396
+ if let Some(absolute) = config.absolute {
397
+ options.absolute = absolute;
398
+ }
399
+ if let Some(no_symlinks) = config.no_symlinks {
400
+ options.no_symlinks = no_symlinks;
401
+ }
402
+ if let Some(ignore_case) = config.ignore_case {
403
+ options.ignore_case = ignore_case;
404
+ }
405
+ if let Some(gitignore) = config.gitignore {
406
+ options.gitignore = gitignore;
407
+ }
408
+ if let Some(debug) = config.debug {
409
+ options.debug = debug;
410
+ }
411
+ if let Some(verbose) = config.verbose {
412
+ options.verbose = verbose;
413
+ }
414
+ if let Some(skip_local) = config.skip_local {
415
+ options.skip_local = skip_local;
416
+ }
417
+ if let Some(exit_code) = config.exit_code {
418
+ options.exit_code = exit_code.into();
419
+ }
420
+ if let Some(no_tips) = config.no_tips {
421
+ options.no_tips = no_tips;
422
+ }
423
+ if let Some(tokens_to_skip) = config.tokens_to_skip {
424
+ options.tokens_to_skip = tokens_to_skip.into_vec();
425
+ }
426
+ Ok(())
427
+ }
428
+
429
+ fn resolve_config_path<T: Into<PathBuf>>(config_dir: &Path, path: T) -> PathBuf {
430
+ let path = path.into();
431
+ if path.is_absolute() {
432
+ path
433
+ } else {
434
+ config_dir.join(path)
435
+ }
436
+ }
437
+
438
+ fn config_syntax_error(path: &Path, data: &str, error: &serde_json::Error) -> String {
439
+ format!(
440
+ "SyntaxError: {}: {}",
441
+ path.display(),
442
+ node_like_json_syntax_message(data, error)
443
+ )
444
+ }
445
+
446
+ fn node_like_json_syntax_message(data: &str, error: &serde_json::Error) -> String {
447
+ let line = error.line();
448
+ let column = error.column();
449
+ let position = json_error_position(data, line, column);
450
+ let message = error.to_string();
451
+
452
+ if message.starts_with("key must be a string") {
453
+ format!(
454
+ "Expected property name or '}}' in JSON at position {position} (line {line} column {column})"
455
+ )
456
+ } else if matches!(error.classify(), serde_json::error::Category::Eof) {
457
+ "Unexpected end of JSON input".to_string()
458
+ } else {
459
+ format!("{message} at position {position} (line {line} column {column})")
460
+ }
461
+ }
462
+
463
+ fn json_error_position(data: &str, line: usize, column: usize) -> usize {
464
+ let before_line = data
465
+ .lines()
466
+ .take(line.saturating_sub(1))
467
+ .map(|line| line.len() + 1)
468
+ .sum::<usize>();
469
+ before_line + column.saturating_sub(1)
470
+ }
471
+
472
+ pub(super) fn resolve_config_ignore(config_dir: &Path, pattern: String) -> Result<String> {
473
+ let path = Path::new(&pattern);
474
+ if path.is_absolute() || pattern.starts_with("**/") {
475
+ return Ok(pattern);
476
+ }
477
+
478
+ let absolute = config_dir.join(&pattern);
479
+ let cwd = std::env::current_dir().context("failed to resolve current directory")?;
480
+ if let Ok(relative) = absolute.strip_prefix(cwd) {
481
+ return Ok(relative.display().to_string());
482
+ }
483
+
484
+ Ok(absolute.display().to_string())
485
+ }
486
+
487
+ #[cfg(test)]
488
+ mod tests {
489
+ use super::*;
490
+ use std::time::{SystemTime, UNIX_EPOCH};
491
+
492
+ #[test]
493
+ fn malformed_config_json_uses_upstream_style_syntax_error() {
494
+ let path = Path::new("/tmp/project/.jscpd.json");
495
+ let data = "{ invalid json\n";
496
+ let error = serde_json::from_str::<FileConfig>(data).unwrap_err();
497
+
498
+ assert_eq!(
499
+ config_syntax_error(path, data, &error),
500
+ "SyntaxError: /tmp/project/.jscpd.json: Expected property name or '}' in JSON at position 2 (line 1 column 3)"
501
+ );
502
+ }
503
+
504
+ #[cfg(unix)]
505
+ #[test]
506
+ fn read_config_preserves_symlink_path_like_upstream() {
507
+ let root = unique_temp_dir("jscpd-rs-config-symlink");
508
+ let real_dir = root.join("real");
509
+ let link_dir = root.join("link");
510
+ std::fs::create_dir_all(&real_dir).unwrap();
511
+ std::fs::create_dir_all(link_dir.join("src")).unwrap();
512
+ std::fs::write(
513
+ real_dir.join(".jscpd.json"),
514
+ r#"{"path":["src"],"ignore":["ignored/**"]}"#,
515
+ )
516
+ .unwrap();
517
+ std::os::unix::fs::symlink("../real/.jscpd.json", link_dir.join(".jscpd.json")).unwrap();
518
+
519
+ let link_config = link_dir.join(".jscpd.json");
520
+ let (config, config_dir, config_path) = read_config(Some(&link_config)).unwrap().unwrap();
521
+
522
+ assert_eq!(config_path, link_config);
523
+ assert_eq!(config_dir, link_dir);
524
+
525
+ let mut options = Options::default();
526
+ apply_config(&mut options, config, &config_dir).unwrap();
527
+ assert_eq!(options.paths, vec![root.join("link/src")]);
528
+ assert_eq!(
529
+ options.ignore,
530
+ vec![root.join("link/ignored/**").display().to_string()]
531
+ );
532
+
533
+ let _ = std::fs::remove_dir_all(root);
534
+ }
535
+
536
+ fn unique_temp_dir(prefix: &str) -> PathBuf {
537
+ let suffix = SystemTime::now()
538
+ .duration_since(UNIX_EPOCH)
539
+ .unwrap()
540
+ .as_nanos();
541
+ std::env::temp_dir().join(format!("{prefix}-{}-{suffix}", std::process::id()))
542
+ }
543
+ }