hachiwari 0.4.0 → 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.
data/lib/hachiwari/cli.rb CHANGED
@@ -1,76 +1,277 @@
1
- # require "hachiwari"
1
+ # frozen_string_literal: true
2
+
2
3
  require "thor"
3
- require 'yaml/store'
4
+ require_relative "status_runner"
5
+ require_relative "storage"
6
+ require_relative "locales"
4
7
 
5
8
  module Hachiwari
6
9
  class CLI < Thor
7
-
8
- Results = Struct.new(:wins, :losses, :target, :language)
9
- @@db = YAML::Store.new("#{Dir.home}/.hachiwari")
10
- @@results = @@db.transaction { @@db[:results] } if @@db
11
- @@results ||= Results.new(0, 0, 80, :ja)
10
+ HIDDEN_GENERAL_HELP_COMMANDS = %w[info i calculate s].freeze
11
+ DEFAULT_THOR_LOCALE = :ja
12
12
 
13
13
  class << self
14
- def save
15
- @@db.transaction { @@db[:results] = @@results }
14
+ # CLI 起動時に旧フォーマットの保存データをマイグレートし、ヘルプやバージョン表示を先に処理する
15
+ def start(given_args = ARGV, config = {}, &)
16
+ migrate_legacy_store
17
+
18
+ args = Array(given_args).dup
19
+ return display_help(nil) if args.empty?
20
+ return if handle_direct_option?(args)
21
+
22
+ args = ["status", *args] if default_to_status?(args)
23
+ super(args, config, &)
16
24
  end
