File Coverage

blib/lib/Test/Which.pm
Criterion Covered Total %
statement 190 291 65.2
branch 89 170 52.3
condition 13 48 27.0
subroutine 13 15 86.6
pod 1 1 100.0
total 306 525 58.2


line stmt bran cond sub pod time code
1             package Test::Which;
2              
3 6     6   1102600 use strict;
  6         11  
  6         214  
4 6     6   60 use warnings;
  6         17  
  6         375  
5              
6 6     6   1816 use parent 'Exporter';
  6         1245  
  6         38  
7             our @ISA = qw(Exporter);
8              
9 6     6   2376 use File::Which qw(which);
  6         5193  
  6         395  
10 6     6   1460 use version (); # provide version->parse
  6         7161  
  6         179  
11 6     6   29 use Test::Builder;
  6         9  
  6         20387  
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.07
27              
28             =cut
29              
30             our $VERSION = '0.07';
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 492106 my (@args) = @_;
325              
326 15         68 my $res = _check_requirements(@args);
327 15         50 my @missing = @{ $res->{missing} };
  15         54  
328 15         22 my @bad = @{ $res->{bad_version} };
  15         34  
329              
330 15 100 100     136 if (@missing || @bad) {
331 3         8 my @msgs;
332 3         10 push @msgs, map { "Missing required program '$_'" } @missing;
  1         3  
333 3         13 push @msgs, map { "Version issue for $_->{name}: $_->{reason}" } @bad;
  2         22  
334 3         11 my $msg = join('; ', @msgs);
335 3         49 $TEST->skip($msg);
336 3         3406 return 0;
337             }
338              
339             # Print versions if TEST_VERBOSE is set
340 12 50 33     173 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       33 $TEST->ok(1, 'Required programs available: ' . join(', ', map { $_->{name} } @{ $res->{checked} || [] }));
  12         286  
  12         82  
