File Coverage

bin/pjstruct
Criterion Covered Total %
statement 194 256 75.7
branch 62 92 67.3
condition 12 22 54.5
subroutine 24 27 88.8
pod n/a
total 292 397 73.5


line stmt bran cond sub pod time code
1             #!/usr/bin/env perl
2             # ABSTRACT: JSON-Structure validator CLI for Perl
3             # PODNAME: pjstruct
4              
5 12     12   61081 use strict;
  12         23  
  12         493  
6 12     12   57 use warnings;
  12         22  
  12         672  
7 12     12   219 use 5.020;
  12         38  
8 12     12   66 use feature 'signatures';
  12         28  
  12         2426  
9 12     12   115 no warnings 'experimental::signatures';
  12         19  
  12         551  
10              
11 12     12   10529 use Getopt::Long qw(:config gnu_getopt bundling);
  12         201558  
  12         72  
12 12     12   9819 use Pod::Usage;
  12         896633  
  12         11794  
13 12     12   6571 use JSON::MaybeXS;
  12         147618  
  12         934  
14 12     12   112 use File::Basename;
  12         20  
  12         1166  
15              
16 12     12   6115 use JSON::Structure;
  12         47  
  12         965  
17              
18 12         2048396 our $VERSION = '0.01';
19              
20             # Exit codes
21             use constant {
22 12         68171 EXIT_SUCCESS => 0,
23             EXIT_INVALID => 1,
24             EXIT_ERROR => 2,
25 12     12   78 };
  12         19  
