The London Perl and Raku Workshop takes place on 26th Oct 2024. If your company depends on Perl, please consider sponsoring and/or attending.
package File::VMSVersions;

use 5.6.0;
use strict;
use warnings;

use IO::Handle;
use File::Basename;
use File::Spec::Functions;
use Carp;
use Fcntl qw(:DEFAULT :flock);
use Data::Dumper;

our $VERSION = '0.1';

my $vcfilename = '.vcntl';

=head1 NAME

File::VMSVersions - Perl extension for opening files in a directory with
                    VMS like versioning

=head1 SYNOPSIS

  use File::VMSVersions;

  my $vdir = File::VMSVersions->new(
     -name  => "./mydir",
     -mode  => 'versions',
     -limit => 3,
  );

  foreach my $i (1..6) {
     my($fh, $fn) = $vdir->open('bla.dat', '>');
     die $fn unless $fh;
     print $fh "file number $i\n";
     print "created $fn\n";
     $fh->close;
  }

Now you should have the following files in ./mydir:

  .vcntl
  bla.dat;lck
  bla.dat;4
  bla.dat;5
  bla.dat;6

=head1 DESCRIPTION

The B<File::VMSVersions> module was developed for maintaining automatic
versioning of files in a directory. When you are using the module's routines for
opening files, it will keep a configurable amount of old versions. The versions
will be identified by a number that is added at the end of the filename after a
semicolon (i. e. F<'myfile.dat;7'>).

The configured options for a directory are saved in the file F<'.vcntl'>. They
are read each time the B<open> method is called and written when the B<new>
constructor or the B<config> method are called with according options.

F<'.vcntl'> consists of only one line with limit and mode separated by an '#'.
For example:

  20#days

  10#versions

It is ok to edit F<'.vcntl'> manually

=cut

=head1 CONSTRUCTOR

To create a new B<File::VMSVersions> call the B<new> contructor

   $obj = File::VMSVersions->new(
        -name  => <directory name>,
      [ -mode  => <'versions'|'days'>,
        -limit => <version limit>, ]
   );

You have to specify both B<-limit> and B<-mode> or none of them. If both
evaluate to false the file F<.vcntl> is read. Otherwise it will be replaced with
the new values. If the file doesn't exist when the configuration is read, there
is no version limit at all.

=cut

sub new {
   my($caller) = shift;
   my($class)  = ref($caller) || $caller;

   my %cfg = @_;

   $cfg{-name} or
      croak << '      END';
         usage: File::VMSVersions->new(
             -name  => <dirname>,
            [-mode  => <"days"|"versions">,
             -limit => <versionlimit>,]
         );
      END

   if ($cfg{-mode} xor $cfg{-limit}) {
      $cfg{-mode} ?
      croak("-limit not specified") :
      croak("-mode not specified");
   }

   %cfg = _config(%cfg);

   return(bless(\%cfg, $class));
}


=head1 METHODS

=over 4

=item B<<< $obj->open(<filename> [, <mode:'<|>|>>']> [, <version>]) >>>

Opens a version of a file. The default mode is '<' (read).

If version is not specified when reading, the last version will be opened.

