shared_tools 0.3.1 → 0.4.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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -16
  3. data/README.md +257 -262
  4. data/lib/shared_tools/browser_tool.rb +5 -0
  5. data/lib/shared_tools/calculator_tool.rb +4 -0
  6. data/lib/shared_tools/clipboard_tool.rb +4 -0
  7. data/lib/shared_tools/composite_analysis_tool.rb +4 -0
  8. data/lib/shared_tools/computer_tool.rb +5 -0
  9. data/lib/shared_tools/cron_tool.rb +4 -0
  10. data/lib/shared_tools/current_date_time_tool.rb +4 -0
  11. data/lib/shared_tools/data_science_kit.rb +4 -0
  12. data/lib/shared_tools/database.rb +4 -0
  13. data/lib/shared_tools/database_query_tool.rb +4 -0
  14. data/lib/shared_tools/database_tool.rb +5 -0
  15. data/lib/shared_tools/disk_tool.rb +5 -0
  16. data/lib/shared_tools/dns_tool.rb +4 -0
  17. data/lib/shared_tools/doc_tool.rb +5 -0
  18. data/lib/shared_tools/error_handling_tool.rb +4 -0
  19. data/lib/shared_tools/eval_tool.rb +5 -0
  20. data/lib/shared_tools/mcp/brave_search_client.rb +37 -0
  21. data/lib/shared_tools/mcp/chart_client.rb +32 -0
  22. data/lib/shared_tools/mcp/github_client.rb +38 -0
  23. data/lib/shared_tools/mcp/hugging_face_client.rb +43 -0
  24. data/lib/shared_tools/mcp/memory_client.rb +33 -0
  25. data/lib/shared_tools/mcp/notion_client.rb +40 -0
  26. data/lib/shared_tools/mcp/sequential_thinking_client.rb +33 -0
  27. data/lib/shared_tools/mcp/slack_client.rb +54 -0
  28. data/lib/shared_tools/mcp/streamable_http_patch.rb +42 -0
  29. data/lib/shared_tools/mcp/tavily_client.rb +41 -0
  30. data/lib/shared_tools/mcp.rb +45 -16
  31. data/lib/shared_tools/system_info_tool.rb +4 -0
  32. data/lib/shared_tools/tools/browser/base_tool.rb +8 -12
  33. data/lib/shared_tools/tools/browser/click_tool.rb +4 -2
  34. data/lib/shared_tools/tools/browser/ferrum_driver.rb +119 -0
  35. data/lib/shared_tools/tools/browser/inspect_tool.rb +4 -2
  36. data/lib/shared_tools/tools/browser/page_inspect_tool.rb +4 -2
  37. data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +19 -7
  38. data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +4 -2
  39. data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +4 -2
  40. data/lib/shared_tools/tools/browser/visit_tool.rb +4 -2
  41. data/lib/shared_tools/tools/browser.rb +31 -2
  42. data/lib/shared_tools/tools/browser_tool.rb +6 -0
  43. data/lib/shared_tools/tools/clipboard_tool.rb +69 -144
  44. data/lib/shared_tools/tools/composite_analysis_tool.rb +60 -4
  45. data/lib/shared_tools/tools/computer/mac_driver.rb +37 -4
  46. data/lib/shared_tools/tools/cron_tool.rb +237 -379
  47. data/lib/shared_tools/tools/current_date_time_tool.rb +54 -120
  48. data/lib/shared_tools/tools/data_science_kit.rb +63 -13
  49. data/lib/shared_tools/tools/dns_tool.rb +335 -269
  50. data/lib/shared_tools/tools/doc/docx_reader_tool.rb +107 -0
  51. data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +171 -0
  52. data/lib/shared_tools/tools/doc/text_reader_tool.rb +57 -0
  53. data/lib/shared_tools/tools/doc.rb +3 -0
  54. data/lib/shared_tools/tools/doc_tool.rb +101 -6
  55. data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
  56. data/lib/shared_tools/tools/enabler.rb +42 -0
  57. data/lib/shared_tools/tools/error_handling_tool.rb +3 -1
  58. data/lib/shared_tools/tools/notification/base_driver.rb +51 -0
  59. data/lib/shared_tools/tools/notification/linux_driver.rb +115 -0
  60. data/lib/shared_tools/tools/notification/mac_driver.rb +66 -0
  61. data/lib/shared_tools/tools/notification/null_driver.rb +29 -0
  62. data/lib/shared_tools/tools/notification.rb +12 -0
  63. data/lib/shared_tools/tools/notification_tool.rb +99 -0
  64. data/lib/shared_tools/tools/system_info_tool.rb +130 -343
  65. data/lib/shared_tools/tools/workflow_manager_tool.rb +32 -0
  66. data/lib/shared_tools/utilities.rb +193 -0
  67. data/lib/shared_tools/version.rb +1 -1
  68. data/lib/shared_tools/weather_tool.rb +4 -0
  69. data/lib/shared_tools/workflow_manager_tool.rb +4 -0
  70. data/lib/shared_tools.rb +28 -38
  71. metadata +74 -9
  72. data/lib/shared_tools/mcp/github_mcp_server.rb +0 -58
  73. data/lib/shared_tools/mcp/imcp.rb +0 -28
  74. data/lib/shared_tools/mcp/tavily_mcp_server.rb +0 -44
  75. data/lib/shared_tools/tools/devops_toolkit.rb +0 -420
