rails_console_pro 0.1.2 → 0.1.4

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec_status +288 -240
  3. data/CHANGELOG.md +7 -0
  4. data/QUICK_START.md +17 -0
  5. data/README.md +43 -0
  6. data/docs/FORMATTING.md +5 -0
  7. data/docs/MODEL_INTROSPECTION.md +371 -0
  8. data/docs/MODEL_STATISTICS.md +4 -0
  9. data/docs/OBJECT_DIFFING.md +6 -0
  10. data/docs/PROFILING.md +91 -0
  11. data/docs/QUERY_BUILDER.md +385 -0
  12. data/docs/QUEUE_INSIGHTS.md +82 -0
  13. data/docs/SCHEMA_INSPECTION.md +5 -0
  14. data/docs/SNIPPETS.md +71 -0
  15. data/lib/rails_console_pro/commands/compare_command.rb +151 -0
  16. data/lib/rails_console_pro/commands/introspect_command.rb +220 -0
  17. data/lib/rails_console_pro/commands/jobs_command.rb +212 -0
  18. data/lib/rails_console_pro/commands/profile_command.rb +84 -0
  19. data/lib/rails_console_pro/commands/query_builder_command.rb +43 -0
  20. data/lib/rails_console_pro/commands/snippets_command.rb +141 -0
  21. data/lib/rails_console_pro/commands.rb +30 -0
  22. data/lib/rails_console_pro/compare_result.rb +81 -0
  23. data/lib/rails_console_pro/configuration.rb +51 -0
  24. data/lib/rails_console_pro/format_exporter.rb +32 -0
  25. data/lib/rails_console_pro/global_methods.rb +24 -0
  26. data/lib/rails_console_pro/initializer.rb +41 -1
  27. data/lib/rails_console_pro/introspect_result.rb +101 -0
  28. data/lib/rails_console_pro/model_validator.rb +1 -1
  29. data/lib/rails_console_pro/printers/compare_printer.rb +138 -0
  30. data/lib/rails_console_pro/printers/introspect_printer.rb +282 -0
  31. data/lib/rails_console_pro/printers/profile_printer.rb +180 -0
  32. data/lib/rails_console_pro/printers/query_builder_printer.rb +81 -0
  33. data/lib/rails_console_pro/printers/queue_insights_printer.rb +150 -0
  34. data/lib/rails_console_pro/printers/snippet_collection_printer.rb +68 -0
  35. data/lib/rails_console_pro/printers/snippet_printer.rb +64 -0
  36. data/lib/rails_console_pro/profile_result.rb +109 -0
  37. data/lib/rails_console_pro/pry_commands.rb +106 -0
  38. data/lib/rails_console_pro/query_builder.rb +197 -0
  39. data/lib/rails_console_pro/query_builder_result.rb +66 -0
  40. data/lib/rails_console_pro/queue_insights_result.rb +110 -0
  41. data/lib/rails_console_pro/serializers/compare_serializer.rb +66 -0
  42. data/lib/rails_console_pro/serializers/introspect_serializer.rb +99 -0
  43. data/lib/rails_console_pro/serializers/profile_serializer.rb +73 -0
  44. data/lib/rails_console_pro/serializers/query_builder_serializer.rb +35 -0
  45. data/lib/rails_console_pro/services/introspection_collector.rb +420 -0
  46. data/lib/rails_console_pro/services/profile_collector.rb +245 -0
  47. data/lib/rails_console_pro/services/queue_action_service.rb +176 -0
  48. data/lib/rails_console_pro/services/queue_insight_fetcher.rb +600 -0
  49. data/lib/rails_console_pro/services/snippet_repository.rb +191 -0
  50. data/lib/rails_console_pro/snippets/collection_result.rb +45 -0
  51. data/lib/rails_console_pro/snippets/single_result.rb +30 -0
  52. data/lib/rails_console_pro/snippets/snippet.rb +112 -0
  53. data/lib/rails_console_pro/snippets.rb +13 -0
  54. data/lib/rails_console_pro/version.rb +1 -1
  55. data/rails_console_pro.gemspec +1 -1
  56. metadata +42 -8
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsolePro
4
+ module Commands
5
+ # Command for model introspection
6
+ class IntrospectCommand < BaseCommand
7
+ def execute(model_class, *options)
8
+ error_message = ModelValidator.validate_for_schema(model_class)
9
+ if error_message
10
+ puts pastel.red("Error: #{error_message}")
11
+ return nil
12
+ end
13
+
14
+ # Parse options
15
+ opts = parse_options(options)
16
+
17
+ # Collect introspection data
18
+ collector = Services::IntrospectionCollector.new(model_class)
19
+ data = collector.collect
20
+
21
+ # If specific option requested, handle it
22
+ if opts[:callbacks_only]
23
+ return handle_callbacks_only(model_class, data[:callbacks])
24
+ elsif opts[:enums_only]
25
+ return handle_enums_only(model_class, data[:enums])
26
+ elsif opts[:concerns_only]
27
+ return handle_concerns_only(model_class, data[:concerns])
28
+ elsif opts[:scopes_only]
29
+ return handle_scopes_only(model_class, data[:scopes])
30
+ elsif opts[:validations_only]
31
+ return handle_validations_only(model_class, data[:validations])
32
+ elsif opts[:method_source]
33
+ return handle_method_source(model_class, opts[:method_source])
34
+ end
35
+
36
+ # Return full result
37
+ IntrospectResult.new(
38
+ model: model_class,
39
+ callbacks: data[:callbacks],
40
+ enums: data[:enums],
41
+ concerns: data[:concerns],
42
+ scopes: data[:scopes],
43
+ validations: data[:validations],
44
+ lifecycle_hooks: data[:lifecycle_hooks]
45
+ )
46
+ rescue => e
47
+ RailsConsolePro::ErrorHandler.handle(e, context: :introspect)
48
+ end
49
+
50
+ private
51
+
52
+ def parse_options(options)
53
+ opts = {}
54
+ options.each do |opt|
55
+ case opt
56
+ when :callbacks, 'callbacks'
57
+ opts[:callbacks_only] = true
58
+ when :enums, 'enums'
59
+ opts[:enums_only] = true
60
+ when :concerns, 'concerns'
61
+ opts[:concerns_only] = true
62
+ when :scopes, 'scopes'
63
+ opts[:scopes_only] = true
64
+ when :validations, 'validations'
65
+ opts[:validations_only] = true
66
+ else
67
+ # Check if it's a method name for source lookup
68
+ if opt.is_a?(Symbol) || opt.is_a?(String)
69
+ opts[:method_source] = opt
70
+ end
71
+ end
72
+ end
73
+ opts
74
+ end
75
+
76
+ def handle_callbacks_only(model_class, callbacks)
77
+ if callbacks.empty?
78
+ puts pastel.yellow("No callbacks found for #{model_class.name}")
79
+ return nil
80
+ end
81
+
82
+ print_callbacks_summary(model_class, callbacks)
83
+ nil
84
+ end
85
+
86
+ def handle_enums_only(model_class, enums)
87
+ if enums.empty?
88
+ puts pastel.yellow("No enums found for #{model_class.name}")
89
+ return nil
90
+ end
91
+
92
+ print_enums_summary(model_class, enums)
93
+ nil
94
+ end
95
+
96
+ def handle_concerns_only(model_class, concerns)
97
+ if concerns.empty?
98
+ puts pastel.yellow("No concerns found for #{model_class.name}")
99
+ return nil
100
+ end
101
+
102
+ print_concerns_summary(model_class, concerns)
103
+ nil
104
+ end
105
+
106
+ def handle_scopes_only(model_class, scopes)
107
+ if scopes.empty?
108
+ puts pastel.yellow("No scopes found for #{model_class.name}")
109
+ return nil
110
+ end
111
+
112
+ print_scopes_summary(model_class, scopes)
113
+ nil
114
+ end
115
+
116
+ def handle_validations_only(model_class, validations)
117
+ if validations.empty?
118
+ puts pastel.yellow("No validations found for #{model_class.name}")
119
+ return nil
120
+ end
121
+
122
+ print_validations_summary(model_class, validations)
123
+ nil
124
+ end
125
+
126
+ def handle_method_source(model_class, method_name)
127
+ collector = Services::IntrospectionCollector.new(model_class)
128
+ location = collector.method_source_location(method_name)
129
+
130
+ if location.nil?
131
+ puts pastel.yellow("Method '#{method_name}' not found or source location unavailable")
132
+ return nil
133
+ end
134
+
135
+ print_method_source(method_name, location)
136
+ nil
137
+ end
138
+
139
+ # Quick print methods for specific data
140
+ def print_callbacks_summary(model_class, callbacks)
141
+ puts pastel.bold.bright_blue("Callbacks for #{model_class.name}:")
142
+ puts pastel.dim("─" * 60)
143
+
144
+ callbacks.each do |type, chain|
145
+ puts "\n#{pastel.cyan(type.to_s)}:"
146
+ chain.each_with_index do |callback, index|
147
+ conditions = []
148
+ conditions << "if: #{callback[:if].join(', ')}" if callback[:if]
149
+ conditions << "unless: #{callback[:unless].join(', ')}" if callback[:unless]
150
+
151
+ condition_str = conditions.any? ? " (#{conditions.join(', ')})" : ""
152
+ puts " #{index + 1}. #{pastel.green(callback[:name])}#{condition_str}"
153
+ end
154
+ end
155
+ end
156
+
157
+ def print_enums_summary(model_class, enums)
158
+ puts pastel.bold.bright_blue("Enums for #{model_class.name}:")
159
+ puts pastel.dim("─" * 60)
160
+
161
+ enums.each do |name, data|
162
+ puts "\n#{pastel.cyan(name)}:"
163
+ puts " Type: #{pastel.yellow(data[:type])}"
164
+ puts " Values: #{pastel.green(data[:values].join(', '))}"
165
+ end
166
+ end
167
+
168
+ def print_concerns_summary(model_class, concerns)
169
+ puts pastel.bold.bright_blue("Concerns for #{model_class.name}:")
170
+ puts pastel.dim("─" * 60)
171
+
172
+ concerns.each do |concern|
173
+ type_badge = case concern[:type]
174
+ when :concern then pastel.green('[Concern]')
175
+ when :class then pastel.blue('[Class]')
176
+ else pastel.yellow('[Module]')
177
+ end
178
+
179
+ puts "\n#{type_badge} #{pastel.cyan(concern[:name])}"
180
+ if concern[:location]
181
+ puts " #{pastel.dim(concern[:location][:file])}:#{concern[:location][:line]}"
182
+ end
183
+ end
184
+ end
185
+
186
+ def print_scopes_summary(model_class, scopes)
187
+ puts pastel.bold.bright_blue("Scopes for #{model_class.name}:")
188
+ puts pastel.dim("─" * 60)
189
+
190
+ scopes.each do |name, data|
191
+ puts "\n#{pastel.cyan(name)}:"
192
+ puts " #{pastel.dim(data[:sql])}"
193
+ end
194
+ end
195
+
196
+ def print_validations_summary(model_class, validations)
197
+ puts pastel.bold.bright_blue("Validations for #{model_class.name}:")
198
+ puts pastel.dim("─" * 60)
199
+
200
+ validations.each do |attribute, validators|
201
+ puts "\n#{pastel.cyan(attribute)}:"
202
+ validators.each do |validator|
203
+ opts_str = validator[:options].map { |k, v| "#{k}: #{v}" }.join(', ')
204
+ opts_str = " (#{opts_str})" unless opts_str.empty?
205
+ puts " - #{pastel.green(validator[:type])}#{opts_str}"
206
+ end
207
+ end
208
+ end
209
+
210
+ def print_method_source(method_name, location)
211
+ puts pastel.bold.bright_blue("Method: #{method_name}")
212
+ puts pastel.dim("─" * 60)
213
+ puts " Owner: #{pastel.cyan(location[:owner])}"
214
+ puts " Type: #{pastel.yellow(location[:type])}"
215
+ puts " Location: #{pastel.green(location[:file])}:#{location[:line]}"
216
+ end
217
+ end
218
+ end
219
+ end
220
+
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsolePro
4
+ module Commands
5
+ class JobsCommand < BaseCommand
6
+ DEFAULT_LIMIT = 20
7
+
8
+ def execute(options = nil)
9
+ unless feature_enabled?
10
+ puts pastel.yellow("Jobs command is disabled. Enable it with: RailsConsolePro.configure { |c| c.queue_command_enabled = true }")
11
+ return nil
12
+ end
13
+
14
+ unless active_job_available?
15
+ puts pastel.red("ActiveJob is not loaded. Queue insights require ActiveJob to be available.")
16
+ return nil
17
+ end
18
+
19
+ normalized_options = normalize_options(options)
20
+
21
+ fetch_options, filter_options, action_options = split_options(normalized_options)
22
+ action_results = perform_actions(action_options, fetch_options)
23
+ action_results.each { |result| print_action_result(result) }
24
+
25
+ result = fetcher.fetch(**fetch_options)
26
+ return result unless result
27
+
28
+ apply_filters(result, filter_options)
29
+ rescue => e
30
+ RailsConsolePro::ErrorHandler.handle(e, context: :jobs)
31
+ end
32
+
33
+ private
34
+
35
+ def feature_enabled?
36
+ RailsConsolePro.config.queue_command_enabled
37
+ end
38
+
39
+ def active_job_available?
40
+ defined?(ActiveJob::Base)
41
+ end
42
+
43
+ def fetcher
44
+ @fetcher ||= Services::QueueInsightFetcher.new
45
+ end
46
+
47
+ def action_service
48
+ @action_service ||= Services::QueueActionService.new
49
+ end
50
+
51
+ def perform_actions(action_options, fetch_options)
52
+ return [] if action_options.empty?
53
+
54
+ action_options.each_with_object([]) do |(action_key, value), results|
55
+ next if value.nil? || (value.respond_to?(:empty?) && value.empty?)
56
+
57
+ result = action_service.perform(
58
+ action: action_key,
59
+ jid: value,
60
+ queue: fetch_options[:queue]
61
+ )
62
+ results << result if result
63
+ end
64
+ end
65
+
66
+ def apply_filters(result, filter_options)
67
+ filters = prepare_filters(filter_options)
68
+ filtered_enqueued = result.enqueued_jobs
69
+ filtered_retry = result.retry_jobs
70
+ filtered_recent = result.recent_executions
71
+ additional_warnings = []
72
+
73
+ statuses = filters[:statuses]
74
+ unless statuses.empty?
75
+ show_enqueued = statuses.include?(:enqueued) || statuses.include?(:scheduled) || statuses.include?(:all)
76
+ show_retry = statuses.include?(:retry) || statuses.include?(:retries) || statuses.include?(:all)
77
+ show_recent = statuses.include?(:recent) || statuses.include?(:executing) || statuses.include?(:running) || statuses.include?(:all)
78
+
79
+ filtered_enqueued = [] unless show_enqueued
80
+ filtered_retry = [] unless show_retry
81
+ filtered_recent = [] unless show_recent
82
+ end
83
+
84
+ if filters[:job_class]
85
+ matcher = filters[:job_class]
86
+ filtered_enqueued = filtered_enqueued.select { |job| job_class_matches?(job, matcher) }
87
+ filtered_retry = filtered_retry.select { |job| job_class_matches?(job, matcher) }
88
+ filtered_recent = filtered_recent.select { |job| job_class_matches?(job, matcher) }
89
+ if filtered_enqueued.empty? && filtered_retry.empty? && filtered_recent.empty?
90
+ additional_warnings << "No jobs matching class filter '#{matcher}'."
91
+ end
92
+ end
93
+
94
+ filtered_result = result.with_overrides(
95
+ enqueued_jobs: filtered_enqueued,
96
+ retry_jobs: filtered_retry,
97
+ recent_executions: filtered_recent,
98
+ warnings: result.warnings + additional_warnings
99
+ )
100
+
101
+ filtered_result
102
+ end
103
+
104
+ def normalize_options(options)
105
+ case options
106
+ when Numeric
107
+ { limit: options.to_i }
108
+ when Hash
109
+ symbolized = options.each_with_object({}) do |(key, value), acc|
110
+ sym_key = key.to_sym
111
+ sym_key = :job_class if sym_key == :class
112
+ acc[sym_key] = value
113
+ end
114
+ symbolized
115
+ when nil
116
+ {}
117
+ else
118
+ {}
119
+ end.then do |opts|
120
+ limit = opts.fetch(:limit, DEFAULT_LIMIT)
121
+ limit = limit.to_i
122
+ limit = DEFAULT_LIMIT if limit <= 0
123
+ opts[:limit] = limit
124
+ opts[:job_class] = opts[:job_class].to_s.strip if opts.key?(:job_class) && opts[:job_class]
125
+ [:retry, :delete, :details].each do |key|
126
+ opts[key] = opts[key].to_s.strip if opts.key?(key) && opts[key]
127
+ end
128
+ opts
129
+ end
130
+ end
131
+
132
+ def split_options(options)
133
+ fetch_options = {
134
+ limit: options[:limit],
135
+ queue: options[:queue]
136
+ }.compact
137
+
138
+ filter_options = {
139
+ statuses: options[:statuses] || options[:status],
140
+ job_class: options[:job_class]
141
+ }.compact
142
+
143
+ action_options = {
144
+ retry: options[:retry],
145
+ delete: options[:delete],
146
+ details: options[:details]
147
+ }.compact
148
+
149
+ [fetch_options, filter_options, action_options]
150
+ end
151
+
152
+ def prepare_filters(filter_options)
153
+ statuses =
154
+ Array(filter_options[:statuses]).flat_map { |s| s.to_s.split(',') }
155
+ .map(&:strip)
156
+ .reject(&:empty?)
157
+ .map { |s| s.downcase.to_sym }
158
+ .uniq
159
+
160
+ {
161
+ statuses: statuses,
162
+ job_class: filter_options[:job_class],
163
+ limit: filter_options[:limit]
164
+ }
165
+ end
166
+
167
+ def job_class_matches?(job, matcher)
168
+ job_class = job.job_class.to_s
169
+ return false if job_class.empty?
170
+
171
+ matcher_str = matcher.to_s
172
+ job_class.casecmp?(matcher_str) ||
173
+ job_class.split('::').last.casecmp?(matcher_str) ||
174
+ job_class.include?(matcher_str)
175
+ end
176
+
177
+ def print_action_result(result)
178
+ return unless result
179
+
180
+ if result.warning
181
+ puts pastel.yellow("⚠️ #{result.warning}")
182
+ end
183
+
184
+ if result.message
185
+ color_method = result.success ? :green : :cyan
186
+ puts pastel.public_send(color_method, result.message)
187
+ end
188
+
189
+ if result.details
190
+ puts pastel.cyan("Details:")
191
+ formatted = format_details(result.details)
192
+ puts formatted
193
+ end
194
+ end
195
+
196
+ def format_details(details)
197
+ case details
198
+ when String
199
+ " #{details}"
200
+ when Hash
201
+ details.map { |key, value| " #{key}: #{value.inspect}" }.join("\n")
202
+ when Array
203
+ details.map { |value| " - #{value.inspect}" }.join("\n")
204
+ else
205
+ details.inspect
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
211
+
212
+
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsolePro
4
+ module Commands
5
+ # Command for profiling arbitrary blocks, callables, or ActiveRecord relations
6
+ class ProfileCommand < BaseCommand
7
+ def execute(target = nil, *args, label: nil, &block)
8
+ return disabled_message unless enabled?
9
+
10
+ label, target, block = normalize_arguments(label, target, block)
11
+ execution = build_execution(target, args, block)
12
+ return execution if execution.nil? || execution.is_a?(String)
13
+
14
+ collector = Services::ProfileCollector.new(config)
15
+ collector.profile(label: label || execution[:label]) do
16
+ execution[:callable].call
17
+ end
18
+ rescue => e
19
+ RailsConsolePro::ErrorHandler.handle(e, context: :profile)
20
+ end
21
+
22
+ private
23
+
24
+ def enabled?
25
+ RailsConsolePro.config.enabled && RailsConsolePro.config.profile_command_enabled
26
+ end
27
+
28
+ def disabled_message
29
+ pastel.yellow('Profile command is disabled. Enable it via RailsConsolePro.configure { |c| c.profile_command_enabled = true }')
30
+ end
31
+
32
+ def config
33
+ RailsConsolePro.config
34
+ end
35
+
36
+ def normalize_arguments(label, target, block)
37
+ if block_provided?(block) && (target.is_a?(String) || target.is_a?(Symbol))
38
+ label ||= target.to_s
39
+ target = nil
40
+ end
41
+ [label, target, block]
42
+ end
43
+
44
+ def block_provided?(block)
45
+ !block.nil?
46
+ end
47
+
48
+ def build_execution(target, args, block)
49
+ if block_provided?(block)
50
+ { callable: block, label: nil }
51
+ elsif relation?(target)
52
+ relation = target
53
+ { callable: -> { relation.load }, label: "#{relation.klass.name} relation" }
54
+ elsif callable?(target)
55
+ callable = target
56
+ { callable: -> { callable.call(*args) }, label: callable_label(callable) }
57
+ elsif target.nil?
58
+ pastel.red('Nothing to profile. Provide a block, callable, or ActiveRecord relation.')
59
+ else
60
+ { callable: -> { target }, label: target.class.name }
61
+ end
62
+ end
63
+
64
+ def relation?(object)
65
+ defined?(ActiveRecord::Relation) && object.is_a?(ActiveRecord::Relation)
66
+ end
67
+
68
+ def callable?(object)
69
+ object.respond_to?(:call)
70
+ end
71
+
72
+ def callable_label(callable)
73
+ if callable.respond_to?(:name) && callable.name
74
+ callable.name
75
+ elsif callable.respond_to?(:receiver) && callable.respond_to?(:name)
76
+ "#{callable.receiver.class}##{callable.name}"
77
+ else
78
+ callable.class.name
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsolePro
4
+ module Commands
5
+ # Command for interactive query building
6
+ class QueryBuilderCommand < BaseCommand
7
+ def execute(model_class, &block)
8
+ return disabled_message unless enabled?
9
+ return pastel.red("#{model_class} is not an ActiveRecord model") unless valid_model?(model_class)
10
+
11
+ builder = QueryBuilder.new(model_class)
12
+
13
+ if block_given?
14
+ builder.instance_eval(&block)
15
+ builder.build
16
+ else
17
+ builder.build
18
+ end
19
+ rescue => e
20
+ RailsConsolePro::ErrorHandler.handle(e, context: :query_builder)
21
+ end
22
+
23
+ private
24
+
25
+ def enabled?
26
+ RailsConsolePro.config.enabled && RailsConsolePro.config.query_builder_command_enabled
27
+ end
28
+
29
+ def disabled_message
30
+ pastel.yellow('Query builder command is disabled. Enable it via RailsConsolePro.configure { |c| c.query_builder_command_enabled = true }')
31
+ end
32
+
33
+ def valid_model?(model_class)
34
+ ModelValidator.valid_model?(model_class)
35
+ end
36
+
37
+ def config
38
+ RailsConsolePro.config
39
+ end
40
+ end
41
+ end
42
+ end
43
+
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsolePro
4
+ module Commands
5
+ # Command for managing, searching, and listing reusable console snippets
6
+ class SnippetsCommand < BaseCommand
7
+ DEFAULT_LIST_LIMIT = 10
8
+
9
+ def execute(action = :list, *args, **kwargs, &block)
10
+ return disabled_message unless enabled?
11
+
12
+ action = infer_action(action, args)
13
+ case action
14
+ when :list
15
+ list_snippets(**kwargs)
16
+ when :search
17
+ search_snippets(*args, **kwargs)
18
+ when :add, :create
19
+ add_snippet(*args, **kwargs, &block)
20
+ when :show
21
+ show_snippet(*args)
22
+ when :delete, :remove
23
+ delete_snippet(*args)
24
+ when :favorite
25
+ toggle_favorite(*args, value: true)
26
+ when :unfavorite
27
+ toggle_favorite(*args, value: false)
28
+ else
29
+ pastel.red("Unknown snippets action: #{action}")
30
+ end
31
+ rescue ArgumentError => e
32
+ pastel.red("Snippets error: #{e.message}")
33
+ end
34
+
35
+ private
36
+
37
+ def enabled?
38
+ RailsConsolePro.config.enabled && RailsConsolePro.config.snippets_command_enabled
39
+ end
40
+
41
+ def disabled_message
42
+ pastel.yellow('Snippets command is disabled. Enable via RailsConsolePro.configure { |c| c.snippets_command_enabled = true }')
43
+ end
44
+
45
+ def infer_action(action, args)
46
+ return action.to_sym if action.respond_to?(:to_sym)
47
+ return :search if args.any?
48
+
49
+ :list
50
+ end
51
+
52
+ def list_snippets(limit: DEFAULT_LIST_LIMIT, favorites: false)
53
+ snippets = repository.all(limit: limit)
54
+ snippets = snippets.select(&:favorite?) if favorites
55
+
56
+ Snippets::CollectionResult.new(
57
+ snippets: snippets,
58
+ limit: limit,
59
+ total_count: repository.all.size,
60
+ tags: favorites ? ['favorite'] : []
61
+ )
62
+ end
63
+
64
+ def search_snippets(term = nil, tags: nil, limit: DEFAULT_LIST_LIMIT)
65
+ search_result = repository.search(term: term, tags: tags, limit: limit)
66
+ snippets = search_result[:results]
67
+ Snippets::CollectionResult.new(
68
+ snippets: snippets,
69
+ query: term,
70
+ tags: Array(tags),
71
+ limit: limit,
72
+ total_count: search_result[:total_count]
73
+ )
74
+ end
75
+
76
+ def add_snippet(body = nil, id: nil, tags: nil, description: nil, favorite: false, metadata: nil, &block)
77
+ snippet_body = extract_body(body, &block)
78
+
79
+ snippet = repository.add(
80
+ body: snippet_body,
81
+ id: id,
82
+ tags: tags,
83
+ description: description,
84
+ favorite: favorite,
85
+ metadata: metadata
86
+ )
87
+
88
+ Snippets::SingleResult.new(snippet: snippet, action: :add, created: true)
89
+ end
90
+
91
+ def show_snippet(id)
92
+ snippet = repository.find(id)
93
+ return pastel.yellow("No snippet found for '#{id}'") unless snippet
94
+
95
+ Snippets::SingleResult.new(snippet: snippet, action: :show, created: false)
96
+ end
97
+
98
+ def delete_snippet(id)
99
+ if repository.delete(id)
100
+ pastel.green("Removed snippet '#{id}'")
101
+ else
102
+ pastel.yellow("No snippet found for '#{id}'")
103
+ end
104
+ end
105
+
106
+ def toggle_favorite(id, value:)
107
+ snippet = repository.update(id, favorite: value)
108
+ return pastel.yellow("No snippet found for '#{id}'") unless snippet
109
+
110
+ status = value ? 'marked as favorite' : 'removed from favorites'
111
+ message = pastel.green("Snippet '#{id}' #{status}.")
112
+ Snippets::SingleResult.new(
113
+ snippet: snippet,
114
+ action: value ? :favorite : :unfavorite,
115
+ created: false,
116
+ message: message
117
+ )
118
+ end
119
+
120
+ def extract_body(body, &block)
121
+ content = if block
122
+ result = block.call
123
+ result.respond_to?(:join) ? result.join("\n") : result.to_s
124
+ else
125
+ body
126
+ end
127
+
128
+ raise ArgumentError, 'Snippet body is required' if content.nil? || content.to_s.strip.empty?
129
+
130
+ content.to_s
131
+ end
132
+
133
+ def repository
134
+ @repository ||= RailsConsolePro::Services::SnippetRepository.new(
135
+ store_path: RailsConsolePro.config.snippet_store_path
136
+ )
137
+ end
138
+ end
139
+ end
140
+ end
141
+