17
- end
18
- CLI.save
19
-
20
- desc "status [wins] [losses] [target] [language]", "Displays winning percentage and number of wins to achieve the goal. (with save status)"
21
- def status(wins = @@results.wins, losses = @@results.losses, target = @@results.target, language = @@results.language, save = true)
22
- wins = wins.to_i if ARGV[1]
23
- losses = losses.to_i if ARGV[2]
24
- target = target.to_i if ARGV[3]
25
- language = language.to_sym if ARGV[4]
26
-
27
- if save
28
- @@results.wins = wins
29
- @@results.losses = losses
30
- @@results.target = target
31
- @@results.language = language
32
- CLI.save
33
- end
34
-
35
- case language
36
- when :ja
37
- puts "#{wins+losses} 戦 #{wins} 勝 #{losses} 敗 勝率 #{winning_percentage(wins, losses)} % です"
38
- puts "あと #{reach_wins(wins, losses)} 勝で 勝率 #{(@@results.target).to_i} % です"
39
- when :en
40
- puts "#{wins+losses} games #{wins} wins #{losses} losses a winning percentage of #{winning_percentage(wins, losses)} %."
41
- puts "You need #{reach_wins(wins, losses)} more wins to reach #{(@@results.target).to_i} %"
25
+
26
+ # ヘルプ用の文字列をユーザー設定ロケール優先で取得する
27
+ def thor_string(command, key)
28
+ fetch_locale_value(help_language, command, key) || fetch_locale_value(DEFAULT_THOR_LOCALE, command, key) || ""
29
+ end
30
+
31
+ # ヘルプ詳細文をロケール順に取得して配列化する
32
+ def thor_details(command)
33
+ value = fetch_locale_value(help_language, command, :details)
34
+ value = fetch_locale_value(DEFAULT_THOR_LOCALE, command, :details) if value.nil?
35
+ array_wrap(value)
36
+ end
37
+
38
+ # 長文説明をメイン文と詳細の組み合わせで構築する
39
+ def thor_long_description(command)
40
+ build_long_description(thor_string(command, :long), thor_details(command))
41
+ end
42
+
43
+ # デフォルトロケールから短い説明を取得する
44
+ def default_thor_string(command, key)
45
+ fetch_locale_value(DEFAULT_THOR_LOCALE, command, key) || ""
46
+ end
47
+
48
+ # デフォルトロケールの詳細情報を配列で取得する
49
+ def default_thor_details(command)
50
+ array_wrap(fetch_locale_value(DEFAULT_THOR_LOCALE, command, :details))
51
+ end
52
+
53
+ # デフォルトロケールで長文説明を生成する
54
+ def default_thor_long_description(command)
55
+ build_long_description(default_thor_string(command, :long), default_thor_details(command))
56
+ end
57
+
58
+ private
59
+
60
+ # 引数に含まれるバージョン/ヘルプ系の即時オプションを処理する
61
+ def handle_direct_option?(args)
62
+ case args.first
63
+ when "--version", "-v"
64
+ puts Hachiwari::VERSION
65
+ return true
66
+ when "--help", "-h", "help"
67
+ display_help(args[1])
68
+ return true
69
+ end
70
+
71
+ help_index = args.index("--help") || args.index("-h")
72
+ return false unless help_index
73
+
74
+ display_help(extract_command_for_help(args, help_index))
75
+ true
76
+ end
77
+
78
+ # 旧ストレージ形式が存在する場合にマイグレーションを実行する
79
+ def migrate_legacy_store
80
+ Storage.new.send(:migrate_legacy_if_needed)
81
+ rescue StandardError
82
+ # 続行
83
+ end
84
+
85
+ # ヘルプ引数の位置からコマンド名候補を取得する
86
+ def extract_command_for_help(args, help_index)
87
+ before = help_index.positive? ? args[help_index - 1] : nil
88
+ after = args[help_index + 1]
89
+ [before, after].compact.find { |candidate| candidate && !candidate.start_with?("-") }
90
+ end
91
+
92
+ # 指定されたコマンドのヘルプを出力する
93
+ def display_help(command)
94
+ command = command&.to_s&.strip
95
+ command = nil if command.nil? || command.empty?
96
+ command ? print_command_help(command) : print_general_help
97
+ end
98
+
99
+ # 全体ヘルプの各コマンド情報を出力する
100
+ def print_general_help
101
+ data = help_content
102
+ puts data[:general_intro]
103
+ data[:commands].each do |name, info|
104
+ next if HIDDEN_GENERAL_HELP_COMMANDS.include?(name.to_s)
105
+
106
+ puts " #{info[:usage]}"
107
+ puts " #{info[:description]}"
108
+ Array(info[:details]).each { |detail| puts " #{detail}" }
109
+ end
110
+ Array(data[:general_footer]).each { |line| puts line }
111
+ end
112
+
113
+ # 個別コマンドの詳細ヘルプを出力する
114
+ def print_command_help(command)
115
+ data = help_content
116
+ info = data[:commands][command.to_sym] || data[:commands][command.to_s]
117
+
118
+ unless info
119
+ puts format(data[:unknown_command], command: command)
120
+ puts
121
+ print_general_help
122
+ return
123
+ end
124
+
125
+ puts format(data[:command_heading], usage: info[:usage])
126
+ puts format(data[:description_heading], description: info[:description])
127
+ Array(info[:details]).each { |detail| puts " #{detail}" }
128
+ end
129
+
130
+ # ロケールごとのヘルプデータを取得する
131
+ def help_content
132
+ Hachiwari::Locales.t(help_language, :help)
133
+ rescue KeyError
134
+ Hachiwari::Locales.t(:ja, :help)
135
+ end
136
+
137
+ # 保存ロケールが利用可能か確認し、ヘルプ用ロケールを返す
138
+ def help_language
139
+ locale = Storage.new.load.language
140
+ available = Hachiwari::Locales.available_locales
141
+ available.include?(locale) ? locale : :ja
142
+ rescue StandardError
143
+ :ja
144
+ end
145
+
146
+ # 指定コマンドが登録済みか判定し、未登録なら status へフォールバックする
147
+ def default_to_status?(args)
148
+ return false if args.empty?
149
+
150
+ first = args.first
151
+ return true if first.start_with?("-")
152
+
153
+ !all_commands.key?(first)
154
+ end
155
+
156
+ # ロケール付きの Thor 文言を取得する
157
+ def fetch_locale_value(locale, command, key)
158
+ Hachiwari::Locales.t(locale, :thor, command, key)
159
+ rescue KeyError
160
+ nil
161
+ end
162
+
163
+ # メイン文と詳細配列から長文を組み立てる
164
+ def build_long_description(primary, extra_lines)
165
+ lines = []
166
+ lines << primary if primary && !primary.empty?
167
+ lines.concat(array_wrap(extra_lines))
168
+ lines.compact.join("\n")
169
+ end
170
+
171
+ # 値を配列にラップして扱いやすくする
172
+ def array_wrap(value)
173
+ case value
174
+ when nil
175
+ []
176
+ when Array
177
+ value
178
+ else
179
+ [value]
180
+ end
42
181
  end
43
182
  end
44
183
 
