File Coverage

lib/Cron/Toolkit.pm
Criterion Covered Total %
statement 460 520 88.4
branch 242 326 74.2
condition 83 145 57.2
subroutine 43 50 86.0
pod 12 20 60.0
total 840 1061 79.1


line stmt bran cond sub pod time code
1             package Cron::Toolkit;
2              
3             # VERSION
4             $VERSION = 1.01;
5              
6 2     2   296440 use strict;
  2         4  
  2         85  
7 2     2   12 use warnings;
  2         4  
  2         161  
8 2     2   660 use Time::Moment;
  2         1987  
  2         59  
9 2     2   1577 use DateTime::TimeZone;
  2         458452  
  2         103  
10 2     2   1067 use Cron::Toolkit::Utils qw(:all);
  2         8  
  2         576  
11 2     2   959 use Cron::Toolkit::Pattern::Single;
  2         5  
  2         76  
12 2     2   956 use Cron::Toolkit::Pattern::Wildcard;
  2         7  
  2         73  
13 2     2   871 use Cron::Toolkit::Pattern::Range;
  2         7  
  2         99  
14 2     2   1156 use Cron::Toolkit::Pattern::List;
  2         7  
  2         79  
15 2     2   1065 use Cron::Toolkit::Pattern::Last;
  2         6  
  2         88  
16 2     2   946 use Cron::Toolkit::Pattern::LastW;
  2         6  
  2         78  
17 2     2   934 use Cron::Toolkit::Pattern::Nth;
  2         6  
  2         78  
18 2     2   1034 use Cron::Toolkit::Pattern::Unspecified;
  2         8  
  2         73  
19 2     2   929 use Cron::Toolkit::Pattern::NearestWeekday;
  2         6  
  2         80  
20 2     2   991 use Cron::Toolkit::Pattern::StepValue;
  2         6  
  2         86  
21 2     2   977 use Cron::Toolkit::Pattern::Step;
  2         7  
  2         92  
22              
23 2     2   16 use List::Util qw(max min);
  2         3  
  2         199  
24 2     2   15 use Exporter qw(import);
  2         19  
  2         97  
25 2     2   14 use feature 'say';
  2         3  
  2         23621  