@@ -1,473 +1,331 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ruby_llm/tool'
4
3
  require 'time'
4
+ require_relative '../../shared_tools'
5
5
 
6
6
  module SharedTools
7
7
  module Tools
8
- # A tool for parsing, validating, and explaining cron expressions.
9
- # Supports standard 5-field cron format (minute, hour, day of month, month, day of week).
8
+ # Parse, validate, explain, and generate cron expressions.
10
9
  #
11
10
  # @example
12
11
  # tool = SharedTools::Tools::CronTool.new
13
- # result = tool.execute(action: 'parse', expression: '0 9 * * 1-5')
14
- # puts result[:description] # "At 09:00, Monday through Friday"
15
- class CronTool < RubyLLM::Tool
16
- def self.name = 'cron'
17
-
18
- description <<~'DESCRIPTION'
19
- Parse, validate, and explain cron expressions.
20
-
21
- Supports standard 5-field cron format:
22
- - minute (0-59)
23
- - hour (0-23)
24
- - day of month (1-31)
25
- - month (1-12 or JAN-DEC)
26
- - day of week (0-7 or SUN-SAT, where 0 and 7 are Sunday)
27
-
28
- Special characters supported:
29
- - * (any value)
30
- - , (value list separator)
31
- - - (range of values)
32
- - / (step values)
12
+ # tool.execute(action: 'parse', expression: '0 9 * * 1-5')
13
+ # tool.execute(action: 'validate', expression: '*/15 * * * *')
14
+ # tool.execute(action: 'next_times', expression: '0 * * * *', count: 5)
15
+ # tool.execute(action: 'generate', description: 'every day at 9am')
16
+ class CronTool < ::RubyLLM::Tool
17
+ def self.name = 'cron_tool'
33
18
 
34
- Actions:
35
- - 'parse': Parse and explain a cron expression
36
- - 'validate': Check if a cron expression is valid
37
- - 'next': Calculate the next N execution times
38
- - 'generate': Generate a cron expression from a description
39
-
40
- Example usage:
41
- tool = SharedTools::Tools::CronTool.new
42
-
43
- # Parse and explain
44
- tool.execute(action: 'parse', expression: '0 9 * * 1-5')
45
- # => "At 09:00, Monday through Friday"
19
+ description <<~DESC
20
+ Parse, validate, explain, and generate cron expressions (standard 5-field format).
46
21
 
47
- # Validate
48
- tool.execute(action: 'validate', expression: '0 9 * * *')
49
- # => { valid: true }
22
+ Actions:
23
+ - 'parse' Parse and explain a cron expression
24
+ - 'validate' — Check whether a cron expression is valid
25
+ - 'next_times' — List the next N execution times (default 5)
26
+ - 'generate' — Generate a cron expression from a natural language description
50
27
 
51
- # Get next execution times
52
- tool.execute(action: 'next', expression: '0 * * * *', count: 5)
28
+ Cron format: minute hour day month weekday
29
+ - Each field accepts: number, range (1-5), list (1,3,5), step (*/15), or wildcard (*)
30
+ - Weekday: 0-7 (0 and 7 both mean Sunday)
53
31
 
54
- # Generate from description
55
- tool.execute(action: 'generate', description: 'every day at 9am')
56
- DESCRIPTION
32
+ Generate examples: 'every day at 9am', 'every monday at noon', 'every 15 minutes',
33
+ 'every weekday', 'first day of every month at midnight'
34
+ DESC
57
35
 