45
- desc "s [wins] [losses] [target] [language]", "Another name for the status command."
46
- def s(wins = @@results.wins, losses = @@results.losses, target = @@results.target, language = @@results.language)
47
- status(wins, losses, target, language)
184
+ attr_reader :runner, :storage
185
+
186
+ # コマンド実行時に利用するストレージとランナーを初期化する
187
+ def initialize(*args, **kwargs)
188
+ super
189
+ @storage = Storage.new
190
+ @runner = StatusRunner.new(storage: storage)
191
+ end
192
+
193
+ option :trial, type: :boolean, aliases: "-t", desc: CLI.default_thor_string(:status, :option_trial)
194
+ desc "status [wins] [losses] [target] [language]", CLI.default_thor_long_description(:status)
195
+
196
+ # 勝敗・目標勝率・言語を受け取り、状態を保存しながら結果を表示
197
+ def status(*args)
198
+ run_status_with_trial_flag(trial: options[:trial], **parse_status_arguments(args))
48
199
  end
49
200
 
50
- desc "info [wins] [losses] [target] [language]", "Displays winning percentage and number of wins to achieve the goal. (Information only)"
51
- def info(wins = @@results.wins, losses = @@results.losses, target = @@results.target, language = @@results.language)
52
- status(wins, losses, target, language, false)
201
+ option :trial, type: :boolean, aliases: "-t", desc: CLI.default_thor_string(:info, :option_trial)
202
+ desc "info [wins] [losses] [target] [language]", CLI.default_thor_long_description(:info)
203
+ # 状態を保存せず試算だけ行うコマンド
204
+ def info(*args)
205
+ warn_deprecation("info", locale_key: :trial)
206
+ run_status_with_trial_flag(trial: true, **parse_status_arguments(args))
53
207
  end
54
208
 
55
- desc "i [wins] [losses] [target] [language]", "Another name for the info command."
56
- def i(wins = @@results.wins, losses = @@results.losses, target = @@results.target, language = @@results.language)
57
- status(wins, losses, target, language, false)
209
+ desc "i [wins] [losses] [target] [language]", CLI.default_thor_long_description(:alias_i)
210
+ # `info` と同等の動作を行う短縮エイリアス
211
+ def i(*args)
212
+ warn_deprecation("i", locale_key: :alias)
213
+ run_status_with_trial_flag(trial: true, **parse_status_arguments(args))
58
214
  end
59
215
 
60
- desc "version", "Displays the version number."
216
+ desc "version", CLI.default_thor_string(:version, :short)
217
+ # CLI のバージョン情報を表示する
61
218
  def version
62
219
  puts Hachiwari::VERSION
63
220
  end
64
221
 
222
+ desc "clear", CLI.default_thor_string(:clear, :short)
223
+ # 保存済みの状態データを削除する
224
+ def clear
225
+ if storage.clear
226
+ say("Saved status data has been cleared.", :green)
227
+ else
228
+ say("No saved status data found.", :yellow)
229
+ end
230
+ end
231
+
232
+ desc "calculate [wins] [losses]", CLI.default_thor_long_description(:calculate)
233
+ option :target, type: :numeric, desc: CLI.default_thor_string(:calculate, :option_target)
234
+ option :language, type: :string, desc: CLI.default_thor_string(:calculate, :option_language)
235
+ # `status`/`info` とは別に、オプション経由でパラメータを受け取って計算のみ行う
236
+ def calculate(wins, losses)
237
+ warn_deprecation("calculate")
238
+ run_status_with_trial_flag(
239
+ trial: true,
240
+ wins: wins, losses: losses, target: options[:target], language: options[:language]
241
+ )
242
+ end
243
+
65
244
  private
66
245
 
67
- def winning_percentage(wins, losses)
68
- (wins / (wins + losses).to_f * 100).round(4)
246
+ # トライアル指定の有無に応じて保存フラグを切り替える
247
+ def run_status_with_trial_flag(trial:, **params)
248
+ run_status(save: !trial, **params)
249
+ end
250
+
251
+ # 位置引数から勝敗・目標・言語を取り出して整形する
252
+ def parse_status_arguments(args)
253
+ %i[wins losses target language].zip(args).to_h
254
+ end
255
+
256
+ # 非推奨コマンド利用時の警告をユーザーへ通知する
257
+ def warn_deprecation(command, message = nil, locale_key: :trial)
258
+ message ||= localized_deprecation(locale_key)
259
+ say("`hachiwari #{command}`: #{message}", :yellow)
260
+ end
261
+
262
+ # 保存された言語設定で非推奨メッセージを取得する
263
+ def localized_deprecation(key)
264
+ locale = storage.load.language
265
+ available = Hachiwari::Locales.available_locales
266
+ locale = :ja unless available.include?(locale)
267
+ Hachiwari::Locales.t(locale, :deprecations, key)
268
+ rescue StandardError
269
+ Hachiwari::Locales.t(:ja, :deprecations, key)
69
270
  end
