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.
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