58
36
  params do
59
- string :action, description: <<~DESC.strip
60
- The action to perform:
61
- - 'parse': Parse and explain a cron expression
62
- - 'validate': Check if expression is valid
63
- - 'next': Calculate next execution times
64
- - 'generate': Generate expression from description
65
- DESC
66
-
67
- string :expression, description: <<~DESC.strip, required: false
68
- The cron expression to parse, validate, or calculate.
69
- Required for 'parse', 'validate', and 'next' actions.
70
- DESC
71
-
72
- string :description, description: <<~DESC.strip, required: false
73
- Human-readable schedule description for 'generate' action.
74
- Examples: 'every day at 9am', 'every monday at noon', 'every 5 minutes'
75
- DESC
76
-
77
- integer :count, description: <<~DESC.strip, required: false
78
- Number of next execution times to return for 'next' action.
79
- Default: 5, Maximum: 20
80
- DESC
37
+ string :action, description: "Action: 'parse', 'validate', 'next_times', 'generate'"
38
+ string :expression, required: false, description: "5-field cron expression. Required for parse, validate, next_times."
39
+ integer :count, required: false, description: "Number of next execution times. Default: 5."
40
+ string :description, required: false, description: "Natural language schedule description. Required for generate."
81
41
  end
82
42
 
83
- DAYS_OF_WEEK = {
84
- 'SUN' => 0, 'MON' => 1, 'TUE' => 2, 'WED' => 3,
85
- 'THU' => 4, 'FRI' => 5, 'SAT' => 6
86
- }.freeze
87
-
88
- MONTHS = {
89
- 'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4,
90
- 'MAY' => 5, 'JUN' => 6, 'JUL' => 7, 'AUG' => 8,
91
- 'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12
92
- }.freeze
93
-
94
43
  # @param logger [Logger] optional logger
95
44
  def initialize(logger: nil)
96
45
  @logger = logger || RubyLLM.logger
97
46
  end
98
47
 
99
- # Execute cron action
100
- #
101
- # @param action [String] action to perform
102
- # @param expression [String, nil] cron expression
103
- # @param description [String, nil] schedule description for generate
104
- # @param count [Integer, nil] number of next executions
48
+ # @param action [String] action to perform
49
+ # @param expression [String, nil] cron expression
50
+ # @param count [Integer, nil] number of times for next_times
51
+ # @param description [String, nil] natural language description for generate
105
52
  # @return [Hash] result
106
- def execute(action:, expression: nil, description: nil, count: nil)
107
- @logger.info("CronTool#execute action=#{action.inspect}")
53
+ def execute(action:, expression: nil, count: nil, description: nil)
54
+ @logger.info("CronTool#execute action=#{action}")
108
55
 
109
56
  case action.to_s.downcase
110
- when 'parse'
111
- parse_expression(expression)
112
- when 'validate'
113
- validate_expression(expression)
114
- when 'next'
115
- next_executions(expression, count || 5)
116
- when 'generate'
117
- generate_expression(description)
57
+ when 'parse' then parse_expression(expression)
58
+ when 'validate' then validate_expression(expression)
59
+ when 'next_times' then next_times(expression, (count || 5).to_i)
60
+ when 'generate' then generate_expression(description)
118
61
  else
119
- {
120
- success: false,
121
- error: "Unknown action: #{action}. Valid actions are: parse, validate, next, generate"
122
- }
62
+ { success: false, error: "Unknown action '#{action}'. Use: parse, validate, next_times, generate" }
123
63
  end
124
64
  rescue => e
125
65
  @logger.error("CronTool error: #{e.message}")
126
- {
127
- success: false,
128
- error: e.message
129
- }
66
+ { success: false, error: e.message }
130
67
  end
131
68
 
132
69
  private
133
70
 