356 12         9503 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   2047 my ($path, $custom_flags) = @_;
362              
363 15 50       28 return undef unless defined $path;
364              
365             # Build cache key
366 15         25 my $cache_key = $path;
367 15 100       27 if (defined $custom_flags) {
368 8 100       35 if (ref $custom_flags eq 'ARRAY') {
    50          
369 1         6 $cache_key .= '|' . join(',', @$custom_flags);
370             } elsif (!ref $custom_flags) {
371 7         14 $cache_key .= '|' . $custom_flags;
372             }
373             }
374 15 100       37 return $VERSION_CACHE{$cache_key} if exists $VERSION_CACHE{$cache_key};
375              
376             # Determine flags to try
377 13         18 my @flags;
378 13 100       38 if (!defined $custom_flags) {
    100          
    50          
379 6         24 @flags = qw(--version -version -v -V);
380 6 50       15 push @flags, qw(/? -?) if $^O eq 'MSWin32';
381             }
382             elsif (ref($custom_flags) eq 'ARRAY') {
383 1         7 @flags = @$custom_flags;
384             }
385             elsif (!ref($custom_flags)) {
386 6         12 @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       42 my $timeout = defined $TIMEOUT ? $TIMEOUT : 5;
396              
397 13 50       30 my $is_win = ($^O eq 'MSWin32') ? 1 : 0;
398 13 50       63 my $is_bat = ($path =~ /\.(bat|cmd)$/i) ? 1 : 0;
399              
400             FLAG:
401 13         71 for my $flag (@flags) {
402              
403             # Build command / args
404 17         34 my @cmd;
405 17 50 33     66 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         55 @cmd = ($path);
415 17 100 66     135 push @cmd, $flag if defined $flag && length $flag;
416             }
417              
418 17         41 my ($stdout, $stderr) = ('', '');
419 17         27 my $ok = 0;
420              
421             # Try IPC::Run3 (preferred) in argv-mode
422 17 50       29 if (eval { require IPC::Run3; 1 }) {
  17         2253  
  0         0  
423 0         0 eval {
424 0     0   0 local $SIG{ALRM} = sub { die 'TIMEOUT' };
  0         0  
425 0         0 alarm $timeout;
426 0         0 IPC::Run3::run3(\@cmd, \undef, \$stdout, \$stderr);
427 0         0 alarm 0;
428             };
429 0 0       0 if ($@) {
430 0 0       0 next FLAG if $@ =~ /TIMEOUT/;
431 0         0 next FLAG;
432             }
433 0   0     0 $ok = ($stdout ne '' || $stderr ne '');
434             }
435              
436             # Fallback to shell qx{} if IPC::Run3 not available or produced no output
437 17 50       48 if (!$ok) {
438 17         19 my $shell_cmd;
439 17 50 33     72 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 17         23 my $escaped = $path;
454 17         50 $escaped =~ s/'/'\\''/g;
455 17 100 66     81 if (defined $flag && length $flag) {
456             # If flag contains spaces, shell will treat it as one word if quoted; use simple approach
457 16         23 my $f = $flag;
458 16         23 $f =~ s/'/'\\''/g;
459 16         60 $shell_cmd = qq{'$escaped' '$f' 2>&1};
460             } else {
461 1         9 $shell_cmd = qq{'$escaped' 2>&1};
462             }
463             }
464              
465 17         26 eval {
466 17     0   248 local $SIG{ALRM} = sub { die 'TIMEOUT' };
  0         0  
467 17         84 alarm $timeout;
468 17         111105 $stdout = qx{$shell_cmd};
469 17         1106 alarm 0;
470             };
471 17 50       85 next FLAG if $@;
472 17         95 $ok = ($stdout ne '');
473             }
474              
475 17 100       161 next FLAG unless $ok;
476              
477             # Merge outputs (IPC::Run3 already gave us stderr separately)
478 11         51 my $output = $stdout . $stderr;
479              
480             # Normalize newlines on Windows
481 11 50       36 $output =~ s/\r\n/\n/g if $is_win;
482              
483             # Cache and return
484 11         159 $VERSION_CACHE{$cache_key} = $output;
485 11         391 return $output;
486             }
487              
488             # Nothing worked — cache failure
489 2         36 $VERSION_CACHE{$cache_key} = undef;
490 2         26 return undef;
491             }
492              
493             # Extract the first version-like token from output
494             sub _extract_version {
495 14     14   889 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       163 if ($output =~ /version[:\s]+v?(\d+(?:\.\d+)+)/i) {
502 6         59 return $1;
503             }
504              
505             # Look at first line (common pattern)
506 6         41 my ($first_line) = split /\n/, $output;
507 6 100       66 if ($first_line =~ /\b(\d+\.\d+(?:\.\d+)*)\b/) {
508 4         39 return $1;
509             }
510              
511             # Any dotted version number
512 2 50       19 if ($output =~ /\b(\d+\.\d+(?:\.\d+)*)\b/) {
513 2         16 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   41 my ($found, $op, $required) = @_;
532              
533 8 50       65 return 0 unless defined $found;
534              
535             # Normalize version strings to have same number of components
536 8         46 my @found_parts = split /\./, $found;
537 8         32 my @req_parts = split /\./, $required;
538              
539             # Pad to same length
540 8 100       47 my $max_len = @found_parts > @req_parts ? @found_parts : @req_parts;
541 8         29 push @found_parts, (0) x ($max_len - @found_parts);
542 8         18 push @req_parts, (0) x ($max_len - @req_parts);
543              
544 8         50 my $found_normalized = join('.', @found_parts);
545 8         62 my $req_normalized = join('.', @req_parts);
546              
547             # Parse with version.pm
548 8         13 my $vf = eval { version->parse($found_normalized) };
  8         213  
549 8 50       43 if ($@) {
550 0         0 warn "Failed to parse found version '$found': $@";
551 0         0 return 0;
552             }
553              
554 8         10 my $vr = eval { version->parse($req_normalized) };
  8         53  
555 8 50       39 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         11 my $result;
562 8 50       25 if ($op eq '>=') { $result = $vf >= $vr }
  8 0       105  
    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       55 return $result ? 1 : 0;
571             }
572              
573             # Parse a constraint like ">=1.2.3" into (op, ver)
574             sub _parse_constraint {
575 9     9   41 my $spec = $_[0];
576              
577 9 50       18 return unless defined $spec;
578              
579 9 50       79 if ($spec =~ /^\s*(>=|<=|==|!=|>|<)\s*([0-9][\w\.\-]*)\s*$/) {
580 9         73 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   45 my (@args) = @_;
596              
597             # Normalize into array of hashrefs: { name => ..., constraint => undef or '>=1' or {version => ...} }
598 15         18 my @reqs;
599 15         24 my $i = 0;
600              
601 15         37 my @missing;
602 15         48 while ($i < @args) {
603 15         27 my $name = $args[$i];
604              
605             # Validate program name
606 15 50       39 unless (defined $name) {
607 0         0 push @missing, "Undefined program name at position $i, skipping";
608 0         0 $i++;
609 0         0 next;
610             }
611              
612 15 50       29 if (ref $name) {
613 0         0 push @missing, "Program name at position $i cannot be a reference, skipping";
614 0         0 $i++;
615 0         0 next;
616             }
617              
618 15         17 $i++;
619              
620             # Check if next argument is a constraint
621 15         35 my $constraint = undef;
622 15 100       38 if ($i < @args) {
623 14         27 my $next = $args[$i];
624              
625 14 50       46 if (defined $next) {
626             # String constraint: >=1.2.3, >1.0, or bare version 1.2.3
627 14 100       112 if (!ref($next)) {
    50          
628 3 50 33     19 if ($next =~ /^(?:>=|<=|==|!=|>|<)/ || $next =~ /^\d+(?:\.\d+)*$/) {
629 3         4 $constraint = $next;
630 3         8 $i++;
631             }
632             # Otherwise it's probably the next program name, don't consume it
633             } elsif (ref($next) eq 'HASH') {
634             # Hashref constraint: { version => qr/.../ } or similar
635 11         17 $constraint = $next;
636 11         20 $i++;
637             }
638             # Other refs (ARRAY, CODE, etc.) - treat as next program name, don't consume
639             }
640             }
641              
642 15         80 push @reqs, { name => $name, constraint => $constraint };
643             }
644              
645 15         34 my @bad_version;
646             my @checked;
647              
648 15         32 for my $r (@reqs) {
649 15         26 my $name = $r->{name};
650 15         21 my $want = $r->{constraint};
651              
652 15         21 my $path = $name;
653 15 50 33     119 if ($name !~ m{^/} && $name !~ m{^[A-Za-z]:[\\/]}) {
654             # Not an absolute path, search in PATH
655 15         108 $path = which($name);
656 15 100       2213 unless ($path) {
657 1         2 push @missing, $name;
658 1         2 next;
659             }
660             }
661              
662             # Verify it's executable
663 14 50       125 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       33 if (!defined $want) {
673 1         6 push @checked, { name => $name, constraint => undef, version_flag => undef };
674 1         4 next;
675             }
676              
677             # Extract custom version flags if provided
678 13         17 my $version_flag = undef;
679              
680             # Handle hashref constraints
681 13 100       42 if (ref($want) eq 'HASH') {
    50          
682             # Currently support { version => ... } and { version_flag => ... }
683              
684             # Extract version_flag if present
685 10 100       34 $version_flag = $want->{version_flag} if exists $want->{version_flag};
686              
687 10 100       24 if($version_flag) {
688 5         14 $r->{version_flag} = $version_flag;
689             }
690              
691 10 100       27 if(exists($want->{'timeout'})) {
692 1         3 $TIMEOUT = $want->{'timeout'};
693             }
694              
695 10 50       34 if (exists $want->{version}) {
696 10         15 my $version_spec = $want->{version};
697 10         29 my $found;
698 10 50       26 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         38 my $out = _capture_version_output($path, $version_flag);
706 10         106 $found = _extract_version($out);
707             }
708              
709 10 100       36 unless (defined $found) {
710 1         14 push @bad_version, {
711             name => $name,
712             reason => 'no version detected for hashref constraint'
713             };
714 1         11 next;
715             }
716              
717             # Regex constraint
718 9 100       71 if (ref($version_spec) eq 'Regexp') {
    50          
719 3 50       70 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         43 my ($op, $ver) = _parse_constraint($version_spec);
729 6 50       32 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       31 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         7 my ($op, $ver) = _parse_constraint($want);
762 3 50       20 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         7 my $out = _capture_version_output($path);
771 3         18 my $found = _extract_version($out);
772              
773 3 100       20 unless (defined $found) {
774 1         43 push @bad_version, {
775             name => $name,
776             reason => 'no version detected'
777             };
778 1         14 next;
779             }
780              
781 2 50       13 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         105 push @checked, $r;
799             }
800              
801             return {
802 15         276 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   29457 my $class = shift;
811 12         1735 $class->export_to_level(1, $class, @EXPORT_OK);
812              
813             # Only run requirement checks if any args remain
814 12         53 my @reqs = grep { $_ ne 'which_ok' } @_;
  8         25  
815              
816 12 50       5207 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__