1use crate::ignore::{is_skipped_dir_name, path_contains_skipped_component};
11use perl_parser_core::source_file::is_perl_source_path;
12use std::collections::HashSet;
13use std::ffi::OsString;
14use std::path::Component;
15use std::path::{Path, PathBuf};
16use std::time::{Duration, Instant};
17use walkdir::{DirEntry, WalkDir};
18
19const GIT_LS_FILES_ARGS: [&str; 5] =
20 ["ls-files", "-z", "--cached", "--others", "--exclude-standard"];
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum DiscoveryMethod {
25 Git,
27 Walk,
29}
30
31#[derive(Debug, Clone)]
33pub struct DiscoveryResult {
34 pub files: Vec<PathBuf>,
36 pub method: DiscoveryMethod,
38 pub duration: Duration,
40 pub excluded_count: usize,
42}
43
44#[must_use]
50pub fn discover_perl_files(root: &Path) -> DiscoveryResult {
51 let start = Instant::now();
52
53 match try_git_discovery(root, start) {
54 Ok(result) => result,
55 Err(_) => walk_discovery(root, start),
56 }
57}
58
59#[must_use]
67pub fn is_perl_discovery_path(path: &Path) -> bool {
68 is_perl_source_path(path)
69 || path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| {
70 ext.eq_ignore_ascii_case("i")
71 || ext.eq_ignore_ascii_case("xs")
72 || ext.eq_ignore_ascii_case("ep")
73 || ext.eq_ignore_ascii_case("tt")
74 || ext.eq_ignore_ascii_case("tt2")
75 })
76}
77
78fn try_git_discovery(root: &Path, start: Instant) -> Result<DiscoveryResult, std::io::Error> {
79 let output = std::process::Command::new("git")
80 .args(GIT_LS_FILES_ARGS)
81 .current_dir(root)
82 .stdout(std::process::Stdio::piped())
83 .stderr(std::process::Stdio::null())
84 .output()?;
85
86 if !output.status.success() {
87 return Err(std::io::Error::other("git ls-files failed"));
88 }
89
90 let (files, excluded_count) = parse_git_ls_files_output(root, &output.stdout);
91 let result = DiscoveryResult {
92 files,
93 method: DiscoveryMethod::Git,
94 duration: start.elapsed(),
95 excluded_count,
96 };
97
98 log_discovery(&result);
99 Ok(result)
100}
101
102fn parse_git_ls_files_output(root: &Path, stdout: &[u8]) -> (Vec<PathBuf>, usize) {
103 let mut files = Vec::new();
104 let mut seen = HashSet::new();
105 let mut excluded_count: usize = 0;
106
107 for entry in stdout.split(|byte| *byte == b'\0') {
108 if entry.is_empty() {
109 continue;
110 }
111
112 let relative_path = PathBuf::from(bytes_to_os_string(entry));
113 let relative_path = relative_path.as_path();
114 if !is_safe_relative_git_path(relative_path) {
115 excluded_count += 1;
116 continue;
117 }
118 if path_contains_skipped_component(relative_path) {
119 excluded_count += 1;
120 continue;
121 }
122
123 let path = root.join(relative_path);
124 if is_perl_discovery_path(&path) {
125 if seen.insert(path.clone()) {
126 files.push(path);
127 } else {
128 excluded_count += 1;
129 }
130 } else {
131 excluded_count += 1;
132 }
133 }
134
135 sort_paths_lexically(&mut files);
136 (files, excluded_count)
137}
138
139#[cfg(unix)]
140fn bytes_to_os_string(bytes: &[u8]) -> OsString {
141 use std::os::unix::ffi::OsStringExt;
142 OsString::from_vec(bytes.to_vec())
143}
144
145#[cfg(not(unix))]
146fn bytes_to_os_string(bytes: &[u8]) -> OsString {
147 String::from_utf8_lossy(bytes).into_owned().into()
148}
149
150fn walk_discovery(root: &Path, start: Instant) -> DiscoveryResult {
151 let mut files = Vec::new();
152 let mut excluded_count: usize = 0;
153 let mut skipped_dir_count: usize = 0;
154
155 for entry in WalkDir::new(root).follow_links(false).into_iter().filter_entry(|entry| {
156 if should_skip_dir(entry) {
157 skipped_dir_count += 1;
158 return false;
159 }
160 true
161 }) {
162 let entry = match entry {
163 Ok(entry) => entry,
164 Err(_) => continue,
165 };
166
167 if !entry.file_type().is_file() {
168 continue;
169 }
170
171 if is_perl_discovery_path(entry.path()) {
172 files.push(entry.path().to_path_buf());
173 } else {
174 excluded_count += 1;
175 }
176 }
177 excluded_count += skipped_dir_count;
178 sort_paths_lexically(&mut files);
179
180 let result = DiscoveryResult {
181 files,
182 method: DiscoveryMethod::Walk,
183 duration: start.elapsed(),
184 excluded_count,
185 };
186
187 log_discovery(&result);
188 result
189}
190
191fn should_skip_dir(entry: &DirEntry) -> bool {
192 if !entry.file_type().is_dir() {
193 return false;
194 }
195
196 is_skipped_dir_name(&entry.file_name().to_string_lossy())
197}
198
199fn sort_paths_lexically(paths: &mut [PathBuf]) {
200 paths.sort_unstable_by(|left, right| left.as_os_str().cmp(right.as_os_str()));
201}
202
203fn is_safe_relative_git_path(path: &Path) -> bool {
204 !path.is_absolute()
205 && !path.components().any(|component| matches!(component, Component::ParentDir))
206}
207
208fn log_discovery(result: &DiscoveryResult) {
209 tracing::debug!(
210 files = result.files.len(),
211 method = ?result.method,
212 duration_ms = result.duration.as_secs_f64() * 1000.0,
213 excluded = result.excluded_count,
214 "workspace discovery complete"
215 );
216}
217
218#[cfg(test)]
219mod tests {
220 use super::{
221 DiscoveryMethod, parse_git_ls_files_output, path_contains_skipped_component,
222 should_skip_dir, walk_discovery,
223 };
224 use std::fs;
225 use std::path::Path;
226 use std::time::Instant;
227
228 type TestResult = Result<(), Box<dyn std::error::Error>>;
229
230 fn create_file(root: &Path, relative: &str) -> TestResult {
231 let path = root.join(relative);
232 if let Some(parent) = path.parent() {
233 fs::create_dir_all(parent)?;
234 }
235 fs::write(path, "# synthetic\n")?;
236 Ok(())
237 }
238
239 #[test]
240 fn parses_git_output_and_filters_entries() {
241 let root = Path::new("/tmp/workspace");
242 let payload = b"lib/Foo.pm\0README.md\0node_modules/pkg.pm\0script.pl\0";
243
244 let (files, excluded_count) = parse_git_ls_files_output(root, payload);
245
246 assert_eq!(files.len(), 2);
247 assert!(files.iter().any(|path| path.ends_with("lib/Foo.pm")));
248 assert!(files.iter().any(|path| path.ends_with("script.pl")));
249 assert_eq!(excluded_count, 2);
250 }
251
252 #[test]
253 fn skipped_component_detection_is_consistent() {
254 assert!(path_contains_skipped_component(Path::new("/repo/node_modules/pkg.pm")));
255 assert!(path_contains_skipped_component(Path::new("/repo/target/build/generated.pm")));
256 assert!(!path_contains_skipped_component(Path::new("/repo/lib/My/Module.pm")));
257 }
258
259 #[test]
260 fn parse_git_output_ignores_skipped_names_in_workspace_root_path() {
261 let root = Path::new("/tmp/target/workspace");
262 let payload = b"lib/Foo.pm\0";
263
264 let (files, excluded_count) = parse_git_ls_files_output(root, payload);
265
266 assert_eq!(files.len(), 1);
267 assert!(files[0].ends_with("lib/Foo.pm"));
268 assert_eq!(excluded_count, 0);
269 }
270
271 #[test]
272 fn walk_discovery_ignores_skipped_directories() -> TestResult {
273 let tmp = tempfile::tempdir()?;
274 let root = tmp.path();
275
276 create_file(root, "lib/Foo.pm")?;
277 create_file(root, "node_modules/pkg.pm")?;
278 create_file(root, "target/build/generated.pm")?;
279 create_file(root, ".cache/precompiled.pm")?;
280
281 let result = walk_discovery(root, Instant::now());
282 assert_eq!(result.method, DiscoveryMethod::Walk);
283 assert_eq!(result.files.len(), 1);
284 assert!(result.files[0].ends_with("lib/Foo.pm"));
285
286 Ok(())
287 }
288
289 #[test]
290 fn walk_discovery_counts_skipped_directories_as_excluded() -> TestResult {
291 let tmp = tempfile::tempdir()?;
292 let root = tmp.path();
293
294 create_file(root, "lib/Foo.pm")?;
295 create_file(root, "node_modules/pkg.pm")?;
296 create_file(root, "target/build/generated.pm")?;
297 create_file(root, ".cache/precompiled.pm")?;
298
299 let result = walk_discovery(root, Instant::now());
300 assert_eq!(result.method, DiscoveryMethod::Walk);
301 assert_eq!(result.files.len(), 1);
302 assert!(result.files[0].ends_with("lib/Foo.pm"));
303 assert_eq!(result.excluded_count, 3);
304
305 Ok(())
306 }
307
308 #[test]
309 fn should_skip_dir_matches_conventional_noise_directories() -> TestResult {
310 let tmp = tempfile::tempdir()?;
311 let root = tmp.path();
312
313 fs::create_dir_all(root.join(".git"))?;
314 fs::create_dir_all(root.join("node_modules"))?;
315 fs::create_dir_all(root.join("src"))?;
316
317 let mut seen_git = false;
318 let mut seen_node_modules = false;
319 let mut seen_src = false;
320
321 for entry in walkdir::WalkDir::new(root).max_depth(1).into_iter().flatten() {
322 if entry.path() == root {
323 continue;
324 }
325 let name = entry.file_name().to_string_lossy();
326 match name.as_ref() {
327 ".git" => {
328 seen_git = true;
329 assert!(should_skip_dir(&entry));
330 }
331 "node_modules" => {
332 seen_node_modules = true;
333 assert!(should_skip_dir(&entry));
334 }
335 "src" => {
336 seen_src = true;
337 assert!(!should_skip_dir(&entry));
338 }
339 _ => {}
340 }
341 }
342
343 assert!(seen_git);
344 assert!(seen_node_modules);
345 assert!(seen_src);
346
347 Ok(())
348 }
349
350 #[test]
353 fn parse_git_output_empty_input_returns_nothing() {
354 let root = Path::new("/tmp/workspace");
355 let (files, excluded_count) = parse_git_ls_files_output(root, b"");
356 assert_eq!(files.len(), 0);
357 assert_eq!(excluded_count, 0);
358 }
359
360 #[test]
361 fn parse_git_output_only_null_separators() {
362 let root = Path::new("/tmp/workspace");
363 let (files, excluded_count) = parse_git_ls_files_output(root, b"\0\0\0");
364 assert_eq!(files.len(), 0);
365 assert_eq!(excluded_count, 0);
366 }
367
368 #[test]
369 fn parse_git_output_recognizes_all_perl_extensions() {
370 let root = Path::new("/tmp/workspace");
371 let payload =
372 b"lib/Foo.pm\0scripts/run.pl\0t/basic.t\0app/main.psgi\0ext/native.xs\0templates/page.html.ep\0templates/page.tt\0templates/layout.tt2\0";
373 let (files, excluded_count) = parse_git_ls_files_output(root, payload);
374
375 assert_eq!(files.len(), 8);
376 assert!(files.iter().any(|p| p.ends_with("Foo.pm")));
377 assert!(files.iter().any(|p| p.ends_with("run.pl")));
378 assert!(files.iter().any(|p| p.ends_with("basic.t")));
379 assert!(files.iter().any(|p| p.ends_with("main.psgi")));
380 assert!(files.iter().any(|p| p.ends_with("native.xs")));
381 assert!(files.iter().any(|p| p.ends_with("page.html.ep")));
382 assert!(files.iter().any(|p| p.ends_with("page.tt")));
383 assert!(files.iter().any(|p| p.ends_with("layout.tt2")));
384 assert_eq!(excluded_count, 0);
385 }
386
387 #[test]
388 fn parse_git_output_counts_non_perl_as_excluded() {
389 let root = Path::new("/tmp/workspace");
390 let payload = b"README.md\0Makefile\0config.yaml\0";
391 let (files, excluded_count) = parse_git_ls_files_output(root, payload);
392
393 assert_eq!(files.len(), 0);
394 assert_eq!(excluded_count, 3);
395 }
396
397 #[test]
398 fn parse_git_output_excludes_all_skipped_directories() {
399 let root = Path::new("/tmp/workspace");
400 let payload = b".git/hooks/pre-commit.pl\0.hg/config.pm\0.svn/entries.pm\0target/out.pm\0node_modules/dep.pm\0.cache/fast.pm\0";
401 let (files, excluded_count) = parse_git_ls_files_output(root, payload);
402
403 assert_eq!(files.len(), 0);
404 assert_eq!(excluded_count, 6);
405 }
406
407 #[test]
408 fn parse_git_output_joins_root_to_relative_paths() {
409 let root = Path::new("/home/user/project");
410 let payload = b"lib/Module.pm\0";
411 let (files, _) = parse_git_ls_files_output(root, payload);
412
413 assert_eq!(files.len(), 1);
414 assert_eq!(files[0], Path::new("/home/user/project/lib/Module.pm"));
415 }
416
417 #[test]
418 fn parse_git_output_excludes_parent_directory_components() {
419 let root = Path::new("/tmp/workspace");
420 let payload = b"../outside.pm\0lib/ok.pm\0";
421 let (files, excluded_count) = parse_git_ls_files_output(root, payload);
422
423 assert_eq!(files, vec![root.join("lib/ok.pm")]);
424 assert_eq!(excluded_count, 1);
425 }
426
427 #[cfg(unix)]
428 #[test]
429 fn parse_git_output_excludes_absolute_paths() {
430 let root = Path::new("/tmp/workspace");
431 let payload = b"/etc/passwd\0lib/ok.pm\0";
434 let (files, excluded_count) = parse_git_ls_files_output(root, payload);
435
436 assert_eq!(files, vec![root.join("lib/ok.pm")]);
437 assert_eq!(excluded_count, 1);
438 }
439
440 #[test]
441 fn parse_git_output_excludes_embedded_parent_directory_traversal() {
442 let root = Path::new("/tmp/workspace");
443 let payload = b"lib/../../etc/passwd\0lib/ok.pm\0";
445 let (files, excluded_count) = parse_git_ls_files_output(root, payload);
446
447 assert_eq!(files, vec![root.join("lib/ok.pm")]);
448 assert_eq!(excluded_count, 1);
449 }
450
451 #[test]
452 fn parse_git_output_deduplicates_duplicate_entries() {
453 let root = Path::new("/tmp/workspace");
454 let payload = b"lib/Foo.pm\0lib/Foo.pm\0script.pl\0script.pl\0README.md\0";
455
456 let (files, excluded_count) = parse_git_ls_files_output(root, payload);
457
458 assert_eq!(files.len(), 2);
459 assert!(files.iter().any(|p| p.ends_with("lib/Foo.pm")));
460 assert!(files.iter().any(|p| p.ends_with("script.pl")));
461 assert_eq!(excluded_count, 3);
463 }
464
465 #[cfg(unix)]
466 #[test]
467 fn parse_git_output_handles_non_utf8_paths() {
468 use std::os::unix::ffi::OsStrExt;
469
470 let root = Path::new("/tmp/workspace");
471 let payload = b"lib/\xFFfoo.pm\0";
472
473 let (files, excluded_count) = parse_git_ls_files_output(root, payload);
474
475 assert_eq!(files.len(), 1);
476 assert_eq!(excluded_count, 0);
477 assert!(files[0].as_os_str().as_bytes().ends_with(b"lib/\xFFfoo.pm"));
478 }
479
480 #[test]
483 fn skipped_component_detects_each_directory_individually() {
484 let skipped = [".git", ".hg", ".svn", "target", "node_modules", ".cache"];
485 for dir in skipped {
486 let path_str = format!("lib/{dir}/nested.pm");
487 assert!(
488 path_contains_skipped_component(Path::new(&path_str)),
489 "expected {dir} to be skipped"
490 );
491 }
492 }
493
494 #[test]
495 fn skipped_component_allows_safe_directories() {
496 let safe = ["lib", "src", "bin", "t", "scripts"];
497 for dir in safe {
498 let path_str = format!("{dir}/Module.pm");
499 assert!(
500 !path_contains_skipped_component(Path::new(&path_str)),
501 "expected {dir} to be allowed"
502 );
503 }
504 }
505
506 #[test]
507 fn skipped_component_rejects_blib_directory() {
508 assert!(path_contains_skipped_component(Path::new("blib/Module.pm")));
509 }
510
511 #[test]
512 fn skipped_component_empty_path_returns_false() {
513 assert!(!path_contains_skipped_component(Path::new("")));
514 }
515
516 #[test]
517 fn skipped_component_single_filename_returns_false() {
518 assert!(!path_contains_skipped_component(Path::new("Module.pm")));
519 }
520
521 #[test]
522 fn skipped_component_deeply_nested() {
523 assert!(path_contains_skipped_component(Path::new("a/b/c/node_modules/d/e/f.pm")));
524 }
525
526 #[test]
529 fn walk_discovery_empty_directory() -> TestResult {
530 let tmp = tempfile::tempdir()?;
531 let result = walk_discovery(tmp.path(), Instant::now());
532
533 assert_eq!(result.method, DiscoveryMethod::Walk);
534 assert_eq!(result.files.len(), 0);
535 assert_eq!(result.excluded_count, 0);
536
537 Ok(())
538 }
539
540 #[test]
541 fn walk_discovery_only_non_perl_files() -> TestResult {
542 let tmp = tempfile::tempdir()?;
543 let root = tmp.path();
544
545 create_file(root, "README.md")?;
546 create_file(root, "Makefile")?;
547 create_file(root, "config.yaml")?;
548
549 let result = walk_discovery(root, Instant::now());
550 assert_eq!(result.method, DiscoveryMethod::Walk);
551 assert_eq!(result.files.len(), 0);
552 assert_eq!(result.excluded_count, 3);
553
554 Ok(())
555 }
556
557 #[test]
558 fn walk_discovery_finds_all_perl_extensions() -> TestResult {
559 let tmp = tempfile::tempdir()?;
560 let root = tmp.path();
561
562 create_file(root, "lib/Foo.pm")?;
563 create_file(root, "bin/run.pl")?;
564 create_file(root, "t/basic.t")?;
565 create_file(root, "app/main.psgi")?;
566 create_file(root, "xs/native.xs")?;
567 create_file(root, "templates/page.html.ep")?;
568 create_file(root, "templates/page.tt")?;
569 create_file(root, "templates/layout.tt2")?;
570
571 let result = walk_discovery(root, Instant::now());
572 assert_eq!(result.files.len(), 8);
573 assert!(result.files.iter().any(|p| p.ends_with("page.html.ep")));
574 assert!(result.files.iter().any(|p| p.ends_with("page.tt")));
575 assert!(result.files.iter().any(|p| p.ends_with("layout.tt2")));
576
577 Ok(())
578 }
579
580 #[test]
581 fn walk_discovery_deeply_nested_perl_files() -> TestResult {
582 let tmp = tempfile::tempdir()?;
583 let root = tmp.path();
584
585 create_file(root, "a/b/c/d/e/Deep.pm")?;
586 create_file(root, "x/y/z/script.pl")?;
587
588 let result = walk_discovery(root, Instant::now());
589 assert_eq!(result.files.len(), 2);
590 assert!(result.files.iter().any(|p| p.ends_with("Deep.pm")));
591 assert!(result.files.iter().any(|p| p.ends_with("script.pl")));
592
593 Ok(())
594 }
595
596 #[test]
597 fn walk_discovery_skips_all_six_noise_directories() -> TestResult {
598 let tmp = tempfile::tempdir()?;
599 let root = tmp.path();
600
601 create_file(root, ".git/hooks/hook.pm")?;
602 create_file(root, ".hg/config.pm")?;
603 create_file(root, ".svn/entries.pm")?;
604 create_file(root, "target/build/out.pm")?;
605 create_file(root, "node_modules/dep.pm")?;
606 create_file(root, ".cache/fast.pm")?;
607 create_file(root, "lib/Visible.pm")?;
608
609 let result = walk_discovery(root, Instant::now());
610 assert_eq!(result.files.len(), 1);
611 assert!(result.files[0].ends_with("lib/Visible.pm"));
612
613 Ok(())
614 }
615
616 #[test]
617 fn walk_discovery_records_duration() -> TestResult {
618 let tmp = tempfile::tempdir()?;
619 let result = walk_discovery(tmp.path(), Instant::now());
620 let _ = result.duration.as_nanos();
622
623 Ok(())
624 }
625
626 #[test]
627 fn walk_discovery_ignores_subdirectories_themselves() -> TestResult {
628 let tmp = tempfile::tempdir()?;
629 let root = tmp.path();
630
631 fs::create_dir_all(root.join("lib/Fake.pm/nested"))?;
633 create_file(root, "lib/Real.pm")?;
634
635 let result = walk_discovery(root, Instant::now());
636 assert_eq!(result.files.len(), 1);
638 assert!(result.files[0].ends_with("lib/Real.pm"));
639
640 Ok(())
641 }
642
643 #[test]
646 fn should_skip_dir_returns_false_for_files() -> TestResult {
647 let tmp = tempfile::tempdir()?;
648 let root = tmp.path();
649
650 fs::write(root.join("target.txt"), "data")?;
652
653 for entry in walkdir::WalkDir::new(root).max_depth(1).into_iter().flatten() {
654 if entry.path() == root {
655 continue;
656 }
657 if entry.file_type().is_file() {
658 assert!(!should_skip_dir(&entry));
660 }
661 }
662
663 Ok(())
664 }
665
666 #[test]
667 fn should_skip_dir_covers_all_six_directories() -> TestResult {
668 let tmp = tempfile::tempdir()?;
669 let root = tmp.path();
670
671 let dirs = [".git", ".hg", ".svn", "target", "node_modules", ".cache"];
672 for d in dirs {
673 fs::create_dir_all(root.join(d))?;
674 }
675
676 let mut matched = 0usize;
677 for entry in walkdir::WalkDir::new(root).max_depth(1).into_iter().flatten() {
678 if entry.path() == root {
679 continue;
680 }
681 if entry.file_type().is_dir() {
682 let name = entry.file_name().to_string_lossy();
683 if dirs.contains(&name.as_ref()) {
684 assert!(should_skip_dir(&entry), "expected {name} to be skipped");
685 matched += 1;
686 }
687 }
688 }
689
690 assert_eq!(matched, dirs.len());
691 Ok(())
692 }
693
694 #[test]
697 fn discovery_method_debug_and_equality() {
698 let git = DiscoveryMethod::Git;
699 let walk = DiscoveryMethod::Walk;
700 let git2 = DiscoveryMethod::Git;
701
702 assert_eq!(git, git2);
703 assert_ne!(git, walk);
704 let _ = format!("{git:?}");
706 let _ = format!("{walk:?}");
707 }
708
709 #[test]
710 fn discovery_method_clone_and_copy() {
711 let original = DiscoveryMethod::Git;
712 let cloned = original;
713 let copied = original;
714
715 assert_eq!(original, cloned);
716 assert_eq!(original, copied);
717 }
718
719 #[test]
722 fn discovery_result_clone_and_debug() -> TestResult {
723 let tmp = tempfile::tempdir()?;
724 let root = tmp.path();
725 create_file(root, "lib/Foo.pm")?;
726
727 let result = walk_discovery(root, Instant::now());
728 let cloned = result.clone();
729
730 assert_eq!(cloned.files.len(), result.files.len());
731 assert_eq!(cloned.method, result.method);
732 assert_eq!(cloned.excluded_count, result.excluded_count);
733 let _ = format!("{result:?}");
735
736 Ok(())
737 }
738
739 #[test]
742 fn walk_discovery_mixed_content_accurate_counts() -> TestResult {
743 let tmp = tempfile::tempdir()?;
744 let root = tmp.path();
745
746 create_file(root, "lib/A.pm")?;
748 create_file(root, "bin/b.pl")?;
749 create_file(root, "t/c.t")?;
750 create_file(root, "README.md")?;
752 create_file(root, "Makefile")?;
753
754 let result = walk_discovery(root, Instant::now());
755 assert_eq!(result.files.len(), 3);
756 assert_eq!(result.excluded_count, 2);
757
758 Ok(())
759 }
760
761 #[test]
762 fn parse_git_output_mixed_content_accurate_counts() {
763 let root = Path::new("/tmp/workspace");
764 let payload =
765 b"lib/A.pm\0bin/b.pl\0t/c.t\0app/d.psgi\0README.md\0Makefile\0node_modules/e.pm\0";
766
767 let (files, excluded_count) = parse_git_ls_files_output(root, payload);
768 assert_eq!(files.len(), 4);
769 assert_eq!(excluded_count, 3);
771 }
772
773 #[test]
774 fn parse_git_output_sorts_paths_lexically_for_determinism() {
775 let root = Path::new("/tmp/workspace");
776 let payload = b"zeta/Z.pm\0alpha/A.pm\0mid/M.pm\0";
777
778 let (files, excluded_count) = parse_git_ls_files_output(root, payload);
779
780 assert_eq!(excluded_count, 0);
781 assert_eq!(
782 files,
783 vec![root.join("alpha/A.pm"), root.join("mid/M.pm"), root.join("zeta/Z.pm"),]
784 );
785 }
786
787 #[test]
788 fn walk_discovery_sorts_paths_lexically_for_determinism() -> TestResult {
789 let tmp = tempfile::tempdir()?;
790 let root = tmp.path();
791
792 create_file(root, "zeta/Z.pm")?;
793 create_file(root, "alpha/A.pm")?;
794 create_file(root, "mid/M.pm")?;
795
796 let result = walk_discovery(root, Instant::now());
797 assert_eq!(
798 result.files,
799 vec![root.join("alpha/A.pm"), root.join("mid/M.pm"), root.join("zeta/Z.pm"),]
800 );
801
802 Ok(())
803 }
804}