70
271
 
71
- def reach_wins(wins, losses)
72
- target = @@results.target / 100.0
73
- (target / (1 - target) * losses - wins).round(6).ceil
272
+ # `StatusRunner` へ依頼し、不要な nil を除いて保存フラグ付きで実行
273
+ def run_status(save:, **params)
274
+ runner.call(params.compact, save: save)
74
275
  end
75
276
  end
76
277
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Hachiwari
6
+ # 自前の軽量ローカライゼーションローダー
7
+ module Locales
8
+ module_function
9
+
10
+ # 指定ロケールの翻訳データを YAML から読み込みキャッシュする
11
+ def load(locale)
12
+ cache[locale.to_sym] ||= begin
13
+ path = locale_path(locale)
14
+ raise KeyError, "Unknown locale: #{locale}" unless path
15
+
16
+ data = YAML.safe_load_file(path, aliases: true, symbolize_names: true)
17
+ data.fetch(locale.to_sym)
18
+ rescue Errno::ENOENT
19
+ raise KeyError, "Missing locale file: #{locale}"
20
+ end
21
+ end
22
+
23
+ # ネストしたキーを辿って翻訳文字列を取得する
24
+ def t(locale, *keys)
25
+ keys.reduce(load(locale)) do |current, key|
26
+ current.fetch(key.to_sym)
27
+ end
28
+ end
29
+
30
+ # 利用可能なロケール一覧を返す
31
+ def available_locales
32
+ locale_files.keys
33
+ end
34
+
35
+ # 翻訳データのキャッシュをクリアする
36
+ def clear_cache
37
+ cache.clear
38
+ end
39
+
40
+ # ロケールごとのキャッシュを初期化または返却する
41
+ def cache
42
+ @cache ||= {}
43
+ end
44
+
45
+ # ロケールシンボルからファイルパスを取得する
46
+ def locale_path(locale)
47
+ locale_files[locale.to_sym]
48
+ end
49
+
50
+ # ロケールファイルを走査し、シンボルとパスの対応表を構築する
51
+ def locale_files
52
+ @locale_files ||= Dir[File.expand_path("../../config/locales/*.yml",
53
+ __dir__)].each_with_object({}) do |path, hash|
54
+ name = File.basename(path, ".yml").to_sym
55
+ hash[name] = path
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hachiwari
4
+ # 勝敗や目標勝率、表示言語など CLI の状態を保持するデータオブジェクト
5
+ Results = Struct.new(:wins, :losses, :target, :language) do
6
+ # YAML への保存や表示時に利用しやすい Hash へ変換
7
+ def to_h
8
+ { wins: wins, losses: losses, target: target, language: language }
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hachiwari
4
+ # 勝率や必要勝数などの数値計算を担当するユーティリティ
5
+ class StatusCalculator
6
+ # 勝率(%)を小数第4位まで算出
7
+ def winning_percentage(results)
8
+ games = total_games(results)
9
+ return 0.0 if games.zero?
10
+
11
+ (results.wins / games.to_f * 100).round(4)
12
+ end
13
+
14
+ # 目標勝率を満たすまでに必要な追加勝利数を算出
15
+ # 既に目標を上回っている、もしくは目標が無効な場合は 0 を返す
16
+ def required_wins(results)
17
+ target = results.target.to_i
18
+ return 0 if target <= 0
19
+
20
+ wins = results.wins.to_i
21
+ losses = results.losses.to_i
22
+ total = wins + losses
23
+
24
+ return 0 if total.positive? && wins * 100 >= target * total
25
+
26
+ denominator = 100 - target
27
+ return 0 if denominator <= 0
28
+
29
+ numerator = (target * total) - (wins * 100)
30
+ return 0 if numerator <= 0
31
+
32
+ (numerator + denominator - 1) / denominator
33
+ end
34
+
35
+ # 勝率が目標を上回っている場合に、目標を下回るまでに許容される敗北数を算出
36
+ # それ以外の状況では 0 を返す
37
+ def losses_until_below_target(results)
38
+ target = results.target.to_i
39
+ return 0 if target <= 0
40
+
41
+ wins = results.wins.to_i
42
+ losses = results.losses.to_i
43
+ total = wins + losses
44
+ return 0 if total.zero?
45
+
46
+ numerator = (wins * 100) - (target * total)
47
+ return 0 if numerator.negative?
48
+
49
+ return 0 if numerator.zero? && target >= 100 && losses.positive?
50
+
51
+ return 1 if numerator.zero? && target >= 100
52
+
53
+ (numerator / target) + 1
54
+ end
55
+
56
+ # 総対局数(勝ち数 + 負け数)を返却
57
+ def total_games(results)
58
+ results.wins + results.losses
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hachiwari
4
+ # 計算済みの値をユーザー向けメッセージに整形して出力する責務を担う
5
+ class StatusPresenter
6
+ # 言語に応じたテンプレートへ値を埋め込み標準出力へ表示
7
+ def render(results, data)
8
+ template = status_template(results.language)
9
+ puts format(template[:summary], **data)
10
+
11
+ if data[:needed].positive?
12
+ puts format(template[:needed], **data)
13
+ elsif data[:losses_to_fall].positive?
14
+ puts format(template[:cushion], **data)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ # 指定ロケールのテンプレートを取得し、失敗時は日本語へフォールバック
21
+ def status_template(locale)
22
+ Hachiwari::Locales.t(locale, :status)
23
+ rescue KeyError
24
+ Hachiwari::Locales.t(:ja, :status)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "results"
4
+ require_relative "status_calculator"
5
+ require_relative "status_presenter"
6
+ require_relative "storage"
7
+
8
+ # 状態の読み書き・計算・表示を束ねる調停役
9
+ module Hachiwari
10
+ class StatusRunner
11
+ # 依存するコンポーネントを DI で受け取り、テストしやすくする
12
+ def initialize(storage: Storage.new, calculator: StatusCalculator.new, presenter: StatusPresenter.new)
13
+ @storage = storage
14
+ @calculator = calculator
15
+ @presenter = presenter
16
+ end
17
+
18
+ # 引数から `Results` を生成し、保存フラグに応じて永続化と表示を行う
19
+ def call(input, save:)
20
+ # レガシーデータが残っている環境でも、試算モード含め常に最新形式へ移行
21
+ storage.send(:migrate_legacy_if_needed) if storage.respond_to?(:send)
22
+ data = normalize_input(input)
23
+ base = storage.load
24
+ results = merge_results(base, data)
25
+ storage.save(results) if save
26
+ presenter.render(results, formatted_data(results))
27
+ results
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :storage, :calculator, :presenter
33
+
34
+ # 受け取った Hash のキーをシンボル化して扱いやすくする
35
+ def normalize_input(input)
36
+ input.transform_keys { |key| key.to_sym rescue key } # rubocop:disable Style/RescueModifier
37
+ end
38
+
39
+ # 既存結果と入力値をマージして新しい `Results` を生成
40
+ def merge_results(base, input)
41
+ Results.new(
42
+ resolve_integer(input[:wins], base.wins),
43
+ resolve_integer(input[:losses], base.losses),
44
+ resolve_integer(input[:target], base.target),
45
+ resolve_language(input[:language], base.language)
46
+ )
47
+ end
48
+
49
+ # 数値系入力の正規化(空入力は既存値を維持)
50
+ def resolve_integer(value, fallback)
51
+ return fallback if value.nil?
52
+
53
+ string = value.to_s.strip
54
+ return fallback if string.empty?
55
+
56
+ string.to_i
57
+ end
58
+
59
+ # 言語指定の正規化(空入力は既存値を維持)
60
+ def resolve_language(value, fallback)
61
+ return fallback if value.nil?
62
+
63
+ string = value.to_s.strip
64
+ return fallback if string.empty?
65
+
66
+ string.to_sym
67
+ end
68
+
69
+ # 表示用の Hash を生成し、プレゼンターへ渡す
70
+ def formatted_data(results)
71
+ {
72
+ total: calculator.total_games(results),
73
+ wins: results.wins,
74
+ losses: results.losses,
75
+ percentage: calculator.winning_percentage(results),
76
+ needed: calculator.required_wins(results),
77
+ losses_to_fall: calculator.losses_until_below_target(results),
78
+ target: results.target.to_i
79
+ }
80
+ end
81
+ end
82
+ end