26              
27             # Main entry point
28 12         88 exit main(@ARGV);
29              
30 12     12   33 sub main (@args) {
  12         46  
  12         26  
31 12         49 local @ARGV = @args;
32            
33             # Global options
34 12         93 my %opts = (
35             format => 'text',
36             quiet => 0,
37             verbose => 0,
38             help => 0,
39             version => 0,
40             );
41            
42             # Parse global options first
43 12         110 Getopt::Long::Configure('pass_through');
44             GetOptions(
45             'h|help' => \$opts{help},
46             'V|version' => \$opts{version},
47 12 50       591 ) or return EXIT_ERROR;
48            
49 12 100       10247 if ($opts{version}) {
50 1         8 say "pjstruct version $VERSION (JSON::Structure $JSON::Structure::VERSION)";
51 1         0 return EXIT_SUCCESS;
52             }
53            
54             # Get command
55 11   100     57 my $command = shift @ARGV // '';
56            
57 11 100 100     123 if ($opts{help} || $command eq 'help' || $command eq '') {
      66        
58 2 100       14 return cmd_help($command eq 'help' ? shift @ARGV : undef);
59             }
60            
61             # Dispatch to command
62             my %commands = (
63             'validate' => \&cmd_validate,
64             'v' => \&cmd_validate,
65             'check' => \&cmd_check,
66             'c' => \&cmd_check,
67 0     0   0 'version' => sub { say "pjstruct version $VERSION"; return EXIT_SUCCESS; },
  0         0  
68 9         120 );
69            
70 9 50       46 if (my $handler = $commands{$command}) {
71 9         43 return $handler->(\%opts);
72             }
73             else {
74 0         0 warn "pjstruct: unknown command '$command'\n";
75 0         0 warn "Run 'pjstruct help' for usage.\n";
76 0         0 return EXIT_ERROR;
77             }
78             }
79              
80 2     2   3 sub cmd_help ($topic = undef) {
  2         5  
  2         3  
81 2 100       9 if (!defined $topic) {
82 1         5 print_usage();
83 1         0 return EXIT_SUCCESS;
84             }
85            
86 1         7 my %topics = (
87             'validate' => \&help_validate,
88             'v' => \&help_validate,
89             'check' => \&help_check,
90             'c' => \&help_check,
91             );
92            
93 1 50       2 if (my $handler = $topics{$topic}) {
94 1         5 $handler->();
95 1         0 return EXIT_SUCCESS;
96             }
97             else {
98 0         0 warn "pjstruct: unknown help topic '$topic'\n";
99 0         0 return EXIT_ERROR;
100             }
101             }
102              
103             sub print_usage {
104 1     1   6 print <<"EOF";
105             pjstruct - JSON-Structure validator for Perl
106              
107             Usage: pjstruct [options] [files...]
108              
109             Commands:
110             validate, v Validate instance(s) against a schema
111             check, c Check schema(s) for validity
112             help Show help for a command
113             version Show version information
114              
115             Options:
116             -h, --help Show help
117             -V, --version Show version
118              
119             Examples:
120             pjstruct check schema.struct.json
121             pjstruct validate -s schema.struct.json data.json
122             pjstruct v -s schema.struct.json *.json
123              
124             Run 'pjstruct help ' for more information on a command.
125             EOF
126             }
127              
128             sub help_validate {
129 1     1   4 print <<"EOF";
130             pjstruct validate - Validate instance(s) against a schema
131              
132             Usage: pjstruct validate [options] ...
133              
134             Options:
135             -s, --schema Schema file (required)
136             -f, --format Output format: text, json, tap (default: text)
137             -q, --quiet Suppress output, use exit code only
138             -v, --verbose Show detailed validation information
139             -h, --help Show this help
140              
141             Arguments:
142             ... JSON instance file(s) to validate
143             Use '-' to read from stdin
144              
145             Exit codes:
146             0 All instances are valid
147             1 One or more instances are invalid
148             2 Error (file not found, parse error, etc.)
149              
150             Examples:
151             pjstruct validate -s schema.struct.json data.json
152             pjstruct validate -s schema.struct.json *.json
153             pjstruct validate -s schema.struct.json data.json --format=json
154             cat data.json | pjstruct validate -s schema.struct.json -
155             EOF
156             }
157              
158             sub help_check {
159 0     0   0 print <<"EOF";
160             pjstruct check - Check schema(s) for validity
161              
162             Usage: pjstruct check [options] ...
163              
164             Options:
165             -f, --format Output format: text, json, tap (default: text)
166             -q, --quiet Suppress output, use exit code only
167             -v, --verbose Show detailed validation information
168             -h, --help Show this help
169              
170             Arguments:
171             ... Schema file(s) to check
172             Use '-' to read from stdin
173              
174             Exit codes:
175             0 All schemas are valid
176             1 One or more schemas are invalid
177             2 Error (file not found, parse error, etc.)
178              
179             Examples:
180             pjstruct check schema.struct.json
181             pjstruct check *.struct.json
182             pjstruct check schema.struct.json --format=json
183             EOF
184             }
185              
186 7     7   18 sub cmd_validate ($global_opts) {
  7         15  
  7         15  
187 7         46 my %opts = (
188             schema => undef,
189             format => 'text',
190             quiet => 0,
191             verbose => 0,
192             help => 0,
193             );
194            
195 7         30 Getopt::Long::Configure('no_pass_through');
196             GetOptions(
197             's|schema=s' => \$opts{schema},
198             'f|format=s' => \$opts{format},
199             'q|quiet' => \$opts{quiet},
200             'v|verbose' => \$opts{verbose},
201             'h|help' => \$opts{help},
202 7 50       358 ) or return EXIT_ERROR;
203            
204 7 50       6715 if ($opts{help}) {
205 0         0 help_validate();
206 0         0 return EXIT_SUCCESS;
207             }
208            
209 7 100       35 unless ($opts{schema}) {
210 1         43 warn "pjstruct validate: missing required option --schema\n";
211 1         8 warn "Run 'pjstruct help validate' for usage.\n";
212 1         0 return EXIT_ERROR;
213             }
214            
215 6 50       22 unless (@ARGV) {
216 0         0 warn "pjstruct validate: no input files specified\n";
217 0         0 warn "Run 'pjstruct help validate' for usage.\n";
218 0         0 return EXIT_ERROR;
219             }
220            
221             # Validate format option
222 6 50       84 unless ($opts{format} =~ /^(text|json|tap)$/) {
223 0         0 warn "pjstruct validate: invalid format '$opts{format}'\n";
224 0         0 warn "Valid formats: text, json, tap\n";
225 0         0 return EXIT_ERROR;
226             }
227            
228             # Load schema
229 6         33 my ($schema, $schema_error) = load_json_file($opts{schema});
230 6 50       23 unless (defined $schema) {
231 0         0 output_error(\%opts, $opts{schema}, $schema_error, 'schema');
232 0         0 return EXIT_ERROR;
233             }
234            
235             # Check schema is valid first
236 6         70 my $schema_validator = JSON::Structure::SchemaValidator->new();
237 6         26 my $schema_result = $schema_validator->validate($schema);
238 6 50       26 if (!$schema_result->is_valid) {
239 0         0 my $first_error = $schema_result->errors->[0];
240 0         0 output_error(\%opts, $opts{schema}, "invalid schema: " . $first_error->message, 'schema');
241 0         0 return EXIT_ERROR;
242             }
243            
244             # Create instance validator
245 6         58 my $validator = JSON::Structure::InstanceValidator->new(schema => $schema);
246            
247 6         11 my @results;
248 6         11 my $has_invalid = 0;
249 6         12 my $has_error = 0;
250            
251 6         35 for my $file (@ARGV) {
252 6         22 my ($instance, $load_error) = load_json_file($file);
253            
254 6 100       38 if (!defined $instance) {
255 1         9 push @results, {
256             file => $file,
257             valid => JSON::MaybeXS::false,
258             error => $load_error,
259             errors => [],
260             };
261 1         2 $has_error = 1;
262 1         5 next;
263             }
264            
265 5         26 my $result = $validator->validate($instance);
266 5         17 my $valid = $result->is_valid;
267            
268             # Convert ValidationError objects to plain hashes for output
269 5         12 my @errors = map { error_to_hash($_) } @{$result->errors};
  2         5  
  5         18  
270            
271 5 100       61 push @results, {
272             file => $file,
273             valid => $valid ? JSON::MaybeXS::true : JSON::MaybeXS::false,
274             errors => \@errors,
275             };
276            
277 5 100       53 $has_invalid = 1 unless $valid;
278             }
279            
280             # Output results
281 6         36 output_results(\%opts, \@results, 'validate');
282            
283 6 100       0 return $has_error ? EXIT_ERROR : ($has_invalid ? EXIT_INVALID : EXIT_SUCCESS);
    100          
284             }
285              
286 2     2   6 sub cmd_check ($global_opts) {
  2         5  
  2         4  
287 2         14 my %opts = (
288             format => 'text',
289             quiet => 0,
290             verbose => 0,
291             help => 0,
292             );
293            
294 2         10 Getopt::Long::Configure('no_pass_through');
295             GetOptions(
296             'f|format=s' => \$opts{format},
297             'q|quiet' => \$opts{quiet},
298             'v|verbose' => \$opts{verbose},
299             'h|help' => \$opts{help},
300 2 50       98 ) or return EXIT_ERROR;
301            
302 2 50       1633 if ($opts{help}) {
303 0         0 help_check();
304 0         0 return EXIT_SUCCESS;
305             }
306            
307 2 50       11 unless (@ARGV) {
308 0         0 warn "pjstruct check: no input files specified\n";
309 0         0 warn "Run 'pjstruct help check' for usage.\n";
310 0         0 return EXIT_ERROR;
311             }
312            
313             # Validate format option
314 2 50       17 unless ($opts{format} =~ /^(text|json|tap)$/) {
315 0         0 warn "pjstruct check: invalid format '$opts{format}'\n";
316 0         0 warn "Valid formats: text, json, tap\n";
317 0         0 return EXIT_ERROR;
318             }
319            
320 2         29 my $validator = JSON::Structure::SchemaValidator->new();
321            
322 2         4 my @results;
323 2         4 my $has_invalid = 0;
324 2         5 my $has_error = 0;
325            
326 2         8 for my $file (@ARGV) {
327 2         11 my ($schema, $load_error) = load_json_file($file);
328            
329 2 50       9 if (!defined $schema) {
330 0         0 push @results, {
331             file => $file,
332             valid => JSON::MaybeXS::false,
333             error => $load_error,
334             errors => [],
335             };
336 0         0 $has_error = 1;
337 0         0 next;
338             }
339            
340 2         14 my $result = $validator->validate($schema);
341 2         9 my $valid = $result->is_valid;
342            
343             # Convert ValidationError objects to plain hashes for output
344 2         6 my @errors = map { error_to_hash($_) } @{$result->errors};
  3         10  
  2         7  
345            
346 2 100       19 push @results, {
347             file => $file,
348             valid => $valid ? JSON::MaybeXS::true : JSON::MaybeXS::false,
349             errors => \@errors,
350             };
351            
352 2 100       25 $has_invalid = 1 unless $valid;
353             }
354            
355             # Output results
356 2         47 output_results(\%opts, \@results, 'check');
357            
358 2 100       0 return $has_error ? EXIT_ERROR : ($has_invalid ? EXIT_INVALID : EXIT_SUCCESS);
    50          
359             }
360              
361 14     14   27 sub load_json_file ($file) {
  14         33  
  14         29  
362 14         23 my $content;
363            
364 14 50       47 if ($file eq '-') {
365 0         0 local $/;
366 0         0 $content = ;
367             }
368             else {
369 14 100       452 unless (-f $file) {
370 1         9 return (undef, "file not found: $file");
371             }
372            
373 13 50   8   684 open my $fh, '<:encoding(UTF-8)', $file
  8         6473  
  8         187  
  8         50  
374             or return (undef, "cannot open file: $!");
375            
376 13         10135 local $/;
377 13         481 $content = <$fh>;
378 13         409 close $fh;
379             }
380            
381 13         154 my $json = JSON::MaybeXS->new->utf8(0)->allow_nonref;
382 13         308 my $data = eval { $json->decode($content) };
  13         190  
383            
384 13 50       47 if ($@) {
385 0         0 my $error = $@;
386 0         0 $error =~ s/ at \S+ line \d+.*//s;
387 0         0 return (undef, "JSON parse error: $error");
388             }
389            
390 13         96 return ($data, undef);
391             }
392              
393             # Convert a ValidationError object to a plain hash for output
394 5     5   7 sub error_to_hash ($error) {
  5         9  
  5         8  
395 5         13 my $location = $error->location;
396             return {
397 5 50 50     15 path => $error->path // '/',
    100 50        
      33        
398             message => $error->message // 'Unknown error',
399             code => $error->code,
400             ($location && $location->is_known ? (
401             line => $location->line,
402             column => $location->column,
403             ) : ()),
404             (defined $error->schema_path ? (schema_path => $error->schema_path) : ()),
405             };
406             }
407              
408 8     8   30 sub output_results ($opts, $results, $command) {
  8         15  
  8         21  
  8         15  
  8         11  
409 8 100       48 return if $opts->{quiet};
410            
411 7         23 my $format = $opts->{format};
412            
413 7 100       33 if ($format eq 'json') {
    100          
414 1         5 output_json($results);
415             }
416             elsif ($format eq 'tap') {
417 1         3 output_tap($results, $opts->{verbose});
418             }
419             else {
420 5         22 output_text($results, $opts->{verbose});
421             }
422             }
423              
424 0     0   0 sub output_error ($opts, $file, $error, $type) {
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
425 0 0       0 return if $opts->{quiet};
426            
427 0         0 my $format = $opts->{format};
428            
429 0 0       0 if ($format eq 'json') {
    0          
430 0         0 my $json = JSON::MaybeXS->new->utf8->pretty->canonical;
431 0         0 say $json->encode({
432             file => $file,
433             valid => JSON::MaybeXS::false,
434             error => $error,
435             });
436             }
437             elsif ($format eq 'tap') {
438 0         0 say "1..1";
439 0         0 say "not ok 1 - $file";
440 0         0 say " # $error";
441             }
442             else {
443 0         0 say STDERR "✗ $file: $error";
444             }
445             }
446              
447 5     5   17 sub output_text ($results, $verbose) {
  5         18  
  5         12  
  5         8  
448 5         14 for my $result (@$results) {
449 5         13 my $file = $result->{file};
450            
451 5 100       141 if ($result->{error}) {
    100          
452 1         15 say "✗ $file: $result->{error}";
453             }
454             elsif ($result->{valid}) {
455 2         39 say "✓ $file: valid";
456             }
457             else {
458 2         42 say "✗ $file: invalid";
459 2         5 for my $error (@{$result->{errors}}) {
  2         7  
460 5   50     15 my $path = $error->{path} // '/';
461 5         9 my $msg = $error->{message};
462 5         7 my $loc = '';
463 5 0 33     26 if ($verbose && $error->{line}) {
464 0         0 $loc = " (line $error->{line}, col $error->{column})";
465             }
466 5         18 say " - $path: $msg$loc";
467             }
468             }
469             }
470             }
471              
472 1     1   2 sub output_json ($results) {
  1         2  
  1         2  
473 1         5 my $json = JSON::MaybeXS->new->utf8->pretty->canonical;
474            
475             # Single result: output object, multiple: output array
476 1 50       36 if (@$results == 1) {
477 1         28 print $json->encode($results->[0]);
478             }
479             else {
480 0         0 print $json->encode($results);
481             }
482             }
483              
484 1     1   2 sub output_tap ($results, $verbose) {
  1         1  
  1         2  
  1         1  
485 1         2 my $count = scalar @$results;
486 1         9 say "1..$count";
487            
488 1         2 my $n = 0;
489 1         2 for my $result (@$results) {
490 1         1 $n++;
491 1         6 my $file = $result->{file};
492            
493 1 50       23 if ($result->{error}) {
    50          
494 0         0 say "not ok $n - $file";
495 0         0 say " # Error: $result->{error}";
496             }
497             elsif ($result->{valid}) {
498 1         10 say "ok $n - $file";
499             }
500             else {
501 0         0 say "not ok $n - $file";
502 0         0 for my $error (@{$result->{errors}}) {
  0         0  
503 0   0     0 my $path = $error->{path} // '/';
504 0         0 my $msg = $error->{message};
505 0         0 say " # $path: $msg";
506             }
507             }
508             }
509             }
510              
511             __END__