File Coverage

blib/lib/Test/Which.pm
Criterion Covered Total %
statement 197 291 67.7
branch 90 170 52.9
condition 15 48 31.2
subroutine 13 15 86.6
pod 1 1 100.0
total 316 525 60.1


line stmt bran cond sub pod time code
1             package Test::Which;
2              
3 6     6   1587421 use strict;
  6         17  
  6         265  
4 6     6   51 use warnings;
  6         21  
  6         435  
5              
6 6     6   2250 use parent 'Exporter';
  6         1487  
  6         72  
7             our @ISA = qw(Exporter);
8              
9 6     6   3297 use File::Which qw(which);
  6         10162  
  6         562  
10 6     6   2376 use version (); # provide version->parse
  6         10822  
  6         248  
11 6     6   38 use Test::Builder;
  6         13  
  6         31332  
12              
13             our @EXPORT_OK = qw(which_ok);
14             our %EXPORT_TAGS = ( all => \@EXPORT_OK );
15              
16             my %VERSION_CACHE;
17             my $TEST = Test::Builder->new();
18             our $TIMEOUT = 5; # Seconds
19              
20             =head1 NAME
21              
22             Test::Which - Skip tests if external programs are missing from PATH (with version checks)
23              
24             =head1 VERSION
25              
26             Version 0.06
27              
28             =cut
29              
30             our $VERSION = '0.06';
31              
32             =head1 SYNOPSIS
33              
34             use Test::Which 'ffmpeg' => '>=6.0', 'convert' => '>=7.1';
35              
36             # At runtime in a subtest or test body
37             use Test::Which qw(which_ok);
38              
39             subtest 'needs ffmpeg' => sub {
40             which_ok 'ffmpeg' => '>=6.0' or return;
41             ... # tests that use ffmpeg
42             };
43              
44             =head1 DESCRIPTION
45              
46             C mirrors L but checks for executables in PATH.
47             It can also check version constraints using a built-in heuristic that tries
48             common version flags (--version, -version, -v, -V) and extracts version numbers
49             from the output.
50              
51             If a version is requested but cannot be determined, the requirement fails.
52              
53             Key features:
54              
55             =over 4
56              
57             =item * Compile-time and runtime checking of program availability
58              
59             =item * Version comparison with standard operators (>=, >, <, <=, ==, !=)
60              
61             =item * Regular expression matching for version strings
62              
63             =item * Custom version flag support for non-standard programs
64              
65             =item * Custom version extraction for unusual output formats
66              
67             =item * Caching to avoid repeated program execution
68              
69             =item * Cross-platform support (Unix, Linux, macOS, Windows)
70              
71             =back
72              
73             =head1 EXAMPLES
74              
75             =head2 Basic Usage
76              
77             Check for program availability without version constraints:
78              
79             use Test::Which qw(which_ok);
80              
81             which_ok 'perl', 'ffmpeg', 'convert';
82              
83             =head2 Version Constraints
84              
85             Check programs with minimum version requirements:
86              
87             # String constraints with comparison operators
88             which_ok 'perl' => '>=5.10';
89             which_ok 'ffmpeg' => '>=4.0', 'convert' => '>=7.1';
90              
91             # Exact version match
92             which_ok 'node' => '==18.0.0';
93              
94             # Version range
95             which_ok 'python' => '>=3.8', 'python' => '<4.0';
96              
97             =head2 Hashref Syntax
98              
99             Use hashrefs for more complex constraints:
100              
101             # String version in hashref
102             which_ok 'perl', { version => '>=5.10' };
103              
104             # Regex matching
105             which_ok 'perl', { version => qr/5\.\d+/ };
106             which_ok 'ffmpeg', { version => qr/^[4-6]\./ };
107              
108             =head2 Custom Version Flags
109              
110             Some programs use non-standard flags to display version information:
111              
112             # Java uses -version (single dash)
113             which_ok 'java', {
114             version => '>=11',
115             version_flag => '-version'
116             };
117              
118             # Try multiple flags in order
119             which_ok 'myprogram', {
120             version => '>=2.0',
121             version_flag => ['--show-version', '-version', '--ver']
122             };
123              
124             # Program prints version without any flag
125             which_ok 'sometool', {
126             version => '>=1.0',
127             version_flag => '',
128             timeout => 10, # seconds - the default is 5
129             };
130              
131             # Windows-specific flag
132             which_ok 'cmd', {
133             version => qr/\d+/,
134             version_flag => '/?'
135             } if $^O eq 'MSWin32';
136              
137             If C is not specified, the module tries these flags in order:
138             C<--version>, C<-version>, C<-v>, C<-V> (and C, C<-?> on Windows)
139              
140             =head2 Custom Version Extraction
141              
142             For programs with unusual version output formats:
143              
144             which_ok 'myprogram', {
145             version => '>=1.0',
146             extractor => sub {
147             my $output = shift;
148             return $1 if $output =~ /Build (\d+\.\d+)/;
149             return undef;
150             }
151             };
152              
153             The extractor receives the program's output and should return the version
154             string or undef if no version could be found.
155              
156             =head2 Mixed Usage
157              
158             Combine different constraint types:
159              
160             which_ok
161             'perl' => '>=5.10', # String constraint
162             'ffmpeg', # No constraint
163             'convert', { version => qr/^7\./ }; # Regex constraint
164              
165             =head2 Compile-Time Checking
166              
167             Skip entire test files if requirements aren't met:
168              
169             use Test::Which 'ffmpeg' => '>=6.0', 'convert' => '>=7.1';
170              
171             # Test file is skipped if either program is missing or the version is too old
172             # No tests below this line will run if requirements aren't met
173              
174             =head2 Runtime Checking in Subtests
175              
176             Check requirements for individual subtests:
177              
178             use Test::Which qw(which_ok);
179              
180             subtest 'video conversion' => sub {
181             which_ok 'ffmpeg' => '>=4.0' or return;
182             # ... tests using ffmpeg
183             };
184              
185             subtest 'image processing' => sub {
186             which_ok 'convert' => '>=7.0' or return;
187             # ... tests using ImageMagick
188             };
189              
190             =head2 Absolute Paths
191              
192             You can specify absolute paths instead of searching PATH:
193              
194             which_ok '/usr/local/bin/myprogram' => '>=1.0';
195              
196             The program must be executable.
197              
198             =head1 VERSION DETECTION
199              
200             The module attempts to detect version numbers using these strategies in order:
201              
202             =over 4
203              
204             =item 1. Look for version near the word "version" (case-insensitive)
205              
206             Matches patterns like: C, C
207              
208             =item 2. Extract dotted version from first line of output
209              
210             Common for programs that print version info prominently
211              
212             =item 3. Find any dotted version number in output
213              
214             Fallback for less standard formats
215              
216             =item 4. Look for single number near "version"
217              
218             For programs that use simple integer versioning
219              
220             =item 5. Use any standalone number found
221              
222             Last resort - least reliable
223              
224             =back
225              
226             =head1 VERSION COMPARISON
227              
228             Version comparison uses Perl's L module. Versions are normalized
229             to have the same number of components before comparison to avoid
230             C's parsing quirks.
231              
232             For example:
233             - C<2020.10> becomes C<2020.10.0>
234             - C<2020.10.15> stays C<2020.10.15>
235             - Then they're compared correctly
236              
237             Supported operators: C<< >= >>, C<< > >>, C<< <= >>, C<< < >>, C<=>, C
238              
239             =head1 CACHING
240              
241             Version detection results are cached to avoid repeated program execution.
242             Each unique combination of program path and version flags creates a separate
243             cache entry.
244              
245             Cache benefits:
246             - Faster repeated checks in test suites
247             - Reduced system load
248             - Works across multiple test files in the same process
249              
250             The cache persists for the lifetime of the Perl process.
251              
252             =head1 VERBOSE OUTPUT
253              
254             Set environment variables to see detected versions:
255              
256             TEST_WHICH_VERBOSE=1 prove -v t/mytest.t
257             TEST_VERBOSE=1 perl t/mytest.t
258             prove -v t/mytest.t # HARNESS_IS_VERBOSE is set automatically
259              
260             Output includes the detected version for each checked program:
261              
262             # perl: version 5.38.0
263             # ffmpeg: version 6.1.1
264              
265             =head1 PLATFORM SUPPORT
266              
267             =over 4
268              
269             =item * B: Full support for all features
270              
271             =item * B: Basic functionality supported. Complex shell features
272             (STDERR redirection, empty flags) may have limitations.
273              
274             =back
275              
276             =head1 DIAGNOSTICS
277              
278             Common error messages:
279              
280             =over 4
281              
282             =item C
283              
284             The program 'foo' could not be found in PATH.
285              
286             =item C
287              
288             The program exists but the module couldn't extract a version number from
289             its output. Try specifying a custom C or C.
290              
291             =item C=2.0>
292              
293             The program's version doesn't meet the constraint.
294              
295             =item C
296              
297             For regex constraints, the detected version didn't match the pattern.
298              
299             =item C
300              
301             When using hashref syntax, you must include a C key.
302              
303             =item C
304              
305             The version constraint string couldn't be parsed. Use formats like
306             C<'>=1.2.3'>, C<'>2.0'>, or C<'1.5'>.
307              
308             =back
309              
310             =head1 FUNCTIONS/METHODS
311              
312             =head2 which_ok @programs_or_pairs
313              
314             Checks the named programs (with optional version constraints).
315             If any requirement is not met, the current test or subtest is skipped
316             via L.
317              
318             Returns true if all requirements are met, false otherwise.
319              
320             =cut
321              
322             # runtime function, returns true if all present & satisfy versions, otherwise calls skip
323             sub which_ok {
324 15     15 1 557975 my (@args) = @_;
325              
326 15         80 my $res = _check_requirements(@args);
327 15         108 my @missing = @{ $res->{missing} };
  15         119  
328 15         32 my @bad = @{ $res->{bad_version} };
  15         48  
329              
330 15 100 100     163 if (@missing || @bad) {
331 3         13 my @msgs;
332 3         12 push @msgs, map { "Missing required program '$_'" } @missing;
  1         5  
333 3         54 push @msgs, map { "Version issue for $_->{name}: $_->{reason}" } @bad;
  2         22  
334 3         21 my $msg = join('; ', @msgs);
335 3         72 $TEST->skip($msg);
336 3         5096 return 0;
337             }
338              
339             # Print versions if TEST_VERBOSE is set
340 12 50 33     156 if ($ENV{TEST_WHICH_VERBOSE} || $ENV{TEST_VERBOSE} || $ENV{HARNESS_IS_VERBOSE}) {
      33        
341 0         0 for my $r (@{ $res->{checked} }) {
  0         0  
342 0         0 my $name = $r->{name};
343 0         0 my $out = _capture_version_output(which($name), $r->{'version_flag'});
344 0         0 my $version = _extract_version($out);
345              
346 0 0       0 if (defined $version) {
347 0         0 $TEST->diag("$name: version $version");
348             } else {
349 0         0 $TEST->diag("$name: found but version unknown");
350             }
351             }
352             }
353              
354             # Actually run a passing test
355 12 50       31 $TEST->ok(1, 'Required programs available: ' . join(', ', map { $_->{name} } @{ $res->{checked} || [] }));
  12         277  
  12         97  
356 12         11435 return 1;
357             }
358              
359             # Helper: run a program with one of the version flags and capture output
360             sub _capture_version_output {
361 15     15   2900 my ($path, $custom_flags) = @_;
362              
363 15 50       47 return undef unless defined $path;
364              
365             # Build cache key
366 15         31 my $cache_key = $path;
367 15 100       49 if (defined $custom_flags) {
368 8 100       56 if (ref $custom_flags eq 'ARRAY') {
    50          
369 1         11 $cache_key .= '|' . join(',', @$custom_flags);
370             } elsif (!ref $custom_flags) {
371 7         21 $cache_key .= '|' . $custom_flags;
372             }
373             }
374 15 100       61 return $VERSION_CACHE{$cache_key} if exists $VERSION_CACHE{$cache_key};
375              
376             # Determine flags to try
377 13         23 my @flags;
378 13 100       53 if (!defined $custom_flags) {
    100          
    50          
379 6         35 @flags = qw(--version -version -v -V);
380 6 50       26 push @flags, qw(/? -?) if $^O eq 'MSWin32';
381             }
382             elsif (ref($custom_flags) eq 'ARRAY') {
383 1         12 @flags = @$custom_flags;
384             }
385             elsif (!ref($custom_flags)) {
386 6         18 @flags = ($custom_flags); # allow empty string ''
387             }
388             else {
389 0         0 warn "Invalid version_flag type: ", ref($custom_flags);
390 0         0 $VERSION_CACHE{$cache_key} = undef;
391 0         0 return undef;
392             }
393              
394             # timeout (default to 5 seconds if not set)
395 13 50       41 my $timeout = defined $TIMEOUT ? $TIMEOUT : 5;
396              
397 13 50       50 my $is_win = ($^O eq 'MSWin32') ? 1 : 0;
398 13 50       106 my $is_bat = ($path =~ /\.(bat|cmd)$/i) ? 1 : 0;
399              
400             FLAG:
401 13         42 for my $flag (@flags) {
402              
403             # Build command / args
404 17         60 my @cmd;
405 17 50 33     64 if ($is_win && $is_bat) {
406             # For .bat/.cmd on Windows, call cmd.exe /c "prog [flag]"
407             # Build a single command string for cmd.exe /c; quote path if it contains spaces
408 0 0       0 my $path_part = ($path =~ /\s/) ? qq{"$path"} : $path;
409 0         0 my $cmdstr = $path_part;
410 0 0 0     0 $cmdstr .= " $flag" if defined $flag && length $flag;
411 0         0 @cmd = ('cmd.exe', '/c', $cmdstr);
412             } else {
413             # Normal argv-style call for binaries / scripts
414 17         53 @cmd = ($path);
415 17 100 66     194 push @cmd, $flag if defined $flag && length $flag;
416             }
417              
418 17         72 my ($stdout, $stderr) = ('', '');
419 17         35 my $ok = 0;
420              
421             # Try IPC::Run3 (preferred) in argv-mode
422 17 50       52 if (eval { require IPC::Run3; 1 }) {
  17         2582  
  17         77678  
423 17         40 eval {
424 17     0   363 local $SIG{ALRM} = sub { die 'TIMEOUT' };
  0         0  
425 17         161 alarm $timeout;
426 17         221 IPC::Run3::run3(\@cmd, \undef, \$stdout, \$stderr);
427 17         132599 alarm 0;
428             };
429 17 50       189 if ($@) {
430 0 0       0 next FLAG if $@ =~ /TIMEOUT/;
431 0         0 next FLAG;
432             }
433 17   100     212 $ok = ($stdout ne '' || $stderr ne '');
434             }
435              
436             # Fallback to shell qx{} if IPC::Run3 not available or produced no output
437 17 100       140 if (!$ok) {
438 6         68 my $shell_cmd;
439 6 50 33     154 if ($is_win && $is_bat) {
    50          
440             # Use cmd.exe /c "prog [flag]" and capture stderr
441 0 0       0 my $path_part = ($path =~ /\s/) ? qq{"$path"} : $path;
442 0         0 my $inner = $path_part;
443 0 0 0     0 $inner .= " $flag" if defined $flag && length $flag;
444 0         0 $shell_cmd = qq{cmd.exe /c "$inner" 2>&1};
445             }
446             elsif ($is_win) {
447             # Non-bat on Windows — quote path and append flag
448 0 0 0     0 my $flagpart = defined $flag && length $flag ? " $flag" : '';
449 0         0 $shell_cmd = qq{"$path"$flagpart 2>&1};
450             }
451             else {
452             # Unix: single-quote the path; if flag present pass it unquoted (shell will split)
453 6         38 my $escaped = $path;
454 6         60 $escaped =~ s/'/'\\''/g;
455 6 50 33     169 if (defined $flag && length $flag) {
456             # If flag contains spaces, shell will treat it as one word if quoted; use simple approach
457 6         40 my $f = $flag;
458 6         22 $f =~ s/'/'\\''/g;
459 6         22 $shell_cmd = qq{'$escaped' '$f' 2>&1};
460             } else {
461 0         0 $shell_cmd = qq{'$escaped' 2>&1};
462             }
463             }
464              
465 6         19 eval {
466 6     0   204 local $SIG{ALRM} = sub { die 'TIMEOUT' };
  0         0  
467 6         67 alarm $timeout;
468 6         48749 $stdout = qx{$shell_cmd};
469 6         544 alarm 0;
470             };
471 6 50       62 next FLAG if $@;
472 6         75 $ok = ($stdout ne '');
473             }
474              
475 17 100       264 next FLAG unless $ok;
476              
477             # Merge outputs (IPC::Run3 already gave us stderr separately)
478 11         91 my $output = $stdout . $stderr;
479              
480             # Normalize newlines on Windows
481 11 50       48 $output =~ s/\r\n/\n/g if $is_win;
482              
483             # Cache and return
484 11         150 $VERSION_CACHE{$cache_key} = $output;
485 11         300 return $output;
486             }
487              
488             # Nothing worked — cache failure
489 2         47 $VERSION_CACHE{$cache_key} = undef;
490 2         35 return undef;
491             }
492              
493             # Extract the first version-like token from output
494             sub _extract_version {
495 14     14   1264 my $output = $_[0];
496              
497 14 100       61 return undef unless defined $output;
498              
499             # Look for version near the word "version"
500             # Handles: "ffmpeg version 4.2.7", "Version: 2.1.0", "ImageMagick 7.1.0-4"
501 12 100       218 if ($output =~ /version[:\s]+v?(\d+(?:\.\d+)+)/i) {
502 6         70 return $1;
503             }
504              
505             # Look at first line (common pattern)
506 6         49 my ($first_line) = split /\n/, $output;
507 6 100       117 if ($first_line =~ /\b(\d+\.\d+(?:\.\d+)*)\b/) {
508 4         38 return $1;
509             }
510              
511             # Any dotted version number
512 2 50       38 if ($output =~ /\b(\d+\.\d+(?:\.\d+)*)\b/) {
513 2         15 return $1;
514             }
515              
516             # Single number near "version"
517 0 0       0 if ($output =~ /version[:\s]+v?(\d+)\b/i) {
518 0         0 return $1;
519             }
520              
521             # Just a standalone number (least reliable)
522 0 0       0 if ($output =~ /\b(\d+)\b/) {
523 0         0 return $1;
524             }
525              
526 0         0 return undef;
527             }
528              
529             # Compare two versions given an operator
530             sub _version_satisfies {
531 8     8   61 my ($found, $op, $required) = @_;
532              
533 8 50       37 return 0 unless defined $found;
534              
535             # Normalize version strings to have same number of components
536 8         47 my @found_parts = split /\./, $found;
537 8         59 my @req_parts = split /\./, $required;
538              
539             # Pad to same length
540 8 100       67 my $max_len = @found_parts > @req_parts ? @found_parts : @req_parts;
541 8         33 push @found_parts, (0) x ($max_len - @found_parts);
542 8         30 push @req_parts, (0) x ($max_len - @req_parts);
543              
544 8         53 my $found_normalized = join('.', @found_parts);
545 8         52 my $req_normalized = join('.', @req_parts);
546              
547             # Parse with version.pm
548 8         22 my $vf = eval { version->parse($found_normalized) };
  8         346  
549 8 50       37 if ($@) {
550 0         0 warn "Failed to parse found version '$found': $@";
551 0         0 return 0;
552             }
553              
554 8         16 my $vr = eval { version->parse($req_normalized) };
  8         121  
555 8 50       34 if ($@) {
556 0         0 warn "Failed to parse required version '$required': $@";
557 0         0 return 0;
558             }
559              
560             # Return explicit 1 or 0
561 8         16 my $result;
562 8 50       62 if ($op eq '>=') { $result = $vf >= $vr }
  8 0       155  
    0          
    0          
    0          
    0          
563 0         0 elsif ($op eq '>') { $result = $vf > $vr }
564 0         0 elsif ($op eq '<=') { $result = $vf <= $vr }
565 0         0 elsif ($op eq '<') { $result = $vf < $vr }
566 0         0 elsif ($op eq '==') { $result = $vf == $vr }
567 0         0 elsif ($op eq '!=') { $result = $vf != $vr }
568 0         0 else { $result = $vf == $vr }
569              
570 8 50       99 return $result ? 1 : 0;
571             }
572              
573             # Parse a constraint like ">=1.2.3" into (op, ver)
574             sub _parse_constraint {
575 9     9   50 my $spec = $_[0];
576              
577 9 50       31 return unless defined $spec;
578              
579 9 50       136 if ($spec =~ /^\s*(>=|<=|==|!=|>|<)\s*([0-9][\w\.\-]*)\s*$/) {
580 9         109 return ($1, $2);
581             }
582             # allow bare version (implies ==)
583 0 0       0 if ($spec =~ /^\s*(\d+(?:\.\d+)*)\s*$/) {
584 0         0 return ('==', $1);
585             }
586              
587             # If we get here, it's invalid
588             # Return empty list, but caller should provide an helpful error
589 0         0 return;
590             }
591              
592             # Core check routine. Accepts a list of program => maybe_constraint pairs,
593             # or simple program names in the list form.
594             sub _check_requirements {
595 15     15   68 my (@args) = @_;
596              
597             # Normalize into array of hashrefs: { name => ..., constraint => undef or '>=1' or {version => ...} }
598 15         32 my @reqs;
599 15         37 my $i = 0;
600              
601 15         350 while ($i < @args) {
602 15         42 my $name = $args[$i];
603              
604             # Validate program name
605 15 50       55 unless (defined $name) {
606 0         0 warn "Undefined program name at position $i, skipping";
607 0         0 $i++;
608 0         0 next;
609             }
610              
611 15 50       65 if (ref $name) {
612 0         0 warn "Program name at position $i cannot be a reference, skipping";
613 0         0 $i++;
614 0         0 next;
615             }
616              
617 15         29 $i++;
618              
619             # Check if next argument is a constraint
620 15         35 my $constraint = undef;
621 15 100       66 if ($i < @args) {
622 14         42 my $next = $args[$i];
623              
624 14 50       55 if (defined $next) {
625             # String constraint: >=1.2.3, >1.0, or bare version 1.2.3
626 14 100       105 if (!ref($next)) {
    50          
627 3 50 33     33 if ($next =~ /^(?:>=|<=|==|!=|>|<)/ || $next =~ /^\d+(?:\.\d+)*$/) {
628 3         12 $constraint = $next;
629 3         12 $i++;
630             }
631             # Otherwise it's probably the next program name, don't consume it
632             } elsif (ref($next) eq 'HASH') {
633             # Hashref constraint: { version => qr/.../ } or similar
634 11         53 $constraint = $next;
635 11         27 $i++;
636             }
637             # Other refs (ARRAY, CODE, etc.) - treat as next program name, don't consume
638             }
639             }
640              
641 15         140 push @reqs, { name => $name, constraint => $constraint };
642             }
643              
644 15         74 my @missing;
645             my @bad_version;
646 15         0 my @checked;
647              
648 15         48 for my $r (@reqs) {
649 15         36 my $name = $r->{name};
650 15         30 my $want = $r->{constraint};
651              
652 15         29 my $path = $name;
653 15 50 33     198 if ($name !~ m{^/} && $name !~ m{^[A-Za-z]:[\\/]}) {
654             # Not an absolute path, search in PATH
655 15         165 $path = which($name);
656 15 100       3357 unless ($path) {
657 1         4 push @missing, $name;
658 1         3 next;
659             }
660             }
661              
662             # Verify it's executable
663 14 50       216 unless (-x $path) {
664 0         0 push @bad_version, {
665             name => $name,
666             reason => "found at $path but not executable"
667             };
668 0         0 next;
669             }
670              
671             # No version constraint - just check if it exists
672 14 100       72 if (!defined $want) {
673 1         6 push @checked, { name => $name, constraint => undef, version_flag => undef };
674 1         3 next;
675             }
676              
677             # Extract custom version flags if provided
678 13         33 my $version_flag = undef;
679              
680             # Handle hashref constraints
681 13 100       95 if (ref($want) eq 'HASH') {
    50          
682             # Currently support { version => ... } and { version_flag => ... }
683              
684             # Extract version_flag if present
685 10 100       65 $version_flag = $want->{version_flag} if exists $want->{version_flag};
686              
687 10 100       63 if($version_flag) {
688 5         18 $r->{version_flag} = $version_flag;
689             }
690              
691 10 100       44 if(exists($want->{'timeout'})) {
692 1         3 $TIMEOUT = $want->{'timeout'};
693             }
694              
695 10 50       37 if (exists $want->{version}) {
696 10         28 my $version_spec = $want->{version};
697 10         20 my $found;
698 10 50       35 if (exists $want->{extractor}) {
699 0         0 my $extractor = $want->{extractor};
700 0 0       0 if (ref($extractor) eq 'CODE') {
701 0         0 my $out = _capture_version_output($path, $version_flag);
702 0         0 $found = $extractor->($out);
703             }
704             } else {
705 10         81 my $out = _capture_version_output($path, $version_flag);
706 10         111 $found = _extract_version($out);
707             }
708              
709 10 100       64 unless (defined $found) {
710 1         20 push @bad_version, {
711             name => $name,
712             reason => 'no version detected for hashref constraint'
713             };
714 1         12 next;
715             }
716              
717             # Regex constraint
718 9 100       91 if (ref($version_spec) eq 'Regexp') {
    50          
719 3 50       90 unless ($found =~ $version_spec) {
720 0         0 push @bad_version, {
721             name => $name,
722             reason => "found version $found but doesn't match pattern $version_spec"
723             };
724 0         0 next;
725             }
726             } elsif (!ref($version_spec)) {
727             # String constraint within hashref (treat like normal string constraint)
728 6         104 my ($op, $ver) = _parse_constraint($version_spec);
729 6 50       40 unless (defined $op) {
730 0         0 push @bad_version, {
731             name => $name,
732             reason => "invalid constraint in hashref '$version_spec' (expected format: '>=1.2.3', '>2.0', '==1.5', or '1.5')"
733             };
734 0         0 next;
735             }
736 6 50       35 unless (_version_satisfies($found, $op, $ver)) {
737 0         0 push @bad_version, {
738             name => $name,
739             reason => "found $found but need $op$ver"
740             };
741 0         0 next;
742             }
743             } else {
744             # Unsupported type in hashref
745 0         0 push @bad_version, {
746             name => $name,
747             reason => "unsupported version spec type in hashref: " . ref($version_spec)
748             };
749 0         0 next;
750             }
751             } else {
752             # Hashref without 'version' key
753 0         0 push @bad_version, {
754             name => $name,
755             reason => "hashref constraint must contain 'version' key"
756             };
757 0         0 next;
758             }
759             } elsif (!ref($want)) {
760             # Handle string constraints
761 3         9 my ($op, $ver) = _parse_constraint($want);
762 3 50       11 unless (defined $op) {
763 0         0 push @bad_version, {
764             name => $name,
765             reason => "invalid constraint '$want' (expected format: '>=1.2.3', '>2.0', '==1.5', or '1.5')"
766             };
767 0         0 next;
768             }
769              
770 3         12 my $out = _capture_version_output($path);
771 3         31 my $found = _extract_version($out);
772              
773 3 100       18 unless (defined $found) {
774 1         28 push @bad_version, {
775             name => $name,
776             reason => 'no version detected'
777             };
778 1         18 next;
779             }
780              
781 2 50       20 unless (_version_satisfies($found, $op, $ver)) {
782 0         0 push @bad_version, {
783             name => $name,
784             reason => "found $found but need $op$ver"
785             };
786 0         0 next;
787             }
788             } else {
789             # Unsupported constraint type
790 0         0 push @bad_version, {
791             name => $name,
792             reason => "unsupported constraint type: " . ref($want)
793             };
794 0         0 next;
795             }
796              
797             # If we got here, the program passed all checks
798 11         166 push @checked, $r;
799             }
800              
801             return {
802 15         339 missing => \@missing,
803             bad_version => \@bad_version,
804             checked => \@checked
805             };
806             }
807              
808             # import: allow compile-time checks like `use Test::Which 'prog' => '>=1.2';`
809             sub import {
810 12     12   41272 my $class = shift;
811 12         2414 $class->export_to_level(1, $class, @EXPORT_OK);
812              
813             # Only run requirement checks if any args remain
814 12         72 my @reqs = grep { $_ ne 'which_ok' } @_;
  8         37  
815              
816 12 50       6446 return unless @reqs;
817              
818 0           my $res = _check_requirements(@reqs);
819 0           my @missing = @{ $res->{missing} };
  0            
820 0           my @bad = @{ $res->{bad_version} };
  0            
821              
822 0 0 0       if (@missing || @bad) {
823 0           my @msgs;
824 0           push @msgs, map { "Missing required program '$_'" } @missing;
  0            
825 0           push @msgs, map { "Version issue for $_->{name}: $_->{reason}" } @bad;
  0            
826 0           my $msg = join('; ', @msgs);
827 0           $TEST->plan(skip_all => "Test::Which requirements not met: $msg");
828             }
829              
830             # Print versions if TEST_VERBOSE is set
831 0 0 0       if ($ENV{TEST_WHICH_VERBOSE} || $ENV{TEST_VERBOSE} || $ENV{HARNESS_IS_VERBOSE}) {
      0        
832 0           for my $r (@{ $res->{checked} }) {
  0            
833 0           my $name = $r->{name};
834 0           my $out = _capture_version_output(which($name), $r->{'version_flag'});
835 0           my $version = _extract_version($out);
836              
837 0 0         if (defined $version) {
838 0           print STDERR "# $name: version $version\n";
839             } else {
840 0           print STDERR "# $name: found but version unknown\n";
841             }
842             }
843             }
844             }
845              
846             1;
847              
848             __END__