geoffreywiseman-prune 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.mdown +24 -0
- data/Rakefile +45 -0
- data/UNLICENSE +24 -0
- data/bin/prune +7 -0
- data/lib/prune.rb +9 -0
- data/lib/prune/archiver.rb +61 -0
- data/lib/prune/category.rb +30 -0
- data/lib/prune/cli.rb +50 -0
- data/lib/prune/default_retention.rb +36 -0
- data/lib/prune/grouper.rb +27 -0
- data/lib/prune/pruner.rb +120 -0
- data/lib/prune/retention.rb +119 -0
- data/spec/archiver_spec.rb +103 -0
- data/spec/cli_spec.rb +205 -0
- data/spec/grouper_spec.rb +63 -0
- data/spec/pruner_spec.rb +206 -0
- data/spec/retention_spec.rb +147 -0
- data/spec/spec_helper.rb +5 -0
- metadata +82 -0
data/README.mdown
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# Prune: Deleting and archiving files by date
|
2
|
+
|
3
|
+
We have some nightly processes that archive information that we'd like to retain, either for reference or
|
4
|
+
for possible restoration. In order to keep space usage somewhat reasonable, we'd like to prune some of
|
5
|
+
those files as they get older. Prune satisfies that need.
|
6
|
+
|
7
|
+
Prune is written as a Ruby library with an optional command-line interface and a wrapping shell script.
|
8
|
+
|
9
|
+
## Usage
|
10
|
+
|
11
|
+
Although prune has a command-line interface, at the moment the retention policy which determines what to
|
12
|
+
do with each of the files (retain, remove, archive) and how to take action (archiving files by month in
|
13
|
+
a .tar.gz) is basically hard-coded for our current needs, although it's easy to change.
|
14
|
+
|
15
|
+
To invoke prune:
|
16
|
+
|
17
|
+
bin/prune <folder>
|
18
|
+
|
19
|
+
To get help on the various command-line options:
|
20
|
+
|
21
|
+
bin/prune --help
|
22
|
+
|
23
|
+
In the long run, I expect this will be packaged as a gem and have the retention policy externalized
|
24
|
+
where it can be configured for your own needs.
|
data/Rakefile
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'rake/clean'
|
2
|
+
require 'rake/packagetask'
|
3
|
+
require 'rspec/core/rake_task'
|
4
|
+
require 'rspec'
|
5
|
+
require 'rubygems'
|
6
|
+
require 'rubygems/package_task'
|
7
|
+
|
8
|
+
CLEAN.include( 'coverage', 'pkg' )
|
9
|
+
|
10
|
+
desc '"spec" (run RSpec)'
|
11
|
+
task :default => :spec
|
12
|
+
|
13
|
+
desc "Run RSpec on spec/*"
|
14
|
+
RSpec::Core::RakeTask.new
|
15
|
+
|
16
|
+
desc "Generate code coverage"
|
17
|
+
RSpec::Core::RakeTask.new(:coverage) do |t|
|
18
|
+
t.rcov = true
|
19
|
+
t.rcov_opts = ['--exclude', 'spec,/gems/,/rubygems/', '--text-report']
|
20
|
+
end
|
21
|
+
|
22
|
+
spec = Gem::Specification.new do |spec|
|
23
|
+
spec.name = 'geoffreywiseman-prune'
|
24
|
+
spec.version = '1.1.0'
|
25
|
+
spec.date = '2011-09-09'
|
26
|
+
spec.summary = 'Prunes files from a folder based on a retention policy, often time-based.'
|
27
|
+
spec.description = 'Prune is meant to analyze a folder full of files, run them against a retention policy and decide which to keep, which to remove and which to archive. It is extensible and embeddable.'
|
28
|
+
spec.author = 'Geoffrey Wiseman'
|
29
|
+
spec.email = 'geoffrey.wiseman@codiform.com'
|
30
|
+
spec.homepage = 'http://geoffreywiseman.github.com/prune'
|
31
|
+
spec.executables << 'prune'
|
32
|
+
|
33
|
+
spec.files = Dir['{lib,spec}/**/*.rb', 'bin/*', 'Rakefile', 'README.mdown', 'UNLICENSE']
|
34
|
+
end
|
35
|
+
|
36
|
+
Gem::PackageTask.new( spec ) do |pkg|
|
37
|
+
pkg.need_tar_gz = true
|
38
|
+
pkg.need_zip = true
|
39
|
+
end
|
40
|
+
|
41
|
+
# Rake::PackageTask.new( "prune", "1.1.0" ) do |p|
|
42
|
+
# p.need_tar_gz = true
|
43
|
+
# p.need_zip = true
|
44
|
+
# p.package_files.include( '{bin,lib,spec}/**/*', 'Rakefile', 'README.mdown', 'UNLICENSE' )
|
45
|
+
# end
|
data/UNLICENSE
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
This is free and unencumbered software released into the public domain.
|
2
|
+
|
3
|
+
Anyone is free to copy, modify, publish, use, compile, sell, or
|
4
|
+
distribute this software, either in source code form or as a compiled
|
5
|
+
binary, for any purpose, commercial or non-commercial, and by any
|
6
|
+
means.
|
7
|
+
|
8
|
+
In jurisdictions that recognize copyright laws, the author or authors
|
9
|
+
of this software dedicate any and all copyright interest in the
|
10
|
+
software to the public domain. We make this dedication for the benefit
|
11
|
+
of the public at large and to the detriment of our heirs and
|
12
|
+
successors. We intend this dedication to be an overt act of
|
13
|
+
relinquishment in perpetuity of all present and future rights to this
|
14
|
+
software under copyright law.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
19
|
+
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
20
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
21
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
22
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
23
|
+
|
24
|
+
For more information, please refer to <http://unlicense.org/>
|
data/bin/prune
ADDED
data/lib/prune.rb
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
#!/usr/bin/ruby
|
2
|
+
require 'rubygems'
|
3
|
+
require 'optparse'
|
4
|
+
require 'date'
|
5
|
+
require 'zlib'
|
6
|
+
require 'archive/tar/minitar'
|
7
|
+
include Archive::Tar
|
8
|
+
|
9
|
+
module Prune
|
10
|
+
class Archiver
|
11
|
+
attr_reader :destination
|
12
|
+
|
13
|
+
def initialize( destination, source, verbose )
|
14
|
+
@source = source
|
15
|
+
@verbose = verbose
|
16
|
+
@destination = destination || get_default_dir
|
17
|
+
end
|
18
|
+
|
19
|
+
def get_default_dir
|
20
|
+
absolute = File.expand_path @source
|
21
|
+
path = File.dirname absolute
|
22
|
+
name = File.basename absolute
|
23
|
+
File.join( path, "#{name}-archives" )
|
24
|
+
end
|
25
|
+
|
26
|
+
def make_destination_dir
|
27
|
+
begin
|
28
|
+
Dir.mkdir @destination unless File.exists? @destination
|
29
|
+
rescue SystemCallError
|
30
|
+
raise IOError, "Archive folder #{@destination} does not exist and cannot be created."
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def archive( group_name, files )
|
35
|
+
make_destination_dir
|
36
|
+
archive_path = File.join( @destination, "archive-#{group_name}.tar.gz")
|
37
|
+
paths = files.map { |file| File.join( @source, file ) }
|
38
|
+
|
39
|
+
if File.exists?( archive_path ) then
|
40
|
+
puts "Archive file #{archive_path} exists." if @verbose
|
41
|
+
Dir.mktmpdir do |tmp_dir|
|
42
|
+
puts "Created temporary directory #{tmp_dir} to extract contents of existing archive file." if @verbose
|
43
|
+
tgz = Zlib::GzipReader.new( File.open( archive_path, 'rb' ) )
|
44
|
+
Minitar.unpack( tgz, tmp_dir )
|
45
|
+
extracted_paths = Dir.entries( tmp_dir ).map { |tmpfile| File.join( tmp_dir, tmpfile ) }.reject { |path| File.directory? path }
|
46
|
+
combined_paths = extracted_paths + paths
|
47
|
+
tgz = Zlib::GzipWriter.new( File.open( archive_path, 'wb' ) )
|
48
|
+
Minitar.pack( combined_paths, tgz )
|
49
|
+
puts "Added #{files.size} file(s) to #{archive_path} archive already containing #{extracted_paths.size} file(s)." if @verbose
|
50
|
+
end
|
51
|
+
else
|
52
|
+
tgz = Zlib::GzipWriter.new( File.open( archive_path, 'wb' ) )
|
53
|
+
Minitar.pack( paths, tgz )
|
54
|
+
puts "Compressed #{files.size} file(s) into #{archive_path} archive." if @verbose
|
55
|
+
end
|
56
|
+
|
57
|
+
File.delete( *paths )
|
58
|
+
puts "Removing #{files.size} source files that have been archived." if @verbose
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
class Category
|
2
|
+
attr_accessor :action, :description
|
3
|
+
|
4
|
+
def initialize( description, action, quiet = false, predicate = Proc.new { |x| true } )
|
5
|
+
@description = description
|
6
|
+
@action = action
|
7
|
+
@predicate = predicate
|
8
|
+
@quiet = quiet
|
9
|
+
end
|
10
|
+
|
11
|
+
def requires_prompt?
|
12
|
+
case @action
|
13
|
+
when :remove
|
14
|
+
true
|
15
|
+
when :archive
|
16
|
+
true
|
17
|
+
else
|
18
|
+
false
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def includes?( filename )
|
23
|
+
@predicate.call filename
|
24
|
+
end
|
25
|
+
|
26
|
+
def quiet?
|
27
|
+
@quiet
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
data/lib/prune/cli.rb
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
#!/usr/bin/ruby
|
2
|
+
require 'rubygems'
|
3
|
+
require 'optparse'
|
4
|
+
require 'date'
|
5
|
+
require 'zlib'
|
6
|
+
require 'archive/tar/minitar'
|
7
|
+
include Archive::Tar
|
8
|
+
|
9
|
+
module Prune
|
10
|
+
VERSION = [1,0,0]
|
11
|
+
|
12
|
+
class CommandLineInterface
|
13
|
+
|
14
|
+
DEFAULT_OPTIONS = { :verbose => false, :did_work => false, :dry_run => false, :prompt => true, :archive => true }
|
15
|
+
|
16
|
+
def self.parse_and_run
|
17
|
+
options = DEFAULT_OPTIONS.dup
|
18
|
+
parser = OptionParser.new do |opts|
|
19
|
+
opts.banner = "Usage: prune [options] folder"
|
20
|
+
opts.on( "-v", "--verbose", "Prints much more frequently during execution about what it's doing." ) { options[:verbose] = true }
|
21
|
+
opts.on( "-d", "--dry-run", "Categorizes files, but does not take any actions on them." ) { options[:dry_run] = true }
|
22
|
+
opts.on( "-f", "--force", "--no-prompt", "Will take action without asking permissions; useful for automation." ) { options[:prompt] = false }
|
23
|
+
opts.on( "-a", "--archive-folder FOLDER", "The folder in which archives should be stored; defaults to <folder>/../<folder-name>-archives." ) { |path| options[:archive_path] = path }
|
24
|
+
opts.on( "--no-archive", "Don't perform archival; typically if the files you're pruning are already compressed." ) { options[:archive] = false }
|
25
|
+
opts.on_tail( "--version", "Displays version information." ) do
|
26
|
+
options[:did_work] = true
|
27
|
+
print "Prune #{VERSION.join('.')}, by Geoffrey Wiseman."
|
28
|
+
end
|
29
|
+
opts.on_tail( "-?", "--help", "Shows quick help about using prune." ) do
|
30
|
+
options[:did_work] = true
|
31
|
+
puts opts
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
begin
|
36
|
+
parser.parse!
|
37
|
+
|
38
|
+
if ARGV.size != 1 then
|
39
|
+
print parser.help unless options[:did_work]
|
40
|
+
else
|
41
|
+
Pruner.new( options ).prune( ARGV.first )
|
42
|
+
end
|
43
|
+
rescue OptionParser::ParseError
|
44
|
+
$stderr.print "Error: " + $! + "\n"
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
preprocess do |file|
|
2
|
+
file.modified_time = File.mtime( file.name )
|
3
|
+
|
4
|
+
modified_date = Date.parse modified_time.to_s
|
5
|
+
file.days_since_modified = Date.today - modified_date
|
6
|
+
file.months_since_modified = ( Date.today.year - modified_date.year ) * 12 + (Date.today.month - modified_date.month)
|
7
|
+
end
|
8
|
+
|
9
|
+
category "Ignoring directories" do
|
10
|
+
match { |file| File.directory?(file.name) }
|
11
|
+
ignore
|
12
|
+
quiet
|
13
|
+
end
|
14
|
+
|
15
|
+
category "Retaining Files from the Last Two Weeks" do
|
16
|
+
match do |file|
|
17
|
+
file.days_since_modified <= 14
|
18
|
+
end
|
19
|
+
retain
|
20
|
+
end
|
21
|
+
|
22
|
+
category "Retaining 'Friday' files Older than Two Weeks" do
|
23
|
+
match { |file| file.modified_time.wday == 5 && file.months_since_modified < 2 && file.days_since_modified > 14 }
|
24
|
+
retain
|
25
|
+
end
|
26
|
+
|
27
|
+
category "Removing 'Non-Friday' files Older than Two Weeks" do
|
28
|
+
match { |file| file.modified_time.wday != 5 && file.days_since_modified > 14 }
|
29
|
+
remove
|
30
|
+
end
|
31
|
+
|
32
|
+
category "Archiving Files Older than Two Months" do
|
33
|
+
match { |file| file.modified_time.wday == 5 && file.months_since_modified >= 2 }
|
34
|
+
archive
|
35
|
+
end
|
36
|
+
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Prune
|
2
|
+
class Grouper
|
3
|
+
|
4
|
+
def initialize( archiver )
|
5
|
+
@groups = Hash.new{ |h,k| h[k] = [] }
|
6
|
+
@archiver = archiver
|
7
|
+
end
|
8
|
+
|
9
|
+
def group( folder_name, files )
|
10
|
+
files.each do |file|
|
11
|
+
mtime = File.mtime( File.join( folder_name, file ) )
|
12
|
+
month_name = Date::ABBR_MONTHNAMES[ mtime.month ]
|
13
|
+
group_name = "#{month_name}-#{mtime.year}"
|
14
|
+
@groups[ group_name ] << file
|
15
|
+
end
|
16
|
+
return self
|
17
|
+
end
|
18
|
+
|
19
|
+
def archive
|
20
|
+
@groups.each_pair do |month,files|
|
21
|
+
@archiver.archive( month, files )
|
22
|
+
end
|
23
|
+
sizes = @groups.values.map { |x| x.size }.join( ', ' )
|
24
|
+
"#{@groups.size} archive(s) created (#{sizes} file(s), respectively)"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
data/lib/prune/pruner.rb
ADDED
@@ -0,0 +1,120 @@
|
|
1
|
+
#!/usr/bin/ruby
|
2
|
+
require 'rubygems'
|
3
|
+
require 'optparse'
|
4
|
+
require 'date'
|
5
|
+
require 'zlib'
|
6
|
+
require 'archive/tar/minitar'
|
7
|
+
include Archive::Tar
|
8
|
+
|
9
|
+
module Prune
|
10
|
+
|
11
|
+
class Pruner
|
12
|
+
attr_reader :categories
|
13
|
+
attr_reader :options
|
14
|
+
|
15
|
+
def initialize( options )
|
16
|
+
@options = options
|
17
|
+
@categories = Hash.new { |h,k| h[k] = [] } # initialize new keys with an empty array
|
18
|
+
@analyzed_count = 0
|
19
|
+
end
|
20
|
+
|
21
|
+
def prune( folder_name )
|
22
|
+
return print( "ERROR: Cannot find folder: #{folder_name}\n" ) unless File.exists? folder_name
|
23
|
+
return puts( "ERROR: #{folder_name} is not a folder" ) unless File.directory? folder_name
|
24
|
+
policy = RetentionPolicy.new folder_name
|
25
|
+
return puts( "ERROR: Retention policy contains no categories." ) if policy.categories.empty?
|
26
|
+
policy.categories.each { |cat| @categories[cat] = Array.new } # retain category order
|
27
|
+
analyze folder_name, policy
|
28
|
+
execute_prune( folder_name, policy ) unless @options[:dry_run]
|
29
|
+
end
|
30
|
+
|
31
|
+
def analyze( folder_name, policy )
|
32
|
+
print "Analyzing '#{folder_name}':\n"
|
33
|
+
files = Dir.entries( folder_name ).sort_by { |f| test(?M, File.join( folder_name, f ) ) }
|
34
|
+
files.each do |file|
|
35
|
+
analyze_file( policy, file )
|
36
|
+
end
|
37
|
+
print "\n" if @options[:verbose]
|
38
|
+
|
39
|
+
display_categories policy
|
40
|
+
print "\t#{@analyzed_count} file(s) analyzed\n"
|
41
|
+
end
|
42
|
+
|
43
|
+
def execute_prune( folder_name, policy )
|
44
|
+
begin
|
45
|
+
if should_prompt?( policy ) && !prompt then
|
46
|
+
puts "Not proceeding; no actions taken."
|
47
|
+
else
|
48
|
+
take_all_actions( folder_name, policy )
|
49
|
+
end
|
50
|
+
rescue IOError
|
51
|
+
$stderr.print "ERROR: #{$!}\n"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def should_prompt?( policy )
|
56
|
+
@options[:prompt] && actions_require_prompt( policy )
|
57
|
+
end
|
58
|
+
|
59
|
+
def actions_require_prompt( policy )
|
60
|
+
@categories.keys.any? { |category| category.requires_prompt? }
|
61
|
+
end
|
62
|
+
|
63
|
+
def prompt
|
64
|
+
print "Proceed? [y/N]: "
|
65
|
+
response = STDIN.gets.chomp.strip.downcase
|
66
|
+
['y','yes','true'].include? response
|
67
|
+
end
|
68
|
+
|
69
|
+
def take_all_actions( folder_name, policy )
|
70
|
+
actions = 0
|
71
|
+
@categories.each_pair do |category,files|
|
72
|
+
action = category.action
|
73
|
+
result = take_action( action, folder_name, files )
|
74
|
+
if !result.nil? then
|
75
|
+
puts result
|
76
|
+
actions += 1
|
77
|
+
end
|
78
|
+
end
|
79
|
+
print "No actions necessary.\n" if actions == 0
|
80
|
+
end
|
81
|
+
|
82
|
+
def take_action( action, folder_name, files )
|
83
|
+
case action
|
84
|
+
when :remove
|
85
|
+
paths = files.map { |file| File.join folder_name, file }
|
86
|
+
begin
|
87
|
+
File.delete *paths
|
88
|
+
"#{files.size} file(s) deleted"
|
89
|
+
rescue
|
90
|
+
raise IOError, "Could not remove file(s): #{$!}"
|
91
|
+
end
|
92
|
+
when :archive
|
93
|
+
if @options[:archive] then
|
94
|
+
archiver = Archiver.new( @options[:archive_path], folder_name, @options[:verbose] )
|
95
|
+
grouper = Grouper.new( archiver )
|
96
|
+
grouper.group( folder_name, files );
|
97
|
+
grouper.archive
|
98
|
+
else
|
99
|
+
"Archive option disabled. Archive(s) not created."
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def display_categories( policy )
|
105
|
+
@categories.each_pair do |category,files|
|
106
|
+
if !category.quiet? || @options[:verbose] then
|
107
|
+
print "\t#{category.description}:\n\t\t"
|
108
|
+
puts files.join( "\n\t\t")
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def analyze_file( policy, file )
|
114
|
+
category = policy.categorize( file )
|
115
|
+
@categories[ category ] << file unless category.nil?
|
116
|
+
@analyzed_count += 1
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
#!/usr/bin/ruby
|
2
|
+
require 'rubygems'
|
3
|
+
require 'date'
|
4
|
+
require 'pathname'
|
5
|
+
|
6
|
+
module Prune
|
7
|
+
|
8
|
+
class RetentionPolicy
|
9
|
+
|
10
|
+
attr_accessor :categories
|
11
|
+
|
12
|
+
def initialize( folder_name )
|
13
|
+
@folder_name = folder_name
|
14
|
+
@today = Date.today
|
15
|
+
@categories = Array.new
|
16
|
+
@default_category = Category.new "Unmatched Files", :retain, true
|
17
|
+
instance_eval *get_retention_dsl( folder_name )
|
18
|
+
end
|
19
|
+
|
20
|
+
def categorize( file_name )
|
21
|
+
file_context = FileContext.new( @folder_name, file_name, @preprocessor )
|
22
|
+
@categories.find { |cat| cat.includes? file_context } || @default_category
|
23
|
+
end
|
24
|
+
|
25
|
+
def get_retention_dsl( folder_name )
|
26
|
+
get_dsl( folder_name, '.prune' ) || get_dsl( File.dirname(__FILE__), 'default_retention.rb', 'core retention policy' )
|
27
|
+
end
|
28
|
+
|
29
|
+
def get_dsl( dsl_folder, dsl_file, human_name=nil )
|
30
|
+
dsl = File.join( dsl_folder, dsl_file )
|
31
|
+
human_name = Pathname.new( dsl ).cleanpath.to_s if human_name.nil?
|
32
|
+
if File.exists?( dsl ) then
|
33
|
+
puts "Loading retention policy from: #{human_name}"
|
34
|
+
return File.read( dsl ), dsl_file
|
35
|
+
else
|
36
|
+
return nil
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def category( description, &block )
|
41
|
+
builder = CategoryBuilder.new( description )
|
42
|
+
builder.instance_eval &block
|
43
|
+
@categories << builder.build
|
44
|
+
end
|
45
|
+
|
46
|
+
def preprocess( &block )
|
47
|
+
@preprocessor = Proc.new &block
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
class CategoryBuilder
|
53
|
+
|
54
|
+
def initialize( description )
|
55
|
+
@description = description
|
56
|
+
@quiet = false
|
57
|
+
end
|
58
|
+
|
59
|
+
def build
|
60
|
+
if @predicate.nil? then
|
61
|
+
raise "Category #{@description} has no predicate defined."
|
62
|
+
elsif @action.nil? then
|
63
|
+
raise "Category #{@description} has no action defined."
|
64
|
+
end
|
65
|
+
Category.new( @description, @action, @quiet, @predicate )
|
66
|
+
end
|
67
|
+
|
68
|
+
def match( &block )
|
69
|
+
@predicate = Proc.new &block
|
70
|
+
end
|
71
|
+
|
72
|
+
def ignore
|
73
|
+
@action = :ignore
|
74
|
+
end
|
75
|
+
|
76
|
+
def retain
|
77
|
+
@action = :retain
|
78
|
+
end
|
79
|
+
|
80
|
+
def archive
|
81
|
+
@action = :archive
|
82
|
+
end
|
83
|
+
|
84
|
+
def remove
|
85
|
+
@action = :remove
|
86
|
+
end
|
87
|
+
|
88
|
+
def quiet
|
89
|
+
@quiet = true
|
90
|
+
end
|
91
|
+
|
92
|
+
end
|
93
|
+
|
94
|
+
class FileContext
|
95
|
+
attr_accessor :name
|
96
|
+
|
97
|
+
def initialize( path, filename, preprocessor )
|
98
|
+
@name = File.join( path, filename )
|
99
|
+
@attributes = Hash.new
|
100
|
+
instance_eval &preprocessor unless preprocessor.nil?
|
101
|
+
end
|
102
|
+
|
103
|
+
# def responds_to?( symbol )
|
104
|
+
# symbol.to_s.end_with? '=' || @attributes.has_key? symbol
|
105
|
+
# end
|
106
|
+
#
|
107
|
+
def method_missing( symbol, *arguments )
|
108
|
+
if symbol.to_s =~ /(.+)=/ && arguments.size == 1 then
|
109
|
+
@attributes[ $1.to_sym ] = arguments.first
|
110
|
+
elsif @attributes.has_key?( symbol ) && arguments.empty? then
|
111
|
+
@attributes[ symbol ]
|
112
|
+
else
|
113
|
+
super symbol, arguments
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
end
|
118
|
+
|
119
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
require 'prune/archiver'
|
2
|
+
require 'tmpdir'
|
3
|
+
require 'rspec'
|
4
|
+
|
5
|
+
describe Prune::Archiver do
|
6
|
+
SOURCE='/mysql'
|
7
|
+
|
8
|
+
describe "with a #{SOURCE} source" do
|
9
|
+
DESTINATION = '/mysql-archives'
|
10
|
+
|
11
|
+
subject { Prune::Archiver.new( nil, '/mysql', true ) }
|
12
|
+
|
13
|
+
it "should have #{DESTINATION} destination" do
|
14
|
+
subject.destination.should eq( DESTINATION )
|
15
|
+
end
|
16
|
+
|
17
|
+
it "should create #{DESTINATION} if does not exist" do
|
18
|
+
File.stub( :exists? ).with( DESTINATION ) { false }
|
19
|
+
Dir.should_receive( :mkdir ).with( DESTINATION )
|
20
|
+
subject.make_destination_dir
|
21
|
+
end
|
22
|
+
|
23
|
+
it "should not attempt to create #{DESTINATION} if it exists" do
|
24
|
+
File.stub( :exists? ).with( DESTINATION ) { true }
|
25
|
+
Dir.should_not_receive( :mkdir )
|
26
|
+
subject.make_destination_dir
|
27
|
+
end
|
28
|
+
|
29
|
+
context "for May 2011" do
|
30
|
+
ARCHIVE_FILE="#{DESTINATION}/archive-May-2011.tar.gz"
|
31
|
+
|
32
|
+
it "should write new archive file in #{DESTINATION} if none exists" do
|
33
|
+
|
34
|
+
# Destination Exists
|
35
|
+
File.stub( :exists? ).with( DESTINATION ) { true }
|
36
|
+
|
37
|
+
# Archive File Exists
|
38
|
+
File.stub( :exists? ).with( ARCHIVE_FILE ) { false }
|
39
|
+
|
40
|
+
# Create Zip File
|
41
|
+
archive_file = double "file"
|
42
|
+
gz = double "GzipWriter"
|
43
|
+
paths = [ "/mysql/a", "/mysql/b", "/mysql/c" ]
|
44
|
+
File.stub( :open ).with( ARCHIVE_FILE, 'wb' ) { archive_file }
|
45
|
+
Zlib::GzipWriter.stub( :new ) { gz }
|
46
|
+
Minitar.should_receive( :pack ).with( paths, gz )
|
47
|
+
File.should_receive( :delete ).with( *paths )
|
48
|
+
|
49
|
+
subject.archive "May-2011", ["a", "b", "c"]
|
50
|
+
end
|
51
|
+
|
52
|
+
it "should add to existing archive file if it exists" do
|
53
|
+
|
54
|
+
# Destination Exists
|
55
|
+
File.stub( :exists? ).with( DESTINATION ) { true }
|
56
|
+
|
57
|
+
# Archive File Exists
|
58
|
+
File.stub( :exists? ).with( ARCHIVE_FILE ) { true }
|
59
|
+
|
60
|
+
# Should Create Temp Dir
|
61
|
+
tmpdir = "/tmp"
|
62
|
+
Dir.stub( :mktmpdir ).and_yield( tmpdir )
|
63
|
+
|
64
|
+
# Should Extract Contents
|
65
|
+
archive_file = double "archive file"
|
66
|
+
File.stub( :open ).with( ARCHIVE_FILE, 'rb' ) { archive_file }
|
67
|
+
gzr = double "GzipReader"
|
68
|
+
Zlib::GzipReader.stub( :new ) { gzr }
|
69
|
+
Minitar.should_receive( :unpack ).with( gzr, tmpdir )
|
70
|
+
Dir.should_receive( :entries ).with( tmpdir ) { ["c", "d"] }
|
71
|
+
extracted_paths = [ "/tmp/c", "/tmp/d" ]
|
72
|
+
extracted_paths.each { |path| File.stub( :directory? ).with( path ).and_return( false ) }
|
73
|
+
|
74
|
+
# Should Create Final Archive
|
75
|
+
File.stub( :open ).with( ARCHIVE_FILE, 'wb' ) { archive_file }
|
76
|
+
gzw = double "GzipWriter"
|
77
|
+
Zlib::GzipWriter.stub( :new ) { gzw }
|
78
|
+
original_paths = [ "/mysql/a", "/mysql/b" ]
|
79
|
+
combined_paths = extracted_paths + original_paths
|
80
|
+
Minitar.should_receive( :pack ).with( combined_paths, gzw )
|
81
|
+
|
82
|
+
# Delete Files
|
83
|
+
File.should_receive( :delete ).with( *original_paths )
|
84
|
+
|
85
|
+
# Go
|
86
|
+
subject.archive "May-2011", ["a", "b"]
|
87
|
+
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
|
92
|
+
context "and a /mysql/archives destination" do
|
93
|
+
subject { Prune::Archiver.new( '/mysql/archives', '/mysql', true ) }
|
94
|
+
|
95
|
+
it "should use the explicit destination" do
|
96
|
+
subject.destination.should eq( '/mysql/archives' )
|
97
|
+
end
|
98
|
+
|
99
|
+
end
|
100
|
+
|
101
|
+
end
|
102
|
+
|
103
|
+
end
|
data/spec/cli_spec.rb
ADDED
@@ -0,0 +1,205 @@
|
|
1
|
+
require 'prune/cli'
|
2
|
+
require 'prune/pruner'
|
3
|
+
require 'spec_helper'
|
4
|
+
require 'rspec'
|
5
|
+
|
6
|
+
describe Prune::CommandLineInterface do
|
7
|
+
USAGE_TEXT = /Usage: prune \[options\] folder/
|
8
|
+
ARCHIVE_PATH = "/prune/fake/archive-path"
|
9
|
+
|
10
|
+
before(:each) do
|
11
|
+
@messages = []
|
12
|
+
$stdout.stub( :write ) { |message| @messages << message }
|
13
|
+
|
14
|
+
@pruner = double( "pruner" )
|
15
|
+
end
|
16
|
+
|
17
|
+
describe "with no arguments" do
|
18
|
+
|
19
|
+
it "should print help" do
|
20
|
+
ARGV.clear
|
21
|
+
Prune::CommandLineInterface::parse_and_run
|
22
|
+
@messages.should include_match( USAGE_TEXT )
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
|
27
|
+
describe "with a path argument" do
|
28
|
+
PATH = "/prune/fake/path"
|
29
|
+
|
30
|
+
before(:each) do
|
31
|
+
ARGV.clear.push( PATH )
|
32
|
+
@pruner.stub(:prune)
|
33
|
+
end
|
34
|
+
|
35
|
+
it "should call prune with path" do
|
36
|
+
Prune::Pruner.stub( :new ) { @pruner }
|
37
|
+
@pruner.should_receive( :prune ).with( PATH )
|
38
|
+
Prune::CommandLineInterface::parse_and_run
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should create pruner with defaults" do
|
42
|
+
Prune::Pruner.stub( :new ).with( Prune::CommandLineInterface::DEFAULT_OPTIONS ) { @pruner }
|
43
|
+
Prune::CommandLineInterface::parse_and_run
|
44
|
+
end
|
45
|
+
|
46
|
+
describe "and a -v argument" do
|
47
|
+
it "should set verbose option" do
|
48
|
+
assert_arg_to_option( "-v", :verbose => true )
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe "and a --verbose argument" do
|
53
|
+
it "should set verbose option" do
|
54
|
+
assert_arg_to_option( "--verbose", :verbose => true )
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
describe "and a -d argument" do
|
59
|
+
it "should set the dry-run option" do
|
60
|
+
assert_arg_to_option( "-d", :dry_run => true )
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
describe "and a --dry-run argument" do
|
65
|
+
it "should set the dry-run option" do
|
66
|
+
assert_arg_to_option( "--dry-run", :dry_run => true )
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
describe "and a -f argument" do
|
71
|
+
it "should set the prompt option to false" do
|
72
|
+
assert_arg_to_option "-f", :prompt => false
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
describe "and a --force argument" do
|
77
|
+
it "should set the prompt option to false" do
|
78
|
+
assert_arg_to_option "--force", :prompt => false
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
describe "and a --no-prompt argument" do
|
83
|
+
it "should set the prompt option to false" do
|
84
|
+
assert_arg_to_option "--no-prompt", :prompt => false
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
describe "and a -a argument" do
|
89
|
+
before(:each) do
|
90
|
+
ARGV.push "-a"
|
91
|
+
end
|
92
|
+
|
93
|
+
describe "with no folder name" do
|
94
|
+
it "should print a parsing error" do
|
95
|
+
$stderr.stub( :print ) { |message| @messages << message }
|
96
|
+
Prune::CommandLineInterface::parse_and_run
|
97
|
+
@messages.should include_match( /missing argument: -a/ )
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
describe "with a folder name" do
|
102
|
+
it "should set the archive_path option to the folder" do
|
103
|
+
assert_arg_to_option ARCHIVE_PATH, :archive_path => ARCHIVE_PATH
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
describe "and a --archive-folder argument" do
|
109
|
+
before(:each) do
|
110
|
+
ARGV.push "--archive-folder"
|
111
|
+
end
|
112
|
+
|
113
|
+
describe "with no folder name" do
|
114
|
+
it "should print a parsing error" do
|
115
|
+
$stderr.stub( :print ) { |message| @messages << message }
|
116
|
+
Prune::CommandLineInterface::parse_and_run
|
117
|
+
@messages.should include_match( /missing argument: --archive-folder/ )
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
describe "with a folder name" do
|
122
|
+
it "should set the archive_path option to the folder" do
|
123
|
+
assert_arg_to_option ARCHIVE_PATH, :archive_path => ARCHIVE_PATH
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
describe "and a -? argument" do
|
129
|
+
|
130
|
+
before(:each) do
|
131
|
+
ARGV.push "-?"
|
132
|
+
end
|
133
|
+
|
134
|
+
it "should print help" do
|
135
|
+
Prune::CommandLineInterface::parse_and_run
|
136
|
+
@messages.should include_match( USAGE_TEXT )
|
137
|
+
end
|
138
|
+
|
139
|
+
it "should not invoke prune" do
|
140
|
+
@pruner.should_not_receive( :prune )
|
141
|
+
Prune::CommandLineInterface::parse_and_run
|
142
|
+
end
|
143
|
+
|
144
|
+
end
|
145
|
+
|
146
|
+
describe "and a --help argument" do
|
147
|
+
|
148
|
+
before(:each) do
|
149
|
+
ARGV.push "--help"
|
150
|
+
end
|
151
|
+
|
152
|
+
it "should print help" do
|
153
|
+
Prune::CommandLineInterface::parse_and_run
|
154
|
+
@messages.should include_match( USAGE_TEXT )
|
155
|
+
end
|
156
|
+
|
157
|
+
it "should not invoke prune" do
|
158
|
+
@pruner.should_not_receive( :prune )
|
159
|
+
Prune::CommandLineInterface::parse_and_run
|
160
|
+
end
|
161
|
+
|
162
|
+
end
|
163
|
+
|
164
|
+
describe "and an unknown argument" do
|
165
|
+
it "should print a parsing error" do
|
166
|
+
ARGV.push "--unknown-argument"
|
167
|
+
$stderr.stub( :print ) { |message| @messages << message }
|
168
|
+
Prune::CommandLineInterface::parse_and_run
|
169
|
+
@messages.should include_match( /invalid option/ )
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
describe "and a --version argument" do
|
174
|
+
before(:each) do
|
175
|
+
ARGV.push "--version"
|
176
|
+
end
|
177
|
+
|
178
|
+
it "should print version number" do
|
179
|
+
$stderr.stub( :print ) { |message| @messages << message }
|
180
|
+
Prune::CommandLineInterface::parse_and_run
|
181
|
+
@messages.should include_match( /Prune 1.0.0/ )
|
182
|
+
end
|
183
|
+
|
184
|
+
it "should not invoke prune" do
|
185
|
+
@pruner.should_not_receive( :prune )
|
186
|
+
Prune::CommandLineInterface::parse_and_run
|
187
|
+
end
|
188
|
+
|
189
|
+
end
|
190
|
+
|
191
|
+
describe "and a --no-archive argument" do
|
192
|
+
it "should set the archive option to false" do
|
193
|
+
assert_arg_to_option "--no-archive", :archive=>false
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
def assert_arg_to_option( arg, *options )
|
198
|
+
Prune::Pruner.should_receive( :new ).with( hash_including( *options ) ).and_return( @pruner )
|
199
|
+
ARGV.push( arg )
|
200
|
+
Prune::CommandLineInterface::parse_and_run
|
201
|
+
end
|
202
|
+
|
203
|
+
end
|
204
|
+
|
205
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'prune/grouper'
|
2
|
+
require 'prune/retention'
|
3
|
+
require 'spec_helper'
|
4
|
+
require 'rspec'
|
5
|
+
|
6
|
+
describe "Prune::Grouper" do
|
7
|
+
|
8
|
+
GROUP_PATH = '/example/prune/folder'
|
9
|
+
before( :each ) do
|
10
|
+
@archiver = double( "Archiver" )
|
11
|
+
@grouper = Prune::Grouper.new( @archiver )
|
12
|
+
end
|
13
|
+
|
14
|
+
context "w/o files" do
|
15
|
+
|
16
|
+
it "should not archive" do
|
17
|
+
@archiver.should_not_receive( :archive )
|
18
|
+
@grouper.group( GROUP_PATH, [] ).archive
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
22
|
+
|
23
|
+
context "with files" do
|
24
|
+
|
25
|
+
it "should archive files" do
|
26
|
+
files = mock_files( Date.new(2011,01,01) )
|
27
|
+
@archiver.should_receive( :archive )
|
28
|
+
@grouper.group( GROUP_PATH, files ).archive
|
29
|
+
end
|
30
|
+
|
31
|
+
it "should specify month and year" do
|
32
|
+
files = mock_files( Date.new(2008,03,01) )
|
33
|
+
@archiver.should_receive( :archive ).with( "Mar-2008", files )
|
34
|
+
@grouper.group( GROUP_PATH, files ).archive
|
35
|
+
end
|
36
|
+
|
37
|
+
it "should combine files with same month/year" do
|
38
|
+
files = mock_files( Date.new(2008,03,01), Date.new(2008,03,02) )
|
39
|
+
@archiver.should_receive( :archive ).with( "Mar-2008", files )
|
40
|
+
@grouper.group( GROUP_PATH, files ).archive
|
41
|
+
end
|
42
|
+
|
43
|
+
it "should not combine files with same month, different year" do
|
44
|
+
files = mock_files( Date.new(2008,03,01), Date.new(2009,03,01) )
|
45
|
+
@archiver.should_receive( :archive ).with( "Mar-2008", [files.first] )
|
46
|
+
@archiver.should_receive( :archive ).with( "Mar-2009", [files.last] )
|
47
|
+
@grouper.group( GROUP_PATH, files ).archive
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
def mock_files( *dates )
|
53
|
+
files = []
|
54
|
+
dates.map do |item|
|
55
|
+
month_name = Date::ABBR_MONTHNAMES[item.month]
|
56
|
+
file_name = "file-#{month_name}-#{item.year}.sql"
|
57
|
+
File.stub( :mtime ).with( "#{GROUP_PATH}/#{file_name}" ) { item }
|
58
|
+
files << file_name
|
59
|
+
end
|
60
|
+
return files;
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
data/spec/pruner_spec.rb
ADDED
@@ -0,0 +1,206 @@
|
|
1
|
+
require 'prune'
|
2
|
+
require 'spec_helper'
|
3
|
+
require 'rspec'
|
4
|
+
|
5
|
+
describe Prune::Pruner do
|
6
|
+
|
7
|
+
PRUNE_PATH = '/example/prune/folder'
|
8
|
+
subject { Prune::Pruner.new Hash.new }
|
9
|
+
|
10
|
+
before( :each ) do
|
11
|
+
@retention_policy = double( "RetentionPolicy" )
|
12
|
+
@retention_policy.stub( :categories ) { [ Category.new( "Unmatched Files", :retain, true ) ] }
|
13
|
+
Prune::RetentionPolicy.stub( :new ) { @retention_policy }
|
14
|
+
end
|
15
|
+
|
16
|
+
context "w/o prompt" do
|
17
|
+
before( :each ) do
|
18
|
+
subject.options[:prompt]=false
|
19
|
+
end
|
20
|
+
|
21
|
+
it "should not attempt to process folder that does not exist" do
|
22
|
+
File.stub( :exists? ).with( PRUNE_PATH ) { false }
|
23
|
+
Dir.should_not_receive( :foreach )
|
24
|
+
$stdout.should_receive( :write ).with( /ERROR: Cannot find folder/ )
|
25
|
+
subject.prune( PRUNE_PATH )
|
26
|
+
end
|
27
|
+
|
28
|
+
context "with no files" do
|
29
|
+
|
30
|
+
before( :each ) do
|
31
|
+
stub_files
|
32
|
+
stub_messages
|
33
|
+
subject.prune PRUNE_PATH
|
34
|
+
end
|
35
|
+
|
36
|
+
it "should not invoke the retention policy" do
|
37
|
+
@retention_policy.should_not_receive( :categorize )
|
38
|
+
end
|
39
|
+
|
40
|
+
it "should print 'Analyzing #{PRUNE_PATH}'" do
|
41
|
+
@messages.should include("Analyzing '#{PRUNE_PATH}':\n")
|
42
|
+
end
|
43
|
+
|
44
|
+
it "should say no action was required" do
|
45
|
+
@messages.should include("No actions necessary.\n")
|
46
|
+
end
|
47
|
+
|
48
|
+
it "should say no files were analyzed" do
|
49
|
+
@messages.should include_match( /0 file\(s\) analyzed/ )
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
|
54
|
+
context "with three files" do
|
55
|
+
ONE_DAY = 86400
|
56
|
+
|
57
|
+
before( :each ) do
|
58
|
+
stub_files "beta.txt" => Time.now - ONE_DAY, "alpha.txt" => Time.now - 3*ONE_DAY, "gamma.txt" => Time.now
|
59
|
+
stub_messages
|
60
|
+
end
|
61
|
+
|
62
|
+
it "should categorize each file in modified order" do
|
63
|
+
@retention_policy.should_receive( :categorize ).with( 'alpha.txt' ).ordered
|
64
|
+
@retention_policy.should_receive( :categorize ).with( 'beta.txt' ).ordered
|
65
|
+
@retention_policy.should_receive( :categorize ).with( 'gamma.txt' ).ordered
|
66
|
+
subject.prune PRUNE_PATH
|
67
|
+
end
|
68
|
+
|
69
|
+
it "should say three files were analyzed" do
|
70
|
+
@retention_policy.as_null_object
|
71
|
+
subject.prune PRUNE_PATH
|
72
|
+
@messages.should include_match( /3 file\(s\) analyzed/ )
|
73
|
+
end
|
74
|
+
|
75
|
+
end
|
76
|
+
|
77
|
+
context "with file categorized as :remove" do
|
78
|
+
it "should delete file" do
|
79
|
+
filename = 'delete-me.txt'
|
80
|
+
stub_files filename
|
81
|
+
|
82
|
+
category = double( category )
|
83
|
+
category.stub( :description ) { "Old" }
|
84
|
+
category.stub( :action ) { :remove }
|
85
|
+
category.stub( :quiet? ) { false }
|
86
|
+
|
87
|
+
@retention_policy.should_receive( :categorize ).with( filename ) { category }
|
88
|
+
File.should_receive( :delete ).with( File.join( PRUNE_PATH, filename ) )
|
89
|
+
subject.prune PRUNE_PATH
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
context "with files categorized as :archive" do
|
94
|
+
let!(:files) { [ 'one.tar.gz', 'two.tar.gz', 'three.tar.gz' ] }
|
95
|
+
|
96
|
+
before do
|
97
|
+
subject.options[:archive] = true
|
98
|
+
stub_files files
|
99
|
+
end
|
100
|
+
|
101
|
+
it "should archive files in groups" do
|
102
|
+
category = double( "category" )
|
103
|
+
@retention_policy.stub( :categorize ) { category }
|
104
|
+
category.stub( :action ) { :archive }
|
105
|
+
category.stub( :description ) { "Ancient" }
|
106
|
+
category.stub( :quiet? ) { false }
|
107
|
+
|
108
|
+
grouper = double( "Grouper" )
|
109
|
+
Prune::Grouper.stub( :new ) { grouper }
|
110
|
+
grouper.should_receive( :group ).with( PRUNE_PATH, files )
|
111
|
+
grouper.should_receive( :archive ) { "2 Archives created." }
|
112
|
+
|
113
|
+
subject.prune PRUNE_PATH
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
end
|
118
|
+
|
119
|
+
describe "Confirmation Prompt" do
|
120
|
+
before( :each ) do
|
121
|
+
subject.options[:prompt]=false
|
122
|
+
end
|
123
|
+
|
124
|
+
it "should interpret 'Y' as true" do
|
125
|
+
expect_prompt_with_response( "Y\n")
|
126
|
+
subject.prompt.should be_true
|
127
|
+
end
|
128
|
+
|
129
|
+
it "should interpret 'Y ' as true" do
|
130
|
+
expect_prompt_with_response("Y \n")
|
131
|
+
subject.prompt.should be_true
|
132
|
+
end
|
133
|
+
|
134
|
+
it "should interpret ' Y' as true" do
|
135
|
+
expect_prompt_with_response(" Y\n")
|
136
|
+
subject.prompt.should be_true
|
137
|
+
end
|
138
|
+
|
139
|
+
it "should interpret ' Y ' as true" do
|
140
|
+
expect_prompt_with_response(" Y \n")
|
141
|
+
subject.prompt.should be_true
|
142
|
+
end
|
143
|
+
|
144
|
+
it "should interpret 'y' as true" do
|
145
|
+
expect_prompt_with_response("y\n")
|
146
|
+
subject.prompt.should be_true
|
147
|
+
end
|
148
|
+
|
149
|
+
it "should interpret 'yes' as true" do
|
150
|
+
expect_prompt_with_response("yes\n")
|
151
|
+
subject.prompt.should be_true
|
152
|
+
end
|
153
|
+
|
154
|
+
it "should interpret 'no' as false" do
|
155
|
+
expect_prompt_with_response("no\n")
|
156
|
+
subject.prompt.should be_false
|
157
|
+
end
|
158
|
+
|
159
|
+
it "should interpret 'n' as false" do
|
160
|
+
expect_prompt_with_response("n\n")
|
161
|
+
subject.prompt.should be_false
|
162
|
+
end
|
163
|
+
|
164
|
+
it "should interpret 'N' as false" do
|
165
|
+
expect_prompt_with_response("N\n")
|
166
|
+
subject.prompt.should be_false
|
167
|
+
end
|
168
|
+
|
169
|
+
it "should interpret 'q' as false" do
|
170
|
+
expect_prompt_with_response("q\n")
|
171
|
+
subject.prompt.should be_false
|
172
|
+
end
|
173
|
+
|
174
|
+
def expect_prompt_with_response( response )
|
175
|
+
$stdout.should_receive( :write ).with( /Proceed?/ )
|
176
|
+
STDIN.stub(:gets) { response }
|
177
|
+
end
|
178
|
+
|
179
|
+
end
|
180
|
+
|
181
|
+
def stub_files( files = nil )
|
182
|
+
File.stub( :exists? ).with( PRUNE_PATH ) { true }
|
183
|
+
File.stub( :directory? ).with( PRUNE_PATH ) { true }
|
184
|
+
case files
|
185
|
+
when nil
|
186
|
+
Dir.stub( :entries ).with( PRUNE_PATH ) { Array.new }
|
187
|
+
when String
|
188
|
+
subject.stub(:test).with( ?M, File.join( PRUNE_PATH, files ) ) { Time.now }
|
189
|
+
Dir.stub( :entries ).with( PRUNE_PATH ) { [ files ] }
|
190
|
+
when Array
|
191
|
+
files.each_index { |index| subject.stub(:test).with( ?M, File.join( PRUNE_PATH, files[index] ) ) { index } }
|
192
|
+
Dir.stub( :entries ).with( PRUNE_PATH ) { files }
|
193
|
+
when Hash
|
194
|
+
files.each_key { |key| subject.stub(:test).with( ?M, File.join( PRUNE_PATH, key ) ) { files[key] } }
|
195
|
+
Dir.stub( :entries ).with( PRUNE_PATH ) { files.keys }
|
196
|
+
else
|
197
|
+
raise "Don't know how to stub files for #{files.class}"
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
def stub_messages
|
202
|
+
@messages = []
|
203
|
+
$stdout.stub( :write ) { |message| @messages << message }
|
204
|
+
end
|
205
|
+
|
206
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
require 'prune/retention'
|
2
|
+
require 'prune/category'
|
3
|
+
require 'rspec'
|
4
|
+
require 'spec/spec_helper'
|
5
|
+
|
6
|
+
DAY = 24 * 60 * 60
|
7
|
+
|
8
|
+
describe Prune::RetentionPolicy do
|
9
|
+
|
10
|
+
SOURCE_DIR = "source_path"
|
11
|
+
SOURCE_FILE = "source_file"
|
12
|
+
SOURCE_PATH = "#{SOURCE_DIR}/#{SOURCE_FILE}"
|
13
|
+
|
14
|
+
subject { Prune::RetentionPolicy.new SOURCE_DIR }
|
15
|
+
|
16
|
+
describe "default retention policy" do
|
17
|
+
|
18
|
+
it "should return categories in dsl order" do
|
19
|
+
cats = subject.categories
|
20
|
+
cats.shift.description.should include( "Ignoring directories" )
|
21
|
+
cats.shift.description.should include( "from the Last Two Weeks" )
|
22
|
+
cats.shift.description.should include( "Retaining 'Friday'" )
|
23
|
+
cats.shift.description.should include( "Removing 'Non-Friday'" )
|
24
|
+
cats.shift.description.should include( "Archiving" )
|
25
|
+
cats.should be_empty
|
26
|
+
end
|
27
|
+
|
28
|
+
describe "analyzing a directory" do
|
29
|
+
let( :dircat ) do
|
30
|
+
File.stub( :directory? ).with( SOURCE_PATH ) { true }
|
31
|
+
File.stub( :mtime ).with( SOURCE_PATH ) { Time.now }
|
32
|
+
subject.categorize( SOURCE_FILE )
|
33
|
+
end
|
34
|
+
|
35
|
+
|
36
|
+
it "should be categorized as 'Ignoring directories'" do
|
37
|
+
dircat.description.should eq( "Ignoring directories" )
|
38
|
+
end
|
39
|
+
|
40
|
+
it "should invoke action :ignore" do
|
41
|
+
dircat.action.should eq( :ignore )
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe "analyzing a file" do
|
46
|
+
|
47
|
+
describe "created yesterday" do
|
48
|
+
|
49
|
+
let( :yestercat ) do
|
50
|
+
File.stub( :directory? ).with( SOURCE_PATH ) { false }
|
51
|
+
File.stub( :mtime ).with( SOURCE_PATH ) { Time.now - DAY }
|
52
|
+
subject.categorize( SOURCE_FILE )
|
53
|
+
end
|
54
|
+
|
55
|
+
it "should be categorized as '... Last Two Weeks'" do
|
56
|
+
yestercat.description.should include( 'Last Two Weeks' )
|
57
|
+
end
|
58
|
+
|
59
|
+
it "should invoke action :retain" do
|
60
|
+
yestercat.action.should eq( :retain )
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
describe "created three weeks ago, wednesday" do
|
65
|
+
|
66
|
+
let( :weeksago ) do
|
67
|
+
File.stub( :directory? ).with( SOURCE_PATH ) { false }
|
68
|
+
File.stub( :mtime ).with( SOURCE_PATH ) { weeks_ago( 3, 'Wed' ) }
|
69
|
+
subject.categorize( SOURCE_FILE )
|
70
|
+
end
|
71
|
+
|
72
|
+
it "should be described as 'Older than Two Weeks' and 'Non-Friday'" do
|
73
|
+
weeksago.description.should include 'Non-Friday'
|
74
|
+
weeksago.description.should include 'Older than Two Weeks'
|
75
|
+
end
|
76
|
+
|
77
|
+
it "should invoke action :remove" do
|
78
|
+
weeksago.action.should eq( :remove )
|
79
|
+
end
|
80
|
+
|
81
|
+
end
|
82
|
+
|
83
|
+
describe "created three weeks ago, friday" do
|
84
|
+
|
85
|
+
let( :weeksagofriday ) do
|
86
|
+
File.stub( :directory? ).with( SOURCE_PATH ) { false }
|
87
|
+
File.stub( :mtime ).with( SOURCE_PATH ) { weeks_ago( 3, 'Fri' ) }
|
88
|
+
subject.categorize( SOURCE_FILE )
|
89
|
+
end
|
90
|
+
|
91
|
+
it "should be described as 'Friday files', 'Older than Two Weeks'" do
|
92
|
+
weeksagofriday.description.should include( "'Friday' files" )
|
93
|
+
weeksagofriday.description.should include( 'Older than Two Weeks' )
|
94
|
+
end
|
95
|
+
|
96
|
+
it "should invoke action :remove" do
|
97
|
+
weeksagofriday.action.should eq( :retain )
|
98
|
+
end
|
99
|
+
|
100
|
+
end
|
101
|
+
|
102
|
+
describe "created three months ago, friday" do
|
103
|
+
|
104
|
+
let( :oldfriday ) do
|
105
|
+
File.stub( :directory? ).with( SOURCE_PATH ) { false }
|
106
|
+
File.stub( :mtime ).with( SOURCE_PATH ) { weeks_ago( 12, 'Fri' ) }
|
107
|
+
subject.categorize( SOURCE_FILE )
|
108
|
+
end
|
109
|
+
|
110
|
+
it "should be described as 'Older than Two Months'" do
|
111
|
+
oldfriday.description.should include( 'Older than Two Months' )
|
112
|
+
end
|
113
|
+
|
114
|
+
it "should invoke action :archive" do
|
115
|
+
oldfriday.action.should eq( :archive )
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
119
|
+
|
120
|
+
describe "created three months ago, wednesday" do
|
121
|
+
|
122
|
+
let( :oldwednesday ) do
|
123
|
+
File.stub( :directory? ).with( SOURCE_PATH ) { false }
|
124
|
+
File.stub( :mtime ).with( SOURCE_PATH ) { weeks_ago( 12, 'Wed' ) }
|
125
|
+
subject.categorize( SOURCE_FILE )
|
126
|
+
end
|
127
|
+
|
128
|
+
it "should be described as 'Non-Friday files', 'Older than Two Weeks'" do
|
129
|
+
oldwednesday.description.should include( "'Non-Friday' files" )
|
130
|
+
oldwednesday.description.should include( "Older than Two Weeks" )
|
131
|
+
end
|
132
|
+
|
133
|
+
it "should invoke action :remove" do
|
134
|
+
oldwednesday.action.should eq( :remove )
|
135
|
+
end
|
136
|
+
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
end
|
142
|
+
|
143
|
+
def weeks_ago( weeks, weekday )
|
144
|
+
sub_weeks = Time.now - ( DAY * 7 * weeks )
|
145
|
+
weekday_adjustment = Time.now.wday - Date::ABBR_DAYNAMES.index( weekday )
|
146
|
+
sub_weeks - ( weekday_adjustment * DAY )
|
147
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,82 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: geoffreywiseman-prune
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 19
|
5
|
+
prerelease:
|
6
|
+
segments:
|
7
|
+
- 1
|
8
|
+
- 1
|
9
|
+
- 0
|
10
|
+
version: 1.1.0
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Geoffrey Wiseman
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2011-09-09 00:00:00 Z
|
19
|
+
dependencies: []
|
20
|
+
|
21
|
+
description: Prune is meant to analyze a folder full of files, run them against a retention policy and decide which to keep, which to remove and which to archive. It is extensible and embeddable.
|
22
|
+
email: geoffrey.wiseman@codiform.com
|
23
|
+
executables:
|
24
|
+
- prune
|
25
|
+
extensions: []
|
26
|
+
|
27
|
+
extra_rdoc_files: []
|
28
|
+
|
29
|
+
files:
|
30
|
+
- lib/prune/archiver.rb
|
31
|
+
- lib/prune/category.rb
|
32
|
+
- lib/prune/cli.rb
|
33
|
+
- lib/prune/default_retention.rb
|
34
|
+
- lib/prune/grouper.rb
|
35
|
+
- lib/prune/pruner.rb
|
36
|
+
- lib/prune/retention.rb
|
37
|
+
- lib/prune.rb
|
38
|
+
- spec/archiver_spec.rb
|
39
|
+
- spec/cli_spec.rb
|
40
|
+
- spec/grouper_spec.rb
|
41
|
+
- spec/pruner_spec.rb
|
42
|
+
- spec/retention_spec.rb
|
43
|
+
- spec/spec_helper.rb
|
44
|
+
- bin/prune
|
45
|
+
- Rakefile
|
46
|
+
- README.mdown
|
47
|
+
- UNLICENSE
|
48
|
+
homepage: http://geoffreywiseman.github.com/prune
|
49
|
+
licenses: []
|
50
|
+
|
51
|
+
post_install_message:
|
52
|
+
rdoc_options: []
|
53
|
+
|
54
|
+
require_paths:
|
55
|
+
- lib
|
56
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
hash: 3
|
62
|
+
segments:
|
63
|
+
- 0
|
64
|
+
version: "0"
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
66
|
+
none: false
|
67
|
+
requirements:
|
68
|
+
- - ">="
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
hash: 3
|
71
|
+
segments:
|
72
|
+
- 0
|
73
|
+
version: "0"
|
74
|
+
requirements: []
|
75
|
+
|
76
|
+
rubyforge_project:
|
77
|
+
rubygems_version: 1.8.10
|
78
|
+
signing_key:
|
79
|
+
specification_version: 3
|
80
|
+
summary: Prunes files from a folder based on a retention policy, often time-based.
|
81
|
+
test_files: []
|
82
|
+
|