Skip to main content

perl_workspace/discovery/
mod.rs

1//! Git-aware Perl workspace file discovery.
2//!
3//! Finds Perl source files in a workspace root with a two-step strategy:
4//! 1. Try `git ls-files` for fast, `.gitignore`-aware enumeration.
5//! 2. Fall back to filesystem walking with `WalkDir` when git is unavailable.
6//!
7//! The resulting behavior is intentionally conservative: common non-source directories
8//! are skipped in both modes (`.git`, `.hg`, `.svn`, `target`, `node_modules`, `.cache`).
9
10use 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/// How files were discovered.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum DiscoveryMethod {
25    /// Files discovered via `git ls-files`.
26    Git,
27    /// Files discovered via `WalkDir` traversal.
28    Walk,
29}
30
31/// File discovery result metadata.
32#[derive(Debug, Clone)]
33pub struct DiscoveryResult {
34    /// Discovered Perl source files.
35    pub files: Vec<PathBuf>,
36    /// Discovery method used.
37    pub method: DiscoveryMethod,
38    /// Elapsed discovery duration.
39    pub duration: Duration,
40    /// Number of entries excluded by extension/skip rules.
41    pub excluded_count: usize,
42}
43
44/// Discover Perl source files under `root`.
45///
46/// Strategy:
47/// 1. Attempt `git ls-files -z --cached --others --exclude-standard`
48/// 2. If git is unavailable or the root is not a repository, use `WalkDir`
49#[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/// Returns `true` if `path` should be considered discoverable by workspace
60/// indexing.
61///
62/// This intentionally includes XS implementation files, SWIG interface files,
63/// and common Perl templating formats so editor discovery can surface them
64/// even though they are not classified as Perl source files by the shared
65/// source-file helper.
66#[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    // --- Additional coverage: parse_git_ls_files_output edge cases ---
351
352    #[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        // git ls-files should never emit absolute paths, but defend against
432        // a corrupted or adversarial git output that attempts path escape.
433        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        // Embedded `..` must be rejected even when not at the start of the path.
444        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        // Two duplicate Perl paths + one non-Perl file.
462        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    // --- Additional coverage: path_contains_skipped_component ---
481
482    #[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    // --- Additional coverage: walk_discovery edge cases ---
527
528    #[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        // Duration should be non-zero (or at least not panic)
621        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        // Create a directory that looks like a .pm file (edge case)
632        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        // Only the actual file should be found, not the directory
637        assert_eq!(result.files.len(), 1);
638        assert!(result.files[0].ends_with("lib/Real.pm"));
639
640        Ok(())
641    }
642
643    // --- Additional coverage: should_skip_dir for non-directory entries ---
644
645    #[test]
646    fn should_skip_dir_returns_false_for_files() -> TestResult {
647        let tmp = tempfile::tempdir()?;
648        let root = tmp.path();
649
650        // Create a file (not a directory)
651        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                // Files should never be skipped by should_skip_dir
659                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    // --- Additional coverage: DiscoveryMethod traits ---
695
696    #[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        // Debug is derivable, just verify it doesn't panic
705        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    // --- Additional coverage: DiscoveryResult ---
720
721    #[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        // Debug format should not panic
734        let _ = format!("{result:?}");
735
736        Ok(())
737    }
738
739    // --- Additional coverage: mixed Perl and non-Perl content ---
740
741    #[test]
742    fn walk_discovery_mixed_content_accurate_counts() -> TestResult {
743        let tmp = tempfile::tempdir()?;
744        let root = tmp.path();
745
746        // 3 Perl files
747        create_file(root, "lib/A.pm")?;
748        create_file(root, "bin/b.pl")?;
749        create_file(root, "t/c.t")?;
750        // 2 non-Perl files
751        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        // README.md + Makefile (non-perl) + node_modules/e.pm (skipped dir)
770        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}