equalshares 0.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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +121 -0
  4. data/exe/equalshares +7 -0
  5. data/lib/equalshares/cli/formatter.rb +79 -0
  6. data/lib/equalshares/cli.rb +100 -0
  7. data/lib/equalshares/comparison.rb +48 -0
  8. data/lib/equalshares/completion.rb +106 -0
  9. data/lib/equalshares/compute.rb +19 -0
  10. data/lib/equalshares/election.rb +77 -0
  11. data/lib/equalshares/errors.rb +11 -0
  12. data/lib/equalshares/fixed_budget.rb +242 -0
  13. data/lib/equalshares/greedy.rb +12 -0
  14. data/lib/equalshares/instance.rb +60 -0
  15. data/lib/equalshares/max_flow.rb +89 -0
  16. data/lib/equalshares/maximin.rb +12 -0
  17. data/lib/equalshares/mes_general.rb +13 -0
  18. data/lib/equalshares/pabulib/csv.rb +56 -0
  19. data/lib/equalshares/pabulib/parser.rb +85 -0
  20. data/lib/equalshares/pabulib/project_row_parser.rb +60 -0
  21. data/lib/equalshares/pabulib/vote_row_parser.rb +96 -0
  22. data/lib/equalshares/pabulib/writer.rb +44 -0
  23. data/lib/equalshares/pabulib.rb +43 -0
  24. data/lib/equalshares/params.rb +61 -0
  25. data/lib/equalshares/phragmen.rb +12 -0
  26. data/lib/equalshares/result.rb +47 -0
  27. data/lib/equalshares/rules/base.rb +44 -0
  28. data/lib/equalshares/rules/cardinal_mes.rb +98 -0
  29. data/lib/equalshares/rules/greedy.rb +49 -0
  30. data/lib/equalshares/rules/maximin.rb +119 -0
  31. data/lib/equalshares/rules/method_of_equal_shares.rb +80 -0
  32. data/lib/equalshares/rules/phragmen.rb +68 -0
  33. data/lib/equalshares/satisfaction.rb +39 -0
  34. data/lib/equalshares/statistics.rb +34 -0
  35. data/lib/equalshares/tie_breaker.rb +95 -0
  36. data/lib/equalshares/tie_breaking.rb +48 -0
  37. data/lib/equalshares/version.rb +5 -0
  38. data/lib/equalshares.rb +40 -0
  39. metadata +86 -0
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ # The fixed-budget Method of Equal Shares loop.
5
+ #
6
+ # This unifies the two near-identical JS implementations
7
+ # (equalSharesFixedBudgetFractions and equalSharesFixedBudgetFloats in
8
+ # js/methodOfEqualSharesWorker.js) into a single loop driven by the numeric type
9
+ # of the values passed in:
10
+ # - fractions mode: `cost` values and `b_total` are Rational -> exact arithmetic
11
+ # - floats mode: `cost` values and `b_total` are Float -> IEEE-754 arithmetic
12
+ #
13
+ # The arithmetic (order and types) is kept bit-identical to the JS tool; the class
14
+ # below only reorganises the control flow into evaluate/charge steps.
15
+ module FixedBudget
16
+ module_function
17
+
18
+ # Dispatch on params.accuracy, preparing cost/budget in the right numeric type.
19
+ # `cost_source` maps project_id => original cost string; `budget_source` is the
20
+ # budget string. This mirrors JS where costs come from parseFloat, then (in the
21
+ # fractions path) get wrapped by new Fraction(...).
22
+ def run(voter_ids, project_ids, cost_source, approvers, budget_source, params,
23
+ report_details: false, progress: nil)
24
+ case params.accuracy
25
+ when "fractions"
26
+ cost = cost_source.transform_values { |c| Election.rational_of(c) }
27
+ b_total = Election.rational_of(budget_source)
28
+ when "floats"
29
+ cost = cost_source.transform_values { |c| Float(c) }
30
+ b_total = Float(budget_source)
31
+ else
32
+ raise ComputeError, "Unknown accuracy parameter"
33
+ end
34
+ Loop.new(voter_ids, project_ids, cost, approvers, b_total, params,
35
+ report_details: report_details, progress: progress).run
36
+ end
37
+
38
+ # Collects the per-candidate explanation data produced while running the rule
39
+ # (money behind each candidate and its effective vote count over the rounds, plus
40
+ # the per-voter endowment). Kept separate from the selection logic; #to_h yields the
41
+ # same hash shape the callers consume.
42
+ class Report
43
+ def initialize(project_ids, endowment)
44
+ @endowment = endowment
45
+ @money_behind_candidate = {}
46
+ @effective_vote_count = {}
47
+ project_ids.each do |c|
48
+ @money_behind_candidate[c] = []
49
+ @effective_vote_count[c] = []
50
+ end
51
+ end
52
+
53
+ def record_money_behind(project_id, value)
54
+ @money_behind_candidate[project_id] << value.to_f
55
+ end
56
+
57
+ def record_effective_vote_count(project_id, value)
58
+ @effective_vote_count[project_id] << value.to_f
59
+ end
60
+
61
+ def record_unaffordable(project_id)
62
+ @effective_vote_count[project_id] << 0
63
+ end
64
+
65
+ def to_h
66
+ { money_behind_candidate: @money_behind_candidate,
67
+ effective_vote_count: @effective_vote_count,
68
+ endowment: @endowment }
69
+ end
70
+ end
71
+
72
+ # One fixed-budget MES run. Generalises the shared logic of
73
+ # equalSharesFixedBudgetFractions/Floats over an additive satisfaction measure (see
74
+ # Equalshares::Satisfaction): the payment mechanics are unchanged (each project
75
+ # raises its full cost), while the selection criterion is effective vote count =
76
+ # u(c) / maxPayment, with u(c) the per-approver utility. For Cost_Sat (u = cost)
77
+ # this is exactly the original rule.
78
+ #
79
+ # Ruby's Hash preserves insertion order and mirrors the JS Map semantics used for
80
+ # `remaining` (re-assigning an existing key keeps its position; delete removes it).
81
+ class Loop
82
+ CandidateEvaluation = Struct.new(:project_id, :money_behind, :effective_vote_count,
83
+ :max_payment, :affordable?, keyword_init: true)
84
+
85
+ def initialize(voter_ids, project_ids, cost, approvers, b_total, params,
86
+ report_details: false, progress: nil)
87
+ @project_ids = project_ids
88
+ @cost = cost
89
+ @approvers = approvers
90
+ @b_total = b_total
91
+ @params = params
92
+ @report_details = report_details
93
+ @progress = progress
94
+ @sat = Satisfaction.for(params.satisfaction)
95
+ @n = voter_ids.length
96
+
97
+ initialize_budget(voter_ids)
98
+ initialize_report
99
+ initialize_remaining
100
+ end
101
+
102
+ def run
103
+ winners = []
104
+ loop do
105
+ best, max_payment_of = select_best
106
+ if best.empty?
107
+ # no remaining candidates are affordable
108
+ unless @remaining.empty?
109
+ raise ComputeError,
110
+ "No available candidate found even though there are still affordable candidates: " \
111
+ "#{@remaining.keys}"
112
+ end
113
+
114
+ break
115
+ end
116
+
117
+ best = Tie.resolve_one(@project_ids, @cost, @approvers, @params, best)
118
+ winners << best
119
+ @progress&.call((100 * winners.sum { |c| @cost[c] } / @b_total).floor)
120
+ charge_winner(best, max_payment_of[best])
121
+ @remaining.delete(best)
122
+ end
123
+
124
+ { winners: winners, report: @report.to_h }
125
+ end
126
+
127
+ private
128
+
129
+ def initialize_budget(voter_ids)
130
+ @budget = {}
131
+ voter_ids.each { |i| @budget[i] = @b_total / @n }
132
+ end
133
+
134
+ def initialize_report
135
+ @report = Report.new(@project_ids, @b_total.to_f / @n)
136
+ end
137
+
138
+ def initialize_remaining
139
+ @remaining = {} # candidate -> previous effective vote count
140
+ @project_ids.each do |c|
141
+ next unless @cost[c].positive? && !@approvers[c].empty?
142
+
143
+ # effective vote count when budgets are ample: u(c) * |approvers| / cost(c)
144
+ @remaining[c] =
145
+ (@sat.call(@cost[c], @approvers[c].length) * @approvers[c].length) / @cost[c]
146
+ end
147
+ end
148
+
149
+ # Pick the affordable candidate(s) with the highest effective vote count this
150
+ # round, returning [tied_best, max_payment_by_candidate].
151
+ def select_best
152
+ best = []
153
+ best_eff_vote_count = 0
154
+ max_payment_of = {} # candidate -> per-approver payment cap computed this round
155
+
156
+ # Walk remaining candidates in order of decreasing previous effective vote count.
157
+ # Stable descending sort (ties keep insertion order), matching JS Array.sort.
158
+ remaining_sorted = @remaining.keys.each_with_index
159
+ .sort_by { |c, idx| [-@remaining[c], idx] }
160
+ .map(&:first)
161
+
162
+ remaining_sorted.each do |c|
163
+ # c cannot beat the best so far (optimization only when not reporting details).
164
+ break if @remaining[c] < best_eff_vote_count && !@report_details
165
+
166
+ evaluation = evaluate_candidate(c)
167
+ record_evaluation(evaluation)
168
+ next unless evaluation.affordable?
169
+
170
+ max_payment_of[c] = evaluation.max_payment
171
+ if evaluation.effective_vote_count > best_eff_vote_count
172
+ best_eff_vote_count = evaluation.effective_vote_count
173
+ best = [c]
174
+ elsif evaluation.effective_vote_count == best_eff_vote_count
175
+ best << c
176
+ end
177
+ end
178
+
179
+ [best, max_payment_of]
180
+ end
181
+
182
+ # Effective vote count and per-approver payment cap for candidate c this round, or
183
+ # an unaffordable result if c cannot raise its cost with current supporter budgets.
184
+ def evaluate_candidate(project_id)
185
+ money_behind_now = @approvers[project_id].sum(numeric_zero) { |i| @budget[i] }
186
+ if money_behind_now < @cost[project_id]
187
+ return CandidateEvaluation.new(project_id: project_id, money_behind: money_behind_now,
188
+ effective_vote_count: 0,
189
+ max_payment: nil, affordable?: false)
190
+ end
191
+
192
+ # Split the cost as equally as possible among approvers. Sort a copy by budget so
193
+ # the instance's approver order is not mutated during computation.
194
+ supporters_by_budget = @approvers[project_id].sort_by { |i| @budget[i] }
195
+ paid_so_far = 0
196
+ denominator = supporters_by_budget.length # approvers who can afford the max payment
197
+ supporters_by_budget.each do |i|
198
+ max_payment = (@cost[project_id] - paid_so_far) / denominator # if remaining approvers pay equally
199
+ if max_payment > @budget[i]
200
+ # i cannot afford the max payment, so pays entire remaining budget
201
+ paid_so_far += @budget[i]
202
+ denominator -= 1
203
+ else
204
+ eff_vote_count = @sat.call(@cost[project_id], @approvers[project_id].length) / max_payment
205
+ return CandidateEvaluation.new(project_id: project_id, money_behind: money_behind_now,
206
+ effective_vote_count: eff_vote_count,
207
+ max_payment: max_payment, affordable?: true)
208
+ end
209
+ end
210
+ raise ComputeError, "Candidate #{project_id} was affordable but no payment cap could be computed."
211
+ end
212
+
213
+ # Apply an evaluation: update the core `remaining` state and record the numbers in
214
+ # the report (the latter delegated to the Report collaborator).
215
+ def record_evaluation(evaluation)
216
+ project_id = evaluation.project_id
217
+ @report.record_money_behind(project_id, evaluation.money_behind)
218
+
219
+ if evaluation.affordable?
220
+ @remaining[project_id] = evaluation.effective_vote_count
221
+ @report.record_effective_vote_count(project_id, evaluation.effective_vote_count)
222
+ else
223
+ @remaining.delete(project_id)
224
+ @report.record_unaffordable(project_id)
225
+ end
226
+ end
227
+
228
+ # Charge each approver of the winning candidate its per-approver payment cap
229
+ # (equals cost/eff only for Cost_Sat), capped at their remaining budget.
230
+ def charge_winner(best, best_max_payment)
231
+ @approvers[best].each do |i|
232
+ @budget[i] = @budget[i] > best_max_payment ? @budget[i] - best_max_payment : numeric_zero
233
+ end
234
+ end
235
+
236
+ # Additive identity in the same numeric family as the budget (0 or 0.0).
237
+ def numeric_zero
238
+ @b_total.is_a?(Float) ? 0.0 : 0
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ # Facade for greedy utilitarian welfare; delegates to Rules::Greedy.
5
+ module Greedy
6
+ module_function
7
+
8
+ def utilitarian_welfare(instance, params = Params.new)
9
+ Rules::Greedy.call(instance, params)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ # A parsed pabulib instance.
5
+ #
6
+ # Mirrors the JS `{ meta, projects, votes, approvers }` shape:
7
+ # meta - Hash{String => String} key/value metadata (includes "budget", "vote_type")
8
+ # projects - Hash{String => Hash{String => String}} project_id => raw row fields (incl "cost", "name")
9
+ # votes - Hash{String => Hash{String => String}} voter_id => raw row fields
10
+ # approvers - Hash{String => Array<String>} project_id => list of voter_ids approving it
11
+ class Instance
12
+ attr_reader :meta, :projects, :votes, :approvers, :scores
13
+
14
+ # `scores` is nil for approval instances; for cardinal ballots (vote_type
15
+ # "scoring"/"cumulative") it is Hash{project_id => Hash{voter_id => score string}}.
16
+ def initialize(meta:, projects:, votes:, approvers:, scores: nil)
17
+ @meta = meta
18
+ @projects = projects
19
+ @votes = votes
20
+ @approvers = approvers
21
+ @scores = scores
22
+ end
23
+
24
+ # True for cardinal (scoring/cumulative) instances that carry per-voter scores.
25
+ def cardinal?
26
+ !@scores.nil?
27
+ end
28
+
29
+ def vote_type
30
+ @meta["vote_type"]
31
+ end
32
+
33
+ # Voter IDs (JS: Object.keys(votes))
34
+ def voter_ids
35
+ @voter_ids ||= self.class.js_key_order(@votes.keys)
36
+ end
37
+
38
+ # Project IDs (JS: Object.keys(projects))
39
+ def project_ids
40
+ @project_ids ||= self.class.js_key_order(@projects.keys)
41
+ end
42
+
43
+ def budget
44
+ @meta["budget"]
45
+ end
46
+
47
+ # Replicate the iteration order of JavaScript's Object.keys, which the original
48
+ # tool relies on (C = Object.keys(projects), N = Object.keys(votes)): integer
49
+ # "array index" keys come first in ascending numeric order, followed by all
50
+ # other keys in insertion order. Project/voter IDs are typically numeric strings,
51
+ # so this ordering affects tie stability (e.g. greedy utilitarian completion).
52
+ ARRAY_INDEX = /\A(?:0|[1-9]\d*)\z/
53
+ UINT32_MAX = (2**32) - 1
54
+
55
+ def self.js_key_order(keys)
56
+ indices, others = keys.partition { |k| ARRAY_INDEX.match?(k) && k.to_i < UINT32_MAX }
57
+ indices.sort_by(&:to_i) + others
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ # Dinic's maximum-flow algorithm with integer capacities. Used by the maximin
5
+ # support rule to compute the exact minimum max-load (a max-density subgraph) via
6
+ # a parametric max-flow, keeping the gem pure-Ruby and dependency-free.
7
+ class MaxFlow
8
+ def initialize(num_nodes)
9
+ @num_nodes = num_nodes
10
+ @graph = Array.new(num_nodes) { [] } # node -> array of edges [to, cap, rev_index]
11
+ end
12
+
13
+ def add_edge(from, to, cap)
14
+ @graph[from] << [to, cap, @graph[to].size]
15
+ @graph[to] << [from, 0, @graph[from].size - 1]
16
+ end
17
+
18
+ def max_flow(source, sink)
19
+ flow = 0
20
+ while build_levels(source, sink)
21
+ @iter = Array.new(@num_nodes, 0)
22
+ while (pushed = augment(source, sink, Float::INFINITY)).positive?
23
+ flow += pushed
24
+ end
25
+ end
26
+ flow
27
+ end
28
+
29
+ # Nodes reachable from `source` in the residual graph (the source side of a
30
+ # minimum cut after max_flow has run).
31
+ def reachable_from(source)
32
+ visited = Array.new(@num_nodes, false)
33
+ visited[source] = true
34
+ queue = [source]
35
+ until queue.empty?
36
+ node = queue.shift
37
+ @graph[node].each do |edge|
38
+ to, cap, = edge
39
+ next unless cap.positive? && !visited[to]
40
+
41
+ visited[to] = true
42
+ queue << to
43
+ end
44
+ end
45
+ visited
46
+ end
47
+
48
+ private
49
+
50
+ # Builds the BFS level graph; returns whether the sink is reachable.
51
+ def build_levels(source, sink) # rubocop:disable Naming/PredicateMethod
52
+ @level = Array.new(@num_nodes, -1)
53
+ @level[source] = 0
54
+ queue = [source]
55
+ until queue.empty?
56
+ node = queue.shift
57
+ @graph[node].each do |edge|
58
+ to, cap, = edge
59
+ next unless cap.positive? && @level[to].negative?
60
+
61
+ @level[to] = @level[node] + 1
62
+ queue << to
63
+ end
64
+ end
65
+ !@level[sink].negative?
66
+ end
67
+
68
+ # The level graph is at most a few layers deep (source-project-voter-sink), so
69
+ # this recursion stays shallow.
70
+ def augment(node, sink, limit)
71
+ return limit if node == sink
72
+
73
+ while @iter[node] < @graph[node].size
74
+ edge = @graph[node][@iter[node]]
75
+ to, cap = edge
76
+ if cap.positive? && @level[node] < @level[to]
77
+ delta = augment(to, sink, [limit, cap].min)
78
+ if delta.positive?
79
+ edge[1] -= delta
80
+ @graph[to][edge[2]][1] += delta
81
+ return delta
82
+ end
83
+ end
84
+ @iter[node] += 1
85
+ end
86
+ 0
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ # Facade for the maximin support rule; delegates to Rules::Maximin.
5
+ module Maximin
6
+ module_function
7
+
8
+ def support(instance, params = Params.new, progress: nil)
9
+ Rules::Maximin.call(instance, params, progress: progress)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ # Facade for the Method of Equal Shares on cardinal/ordinal ballots; delegates to
5
+ # Rules::CardinalMes.
6
+ module MesGeneral
7
+ module_function
8
+
9
+ def equal_shares(instance, params = Params.new, progress: nil)
10
+ Rules::CardinalMes.call(instance, params, progress: progress)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ module Pabulib
5
+ # Low-level lexing shared by the parser: the ';'-delimited, double-quote-escaped
6
+ # field splitter (port of parseCSVLine in js/pabulibParser.js) and the JS-style
7
+ # numeric check.
8
+ module Csv
9
+ module_function
10
+
11
+ def parse_line(line)
12
+ result = []
13
+ current = +""
14
+ in_quotes = false
15
+ i = 0
16
+
17
+ while i < line.length
18
+ char = line[i]
19
+ if char == '"'
20
+ if in_quotes && i + 1 < line.length && line[i + 1] == '"'
21
+ current << '"'
22
+ i += 2
23
+ else
24
+ in_quotes = !in_quotes
25
+ i += 1
26
+ end
27
+ elsif char == ";" && !in_quotes
28
+ result << current
29
+ current = +""
30
+ i += 1
31
+ else
32
+ current << char
33
+ i += 1
34
+ end
35
+ end
36
+
37
+ result << current
38
+ result
39
+ end
40
+
41
+ # Whether a string parses as a number the way JavaScript's Number() does: empty
42
+ # or whitespace-only strings count as numeric (they coerce to 0), nil does not.
43
+ def numeric_string?(value)
44
+ return false if value.nil?
45
+
46
+ str = value.strip
47
+ return true if str.empty?
48
+
49
+ Float(str)
50
+ true
51
+ rescue ArgumentError, TypeError
52
+ false
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ module Pabulib
5
+ # Mutable bag of results filled in as the sections are read.
6
+ Accumulator = Struct.new(:meta, :projects, :votes, :approvers, :scores,
7
+ :project_ids_set, :voter_ids_set, keyword_init: true)
8
+
9
+ # Reads a pabulib (.pb) document: drives the META / PROJECTS / VOTES section state
10
+ # machine and delegates each data row to the appropriate row parser.
11
+ class Parser
12
+ def parse(text)
13
+ @acc = Accumulator.new(meta: {}, projects: {}, votes: {}, approvers: {}, scores: {},
14
+ project_ids_set: {}, voter_ids_set: {})
15
+ @encountered = {}
16
+ @section = ""
17
+ @header = []
18
+ @line_number = 0
19
+
20
+ text.split("\n").each do |line|
21
+ @line_number += 1
22
+ handle_line(line) unless line.strip.empty?
23
+ end
24
+
25
+ finish
26
+ end
27
+
28
+ private
29
+
30
+ def handle_line(line)
31
+ row = Csv.parse_line(line)
32
+ if section_header?(row)
33
+ start_section(row)
34
+ elsif @header.empty?
35
+ read_header(row)
36
+ else
37
+ dispatch_row(row)
38
+ end
39
+ end
40
+
41
+ def section_header?(row)
42
+ SECTIONS.include?(row[0].strip.downcase)
43
+ end
44
+
45
+ def start_section(row)
46
+ @section = row[0].strip.downcase
47
+ @encountered[@section] = true
48
+ @header = []
49
+ end
50
+
51
+ def read_header(row)
52
+ @header = row.map(&:strip)
53
+ return unless @section == "meta" && (@header[0] != "key" || @header[1] != "value")
54
+
55
+ raise ParseError, "Line #{@line_number}: Invalid header in meta section (expecting \"key;value\")."
56
+ end
57
+
58
+ def dispatch_row(row)
59
+ case @section
60
+ when "meta"
61
+ @acc.meta[row[0]] = row[1].strip
62
+ when "projects"
63
+ (@project_row_parser ||= ProjectRowParser.new(@acc)).parse(row, @header, @line_number)
64
+ when "votes"
65
+ (@vote_row_parser ||= VoteRowParser.new(@acc)).parse(row, @header, @line_number)
66
+ end
67
+ end
68
+
69
+ def finish
70
+ SECTIONS.each do |section_name|
71
+ next if @encountered[section_name]
72
+
73
+ raise ParseError, "The file is missing the required '#{section_name}' section."
74
+ end
75
+ unless Csv.numeric_string?(@acc.meta["budget"])
76
+ raise ParseError, "The 'budget' in the meta section is not a numeric value."
77
+ end
78
+
79
+ scored = SCORED_VOTE_TYPES.include?(@acc.meta["vote_type"])
80
+ Instance.new(meta: @acc.meta, projects: @acc.projects, votes: @acc.votes,
81
+ approvers: @acc.approvers, scores: scored ? @acc.scores : nil)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ module Pabulib
5
+ # Parses a single row of the PROJECTS section into the accumulator.
6
+ class ProjectRowParser
7
+ def initialize(accumulator)
8
+ @acc = accumulator
9
+ end
10
+
11
+ def parse(row, header, line_number)
12
+ project_id_idx = header.index("project_id")
13
+ cost_idx = header.index("cost")
14
+ name_idx = header.index("name")
15
+
16
+ require_columns!(project_id_idx, cost_idx, line_number)
17
+ # Validate the column count before any indexed access, so a short row raises a
18
+ # ParseError rather than a NoMethodError on nil.
19
+ validate_column_count!(row, header, line_number)
20
+
21
+ project_id = row[project_id_idx].strip
22
+ if @acc.project_ids_set[project_id]
23
+ raise ParseError, "Line #{line_number}: Duplicate project ID '#{project_id}' found."
24
+ end
25
+ if row[project_id_idx].to_s.empty? || !Csv.numeric_string?(row[cost_idx])
26
+ raise ParseError, "Line #{line_number}: Invalid or missing values in projects section."
27
+ end
28
+
29
+ store(row, header, project_id, name_idx)
30
+ end
31
+
32
+ private
33
+
34
+ def require_columns!(project_id_idx, cost_idx, line_number)
35
+ return unless project_id_idx.nil? || cost_idx.nil?
36
+
37
+ missing = []
38
+ missing << "project_id " if project_id_idx.nil?
39
+ missing << "cost" if cost_idx.nil?
40
+ raise ParseError, "Line #{line_number}: Missing required column(s) in projects section: #{missing.join}."
41
+ end
42
+
43
+ def validate_column_count!(row, header, line_number)
44
+ return if row.length == header.length
45
+
46
+ raise ParseError, "Line #{line_number}: Invalid number of columns in projects section."
47
+ end
48
+
49
+ def store(row, header, project_id, name_idx)
50
+ @acc.project_ids_set[project_id] = true
51
+ @acc.projects[project_id] = {}
52
+ @acc.approvers[project_id] = []
53
+ header.each_index { |idx| @acc.projects[project_id][header[idx].strip] = row[idx].strip }
54
+
55
+ name = @acc.projects[project_id]["name"]
56
+ @acc.projects[project_id]["name"] = project_id if name_idx.nil? || name.nil? || name.strip.empty?
57
+ end
58
+ end
59
+ end
60
+ end