geoffreywiseman-prune 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
+
|