jscpd-rs 0.1.2 → 0.1.3

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.
package/src/cli/config.rs CHANGED
@@ -501,6 +501,206 @@ mod tests {
501
501
  );
502
502
  }
503
503
 
504
+ #[test]
505
+ fn config_number_strings_follow_node_number_rules() {
506
+ assert_eq!(parse_config_usize_string("0x10").unwrap(), 16);
507
+ assert_eq!(parse_config_usize_string(" 42 ").unwrap(), 42);
508
+ assert!(parse_config_usize_string("1.5").is_err());
509
+ assert!(parse_config_usize_string("-1").is_err());
510
+ assert!(parse_config_usize_string("Infinity").is_err());
511
+
512
+ let config: FileConfig =
513
+ serde_json::from_str(r#"{"minLines":"0b101","threshold":"1e2"}"#).unwrap();
514
+ assert_eq!(config.min_lines, Some(5));
515
+ assert_eq!(config.threshold, Some(100.0));
516
+
517
+ let error = serde_json::from_str::<FileConfig>(r#"{"minLines":[]}"#).unwrap_err();
518
+ assert!(error.to_string().contains("invalid type: array"));
519
+ }
520
+
521
+ #[test]
522
+ fn config_number_deserializers_accept_numbers_nulls_and_reject_wrong_types() {
523
+ let config: FileConfig =
524
+ serde_json::from_str(r#"{"minLines":7,"maxLines":null,"threshold":2.5}"#).unwrap();
525
+ assert_eq!(config.min_lines, Some(7));
526
+ assert_eq!(config.max_lines, None);
527
+ assert_eq!(config.threshold, Some(2.5));
528
+
529
+ let config: FileConfig =
530
+ serde_json::from_str(r#"{"minLines":null,"threshold":null}"#).unwrap();
531
+ assert_eq!(config.min_lines, None);
532
+ assert_eq!(config.threshold, None);
533
+
534
+ let error = serde_json::from_str::<FileConfig>(r#"{"threshold":{}}"#).unwrap_err();
535
+ assert!(error.to_string().contains("invalid type: object"));
536
+ }
537
+
538
+ #[test]
539
+ fn clean_lexical_path_collapses_dot_and_parent_components() {
540
+ assert_eq!(
541
+ clean_lexical_path(Path::new("repo/app/./src/../tests/file.js")),
542
+ PathBuf::from("repo/app/tests/file.js")
543
+ );
544
+ assert_eq!(
545
+ clean_lexical_path(Path::new("../repo/./src")),
546
+ PathBuf::from("../repo/src")
547
+ );
548
+ assert!(
549
+ absolute_config_path(Path::new("Cargo.toml"))
550
+ .unwrap()
551
+ .is_absolute()
552
+ );
553
+ }
554
+
555
+ #[test]
556
+ fn resolves_config_paths_and_ignores_like_upstream() {
557
+ let cwd = std::env::current_dir().unwrap();
558
+ let config_dir = cwd.join("fixtures").join("config");
559
+ let absolute = cwd.join("src");
560
+
561
+ assert_eq!(
562
+ resolve_config_path(&config_dir, "src"),
563
+ config_dir.join("src")
564
+ );
565
+ assert_eq!(resolve_config_path(&config_dir, absolute.clone()), absolute);
566
+ assert_eq!(
567
+ resolve_config_ignore(&config_dir, "**/vendor/**".to_string()).unwrap(),
568
+ "**/vendor/**"
569
+ );
570
+ assert_eq!(
571
+ resolve_config_ignore(&cwd, "target/**".to_string()).unwrap(),
572
+ "target/**"
573
+ );
574
+
575
+ let absolute = cwd.join("dist/**").display().to_string();
576
+ assert_eq!(
577
+ resolve_config_ignore(&config_dir, absolute.clone()).unwrap(),
578
+ absolute
579
+ );
580
+ }
581
+
582
+ #[test]
583
+ fn applies_config_mappings_and_exit_code_variants() {
584
+ let config: FileConfig = serde_json::from_str(
585
+ r#"{
586
+ "format": "javascript,typescript",
587
+ "formatsExts": {"javascript": ["mjs", "cjs"]},
588
+ "formatsNames": "makefile:Makefile,GNUmakefile",
589
+ "exitCode": false,
590
+ "tokensToSkip": "import,require"
591
+ }"#,
592
+ )
593
+ .unwrap();
594
+ let mut options = Options::default();
595
+
596
+ apply_config(&mut options, config, Path::new(".")).unwrap();
597
+
598
+ assert_eq!(
599
+ options.format_order,
600
+ Some(vec!["javascript".to_string(), "typescript".to_string()])
601
+ );
602
+ assert_eq!(
603
+ options.formats_exts.find_format_for_value("mjs"),
604
+ Some("javascript")
605
+ );
606
+ assert_eq!(
607
+ options.formats_names.find_format_for_value("GNUmakefile"),
608
+ Some("makefile")
609
+ );
610
+ assert_eq!(options.exit_code, ExitCode::Boolean(false));
611
+ assert_eq!(options.tokens_to_skip, vec!["import", "require"]);
612
+ }
613
+
614
+ #[test]
615
+ fn node_like_json_syntax_message_handles_eof() {
616
+ let data = "{\"minLines\":";
617
+ let error = serde_json::from_str::<FileConfig>(data).unwrap_err();
618
+
619
+ assert_eq!(
620
+ node_like_json_syntax_message(data, &error),
621
+ "Unexpected end of JSON input"
622
+ );
623
+ assert_eq!(json_error_position("a\nbc", 2, 2), 3);
624
+
625
+ let trailing = "[1,]";
626
+ let error = serde_json::from_str::<serde_json::Value>(trailing).unwrap_err();
627
+ assert!(node_like_json_syntax_message(trailing, &error).contains("at position"));
628
+ }
629
+
630
+ #[test]
631
+ fn read_config_reports_syntax_errors_with_upstream_shape() {
632
+ let root = unique_temp_dir("jscpd-rs-config-syntax");
633
+ std::fs::create_dir_all(&root).unwrap();
634
+ let path = root.join(".jscpd.json");
635
+ std::fs::write(&path, "{ invalid json\n").unwrap();
636
+
637
+ let error = read_config(Some(&path)).unwrap_err();
638
+ let _ = std::fs::remove_dir_all(root);
639
+
640
+ assert!(error.to_string().starts_with("SyntaxError: "));
641
+ assert!(error.to_string().contains("Expected property name or '}'"));
642
+ }
643
+
644
+ #[test]
645
+ fn apply_config_updates_main_runtime_flags() {
646
+ let config: FileConfig = serde_json::from_str(
647
+ r#"{
648
+ "executionId": "run-1",
649
+ "path": "src,tests",
650
+ "pattern": "**/*.rs",
651
+ "ignore": "target/**,dist/**",
652
+ "reporters": "json,console",
653
+ "ignorePattern": ["import .*"],
654
+ "minLines": 3,
655
+ "minTokens": 7,
656
+ "maxLines": 99,
657
+ "maxSize": "2kb",
658
+ "threshold": 10,
659
+ "mode": "weak",
660
+ "silent": true,
661
+ "absolute": true,
662
+ "noSymlinks": true,
663
+ "ignoreCase": true,
664
+ "gitignore": false,
665
+ "debug": true,
666
+ "verbose": true,
667
+ "skipLocal": true,
668
+ "exitCode": 2,
669
+ "noTips": true
670
+ }"#,
671
+ )
672
+ .unwrap();
673
+ let mut options = Options::default();
674
+
675
+ apply_config(&mut options, config, Path::new(".")).unwrap();
676
+
677
+ assert_eq!(options.execution_id.as_deref(), Some("run-1"));
678
+ assert_eq!(
679
+ options.paths,
680
+ vec![PathBuf::from("./src"), PathBuf::from("./tests")]
681
+ );
682
+ assert_eq!(options.pattern, "**/*.rs");
683
+ assert_eq!(options.ignore, vec!["./target/**", "./dist/**"]);
684
+ assert_eq!(options.reporters, vec!["json", "console"]);
685
+ assert_eq!(options.ignore_pattern.len(), 1);
686
+ assert_eq!(options.min_lines, 3);
687
+ assert_eq!(options.min_tokens, 7);
688
+ assert_eq!(options.max_lines, 99);
689
+ assert_eq!(options.max_size_bytes, 2 * 1024);
690
+ assert_eq!(options.threshold, Some(10.0));
691
+ assert_eq!(options.mode, super::super::Mode::Weak);
692
+ assert!(options.silent);
693
+ assert!(options.absolute);
694
+ assert!(options.no_symlinks);
695
+ assert!(options.ignore_case);
696
+ assert!(!options.gitignore);
697
+ assert!(options.debug);
698
+ assert!(options.verbose);
699
+ assert!(options.skip_local);
700
+ assert_eq!(options.exit_code, ExitCode::Number(2.0));
701
+ assert!(options.no_tips);
702
+ }
703
+
504
704
  #[cfg(unix)]
505
705
  #[test]
506
706
  fn read_config_preserves_symlink_path_like_upstream() {
@@ -329,7 +329,6 @@ mod tests {
329
329
  meta: SourceMeta {
330
330
  source_id: source_id.to_string(),
331
331
  format: "javascript".to_string(),
332
- content: String::new(),
333
332
  lines: hashes.len(),
334
333
  tokens: hashes.len(),
335
334
  },
@@ -1,4 +1,5 @@
1
1
  use std::collections::{BTreeMap, HashMap};
2
+ use std::sync::Arc;
2
3
 
3
4
  use serde::Serialize;
4
5
 
@@ -157,7 +158,6 @@ pub(super) struct TokenSpan {
157
158
  pub(super) struct SourceMeta {
158
159
  pub(super) source_id: String,
159
160
  pub(super) format: String,
160
- pub(super) content: String,
161
161
  pub(super) lines: usize,
162
162
  pub(super) tokens: usize,
163
163
  }
@@ -185,6 +185,7 @@ pub(super) struct PreparedSource {
185
185
  #[derive(Clone, Debug)]
186
186
  pub(crate) struct PreparedSourceDraft {
187
187
  pub(super) meta: SourceMeta,
188
- pub(super) hashes: Vec<u64>,
189
- pub(super) spans: Vec<TokenSpan>,
188
+ pub(super) content: Arc<str>,
189
+ pub(super) hashes: Arc<Vec<u64>>,
190
+ pub(super) spans: Arc<Vec<TokenSpan>>,
190
191
  }
@@ -1,3 +1,5 @@
1
+ use std::sync::Arc;
2
+
1
3
  use rustc_hash::FxHashMap;
2
4
 
3
5
  use crate::cli::Options;
@@ -26,19 +28,23 @@ pub(super) fn assign_formats(files: &[PreparedSourceDraft]) -> (Vec<FormatId>, V
26
28
  }
27
29
 
28
30
  pub(super) fn prepare_file_maps(file: SourceFile, options: &Options) -> Vec<PreparedSourceDraft> {
29
- tokenize_maps_for_detection(&file.content, &file.format, options)
30
- .into_iter()
31
+ let source_id = file.source_id;
32
+ let content = file.content;
33
+ let maps = tokenize_maps_for_detection(&content, &file.format, options);
34
+ let content = Arc::<str>::from(content);
35
+
36
+ maps.into_iter()
31
37
  .map(|map| {
32
38
  let (hashes, spans) = split_tokens(map.tokens);
33
39
  let (stat_lines, stat_tokens) = token_stream_statistics(&spans);
34
40
  PreparedSourceDraft {
35
41
  meta: SourceMeta {
36
- source_id: file.source_id.clone(),
42
+ source_id: source_id.clone(),
37
43
  format: map.format,
38
- content: file.content.clone(),
39
44
  lines: stat_lines,
40
45
  tokens: stat_tokens,
41
46
  },
47
+ content: Arc::clone(&content),
42
48
  hashes,
43
49
  spans,
44
50
  }
@@ -46,7 +52,7 @@ pub(super) fn prepare_file_maps(file: SourceFile, options: &Options) -> Vec<Prep
46
52
  .collect()
47
53
  }
48
54
 
49
- fn split_tokens(tokens: Vec<DetectionToken>) -> (Vec<u64>, Vec<TokenSpan>) {
55
+ fn split_tokens(tokens: Vec<DetectionToken>) -> (Arc<Vec<u64>>, Arc<Vec<TokenSpan>>) {
50
56
  let mut hashes = Vec::with_capacity(tokens.len());
51
57
  let mut spans = Vec::with_capacity(tokens.len());
52
58
  for token in tokens {
@@ -57,7 +63,7 @@ fn split_tokens(tokens: Vec<DetectionToken>) -> (Vec<u64>, Vec<TokenSpan>) {
57
63
  range: token.range,
58
64
  });
59
65
  }
60
- (hashes, spans)
66
+ (Arc::new(hashes), Arc::new(spans))
61
67
  }
62
68
 
63
69
  fn token_stream_statistics(spans: &[TokenSpan]) -> (usize, usize) {
package/src/detector.rs CHANGED
@@ -1,4 +1,5 @@
1
1
  use std::collections::HashMap;
2
+ use std::sync::Arc;
2
3
 
3
4
  use rayon::prelude::*;
4
5
  use rustc_hash::FxHashSet;
@@ -131,29 +132,36 @@ pub(crate) fn detect_prepared_drafts(
131
132
  prepared_drafts: Vec<PreparedSourceDraft>,
132
133
  options: &Options,
133
134
  ) -> DetectionResult {
135
+ let include_source_contents = options
136
+ .reporters
137
+ .iter()
138
+ .any(|reporter| matches!(reporter.as_str(), "json" | "xml" | "html" | "consoleFull"));
139
+ let mut source_contents = HashMap::new();
134
140
  let (format_ids, format_names) = assign_formats(&prepared_drafts);
135
141
  let prepared_files = prepared_drafts
136
142
  .into_iter()
137
143
  .enumerate()
138
- .map(|(idx, draft)| PreparedSource {
139
- meta: draft.meta,
140
- stream: TokenStream {
141
- source_id: SourceId(idx),
142
- format_id: format_ids[idx],
143
- hashes: draft.hashes,
144
- spans: draft.spans,
145
- },
144
+ .map(|(idx, draft)| {
145
+ if include_source_contents && !draft.spans.is_empty() {
146
+ source_contents
147
+ .entry(draft.meta.source_id.clone())
148
+ .or_insert_with(|| draft.content.to_string());
149
+ }
150
+ PreparedSource {
151
+ meta: draft.meta,
152
+ stream: TokenStream {
153
+ source_id: SourceId(idx),
154
+ format_id: format_ids[idx],
155
+ hashes: unwrap_or_clone_arc_vec(draft.hashes),
156
+ spans: unwrap_or_clone_arc_vec(draft.spans),
157
+ },
158
+ }
146
159
  })
147
160
  .collect::<Vec<_>>();
148
161
 
149
162
  let mut statistics = Statistics::default();
150
163
  let mut sources = Vec::new();
151
- let mut source_contents = HashMap::new();
152
164
  let mut source_indices_by_format = vec![Vec::new(); format_names.len()];
153
- let include_source_contents = options
154
- .reporters
155
- .iter()
156
- .any(|reporter| matches!(reporter.as_str(), "json" | "xml" | "html" | "consoleFull"));
157
165
 
158
166
  for (idx, prepared) in prepared_files.iter().enumerate() {
159
167
  if prepared.stream.spans.is_empty() {
@@ -172,12 +180,6 @@ pub(crate) fn detect_prepared_drafts(
172
180
  lines: prepared.meta.lines,
173
181
  tokens: prepared.meta.tokens,
174
182
  });
175
- if include_source_contents {
176
- source_contents.insert(
177
- prepared.meta.source_id.clone(),
178
- prepared.meta.content.clone(),
179
- );
180
- }
181
183
  source_indices_by_format[prepared.stream.format_id.0].push(idx);
182
184
  }
183
185
 
@@ -222,6 +224,10 @@ fn dedup_exact_clones(clones: &mut Vec<CloneMatch>) {
222
224
  clones.retain(|clone| seen.insert(CloneDedupKey::from(clone)));
223
225
  }
224
226
 
227
+ fn unwrap_or_clone_arc_vec<T: Clone>(value: Arc<Vec<T>>) -> Vec<T> {
228
+ Arc::try_unwrap(value).unwrap_or_else(|value| (*value).clone())
229
+ }
230
+
225
231
  #[derive(Hash, Eq, PartialEq)]
226
232
  struct CloneDedupKey {
227
233
  format: String,
@@ -1,6 +1,10 @@
1
1
  use std::fs;
2
2
  use std::path::{Path, PathBuf};
3
- use std::sync::{Arc, Mutex};
3
+ use std::sync::{
4
+ Arc,
5
+ atomic::{AtomicBool, Ordering},
6
+ mpsc,
7
+ };
4
8
 
5
9
  use anyhow::{Context, Result, anyhow};
6
10
  use globset::{Glob, GlobSet, GlobSetBuilder};
@@ -59,14 +63,11 @@ pub fn discover(options: &Options) -> Result<Vec<SourceFile>> {
59
63
  let metadata = fs::metadata(root)
60
64
  .with_context(|| format!("failed to inspect path `{}`", root.display()))?;
61
65
  if metadata.is_file() {
62
- collect_candidate(
63
- root,
64
- root_index,
65
- options,
66
- &ignore_set,
67
- &cwd,
68
- &mut candidates,
69
- )?;
66
+ if let Some(candidate) =
67
+ candidate_for_path(root, root_index, options, &ignore_set, &cwd)?
68
+ {
69
+ candidates.push(candidate);
70
+ }
70
71
  continue;
71
72
  }
72
73
 
@@ -158,14 +159,15 @@ fn collect_candidates_sequential(
158
159
  if !context.pattern_set.is_match(relative) {
159
160
  continue;
160
161
  }
161
- collect_candidate(
162
+ if let Some(candidate) = candidate_for_path(
162
163
  path,
163
164
  context.root_index,
164
165
  context.options,
165
166
  context.ignore_set,
166
167
  context.cwd,
167
- candidates,
168
- )?;
168
+ )? {
169
+ candidates.push(candidate);
170
+ }
169
171
  }
170
172
 
171
173
  Ok(())
@@ -176,24 +178,25 @@ fn collect_candidates_parallel(
176
178
  context: &CandidateCollectionContext<'_>,
177
179
  candidates: &mut Vec<CandidateFile>,
178
180
  ) -> Result<()> {
179
- let collected = Arc::new(Mutex::new(Vec::new()));
180
- let error = Arc::new(Mutex::new(None));
181
+ let (sender, receiver) = mpsc::channel::<Result<CandidateFile>>();
182
+ let failed = Arc::new(AtomicBool::new(false));
181
183
 
182
184
  builder.build_parallel().run(|| {
183
- let collected = Arc::clone(&collected);
184
- let error = Arc::clone(&error);
185
+ let sender = sender.clone();
186
+ let failed = Arc::clone(&failed);
185
187
  Box::new(move |entry| {
186
- if error.lock().unwrap().is_some() {
188
+ if failed.load(Ordering::Relaxed) {
187
189
  return WalkState::Quit;
188
190
  }
189
191
 
190
192
  let entry = match entry {
191
193
  Ok(entry) => entry,
192
194
  Err(err) => {
193
- *error.lock().unwrap() = Some(anyhow!(
195
+ failed.store(true, Ordering::Relaxed);
196
+ let _ = sender.send(Err(anyhow!(
194
197
  "failed to walk path `{}`: {err}",
195
198
  context.root.display()
196
- ));
199
+ )));
197
200
  return WalkState::Quit;
198
201
  }
199
202
  };
@@ -210,47 +213,50 @@ fn collect_candidates_parallel(
210
213
  return WalkState::Continue;
211
214
  }
212
215
 
213
- let mut local = Vec::with_capacity(1);
214
- if let Err(err) = collect_candidate(
216
+ match candidate_for_path(
215
217
  path,
216
218
  context.root_index,
217
219
  context.options,
218
220
  context.ignore_set,
219
221
  context.cwd,
220
- &mut local,
221
222
  ) {
222
- *error.lock().unwrap() = Some(err);
223
- return WalkState::Quit;
224
- }
225
- if !local.is_empty() {
226
- collected.lock().unwrap().extend(local);
223
+ Ok(Some(candidate)) => {
224
+ if sender.send(Ok(candidate)).is_err() {
225
+ failed.store(true, Ordering::Relaxed);
226
+ return WalkState::Quit;
227
+ }
228
+ }
229
+ Ok(None) => {}
230
+ Err(err) => {
231
+ failed.store(true, Ordering::Relaxed);
232
+ let _ = sender.send(Err(err));
233
+ return WalkState::Quit;
234
+ }
227
235
  }
228
236
  WalkState::Continue
229
237
  })
230
238
  });
239
+ drop(sender);
231
240
 
232
- if let Some(error) = Arc::try_unwrap(error).unwrap().into_inner().unwrap() {
233
- return Err(error);
241
+ for item in receiver {
242
+ candidates.push(item?);
234
243
  }
235
244
 
236
- candidates.extend(Arc::try_unwrap(collected).unwrap().into_inner().unwrap());
237
-
238
245
  Ok(())
239
246
  }
240
247
 
241
- fn collect_candidate(
248
+ fn candidate_for_path(
242
249
  path: &Path,
243
250
  root_index: usize,
244
251
  options: &Options,
245
252
  ignore_set: &IgnoreMatcher,
246
253
  cwd: &Path,
247
- candidates: &mut Vec<CandidateFile>,
248
- ) -> Result<()> {
254
+ ) -> Result<Option<CandidateFile>> {
249
255
  if options.no_symlinks && is_symlink(path) {
250
- return Ok(());
256
+ return Ok(None);
251
257
  }
252
258
  if is_ignored(path, ignore_set, cwd) {
253
- return Ok(());
259
+ return Ok(None);
254
260
  }
255
261
 
256
262
  let format = if let Some(format) =
@@ -269,7 +275,7 @@ fn collect_candidate(
269
275
  options.max_size_bytes
270
276
  );
271
277
  }
272
- return Ok(());
278
+ return Ok(None);
273
279
  }
274
280
  shebang_format_for_path(path, &metadata)?.map(str::to_string)
275
281
  };
@@ -277,7 +283,7 @@ fn collect_candidate(
277
283
  if options.verbose {
278
284
  eprintln!("skipped unsupported format: {}", path.display());
279
285
  }
280
- return Ok(());
286
+ return Ok(None);
281
287
  };
282
288
  if let Some(formats) = &options.formats
283
289
  && !formats.contains(format.as_str())
@@ -285,15 +291,13 @@ fn collect_candidate(
285
291
  if options.verbose || options.debug {
286
292
  println!("{}", format_filter_skip_message(path, &format, cwd));
287
293
  }
288
- return Ok(());
294
+ return Ok(None);
289
295
  }
290
- candidates.push(CandidateFile {
296
+ Ok(Some(CandidateFile {
291
297
  path: path.to_path_buf(),
292
298
  format,
293
299
  root_index,
294
- });
295
-
296
- Ok(())
300
+ }))
297
301
  }
298
302
 
299
303
  fn read_candidate(
@@ -415,7 +419,7 @@ fn push_pattern_once(
415
419
  }
416
420
  }
417
421
 
418
- fn normalize_glob_path(path: PathBuf) -> String {
422
+ pub(super) fn normalize_glob_path(path: PathBuf) -> String {
419
423
  let mut normalized = PathBuf::new();
420
424
  for component in path.components() {
421
425
  match component {
@@ -496,7 +500,7 @@ pub(super) fn build_ignore_matcher(patterns: &[String]) -> Result<IgnoreMatcher>
496
500
  })
497
501
  }
498
502
 
499
- fn build_glob_set(patterns: &[String]) -> Result<GlobSet> {
503
+ pub(super) fn build_glob_set(patterns: &[String]) -> Result<GlobSet> {
500
504
  let mut builder = GlobSetBuilder::new();
501
505
  if patterns.is_empty() {
502
506
  return Ok(builder.build()?);
@@ -201,3 +201,83 @@ fn push_gitignore_glob_variants(globs: &mut Vec<String>, path: &Path) {
201
201
  globs.push(format!("{relative}/**"));
202
202
  }
203
203
  }
204
+
205
+ #[cfg(test)]
206
+ mod tests {
207
+ use super::super::test_support::unique_temp_path;
208
+ use super::*;
209
+
210
+ #[test]
211
+ fn scoped_gitignore_globs_cover_rooted_nested_and_filename_patterns() {
212
+ let base = std::env::current_dir().unwrap();
213
+
214
+ let rooted = scoped_gitignore_globs(&base, "dist", true);
215
+ assert!(rooted.iter().any(|glob| glob == "dist"));
216
+ assert!(rooted.iter().any(|glob| glob == "dist/**"));
217
+
218
+ let nested = scoped_gitignore_globs(&base, "src/generated", false);
219
+ assert!(nested.iter().any(|glob| glob == "src/generated"));
220
+ assert!(nested.iter().any(|glob| glob == "**/src/generated"));
221
+
222
+ let filename = scoped_gitignore_globs(&base, "*.snap", false);
223
+ assert!(filename.iter().any(|glob| glob == "**/*.snap"));
224
+ assert!(filename.iter().any(|glob| glob == "**/*.snap/**"));
225
+ }
226
+
227
+ #[test]
228
+ fn gitignore_lines_trim_slashes_backslashes_and_negations() {
229
+ assert_eq!(
230
+ gitignore_line_to_globs(r"build\generated", None),
231
+ vec![
232
+ "build/generated",
233
+ "build/generated/**",
234
+ "**/build/generated",
235
+ "**/build/generated/**",
236
+ ]
237
+ );
238
+ assert_eq!(
239
+ gitignore_line_to_globs("!/dist/", None),
240
+ vec!["!dist", "!dist/**"]
241
+ );
242
+ }
243
+
244
+ #[test]
245
+ fn collect_gitignore_patterns_walks_parents_and_repo_exclude_once() {
246
+ let repo = unique_temp_path("gitignore-repo");
247
+ let nested = repo.join("packages").join("app");
248
+ std::fs::create_dir_all(repo.join(".git").join("info")).unwrap();
249
+ std::fs::create_dir_all(&nested).unwrap();
250
+ std::fs::write(repo.join(".gitignore"), "/target\n").unwrap();
251
+ std::fs::write(nested.join(".gitignore"), "local-cache\n").unwrap();
252
+ std::fs::write(
253
+ repo.join(".git").join("info").join("exclude"),
254
+ "repo-only\n",
255
+ )
256
+ .unwrap();
257
+
258
+ let patterns =
259
+ collect_gitignore_patterns_with_global(&[nested.clone(), nested.join("src")], None);
260
+ let _ = std::fs::remove_dir_all(&repo);
261
+
262
+ assert!(patterns.iter().any(|pattern| pattern.ends_with("/target")));
263
+ assert!(
264
+ patterns
265
+ .iter()
266
+ .any(|pattern| pattern.ends_with("/**/local-cache"))
267
+ );
268
+ assert!(
269
+ patterns
270
+ .iter()
271
+ .any(|pattern| pattern.ends_with("/**/repo-only"))
272
+ );
273
+ let repo_only = patterns
274
+ .iter()
275
+ .filter(|pattern| pattern.contains("repo-only"))
276
+ .collect::<Vec<_>>();
277
+ let unique_repo_only = repo_only
278
+ .iter()
279
+ .copied()
280
+ .collect::<std::collections::HashSet<_>>();
281
+ assert_eq!(repo_only.len(), unique_repo_only.len());
282
+ }
283
+ }