134
- def parse_expression(expression)
135
- return { success: false, error: "Expression is required" } if expression.nil? || expression.empty?
136
-
137
- validation = validate_expression(expression)
138
- return validation unless validation[:valid]
139
-
140
- parts = expression.strip.split(/\s+/)
141
- minute, hour, dom, month, dow = parts
142
-
143
- {
144
- success: true,
145
- expression: expression,
146
- fields: {
147
- minute: minute,
148
- hour: hour,
149
- day_of_month: dom,
150
- month: month,
151
- day_of_week: dow
152
- },
153
- description: build_description(minute, hour, dom, month, dow),
154
- expanded: {
155
- minutes: expand_field(minute, 0, 59),
156
- hours: expand_field(hour, 0, 23),
157
- days_of_month: expand_field(dom, 1, 31),
158
- months: expand_field(month, 1, 12),
159
- days_of_week: expand_field(dow, 0, 6)
160
- }
161
- }
71
+ FIELD_NAMES = %w[minute hour day month weekday].freeze
72
+ FIELD_RANGES = {
73
+ 'minute' => 0..59,
74
+ 'hour' => 0..23,
75
+ 'day' => 1..31,
76
+ 'month' => 1..12,
77
+ 'weekday' => 0..7
78
+ }.freeze
79
+ DAY_NAMES = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
80
+ MONTH_NAMES = %w[January February March April May June July August
81
+ September October November December].freeze
82
+
83
+ # -------------------------------------------------------------------------
84
+ # Action implementations
85
+ # -------------------------------------------------------------------------
86
+
87
+ def parse_expression(expr)
88
+ require_expr!(expr)
89
+ parts = split_expr(expr)
90
+ fields = {}
91
+ FIELD_NAMES.zip(parts).each do |name, raw|
92
+ fields[name] = { raw: raw, values: expand_field(raw, FIELD_RANGES[name]) }
93
+ end
94
+ { success: true, valid: true, expression: expr, fields: fields, explanation: explain(parts) }
95
+ rescue ArgumentError => e
96
+ { success: false, valid: false, expression: expr, error: e.message }
162
97
  end
163
98
 
164
- def validate_expression(expression)
165
- return { valid: false, error: "Expression is required" } if expression.nil? || expression.empty?
166
-
167
- parts = expression.strip.split(/\s+/)
99
+ def validate_expression(expr)
100
+ require_expr!(expr)
101
+ parts = split_expr(expr)
102
+ FIELD_NAMES.zip(parts).each { |name, raw| expand_field(raw, FIELD_RANGES[name]) }
103
+ { success: true, valid: true, expression: expr, explanation: explain(parts) }
104
+ rescue ArgumentError => e
105
+ { success: true, valid: false, expression: expr, error: e.message }
106
+ end
168
107
 
169
- unless parts.length == 5
170
- return {
171
- valid: false,
172
- error: "Invalid cron expression: expected 5 fields, got #{parts.length}"
173
- }
108
+ def next_times(expr, count)
109
+ require_expr!(expr)
110
+ parts = split_expr(expr)
111
+
112
+ sets = FIELD_NAMES.map.with_index { |name, i| expand_field(parts[i], FIELD_RANGES[name]) }
113
+ mins_set, hrs_set, days_set, months_set, wdays_set = sets
114
+
115
+ # Normalise Sunday: weekday 7 == 0
116
+ wdays_set = wdays_set.map { |d| d == 7 ? 0 : d }.uniq.sort
117
+
118
+ times = []
119
+ t = Time.now
120
+ # Advance to the next minute boundary
121
+ t = Time.new(t.year, t.month, t.day, t.hour, t.min + 1, 0)
122
+ limit = 527_040 # 1 year of minutes — safety cap
123
+
124
+ while times.size < count && limit > 0
125
+ limit -= 1
126
+ if months_set.include?(t.month) &&
127
+ days_set.include?(t.day) &&
128
+ wdays_set.include?(t.wday) &&
129
+ hrs_set.include?(t.hour) &&
130
+ mins_set.include?(t.min)
131
+ times << t.strftime('%Y-%m-%d %H:%M (%A)')
132
+ end
133
+ t += 60
174
134
  end
175
135
 
176
- minute, hour, dom, month, dow = parts
136
+ { success: true, expression: expr, explanation: explain(parts), next_times: times }
137
+ end
177
138
 
178
- errors = []
179
- errors << validate_field(minute, 0, 59, 'minute')
180
- errors << validate_field(hour, 0, 23, 'hour')
181
- errors << validate_field(dom, 1, 31, 'day of month')
182
- errors << validate_field(month, 1, 12, 'month')
183
- errors << validate_field(dow, 0, 7, 'day of week')
139
+ def generate_expression(desc)
140
+ raise ArgumentError, "description is required for the generate action" if desc.nil? || desc.strip.empty?
184
141
 
185
- errors.compact!
142
+ d = desc.downcase
143
+ expr = match_pattern(d)
186
144
 
