File Coverage

blib/lib/File/Rotate/Simple.pm
Criterion Covered Total %
statement 98 106 92.4
branch 30 40 75.0
condition 14 15 93.3
subroutine 16 16 100.0
pod 2 2 100.0
total 160 179 89.3


line stmt bran cond sub pod time code
1             package File::Rotate::Simple;
2              
3 4     4   2688 use v5.8.8;
  4         12  
4              
5 4     4   1931 use Moo 1.001000;
  4         38949  
  4         22  
6             extends 'Exporter';
7              
8 4     4   7417 use Graph;
  4         111280  
  4         170  
9 4     4   39 use List::Util 1.43, qw/ first /;
  4         8  
  4         893  
10 4     4   26 use Module::Runtime qw/ require_module /;
  4         9  
  4         36  
11 4     4   252 use Path::Tiny 0.015;
  4         74  
  4         186  
12 4     4   2304 use Ref::Util qw/ is_blessed_ref /;
  4         5851  
  4         250  
13 4     4   836 use Time::Seconds qw/ ONE_DAY /;
  4         2309  
  4         230  
14 4     4   1939 use Types::Standard -types;
  4         251069  
  4         41  
15              
16 4     4   17900 use namespace::autoclean;
  4         46641  
  4         16  
17              
18             our $VERSION = 'v0.2.5';
19              
20             # ABSTRACT: no-frills file rotation
21              
22             # RECOMMEND PREREQ: Class::Load::XS
23             # RECOMMEND PREREQ: Ref::Util::XS
24             # RECOMMEND PREREQ: Type::Tiny::XS
25              
26             our @EXPORT_OK = qw/ rotate_files /;
27              
28              
29             has age => (
30             is => 'ro',
31             isa => Int,
32             default => 0,
33             );
34              
35              
36             has max => (
37             is => 'ro',
38             isa => Int,
39             default => 0,
40             );
41              
42              
43             has file => (
44             is => 'ro',
45             isa => InstanceOf['Path::Tiny'],
46             coerce => \&path,
47             required => 1,
48             );
49              
50              
51             has start_num => (
52             is => 'ro',
53             isa => Int,
54             default => 1,
55             );
56              
57              
58             has extension_format => (
59             is => 'ro',
60             isa => Str,
61             default => '.%#',
62             );
63              
64              
65             has replace_extension => (
66             is => 'ro',
67             isa => Maybe[Str],
68             );
69              
70              
71             has if_missing => (
72             is => 'ro',
73             isa => Bool,
74             default => 1,
75             );
76              
77              
78             has touch => (
79             is => 'ro',
80             isa => Bool,
81             default => 0,
82             );
83              
84              
85             has time => (
86             is => 'rw',
87             isa => InstanceOf[qw/ Time::Piece Time::Moment DateTime /],
88             lazy => 1,
89             default => sub { require_module('Time::Piece'); Time::Piece::localtime() },
90             handles => {
91             _strftime => 'strftime',
92             _epoch => 'epoch',
93             },
94             );
95              
96              
97             sub rotate {
98 10     10 1 26345 my $self = shift;
99              
100 10 100       35 unless (is_blessed_ref $self) {
101 5 50       43 my %args = (@_ == 1) ? %{ $_[0] } : @_;
  0         0  
102              
103 5 50       20 if (my $files = delete $args{files}) {
104 0         0 foreach my $file (@{$files}) {
  0         0  
105 0         0 $self->new( %args, file => $file )->rotate;
106             }
107 0         0 return;
108             }
109              
110 5         118 $self = $self->new(%args);
111             }
112              
113 10         11683 my $max = $self->max;
114 10 100       51 my $age = ($self->age)
115             ? $self->_epoch - ($self->age * ONE_DAY)
116             : 0;
117              
118 10         213 my @files = @{ $self->_build_files_to_rotate };
  10         26  
119              
120 10         1780 my $index = scalar( @files );
121              
122 10         30 while ($index--) {
123              
124 21 50       487 my $file = $files[$index] or next;
125              
126 21         37 my $current = $file->{current};
127 21         27 my $rotated = $file->{rotated};
128              
129 21 100       34 unless (defined $rotated) {
130 1         6 $current->remove;
131 1         109 next;
132             }
133              
134 20 50 66     57 if ($max && $index >= $max) {
135 0         0 $current->remove;
136 0         0 next;
137             }
138              
139 20 100 100     50 if ($age && $current->stat->mtime < $age) {
140 1         135 $current->remove;
141 1         73 next;
142             }
143              
144 19 50       7409 die "Cannot move ${current} -> ${rotated}: file exists"
145             if $rotated->exists;
146              
147 19         364 $current->move($rotated);
148             }
149              
150 10 50       402 $self->file->touch if $self->touch;
151              
152             # TODO: chmod/chown arguments
153             }
154              
155              
156             sub _build_files_to_rotate {
157 10     10   22 my ($self) = @_;
158              
159 10         16 my %files;
160              
161 10         19 my $num = $self->start_num;
162              
163 10         25 my $file = $self->_rotated_name( $num );
164 10 100       256 if ($self->file->exists) {
165              
166 3         95 $files{ $self->file } = {
167             current => $self->file,
168             rotated => $file,
169             };
170              
171             } else {
172              
173 7 50       117 return [ ] unless $self->if_missing;
174              
175             }
176              
177 10         37 my $max = $self->max;
178 10   100     31 while ($file->exists || ($max && $num <= $max)) {
      100        
179              
180 19         292 my $rotated = $self->_rotated_name( ++$num );
181              
182 19 50       496 last if $rotated eq $file;
183              
184 19 100       111 if ($file->exists) {
185 18 100 100     356 $files{ $file } = {
186             current => $file,
187             rotated => (!$max || $num <= $max) ? $rotated : undef,
188             };
189             }
190              
191 19         98 $file = $rotated;
192              
193             }
194              
195             # Using a topoligical sort is probably overkill, but it allows us
196             # to use more complicated filename rotation schemes in a subclass
197             # without having to worry about file order.
198              
199 10         234 my $g = Graph->new;
200 10         10698 foreach my $file (values %files) {
201 21         1035 my $current = $file->{current};
202 21 100       70 if (my $rotated = $file->{rotated}) {
203 20         49 $g->add_edge( $current->stringify,
204             $rotated->stringify );
205             } else {
206 1         4 $g->add_vertex( $current->stringify );
207             }
208             }
209              
210             # Now check that there is not more than one file being rotated to
211             # the same name.
212              
213 10         510 my %rotated;
214 10         26 $rotated{$_->[1]}++ for ($g->edges);
215              
216 10 50   20   665 if (my $duplicate = first { $rotated{$_} > 1 } keys %rotated) {
  20         39  
217 0         0 die "multiple files are rotated to '${duplicate}'";
218             }
219              
220 10 50       43 die "dependency chain is cyclic"
221             if $g->has_a_cycle;
222              
223             return [
224 27         97 grep { defined $_ }
225 10         20209 map { $files{$_} } $g->topological_sort()
  27         14394  
226             ];
227              
228             }
229              
230              
231             sub _rotated_name {
232 136     136   73233 my ($self, $index) = @_;
233              
234 136         303 my $format = $self->extension_format;
235             {
236 4     4   3584 no warnings 'uninitialized';
  4         9  
  4         894  
  136         181  
237 136         721 $format =~ s/\%(\d+)*#/sprintf("\%0$1d", $index)/ge;
  133         669  
238             }
239              
240 136         427 my $file = $self->file->stringify;
241 136 100       662 my $extension = ($format =~ /\%/) ? $self->_strftime($format) : $format;
242 136         472 my $replace = $self->replace_extension;
243              
244 136 100       219 if (defined $replace) {
245              
246 4         9 my $re = quotemeta($replace);
247 4         26 $file =~ s/${re}$/${extension}/;
248              
249 4         11 return path($file);
250              
251             } else {
252              
253 132         318 return path( $file . $extension );
254              
255             }
256             }
257              
258              
259             sub rotate_files {
260 1     1 1 17387 __PACKAGE__->rotate( @_ );
261             }
262              
263              
264             1;
265              
266             __END__