26              
27             =encoding utf-8
28              
29             =head1 NAME
30              
31             Cron::Toolkit - Quartz-compatible cron parser with unique extensions and over 400 tests
32              
33             =head1 SYNOPSIS
34              
35             use Cron::Toolkit;
36             use feature qw(say);
37              
38             my $c = Cron::Toolkit->new(
39             expression => "0 30 14 ? * 6-2 *",
40             time_zone => "Europe/London",
41             );
42              
43             say $c->describe;
44             # 2:30 PM every day from Saturday to Tuesday of every month
45              
46             # next occurence in epoch seconds
47             say $c->next;
48              
49             # previous occurence in epoch seconds
50             say $c->previous;
51              
52             # Question: when does February 29th next land on a Monday?
53             say Cron::Toolkit->new(expression => "0 0 0 29 2 1 *")->next;
54             # Mon Feb 29 00:00:00 2044
55              
56             # See exactly what was parsed
57             $c->dump_tree;
58             # ┌─ second: 0
59             # ├─ minute: 30
60             # ├─ hour: 14
61             # ├─ dom: ?
62             # ├─ month: *
63             # ├─ dow: 6-2
64             # └─ year: *
65              
66             =head1 DESCRIPTION
67              
68             C<Cron::Toolkit> implements a complete, rigorously-tested cron expression parser that supports the full Quartz Scheduler syntax plus several useful extensions not found in other implementations.
69              
70             Notable features include:
71              
72             =over 4
73              
74             =item * Full 7-field Quartz syntax (seconds and year fields)
75              
76             =item * Both day-of-month and day-of-week may be specified simultaneously (AND logic)
77              
78             =item * Wrapped day-of-week ranges (e.g. C<6-2> = Saturday through Tuesday)
79              
80             =item * Proper Quartz-compatible DST handling
81              
82             =item * Time-zone support via IANA names or fixed UTC offsets
83              
84             =item * Natural-language English descriptions
85              
86             =item * Complete crontab parsing with environment variable expansion
87              
88             =item * Full abstract syntax tree and C<dump_tree()> for debugging
89              
90             =back
91              
92             =head1 RELIABILITY
93              
94             The distribution ships with over 400 data-driven tests covering every supported token, leap years, DST transitions, all time zones from UTC−12 to UTC+14, and every edge case discovered during development.
95              
96             If it parses, the result is correct.
97              
98             =head1 UNIQUE EXTENSIONS
99              
100             =over 4
101              
102             =item * DOM + DOW = AND logic
103              
104             Allows queries such as "next February 29 that falls on a Monday".
105              
106             =item * Wrapped day-of-week ranges
107              
108             6-2 matches Saturday, Sunday, Monday, Tuesday
109              
110             =item * Internal day-of-week: 1–7 = Monday–Sunday
111              
112             Matches L<Time::Moment> and L<DateTime>. C<as_quartz_string()> converts back to Quartz's 1=Sunday convention.
113              
114             =back
115              
116             =head1 FIELD REFERENCE & ALLOWED VALUES
117              
118             Field Allowed values Allowed special characters
119             -------------------------------------------------------------------
120             Second 0–59 *,/,-
121             Minute 0–59 *,/,-,
122             Hour 0–23 *,/,-,
123             Day of month 1–31 *,/,-,?,L,LW,W
124             Month 1–12 or JAN–DEC *,/,-
125             Day of week 1–7 or MON-SUN *,/,-,?,L,#
126             Year (optional) 1970–2099 *,/,-
127              
128             Legend:
129             * wildcard
130             , list
131             - range
132             / step
133             ? no specific value (DOM or DOW only)
134             L last (day or day-of-week)
135             L-n n to last day of the month
136             nL last n-day of the month
137             LW last weekday of month
138             nW nearest weekday to n
139             # nth day-of-week (e.g. 3#2 = 2nd Wednesday)
140              
141             @aliases: @yearly @annually @monthly @weekly @daily @hourly (Quartz standard)
142              
143             =head1 METHODS
144              
145             =over 4
146              
147             =item C<< Cron::Toolkit->new( expression => $expr, %options ) >>
148              
149             Main constructor; auto-detects Unix vs Quartz format.
150              
151             =item C<< Cron::Toolkit->new_from_unix( expression => $expr, %options ) >>
152              
153             Force traditional 5-field Unix interpretation.
154              
155             =item C<< Cron::Toolkit->new_from_quartz( expression => $expr, %options ) >>
156              
157             Force Quartz interpretation.
158              
159             =item C<< Cron::Toolkit->new_from_crontab( $string ) >>
160              
161             Parse a full crontab; returns a list of C<Cron::Toolkit> objects.
162             Supports C<$VAR> expansion, user field, and comments.
163              
164             =item C<< $c->as_string >>
165              
166             Normalized 7-field representation (DOW 1–7 = Mon–Sun).
167              
168             =item C<< $c->as_quartz_string >>
169              
170             Quartz-compatible string (DOW 1=Sunday).
171              
172             =item C<< $c->describe >>
173              
174             Human-readable English description.
175              
176             =item C<< $c->next( [$from_epoch] ) >>
177              
178             Next occurrence after C<$from_epoch> or C<time>.
179              
180             =item C<< $c->previous( [$from_epoch] ) >>
181              
182             Previous occurrence before C<$from_epoch> or C<time>.
183              
184             =item C<< $c->is_match( $epoch ) >>
185              
186             Returns true if C<$epoch> matches the expression.
187              
188             =item C<< $c->dump_tree >>
189              
190             Pretty-printed abstract syntax tree (invaluable for debugging).
191              
192             =item C<< $c->to_json >>
193              
194             JSON representation of the object (expression, description, bounds, etc.).
195              
196             =item Accessors
197              
198             $c->time_zone("Europe/Berlin")
199             $c->utc_offset(+180) # minutes
200             $c->begin_epoch($epoch)
201             $c->end_epoch($epoch) # undef = no limit
202              
203             =back
204              
205             =head1 TIME ZONES AND DST
206              
207             All calculations are performed in the configured time zone.
208             DST transitions follow Quartz Scheduler rules exactly:
209              
210             =over 4
211              
212             =item * Spring forward — times that do not exist are skipped
213              
214             =item * Fall back — repeated local times fire twice
215              
216             =back
217              
218             =head1 BUGS AND CONTRIBUTIONS
219              
220             The test suite currently contains over 400 data-driven tests covering every supported token, DST transitions, leap years, all time zones, and many edge cases — but real-world cron expressions can be surprisingly creative.
221              
222             If you find:
223              
224             =over 4
225              
226             =item * an expression that should be valid but dies or is rejected
227              
228             =item * a next/previous occurrence that is wrong
229              
230             =item * a description that is misleading or unclear
231              
232             =item * any behaviour that differs from Quartz Scheduler (when using Quartz syntax)
233              
234             =back
235              
236             ...please file a bug report at
237             L<https://github.com/nathanielgraham/cron-toolkit-perl/issues>
238              
239             Pull requests with failing test cases are especially welcome — they are the fastest way to get a fix merged.
240              
241             Feature requests (e.g. more natural-language locales, RRULE export, etc.) are also very much appreciated.
242              
243             Thank you!
244              
245             =cut
246              
247             =head1 AUTHOR
248              
249             Nathaniel Graham
250              
251             =head1 COPYRIGHT AND LICENSE
252              
253             Copyright 2025 Nathaniel Graham
254              
255             This library is free software; you may redistribute it and/or modify it
256             under the same terms as Perl itself.
257              
258             =cut
259              
260             sub new_from_unix {
261 0     0 1 0 my ( $class, %args ) = @_;
262 0         0 $args{is_quartz} = 0;
263 0         0 my $self = $class->new(%args);
264 0         0 return $self;
265             }
266              
267             sub new_from_quartz {
268 0     0 1 0 my ( $class, %args ) = @_;
269 0         0 $args{is_quartz} = 1;
270 0         0 my $self = $class->new(%args);
271 0         0 return $self;
272             }
273              
274             sub new {
275 88     88 1 1326502 my ( $class, %args ) = @_;
276 88 50       553 die "expression required" unless defined $args{expression};
277 88         344 my $expr = uc $args{expression};
278 88         969 $expr =~ s/\s+/ /g;
279 88         698 $expr =~ s/^\s+|\s+$//g;
280              
281             # alias support
282 88 100       471 if ( $expr =~ /^(@.*)/ ) {
283 6         26 my $alias = lc($1);
284 6 50       36 $expr = $ALIASES{$alias} or die "no such alias: $alias";
285             }
286              
287 88         411 my @fields = split /\s+/, $expr;
288 88         481 my @raw_fields = @fields;
289              
290             # normalize to 7-fields
291 88 100       322 unshift( @fields, 0 ) if scalar @fields == 5; # seconds
292 88 100       330 push( @fields, '*' ) if scalar @fields == 6; # year
293 88 50       307 die "expected 5-7 fields" unless scalar @fields == 7;
294              
295             # normalize to 7-field quartz expression
296 88 50       257 if ( $args{is_quartz} ) {
297              
298             # Reject Quartz DOW 0
299 0 0 0     0 if ( $fields[5] =~ /\b0\b/ && $fields[5] !~ /#\d+/ ) {
300 0         0 die "Invalid dow value: 0, must be [1-7] in Quartz";
301             }
302              
303             # Map Quartz DOW names
304 0         0 while ( my ( $name, $num ) = each %DOW_MAP_QUARTZ ) {
305 0         0 $fields[5] =~ s/\b\Q$name\E\b/$num/gi;
306             }
307              
308             # Normalize Quartz DOW 1-7 to 0-6, skip nth and step
309 0         0 $fields[5] =~ s/(?<![#\/])(\b[1-7]\b)(?![#\/])/$1-1/ge;
  0         0  
310             }
311             else {
312             # convert dow names to unix numerical equivalent
313 88         642 while ( my ( $name, $num ) = each %DOW_MAP_UNIX ) {
314 1232         9804 $fields[5] =~ s/\b\Q$name\E\b/$num/gi;
315             }
316             }
317              
318             # Convert month names to numerical equivalent
319 88         467 while ( my ( $name, $num ) = each %MONTH_MAP ) { $fields[4] =~ s/\b\Q$name\E\b/$num/gi; }
  2024         12628  
320              
321             # align dom and dow fields
322 88 100 100     2989 if ( $fields[3] ne '?' && $fields[5] eq '*' ) {
    100 100        
    50 66        
323 29         90 $fields[5] = '?';
324             }
325             elsif ( $fields[3] eq '*' && $fields[5] ne '?' ) {
326 2         5 $fields[3] = '?';
327             }
328             elsif ( $fields[3] eq '?' && $fields[5] eq '?' ) {
329 0         0 die "dow and dom cannot both be unspecified\n";
330             }
331              
332 88 50       888 die "Invalid characters" unless join( ' ', @fields ) =~ /^[#LW\d\?\*\s\-\/,]+$/;
333              
334 88         1136 my $self = bless {
335             fields => \@fields,
336             raw_fields => \@raw_fields,
337             nodes => [],
338             utc_offset => 0,
339             time_zone => 'UTC',
340             begin_epoch => time - ( 10 * 365 * 86400 ), # ~10 years ago
341             end_epoch => time + ( 10 * 365 * 86400 ), # ~10 years ahead
342             }, $class;
343              
344 88 50       351 $self->utc_offset( $args{utc_offset} ) if defined $args{utc_offset};
345 88 50       277 $self->time_zone( $args{time_zone} ) if defined $args{time_zone};
346 88 100       248 $self->user( $args{user} ) if defined $args{user};
347 88 100       256 $self->command( $args{command} ) if defined $args{command};
348 88 100       300 $self->env( $args{env} ) if defined $args{env};
349              
350 88         406 $self->_build_tree;
351              
352 88         336 return $self;
353             }
354              
355             sub _build_tree {
356 88     88   161 my $self = shift;
357 88         382 my @types = qw(second minute hour dom month dow year);
358 88         348 for my $i ( 0 .. $#types ) {
359 616         1695 my $node = $self->_build_node( $types[$i], $self->{fields}[$i] );
360 616         1268 $node = $self->_optimize_node( $node, $types[$i] );
361 616         692 push( @{ $self->{nodes} }, $node );
  616         1420  
362             }
363 88         384 $self->_finalize_dow( $self->{nodes}[5] );
364             }
365              
366             sub _optimize_node {
367 625     625   946 my ( $self, $node, $field ) = @_;
368              
369             # Get field limits
370 625         631 my ( $min, $max ) = @{ $LIMITS{$field} };
  625         1000  
371 625 100       1078 $min = 0 if $field eq 'dow';
372              
373             # Step collapse — only if degenerate
374 625 100       4161 if ( $node->type eq 'step' ) {
375 9         30 my $base_node = $node->{children}[0];
376 9         23 my $step = $node->{children}[1]{value};
377 9         26 my @values;
378              
379 9 100       61 if ( $base_node->type eq 'wildcard' ) {
    100          
    50          
380 6         16 my ( $min, $max ) = @{ $LIMITS{$field} };
  6         13  
381 6 100       20 $min = 0 if $field eq 'dow';
382 6         30 @values = ( $min .. $max );
383             }
384              
385             elsif ( $base_node->type eq 'single' ) {
386 1         4 my $start = $base_node->{value};
387 1         6 @values = ( $start .. $max );
388             }
389             elsif ( $base_node->type eq 'range' ) {
390 2         6 my ( $start, $end ) = map { $_->{value} } @{ $base_node->{children} };
  4         12  
  2         5  
391 2         11 @values = ( $start .. $end );
392             }
393              
394 9         15 my @stepped;
395 9         33 for ( my $v = $values[0] ; $v <= $values[-1] ; $v += $step ) {
396 52 50       52 push @stepped, $v if grep { $_ == $v } @values;
  1699         1614  
397             }
398              
399             # === DEGENERATE CASE: 0 or 1 value → collapse ===
400 9 50       44 if ( @stepped == 0 ) {
    50          
401 0         0 return Cron::Toolkit::Pattern::Wildcard->new(
402             value => '*',
403             field_type => $field
404             );
405             }
406             elsif ( @stepped == 1 ) {
407 0         0 return Cron::Toolkit::Pattern::Single->new(
408             value => $stepped[0],
409             field_type => $field
410             );
411             }
412              
413             # === NON-DEGENERATE: keep as step (but optimize base if possible) ===
414             # Recursively optimize base (e.g., 1-10/5 → range(1,10))
415 9         50 my $optimized_base = $self->_optimize_node( $base_node, $field );
416 9 50       60 return $node if $optimized_base == $base_node; # no change
417              
418 0         0 my $new_step = Cron::Toolkit::Pattern::Step->new( field_type => $field );
419 0         0 $new_step->add_child($optimized_base);
420 0         0 $new_step->add_child( $node->{children}[1] ); # step value
421 0         0 return $new_step;
422             }
423              
424             # List-to-range
425 616 100       1040 if ( $node->type eq 'list' ) {
426 23         47 my @values = sort { $a <=> $b } map { $_->{value} }
  25         79  
427 9         22 grep { $_->type eq 'single' } @{ $node->{children} };
  27         49  
  9         26  
428 9 50 66     55 if ( @values >= 2 && $values[-1] - $values[0] == $#values ) {
429 0         0 my $range = Cron::Toolkit::Pattern::Range->new( field_type => $field );
430 0         0 $range->add_child(
431             Cron::Toolkit::Pattern::Single->new(
432             value => $values[0],
433             field_type => $field
434             )
435             );
436 0         0 $range->add_child(
437             Cron::Toolkit::Pattern::Single->new(
438             value => $values[-1],
439             field_type => $field
440             )
441             );
442 0         0 return $range;
443             }
444             }
445              
446 616         907 return $node;
447             }
448              
449             sub _finalize_dow {
450 115     115   182 my $self = shift;
451 115         214 my $dow_node = shift;
452              
453 115 100 100     407 if ( $dow_node->has_children ) {
    100          
454 13         22 $self->_finalize_dow($_) for @{ $dow_node->{children} };
  13         100  
455             }
456              
457             elsif ( $dow_node->type eq 'single' && $dow_node->{value} == 0 ) {
458 1         3 $dow_node->{value} = 7;
459             }
460             }
461              
462             sub _build_node {
463 643     643   1178 my ( $self, $field, $value ) = @_;
464              
465 643 50       4112 die "Invalid characters in $field: $value" unless $value =~ $ALLOWED_CHARS{$field};
466              
467 643         714 my ( $min, $max ) = @{ $LIMITS{$field} };
  643         1331  
468 643 100       1177 $min = 0 if $field eq 'dow';
469              
470 643         683 my $node;
471              
472             # validation and node creation
473 643 100       4627 if ( $value eq '*' ) {
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    50          
474 173         449 $node = Cron::Toolkit::Pattern::Wildcard->new(
475             value => '*',
476             field_type => $field
477             );
478             }
479             elsif ( $value eq '?' ) {
480 88 50       556 die "Syntax: ? only allowed in dom or dow, not $field"
481             unless $field =~ /^(dom|dow)$/;
482 88         385 $node = Cron::Toolkit::Pattern::Unspecified->new(
483             value => '?',
484             field_type => $field
485             );
486             }
487             elsif ( $value =~ /^(\d+)?L$/ ) {
488 9         61 my ($day) = ($1);
489 9 50       94 die "Syntax: L only allowed in dow or dom, not $field"
490             unless $field =~ /^dom|dow$/;
491              
492 9         125 $node = Cron::Toolkit::Pattern::Last->new(
493             value => $value,
494             offset => 0,
495             field_type => $field,
496             );
497              
498 9 100       33 if ( $field eq 'dom' ) {
499 5 50       30 die $day . "L not allowed in dom" if defined $day;
500             }
501             else {
502 4   66     30 $day //= $max;
503 4 50 33     72 die "dow $day out of range [$min-$max]" unless $day >= $min && $day <= $max;
504 4         14 $node->{dow} = $day;
505 4         36 $node->{value} = $day . 'L';
506             }
507             }
508             elsif ( $value =~ qr/^L-(\d+)$/ ) {
509 1         5 my $offset = $1;
510 1 50       6 die "Syntax: L only allowed in dom, not $field" unless $field eq 'dom';
511              
512 1 50       7 if ($offset) {
513 1 50       6 die "dom offset $offset too large" if $offset >= $max - 1;
514             }
515              
516 1         10 $node = Cron::Toolkit::Pattern::Last->new(
517             value => $value,
518             offset => $offset,
519             field_type => $field
520             );
521             }
522             elsif ( $value =~ /^LW$/ ) {
523 1 50       4 die "Syntax: LW only allowed in dom, not $field" unless $field eq 'dom';
524 1         17 $node = Cron::Toolkit::Pattern::LastW->new(
525             value => 'LW',
526             field_type => $field
527             );
528             }
529             elsif ( $value =~ /^(\d+)W$/ ) {
530 3 50       29 die "Syntax: W only allowed in dom, not $field" unless $field eq 'dom';
531 3         13 my ($day) = ($1);
532 3 50 33     21 die "dom $day out of range [1-31]" unless $day >= 1 && $day <= 31;
533 3         33 $node = Cron::Toolkit::Pattern::NearestWeekday->new(
534             value => $value,
535             dom => $day,
536             field_type => $field
537             );
538             }
539             elsif ( $value =~ /^(\d+)#(\d+)$/ ) {
540 9 50       52 die "Syntax: # only allowed in dow, not $field" unless $field eq 'dow';
541 9         68 my ( $day, $nth ) = ( $1, $2 );
542 9 50 33     79 die "dow $day out of range [1-7]" unless $day >= 1 && $day <= 7;
543 9 50 33     54 die "nth $nth out of range [1-5]" unless $nth >= 1 && $nth <= 5;
544 9         165 $node = Cron::Toolkit::Pattern::Nth->new(
545             value => $value,
546             nth => $nth,
547             dow => $day,
548             field_type => $field
549             );
550             }
551             elsif ( $value =~ /^\d+$/ ) {
552 323 50 33     1448 die "$field $value out of range [$min-$max]" unless $value >= $min && $value <= $max;
553 323         1169 $node = Cron::Toolkit::Pattern::Single->new(
554             value => $value,
555             field_type => $field
556             );
557             }
558             elsif ( $value =~ /^(\*|\d+)-(\d+)$/ ) {
559 18         87 my ( $start, $end ) = ( $1, $2 );
560 18 50       69 $start = $min if $start eq '*';
561 18 50 33     114 die "$field start $start out of range [$min-$max]" unless $start >= $min && $start <= $max;
562 18 50 33     132 die "$field end $end out of range [$min-$max]" unless $end >= $min && $end <= $max;
563 18 50 66     82 die "$field range start $start must be <= end $end" if $start > $end && $field ne 'dow';
564              
565 18         132 $node = Cron::Toolkit::Pattern::Range->new( field_type => $field );
566 18         60 $node->add_child( Cron::Toolkit::Pattern::Single->new( value => $start, field_type => $field ) );
567 18         51 $node->add_child( Cron::Toolkit::Pattern::Single->new( value => $end, field_type => $field ) );
568              
569 18 100 100     88 if ( $field eq 'dow' && $start > $end ) {
570 4         27 $node->{wrapped} = 1;
571             }
572             }
573             elsif ( $value =~ /^(\*|\d+)\/(\d+)$/ ) {
574 7         31 my ( $base_str, $step ) = ( $1, $2 );
575 7 50 33     43 die "$field step $step out of range [$min-$max]" unless $step >= $min && $step <= $max;
576 7 50 33     30 die "$field base $base_str out of range [$min-$max]" if $base_str ne '*' && ( $base_str < $min || $base_str > $max );
      66        
577 7         51 $node = Cron::Toolkit::Pattern::Step->new(
578             type => 'step',
579             field_type => $field
580             );
581 7 100       43 my $base_node =
582             $base_str eq '*'
583             ? Cron::Toolkit::Pattern::Wildcard->new( type => 'wildcard', value => '*', field_type => $field )
584             : Cron::Toolkit::Pattern::Single->new( type => 'single', value => $base_str, field_type => $field );
585 7         31 $node->add_child($base_node);
586 7         64 $node->add_child(
587             Cron::Toolkit::Pattern::StepValue->new(
588             type => 'step_value',
589             value => $step,
590             field_type => $field
591             )
592             );
593             }
594             elsif ( $value =~ /^(\*|\d+)-(\d+)\/(\d+)$/ ) {
595 2         12 my ( $base_str, $end, $step ) = ( $1, $2, $3 );
596 2 50       9 my $start = $base_str eq '*' ? $min : $base_str;
597              
598 2 50 33     12 die "$field start $start out of range" unless $start >= $min && $start <= $max;
599 2 50 33     14 die "$field end $end out of range" unless $end >= $min && $end <= $max;
600 2 50       8 die "$field step $step invalid" unless $step > 0;
601              
602 2         5 my $wrapped = 0;
603 2 50 33     8 if ( $field eq 'dow' && $start > $end ) {
604 0         0 $wrapped = 1;
605             }
606             else {
607 2 50 33     8 die "$field range start $start must be <= end $end" if $start > $end && $field ne 'dow';
608             }
609              
610 2         13 my $range_node = Cron::Toolkit::Pattern::Range->new(
611             field_type => $field,
612             wrapped => $wrapped
613             );
614 2         7 $range_node->add_child( Cron::Toolkit::Pattern::Single->new( value => $start, field_type => $field ) );
615 2         6 $range_node->add_child( Cron::Toolkit::Pattern::Single->new( value => $end, field_type => $field ) );
616              
617 2         14 $node = Cron::Toolkit::Pattern::Step->new( field_type => $field );
618 2         8 $node->add_child($range_node);
619 2         9 $node->add_child( Cron::Toolkit::Pattern::StepValue->new( value => $step, field_type => $field ) );
620             }
621             elsif ( $value =~ /,/ ) {
622 9         144 $node = Cron::Toolkit::Pattern::List->new(
623             type => 'list',
624             field_type => $field
625             );
626 9         40 for my $sub ( split /,/, $value ) {
627 27         39 eval {
628 27         76 my $sub_node = $self->_build_node( $field, $sub );
629 27 50       53 die "Invalid list element in $field: list not allowed" if $sub_node->type eq 'list';
630 27         63 $node->add_child($sub_node);
631             };
632 27 50       60 if ($@) {
633 0         0 my $error = $@;
634 0         0 $error =~ s/^Invalid $field:/Invalid $field list element:/;
635 0         0 $error =~ s/^$field ([^:]+):/Invalid $field list element $1:/;
636 0         0 die $error;
637             }
638             }
639             }
640             else {
641 0         0 die "Unsupported field: $value ($field)";
642             }
643              
644 643         1252 return $node;
645             }
646              
647             sub utc_offset {
648 164     164 0 2929 my ( $self, $offset ) = @_;
649 164 100       563 if ( $offset ) {
650 48 50 33     871 if ( $offset !~ /^-?\d+$/ || $offset < -1080 || $offset > 1080 ) {
      33        
651 0         0 die "Invalid utc_offset '$offset': must be an integer between -1080 and 1080 minutes";
652             }
653 48         177 $self->{utc_offset} = $offset;
654             }
655 164         471 return $self->{utc_offset};
656             }
657              
658             sub time_zone {
659 38     38 0 417 my ( $self, $tz ) = @_;
660 38 50       100 if ( $tz ) {
661 38         68 my $zone = eval { DateTime::TimeZone->new( name => $tz ) };
  38         353  
662 38 50       470249 die "Invalid time_zone '$tz': must be a valid TZ identifier ($@)" if $@;
663 38         136 $self->{time_zone} = $tz;
664 38         450 my $tm = Time::Moment->now_utc;
665 38         281 $self->{utc_offset} = $zone->offset_for_datetime($tm) / 60; # Recalc to minutes (DST-aware)
666             }
667 38         6768 return $self->{time_zone};
668             }
669              
670             sub begin_epoch {
671 0     0 0 0 my ( $self, $new_begin ) = @_;
672 0 0       0 if ( @_ > 1 ) {
673 0 0 0     0 die "Invalid begin_epoch '$new_begin': must be a non-negative integer" unless defined $new_begin && $new_begin =~ /^\d+$/ && $new_begin >= 0;
      0        
674 0         0 $self->{begin_epoch} = $new_begin;
675             }
676 0         0 return $self->{begin_epoch};
677             }
678              
679             sub end_epoch {
680 0     0 0 0 my ( $self, $new_end ) = @_;
681 0 0       0 if ( @_ > 1 ) {
682 0 0 0     0 die "Invalid end_epoch '$new_end': must be undef or a non-negative integer" unless !defined $new_end || ( $new_end =~ /^\d+$/ && $new_end >= 0 );
      0        
683 0         0 $self->{end_epoch} = $new_end;
684             }
685 0         0 return $self->{end_epoch};
686             }
687              
688             sub user {
689 5     5 0 10 my ($self, $user) = @_;
690 5 100       11 $self->{user} = $user if $user;
691 5         12 return $self->{user};
692             }
693              
694             sub command {
695 12     12 0 20 my ($self, $command) = @_;
696 12 100       29 $self->{command} = $command if $command;
697 12         27 return $self->{command};
698             }
699              
700             sub env {
701 8     8 0 12 my ($self, $env) = @_;
702 8 100       13 $self->{env} = $env if $env;
703 8         20 return $self->{env};
704             }
705              
706             sub as_unix_string {
707 0     0 0 0 my $self = shift;
708 0         0 my $expr = $self->_as_string;
709 0         0 $expr =~ s/\?/*/;
710 0         0 my @fields = split( /\s+/, $expr );
711 0         0 shift @fields; # remove seconds
712 0         0 pop @fields; # remove year
713 0         0 return join( ' ', @fields );
714             }
715              
716             sub as_quartz_string {
717 82     82 1 450 my $self = shift;
718 82         163 my $expr = $self->_as_string;
719 82         362 my @fields = split /\s+/, $expr;
720              
721 82 50       249 return $expr unless @fields > 5;
722              
723 82         188 my $dow = $fields[5];
724              
725 82         425 $dow =~ s{
726             (?<![L#/]) # not preceded by L or #
727             \b([1-7])\b # standalone 1-7
728             }{
729 33 100       257 $1 == 7 ? 1 : $1 + 1
730             }gex;
731              
732 82         155 $fields[5] = $dow;
733 82         446 return join ' ', @fields;
734             }
735              
736             sub as_string {
737 86     86 1 1030 my $self = shift;
738 86         309 return $self->_as_string;
739             }
740              
741             sub _as_string {
742 168     168   276 my $self = shift;
743 168         278 my $string = join( ' ', map { $self->_rebuild_from_node($_) } @{ $self->{nodes} } );
  1176         1881  
  168         434  
744             }
745              
746             sub to_json {
747 0     0 1 0 my $self = shift;
748 0         0 return JSON::PP::encode_json(
749             {
750             expression => $self->_as_string,
751             description => $self->describe,
752             utc_offset => $self->utc_offset,
753             time_zone => $self->time_zone,
754             begin_epoch => $self->begin_epoch,
755             end_epoch => $self->end_epoch,
756             }
757             );
758             }
759              
760             sub new_from_crontab {
761 1     1 1 268651 my ( $class, $content ) = @_;
762 1 50 33     7 die "crontab content required (string)" unless defined $content && length $content;
763 1         2 my @crons;
764             my %env;
765 1         6 foreach my $line ( split /\n/, $content ) {
766              
767             # Strip trailing comments and trim
768 22         48 $line =~ s/\s*#.*$//; # Remove comments from end
769 22         59 $line =~ s/^\s+|\s+$//g; # Trim whitespace
770 22 100       35 next unless $line =~ /\S/; # Skip empty
771              
772 9 100       19 if ( $line =~ /^([A-Z_][A-Z0-9_]*)=(.*)$/ ) {
773 3         9 $env{$1} = $2;
774 3         4 next;
775             }
776              
777 6         18 while ( my ( $var, $val ) = each %env ) {
778 14         171 $line =~ s/\$$var\b/$val/g;
779             }
780              
781 6         14 my @parts = split /\s+/, $line;
782              
783 6         6 my @cron_parts;
784 6         7 my $is_alias = 0;
785 6         6 for my $part (@parts) {
786 33 100       34 last if @cron_parts >= 7; # Cap at max Quartz fields
787 32 100 100     100 if ( @cron_parts == 0 && $part =~ /^@/ ) {
    100 100        
      100        
788              
789             # Alias as single token
790 1         2 push @cron_parts, $part;
791 1         2 $is_alias = 1;
792 1         1 last; # Aliases are single
793             }
794 70         306 elsif ( $part =~ /^[0-9*?,\/\-L#W?]+$/ || scalar (grep { $part =~ /$_/ } keys %DOW_MAP_UNIX) || scalar (grep { $part =~ /$_/ } keys %MONTH_MAP) ) { # Cron-like: digits, *, ?, -, /, ,, L, W, #
  92         304  
795 27         27 push @cron_parts, $part;
796             }
797             else {
798 4         5 last; # Non-cron token
799             }
800             }
801              
802             # Validate expression length
803 6         19 my $expr = join ' ', @cron_parts;
804 6 50 33     20 next unless $is_alias || ( @cron_parts >= 5 && @cron_parts <= 7 );
      66        
805              
806             # Extract user: Next token after prefix, if simple word (alphanumeric, no / or special)
807 6         7 my ($user, $command);
808 6         6 my $cron_end = scalar @cron_parts;
809 6         6 my $next_start = $cron_end;
810 6 50       9 if ( @parts > $cron_end ) {
811 6         7 my $potential_user = $parts[$cron_end];
812 6 100       14 if ( $potential_user =~ /^\w+$/ ) { # Simple username: letters/digits/_
813 2         1 $user = $potential_user;
814 2         3 $next_start = $cron_end + 1;
815             }
816             }
817              
818 6 50       20 $command = join ' ', @parts[ $next_start .. $#parts ] if @parts > $next_start;
819              
820 6         7 my $cron;
821 6         5 eval {
822 6         42 $cron = $class->new(
823             expression => $expr,
824             user => $user,
825             command => $command,
826             env => {%env} # Copy current env
827             );
828             };
829 6 50       10 if ($@) {
830 0         0 warn "Skipped invalid crontab line: '$line' ($@)";
831             }
832             else {
833 6         20 push @crons, $cron;
834             }
835             }
836 1         9 return @crons;
837             }
838              
839             sub dump_tree {
840 82     82 1 36654 my ( $self, $indent ) = @_;
841 82         206 my $out;
842              
843 82         488 my @names = qw(second minute hour dom month dow year);
844 82         186 for my $i ( 0 .. $#{ $self->{nodes} } ) {
  82         382  
845 574         894 my $node = $self->{nodes}[$i];
846 574         766 my $name = $names[$i];
847              
848 574 100       1011 my $prefix = $i == 0 ? '┌─' : $i == $#{ $self->{nodes} } ? '└─' : '├─';
  492 100       969  
849 574 100       749 my $child_indent = $i == $#{ $self->{nodes} } ? ' ' : '│ ';
  574         1023  
850              
851 574         1620 $out .= "$prefix $name: " . $node->_dump_tree($child_indent) . "\n";
852             }
853 82         437 return $out;
854             }
855              
856             sub _rebuild_from_node {
857 1344     1344   1819 my ( $self, $node ) = @_;
858 1344         2458 my $type = $node->type;
859 1344 100       2867 return '*' if $type eq 'wildcard';
860 1012 100       1644 return '?' if $type eq 'unspecified';
861 844 100 100     5694 return $node->value if $type eq 'single' || $type eq 'last' || $type eq 'lastW' || $type eq 'nth' || $type eq 'nearest_weekday' || $type eq 'step_value';
      100        
      100        
      100        
      100        
862 75 100       291 return $self->_rebuild_from_node( $node->{children}[0] ) . '-' . $self->_rebuild_from_node( $node->{children}[1] ) if $type eq 'range';
863 36 100       105 return $self->_rebuild_from_node( $node->{children}[0] ) . '/' . $self->_rebuild_from_node( $node->{children}[1] ) if $type eq 'step';
864 18 50       42 return join ',', map { $self->_rebuild_from_node($_) } @{ $node->{children} } if $type eq 'list';
  54         100  
  18         38  
865 0         0 die "Unsupported for rebuild: $type";
866             }
867              
868             # describing
869              
870             sub describe {
871 82     82 1 425 my $self = shift;
872 82         138 my $hms;
873 82         186 my $dmy = '';
874 82         119 my @nodes;
875              
876 82         158 my $wildcards = scalar grep { $_->type eq 'wildcard' } @{ $self->{nodes} }[ 0 .. 2 ];
  246         403  
  82         286  
877 82         171 my $singles = scalar grep { $_->type eq 'single' } @{ $self->{nodes} }[ 0 .. 2 ];
  246         392  
  82         186  
878              
879             # dedupe wildcards
880 82         127 my $prev_type = '';
881 82         170 for my $node ( @{ $self->{nodes} } ) {
  82         242  
882 574 100 100     901 push @nodes, $node->type eq 'unspecified' || ($node->type eq 'wildcard' && $prev_type eq 'wildcard') ? undef : $node;
883            
884 574         890 $prev_type = $node->type;
885             }
886              
887             # HMS
888 82 100       394 if ( $wildcards == 3 ) {
    100          
889 2         29 $hms = $nodes[0]->to_english;
890             }
891             elsif ( $singles == 3 ) {
892 65         295 $hms = format_time( map { $_->value } @nodes[ 0 .. 2 ] );
  195         427  
893             }
894             else {
895 15 100 100     33 $hms = join( ' of ', map { $_->to_english } grep { defined $_ && !( $_->type eq 'single' && $_->value == 0 ) } @nodes[ 0 .. 2 ] );
  25         85  
  45         96  
896             }
897              
898             # DMY
899 82 100       297 if ( defined $nodes[3] ) {
900 45 100       160 if ( $nodes[3]->type eq 'single' ) {
901 24         50 $dmy .= 'on ';
902             }
903 45         181 $dmy .= $nodes[3]->to_english . ' of ' . $self->{nodes}[4]->to_english;
904             }
905              
906 82 50 66     460 if ( defined $nodes[3] && defined $nodes[5] ) {
907 0         0 $dmy .= ' and ';
908             }
909              
910 82 100       285 if ( defined $nodes[5] ) {
911              
912 26 100       75 if ( $nodes[5]->type eq 'single' ) {
913 3         22 $dmy .= 'every ';
914             }
915 26         122 $dmy .= $nodes[5]->to_english . ' of ' . $self->{nodes}[4]->to_english;
916             }
917              
918 82 100 100     373 if ( defined $nodes[4] && $nodes[4]->type ne 'wildcard' && !defined $nodes[3] && !defined $nodes[5] ) {
      100        
      66        
919 1         5 $dmy .= 'of ' . $self->{nodes}[4]->to_english;
920             }
921              
922 82 100 66     392 if ( defined $nodes[6] && $nodes[6]->type ne 'wildcard' ) {
923 22         84 $dmy .= ' ' . $self->{nodes}[6]->to_english;
924             }
925 82         186 return join ' ', grep { $_ } ($hms, $dmy);
  164         568  
926             }
927              
928             # matching
929              
930             sub is_match {
931 0     0 1 0 my ( $self, $epoch_seconds ) = @_;
932 0         0 my $tm = Time::Moment->from_epoch($epoch_seconds);
933 0 0       0 return unless $tm;
934 0         0 return $self->_is_match($tm);
935             }
936              
937             sub _is_match {
938 488009     488009   752412 my ( $self, $tm ) = @_;
939              
940 488009         593859 NODE: for my $node ( @{ $self->{nodes} } ) {
  488009         979484  
941 1947806         3807502 my $value = $self->_field_value( $tm, $node->field_type );
942 1947806 100       3861485 if ( $node->type eq 'list' ) {
943 139         128 for my $child ( @{ $node->children } ) {
  139         212  
944 425 100       566 next NODE if $child->match( $value, $tm );
945             }
946 112         249 return 0;
947             }
948 1947667 100       3595550 return 0 unless $node->match( $value, $tm );
949             }
950 187         1421 return 1;
951             }
952              
953             sub next {
954 82     82 1 814 my ( $self, $epoch_seconds ) = @_;
955 82   33     223 $epoch_seconds //= time;
956              
957 82         551 my $clamped = max( $epoch_seconds, $self->{begin_epoch} );
958              
959 82 50       303 return if $clamped > $self->{end_epoch};
960              
961 82         1002 my $tm = Time::Moment->from_epoch($clamped)->with_offset_same_instant( $self->{utc_offset} );
962 82         487 $tm = $tm->plus_seconds(1);
963              
964             # shortcut for HMS
965 82         250 NODE: foreach my $i ( 0 .. 2 ) {
966 232         372 my $node = $self->{nodes}[$i];
967 232         522 my $curval = $self->_field_value( $tm, $node->field_type );
968 232         711 my $lowval = $node->lowest($tm);
969 232         605 my $highval = $node->highest($tm);
970              
971 232 100       801 if ($curval >= $highval) {
972 190         505 $tm = $self->_set_date( $tm, $node->field_type, $lowval );
973 190         777 $tm = $self->_plus_one( $tm, $self->{nodes}[ $i + 1 ]->field_type );
974 190         1681 next NODE;
975             }
976              
977 42         110 for my $c ( $curval .. $highval ) {
978 636         1099 my $c_tm = $self->_set_date( $tm, $node->field_type, $c );
979 636 100       1079 if ( $self->_is_match($c_tm) ) {
980 17         31 $tm = $c_tm;
981 17         65 last NODE;
982             }
983             }
984              
985             # flip odometer if no match
986 25         91 $tm = $self->_set_date( $tm, $node->field_type, $lowval );
987 25         93 $tm = $self->_plus_one( $tm, $self->{nodes}[ $i + 1 ]->field_type );
988             }
989              
990             # set year
991 82         239 my $year_node = $self->{nodes}[6];
992 82         232 my $year_lowval = $year_node->lowest($tm);
993 82         224 my $tm_year_low = $self->_set_date( $tm, $year_node->field_type, $year_lowval );
994 82         214 $tm_year_low = $self->_minus_one( $tm_year_low, $year_node->field_type );
995              
996 82 100       395 $tm = $tm_year_low if $tm->is_before($tm_year_low);
997              
998 82         721 my $max_tm = Time::Moment->new(
999             year => 2099,
1000             month => 12,
1001             day => 31,
1002             hour => 23,
1003             minute => 59,
1004             second => 59,
1005             );
1006              
1007 82         285 my $max_iter = $tm->delta_days($max_tm);
1008              
1009             # the brute force approach for DMY is correct here because:
1010             # 1) the design is simple and easy to understand and debug
1011             # 2) solves all tricky end-of-month and leap year calculations
1012             # 3) 365 iterations per one-year time window is good enough
1013              
1014 82         227 for my $day ( 1 .. $max_iter ) {
1015 328702 100       611778 return $tm->epoch if $self->_is_match($tm);
1016 328632         1111743 $tm = $tm->plus_days(1);
1017             }
1018 12         181 return;
1019             }
1020              
1021             sub previous {
1022 82     82 1 614 my ( $self, $epoch_seconds ) = @_;
1023 82   33     191 $epoch_seconds //= time;
1024              
1025 82         514 my $clamped = min( $epoch_seconds, $self->{end_epoch} );
1026              
1027 82 50       269 return if $clamped < $self->{begin_epoch};
1028              
1029 82         732 my $tm = Time::Moment->from_epoch($clamped)->with_offset_same_instant( $self->{utc_offset} );
1030 82         370 $tm = $tm->minus_seconds(1);
1031              
1032 82         226 NODE: foreach my $i ( 0 .. 2 ) {
1033 229         419 my $node = $self->{nodes}[$i];
1034              
1035 229         580 my $lowval = $node->lowest($tm);
1036 229         686 my $highval = $node->highest($tm);
1037 229         790 my $curval = $self->_field_value( $tm, $node->field_type );
1038              
1039 229 100       615 if ($curval <= $lowval) {
1040 5         25 $tm = $self->_set_date( $tm, $node->field_type, $highval );
1041 5         31 $tm = $self->_minus_one( $tm, $self->{nodes}[ $i + 1 ]->field_type );
1042 5         23 next NODE;
1043             }
1044              
1045 224         665 for ( my $c = $curval ; $c >= $lowval ; $c-- ) {
1046 9838         15527 my $c_tm = $self->_set_date( $tm, $node->field_type, $c );
1047 9838 100       14564 if ( $self->_is_match($c_tm) ) {
1048 25         54 $tm = $c_tm;
1049 25         75 last NODE;
1050             }
1051             }
1052              
1053             # flip odometer if no match
1054 199         540 $tm = $self->_set_date( $tm, $node->field_type, $highval );
1055 199         700 $tm = $self->_minus_one( $tm, $self->{nodes}[ $i + 1 ]->field_type );
1056             }
1057              
1058             # set year
1059 82         205 my $year_node = $self->{nodes}[6];
1060 82         320 my $year_highval = $year_node->highest($tm);
1061 82         296 my $tm_year_high = $self->_set_date( $tm, $year_node->field_type, $year_highval );
1062 82         228 $tm_year_high = $self->_plus_one( $tm_year_high, $year_node->field_type );
1063 82 100       417 $tm = $tm_year_high if $tm->is_after($tm_year_high);
1064              
1065             # calculate maximum iterations
1066 82         771 my $min_tm = Time::Moment->new(
1067             year => 1970,
1068             month => 1,
1069             day => 1,
1070             hour => 0,
1071             minute => 0,
1072             second => 0,
1073             );
1074              
1075 82         287 my $min_iter = $min_tm->delta_days($tm);
1076              
1077 82         240 for my $day ( 0 .. $min_iter ) {
1078 148833 100       275650 return $tm->epoch if $self->_is_match($tm);
1079 148758         521027 $tm = $tm->minus_days(1);
1080             }
1081 7         106 return;
1082             }
1083              
1084             sub _field_value {
1085 1948267     1948267   2965885 my ( $self, $tm, $field_type ) = @_;
1086 1948267 100       3950008 return $tm->second if $field_type eq 'second';
1087 1460094 100       2964867 return $tm->minute if $field_type eq 'minute';
1088 976558 100       2247020 return $tm->hour if $field_type eq 'hour';
1089 497415 100       1445393 return $tm->day_of_month if $field_type eq 'dom';
1090 19510 100       62022 return $tm->month if $field_type eq 'month';
1091 3330 100       9314 return $tm->day_of_week if $field_type eq 'dow';
1092 1438 50       6096 return $tm->year if $field_type eq 'year';
1093             }
1094              
1095             sub _set_date {
1096 11057     11057   16267 my ( $self, $tm, $field_type, $value ) = @_;
1097 11057 100       35211 return $tm->with_second($value) if $field_type eq 'second';
1098 6071 100       15123 return $tm->with_minute($value) if $field_type eq 'minute';
1099 1406 100       4188 return $tm->with_hour($value) if $field_type eq 'hour';
1100 164 50       364 return $tm->with_day_of_month($value) if $field_type eq 'dom';
1101 164 50       382 return $tm->with_month($value) if $field_type eq 'month';
1102 164 50       374 if ( $field_type eq 'dow' ) {
1103 0 0       0 $value = 7 if $value == 0;
1104 0         0 return $tm->with_day_of_week($value);
1105             }
1106 164 50       966 return $tm->with_year($value) if $field_type eq 'year';
1107             }
1108              
1109             sub _plus_one {
1110 297     297   554 my ( $self, $tm, $field_type ) = @_;
1111 297 50       576 return $tm->plus_seconds(1) if $field_type eq 'second';
1112 297 100       919 return $tm->plus_minutes(1) if $field_type eq 'minute';
1113 220 100       767 return $tm->plus_hours(1) if $field_type eq 'hour';
1114 147 100       625 return $tm->plus_days(1) if $field_type eq 'dom';
1115 82 50       196 return $tm->plus_months(1) if $field_type eq 'month';
1116 82 50       232 return $tm->plus_weeks(1) if $field_type eq 'dow';
1117 82 50       603 return $tm->plus_years(1) if $field_type eq 'year';
1118             }
1119              
1120             sub _minus_one {
1121 286     286   581 my ( $self, $tm, $field_type ) = @_;
1122 286 50       757 return $tm->minus_seconds(1) if $field_type eq 'second';
1123 286 100       1030 return $tm->minus_minutes(1) if $field_type eq 'minute';
1124 209 100       1929 return $tm->minus_hours(1) if $field_type eq 'hour';
1125 139 100       573 return $tm->minus_days(1) if $field_type eq 'dom';
1126 82 50       216 return $tm->minus_months(1) if $field_type eq 'month';
1127 82 50       198 return $tm->minus_weeks(1) if $field_type eq 'dow';
1128 82 50       550 return $tm->minus_years(1) if $field_type eq 'year';
1129             }
1130              
1131             1;
1132             __END__