187
- if errors.empty?
188
- { valid: true, expression: expression }
145
+ if expr
146
+ parts = expr.split
147
+ { success: true, description: desc, expression: expr, explanation: explain(parts) }
189
148
  else
190
- { valid: false, errors: errors }
149
+ {
150
+ success: false,
151
+ description: desc,
152
+ error: "Could not generate an expression from that description. " \
153
+ "Try: 'every day at 9am', 'every monday at noon', 'every 15 minutes', " \
154
+ "'every weekday', 'first day of every month at midnight'."
155
+ }
191
156
  end
192
157
  end
193
158
 
194
- def next_executions(expression, count)
195
- return { success: false, error: "Expression is required" } if expression.nil? || expression.empty?
196
-
197
- validation = validate_expression(expression)
198
- return { success: false, error: validation[:errors]&.join(', ') || validation[:error] } unless validation[:valid]
199
-
200
- count = [[count.to_i, 1].max, 20].min
201
-
202
- parts = expression.strip.split(/\s+/)
203
- minute_spec, hour_spec, dom_spec, month_spec, dow_spec = parts
204
-
205
- minutes = expand_field(minute_spec, 0, 59)
206
- hours = expand_field(hour_spec, 0, 23)
207
- doms = expand_field(dom_spec, 1, 31)
208
- months = expand_field(month_spec, 1, 12)
209
- dows = expand_field(dow_spec, 0, 6)
210
-
211
- executions = []
212
- current = Time.now + 60 # Start from next minute
159
+ # -------------------------------------------------------------------------
160
+ # Pattern matching for generate
161
+ # -------------------------------------------------------------------------
213
162
 
214
- max_iterations = 366 * 24 * 60 # One year of minutes max
215
- iterations = 0
163
+ def match_pattern(d)
164
+ return '* * * * *' if d.include?('every minute')
216
165
 
217
- while executions.length < count && iterations < max_iterations
218
- iterations += 1
219
-
220
- if matches_cron?(current, minutes, hours, doms, months, dows)
221
- executions << current.strftime('%Y-%m-%d %H:%M')
222
- current += 60
223
- else
224
- current += 60
225
- end
166
+ if (m = d.match(/every\s+(\d+)\s+minutes?/))
167
+ return "*/#{m[1]} * * * *"
226
168
  end
227
169
 
228
- {
229
- success: true,
230
- expression: expression,
231
- count: executions.length,
232
- next_executions: executions
233
- }
234
- end
235
-
236
- def generate_expression(description)
237
- return { success: false, error: "Description is required" } if description.nil? || description.empty?
238
-
239
- desc = description.downcase.strip
240
- expression = nil
241
-
242
- # Common patterns - more specific patterns must come before general ones
243
- case desc
244
- when /every\s+minute/
245
- expression = '* * * * *'
246
- when /every\s+(\d+)\s+minutes?/
247
- interval = $1.to_i
248
- expression = "*/#{interval} * * * *"
249
- when /every\s+hour/
250
- expression = '0 * * * *'
251
- when /every\s+(\d+)\s+hours?/
252
- interval = $1.to_i
253
- expression = "0 */#{interval} * * *"
254
- when /every\s+day\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
255
- hour, min, ampm = $1.to_i, ($2 || '0').to_i, $3
256
- hour += 12 if ampm == 'pm' && hour != 12
257
- hour = 0 if ampm == 'am' && hour == 12
258
- expression = "#{min} #{hour} * * *"
259
- when /every\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
260
- day_name = $1.upcase[0..2]
261
- day_num = DAYS_OF_WEEK[day_name]
262
- hour, min, ampm = $2.to_i, ($3 || '0').to_i, $4
263
- hour += 12 if ampm == 'pm' && hour != 12
264
- hour = 0 if ampm == 'am' && hour == 12
265
- expression = "#{min} #{hour} * * #{day_num}"
266
- when /every\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/
267
- day_name = $1.upcase[0..2]
268
- day_num = DAYS_OF_WEEK[day_name]
269
- expression = "0 0 * * #{day_num}"
270
- when /weekdays?\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
271
- hour, min, ampm = $1.to_i, ($2 || '0').to_i, $3
272
- hour += 12 if ampm == 'pm' && hour != 12
273
- hour = 0 if ampm == 'am' && hour == 12
274
- expression = "#{min} #{hour} * * 1-5"
275
- when /weekends?\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
276
- hour, min, ampm = $1.to_i, ($2 || '0').to_i, $3
277
- hour += 12 if ampm == 'pm' && hour != 12
278
- hour = 0 if ampm == 'am' && hour == 12
279
- expression = "#{min} #{hour} * * 0,6"
280
- when /at\s+noon/
281
- expression = '0 12 * * *'
282
- when /at\s+midnight/
283
- expression = '0 0 * * *'
284
- when /monthly|first\s+of\s+(the\s+)?month/
285
- expression = '0 0 1 * *'
286
- when /yearly|annually/
287
- expression = '0 0 1 1 *'
288
- when /hourly\s+at\s+:?(\d{2})/
289
- min = $1.to_i
290
- expression = "#{min} * * * *"
170
+ if d.match?(/every\s+hour\b/) && !d.match?(/\d+\s+hours?/)
171
+ return '0 * * * *'
291
172
  end
