kettle-drift 1.0.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.
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "set"
5
+
6
+ module Kettle
7
+ module Drift
8
+ class CLI
9
+ def run(argv = ARGV)
10
+ options = parse(argv)
11
+ return options if options.is_a?(Integer)
12
+
13
+ project_root = File.expand_path(options.fetch(:project_root))
14
+ outcome = Kettle::Drift.run(
15
+ project_root: project_root,
16
+ template_dir: options[:template_dir],
17
+ min_chars: options.fetch(:min_chars),
18
+ json_path: options[:json_path],
19
+ lock_path: options[:lock_path],
20
+ mode: options.fetch(:mode),
21
+ )
22
+
23
+ if outcome.clean?
24
+ puts "[kettle-drift] ✅ No duplicate drift detected (min_chars=#{options[:min_chars]}, files=#{outcome.files.size}, baseline=#{outcome.baseline_set.size})"
25
+ else
26
+ puts "[kettle-drift] ⚠️ #{outcome.warning_count} drift warning(s) across #{outcome.results.size} unique chunk(s) (files=#{outcome.files.size}, baseline=#{outcome.baseline_set.size})"
27
+ puts "[kettle-drift] 📄 Report: #{Kettle::Drift.display_path(outcome.json_path)}" if outcome.json_path
28
+ end
29
+
30
+ outcome.exit_code
31
+ end
32
+
33
+ private
34
+
35
+ def parse(argv)
36
+ options = {
37
+ min_chars: Kettle::Drift::DuplicateLineValidator::DEFAULT_MIN_CHARS,
38
+ lock_path: Kettle::Drift::DEFAULT_LOCKFILE,
39
+ mode: :update,
40
+ project_root: Dir.pwd,
41
+ }
42
+
43
+ parser = OptionParser.new do |opts|
44
+ opts.banner = "Usage: kettle-drift [PROJECT_ROOT] [options]"
45
+ opts.on("--min-chars=N", Integer, "Minimum non-whitespace characters per line") { |value| options[:min_chars] = value }
46
+ opts.on("--json=PATH", "Write JSON report to PATH") { |value| options[:json_path] = value }
47
+ opts.on("--lockfile=PATH", "Use PATH for the lockfile") { |value| options[:lock_path] = value }
48
+ opts.on("--template-dir=PATH", "Use template-managed files and template baseline from PATH") { |value| options[:template_dir] = value }
49
+ opts.on("--update", "Update the lockfile when drift is new, reduced, or otherwise changed (default)") { options[:mode] = :update }
50
+ opts.on("--check", "Fail if current drift differs from the lockfile") { options[:mode] = :check }
51
+ opts.on("--force-update", "Update the lockfile even when drift gets worse") { options[:mode] = :force_update }
52
+ end
53
+
54
+ remaining = parser.parse(argv.dup)
55
+ options[:project_root] = remaining.first if remaining.first
56
+ options[:template_dir] = expand_optional_path(options[:template_dir], File.expand_path(options[:project_root]))
57
+ options[:lock_path] = File.expand_path(options.fetch(:lock_path), File.expand_path(options[:project_root]))
58
+ options[:json_path] = options[:json_path] && File.expand_path(options[:json_path], File.expand_path(options[:project_root]))
59
+ options
60
+ rescue OptionParser::ParseError => e
61
+ warn("[kettle-drift] #{e.message}")
62
+ 2
63
+ end
64
+
65
+ def expand_optional_path(path, project_root)
66
+ return if path.to_s.strip.empty?
67
+
68
+ File.expand_path(path, project_root)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Drift
5
+ class Diff
6
+ attr_reader :state, :new_entries, :fixed_entries, :unchanged_entries
7
+
8
+ def initialize(state:, new_entries: [], fixed_entries: [], unchanged_entries: [])
9
+ @state = state
10
+ @new_entries = new_entries
11
+ @fixed_entries = fixed_entries
12
+ @unchanged_entries = unchanged_entries
13
+ end
14
+
15
+ def statistics
16
+ {
17
+ left: unchanged_entries.size + new_entries.size,
18
+ fixed: fixed_entries.size,
19
+ new: new_entries.size,
20
+ unchanged: unchanged_entries.size,
21
+ }
22
+ end
23
+
24
+ def files
25
+ new_entries.group_by { |entry| entry[:file] }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "set"
6
+
7
+ module Kettle
8
+ module Drift
9
+ # Scans managed files for repeated adjacent-line chunks that usually signal
10
+ # duplication drift or template corruption.
11
+ module DuplicateLineValidator
12
+ module_function
13
+
14
+ DEFAULT_MIN_CHARS = 6
15
+ BINARY_EXTENSIONS = Set.new(%w[
16
+ .7z
17
+ .bz2
18
+ .class
19
+ .dll
20
+ .exe
21
+ .gem
22
+ .gif
23
+ .gz
24
+ .ico
25
+ .jar
26
+ .jpeg
27
+ .jpg
28
+ .pdf
29
+ .png
30
+ .so
31
+ .tar
32
+ .tgz
33
+ .ttf
34
+ .woff
35
+ .woff2
36
+ .xz
37
+ .zip
38
+ ]).freeze
39
+ APPRAISALS_DEP_LINE_RE = /\A(?:eval_gemfile|gem)\s+["']/
40
+ CHANGELOG_METRIC_RE = /\A-\s+(?:(?:(?:line|branch)\s+)?coverage:|\d+\.\d+%\s+documented)/i
41
+ EXCLUDED_FILENAMES = Set.new(["CODE_OF_CONDUCT.md", ".gitlab-ci.yml"]).freeze
42
+ KETTLE_JEM_CONFIG_RE = /\A(?:strategy|recipe|preference|add_missing|freeze_token|file_type|max_recursion_depth):\s/
43
+ RAKEFILE_ENV_ASSIGNMENT_RE = /\AENV\[["']/
44
+ RESCUE_LOAD_ERROR_RE = /\Arescue\s+LoadError/
45
+ NOCOV_MARKER_RE = /\A# :nocov:\z/
46
+ CHANGELOG_SUBHEADINGS = Set.new([
47
+ "### Added",
48
+ "### Changed",
49
+ "### Deprecated",
50
+ "### Removed",
51
+ "### Fixed",
52
+ "### Security",
53
+ ]).freeze
54
+
55
+ def scan(files:, min_chars: DEFAULT_MIN_CHARS)
56
+ duplicates = {}
57
+
58
+ files.each do |path|
59
+ next unless File.file?(path)
60
+ next if EXCLUDED_FILENAMES.include?(File.basename(path.to_s))
61
+
62
+ content = read_text_content(path)
63
+ next unless content
64
+
65
+ fence_lines = (File.extname(path.to_s) == ".md") ? compute_fence_lines(content) : Set.new
66
+ indexed = content.each_line.map.with_index(1) { |raw, n| [n, raw.strip] }
67
+
68
+ chunk_map = Hash.new { |h, k| h[k] = [] }
69
+ indexed.each_cons(2) do |(lineno1, line1), (lineno2, line2)|
70
+ next if line1.gsub(/\s/, "").length <= min_chars
71
+ next if line2.gsub(/\s/, "").length <= min_chars
72
+ next if CHANGELOG_SUBHEADINGS.include?(line1)
73
+ next if fence_lines.include?(lineno1) && fence_lines.include?(lineno2)
74
+ next if ignored_duplicate_chunk?(path, line1, line2)
75
+
76
+ chunk_map["#{line1}\n#{line2}"] << lineno1
77
+ end
78
+
79
+ chunk_map.each do |chunk_content, start_lines|
80
+ next if start_lines.size < 2
81
+
82
+ duplicates[chunk_content] ||= []
83
+ duplicates[chunk_content] << {
84
+ file: path,
85
+ lines: start_lines,
86
+ }
87
+ end
88
+ end
89
+
90
+ duplicates
91
+ end
92
+
93
+ def ignored_duplicate_chunk?(path, line1, line2)
94
+ basename = File.basename(path.to_s)
95
+
96
+ if basename == "Appraisals"
97
+ return true if APPRAISALS_DEP_LINE_RE.match?(line1) && APPRAISALS_DEP_LINE_RE.match?(line2)
98
+ return true if line1.start_with?("#") && APPRAISALS_DEP_LINE_RE.match?(line2)
99
+ end
100
+
101
+ return true if basename == "CHANGELOG.md" && CHANGELOG_METRIC_RE.match?(line1) && CHANGELOG_METRIC_RE.match?(line2)
102
+ return true if basename == "Rakefile" && RAKEFILE_ENV_ASSIGNMENT_RE.match?(line1) && RAKEFILE_ENV_ASSIGNMENT_RE.match?(line2)
103
+ return true if RESCUE_LOAD_ERROR_RE.match?(line1) && NOCOV_MARKER_RE.match?(line2)
104
+ return true if File.extname(path.to_s) == ".md" && line1.start_with?("|") && line2.start_with?("|")
105
+ return true if basename == ".kettle-jem.yml" && KETTLE_JEM_CONFIG_RE.match?(line1) && KETTLE_JEM_CONFIG_RE.match?(line2)
106
+
107
+ false
108
+ end
109
+
110
+ def compute_fence_lines(content)
111
+ in_fence = false
112
+ fence_marker = nil
113
+ fence_lines = Set.new
114
+
115
+ content.each_line.with_index(1) do |raw, lineno|
116
+ stripped = raw.strip
117
+ if in_fence
118
+ fence_lines.add(lineno)
119
+ if stripped.match?(/\A#{Regexp.escape(fence_marker)}\s*\z/)
120
+ in_fence = false
121
+ fence_marker = nil
122
+ end
123
+ elsif (match = stripped.match(/\A(`{3,}|~{3,})/))
124
+ fence_marker = match[1]
125
+ in_fence = true
126
+ fence_lines.add(lineno)
127
+ end
128
+ end
129
+
130
+ fence_lines
131
+ end
132
+
133
+ def scan_template_results(template_results:, min_chars: DEFAULT_MIN_CHARS)
134
+ written_files = template_results.select { |_path, rec| %i[create replace].include?(rec[:action]) }.keys
135
+ scan(files: written_files, min_chars: min_chars)
136
+ end
137
+
138
+ def baseline(template_dir: nil, min_chars: DEFAULT_MIN_CHARS)
139
+ return Set.new unless template_dir && File.directory?(template_dir)
140
+
141
+ template_files = Dir.glob(
142
+ File.join(template_dir, "**", "*"),
143
+ File::FNM_DOTMATCH,
144
+ ).select { |f| File.file?(f) }
145
+
146
+ Set.new(scan(files: template_files, min_chars: min_chars).keys)
147
+ end
148
+
149
+ def subtract_baseline(results, baseline_set:)
150
+ results.reject { |line_content, _| baseline_set.include?(line_content) }
151
+ end
152
+
153
+ def template_managed_files(project_root:, template_dir: nil)
154
+ template_dir ||= File.join(project_root, "template")
155
+ return [] unless File.directory?(template_dir)
156
+
157
+ managed = []
158
+ Dir.glob(File.join(template_dir, "**", "*"), File::FNM_DOTMATCH).each do |src|
159
+ next unless File.file?(src)
160
+
161
+ rel = src.sub(%r{^#{Regexp.escape(template_dir)}/?}, "")
162
+ rel = rel.sub(/\.example\z/, "")
163
+ next if rel.include?(".no-osc")
164
+
165
+ dest = File.join(project_root, rel)
166
+ managed << dest if File.file?(dest)
167
+ end
168
+
169
+ managed.uniq
170
+ end
171
+
172
+ def warning_count(results)
173
+ results.values.flatten.size
174
+ end
175
+
176
+ def to_json(results)
177
+ JSON.pretty_generate(results.transform_values do |entries|
178
+ entries.map { |entry| {file: Kettle::Drift.display_path(entry[:file]), lines: entry[:lines]} }
179
+ end)
180
+ end
181
+
182
+ def write_json(results, json_path)
183
+ FileUtils.mkdir_p(File.dirname(json_path))
184
+ File.write(json_path, to_json(results))
185
+ json_path
186
+ end
187
+
188
+ def report_summary(results, project_root: nil)
189
+ return "No duplicate lines detected.\n" if results.empty?
190
+
191
+ lines = ["### Duplicate Line Report\n", "| Chunk (line1 ↵ line2) | File | Start Lines |", "|---|---|---|"]
192
+
193
+ results.each do |content, entries|
194
+ display = content.gsub("\n", " ↵ ")
195
+ display = "#{display[0, 77]}..." if display.length > 80
196
+ display = display.gsub("|", "\\|")
197
+
198
+ entries.each do |entry|
199
+ file = Kettle::Drift.display_path(entry[:file])
200
+ if project_root
201
+ display_root = Kettle::Drift.display_path(project_root)
202
+ file = file.sub(%r{^#{Regexp.escape(display_root)}/?}, "")
203
+ end
204
+ lines << "| `#{display}` | #{file} | #{entry[:lines].join(", ")} |"
205
+ end
206
+ end
207
+
208
+ lines << ""
209
+ lines.join("\n")
210
+ end
211
+
212
+ def read_text_content(path)
213
+ raw = File.binread(path)
214
+ return if binary_content?(path, raw)
215
+
216
+ content = raw.dup.force_encoding(Encoding::UTF_8)
217
+ return content if content.valid_encoding?
218
+
219
+ content.scrub
220
+ rescue StandardError
221
+ nil
222
+ end
223
+
224
+ def binary_content?(path, raw)
225
+ return true if BINARY_EXTENSIONS.include?(File.extname(path.to_s).downcase)
226
+
227
+ sample = raw.byteslice(0, 4096) || "".b
228
+ return true if sample.include?("\x00")
229
+ return false if sample.empty?
230
+
231
+ control_bytes = sample.each_byte.count do |byte|
232
+ (0..8).cover?(byte) || byte == 11 || byte == 12 || (14..31).cover?(byte)
233
+ end
234
+ control_bytes.fdiv(sample.bytesize) > 0.1
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Kettle
6
+ module Drift
7
+ class LockFile
8
+ attr_reader :path
9
+
10
+ def initialize(path)
11
+ @path = path
12
+ end
13
+
14
+ def read_results
15
+ return unless File.exist?(path)
16
+
17
+ Kettle::Drift::Serializer.deserialize(File.read(path, encoding: Encoding::UTF_8))
18
+ end
19
+
20
+ def write_results(results, project_root: nil)
21
+ FileUtils.mkdir_p(File.dirname(path))
22
+ File.write(path, Kettle::Drift::Serializer.serialize(results, project_root: project_root), encoding: Encoding::UTF_8)
23
+ end
24
+
25
+ def delete
26
+ return unless File.exist?(path)
27
+
28
+ File.delete(path)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Drift
5
+ class Outcome
6
+ attr_reader :project_root,
7
+ :files,
8
+ :template_dir,
9
+ :baseline_set,
10
+ :results,
11
+ :warning_count,
12
+ :json_path,
13
+ :lock_path,
14
+ :mode,
15
+ :diff,
16
+ :exit_code
17
+
18
+ def initialize(
19
+ project_root:,
20
+ files:,
21
+ template_dir:,
22
+ baseline_set:,
23
+ results:,
24
+ warning_count:,
25
+ json_path:,
26
+ lock_path:,
27
+ mode:,
28
+ diff:,
29
+ exit_code:
30
+ )
31
+ @project_root = project_root
32
+ @files = files
33
+ @template_dir = template_dir
34
+ @baseline_set = baseline_set
35
+ @results = results
36
+ @warning_count = warning_count
37
+ @json_path = json_path
38
+ @lock_path = lock_path
39
+ @mode = mode
40
+ @diff = diff
41
+ @exit_code = exit_code
42
+ end
43
+
44
+ def clean?
45
+ results.empty?
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Drift
5
+ module Plugin
6
+ module_function
7
+
8
+ SNIPPET_MARKER = "### DUPLICATE DRIFT TASKS"
9
+
10
+ RAKEFILE_SNIPPET = <<~RUBY
11
+ ### DUPLICATE DRIFT TASKS
12
+ begin
13
+ require "kettle/drift"
14
+ Kettle::Drift.install_tasks
15
+ rescue LoadError
16
+ desc("(stub) kettle:drift:check is unavailable")
17
+ task("kettle:drift:check") do
18
+ warn("NOTE: kettle-drift isn't installed, or is disabled for \#{RUBY_VERSION} in the current environment")
19
+ end
20
+ desc("(stub) kettle:drift:update is unavailable")
21
+ task("kettle:drift:update") do
22
+ warn("NOTE: kettle-drift isn't installed, or is disabled for \#{RUBY_VERSION} in the current environment")
23
+ end
24
+ desc("(stub) kettle:drift:force_update is unavailable")
25
+ task("kettle:drift:force_update") do
26
+ warn("NOTE: kettle-drift isn't installed, or is disabled for \#{RUBY_VERSION} in the current environment")
27
+ end
28
+ desc("(stub) kettle:drift is unavailable")
29
+ task("kettle:drift" => "kettle:drift:update")
30
+ end
31
+ RUBY
32
+
33
+ def register!(registrar)
34
+ registrar.after_phase(:remaining_files) do |context:, **|
35
+ inject_rakefile_tasks(context)
36
+ end
37
+ end
38
+
39
+ def inject_rakefile_tasks(context)
40
+ rakefile_path = File.join(context.project_root, "Rakefile")
41
+ return unless File.exist?(rakefile_path)
42
+
43
+ existing = File.read(rakefile_path)
44
+ updated = upsert_rakefile_snippet(existing)
45
+ return if updated == existing
46
+
47
+ File.write(rakefile_path, updated)
48
+ context.helpers.record_template_result(rakefile_path, :replace)
49
+ context.out.report_detail("[kettle-drift] Injected Rakefile tasks")
50
+ end
51
+
52
+ def upsert_rakefile_snippet(content)
53
+ if content.include?(SNIPPET_MARKER)
54
+ return replace_existing_snippet(content)
55
+ end
56
+
57
+ insert_snippet(content)
58
+ end
59
+
60
+ def replace_existing_snippet(content)
61
+ marker_index = content.index(SNIPPET_MARKER)
62
+ next_section_index = content.index(/\n### [A-Z][^\n]*\n/, marker_index + SNIPPET_MARKER.length)
63
+ prefix = content[0...marker_index].rstrip
64
+ suffix = next_section_index ? content[next_section_index..].lstrip : ""
65
+ "#{[prefix, RAKEFILE_SNIPPET.rstrip, suffix].reject(&:empty?).join("\n\n").rstrip}\n"
66
+ end
67
+
68
+ def insert_snippet(content)
69
+ lines = content.lines
70
+ require_index = lines.rindex { |line| line.match?(/^\s*require\s+["']kettle\/dev["']/) }
71
+ if require_index
72
+ lines.insert(require_index + 1, "\n", RAKEFILE_SNIPPET, "\n")
73
+ lines.join
74
+ else
75
+ [content.rstrip, "", RAKEFILE_SNIPPET.rstrip, ""].join("\n")
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Drift
5
+ class Process
6
+ module CalculateDiff
7
+ class << self
8
+ def call(new_results, old_results)
9
+ new_entries = flatten_results(new_results)
10
+ return Kettle::Drift::Diff.new(state: :complete, fixed_entries: [], new_entries: [], unchanged_entries: []) if new_entries.empty? && old_results.nil?
11
+ return Kettle::Drift::Diff.new(state: :new, new_entries: new_entries, unchanged_entries: []) if old_results.nil?
12
+
13
+ old_entries = flatten_results(old_results)
14
+ new_map = index_entries(new_entries)
15
+ old_map = index_entries(old_entries)
16
+
17
+ added = (new_map.keys - old_map.keys).map { |key| new_map.fetch(key) }
18
+ fixed = (old_map.keys - new_map.keys).map { |key| old_map.fetch(key) }
19
+ unchanged = (new_map.keys & old_map.keys).map { |key| new_map.fetch(key) }
20
+
21
+ state = if new_entries.empty?
22
+ :complete
23
+ elsif added.empty? && fixed.empty?
24
+ :no_changes
25
+ elsif added.empty?
26
+ :better
27
+ elsif fixed.empty?
28
+ :worse
29
+ else
30
+ :updated
31
+ end
32
+
33
+ Kettle::Drift::Diff.new(
34
+ state: state,
35
+ new_entries: added,
36
+ fixed_entries: fixed,
37
+ unchanged_entries: unchanged,
38
+ )
39
+ end
40
+
41
+ private
42
+
43
+ def flatten_results(results)
44
+ results.keys.sort.flat_map do |chunk|
45
+ Array(results.fetch(chunk)).map do |entry|
46
+ {
47
+ chunk: chunk,
48
+ file: entry.fetch(:file),
49
+ lines: entry.fetch(:lines),
50
+ }
51
+ end
52
+ end
53
+ end
54
+
55
+ def index_entries(entries)
56
+ entries.each_with_object({}) do |entry, indexed|
57
+ indexed[entry_key(entry)] = entry
58
+ end
59
+ end
60
+
61
+ def entry_key(entry)
62
+ [entry.fetch(:chunk), entry.fetch(:file), Array(entry.fetch(:lines))]
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Drift
5
+ class Process
6
+ class Printer
7
+ def initialize(diff:, lock_path:, mode: :update)
8
+ @diff = diff
9
+ @lock_path = lock_path
10
+ @mode = mode
11
+ end
12
+
13
+ def print_results
14
+ send(:"print_#{diff.state}")
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :diff, :lock_path, :mode
20
+
21
+ def print_complete
22
+ puts "Kettle Drift is complete!"
23
+ puts "Removing `#{Kettle::Drift.display_path(lock_path)}` lock file..." if diff.statistics[:fixed].positive?
24
+ end
25
+
26
+ def print_updated
27
+ if mode == :force_update
28
+ puts "Kettle Drift found both fixed drift and new untracked drift, and is force-updating the lockfile."
29
+ else
30
+ puts "Kettle Drift found both fixed drift and new untracked drift."
31
+ end
32
+ end
33
+
34
+ def print_no_changes
35
+ puts "Kettle Drift got no changes."
36
+ end
37
+
38
+ def print_new
39
+ puts "Kettle Drift got results for the first time. #{diff.statistics[:left]} drift item(s) found."
40
+ puts "Don't forget to commit `#{Kettle::Drift.display_path(lock_path)}`."
41
+ end
42
+
43
+ def print_better
44
+ puts "Kettle Drift got #{diff.statistics[:fixed]} drift item(s) fixed, #{diff.statistics[:left]} left. Keep going!"
45
+ end
46
+
47
+ def print_worse
48
+ if mode == :force_update
49
+ puts "Kettle Drift found new untracked drift and is force-updating the lockfile:"
50
+ else
51
+ puts "Uh oh, Kettle Drift got worse:"
52
+ end
53
+ diff.files.each do |file, entries|
54
+ puts "-> #{Kettle::Drift.display_path(file)} (#{entries.size} new drift item(s))"
55
+ entries.each do |entry|
56
+ puts " (lines #{entry[:lines].join(", ")}) #{entry[:chunk].inspect}"
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "process/calculate_diff"
4
+ require_relative "process/printer"
5
+
6
+ module Kettle
7
+ module Drift
8
+ class Process
9
+ Result = Struct.new(:diff, :exit_code, keyword_init: true)
10
+
11
+ attr_reader :project_root, :lock_file, :old_results, :new_results, :mode, :printer_class
12
+
13
+ def initialize(project_root:, results:, lock_path:, mode: :update, printer_class: Kettle::Drift::Process::Printer)
14
+ @project_root = File.expand_path(project_root)
15
+ @lock_file = Kettle::Drift::LockFile.new(lock_path)
16
+ @old_results = lock_file.read_results
17
+ @new_results = Kettle::Drift::Serializer.normalize(results, project_root: @project_root)
18
+ @mode = mode
19
+ @printer_class = printer_class
20
+ end
21
+
22
+ def call
23
+ run.exit_code
24
+ end
25
+
26
+ def run
27
+ diff = Kettle::Drift::Process::CalculateDiff.call(new_results, old_results)
28
+ printer_class&.new(diff: diff, lock_path: lock_file.path, mode: mode)&.print_results
29
+
30
+ exit_code = error_code(diff)
31
+ sync_lock_file(diff) if exit_code.zero?
32
+ Result.new(diff: diff, exit_code: exit_code)
33
+ end
34
+
35
+ private
36
+
37
+ def fail_with_outdated_lock?(diff)
38
+ return false unless mode == :check
39
+ return false if diff.state == :complete && old_results.nil?
40
+
41
+ diff.state != :no_changes
42
+ end
43
+
44
+ def new_untracked_drift?(diff)
45
+ return false if old_results.nil?
46
+
47
+ diff.new_entries.any?
48
+ end
49
+
50
+ def sync_lock_file(diff)
51
+ return lock_file.delete if diff.state == :complete
52
+
53
+ lock_file.write_results(new_results)
54
+ end
55
+
56
+ def error_code(diff)
57
+ return 1 if fail_with_outdated_lock?(diff)
58
+ return 1 if new_untracked_drift?(diff) && mode != :force_update
59
+
60
+ 0
61
+ end
62
+ end
63
+ end
64
+ end