twinshot 1.2.1

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,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "diff/lcs"
4
+
5
+ module Twinshot
6
+ # Per-format comparison strategies. An adapter is registered for a set of
7
+ # extensions and responds to .call(rel, oracle_file, current_file, ctx),
8
+ # returning a result hash (string keys — results travel through JSON in
9
+ # worker processes):
10
+ #
11
+ # { "kind" => "png", "status" => "pass"|"fail", "detail" => "...",
12
+ # "image" => true|nil, ...format-specific keys }
13
+ #
14
+ # ctx carries max_diff_ratio and report_dir.
15
+ module Adapters
16
+ Ctx = Struct.new(:max_diff_ratio, :report_dir, keyword_init: true)
17
+
18
+ @registry = {}
19
+ class << self
20
+ attr_reader :registry
21
+
22
+ def register(adapter, *extensions)
23
+ extensions.each { |ext| @registry[ext.to_s.downcase] = adapter }
24
+ end
25
+
26
+ # nil means "no adapter" — an unknown file is a failure by design.
27
+ def for(filename)
28
+ @registry[File.extname(filename).delete_prefix(".").downcase]
29
+ end
30
+
31
+ # Shared structural comparison (used by html and xml adapters): two
32
+ # canonical line arrays → a result hash. Pass when structures identical.
33
+ def structural_result(kind, a_lines, b_lines)
34
+ return { "kind" => kind, "status" => "pass", "detail" => "structure identical" } if a_lines == b_lines
35
+
36
+ changed, entries = line_diff(a_lines, b_lines)
37
+ { "kind" => kind, "status" => "fail", "detail" => "#{changed} structural line(s) differ",
38
+ "diff_lines" => changed, "diff" => entries }
39
+ end
40
+
41
+ # Shared canonical DOM serialization: comments dropped, whitespace-only
42
+ # text nodes dropped, runs of whitespace in text squashed to one space,
43
+ # attributes sorted by name. One line per element/text node, indented by
44
+ # depth. downcase_names: true for html (case-insensitive markup),
45
+ # false for xml (case-sensitive).
46
+ def canonical_dom_lines(doc, downcase_names:)
47
+ lines = []
48
+ walk_dom(doc, 0, lines, downcase_names)
49
+ lines
50
+ end
51
+
52
+ def walk_dom(node, depth, lines, downcase_names)
53
+ node.children.each do |child|
54
+ case child.type
55
+ when Nokogiri::XML::Node::TEXT_NODE
56
+ text = child.content.gsub(/\s+/, " ").strip
57
+ lines << format("%s%s", " " * depth, text) unless text.empty?
58
+ when Nokogiri::XML::Node::ELEMENT_NODE
59
+ name = downcase_names ? child.name.downcase : child.name
60
+ attrs = child.attributes.sort_by { |name, _| name }
61
+ .map { |name, attr| %( #{name}="#{escape_attr(attr.value)}") }.join
62
+ lines << format("%s<%s%s>", " " * depth, name, attrs)
63
+ walk_dom(child, depth + 1, lines, downcase_names)
64
+ end
65
+ end
66
+ end
67
+
68
+ def escape_attr(value)
69
+ value.gsub("&", "&amp;").gsub('"', "&quot;")
70
+ end
71
+
72
+ # Shared line diff over two line arrays (used by txt and the structural
73
+ # html/xml adapters).
74
+ # Returns [changed_line_count, [ { "t" => "same"|"del"|"add", "text" }, ... ]].
75
+ def line_diff(a_lines, b_lines, cap: 400)
76
+ changed = 0
77
+ entries = []
78
+ Diff::LCS.sdiff(a_lines, b_lines).each do |op|
79
+ case op.action
80
+ when "="
81
+ entries << { "t" => "same", "text" => op.old_element }
82
+ when "-"
83
+ changed += 1
84
+ entries << { "t" => "del", "text" => op.old_element }
85
+ when "+"
86
+ changed += 1
87
+ entries << { "t" => "add", "text" => op.new_element }
88
+ when "!"
89
+ changed += 2
90
+ entries << { "t" => "del", "text" => op.old_element }
91
+ entries << { "t" => "add", "text" => op.new_element }
92
+ end
93
+ end
94
+ entries << { "t" => "same", "text" => "… (diff truncated)" } if entries.size > cap
95
+ [changed, entries.first(cap)]
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ require_relative "adapters/binary"
102
+ require_relative "adapters/txt"
103
+ require_relative "adapters/html"
104
+ require_relative "adapters/xml"
105
+ require_relative "adapters/png"
@@ -0,0 +1,503 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require "shellwords"
6
+ require "fileutils"
7
+ require_relative "progress"
8
+
9
+ module Twinshot
10
+ # Command-line interface: twinshot help | generate | compare | review.
11
+ #
12
+ # Exit codes: 0 — all pass (or command succeeded), 1 — parity differences
13
+ # found (or a generate command failed), 2 — usage or config error.
14
+ class CLI
15
+ EXIT_OK = 0
16
+ EXIT_DIFF = 1
17
+ EXIT_ERROR = 2
18
+
19
+ def initialize(argv, out: $stdout, err: $stderr)
20
+ @argv = argv.dup
21
+ @out = out
22
+ @err = err
23
+ @config_path = nil
24
+ @jobs = nil
25
+ @json = false
26
+ @open = nil
27
+ @helped = false
28
+ end
29
+
30
+ # Words bash feeds to the hidden completion helper. `completion install`
31
+ # registers the `complete -C` one-liner that triggers this path.
32
+ COMMANDS = %w[compare generate review help completion].freeze
33
+
34
+ def call
35
+ # `complete -C twinshot twinshot` protocol: bash runs us with
36
+ # COMP_LINE/COMP_POINT in the env and argv ["twinshot", cur, prev].
37
+ # Intercepted first — before option parsing — so a half-typed option
38
+ # can't raise. Hidden: never listed anywhere.
39
+ return complete_c if completer_invocation?
40
+
41
+ # Same candidates from words on the command line, for manual use and
42
+ # tests: twinshot __complete WORD... (last word = one being completed).
43
+ return complete(@argv.drop(1)) if @argv.first == "__complete"
44
+
45
+ global_parser.order!(@argv)
46
+ return version_text if @version
47
+
48
+ if @helped
49
+ @out.puts global_parser
50
+ return EXIT_OK
51
+ end
52
+
53
+ command = @argv.shift
54
+ case command
55
+ when "compare" then compare
56
+ when "generate" then generate
57
+ when "review" then review
58
+ when "completion" then completion
59
+ when "help" then help
60
+ when nil
61
+ @err.puts global_parser
62
+ EXIT_ERROR
63
+ else
64
+ @err.puts "twinshot: unknown command #{command.inspect}"
65
+ @err.puts global_parser
66
+ EXIT_ERROR
67
+ end
68
+ rescue OptionParser::ParseError, Config::Error, Compare::Error => e
69
+ @err.puts "twinshot: #{e.message}"
70
+ EXIT_ERROR
71
+ end
72
+
73
+ private
74
+
75
+ def version_text
76
+ @out.puts "twinshot #{Twinshot::VERSION}"
77
+ EXIT_OK
78
+ end
79
+
80
+ def global_parser
81
+ OptionParser.new do |o|
82
+ o.banner = "twinshot — 1:1 artifact parity: oracle vs current"
83
+ o.separator ""
84
+ o.separator "Usage: twinshot [options] COMMAND [options]"
85
+ o.separator ""
86
+ o.separator "Commands:"
87
+ o.separator " compare [GROUP|all] compare every group or just one (oracle vs"
88
+ o.separator " current trees), write reports"
89
+ o.separator " generate [GROUP:SIDE] run generate commands: dogs:oracle, a bare"
90
+ o.separator " side (default group), a group, or 'all'"
91
+ o.separator " review [GROUP|all] regenerate a group (or every group), then"
92
+ o.separator " compare it — generate and compare in one run"
93
+ o.separator " completion install install bash completion (works on the next TAB)"
94
+ o.separator " help [COMMAND] show help"
95
+ o.separator ""
96
+ o.separator "Options:"
97
+ o.on("-c", "--config PATH", "Path to twinshot.yml (default: #{Config::DEFAULT_FILENAME})") do |v|
98
+ @config_path = v
99
+ end
100
+ o.on("-V", "--version", "Show version") { @version = true }
101
+ o.on("-h", "--help", "Show this help") { @helped = true }
102
+ end
103
+ end
104
+
105
+ def compare_parser
106
+ OptionParser.new do |o|
107
+ o.banner = "Usage: twinshot compare [GROUP|all] [-c PATH] [-j N] [--json] [--[no-]open]"
108
+ o.separator ""
109
+ o.separator "Compares the oracle and current trees: structure first, then every"
110
+ o.separator "file through a format adapter (png/html/txt/binary). Unknown files"
111
+ o.separator "and structure mismatches are failures. With GROUP only that group"
112
+ o.separator "runs and only its report is written; 'all' (the default) — every"
113
+ o.separator "configured group. Each compared group gets its own folder in"
114
+ o.separator "twinshot-report/ (report_for_human.html + report_for_agent.md"
115
+ o.separator "+ summary.json + diffs/)."
116
+ o.separator ""
117
+ o.separator "Exit status: 0 — all pass, 1 — differences found, 2 — config error."
118
+ o.separator "soft_check entries are exact paths or globs (code/x/*);"
119
+ o.separator "their content diffs and missing/extra structure results"
120
+ o.separator "downgrade to warnings: shown in the reports, never change"
121
+ o.separator "the exit code."
122
+ o.separator ""
123
+ o.separator "Options:"
124
+ add_compare_options(o)
125
+ end
126
+ end
127
+
128
+ def review_parser
129
+ OptionParser.new do |o|
130
+ o.banner = "Usage: twinshot review [GROUP|all] [-c PATH] [-j N] [--json] [--[no-]open]"
131
+ o.separator ""
132
+ o.separator "Generate and compare in one run: regenerates both sides of"
133
+ o.separator "every selected group (a tree without a generate command is"
134
+ o.separator "taken as it is), then compares exactly like `compare` and"
135
+ o.separator "writes the same reports. A failed generate command fails"
136
+ o.separator "the run without comparing — reports over stale trees would"
137
+ o.separator "be misleading."
138
+ o.separator ""
139
+ o.separator "Exit status: as compare (0 — all pass, 1 — differences or a"
140
+ o.separator "failed generate command, 2 — config error)."
141
+ o.separator ""
142
+ o.separator "Options:"
143
+ add_compare_options(o)
144
+ end
145
+ end
146
+
147
+ # The options shared by compare and review.
148
+ def add_compare_options(opts)
149
+ opts.on("-c", "--config PATH", "Path to twinshot.yml (default: #{Config::DEFAULT_FILENAME})") do |v|
150
+ @config_path = v
151
+ end
152
+ opts.on("-j", "--jobs N", Integer, "Worker processes (default: auto, capped at #{Compare::MAX_WORKERS})") do |v|
153
+ @jobs = v
154
+ end
155
+ opts.on("--json", "Print the machine summary to stdout") { @json = true }
156
+ opts.on("--[no-]open", "Open the report in the default browser",
157
+ "(default: on when stdout is a tty)") { |v| @open = v }
158
+ opts.on("-h", "--help", "Show this help") { @helped = true }
159
+ end
160
+
161
+ def generate_parser
162
+ OptionParser.new do |o|
163
+ o.banner = "Usage: twinshot generate [GROUP|all|SIDE][:oracle|current]"
164
+ o.separator ""
165
+ o.separator "Runs the generate commands configured in twinshot.yml, with"
166
+ o.separator "the config's directory as cwd. The selector picks what to"
167
+ o.separator "regenerate:"
168
+ o.separator " dogs:oracle, dogs:current one tree of a named group"
169
+ o.separator " oracle, current the default group's tree (no"
170
+ o.separator " namespace means default, as in"
171
+ o.separator " default:oracle)"
172
+ o.separator " dogs a whole group: both trees"
173
+ o.separator " all, all:oracle every group: both trees, or one"
174
+ o.separator " side of every group"
175
+ o.separator "No selector at all: every configured tree — all groups,"
176
+ o.separator "oracle then current — announced as a list and regenerated"
177
+ o.separator "sequentially. Optional: a compare run just takes the two"
178
+ o.separator "directories as they are."
179
+ o.separator ""
180
+ o.separator "Exit status: 0 — all commands succeeded, 1 — a command failed,"
181
+ o.separator "2 — nothing configured / usage error."
182
+ o.separator ""
183
+ o.separator "Options:"
184
+ o.on("-c", "--config PATH", "Path to twinshot.yml (default: #{Config::DEFAULT_FILENAME})") do |v|
185
+ @config_path = v
186
+ end
187
+ o.on("-h", "--help", "Show this help") { @helped = true }
188
+ end
189
+ end
190
+
191
+ def compare
192
+ compare_parser.order!(@argv)
193
+ if @helped
194
+ @out.puts compare_parser
195
+ return EXIT_OK
196
+ end
197
+
198
+ config = Config.load(@config_path)
199
+ run_compare(config, select_groups(config))
200
+ end
201
+
202
+ # The compare run shared by compare and review: compares the groups,
203
+ # writes the reports, prints the summary, opens the report. Returns the
204
+ # exit code (0 — all pass, 1 — differences found).
205
+ def run_compare(config, groups)
206
+ progress = Progress.new(out: @out, quiet: @json)
207
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
208
+ summary = Compare.new(config).run(jobs: @jobs, progress: progress, groups: groups)
209
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
210
+ Report.new(config, summary).write
211
+ @out.puts JSON.pretty_generate(summary) if @json
212
+ progress.summary(summary, config.report_root, elapsed: elapsed)
213
+ open_report(config, groups)
214
+ summary["failed"].zero? ? EXIT_OK : EXIT_DIFF
215
+ end
216
+
217
+ # The report opens in the browser after a compare run: by default when
218
+ # stdout is a tty (so CI and --json stay quiet), --open forces it,
219
+ # --no-open turns it off. The first compared group's report is the one
220
+ # opened. A failed launch is a warning, not a parity result — it never
221
+ # changes the exit code.
222
+ def open_report(config, groups)
223
+ should_open = @open.nil? ? !@json && @out.tty? : @open
224
+ return unless should_open
225
+
226
+ Opener.open(File.join(config.report_dir(groups.first), Config::HUMAN_REPORT))
227
+ rescue Opener::Error => e
228
+ @err.puts "twinshot: #{e.message}"
229
+ end
230
+
231
+ # The positional GROUP|all selector shared by compare and generate:
232
+ # 'all' or nothing — every configured group, otherwise the named one
233
+ # (Config#group raises on an unknown name).
234
+ def select_groups(config)
235
+ selector = @argv.shift
236
+ return config.groups if selector.nil? || selector == "all"
237
+
238
+ [config.group(selector)]
239
+ end
240
+
241
+ # The generate selector [GROUP|all|SIDE][:SIDE]. 'dogs:oracle' — one
242
+ # tree of a named group; a bare side ('oracle') — the default group's
243
+ # tree, no namespace means default ('default:oracle' is the same thing);
244
+ # 'dogs' — both trees of one group; 'all', 'all:oracle' or nothing —
245
+ # every group, both trees or one side. Group names can never be sides
246
+ # (reserved in Config), so a bare side is unambiguous.
247
+ def generate_selection(config, selector)
248
+ return [config.groups, nil] if selector.nil? || selector == "all"
249
+
250
+ name, side = selector.split(":", 2)
251
+ return [[config.group(Config::DEFAULT_GROUP)], name] if side.nil? && Config::SIDES.include?(name)
252
+
253
+ unless side.nil? || Config::SIDES.include?(side)
254
+ raise Config::Error, "unknown side: #{side.inspect} in #{selector.inspect} (expected oracle or current)"
255
+ end
256
+
257
+ groups = if name == "all" then config.groups
258
+ elsif name.empty? then [config.group(Config::DEFAULT_GROUP)]
259
+ else [config.group(name)]
260
+ end
261
+ [groups, side]
262
+ end
263
+
264
+ def generate
265
+ generate_parser.order!(@argv)
266
+ if @helped
267
+ @out.puts generate_parser
268
+ return EXIT_OK
269
+ end
270
+
271
+ config = Config.load(@config_path)
272
+ groups, side = generate_selection(config, @argv.shift)
273
+ if (extra = @argv.shift)
274
+ @err.puts "twinshot: unexpected argument #{extra.inspect}"
275
+ @err.puts generate_parser
276
+ return EXIT_ERROR
277
+ end
278
+
279
+ targets = generate_targets(groups, side)
280
+ if targets.empty?
281
+ what = side ? "#{side}.generate" : "generate"
282
+ scope = groups.size == 1 ? "for group #{groups.first.name}" : "in any selected group"
283
+ @err.puts "twinshot: no #{what} configured #{scope} in #{File.basename(config.path)}"
284
+ return EXIT_ERROR
285
+ end
286
+
287
+ run_generate(config, targets) ? EXIT_OK : EXIT_DIFF
288
+ end
289
+
290
+ # The review command is generate + compare in one run: both sides of the selected
291
+ # groups regenerate first (a tree without a generate command is taken as
292
+ # it is — nothing to rebuild is not an error here, the compare still
293
+ # runs), then the compare. A failed generate command fails the whole run
294
+ # with no compare and no reports.
295
+ def review
296
+ review_parser.order!(@argv)
297
+ if @helped
298
+ @out.puts review_parser
299
+ return EXIT_OK
300
+ end
301
+
302
+ config = Config.load(@config_path)
303
+ groups = select_groups(config)
304
+ targets = generate_targets(groups)
305
+ return run_compare(config, groups) if targets.empty?
306
+
307
+ run_generate(config, targets) ? run_compare(config, groups) : EXIT_DIFF
308
+ end
309
+
310
+ # Trees (group, side, command) with a generate command, in oracle-then-
311
+ # current order. Trees without one are simply skipped.
312
+ def generate_targets(groups, side = nil)
313
+ sides = side ? [side] : Config::SIDES
314
+ groups.flat_map do |g|
315
+ sides.filter_map do |s|
316
+ command = g.generate_command(s)
317
+ [g, s, command] if command
318
+ end
319
+ end
320
+ end
321
+
322
+ # Says the full list of trees about to be regenerated first, then runs
323
+ # the commands one by one from the config's directory; a failing command
324
+ # doesn't stop the rest. true — all succeeded.
325
+ def run_generate(config, targets)
326
+ @out.puts "Generate:"
327
+ targets.each { |group, tree, _command| @out.puts "- #{group.name}/#{tree}" }
328
+ targets.map do |group, tree, command|
329
+ @out.puts "twinshot: [#{group.name}/#{tree}] #{command}"
330
+ system(command, chdir: config.root)
331
+ end.all?
332
+ end
333
+
334
+ # The `complete -C` contract: bash passes the whole line in COMP_LINE and
335
+ # the cursor in COMP_POINT; argv is ["twinshot", cur, prev]. Split the
336
+ # line up to the cursor the way bash word-splits it, drop the program
337
+ # name, make sure a word is being completed (append "" after a trailing
338
+ # space), and reuse the shared candidate logic.
339
+ def complete_c
340
+ line = ENV.fetch("COMP_LINE", "")
341
+ before = line[0, [ENV.fetch("COMP_POINT", line.length).to_i, line.length].min]
342
+ words = begin
343
+ Shellwords.split(before)
344
+ rescue ArgumentError # unbalanced quote mid-word while typing
345
+ before.split
346
+ end
347
+ words = words.drop(1)
348
+ words << "" if before.empty? || before.end_with?(" ")
349
+ complete(words)
350
+ end
351
+
352
+ def completer_invocation?
353
+ ENV.key?("COMP_LINE") && @argv.size == 3 && @argv.first == "twinshot"
354
+ end
355
+
356
+ # Completion: prints the candidates for the words typed so far, one per
357
+ # line. `words` is everything after `twinshot`; the last word is the one
358
+ # being completed (possibly empty), the rest are what the user already
359
+ # typed. We filter by prefix ourselves, so callers (the complete -C
360
+ # protocol, `__complete`, tests) get the final list as-is.
361
+ def complete(words)
362
+ current = words.last.to_s
363
+ typed = words[0...-1]
364
+ candidates_for(typed, current).select { |c| c.start_with?(current) }.each { |c| @out.puts c }
365
+ EXIT_OK
366
+ end
367
+
368
+ def candidates_for(typed, current = "")
369
+ case completion_command(typed)
370
+ when nil then COMMANDS + parser_flags(global_parser)
371
+ when "help" then COMMANDS
372
+ when "completion" then %w[install]
373
+ when "compare", "review"
374
+ # Only the first positional is a group name (or 'all'); after that —
375
+ # and for a bogus word — just the options.
376
+ positionals(typed).empty? ? group_candidates(typed) | parser_flags(compare_parser) : parser_flags(compare_parser)
377
+ when "generate"
378
+ flags = parser_flags(generate_parser)
379
+ if positionals(typed).empty?
380
+ # Half-typed 'GROUP:' completes the sides; otherwise group names,
381
+ # 'all' and the bare sides (a bare side = the default group).
382
+ return side_completions(current) if current.include?(":")
383
+
384
+ group_candidates(typed) | flags | Config::SIDES
385
+ else
386
+ flags
387
+ end
388
+ else
389
+ []
390
+ end
391
+ end
392
+
393
+ # 'dogs:<TAB>' / 'all:<TAB>' → the side spellings on that prefix.
394
+ def side_completions(current)
395
+ prefix = current[0..current.index(":")]
396
+ ["#{prefix}oracle", "#{prefix}current"]
397
+ end
398
+
399
+ # The command word, skipping global flags and their values that may
400
+ # precede it (`twinshot -c x.yml generate ...`). nil — no command yet.
401
+ def completion_command(typed)
402
+ walk_flags(typed) { |word, _i| return word }
403
+ nil
404
+ end
405
+
406
+ # Positional words after the command, with flags and their values
407
+ # (-c/--config) skipped.
408
+ def positionals(typed)
409
+ command = completion_command(typed)
410
+ return [] unless command
411
+
412
+ rest = typed.drop(typed.index(command) + 1)
413
+ found = []
414
+ walk_flags(rest) { |word, _i| found << word }
415
+ found
416
+ end
417
+
418
+ def walk_flags(words)
419
+ i = 0
420
+ while i < words.size
421
+ case words[i]
422
+ when "-c", "--config" then i += 2
423
+ when /\A-/ then i += 1
424
+ else yield words[i], i
425
+ i += 1
426
+ end
427
+ end
428
+ end
429
+
430
+ # The flag spellings straight out of the parser, so completion can never
431
+ # drift from the real options. summarize() renders each switch as
432
+ # " -c, --config PATH description" — keep option lines, take the
433
+ # flag column (first run of the line), drop trailing commas and argument
434
+ # names (PATH, N), and expand --[no-]open into both spellings.
435
+ def parser_flags(parser)
436
+ parser.summarize.map(&:strip)
437
+ .select { |line| line.start_with?("-") }
438
+ .flat_map { |line| line.split(/\s{2,}/).first.to_s.split }
439
+ .map { |token| token.chomp(",") }
440
+ .select { |token| token.start_with?("-") }
441
+ .flat_map do |flag|
442
+ if flag =~ /\A--\[no-\](\S+)\z/
443
+ ["--#{Regexp.last_match(1)}",
444
+ "--no-#{Regexp.last_match(1)}"]
445
+ else
446
+ [flag]
447
+ end
448
+ end
449
+ end
450
+
451
+ # Group names for the first positional of compare/generate, read from
452
+ # the config the user pointed at (or the default one), plus 'all'. Any
453
+ # config problem — missing file, bad YAML — quietly leaves `all` as the
454
+ # only candidate.
455
+ def group_candidates(typed)
456
+ flag = typed.rindex("-c") || typed.rindex("--config")
457
+ Config.load(flag && typed[flag + 1]).groups.map(&:name) + ["all"]
458
+ rescue Config::Error
459
+ ["all"]
460
+ end
461
+
462
+ # `twinshot completion install`: writes the `complete -C` one-liner into
463
+ # the bash-completion user dir, which bash-completion lazy-loads on the
464
+ # first TAB — no .bashrc edits, nothing sourced manually. Uninstall is
465
+ # just deleting the file.
466
+ def completion
467
+ action = @argv.shift
468
+ case action
469
+ when "install"
470
+ dir = File.join(data_home, "bash-completion", "completions")
471
+ target = File.join(dir, "twinshot")
472
+ FileUtils.mkdir_p(dir)
473
+ File.write(target, "complete -C twinshot twinshot\n")
474
+ @out.puts "Wrote #{target}"
475
+ @out.puts "Completion is live in a new shell (loaded on your first TAB)."
476
+ EXIT_OK
477
+ else
478
+ @err.puts "twinshot: expected 'completion install'"
479
+ EXIT_ERROR
480
+ end
481
+ end
482
+
483
+ def data_home
484
+ xdg = ENV["XDG_DATA_HOME"]
485
+ xdg&.start_with?("/") ? xdg : File.join(Dir.home, ".local", "share")
486
+ end
487
+
488
+ def help
489
+ topic = @argv.shift
490
+ text = case topic
491
+ when nil then global_parser.to_s
492
+ when "compare" then compare_parser.to_s
493
+ when "generate" then generate_parser.to_s
494
+ when "review" then review_parser.to_s
495
+ else
496
+ @err.puts "twinshot: unknown help topic #{topic.inspect} (expected compare, generate or review)"
497
+ return EXIT_ERROR
498
+ end
499
+ @out.puts text
500
+ EXIT_OK
501
+ end
502
+ end
503
+ end