292
173
 
293
- if expression
294
- parsed = parse_expression(expression)
295
- {
296
- success: true,
297
- description: description,
298
- expression: expression,
299
- explanation: parsed[:description]
300
- }
301
- else
302
- {
303
- success: false,
304
- error: "Could not parse description: '#{description}'. Try formats like 'every day at 9am', 'every monday at noon', 'every 5 minutes'"
305
- }
174
+ if (m = d.match(/every\s+(\d+)\s+hours?/))
175
+ return "0 */#{m[1]} * * *"
306
176
  end
307
- end
308
177
 
309
- def validate_field(field, min, max, name)
310
- return nil if field == '*'
178
+ return '0 9 * * 1-5' if d.include?('weekday')
179
+ return '0 0 * * 0,6' if d.include?('weekend')
311
180
 
312
- # Handle step values
313
- if field.include?('/')
314
- base, step = field.split('/')
315
- return "Invalid step value in #{name}" unless step =~ /^\d+$/ && step.to_i > 0
316
- return validate_field(base, min, max, name) unless base == '*'
317
- return nil
318
- end
181
+ day_pattern = DAY_NAMES.map(&:downcase).join('|')
319
182
 
320
- # Handle ranges
321
- if field.include?('-')
322
- parts = field.split('-')
323
- return "Invalid range in #{name}" unless parts.length == 2
324
- start_val = normalize_value(parts[0], name)
325
- end_val = normalize_value(parts[1], name)
326
- return "Invalid range values in #{name}" if start_val.nil? || end_val.nil?
327
- return "Range out of bounds in #{name}" if start_val < min || end_val > max || start_val > end_val
328
- return nil
183
+ if (m = d.match(/every\s+(#{day_pattern})\s+at\s+(\d+)(?::(\d+))?\s*(am|pm)?/))
184
+ day_num = DAY_NAMES.map(&:downcase).index(m[1])
185
+ return "#{hm_to_cron(m[2], m[3], m[4])} * * #{day_num}"
329
186
  end
330
187
 
331
- # Handle lists
332
- if field.include?(',')
333
- field.split(',').each do |val|
334
- err = validate_field(val.strip, min, max, name)
335
- return err if err
336
- end
337
- return nil
188
+ if (m = d.match(/every\s+(#{day_pattern})\s+at\s+noon/))
189
+ day_num = DAY_NAMES.map(&:downcase).index(m[1])
190
+ return "0 12 * * #{day_num}"
338
191
  end
339
192
 
340
- # Single value
341
- val = normalize_value(field, name)
342
- return "Invalid value '#{field}' in #{name}" if val.nil?
343
- return "Value #{val} out of bounds (#{min}-#{max}) in #{name}" if val < min || val > max
344
-
345
- nil
346
- end
193
+ if (m = d.match(/every\s+(#{day_pattern})/))
194
+ day_num = DAY_NAMES.map(&:downcase).index(m[1])
195
+ return "0 0 * * #{day_num}"
196
+ end
347
197
 
348
- def normalize_value(val, field_name)
349
- return nil if val.nil? || val.empty?
198
+ return '0 12 * * *' if d.include?('noon')
199
+ return '0 0 * * *' if d.include?('midnight')
350
200
 
351
- # Handle day of week names
352
- if %w[day\ of\ week].include?(field_name)
353
- return DAYS_OF_WEEK[val.upcase] if DAYS_OF_WEEK.key?(val.upcase)
201
+ if (m = d.match(/every\s+day\s+at\s+(\d+)(?::(\d+))?\s*(am|pm)?/))
202
+ return "#{hm_to_cron(m[1], m[2], m[3])} * * *"
354
203
  end
355
204
 
356
- # Handle month names
357
- if field_name == 'month'
358
- return MONTHS[val.upcase] if MONTHS.key?(val.upcase)
205
+ if (m = d.match(/first\s+day\s+(?:of\s+(?:every\s+)?month\s+)?at\s+(\d+)(?::(\d+))?\s*(am|pm)?/))
206
+ return "#{hm_to_cron(m[1], m[2], m[3])} 1 * *"
359
207
  end
360
208
 
361
- return val.to_i if val =~ /^\d+$/
209
+ return '0 0 1 * *' if d.match?(/first\s+day/)
362
210
 
363
211
  nil
364
212
  end
365
213
 
366
- def expand_field(field, min, max)
367
- return (min..max).to_a if field == '*'
214
+ # Convert hour/minute/ampm strings to "min hour" cron tokens.
215
+ def hm_to_cron(h, m, ap)
216
+ hour = h.to_i
217
+ min = (m || '0').to_i
218
+ hour += 12 if ap == 'pm' && hour != 12
219
+ hour = 0 if ap == 'am' && hour == 12
220
+ "#{min} #{hour}"
221
+ end
368
222
 
369
- if field.include?('/')
370
- base, step = field.split('/')
371
- step = step.to_i
372
- start_vals = base == '*' ? (min..max).to_a : expand_field(base, min, max)
373
- return start_vals.select { |v| (v - start_vals.first) % step == 0 }
223
+ # -------------------------------------------------------------------------
224
+ # Field expansion
225
+ # -------------------------------------------------------------------------
226
+
227
+ # Expand one cron field to a sorted array of integers.
228
+ def expand_field(value, range)
229
+ return range.to_a if value == '*'
230
+
231
+ result = []
232
+ value.split(',').each do |part|
233
+ if part.include?('/')
234
+ base_str, step_str = part.split('/')
235
+ step = step_str.to_i
236
+ raise ArgumentError, "Step must be >= 1, got #{step}" if step < 1
237
+ base_set = base_str == '*' ? range.to_a : expand_range_part(base_str, range)
238
+ result.concat(base_set.each_with_index.filter_map { |v, i| v if (i % step).zero? })
239
+ elsif part.include?('-')
240
+ a, b = part.split('-').map(&:to_i)
241
+ unless range_with_sunday(range).cover?(a) && range_with_sunday(range).cover?(b)
242
+ raise ArgumentError, "Range #{part} is out of bounds for #{range}"
243
+ end
244
+ result.concat((a..b).to_a)
245
+ else
246
+ v = part.to_i
247
+ unless range_with_sunday(range).cover?(v)
248
+ raise ArgumentError, "Value #{v} is out of range #{range}"
249
+ end
250
+ result << v
251
+ end
374
252
  end
375
253
 
376
- if field.include?(',')
377
- return field.split(',').flat_map { |f| expand_field(f.strip, min, max) }.sort.uniq
378
- end
254
+ result.uniq.sort
255
+ end
379
256
 
380
- if field.include?('-')
381
- start_val, end_val = field.split('-').map { |v| normalize_value(v, '') || v.to_i }
382
- return (start_val..end_val).to_a
257
+ def expand_range_part(str, range)
258
+ if str.include?('-')
259
+ a, b = str.split('-').map(&:to_i)
260
+ (a..b).to_a
261
+ else
262
+ v = str.to_i
263
+ (v..range.last).to_a
383
264
  end
384
-
385
- [normalize_value(field, '') || field.to_i]
386
265
  end
387
266
 
388
- def matches_cron?(time, minutes, hours, doms, months, dows)
389
- return false unless minutes.include?(time.min)
390
- return false unless hours.include?(time.hour)
391
- return false unless months.include?(time.month)
392
-
393
- # Day matching: either day of month OR day of week must match
394
- # (unless both are specified as specific values)
395
- dom_match = doms.include?(time.day)
396
- dow_match = dows.include?(time.wday)
397
-
398
- dom_match || dow_match
267
+ # Allow weekday 7 (Sunday alias)
268
+ def range_with_sunday(range)
269
+ range == FIELD_RANGES['weekday'] ? (0..7) : range
399
270
  end
400
271
 
401
- def build_description(minute, hour, dom, month, dow)
402
- parts = []
272
+ # -------------------------------------------------------------------------
273
+ # Human-readable explanation
274
+ # -------------------------------------------------------------------------
403
275
 
404
- # Time part
405
- time_desc = describe_time(minute, hour)
406
- parts << time_desc if time_desc
276
+ def explain(parts)
277
+ min, hour, day, month, weekday = parts
278
+ segments = []
407
279
 
408
- # Day of month part
409
- if dom != '*'
410
- parts << "on day #{describe_field(dom)} of the month"
411
- end
280
+ segments << if min == '*' then 'every minute'
281
+ elsif min.start_with?('*/') then "every #{min[2..]} minutes"
282
+ else "at minute #{min}"
283
+ end
412
284
 
413
- # Month part
414
- if month != '*'
415
- month_names = expand_field(month, 1, 12).map { |m| Date::MONTHNAMES[m] }
416
- parts << "in #{month_names.join(', ')}"
417
- end
285
+ segments << if hour == '*' then 'of every hour'
286
+ elsif hour.start_with?('*/') then "every #{hour[2..]} hours"
287
+ else "at #{fmt_hour(hour)}"
288
+ end
418
289
 
419
- # Day of week part
420
- if dow != '*'
421
- day_names = expand_field(dow, 0, 6).map { |d| Date::DAYNAMES[d] }
422
- if day_names == %w[Monday Tuesday Wednesday Thursday Friday]
423
- parts << "on weekdays"
424
- elsif day_names == %w[Sunday Saturday]
425
- parts << "on weekends"
426
- else
427
- parts << "on #{day_names.join(', ')}"
428
- end
429
- end
290
+ segments << "on day #{day} of the month" unless day == '*'
291
+ segments << "in #{fmt_month(month)}" unless month == '*'
292
+ segments << "on #{fmt_weekday(weekday)}" unless weekday == '*'
430
293
 
431
- parts.empty? ? "Every minute" : parts.join(', ')
294
+ segments.join(', ')
432
295
  end
433
296
 
434
- def describe_time(minute, hour)
435
- if minute == '*' && hour == '*'
436
- return nil # Every minute, handled by default
437
- end
438
-
439
- if minute == '*'
440
- hours = expand_field(hour, 0, 23)
441
- return "Every minute during hour(s) #{hours.join(', ')}"
442
- end
443
-
444
- if hour == '*'
445
- minutes = expand_field(minute, 0, 59)
446
- if minute.include?('/')
447
- return "Every #{minute.split('/').last} minutes"
448
- end
449
- return "At minute #{minutes.join(', ')} of every hour"
450
- end
451
-
452
- hours = expand_field(hour, 0, 23)
453
- minutes = expand_field(minute, 0, 59)
297
+ def fmt_hour(h)
298
+ return h if h.match?(/[,\-\/]/)
299
+ n = h.to_i
300
+ disp = n == 0 ? 12 : (n > 12 ? n - 12 : n)
301
+ ampm = n < 12 ? 'AM' : 'PM'
302
+ "#{disp}:00 #{ampm}"
303
+ end
454
304
 
455
- times = hours.flat_map do |h|
456
- minutes.map { |m| format('%02d:%02d', h, m) }
457
- end
305
+ def fmt_month(m)
306
+ return m if m.match?(/[,\-\/]/)
307
+ MONTH_NAMES[m.to_i - 1] || m
308
+ end
458
309
 
459
- "At #{times.join(', ')}"
310
+ def fmt_weekday(w)
311
+ return 'weekdays (Mon–Fri)' if w == '1-5'
312
+ return w if w.match?(/[,\/]/)
313
+ return w if w.include?('-')
314
+ DAY_NAMES[w.to_i % 7] || w
460
315
  end
461
316
 
462
- def describe_field(field)
463
- return 'every' if field == '*'
317
+ # -------------------------------------------------------------------------
318
+ # Helpers
319
+ # -------------------------------------------------------------------------
464
320
 
465
- if field.include?('/')
466
- base, step = field.split('/')
467
- return "every #{step}#{base == '*' ? '' : " starting at #{base}"}"
468
- end
321
+ def require_expr!(expr)
322
+ raise ArgumentError, "expression is required for this action" if expr.nil? || expr.strip.empty?
323
+ end
469
324
 
470
- field
325
+ def split_expr(expr)
326
+ parts = expr.strip.split(/\s+/)
327
+ raise ArgumentError, "Cron expression must have 5 fields, got #{parts.size}" unless parts.size == 5
328
+ parts
471
329
  end
472
330
  end
473
331
  end