File Coverage

blib/lib/File/Rotate/Simple.pm
Criterion Covered Total %
statement 97 105 92.3
branch 30 40 75.0
condition 14 15 93.3
subroutine 16 16 100.0
pod 2 2 100.0
total 159 178 89.3


line stmt bran cond sub pod time code
1             package File::Rotate::Simple;
2              
3 4     4   3191 use v5.14;
  4         18  
4              
5 4     4   2228 use Moo 1.001000;
  4         46867  
  4         24  
6             extends 'Exporter';
7              
8 4     4   9027 use Graph;
  4         132353  
  4         165  
9 4     4   34 use List::Util 1.43, qw/ first /;
  4         8  
  4         799  
10 4     4   40 use Module::Runtime qw/ require_module /;
  4         8  
  4         25  
11 4     4   260 use Path::Tiny 0.015;
  4         90  
  4         216  
12 4     4   2171 use Ref::Util qw/ is_blessed_ref /;
  4         6812  
  4         303  
13 4     4   947 use Time::Seconds qw/ ONE_DAY /;
  4         2668  
  4         287  
14 4     4   2378 use Types::Standard -types;
  4         299092  
  4         37  
15              
16 4     4   20572 use namespace::autoclean;
  4         55447  
  4         20  
17              
18             our $VERSION = 'v0.3.0';
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 31236 my $self = shift;
99              
100 10 100       39 unless (is_blessed_ref $self) {
101 5 50       29 my %args = (@_ == 1) ? %{ $_[0] } : @_;
  0         0  
102              
103 5 50       17 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         94 $self = $self->new(%args);
111             }
112              
113 10         13560 my $max = $self->max;
114 10 100       56 my $age = ($self->age)
115             ? $self->_epoch - ($self->age * ONE_DAY)
116             : 0;
117              
118 10         265 my @files = @{ $self->_build_files_to_rotate };
  10         23  
119              
120 10         2107 my $index = scalar( @files );
121              
122 10         35 while ($index--) {
123              
124 21 50       4531 my $file = $files[$index] or next;
125              
126 21         42 my $current = $file->{current};
127 21         34 my $rotated = $file->{rotated};
128              
129 21 100       47 unless (defined $rotated) {
130 1         10 $current->remove;
131 1         88 next;
132             }
133              
134 20 50 66     63 if ($max && $index >= $max) {
135 0         0 $current->remove;
136 0         0 next;
137             }
138              
139 20 100 100     104 if ($age && $current->stat->mtime < $age) {
140 1         157 $current->remove;
141 1         60 next;
142             }
143              
144 19 50       8215 die "Cannot move ${current} -> ${rotated}: file exists"
145             if $rotated->exists;
146              
147 19         436 $current->move($rotated);
148             }
149              
150 10 50       6707 $self->file->touch if $self->touch;
151              
152             # TODO: chmod/chown arguments
153             }
154              
155              
156             sub _build_files_to_rotate {
157 10     10   32 my ($self) = @_;
158              
159 10         19 my %files;
160              
161 10         28 my $num = $self->start_num;
162              
163 10         27 my $file = $self->_rotated_name( $num );
164 10 100       381 if ($self->file->exists) {
165              
166 3         96 $files{ $self->file } = {
167             current => $self->file,
168             rotated => $file,
169             };
170              
171             } else {
172              
173 7 50       126 return [ ] unless $self->if_missing;
174              
175             }
176              
177 10         41 my $max = $self->max;
178 10   100     34 while ($file->exists || ($max && $num <= $max)) {
      100        
179              
180 19         307 my $rotated = $self->_rotated_name( ++$num );
181              
182 19 50       662 last if $rotated eq $file;
183              
184 19 100       154 if ($file->exists) {
185 18 100 100     387 $files{ $file } = {
186             current => $file,
187             rotated => (!$max || $num <= $max) ? $rotated : undef,
188             };
189             }
190              
191 19         177 $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         277 my $g = Graph->new;
200 10         11671 foreach my $file (values %files) {
201 21         1716 my $current = $file->{current};
202 21 100       83 if (my $rotated = $file->{rotated}) {
203 20         88 $g->add_edge( $current->stringify,
204             $rotated->stringify );
205             } else {
206 1         16 $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         907 my %rotated;
214 10         43 $rotated{$_->[1]}++ for ($g->edges);
215              
216 10 50   20   471 if (my $duplicate = first { $rotated{$_} > 1 } keys %rotated) {
  20         47  
217 0         0 die "multiple files are rotated to '${duplicate}'";
218             }
219              
220 10 50       58 die "dependency chain is cyclic"
221             if $g->has_a_cycle;
222              
223             return [
224 27         111 grep { defined $_ }
225 10         51844 map { $files{$_} } $g->topological_sort()
  27         22745  
226             ];
227              
228             }
229              
230              
231             sub _rotated_name {
232 136     136   87333 my ($self, $index) = @_;
233              
234 136         337 my $format = $self->extension_format;
235             {
236 4     4   4362 no warnings 'uninitialized';
  4         12  
  4         1107  
  136         201  
237 136         855 $format =~ s/\%(\d+)*#/sprintf("\%0$1d", $index)/gea;
  133         725  
238             }
239              
240 136         539 my $file = $self->file->stringify;
241 136 100       863 my $extension = ($format =~ /\%/) ? $self->_strftime($format) : $format;
242 136         558 my $replace = $self->replace_extension;
243              
244 136 100       265 if (defined $replace) {
245              
246 4         8 my $re = quotemeta($replace);
247 4         46 return path($file =~ s/${re}$/${extension}/r );
248              
249             } else {
250              
251 132         372 return path( $file . $extension );
252              
253             }
254             }
255              
256              
257             sub rotate_files {
258 1     1 1 22238 __PACKAGE__->rotate( @_ );
259             }
260              
261              
262             1;
263              
264             __END__