If mode equals '>' (write) or '>>' (append), the specified version of the
desired file will be created or appended (append will create a new file if the
version doesn't exist).

If there is no version specified, the highest existing version will be
incremented by 1.

If the specified version is negative the nth last version will be opened.

B<open> returns a list with an indirect filehandle and the filename. On errors
the filehandle is undefined and the filename contains an error message.

=cut

sub open {
   my($self)            = shift;
   my($fn, $mode, $ver) = @_;

   $mode ||= '<';
   croak("illegal mode '$mode'") unless $mode =~ /^<|>|>>$/;

   $ver  ||= 0;

   my $fullfn = catfile($self->{-name}, $fn);

   # waiting for lock on lockfile in write mode
   my $lck;
   if ($mode =~ />/) {
      $lck = _getlock("$fullfn;lck");
   }

   # get version info
   my $info = $self->info($fn);

   my $purge = 0;

   if ($mode eq '>') {

      # negative versions in write mode make no sense
      # increase version anyway (like VMS)
      $ver = $info->{max} + 1 if $ver <= 0;
      $purge = 1;

   } else {
      if ($ver) {
         if ($ver > 0) {
            # ver too small -> set to minimum
            $ver = $info->{min} if $ver < $info->{min};
         } else {
            # get the desired version with negative array index
            $ver = $info->{$self->{-mode}}->[$ver-1];
            return(undef, "version >>$ver<< not found") unless defined($ver);
         }
         if ( !exists($info->{$ver}) and $mode eq '>>') {
            $purge = 1;
         }
      } else {
         $ver = $info->{max};
      }
   }

   CORE::open(my $fh, $mode, "$fullfn;$ver") or
      return(undef, "error opening $fullfn version $ver in mode $mode, $!");

   $self->purge($fn, $self->config()) if $purge;

   # releasing lock
   $lck->close if $lck;

   return($fh, "$fullfn;$ver");
}


=item B<<< $obj->purge(<filename>, [-mode => <mode>, -limit => <limit>] >>>

purges the versions of a file to the specified limit. When limit and mode are
not specified all but the last versions are purged. There is no need to call
B<purge> for normal versioning.

=cut

sub purge {
   my($self) = shift;
   my($fn, %cfg) = @_;

   croak("purge: no filename specified") unless $fn;

   my $fullfn = catfile($self->{-name}, $fn);

   if ($cfg{-mode} xor $cfg{-limit}) {
      $cfg{-mode} ?
      croak("-limit not specified") :
      croak("-mode not specified");
   }

   ($cfg{-limit}, $cfg{-mode}) = (1, 'versions') unless $cfg{-mode};

   my $info = $self->info($fn);

   print Dumper($info);
   print Dumper($self);
   print Dumper(\%cfg);

   foreach my $v ( @{$info->{$self->{-mode}}} ) {

      if ($cfg{-mode} eq 'versions') {

         last if ( $info->{count} <= $cfg{-limit} );

         if ( unlink("$fullfn;$v") ) {
            delete($info->{$v});
         } else {
            carp("couldn't purge $fullfn;$v");
         }

         $info->{count}--;

      } else {

         if ( $info->{$v} - $info->{d_max} > $cfg{-limit} ) {
            if ( unlink("$fullfn;$v") ) {
               delete($info->{$v});
            } else {
               carp("couldn't purge $fullfn;$v");
            }
         }

      }
   }
}


=item B<<< $obj->config([-mode => <mode>, -limit => <limit>]) >>>

Sets and/or returns limit and mode of the directory

=cut

sub config {
   my $self = shift;

   my %cfg = @_;

   if ($cfg{-limit} xor $cfg{-mode}) {
      croak('please specify both -limit and -mode or none of them!');
   }

   $cfg{-name} = $self->{-name};

   return(_config(%cfg));
}


sub _config {
   my(%cfg) = @_;

   my $vfn = catfile($cfg{-name}, $vcfilename);

   if ( $cfg{-limit} ) {

      croak("illegal mode >>$cfg{-mode}<<")   unless $cfg{-mode}  =~ /^days|versions$/;
      croak("illegal limit >>$cfg{-limit}<<") unless $cfg{-limit} =~ /^\d+$/;

      CORE::open(my $vfh, ">", $vfn) or croak("could not write $vfn, $!");
      print $vfh join('#', $cfg{-limit}, $cfg{-mode});
      $vfh->close;

   } else {

      if ( -f $vfn ) {
         CORE::open(my $vfh, "<", $vfn) or croak("couldn't read $vfn, $!");
         ( $cfg{-limit}, $cfg{-mode} ) = split(/#/, <$vfh>);
         $vfh->close;

         croak("illegal mode >>$cfg{-mode}<< from $vfn")
            unless $cfg{-mode}  =~ /^days|versions$/;
         croak("illegal limit >>$cfg{-limit}<< from $vfn")
            unless $cfg{-limit} =~ /^\d+$/;

      } else {
         ( $cfg{-limit}, $cfg{-mode} ) = (999999999999999, 'versions');
      }

   }

   return(%cfg);
}


=item B<<< $obj->info(<filename>) >>>

returns a hashref with version information for <filename>

=cut

sub info {
   my($self) = shift;
   my($fn)   = @_;

   $fn or croak "usage: info(<filename>)";

   my $fullfn = catfile($self->{-name}, $fn);

   my(%info, @tmp, $ver);

   foreach my $f (glob("$fullfn;*")) {
      $ver  = (split(/;/, $f))[-1];
      next unless $ver =~ /^\d+$/;
      $info{$ver} = -M $f;
   }

   @tmp = sort {$a <=> $b} keys(%info);
   $info{versions} = [@tmp];
   $info{count}    = @tmp;
   $info{min}      = $tmp[0]  || 0;
   $info{max}      = $tmp[-1] || 0;
   @tmp = sort {$info{$b} <=> $info{$a}} grep {/^\d+$/} keys(%info);
   $info{days}     = [@tmp];
   $info{d_min}    = $tmp[0]  ? $info{$tmp[0]}  : 0;
   $info{d_max}    = $tmp[-1] ? $info{$tmp[-1]} : 0;

   return(\%info);
}


sub _getlock {
   my($fn) = @_;

   my $mode = -e $fn ? '<' : '>';
   CORE::open(my $lck, $mode, $fn) or croak "couldn't open lock file $fn, $!";

   unless (flock($lck, LOCK_EX | LOCK_NB)) {
      flock($lck, LOCK_EX);
   }

   return($lck);
}


=head1 AUTHOR

Thomas Kratz, E<lt>ThomasKratz@web.deE<gt>

=head1 COPYRIGHT AND LICENSE

Copyright 2003 by Thomas Kratz

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.

=cut