fasti 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.
- checksums.yaml +7 -0
- data/.mcp.json +19 -0
- data/.rspec +3 -0
- data/.rubocop.yml +82 -0
- data/.rubocop_todo.yml +89 -0
- data/.serena/project.yml +68 -0
- data/.simplecov +31 -0
- data/.yardopts +9 -0
- data/AGENTS.md +60 -0
- data/CHANGELOG.md +25 -0
- data/CLAUDE.md +1 -0
- data/LICENSE.txt +21 -0
- data/README.md +416 -0
- data/RELEASING.md +202 -0
- data/Rakefile +34 -0
- data/TODO.md +11 -0
- data/benchmark/holiday_cache_benchmark.rb +111 -0
- data/benchmark/memory_benchmark.rb +86 -0
- data/docs/agents/git-pr.md +298 -0
- data/docs/agents/languages.md +388 -0
- data/docs/agents/rubocop.md +55 -0
- data/docs/plans/positional-arguments.md +303 -0
- data/docs/plans/structured-config.md +232 -0
- data/examples/config.rb +80 -0
- data/exe/fasti +6 -0
- data/lib/fasti/calendar.rb +292 -0
- data/lib/fasti/calendar_transition.rb +266 -0
- data/lib/fasti/cli.rb +550 -0
- data/lib/fasti/config/schema.rb +36 -0
- data/lib/fasti/config/types.rb +66 -0
- data/lib/fasti/config.rb +125 -0
- data/lib/fasti/error.rb +6 -0
- data/lib/fasti/formatter.rb +234 -0
- data/lib/fasti/style_parser.rb +211 -0
- data/lib/fasti/version.rb +6 -0
- data/lib/fasti.rb +21 -0
- data/mise.toml +5 -0
- data/sig/fasti.rbs +4 -0
- metadata +181 -0
data/lib/fasti/cli.rb
ADDED
@@ -0,0 +1,550 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "locale"
|
4
|
+
require "optparse"
|
5
|
+
require "pathname"
|
6
|
+
|
7
|
+
module Fasti
|
8
|
+
# Immutable data structure for CLI options
|
9
|
+
Options = Data.define(:format, :start_of_week, :country, :style, :config)
|
10
|
+
|
11
|
+
# Command-line interface for the fasti calendar application.
|
12
|
+
#
|
13
|
+
# This class handles all CLI functionality including option parsing, validation,
|
14
|
+
# and calendar generation. It supports various display formats and customization
|
15
|
+
# options while maintaining backwards compatibility.
|
16
|
+
#
|
17
|
+
# ## Usage
|
18
|
+
# Positional arguments for month/year specification:
|
19
|
+
# - 0 args: current month + current year
|
20
|
+
# - 1 arg: 1-12 → month + current year, 13+ → current month + year
|
21
|
+
# - 2 args: month year (first arg = month 1-12, second arg = year)
|
22
|
+
#
|
23
|
+
# ## Options
|
24
|
+
# - `--format, -f`: Display format (month/quarter/year, default: month)
|
25
|
+
# - `--start-of-week, -w`: Week start (sunday/monday, default: sunday)
|
26
|
+
# - `--country, -c`: Country code for holidays (auto-detected from LANG/LC_ALL, supports many countries)
|
27
|
+
# - `--style, -s`: Custom styling for different day types (e.g., "sunday:bold holiday:foreground=red")
|
28
|
+
# - `--version, -v`: Show version information
|
29
|
+
# - `--help, -h`: Show help message
|
30
|
+
#
|
31
|
+
# ## Configuration File
|
32
|
+
# Default options can be specified in a configuration file:
|
33
|
+
# - Path: `$XDG_CONFIG_HOME/fasti/config.rb` (or `$HOME/.config/fasti/config.rb` if XDG_CONFIG_HOME is unset)
|
34
|
+
# - Format: Ruby DSL using Fasti.configure block
|
35
|
+
# - Precedence: Command line options override config file options
|
36
|
+
#
|
37
|
+
# @example Basic usage
|
38
|
+
# CLI.run(["6", "2024"]) # June 2024
|
39
|
+
#
|
40
|
+
# @example Month only
|
41
|
+
# CLI.run(["6"]) # June current year
|
42
|
+
#
|
43
|
+
# @example Year only
|
44
|
+
# CLI.run(["2024"]) # Current month 2024
|
45
|
+
#
|
46
|
+
# @example Year view
|
47
|
+
# CLI.run(["2024", "--format", "year", "--country", "US"])
|
48
|
+
#
|
49
|
+
# @example Config file content ($HOME/.config/fasti/config.rb)
|
50
|
+
# Fasti.configure do |config|
|
51
|
+
# config.format = :quarter
|
52
|
+
# config.start_of_week = :monday
|
53
|
+
# config.country = :US
|
54
|
+
# end
|
55
|
+
class CLI
|
56
|
+
# Non-country locales that should be skipped in country detection
|
57
|
+
NON_COUNTRY_LOCALES = %w[C POSIX].freeze
|
58
|
+
private_constant :NON_COUNTRY_LOCALES
|
59
|
+
|
60
|
+
# General configuration attributes (non-style attributes)
|
61
|
+
GENERAL_ATTRIBUTES = %i[format start_of_week country config].freeze
|
62
|
+
private_constant :GENERAL_ATTRIBUTES
|
63
|
+
|
64
|
+
# Runs the CLI with the specified arguments.
|
65
|
+
#
|
66
|
+
# @param argv [Array<String>] Command line arguments to parse
|
67
|
+
def self.run(argv)
|
68
|
+
new.run(argv)
|
69
|
+
end
|
70
|
+
|
71
|
+
# Runs the CLI instance with the specified arguments.
|
72
|
+
#
|
73
|
+
# @param argv [Array<String>] Command line arguments to parse
|
74
|
+
def run(argv)
|
75
|
+
@current_time = Time.now # Single source of truth for time
|
76
|
+
catch(:early_exit) do
|
77
|
+
month, year, options = parse_arguments(argv)
|
78
|
+
generate_calendar(month, year, options)
|
79
|
+
end
|
80
|
+
rescue => e
|
81
|
+
handle_error(e)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Parses all command line arguments (positional + options).
|
85
|
+
#
|
86
|
+
# @param argv [Array<String>] Arguments to parse
|
87
|
+
# @return [Array<Integer, Integer, Options>] Month, year, and parsed options
|
88
|
+
private def parse_arguments(argv)
|
89
|
+
options = parse_options(argv)
|
90
|
+
month, year = parse_positional_args(argv)
|
91
|
+
[month, year, options]
|
92
|
+
end
|
93
|
+
|
94
|
+
# Parses CLI options, merges with config/defaults, and validates.
|
95
|
+
#
|
96
|
+
# @param argv [Array<String>] Arguments to parse (destructively modified)
|
97
|
+
# @return [Options] Final validated options object
|
98
|
+
private def parse_options(argv)
|
99
|
+
# 1. Parse CLI arguments into separate hash
|
100
|
+
cli_options_hash = {}
|
101
|
+
parser = create_option_parser(cli_options_hash, include_help: true)
|
102
|
+
parser.parse!(argv) # Destructively modifies argv
|
103
|
+
|
104
|
+
# 2. Apply CLI option overrides to base options (Options + Hash → Options)
|
105
|
+
final_options = apply_cli_overrides(base_options(cli_options_hash), cli_options_hash)
|
106
|
+
|
107
|
+
# 3. Validate required options
|
108
|
+
unless final_options.country
|
109
|
+
raise ArgumentError,
|
110
|
+
"Country could not be determined. Use --country with a country code or set LANG/LC_ALL environment variables"
|
111
|
+
end
|
112
|
+
|
113
|
+
final_options
|
114
|
+
end
|
115
|
+
|
116
|
+
# Returns base configuration (defaults + config file settings).
|
117
|
+
#
|
118
|
+
# @return [Options] Base options before CLI overrides
|
119
|
+
private def base_options(cli_options_hash)
|
120
|
+
defaults = base_default_values
|
121
|
+
config_options = load_config_options(cli_options_hash)
|
122
|
+
merge_defaults_with_config(defaults, config_options)
|
123
|
+
end
|
124
|
+
|
125
|
+
# Returns the base default option values.
|
126
|
+
#
|
127
|
+
# @return [Hash] Base default values
|
128
|
+
private def base_default_values
|
129
|
+
{
|
130
|
+
format: :month,
|
131
|
+
start_of_week: :sunday,
|
132
|
+
country: detect_country_from_environment,
|
133
|
+
style: nil,
|
134
|
+
config: nil # nil = use default config file
|
135
|
+
}
|
136
|
+
end
|
137
|
+
|
138
|
+
# Loads options from the config file if it exists.
|
139
|
+
#
|
140
|
+
# @return [Hash] Config options or empty hash if no config file
|
141
|
+
private def load_config_options(cli_options_hash)
|
142
|
+
config_option = cli_options_hash[:config]
|
143
|
+
|
144
|
+
case config_option
|
145
|
+
when false
|
146
|
+
# --no-config: Skip config file loading entirely
|
147
|
+
return {}
|
148
|
+
when String
|
149
|
+
# --config PATH: Use custom config file
|
150
|
+
config_file = Pathname.new(config_option)
|
151
|
+
# For custom config paths, error if file doesn't exist
|
152
|
+
unless config_file.exist?
|
153
|
+
raise ArgumentError, "Configuration file not found: #{config_file}"
|
154
|
+
end
|
155
|
+
else
|
156
|
+
# nil: Use default config file path
|
157
|
+
config_file = config_file_path
|
158
|
+
# For default path, silently skip if file doesn't exist
|
159
|
+
return {} unless config_file.exist?
|
160
|
+
end
|
161
|
+
|
162
|
+
begin
|
163
|
+
Config.load_from_file(config_file.to_s)
|
164
|
+
rescue => e
|
165
|
+
puts "Warning: Failed to load config file #{config_file}: #{e.message}"
|
166
|
+
{}
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
# Merges default values with config file options.
|
171
|
+
#
|
172
|
+
# @param defaults [Hash] Base default values
|
173
|
+
# @param config_options [Hash] Config file options
|
174
|
+
# @return [Options] Merged options object
|
175
|
+
private def merge_defaults_with_config(defaults, config_options)
|
176
|
+
# Merge general attributes (config overrides defaults)
|
177
|
+
merged_general = defaults.slice(*GENERAL_ATTRIBUTES)
|
178
|
+
.merge(config_options.slice(*GENERAL_ATTRIBUTES))
|
179
|
+
|
180
|
+
# Handle style separately (just use config style, no defaults)
|
181
|
+
merged_options = merged_general.merge(style: config_options[:style])
|
182
|
+
|
183
|
+
Options.new(**merged_options)
|
184
|
+
end
|
185
|
+
|
186
|
+
# Determines the config file path using XDG specification.
|
187
|
+
#
|
188
|
+
# @return [Pathname] Path to config file
|
189
|
+
private def config_file_path
|
190
|
+
config_home = ENV["XDG_CONFIG_HOME"] || (Pathname.new(Dir.home) / ".config")
|
191
|
+
Pathname.new(config_home) / "fasti" / "config.rb"
|
192
|
+
end
|
193
|
+
|
194
|
+
# Creates a shared OptionParser for both CLI and config file parsing.
|
195
|
+
#
|
196
|
+
# @param options [Hash] Hash to store parsed options
|
197
|
+
# @param include_help [Boolean] Whether to include help and version options
|
198
|
+
# @return [OptionParser] Configured option parser
|
199
|
+
private def create_option_parser(options, include_help:)
|
200
|
+
OptionParser.new do |opts|
|
201
|
+
# Register custom type converters
|
202
|
+
opts.accept(Symbol) {|value| value.to_sym }
|
203
|
+
opts.accept(:downcase_symbol) {|value| value.downcase.to_sym }
|
204
|
+
if include_help
|
205
|
+
opts.banner = "Usage: fasti [month] [year] [options]"
|
206
|
+
opts.separator ""
|
207
|
+
opts.separator "Arguments:"
|
208
|
+
opts.separator " month Month (1-12, optional)"
|
209
|
+
opts.separator " year Year (optional)"
|
210
|
+
opts.separator ""
|
211
|
+
opts.separator "Format options:"
|
212
|
+
end
|
213
|
+
|
214
|
+
opts.on(
|
215
|
+
"-f",
|
216
|
+
"--format FORMAT",
|
217
|
+
%w[month quarter year],
|
218
|
+
Symbol,
|
219
|
+
"Output format (month, quarter, year)"
|
220
|
+
) do |format|
|
221
|
+
options[:format] = format
|
222
|
+
end
|
223
|
+
|
224
|
+
opts.on(
|
225
|
+
"-m",
|
226
|
+
"--month",
|
227
|
+
"Display month format (equivalent to --format month)"
|
228
|
+
) do
|
229
|
+
options[:format] = :month
|
230
|
+
end
|
231
|
+
|
232
|
+
opts.on(
|
233
|
+
"-q",
|
234
|
+
"--quarter",
|
235
|
+
"Display quarter format (equivalent to --format quarter)"
|
236
|
+
) do
|
237
|
+
options[:format] = :quarter
|
238
|
+
end
|
239
|
+
|
240
|
+
opts.on(
|
241
|
+
"-y",
|
242
|
+
"--year",
|
243
|
+
"Display year format (equivalent to --format year)"
|
244
|
+
) do
|
245
|
+
options[:format] = :year
|
246
|
+
end
|
247
|
+
|
248
|
+
if include_help
|
249
|
+
opts.separator ""
|
250
|
+
opts.separator "Calendar display options:"
|
251
|
+
end
|
252
|
+
|
253
|
+
opts.on(
|
254
|
+
"-w",
|
255
|
+
"--start-of-week WEEKDAY",
|
256
|
+
%w[sunday monday tuesday wednesday thursday friday saturday],
|
257
|
+
Symbol,
|
258
|
+
"Week start day (sunday, monday, tuesday, wednesday, thursday, friday, saturday)"
|
259
|
+
) do |weekday|
|
260
|
+
options[:start_of_week] = weekday
|
261
|
+
end
|
262
|
+
|
263
|
+
opts.on(
|
264
|
+
"-c",
|
265
|
+
"--country COUNTRY",
|
266
|
+
:downcase_symbol,
|
267
|
+
"Country code for holidays (e.g., JP, US, GB, DE)"
|
268
|
+
) do |country|
|
269
|
+
options[:country] = country
|
270
|
+
end
|
271
|
+
|
272
|
+
opts.on(
|
273
|
+
"-s",
|
274
|
+
"--style STYLE",
|
275
|
+
String,
|
276
|
+
"Custom styling (e.g., \"sunday:bold holiday:foreground=red today:inverse\")"
|
277
|
+
) do |style|
|
278
|
+
# Parse style string immediately to Hash format
|
279
|
+
options[:style] = StyleParser.new.parse(style)
|
280
|
+
end
|
281
|
+
|
282
|
+
if include_help
|
283
|
+
opts.separator ""
|
284
|
+
opts.separator "Configuration options:"
|
285
|
+
end
|
286
|
+
|
287
|
+
opts.on(
|
288
|
+
"--config CONFIG_PATH",
|
289
|
+
String,
|
290
|
+
"Specify custom configuration file path"
|
291
|
+
) do |config_path|
|
292
|
+
options[:config] = config_path
|
293
|
+
end
|
294
|
+
|
295
|
+
opts.on(
|
296
|
+
"--no-config",
|
297
|
+
"Skip configuration file loading (use defaults only)"
|
298
|
+
) do
|
299
|
+
options[:config] = false
|
300
|
+
end
|
301
|
+
|
302
|
+
if include_help
|
303
|
+
opts.separator ""
|
304
|
+
opts.separator "Other options:"
|
305
|
+
|
306
|
+
opts.on("-v", "--version", "Show version") do
|
307
|
+
puts Fasti::VERSION
|
308
|
+
throw :early_exit
|
309
|
+
end
|
310
|
+
|
311
|
+
opts.on("-h", "--help", "Show this help") do
|
312
|
+
puts opts
|
313
|
+
throw :early_exit
|
314
|
+
end
|
315
|
+
end
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
# Generates and displays the calendar based on parsed options.
|
320
|
+
#
|
321
|
+
# @param options [Options] Parsed options
|
322
|
+
private def generate_calendar(month, year, options)
|
323
|
+
styles = options.style || {}
|
324
|
+
|
325
|
+
formatter = Formatter.new(styles:)
|
326
|
+
start_of_week = options.start_of_week
|
327
|
+
country = options.country
|
328
|
+
output = case options.format
|
329
|
+
when :month
|
330
|
+
generate_month_calendar(month, year, country, formatter, start_of_week)
|
331
|
+
when :quarter
|
332
|
+
generate_quarter_calendar(month, year, country, formatter, start_of_week)
|
333
|
+
when :year
|
334
|
+
generate_year_calendar(month, year, country, formatter, start_of_week)
|
335
|
+
else
|
336
|
+
raise ArgumentError, "Unknown format: #{options.format}"
|
337
|
+
end
|
338
|
+
|
339
|
+
puts output
|
340
|
+
end
|
341
|
+
|
342
|
+
# Generates a single month calendar.
|
343
|
+
#
|
344
|
+
# @param options [Options] Parsed options
|
345
|
+
# @param formatter [Formatter] Calendar formatter
|
346
|
+
# @param start_of_week [Symbol] Week start preference
|
347
|
+
# @return [String] Formatted calendar
|
348
|
+
private def generate_month_calendar(month, year, country, formatter, start_of_week)
|
349
|
+
calendar = Calendar.new(
|
350
|
+
year,
|
351
|
+
month,
|
352
|
+
start_of_week:,
|
353
|
+
country:
|
354
|
+
)
|
355
|
+
formatter.format_month(calendar)
|
356
|
+
end
|
357
|
+
|
358
|
+
# Generates a quarter view calendar (3 months).
|
359
|
+
#
|
360
|
+
# @param options [Options] Parsed options
|
361
|
+
# @param formatter [Formatter] Calendar formatter
|
362
|
+
# @param start_of_week [Symbol] Week start preference
|
363
|
+
# @return [String] Formatted quarter calendar
|
364
|
+
private def generate_quarter_calendar(month, year, country, formatter, start_of_week)
|
365
|
+
base_month = month
|
366
|
+
|
367
|
+
months = [(base_month - 1), base_month, (base_month + 1)].map {|m|
|
368
|
+
if m < 1
|
369
|
+
[year - 1, 12]
|
370
|
+
elsif m > 12
|
371
|
+
[year + 1, 1]
|
372
|
+
else
|
373
|
+
[year, m]
|
374
|
+
end
|
375
|
+
}
|
376
|
+
|
377
|
+
calendars = months.map {|y, m|
|
378
|
+
Calendar.new(y, m, start_of_week:, country:)
|
379
|
+
}
|
380
|
+
|
381
|
+
formatter.format_quarter(calendars)
|
382
|
+
end
|
383
|
+
|
384
|
+
# Generates a full year calendar.
|
385
|
+
#
|
386
|
+
# @param options [Options] Parsed options
|
387
|
+
# @param formatter [Formatter] Calendar formatter
|
388
|
+
# @param start_of_week [Symbol] Week start preference
|
389
|
+
# @return [String] Formatted year calendar
|
390
|
+
private def generate_year_calendar(_month, year, country, formatter, start_of_week)
|
391
|
+
formatter.format_year(
|
392
|
+
year,
|
393
|
+
country:,
|
394
|
+
start_of_week:
|
395
|
+
)
|
396
|
+
end
|
397
|
+
|
398
|
+
# Validates month parameter.
|
399
|
+
#
|
400
|
+
# @param month [Integer] Month to validate
|
401
|
+
# @raise [ArgumentError] If month is invalid
|
402
|
+
private def validate_month!(month)
|
403
|
+
raise ArgumentError, "Month must be between 1 and 12" unless (1..12).cover?(month)
|
404
|
+
end
|
405
|
+
|
406
|
+
# Validates year parameter.
|
407
|
+
#
|
408
|
+
# @param year [Integer] Year to validate
|
409
|
+
# @raise [ArgumentError] If year is invalid
|
410
|
+
private def validate_year!(year)
|
411
|
+
raise ArgumentError, "Year must be positive" unless year.positive?
|
412
|
+
end
|
413
|
+
|
414
|
+
# Parse positional arguments for month and year specification
|
415
|
+
private def parse_positional_args(argv)
|
416
|
+
case argv.length
|
417
|
+
when 0
|
418
|
+
# Use current month and year from instance variable
|
419
|
+
[@current_time.month, @current_time.year]
|
420
|
+
when 1
|
421
|
+
interpret_single_argument(argv[0])
|
422
|
+
when 2
|
423
|
+
validate_two_arguments(argv[0], argv[1])
|
424
|
+
else
|
425
|
+
raise ArgumentError, "Too many arguments. Expected 0-2, got #{argv.length}"
|
426
|
+
end
|
427
|
+
end
|
428
|
+
|
429
|
+
# Single argument interpretation
|
430
|
+
private def interpret_single_argument(arg)
|
431
|
+
begin
|
432
|
+
value = Integer(arg, 10)
|
433
|
+
rescue ArgumentError
|
434
|
+
raise ArgumentError, "Invalid argument: '#{arg}'. Expected integer."
|
435
|
+
end
|
436
|
+
|
437
|
+
if (1..12).cover?(value)
|
438
|
+
[value, @current_time.year] # Return [month, current_year]
|
439
|
+
elsif value >= 13
|
440
|
+
[@current_time.month, value] # Return [current_month, year]
|
441
|
+
else
|
442
|
+
raise ArgumentError, "Invalid argument: #{value}. Expected 1-12 (month) or 13+ (year)."
|
443
|
+
end
|
444
|
+
end
|
445
|
+
|
446
|
+
# Two argument validation
|
447
|
+
private def validate_two_arguments(month_arg, year_arg)
|
448
|
+
begin
|
449
|
+
month = Integer(month_arg, 10)
|
450
|
+
rescue ArgumentError
|
451
|
+
raise ArgumentError, "Invalid month: '#{month_arg}'"
|
452
|
+
end
|
453
|
+
|
454
|
+
begin
|
455
|
+
year = Integer(year_arg, 10)
|
456
|
+
rescue ArgumentError
|
457
|
+
raise ArgumentError, "Invalid year: '#{year_arg}'"
|
458
|
+
end
|
459
|
+
|
460
|
+
validate_month!(month)
|
461
|
+
validate_year!(year)
|
462
|
+
|
463
|
+
[month, year] # Return [month, year]
|
464
|
+
end
|
465
|
+
|
466
|
+
# Detects country code from environment variables (LC_ALL, LANG only).
|
467
|
+
#
|
468
|
+
# Uses LC_ALL and LANG only as they represent the user's preferred locale.
|
469
|
+
# LC_MESSAGES and other LC_* variables are for specific categories and not
|
470
|
+
# appropriate for determining holiday context.
|
471
|
+
# Priority: LC_ALL > LANG
|
472
|
+
#
|
473
|
+
# @return [Symbol, nil] Country symbol (e.g., :jp, :us) or nil if not detected
|
474
|
+
#
|
475
|
+
# @example
|
476
|
+
# ENV["LC_ALL"] = "en_US.UTF-8" # -> :us
|
477
|
+
# ENV["LANG"] = "ja_JP.UTF-8" # -> :jp (if LC_ALL is unset)
|
478
|
+
private def detect_country_from_environment
|
479
|
+
env_vars = [ENV["LC_ALL"], ENV["LANG"]]
|
480
|
+
env_vars.compact!
|
481
|
+
env_vars.reject!(&:empty?)
|
482
|
+
|
483
|
+
env_vars.each do |var|
|
484
|
+
# Skip C and POSIX locales as they don't represent specific countries
|
485
|
+
next if NON_COUNTRY_LOCALES.include?(var.upcase)
|
486
|
+
|
487
|
+
begin
|
488
|
+
locale_part = var.split(".").first
|
489
|
+
parsed = Locale::Tag.parse(locale_part)
|
490
|
+
return parsed.country.downcase.to_sym if parsed.country && !parsed.country.empty?
|
491
|
+
rescue
|
492
|
+
next
|
493
|
+
end
|
494
|
+
end
|
495
|
+
|
496
|
+
nil
|
497
|
+
end
|
498
|
+
|
499
|
+
# Applies CLI option overrides to base options, handling style composition specially.
|
500
|
+
#
|
501
|
+
# @param base_options [Options] Base options (config + defaults)
|
502
|
+
# @param cli_overrides [Hash] CLI option overrides
|
503
|
+
# @return [Options] Final options with CLI overrides applied
|
504
|
+
private def apply_cli_overrides(base_options, cli_overrides)
|
505
|
+
# Merge general attributes (CLI overrides base)
|
506
|
+
merged_general = base_options.to_h.slice(*GENERAL_ATTRIBUTES)
|
507
|
+
.merge(cli_overrides.slice(*GENERAL_ATTRIBUTES))
|
508
|
+
|
509
|
+
# Compose styles specially (not simple override)
|
510
|
+
merged_style = compose_styles(base_options.style, cli_overrides[:style])
|
511
|
+
|
512
|
+
# Create final options
|
513
|
+
Options.new(**merged_general, style: merged_style)
|
514
|
+
end
|
515
|
+
|
516
|
+
# Composes two style hashes using Style >> operator for same targets
|
517
|
+
#
|
518
|
+
# @param base_styles [Hash<Symbol, Style>, nil] Base style hash
|
519
|
+
# @param overlay_styles [Hash<Symbol, Style>, nil] Overlay style hash
|
520
|
+
# @return [Hash<Symbol, Style>] Composed style hash
|
521
|
+
private def compose_styles(base_styles, overlay_styles)
|
522
|
+
return overlay_styles if base_styles.nil?
|
523
|
+
return base_styles if overlay_styles.nil?
|
524
|
+
|
525
|
+
result = base_styles.dup
|
526
|
+
overlay_styles.each do |target, overlay_style|
|
527
|
+
result[target] = if result[target]
|
528
|
+
# Same target: compose using >> operator
|
529
|
+
result[target] >> overlay_style
|
530
|
+
else
|
531
|
+
# New target: add directly
|
532
|
+
overlay_style
|
533
|
+
end
|
534
|
+
end
|
535
|
+
|
536
|
+
result
|
537
|
+
end
|
538
|
+
|
539
|
+
# Handles errors that occur during CLI execution.
|
540
|
+
#
|
541
|
+
# This method is extracted to improve testability by allowing
|
542
|
+
# error handling to be mocked or stubbed during testing.
|
543
|
+
#
|
544
|
+
# @param error [Exception] The error that occurred
|
545
|
+
private def handle_error(error)
|
546
|
+
puts "Error: #{error.message}"
|
547
|
+
exit 1
|
548
|
+
end
|
549
|
+
end
|
550
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry-schema"
|
4
|
+
|
5
|
+
module Fasti
|
6
|
+
class Config
|
7
|
+
# Configuration schema definitions using dry-schema
|
8
|
+
#
|
9
|
+
# Provides structured validation for configuration hashes.
|
10
|
+
module Schema
|
11
|
+
# Style attribute schema
|
12
|
+
# Defines the structure for individual style attributes like {bold: true, foreground: :red}
|
13
|
+
StyleAttribute = Dry::Schema.Params {
|
14
|
+
config.validate_keys = true # Strict mode: reject unknown keys
|
15
|
+
|
16
|
+
# Color attributes
|
17
|
+
optional(:foreground).maybe(Types::Color)
|
18
|
+
optional(:background).maybe(Types::Color)
|
19
|
+
|
20
|
+
# Boolean styling attributes - allow string coercion for config files
|
21
|
+
optional(:bold).maybe(Types::Params::Bool)
|
22
|
+
optional(:italic).maybe(Types::Params::Bool)
|
23
|
+
optional(:faint).maybe(Types::Params::Bool)
|
24
|
+
optional(:inverse).maybe(Types::Params::Bool)
|
25
|
+
optional(:blink).maybe(Types::Params::Bool)
|
26
|
+
optional(:conceal).maybe(Types::Params::Bool)
|
27
|
+
optional(:overline).maybe(Types::Params::Bool)
|
28
|
+
|
29
|
+
# Special underline attribute
|
30
|
+
optional(:underline).maybe(Types::UnderlineOption)
|
31
|
+
}
|
32
|
+
|
33
|
+
public_constant :StyleAttribute
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry-types"
|
4
|
+
require "tint_me"
|
5
|
+
|
6
|
+
module Fasti
|
7
|
+
class Config
|
8
|
+
# Configuration types using dry-types and TIntMe types
|
9
|
+
#
|
10
|
+
# Provides type definitions for configuration values with automatic
|
11
|
+
# coercion and validation. Style-related types are inherited from
|
12
|
+
# TIntMe for consistency and enhanced functionality.
|
13
|
+
module Types
|
14
|
+
include Dry.Types()
|
15
|
+
|
16
|
+
# Calendar display format
|
17
|
+
Format = Coercible::Symbol.constrained(
|
18
|
+
included_in: %i[month quarter year]
|
19
|
+
)
|
20
|
+
|
21
|
+
# Week start day
|
22
|
+
StartOfWeek = Coercible::Symbol.constrained(
|
23
|
+
included_in: %i[sunday monday tuesday wednesday thursday friday saturday]
|
24
|
+
)
|
25
|
+
|
26
|
+
# Country code for holiday detection
|
27
|
+
Country = Coercible::Symbol
|
28
|
+
|
29
|
+
# Style target names
|
30
|
+
StyleTarget = Coercible::Symbol.constrained(
|
31
|
+
included_in: %i[sunday monday tuesday wednesday thursday friday saturday holiday today]
|
32
|
+
)
|
33
|
+
|
34
|
+
# Color values - TIntMe's type with smart coercion
|
35
|
+
# Named colors: string -> symbol, hex colors: preserved as string
|
36
|
+
# Extract color names directly from TIntMe to ensure consistency
|
37
|
+
# TIntMe::Style::Types::Color = SymbolEnum | HexString, we need the left (Symbol) side
|
38
|
+
TINTME_COLOR_NAMES = TIntMe::Style::Types::Color.left.values.freeze
|
39
|
+
NamedColorCoercion = Coercible::Symbol.constrained(included_in: TINTME_COLOR_NAMES)
|
40
|
+
HexColorString = Coercible::String.constrained(format: /\A#?\h{3}(?:\h{3})?\z/)
|
41
|
+
Color = NamedColorCoercion | HexColorString
|
42
|
+
|
43
|
+
# Underline attribute value - compatible with TIntMe with coercion
|
44
|
+
# Extract special values from TIntMe UnderlineOption type
|
45
|
+
# TIntMe::Style::Types::UnderlineOption = Nil | True | False | Enum[:double, :reset]
|
46
|
+
# Navigate: UnderlineOption.right.right to get the Enum part
|
47
|
+
TINTME_UNDERLINE_SYMBOLS = TIntMe::Style::Types::UnderlineOption.right.right.values.freeze
|
48
|
+
UnderlineOption = Params::Bool.optional |
|
49
|
+
Coercible::Symbol.constrained(included_in: TINTME_UNDERLINE_SYMBOLS)
|
50
|
+
|
51
|
+
# Internal type constants (used only for composition)
|
52
|
+
private_constant :TINTME_COLOR_NAMES
|
53
|
+
private_constant :TINTME_UNDERLINE_SYMBOLS
|
54
|
+
private_constant :NamedColorCoercion
|
55
|
+
private_constant :HexColorString
|
56
|
+
|
57
|
+
# Make public type constants explicitly public
|
58
|
+
public_constant :Format
|
59
|
+
public_constant :StartOfWeek
|
60
|
+
public_constant :Country
|
61
|
+
public_constant :StyleTarget
|
62
|
+
public_constant :Color
|
63
|
+
public_constant :UnderlineOption
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|