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.
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-configurable"
4
+
5
+ module Fasti
6
+ # Configuration management using dry-configurable
7
+ #
8
+ # Provides a clean interface for managing fasti configuration
9
+ # with type safety and validation.
10
+ #
11
+ # @example Basic usage
12
+ # Fasti.configure do |config|
13
+ # config.format = :quarter
14
+ # config.country = :jp
15
+ # end
16
+ #
17
+ # @example With styling
18
+ # Fasti.configure do |config|
19
+ # config.style = {
20
+ # sunday: { foreground: :red, bold: true },
21
+ # holiday: { background: :yellow }
22
+ # }
23
+ # end
24
+ class Config
25
+ extend Dry::Configurable
26
+
27
+ # Calendar display format
28
+ setting :format, default: :month, constructor: Types::Format
29
+
30
+ # Week start day
31
+ setting :start_of_week, default: :sunday, constructor: Types::StartOfWeek
32
+
33
+ # Country code for holiday detection
34
+ setting :country, default: :us, constructor: Types::Country
35
+
36
+ # Style configuration
37
+ # Accepts a hash mapping style targets to their attributes
38
+ # @param value [Hash<Symbol|String, Hash>] Style configuration hash
39
+ # @return [Hash<Symbol, Hash>] Validated and normalized style hash
40
+ setting :style, default: nil, constructor: ->(value) do
41
+ case value
42
+ when nil
43
+ nil
44
+ when Hash
45
+ validate_and_normalize_style(value)
46
+ else
47
+ raise ArgumentError, "Style must be nil or Hash, got #{value.class}"
48
+ end
49
+ end
50
+
51
+ # Validates and normalizes a style configuration hash
52
+ #
53
+ # @param style_hash [Hash] Raw style configuration
54
+ # @return [Hash<Symbol, Hash>] Validated and normalized style hash
55
+ # @raise [ArgumentError] If validation fails
56
+ def self.validate_and_normalize_style(style_hash)
57
+ validated_style = {}
58
+
59
+ style_hash.each do |target, attributes|
60
+ # Validate and convert target
61
+ target_sym = Types::StyleTarget.call(target)
62
+
63
+ # Validate attributes structure
64
+ unless attributes.is_a?(Hash)
65
+ raise ArgumentError, "Style attributes for #{target} must be a Hash, got #{attributes.class}"
66
+ end
67
+
68
+ # Validate individual attributes using schema
69
+ result = Schema::StyleAttribute.call(attributes)
70
+ if result.success?
71
+ validated_style[target_sym] = TIntMe[**result.to_h]
72
+ else
73
+ errors = result.errors.to_h.map {|key, messages|
74
+ "#{key}: #{Array(messages).join(", ")}"
75
+ }.join("; ")
76
+ raise ArgumentError, "Invalid style attributes for #{target}: #{errors}"
77
+ end
78
+ end
79
+
80
+ validated_style
81
+ end
82
+
83
+ # Reset configuration to defaults
84
+ def self.reset!
85
+ configure do |config|
86
+ config.format = :month
87
+ config.start_of_week = :sunday
88
+ config.country = :us
89
+ config.style = nil
90
+ end
91
+ end
92
+
93
+ # Load configuration from a Ruby file
94
+ #
95
+ # @param file_path [String] Path to configuration file
96
+ # @return [Hash] Configuration hash loaded from file
97
+ # @raise [ConfigError] If file has syntax errors
98
+ def self.load_from_file(file_path)
99
+ return {} unless File.exist?(file_path)
100
+
101
+ begin
102
+ # Execute the configuration file in the context of Fasti
103
+ instance_eval(File.read(file_path), file_path)
104
+ config.to_h
105
+ rescue SyntaxError => e
106
+ raise ConfigError, "Invalid Ruby syntax in #{file_path}: #{e.message}"
107
+ rescue => e
108
+ raise ConfigError, "Error loading configuration from #{file_path}: #{e.message}"
109
+ end
110
+ end
111
+ end
112
+
113
+ # Configuration error
114
+ class ConfigError < StandardError; end
115
+
116
+ # Convenience method for configuration
117
+ def self.configure(&)
118
+ Config.configure(&)
119
+ end
120
+
121
+ # Access current configuration
122
+ def self.config
123
+ Config.config
124
+ end
125
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fasti
4
+ # Base error class for all Fasti-related errors
5
+ class Error < StandardError; end
6
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fasti
4
+ # Handles calendar formatting and display with color-coded output.
5
+ #
6
+ # This class provides various calendar display formats (month, quarter, year)
7
+ # with ANSI color coding for holidays, weekends, and the current date.
8
+ # Holiday detection is handled by the Calendar class using the holidays gem.
9
+ #
10
+ # ## Styling
11
+ # - **Holidays**: Bold text
12
+ # - **Sundays**: Bold text
13
+ # - **Today**: Inverted background/text colors (combined with above styles)
14
+ #
15
+ # @example Basic month formatting
16
+ # formatter = Formatter.new
17
+ # calendar = Calendar.new(2024, 6, country: :jp)
18
+ # puts formatter.format_month(calendar)
19
+ #
20
+ # @example Year view
21
+ # formatter = Formatter.new
22
+ # puts formatter.format_year(2024, start_of_week: :sunday, country: :jp)
23
+ class Formatter
24
+ # Creates a new formatter instance.
25
+ #
26
+ # @param styles [Hash<Symbol, Style>] Styles for different day types
27
+ # Keys can be :sunday, :monday, ..., :saturday, :holiday, :today
28
+ # @example
29
+ # Formatter.new(styles: config.style)
30
+ def initialize(styles: {})
31
+ @styles = styles
32
+ @style_cache = {}
33
+ end
34
+
35
+ # Formats a single month calendar with headers and color coding.
36
+ #
37
+ # Displays the month/year header, day abbreviations, and calendar grid
38
+ # with appropriate color coding for holidays, weekends, and today.
39
+ #
40
+ # @param calendar [Calendar] The calendar to format
41
+ # @return [String] Formatted calendar string with ANSI color codes
42
+ #
43
+ # @example
44
+ # calendar = Calendar.new(2024, 6, country: :jp)
45
+ # formatter.format_month(calendar)
46
+ # # Output:
47
+ # # June 2024
48
+ # #
49
+ # # Su Mo Tu We Th Fr Sa
50
+ # # 1
51
+ # # 2 3 4 5 6 7 8
52
+ # # ...
53
+ def format_month(calendar)
54
+ output = []
55
+
56
+ # Month/Year header - centered
57
+ header = calendar.month_year_header
58
+ output << header.center(20)
59
+ output << ""
60
+
61
+ # Day headers
62
+ output << calendar.day_headers.join(" ")
63
+
64
+ # Calendar grid
65
+ calendar.calendar_grid.each do |week|
66
+ week_str = week.map {|day|
67
+ format_day(day, calendar)
68
+ }.join(" ")
69
+ output << week_str
70
+ end
71
+
72
+ output.join("\n")
73
+ end
74
+
75
+ # Formats three calendars side-by-side in a quarter view.
76
+ #
77
+ # Displays three months horizontally with aligned headers and grids.
78
+ # Typically used for showing current month with adjacent months.
79
+ #
80
+ # @param calendars [Array<Calendar>] Array of exactly 3 calendars to display
81
+ # @return [String] Formatted quarter view string
82
+ # @raise [ArgumentError] If not exactly 3 calendars provided
83
+ #
84
+ # @example Quarter view
85
+ # calendars = [
86
+ # Calendar.new(2024, 5, country: :jp),
87
+ # Calendar.new(2024, 6, country: :jp),
88
+ # Calendar.new(2024, 7, country: :jp)
89
+ # ]
90
+ # formatter.format_quarter(calendars)
91
+ def format_quarter(calendars)
92
+ raise ArgumentError, "Expected 3 calendars for quarter view" unless calendars.length == 3
93
+
94
+ output = []
95
+
96
+ # Headers for all three months
97
+ headers = calendars.map {|cal| cal.month_year_header.center(20) }
98
+ output << headers.join(" ")
99
+ output << ""
100
+
101
+ # Day headers for all three months
102
+ day_headers = calendars.map {|cal| cal.day_headers.join(" ") }
103
+ output << day_headers.join(" ")
104
+
105
+ # Calendar grids side by side
106
+ max_rows = calendars.map {|cal| cal.calendar_grid.length }.max
107
+
108
+ (0...max_rows).each do |row_index|
109
+ row_parts = calendars.map {|cal|
110
+ if row_index < cal.calendar_grid.length
111
+ week = cal.calendar_grid[row_index]
112
+ week.map {|day| format_day(day, cal) }.join(" ")
113
+ else
114
+ " " * 20 # Empty space for shorter months
115
+ end
116
+ }
117
+ output << row_parts.join(" ")
118
+ end
119
+
120
+ output.join("\n")
121
+ end
122
+
123
+ # Formats a complete year view with all 12 months in quarters.
124
+ #
125
+ # Displays the full year as 4 quarters, each containing 3 months
126
+ # side-by-side. Each quarter is separated by blank lines.
127
+ #
128
+ # @param year [Integer] The year to display
129
+ # @param start_of_week [Symbol] Week start preference (:sunday or :monday)
130
+ # @param country [String] Country code for holiday context
131
+ # @return [String] Formatted year view string
132
+ #
133
+ # @example Full year display
134
+ # formatter.format_year(2024, start_of_week: :sunday, country: :jp)
135
+ # # Displays all 12 months in 4 rows of 3 months each
136
+ def format_year(year, country:, start_of_week: :sunday)
137
+ output = []
138
+
139
+ # Year header
140
+ output << year.to_s.center(64)
141
+ output << ""
142
+
143
+ # Process 4 quarters (3 months each)
144
+ quarters = []
145
+ (1..12).each_slice(3) do |months|
146
+ calendars = months.map {|month| Calendar.new(year, month, start_of_week:, country:) }
147
+ quarters << format_quarter(calendars)
148
+ end
149
+
150
+ output << quarters.join("\n\n")
151
+ output.join("\n")
152
+ end
153
+
154
+ # Formats a single day with appropriate color coding.
155
+ #
156
+ # Applies ANSI styling based on the day's characteristics:
157
+ # - Today: Inverted colors (combined with other formatting)
158
+ # - Holidays: Bold text
159
+ # - Sundays: Bold text
160
+ # - Regular days: No special formatting
161
+ #
162
+ # @param day [Integer, nil] Day of the month (1-31) or nil for empty cells
163
+ # @param calendar [Calendar] Calendar context for date conversion
164
+ # @return [String] Formatted day string with ANSI codes, right-aligned to 2 characters
165
+ #
166
+ # @example
167
+ # format_day(15, calendar) #=> "15" (regular day)
168
+ # format_day(1, calendar) #=> styled " 1" with bold text (if Sunday/holiday)
169
+ # format_day(nil, calendar) #=> " " (empty cell)
170
+ # Determines applicable style targets for a given day
171
+ #
172
+ # @param day [Integer] Day of month
173
+ # @param calendar [Calendar] Calendar instance
174
+ # @return [Array<Symbol>] Sorted array of style target symbols
175
+ private def determine_style_targets(day, calendar)
176
+ return [] unless day
177
+
178
+ date = calendar.to_date(day)
179
+ return [] unless date
180
+
181
+ targets = []
182
+
183
+ # Add weekday target
184
+ weekday_key = %i[sunday monday tuesday wednesday thursday friday saturday][date.wday]
185
+ targets << weekday_key if @styles.key?(weekday_key)
186
+
187
+ # Add holiday target
188
+ targets << :holiday if calendar.holiday?(day) && @styles.key?(:holiday)
189
+
190
+ # Add today target
191
+ targets << :today if date == Date.today && @styles.key?(:today)
192
+
193
+ targets.sort
194
+ end
195
+
196
+ # Gets or creates a composed style for the given targets
197
+ #
198
+ # @param targets [Array<Symbol>] Sorted array of style target symbols
199
+ # @return [TIntMe::Style, nil] Composed style object or nil if no styling needed
200
+ private def get_composed_style(targets)
201
+ return nil if targets.empty?
202
+
203
+ cache_key = targets.dup.freeze
204
+ return @style_cache[cache_key] if @style_cache.key?(cache_key)
205
+
206
+ # Compose styles in order using reduce without initial value
207
+ # This avoids unnecessary composition with DEFAULT_STYLE
208
+ composed_style = targets.map {|target| @styles[target] }.reduce(&:>>)
209
+
210
+ @style_cache[cache_key] = composed_style
211
+ composed_style
212
+ end
213
+
214
+ private def format_day(day, calendar)
215
+ return " " unless day
216
+
217
+ date = calendar.to_date(day)
218
+
219
+ # Handle calendar transition gaps - date might be nil
220
+ unless date
221
+ # For gap days, return empty space to show the gap visually
222
+ return " "
223
+ end
224
+
225
+ day_str = day.to_s.rjust(2)
226
+
227
+ # Determine applicable style targets and get composed style from cache
228
+ targets = determine_style_targets(day, calendar)
229
+ style = get_composed_style(targets)
230
+
231
+ style ? style.call(day_str) : day_str
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fasti
4
+ # Parses style definition strings into Style objects for calendar formatting.
5
+ #
6
+ # This class handles the parsing of style definition strings in the format:
7
+ # "target:attribute=value,attribute,no-attribute target:attribute=value"
8
+ #
9
+ # Supported targets:
10
+ # - Weekdays: sunday, monday, tuesday, wednesday, thursday, friday, saturday
11
+ # - Special days: holiday, today
12
+ #
13
+ # Supported attributes:
14
+ # - Colors: foreground=color, background=color
15
+ # - Text effects: bold, italic, underline, underline=double, overline, blink, inverse, hide, faint
16
+ # - Negation: no-bold, no-italic, etc.
17
+ #
18
+ # @example Basic usage
19
+ # parser = StyleParser.new
20
+ # styles = parser.parse("sunday:bold,foreground=red holiday:bold today:inverse")
21
+ # styles[:sunday] #=> Style.new(bold: true, foreground: :red)
22
+ #
23
+ # @example With negation
24
+ # styles = parser.parse("sunday:foreground=red,no-bold saturday:no-italic")
25
+ # styles[:sunday] #=> Style.new(foreground: :red, bold: false)
26
+ class StyleParser
27
+ # Valid target names that can be used in style definitions
28
+ VALID_TARGETS = %w[sunday monday tuesday wednesday thursday friday saturday holiday today].freeze
29
+ private_constant :VALID_TARGETS
30
+
31
+ # Valid color names for foreground and background
32
+ VALID_COLORS = %w[red green blue yellow magenta cyan white black default].freeze
33
+ private_constant :VALID_COLORS
34
+
35
+ # Valid boolean attributes that can be enabled/disabled
36
+ BOOLEAN_ATTRIBUTES = %w[bold faint italic overline blink inverse conceal].freeze
37
+ private_constant :BOOLEAN_ATTRIBUTES
38
+
39
+ # Special attributes that can have specific values
40
+ SPECIAL_ATTRIBUTES = {"underline" => [true, false, :double]}.freeze
41
+ private_constant :SPECIAL_ATTRIBUTES
42
+
43
+ # Parses a style definition string into a hash of Style objects.
44
+ #
45
+ # @param style_string [String] Style definition string
46
+ # @return [Hash<Symbol, Style>] Hash mapping target symbols to Style objects
47
+ # @raise [ArgumentError] If the style string contains invalid syntax or values
48
+ #
49
+ # @example
50
+ # parse("sunday:bold,foreground=red holiday:bold today:inverse")
51
+ # #=> { sunday: Style.new(...), holiday: Style.new(...), today: Style.new(...) }
52
+ def parse(style_string)
53
+ return {} if style_string.nil? || style_string.strip.empty?
54
+
55
+ style_string.strip.split(/\s+/).each_with_object({}) do |entry, styles|
56
+ target, attributes_hash = parse_entry(entry)
57
+ styles[target] = create_style(attributes_hash)
58
+ end
59
+ end
60
+
61
+ # Parses a single style entry in the format "target:attributes"
62
+ #
63
+ # @param entry [String] Single style entry
64
+ # @return [Array<Symbol, Hash>] Target symbol and attributes hash
65
+ # @raise [ArgumentError] If entry format is invalid
66
+ private def parse_entry(entry)
67
+ # Entry should not contain whitespace since it comes from split(/\s+/)
68
+ if entry.match?(/\s/)
69
+ raise ArgumentError, "Style entry should not contain whitespace: '#{entry}'"
70
+ end
71
+
72
+ parts = entry.split(":", 2)
73
+ raise ArgumentError, "Invalid style entry format: '#{entry}'" if parts.length != 2
74
+
75
+ target = parts[0]
76
+ attributes_str = parts[1]
77
+
78
+ raise ArgumentError, "Invalid target: '#{target}'" unless VALID_TARGETS.include?(target)
79
+
80
+ attributes = parse_attributes(attributes_str)
81
+ [target.to_sym, attributes]
82
+ end
83
+
84
+ # Parses attribute string into a hash of attribute names and values
85
+ #
86
+ # @param attributes_str [String] Comma-separated attribute string
87
+ # @return [Hash<String, Object>] Hash of attribute names to values
88
+ private def parse_attributes(attributes_str)
89
+ attributes_str.split(",").each_with_object({}) do |attr, attributes|
90
+ attr = attr.strip
91
+ next if attr.empty?
92
+
93
+ case attr
94
+ when /\A(?!no-)(.+)=(.+)\z/
95
+ # Handle key=value format
96
+ key = $1
97
+ value = $2
98
+
99
+ # Reject keys or values with whitespace
100
+ if key != key.strip || value != value.strip
101
+ raise ArgumentError, "Attribute keys and values cannot contain leading or trailing spaces: '#{attr}'"
102
+ end
103
+
104
+ attributes[key] = parse_attribute_value(key, value)
105
+ when /\Ano-(.+)\z/
106
+ # Handle no- prefix (negation)
107
+ attribute_name = $1
108
+ validate_boolean_attribute(attribute_name)
109
+ attributes[attribute_name] = false
110
+ else
111
+ # Handle simple boolean attributes (bold, italic, etc.)
112
+ validate_boolean_attribute(attr)
113
+ attributes[attr] = true
114
+ end
115
+ end
116
+ end
117
+
118
+ # Parses an attribute value based on the attribute key
119
+ #
120
+ # @param key [String] Attribute name
121
+ # @param value [String] Attribute value
122
+ # @return [Object] Parsed value (Symbol, Boolean, etc.)
123
+ # @raise [ArgumentError] If the key or value is invalid
124
+ private def parse_attribute_value(key, value)
125
+ case key
126
+ when "foreground", "background"
127
+ parse_color_value(value)
128
+ when "underline"
129
+ parse_underline_value(value)
130
+ when *BOOLEAN_ATTRIBUTES
131
+ raise ArgumentError, "Boolean attributes should not use '=' syntax. Use '#{key}' or 'no-#{key}' instead"
132
+ else
133
+ raise ArgumentError, "Unknown attribute: '#{key}'"
134
+ end
135
+ end
136
+
137
+ # Parses a color value (color name or hex code)
138
+ #
139
+ # @param value [String] Color value
140
+ # @return [Symbol, String] Color as symbol or hex string
141
+ # @raise [ArgumentError] If color is invalid
142
+ private def parse_color_value(value)
143
+ # Check if it's a hex color (with or without #)
144
+ if value.match?(/\A#?\h{3}(?:\h{3})?\z/)
145
+ parse_hex_color(value)
146
+ elsif VALID_COLORS.include?(value)
147
+ value.to_sym
148
+ else
149
+ raise ArgumentError, "Invalid color: '#{value}'. Valid colors: #{VALID_COLORS.join(", ")}, or hex colors " \
150
+ "(#RGB, #RRGGBB, RGB, RRGGBB)"
151
+ end
152
+ end
153
+
154
+ # Parses and normalizes hex color values
155
+ #
156
+ # @param value [String] Hex color value (with or without #, 3 or 6 digits)
157
+ # @return [String] Normalized 6-digit hex color with #
158
+ # @raise [ArgumentError] If hex color format is invalid
159
+ private def parse_hex_color(value)
160
+ # Remove # if present
161
+ hex_part = value.start_with?("#") ? value[1..] : value
162
+
163
+ case hex_part.length
164
+ when 3
165
+ # Expand 3-digit to 6-digit (F00 -> FF0000)
166
+ expanded = hex_part.chars.map {|c| c + c }.join
167
+ "##{expanded.upcase}"
168
+ when 6
169
+ "##{hex_part.upcase}"
170
+ else
171
+ raise ArgumentError, "Invalid hex color: '#{value}'. Use 3-digit (#RGB) or 6-digit (#RRGGBB) format"
172
+ end
173
+ end
174
+
175
+ # Parses underline attribute value
176
+ #
177
+ # @param value [String] Underline value
178
+ # @return [Symbol] Parsed underline value
179
+ # @raise [ArgumentError] If underline value is invalid
180
+ private def parse_underline_value(value)
181
+ case value
182
+ when "double"
183
+ :double
184
+ else
185
+ raise ArgumentError, "Invalid underline value: '#{value}'. Valid values: double"
186
+ end
187
+ end
188
+
189
+ # Validates that an attribute is a valid boolean attribute
190
+ #
191
+ # @param attribute [String] Attribute name to validate
192
+ # @raise [ArgumentError] If attribute is not a valid boolean attribute
193
+ private def validate_boolean_attribute(attribute)
194
+ valid_attributes = BOOLEAN_ATTRIBUTES + ["underline"]
195
+ return if valid_attributes.include?(attribute)
196
+
197
+ raise ArgumentError,
198
+ "Invalid boolean attribute: '#{attribute}'. Valid attributes: #{valid_attributes.join(", ")}"
199
+ end
200
+
201
+ # Creates a Style object from parsed attributes
202
+ #
203
+ # @param attributes [Hash<String, Object>] Hash of attribute names to values
204
+ # @return [Style] New Style object
205
+ private def create_style(attributes)
206
+ style_params = attributes.transform_keys(&:to_sym)
207
+
208
+ TIntMe[**style_params]
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fasti
4
+ VERSION = "1.0.0"
5
+ public_constant :VERSION
6
+ end
data/lib/fasti.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tint_me"
4
+ require "zeitwerk"
5
+
6
+ # Manually require version for gemspec compatibility (ignored by Zeitwerk)
7
+ require_relative "fasti/version"
8
+
9
+ # Fasti - Flexible calendar application with multi-country holiday support
10
+ #
11
+ # Main namespace containing all Fasti components including configuration,
12
+ # calendar logic, formatting, and CLI interface.
13
+ module Fasti
14
+ # Setup Zeitwerk autoloader
15
+ loader = Zeitwerk::Loader.for_gem
16
+ # VERSION constant doesn't follow Zeitwerk naming conventions, so ignore it
17
+ loader.ignore("#{__dir__}/fasti/version.rb")
18
+ # CLI acronym inflection - cli.rb defines CLI constant, not Cli
19
+ loader.inflector.inflect("cli" => "CLI")
20
+ loader.setup
21
+ end
data/mise.toml ADDED
@@ -0,0 +1,5 @@
1
+ [tools]
2
+ ruby = "3.2"
3
+
4
+ [env]
5
+ _.path = ["bin", "exe"]
data/sig/fasti.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Fasti
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end