time_tree 2.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +18 -0
- data/.rvmrc +1 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +57 -0
- data/Rakefile +1 -0
- data/bin/timetree +89 -0
- data/fixtures/.a_dot_file +1 -0
- data/fixtures/real.txt +17 -0
- data/fixtures/time/jun1.txt +3 -0
- data/fixtures/time/jun2.txt +3 -0
- data/fixtures/time/jun3.txt +3 -0
- data/fixtures/time.txt +3 -0
- data/lib/time_tree/activity_tree.rb +36 -0
- data/lib/time_tree/date_calculator.rb +37 -0
- data/lib/time_tree/file_parser.rb +143 -0
- data/lib/time_tree/version.rb +3 -0
- data/lib/time_tree.rb +3 -0
- data/spec/activity_tree_spec.rb +45 -0
- data/spec/date_calculator_spec.rb +110 -0
- data/spec/file_parser_spec.rb +276 -0
- data/spec/helper.rb +4 -0
- data/spec/integration_spec.rb +90 -0
- data/time_tree.gemspec +20 -0
- metadata +91 -0
data/.gitignore
ADDED
data/.rvmrc
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
rvm --create use 1.9.3@time_log
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 Andy White
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
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
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
# Timetree
|
2
|
+
|
3
|
+
Timetree is a command line utility that prints a tree-like breakdown of time spent on activities based on a simple, human readable time logging language stored in plain text files.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
$ gem install time_tree
|
8
|
+
|
9
|
+
## Time Log Format
|
10
|
+
|
11
|
+
Each day's log should start with a date in YYYY/MM/DD format. Any text following is ignored as comments. Following this are lines starting with a time in 24 hour format - HHMI, followed by an activity which must contain no spaces, any text following is ignored and considered as comments. Activities may have nested subcategories to any level separated by forward slashes - these will be printed as a hierarchy by timetree. Use a dash (-) for the activity to be ignored and not reported on - this means a day's log must always end with a time followed by a dash to denote when the previous line's activity finished.
|
12
|
+
|
13
|
+
Blank lines are ignored and lines starting with # are ignored as comments.
|
14
|
+
|
15
|
+
Here's an example:
|
16
|
+
|
17
|
+
2013/04/21 A splendid day!
|
18
|
+
|
19
|
+
0930 admin some comments
|
20
|
+
0945 development/project1 some more comments
|
21
|
+
1005 -
|
22
|
+
1030 developemnt/project2
|
23
|
+
1115 -
|
24
|
+
|
25
|
+
# Did some work in the evening!
|
26
|
+
2230 bugfixing/project3 # Use a hash after a time line if you want but not needed
|
27
|
+
2315 -
|
28
|
+
|
29
|
+
|
30
|
+
A day's log can't span across more than one file, but a file may contain multiple day's logs. This gives flexibility, all time may be stored in one big file or there may be multiple files, one for each day or week for example. Multiple files may be stored in a nested folder hierarchy - time tree will recursively search for files, ignoring those starting with . (dot).
|
31
|
+
|
32
|
+
## Usage
|
33
|
+
|
34
|
+
$ timetree [options] [path]
|
35
|
+
|
36
|
+
### Options
|
37
|
+
|
38
|
+
Reports on the given date or period. Weeks are deemed to start on Monday.
|
39
|
+
|
40
|
+
* *--all*, *-a* - include all dates recorded
|
41
|
+
* *--today*, *-t* - this is the assumed default
|
42
|
+
* *--yesterday*, *-y*
|
43
|
+
* *--week [weeks-previous]*, *-w* - the current week so far. To report on previous weeks use the optional *weeks-previous* argument, 0 (the assumed default) would mean the current week, 1 would mean last week, 2 would mean the week previous to that and so on.
|
44
|
+
* *--month [months-previous]*, *-m* - the current calendar month so far. The optional *months-revious* argument works in a similar way to *--week weeks-previous*
|
45
|
+
* *--date YYYY/MM/DD*, *-d*
|
46
|
+
* *--between YYYY/MM/DD:YYYY/MM/DD*, *-b* - reports on the given inclusive range of dates
|
47
|
+
* *--filter search-string[,search-string]*, *-f* - only reports on activities matching *search-string*
|
48
|
+
|
49
|
+
If given no path, Timetree will look in the users's home directory for a file or directory called *Time*, *time* or *.time*.
|
50
|
+
|
51
|
+
## Contributing
|
52
|
+
|
53
|
+
1. Fork it
|
54
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
55
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
56
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
57
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/bin/timetree
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
$:.unshift File.expand_path(File.join('..', '..', 'lib'), __FILE__)
|
3
|
+
require 'time_tree'
|
4
|
+
require 'optparse'
|
5
|
+
|
6
|
+
module TimeTree
|
7
|
+
options = {:today => true}
|
8
|
+
|
9
|
+
option_parser = OptionParser.new do |opts|
|
10
|
+
opts.banner = "Usage: #{File.basename($0)} [options] [data_path]"
|
11
|
+
|
12
|
+
opts.on('-a', '--all') do
|
13
|
+
options[:all] = true
|
14
|
+
options[:today] = false
|
15
|
+
end
|
16
|
+
|
17
|
+
opts.on('-t', '--today') do
|
18
|
+
options[:all] = false
|
19
|
+
options[:today] = true
|
20
|
+
end
|
21
|
+
|
22
|
+
opts.on('-y', '--yesterday') do
|
23
|
+
options[:yesterday] = true
|
24
|
+
options[:all] = false
|
25
|
+
options[:today] = false
|
26
|
+
end
|
27
|
+
|
28
|
+
opts.on('-w [WEEKS_PREVIOUS]', '--week', 'The current week is the default') do |weeks_previous|
|
29
|
+
options[:week] = weeks_previous.to_i || 0
|
30
|
+
options[:all] = false
|
31
|
+
options[:today] = false
|
32
|
+
end
|
33
|
+
|
34
|
+
opts.on('-m [MONTHS_PREVIOUS]', '--month', 'The current month is the default') do |months_previous|
|
35
|
+
options[:month] = months_previous.to_i || 0
|
36
|
+
options[:all] = false
|
37
|
+
options[:today] = false
|
38
|
+
end
|
39
|
+
|
40
|
+
opts.on('-d DATE', '--date', 'Date must be of format YYYY/MM/DD') do |date|
|
41
|
+
if date =~ /\d\d\d\d\/\d\d\/\d\d/
|
42
|
+
options[:date] = date
|
43
|
+
options[:all] = false
|
44
|
+
options[:today] = false
|
45
|
+
else
|
46
|
+
puts "Invalid date format. Must be YYYY/MM/DD"
|
47
|
+
exit 1
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
opts.on('-r RANGE', '--range', 'Range must be of format YYYY/MM/DD:YYYY/MM/DD') do |range|
|
52
|
+
if range =~ /\d\d\d\d\/\d\d\/\d\d:\d\d\d\d\/\d\d\/\d\d/
|
53
|
+
options[:range] = range
|
54
|
+
options[:all] = false
|
55
|
+
options[:today] = false
|
56
|
+
else
|
57
|
+
puts "Invalid date range. Must be YYYY/MM/DD:YYYY/MM/DD"
|
58
|
+
exit 1
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
opts.on('-f SEARCH', '--filter') do |search|
|
63
|
+
options[:filter] = search.split(',')
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
option_parser.parse!
|
68
|
+
tree = ActivityTree.new
|
69
|
+
parser = FileParser.new(tree, options)
|
70
|
+
|
71
|
+
if ARGV[0]
|
72
|
+
parser.process_file(ARGV[0])
|
73
|
+
else
|
74
|
+
home = ENV['HOME']
|
75
|
+
path = parser.find_path([File.join(home, 'Time'), File.join(home, 'time'),
|
76
|
+
File.join(home, '.time')])
|
77
|
+
parser.process_file(path) if path
|
78
|
+
end
|
79
|
+
|
80
|
+
if parser.valid?
|
81
|
+
tree.process
|
82
|
+
tree.print
|
83
|
+
exit 0
|
84
|
+
else
|
85
|
+
STDERR.puts "ERRORS:"
|
86
|
+
STDERR.puts parser.errors.join("\n")
|
87
|
+
exit 1
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
foo
|
data/fixtures/real.txt
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
1975/06/01
|
2
|
+
0755 i/bug - failing article filtering - on train
|
3
|
+
0825 -
|
4
|
+
0910 i/bug
|
5
|
+
0925 i/sysadm - fixing dead teamcity with Al
|
6
|
+
0955 i/bug
|
7
|
+
1025 i/adm emails
|
8
|
+
1040 i/adm weekly update meeting
|
9
|
+
1110 i/sysadm - learning how to do end of sprint release
|
10
|
+
1135 i/adm tech scrum
|
11
|
+
1200 i/bug/1760 optimise edit schedules page
|
12
|
+
1255 - lunch
|
13
|
+
1355 i/adm listening in on user meeting
|
14
|
+
1405 i/sysadm release to live & sanity test
|
15
|
+
1505 i/bug/1760
|
16
|
+
1750 i/adm log time
|
17
|
+
1800 -
|
data/fixtures/time.txt
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
module TimeTree
|
2
|
+
class ActivityTree
|
3
|
+
attr_reader :activities, :output
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
@activities = {}
|
7
|
+
@output = []
|
8
|
+
end
|
9
|
+
|
10
|
+
def load(activities, minutes, level = 0, target = @activities)
|
11
|
+
activities.unshift 'All' if level == 0
|
12
|
+
activity = activities.shift
|
13
|
+
target[activity] = {:minutes => 0, :children => {}} unless target[activity]
|
14
|
+
target[activity][:minutes] += minutes
|
15
|
+
load(activities, minutes, level+1, target[activity][:children]) if activities.any?
|
16
|
+
end
|
17
|
+
|
18
|
+
def process(level = 0, target = activities)
|
19
|
+
target.each do |activity, values|
|
20
|
+
output << "%-25s %4d min (%s)" % ["#{(1..level*2).to_a.map{' '}.join}#{activity}",
|
21
|
+
values[:minutes],
|
22
|
+
to_hrs_mins(values[:minutes])]
|
23
|
+
process(level+1, values[:children]) if values[:children].any?
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def to_hrs_mins(mins)
|
28
|
+
hours = (mins/60.0).floor
|
29
|
+
"%d:%02d" % [hours, mins - (hours*60)]
|
30
|
+
end
|
31
|
+
|
32
|
+
def print
|
33
|
+
puts output.join("\n")
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
module TimeTree
|
4
|
+
module DateCalculator
|
5
|
+
def prev_weekday(date, weekday, count = 0)
|
6
|
+
delta = (7 + date.wday - daynum(weekday)) % 7
|
7
|
+
date - (delta + (count * 7))
|
8
|
+
end
|
9
|
+
|
10
|
+
def next_weekday(date, weekday, count = 0)
|
11
|
+
delta = (7 - date.wday + daynum(weekday)) % 7
|
12
|
+
delta = 7 if delta == 0
|
13
|
+
date + (delta + (count * 7))
|
14
|
+
end
|
15
|
+
|
16
|
+
def date_between?(date, from, to)
|
17
|
+
date >= from && to >= date
|
18
|
+
end
|
19
|
+
|
20
|
+
def in_prev_week?(date, count = 0, ref_date = Date.today)
|
21
|
+
monday = prev_weekday(ref_date, :mon, count)
|
22
|
+
sunday = monday + 6
|
23
|
+
date_between?(date, monday, sunday)
|
24
|
+
end
|
25
|
+
|
26
|
+
def in_prev_month?(date, count = 0, ref_date = Date.today)
|
27
|
+
ref_month = ref_date >> (-1 * count)
|
28
|
+
date.year == ref_month.year && date.month == ref_month.month
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def daynum(weekday)
|
34
|
+
{:mon => 1, :tue => 2, :wed => 3, :thu => 4, :fri => 5, :sat => 6, :sun => 7}[weekday]
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
require 'date'
|
2
|
+
require 'time_tree/date_calculator'
|
3
|
+
|
4
|
+
module TimeTree
|
5
|
+
class FileParser
|
6
|
+
include DateCalculator
|
7
|
+
attr_reader :errors
|
8
|
+
|
9
|
+
def initialize(tree, options)
|
10
|
+
@errors = []
|
11
|
+
@activity_tree = tree
|
12
|
+
@options = options
|
13
|
+
end
|
14
|
+
|
15
|
+
def find_path(paths)
|
16
|
+
paths.each do |path|
|
17
|
+
return path if File.exist?(path)
|
18
|
+
end
|
19
|
+
|
20
|
+
@errors << "File not found in: #{paths.join(', ')}"
|
21
|
+
false
|
22
|
+
end
|
23
|
+
|
24
|
+
def set_file(path)
|
25
|
+
@path = path
|
26
|
+
@line_number = 0
|
27
|
+
end
|
28
|
+
|
29
|
+
def set_date(date)
|
30
|
+
@date = date
|
31
|
+
@prev_mins = nil
|
32
|
+
@prev_activities = nil
|
33
|
+
end
|
34
|
+
|
35
|
+
def process_file(path)
|
36
|
+
if File.exist?(path)
|
37
|
+
if File.directory?(path)
|
38
|
+
process_folder(path)
|
39
|
+
else
|
40
|
+
set_file(path)
|
41
|
+
File.read(path).each_line {|line| parse_line(line) }
|
42
|
+
true
|
43
|
+
end
|
44
|
+
else
|
45
|
+
@errors << "File not found: #{path}"
|
46
|
+
false
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def process_folder(path)
|
51
|
+
Dir.new(path).each do |file|
|
52
|
+
process_file(File.join(path, file)) unless file =~ /^\./
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def parse_line(line)
|
57
|
+
@line_number += 1
|
58
|
+
|
59
|
+
case line.chomp.sub(/#.*/, '').strip
|
60
|
+
when ''
|
61
|
+
# ignore blank lines and comments
|
62
|
+
true
|
63
|
+
|
64
|
+
when /^(\d\d\d\d\/\d\d\/\d\d) *.*$/
|
65
|
+
set_date($1)
|
66
|
+
true
|
67
|
+
|
68
|
+
when /^(\d\d\d\d) +([^ ]+) *.*$/
|
69
|
+
if minutes = mins($1)
|
70
|
+
unless @prev_mins.nil?
|
71
|
+
if minutes > @prev_mins
|
72
|
+
if @prev_activities != '-' && selected?(@date, @prev_activities)
|
73
|
+
process_line(minutes, @prev_activities)
|
74
|
+
end
|
75
|
+
else
|
76
|
+
add_error(line, 'time does not advance')
|
77
|
+
return false
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
@prev_mins = minutes
|
82
|
+
@prev_activities = $2
|
83
|
+
true
|
84
|
+
else
|
85
|
+
false
|
86
|
+
end
|
87
|
+
|
88
|
+
else
|
89
|
+
add_error(line, 'line not understood')
|
90
|
+
false
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def add_error(line, message)
|
95
|
+
@errors << "%s:%d: %s - %s" % [File.basename(@path), @line_number, line.chomp, message]
|
96
|
+
end
|
97
|
+
|
98
|
+
def valid?
|
99
|
+
errors.empty?
|
100
|
+
end
|
101
|
+
|
102
|
+
def selected?(date = @date, activities = '', options = @options)
|
103
|
+
date_options = [:today, :yesterday, :week, :month, :date, :range]
|
104
|
+
parsed_date = Date.parse(date)
|
105
|
+
|
106
|
+
(
|
107
|
+
options.detect { |key, val| date_options.include?(key) }.nil? ||
|
108
|
+
options[:all] ||
|
109
|
+
options[:today] && parsed_date == Date.today ||
|
110
|
+
options[:yesterday] && parsed_date == Date.today-1 ||
|
111
|
+
options[:week] && in_prev_week?(parsed_date, options[:week]) ||
|
112
|
+
options[:month] && in_prev_month?(parsed_date, options[:month]) ||
|
113
|
+
options[:date] && parsed_date == Date.parse(options[:date]) ||
|
114
|
+
options[:range] && parsed_date >= Date.parse(options[:range].split(':').first) &&
|
115
|
+
parsed_date <= Date.parse(options[:range].split(':').last)
|
116
|
+
) &&
|
117
|
+
(
|
118
|
+
options[:filter] && options[:filter].detect { |f| activities =~ Regexp.new(f) } ||
|
119
|
+
options[:filter].nil?
|
120
|
+
)
|
121
|
+
end
|
122
|
+
|
123
|
+
private
|
124
|
+
|
125
|
+
def process_line(minutes, activities)
|
126
|
+
duration = minutes - @prev_mins
|
127
|
+
@activity_tree.load(activities.split('/'), duration)
|
128
|
+
end
|
129
|
+
|
130
|
+
def mins(str)
|
131
|
+
return 1440 if str == '0000' && @prev_mins
|
132
|
+
hours = str[0..1].to_i
|
133
|
+
mins = str[2..3].to_i
|
134
|
+
|
135
|
+
if hours <= 23 && mins <= 59
|
136
|
+
(hours * 60) + mins
|
137
|
+
else
|
138
|
+
add_error(str, 'invalid time')
|
139
|
+
false
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
data/lib/time_tree.rb
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'time_tree/activity_tree'
|
2
|
+
|
3
|
+
module TimeTree
|
4
|
+
describe ActivityTree do
|
5
|
+
let(:tree) { ActivityTree.new }
|
6
|
+
|
7
|
+
before do
|
8
|
+
tree.load(%w{foo bar baz}, 10)
|
9
|
+
tree.load(%w{foo bar bam}, 5)
|
10
|
+
tree.load(%w{blah}, 12)
|
11
|
+
end
|
12
|
+
|
13
|
+
it "should have 2 root activities" do
|
14
|
+
tree.activities['All'][:children].size.should == 2
|
15
|
+
end
|
16
|
+
|
17
|
+
it "foo should have 15 mins" do
|
18
|
+
tree.activities['All'][:children]['foo'][:minutes].should == 15
|
19
|
+
end
|
20
|
+
|
21
|
+
it "bar should have 15 mins" do
|
22
|
+
tree.activities['All'][:children]['foo'][:children]['bar'][:minutes].should == 15
|
23
|
+
end
|
24
|
+
|
25
|
+
it "baz should have 10 mins" do
|
26
|
+
tree.activities['All'][:children]['foo'][:children]['bar'][:children]['baz'][:minutes].should == 10
|
27
|
+
end
|
28
|
+
|
29
|
+
it "bam should have 5 mins" do
|
30
|
+
tree.activities['All'][:children]['foo'][:children]['bar'][:children]['bam'][:minutes].should == 5
|
31
|
+
end
|
32
|
+
|
33
|
+
it "blah should have 12 mins" do
|
34
|
+
tree.activities['All'][:children]['blah'][:minutes].should == 12
|
35
|
+
end
|
36
|
+
|
37
|
+
it "blah should have no children" do
|
38
|
+
tree.activities['All'][:children]['blah'][:children].size.should == 0
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should print" do
|
42
|
+
tree.print
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
require 'time_tree/date_calculator'
|
2
|
+
|
3
|
+
module TimeTree
|
4
|
+
describe DateCalculator do
|
5
|
+
include DateCalculator
|
6
|
+
|
7
|
+
describe "prev_weekday" do
|
8
|
+
it "monday" do
|
9
|
+
prev_weekday(Date.new(2013, 5, 5), :mon, 0).should == Date.new(2013, 4, 29)
|
10
|
+
end
|
11
|
+
|
12
|
+
it "tuesday" do
|
13
|
+
prev_weekday(Date.new(2013, 5, 6), :tue, 0).should == Date.new(2013, 4, 30)
|
14
|
+
end
|
15
|
+
|
16
|
+
it "sunday" do
|
17
|
+
prev_weekday(Date.new(2013, 5, 7), :sun, 0).should == Date.new(2013, 5, 5)
|
18
|
+
end
|
19
|
+
|
20
|
+
it "sunday prev week" do
|
21
|
+
prev_weekday(Date.new(2013, 5, 8), :sun, 1).should == Date.new(2013, 4, 28)
|
22
|
+
end
|
23
|
+
|
24
|
+
it "tuesday prev prev week" do
|
25
|
+
prev_weekday(Date.new(2013, 5, 9), :tue, 2).should == Date.new(2013, 4, 23)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
describe "next_weekday" do
|
30
|
+
it "monday" do
|
31
|
+
next_weekday(Date.new(2013, 5, 6), :mon, 0).should == Date.new(2013, 5, 13)
|
32
|
+
end
|
33
|
+
|
34
|
+
it "tuesday" do
|
35
|
+
next_weekday(Date.new(2013, 5, 6), :tue, 0).should == Date.new(2013, 5, 7)
|
36
|
+
end
|
37
|
+
|
38
|
+
it "sunday" do
|
39
|
+
next_weekday(Date.new(2013, 5, 7), :sun, 0).should == Date.new(2013, 5, 12)
|
40
|
+
end
|
41
|
+
|
42
|
+
it "sunday next week" do
|
43
|
+
next_weekday(Date.new(2013, 5, 8), :sun, 1).should == Date.new(2013, 5, 19)
|
44
|
+
end
|
45
|
+
|
46
|
+
it "tuesday next next week" do
|
47
|
+
next_weekday(Date.new(2013, 5, 9), :tue, 2).should == Date.new(2013, 5, 28)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
describe "date_between" do
|
52
|
+
let(:jun1) { Date.new(2000, 6, 1) }
|
53
|
+
let(:jun2) { Date.new(2000, 6, 2) }
|
54
|
+
let(:jun3) { Date.new(2000, 6, 3) }
|
55
|
+
|
56
|
+
it "between" do
|
57
|
+
date_between?(jun2, jun1, jun3).should be_true
|
58
|
+
end
|
59
|
+
|
60
|
+
it "outside" do
|
61
|
+
date_between?(jun1, jun2, jun3).should be_false
|
62
|
+
end
|
63
|
+
|
64
|
+
it "at start" do
|
65
|
+
date_between?(jun1, jun1, jun3).should be_true
|
66
|
+
end
|
67
|
+
|
68
|
+
it "at end" do
|
69
|
+
date_between?(jun3, jun1, jun3).should be_true
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
describe "in_prev_week?" do
|
74
|
+
let(:may7) { Date.new(2013, 5, 7) }
|
75
|
+
let(:may1) { Date.new(2013, 5, 1) }
|
76
|
+
|
77
|
+
it "within - zero count" do
|
78
|
+
in_prev_week?(may7, 0, may7).should be_true
|
79
|
+
end
|
80
|
+
|
81
|
+
it "within - 1 count" do
|
82
|
+
in_prev_week?(may1, 1, may7).should be_true
|
83
|
+
end
|
84
|
+
|
85
|
+
it "outside" do
|
86
|
+
in_prev_week?(may7, 1, may7).should be_false
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
describe"in_prev_month?" do
|
91
|
+
let(:may31) { Date.new(2013, 5, 31) }
|
92
|
+
let(:may7) { Date.new(2013, 5, 7) }
|
93
|
+
let(:may1) { Date.new(2013, 5, 1) }
|
94
|
+
let(:apr1) { Date.new(2013, 4, 1) }
|
95
|
+
|
96
|
+
it "within - zero count" do
|
97
|
+
in_prev_month?(may1, 0, may7).should be_true
|
98
|
+
in_prev_month?(may31, 0, may7).should be_true
|
99
|
+
end
|
100
|
+
|
101
|
+
it "within - 1 count" do
|
102
|
+
in_prev_month?(apr1, 1, may7).should be_true
|
103
|
+
end
|
104
|
+
|
105
|
+
it "outside" do
|
106
|
+
in_prev_month?(apr1, 0, may7).should be_false
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,276 @@
|
|
1
|
+
require 'time_tree/file_parser'
|
2
|
+
require 'helper'
|
3
|
+
|
4
|
+
module TimeTree
|
5
|
+
describe FileParser do
|
6
|
+
let(:tree) { mock('ActivityTree', :load => nil) }
|
7
|
+
let(:parser) { FileParser.new(tree, {}) }
|
8
|
+
let(:home) { ENV['HOME'] }
|
9
|
+
|
10
|
+
describe "#find_path" do
|
11
|
+
it "finds a path" do
|
12
|
+
parser.find_path(['/not/on/your/nelly', "#{home}/.bash_profile"]).should =~ /bash_profile/
|
13
|
+
end
|
14
|
+
|
15
|
+
it "finds no path" do
|
16
|
+
parser.find_path(['/not/on/your/nelly', "#/not/there"]).should be_false
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
describe "#process_file" do
|
21
|
+
context "is a directory" do
|
22
|
+
before do
|
23
|
+
FileParser.any_instance.stub(:process_folder => true)
|
24
|
+
end
|
25
|
+
|
26
|
+
it "returns true" do
|
27
|
+
parser.process_file(fixtures).should be_true
|
28
|
+
end
|
29
|
+
|
30
|
+
it "calls #process_folder" do
|
31
|
+
parser.should_receive(:process_folder).with(fixtures).once
|
32
|
+
parser.process_file(fixtures)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
context "is a file" do
|
37
|
+
before do
|
38
|
+
FileParser.any_instance.stub(:parse_line => true)
|
39
|
+
end
|
40
|
+
|
41
|
+
it "returns true" do
|
42
|
+
parser.process_file(fixtures('time.txt')).should be_true
|
43
|
+
end
|
44
|
+
|
45
|
+
it "calls process_line for each line of the file" do
|
46
|
+
parser.should_receive(:parse_line).exactly(3).times
|
47
|
+
parser.process_file(fixtures('time.txt'))
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
context "file or directory not found" do
|
52
|
+
before do
|
53
|
+
@result = parser.process_file('does/not/exist')
|
54
|
+
end
|
55
|
+
|
56
|
+
it "returns false" do
|
57
|
+
@result.should be_false
|
58
|
+
end
|
59
|
+
|
60
|
+
it "flags an error and sets object invalid" do
|
61
|
+
parser.errors.size.should == 1
|
62
|
+
parser.valid?.should be_false
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
describe "#process_folder" do
|
68
|
+
before do
|
69
|
+
FileParser.any_instance.stub(:process_file => true)
|
70
|
+
end
|
71
|
+
|
72
|
+
it "calls #process_file for each contained file" do
|
73
|
+
parser.should_receive(:process_file).with(fixtures('time/jun1.txt')).once
|
74
|
+
parser.should_receive(:process_file).with(fixtures('time/jun2.txt')).once
|
75
|
+
parser.should_receive(:process_file).with(fixtures('time/jun3.txt')).once
|
76
|
+
parser.process_folder(fixtures('time'))
|
77
|
+
end
|
78
|
+
|
79
|
+
it "ignores files starting with dot" do
|
80
|
+
File.open(fixtures('.a_dot_file'), 'w') {|f| f.write('foo')}
|
81
|
+
parser.should_not_receive(:process_file).with(fixtures('.a_dot_file'))
|
82
|
+
parser.process_folder(fixtures)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
describe "#parse_line" do
|
87
|
+
before do
|
88
|
+
parser.set_file('my_file.txt')
|
89
|
+
end
|
90
|
+
|
91
|
+
context "date lines" do
|
92
|
+
before do
|
93
|
+
FileParser.any_instance.stub(:set_date => true)
|
94
|
+
end
|
95
|
+
|
96
|
+
context "well formed date" do
|
97
|
+
let(:good_date) { '2013/04/23 some comments' }
|
98
|
+
|
99
|
+
it "returns true" do
|
100
|
+
parser.parse_line(good_date).should be_true
|
101
|
+
end
|
102
|
+
|
103
|
+
it "calls set_date" do
|
104
|
+
parser.should_receive(:set_date).once
|
105
|
+
parser.parse_line(good_date)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
context "malformed date" do
|
110
|
+
let(:bad_date) { '201b/foo/bar' }
|
111
|
+
|
112
|
+
it "returns false" do
|
113
|
+
parser.parse_line(bad_date).should be_false
|
114
|
+
end
|
115
|
+
|
116
|
+
it "does not call set_date" do
|
117
|
+
parser.should_not_receive(:set_date)
|
118
|
+
parser.parse_line(bad_date)
|
119
|
+
end
|
120
|
+
|
121
|
+
it "logs an error" do
|
122
|
+
parser.parse_line(bad_date)
|
123
|
+
parser.errors.size.should == 1
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
context "time lines" do
|
129
|
+
before do
|
130
|
+
parser.set_date('2013/01/02')
|
131
|
+
end
|
132
|
+
|
133
|
+
context "normal activities" do
|
134
|
+
it "returns true and remains valid" do
|
135
|
+
parser.parse_line("1634 adm/foo/bar dhffhkdhsdhjdf").should be_true
|
136
|
+
parser.parse_line("1635 adm/foo/bar dhffh kdh sdhjdf ").should be_true
|
137
|
+
parser.parse_line("1636 adm/foo/bar dhffh kdh sdhjdf ").should be_true
|
138
|
+
parser.parse_line("1637 adm").should be_true
|
139
|
+
parser.parse_line("1638 -").should be_true
|
140
|
+
parser.parse_line("1639 - sdf sdfs sfsad").should be_true
|
141
|
+
parser.valid?.should be_true
|
142
|
+
end
|
143
|
+
|
144
|
+
it "calls ActivityTree#load after first line" do
|
145
|
+
tree.should_receive(:load).with(%w{adm foo bar}, 1).once
|
146
|
+
parser.parse_line("1634 adm/foo/bar dhffhkdhsdhjdf")
|
147
|
+
parser.parse_line("1635 -")
|
148
|
+
end
|
149
|
+
|
150
|
+
it "flags invalid times" do
|
151
|
+
parser.parse_line("2435 - df sdfsdg").should be_false
|
152
|
+
parser.parse_line("2x3d - df sdfsdg").should be_false
|
153
|
+
parser.valid?.should be_false
|
154
|
+
end
|
155
|
+
|
156
|
+
it "flags non-advancing times" do
|
157
|
+
parser.parse_line("1253 - df sdfsdg").should be_true
|
158
|
+
parser.parse_line("1253 - df sdfsdg").should be_false
|
159
|
+
parser.valid?.should be_false
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
context "dash for activity" do
|
165
|
+
it "returns true and remains valid" do
|
166
|
+
parser.parse_line("1634 -").should be_true
|
167
|
+
parser.valid?.should be_true
|
168
|
+
end
|
169
|
+
|
170
|
+
it "does not call ActivityTree#load after first line" do
|
171
|
+
tree.should_not_receive(:load).with(%w{-}, 1)
|
172
|
+
parser.parse_line("1634 -")
|
173
|
+
parser.parse_line("1635 foo")
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
context "comment lines" do
|
178
|
+
it "should return true" do
|
179
|
+
parser.parse_line("# a comment").should be_true
|
180
|
+
end
|
181
|
+
|
182
|
+
it "should not log errors" do
|
183
|
+
parser.parse_line("# a comment")
|
184
|
+
parser.errors.size.should == 0
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
describe "#selected?" do
|
190
|
+
it "should be true if no date selectors" do
|
191
|
+
parser.selected?('2013/01/01', {:bish => true, :bosh => :tigers}).should be_true
|
192
|
+
end
|
193
|
+
|
194
|
+
context "today" do
|
195
|
+
let(:options) { {:today => true} }
|
196
|
+
|
197
|
+
it "should be true if date is today" do
|
198
|
+
parser.selected?(Time.now.strftime("%Y/%m/%d"), options).should be_true
|
199
|
+
end
|
200
|
+
|
201
|
+
it "should be false if date is not today" do
|
202
|
+
parser.selected?('1962/01/03', '', options).should be_false
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
context "yesterday" do
|
207
|
+
let(:options) { {:yesterday => true} }
|
208
|
+
|
209
|
+
it "should be true if date is yesterday" do
|
210
|
+
parser.selected?((Time.now-(24*60*60)).strftime("%Y/%m/%d"), options).should be_true
|
211
|
+
end
|
212
|
+
|
213
|
+
it "should be false if date is not yesterday" do
|
214
|
+
parser.selected?('1962/01/03', '', options).should be_false
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
context "week" do
|
219
|
+
end
|
220
|
+
|
221
|
+
context "month" do
|
222
|
+
end
|
223
|
+
|
224
|
+
context "specific date" do
|
225
|
+
let(:options) { {:date => '2012/01/01'} }
|
226
|
+
|
227
|
+
it "should be true if date matches" do
|
228
|
+
parser.selected?('2012/01/01', options).should be_true
|
229
|
+
end
|
230
|
+
|
231
|
+
it "should be false if date does not match" do
|
232
|
+
parser.selected?('1962/01/03', '', options).should be_false
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
context "date range" do
|
237
|
+
let(:options) { {:range => '2012/01/01:2012/01/02'} }
|
238
|
+
|
239
|
+
it "should be true if date within" do
|
240
|
+
parser.selected?('2012/01/01', options).should be_true
|
241
|
+
parser.selected?('2012/01/02', options).should be_true
|
242
|
+
end
|
243
|
+
|
244
|
+
it "should be false if date outside" do
|
245
|
+
parser.selected?('1962/01/03', '', options).should be_false
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
context "filter" do
|
250
|
+
it "finds a hit" do
|
251
|
+
parser.selected?('1962/01/03', 'foo/bar', {:filter => ['foo', 'boo']}).should be_true
|
252
|
+
end
|
253
|
+
|
254
|
+
it "finds no hit" do
|
255
|
+
parser.selected?('1962/01/03', 'foo/bar', {:filter => ['blork', 'flange']}).should be_false
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
describe "midnight edge case" do
|
261
|
+
context "no previous mins" do
|
262
|
+
it "returns 0 mins" do
|
263
|
+
parser.send(:mins, '0000').should == 0
|
264
|
+
end
|
265
|
+
end
|
266
|
+
|
267
|
+
context "previous mins" do
|
268
|
+
it "returns 1440 mins (1 day)" do
|
269
|
+
parser.set_file('afile')
|
270
|
+
parser.parse_line('2359 foo')
|
271
|
+
parser.send(:mins, '0000').should == 1440
|
272
|
+
end
|
273
|
+
end
|
274
|
+
end
|
275
|
+
end
|
276
|
+
end
|
data/spec/helper.rb
ADDED
@@ -0,0 +1,90 @@
|
|
1
|
+
require 'time_tree/activity_tree'
|
2
|
+
require 'time_tree/file_parser'
|
3
|
+
require 'helper'
|
4
|
+
|
5
|
+
module TimeTree
|
6
|
+
describe 'Integration' do
|
7
|
+
let(:tree) { ActivityTree.new }
|
8
|
+
|
9
|
+
it "nominal" do
|
10
|
+
parser = FileParser.new(tree, {})
|
11
|
+
parser.process_file(fixtures('time'))
|
12
|
+
tree.process
|
13
|
+
tree.output[0].should =~ /All +6 min/
|
14
|
+
tree.output[1].should =~ /jun1stuff +1 min/
|
15
|
+
tree.output[2].should =~ /jun2stuff +2 min/
|
16
|
+
tree.output[3].should =~ /jun3stuff +3 min/
|
17
|
+
tree.output.size.should == 4
|
18
|
+
end
|
19
|
+
|
20
|
+
it "specific date" do
|
21
|
+
parser = FileParser.new(tree, {:date => '1975/06/02'})
|
22
|
+
parser.process_file(fixtures('time'))
|
23
|
+
tree.process
|
24
|
+
tree.output[0].should =~ /All +2 min/
|
25
|
+
tree.output[1].should =~ /jun2stuff +2 min/
|
26
|
+
tree.output.size.should == 2
|
27
|
+
end
|
28
|
+
|
29
|
+
it "date range" do
|
30
|
+
parser = FileParser.new(tree, {:range => '1975/06/02:1975/06/03'})
|
31
|
+
parser.process_file(fixtures('time'))
|
32
|
+
tree.process
|
33
|
+
tree.output[0].should =~ /All +5 min/
|
34
|
+
tree.output[1].should =~ /jun2stuff +2 min/
|
35
|
+
tree.output[2].should =~ /jun3stuff +3 min/
|
36
|
+
tree.output.size.should == 3
|
37
|
+
end
|
38
|
+
|
39
|
+
it "today" do
|
40
|
+
parser = FileParser.new(tree, {:today => true})
|
41
|
+
parser.process_file(fixtures('time'))
|
42
|
+
tree.process
|
43
|
+
tree.output.size.should == 0
|
44
|
+
end
|
45
|
+
|
46
|
+
it "yesterday" do
|
47
|
+
parser = FileParser.new(tree, {:yesterday => true})
|
48
|
+
parser.process_file(fixtures('time'))
|
49
|
+
tree.process
|
50
|
+
tree.output.size.should == 0
|
51
|
+
end
|
52
|
+
|
53
|
+
it "this week" do
|
54
|
+
parser = FileParser.new(tree, {:week => 0})
|
55
|
+
parser.process_file(fixtures('time'))
|
56
|
+
tree.process
|
57
|
+
tree.output.size.should == 0
|
58
|
+
end
|
59
|
+
|
60
|
+
it "last week" do
|
61
|
+
parser = FileParser.new(tree, {:week => 1})
|
62
|
+
parser.process_file(fixtures('time'))
|
63
|
+
tree.process
|
64
|
+
tree.output.size.should == 0
|
65
|
+
end
|
66
|
+
|
67
|
+
it "this month" do
|
68
|
+
parser = FileParser.new(tree, {:month => 0})
|
69
|
+
parser.process_file(fixtures('time'))
|
70
|
+
tree.process
|
71
|
+
tree.output.size.should == 0
|
72
|
+
end
|
73
|
+
|
74
|
+
it "last month" do
|
75
|
+
parser = FileParser.new(tree, {:month => 1})
|
76
|
+
parser.process_file(fixtures('time'))
|
77
|
+
tree.process
|
78
|
+
tree.output.size.should == 0
|
79
|
+
end
|
80
|
+
|
81
|
+
it "filter" do
|
82
|
+
parser = FileParser.new(tree, {:filter => ['jun2stuff']})
|
83
|
+
parser.process_file(fixtures('time'))
|
84
|
+
tree.process
|
85
|
+
tree.output[0].should =~ /All +2 min/
|
86
|
+
tree.output[1].should =~ /jun2stuff +2 min/
|
87
|
+
tree.output.size.should == 2
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
data/time_tree.gemspec
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'time_tree/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = "time_tree"
|
8
|
+
gem.version = TimeTree::VERSION
|
9
|
+
gem.authors = ["Andy White"]
|
10
|
+
gem.email = ["andy@wireworldmedia.co.uk"]
|
11
|
+
gem.description = %q{Summarises a time file}
|
12
|
+
gem.summary = %q{Summarises a time file}
|
13
|
+
gem.homepage = ""
|
14
|
+
|
15
|
+
gem.files = `git ls-files`.split($/)
|
16
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
17
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
18
|
+
gem.require_paths = ["lib"]
|
19
|
+
gem.add_development_dependency "rspec", "~> 2.11.0"
|
20
|
+
end
|
metadata
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: time_tree
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 2.1.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Andy White
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-05-09 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: rspec
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 2.11.0
|
22
|
+
type: :development
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ~>
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 2.11.0
|
30
|
+
description: Summarises a time file
|
31
|
+
email:
|
32
|
+
- andy@wireworldmedia.co.uk
|
33
|
+
executables:
|
34
|
+
- timetree
|
35
|
+
extensions: []
|
36
|
+
extra_rdoc_files: []
|
37
|
+
files:
|
38
|
+
- .gitignore
|
39
|
+
- .rvmrc
|
40
|
+
- Gemfile
|
41
|
+
- LICENSE.txt
|
42
|
+
- README.md
|
43
|
+
- Rakefile
|
44
|
+
- bin/timetree
|
45
|
+
- fixtures/.a_dot_file
|
46
|
+
- fixtures/real.txt
|
47
|
+
- fixtures/time.txt
|
48
|
+
- fixtures/time/jun1.txt
|
49
|
+
- fixtures/time/jun2.txt
|
50
|
+
- fixtures/time/jun3.txt
|
51
|
+
- lib/time_tree.rb
|
52
|
+
- lib/time_tree/activity_tree.rb
|
53
|
+
- lib/time_tree/date_calculator.rb
|
54
|
+
- lib/time_tree/file_parser.rb
|
55
|
+
- lib/time_tree/version.rb
|
56
|
+
- spec/activity_tree_spec.rb
|
57
|
+
- spec/date_calculator_spec.rb
|
58
|
+
- spec/file_parser_spec.rb
|
59
|
+
- spec/helper.rb
|
60
|
+
- spec/integration_spec.rb
|
61
|
+
- time_tree.gemspec
|
62
|
+
homepage: ''
|
63
|
+
licenses: []
|
64
|
+
post_install_message:
|
65
|
+
rdoc_options: []
|
66
|
+
require_paths:
|
67
|
+
- lib
|
68
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
69
|
+
none: false
|
70
|
+
requirements:
|
71
|
+
- - ! '>='
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: '0'
|
74
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
75
|
+
none: false
|
76
|
+
requirements:
|
77
|
+
- - ! '>='
|
78
|
+
- !ruby/object:Gem::Version
|
79
|
+
version: '0'
|
80
|
+
requirements: []
|
81
|
+
rubyforge_project:
|
82
|
+
rubygems_version: 1.8.25
|
83
|
+
signing_key:
|
84
|
+
specification_version: 3
|
85
|
+
summary: Summarises a time file
|
86
|
+
test_files:
|
87
|
+
- spec/activity_tree_spec.rb
|
88
|
+
- spec/date_calculator_spec.rb
|
89
|
+
- spec/file_parser_spec.rb
|
90
|
+
- spec/helper.rb
|
91
|
+
- spec/integration_spec.rb
|