anima-core 0.2.1 → 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.
Files changed (280) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +27 -1
  3. data/CHANGELOG.md +19 -0
  4. data/README.md +213 -43
  5. data/agents/codebase-analyzer.md +88 -0
  6. data/agents/codebase-pattern-finder.md +83 -0
  7. data/agents/documentation-researcher.md +59 -0
  8. data/agents/thoughts-analyzer.md +102 -0
  9. data/agents/web-search-researcher.md +71 -0
  10. data/anima-core.gemspec +3 -0
  11. data/app/channels/session_channel.rb +195 -45
  12. data/app/decorators/user_message_decorator.rb +16 -5
  13. data/app/jobs/agent_request_job.rb +55 -2
  14. data/app/jobs/analytical_brain_job.rb +33 -0
  15. data/app/jobs/count_event_tokens_job.rb +15 -4
  16. data/app/models/concerns/event/broadcasting.rb +81 -0
  17. data/app/models/event.rb +20 -1
  18. data/app/models/goal.rb +91 -0
  19. data/app/models/session.rb +366 -21
  20. data/config/application.rb +2 -0
  21. data/config/initializers/event_subscribers.rb +0 -1
  22. data/config/routes.rb +0 -6
  23. data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
  24. data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
  25. data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
  26. data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
  27. data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
  28. data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
  29. data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
  30. data/db/migrate/20260315140843_create_goals.rb +16 -0
  31. data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
  32. data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
  33. data/lib/agent_loop.rb +65 -6
  34. data/lib/agents/definition.rb +116 -0
  35. data/lib/agents/registry.rb +106 -0
  36. data/lib/analytical_brain/runner.rb +276 -0
  37. data/lib/analytical_brain/tools/activate_skill.rb +52 -0
  38. data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
  39. data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
  40. data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
  41. data/lib/analytical_brain/tools/finish_goal.rb +62 -0
  42. data/lib/analytical_brain/tools/read_workflow.rb +58 -0
  43. data/lib/analytical_brain/tools/rename_session.rb +63 -0
  44. data/lib/analytical_brain/tools/set_goal.rb +60 -0
  45. data/lib/analytical_brain/tools/update_goal.rb +60 -0
  46. data/lib/analytical_brain.rb +23 -0
  47. data/lib/anima/cli/mcp/secrets.rb +76 -0
  48. data/lib/anima/cli/mcp.rb +197 -0
  49. data/lib/anima/cli.rb +5 -40
  50. data/lib/anima/installer.rb +168 -0
  51. data/lib/anima/settings.rb +226 -0
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +9 -0
  54. data/lib/credential_store.rb +103 -0
  55. data/lib/environment_probe.rb +232 -0
  56. data/lib/events/subscribers/persister.rb +1 -0
  57. data/lib/events/user_message.rb +17 -0
  58. data/lib/llm/client.rb +29 -10
  59. data/lib/mcp/client_manager.rb +86 -0
  60. data/lib/mcp/config.rb +213 -0
  61. data/lib/mcp/health_check.rb +77 -0
  62. data/lib/mcp/secrets.rb +73 -0
  63. data/lib/mcp/stdio_transport.rb +206 -0
  64. data/lib/providers/anthropic.rb +11 -20
  65. data/lib/shell_session.rb +11 -10
  66. data/lib/skills/definition.rb +97 -0
  67. data/lib/skills/registry.rb +105 -0
  68. data/lib/tools/edit.rb +226 -0
  69. data/lib/tools/mcp_tool.rb +114 -0
  70. data/lib/tools/read.rb +151 -0
  71. data/lib/tools/registry.rb +14 -12
  72. data/lib/tools/request_feature.rb +121 -0
  73. data/lib/tools/return_result.rb +81 -0
  74. data/lib/tools/spawn_specialist.rb +109 -0
  75. data/lib/tools/spawn_subagent.rb +111 -0
  76. data/lib/tools/subagent_prompts.rb +12 -0
  77. data/lib/tools/web_get.rb +8 -9
  78. data/lib/tools/write.rb +86 -0
  79. data/lib/tui/app.rb +985 -26
  80. data/lib/tui/cable_client.rb +69 -31
  81. data/lib/tui/message_store.rb +103 -8
  82. data/lib/tui/screens/chat.rb +293 -45
  83. data/lib/workflows/definition.rb +97 -0
  84. data/lib/workflows/registry.rb +89 -0
  85. data/skills/activerecord/SKILL.md +255 -0
  86. data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
  87. data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
  88. data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
  89. data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
  90. data/skills/activerecord/examples/associations/self_referential.rb +302 -0
  91. data/skills/activerecord/examples/associations/through_associations.rb +203 -0
  92. data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
  93. data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
  94. data/skills/activerecord/examples/basics/inheritance.rb +377 -0
  95. data/skills/activerecord/examples/basics/type_casting.rb +317 -0
  96. data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
  97. data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
  98. data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
  99. data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
  100. data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
  101. data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
  102. data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
  103. data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
  104. data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
  105. data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
  106. data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
  107. data/skills/activerecord/examples/querying/optimization.rb +275 -0
  108. data/skills/activerecord/examples/querying/scopes.rb +260 -0
  109. data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
  110. data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
  111. data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
  112. data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
  113. data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
  114. data/skills/activerecord/references/associations.md +709 -0
  115. data/skills/activerecord/references/basics.md +622 -0
  116. data/skills/activerecord/references/callbacks.md +738 -0
  117. data/skills/activerecord/references/migrations.md +657 -0
  118. data/skills/activerecord/references/querying.md +655 -0
  119. data/skills/activerecord/references/validations.md +596 -0
  120. data/skills/dragonruby/SKILL.md +250 -0
  121. data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
  122. data/skills/dragonruby/examples/audio/background_music.rb +29 -0
  123. data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
  124. data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
  125. data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
  126. data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
  127. data/skills/dragonruby/examples/core/hello_world.rb +24 -0
  128. data/skills/dragonruby/examples/core/labels.rb +22 -0
  129. data/skills/dragonruby/examples/core/sprites.rb +35 -0
  130. data/skills/dragonruby/examples/core/state_management.rb +29 -0
  131. data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
  132. data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
  133. data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
  134. data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
  135. data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
  136. data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
  137. data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
  138. data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
  139. data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
  140. data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
  141. data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
  142. data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
  143. data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
  144. data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
  145. data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
  146. data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
  147. data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
  148. data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
  149. data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
  150. data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
  151. data/skills/dragonruby/examples/input/controller_input.rb +28 -0
  152. data/skills/dragonruby/examples/input/directional_input.rb +24 -0
  153. data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
  154. data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
  155. data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
  156. data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
  157. data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
  158. data/skills/dragonruby/examples/rendering/labels.rb +32 -0
  159. data/skills/dragonruby/examples/rendering/layering.rb +51 -0
  160. data/skills/dragonruby/examples/rendering/solids.rb +61 -0
  161. data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
  162. data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
  163. data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
  164. data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
  165. data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
  166. data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
  167. data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
  168. data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
  169. data/skills/dragonruby/references/audio.md +396 -0
  170. data/skills/dragonruby/references/core.md +385 -0
  171. data/skills/dragonruby/references/distribution.md +434 -0
  172. data/skills/dragonruby/references/entities.md +516 -0
  173. data/skills/dragonruby/references/game-logic/persistence.md +386 -0
  174. data/skills/dragonruby/references/game-logic/state.md +389 -0
  175. data/skills/dragonruby/references/input.md +414 -0
  176. data/skills/dragonruby/references/rendering/animation.md +467 -0
  177. data/skills/dragonruby/references/rendering/primitives.md +403 -0
  178. data/skills/dragonruby/references/scenes.md +443 -0
  179. data/skills/draper-decorators/SKILL.md +344 -0
  180. data/skills/draper-decorators/examples/application_decorator.rb +61 -0
  181. data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
  182. data/skills/draper-decorators/examples/model_decorator.rb +152 -0
  183. data/skills/draper-decorators/references/anti-patterns.md +640 -0
  184. data/skills/draper-decorators/references/patterns.md +507 -0
  185. data/skills/draper-decorators/references/testing.md +559 -0
  186. data/skills/gh-issue.md +182 -0
  187. data/skills/mcp-server/SKILL.md +177 -0
  188. data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
  189. data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
  190. data/skills/mcp-server/examples/http_client.rb +48 -0
  191. data/skills/mcp-server/examples/http_server.rb +97 -0
  192. data/skills/mcp-server/examples/rails_integration.rb +88 -0
  193. data/skills/mcp-server/examples/stdio_server.rb +108 -0
  194. data/skills/mcp-server/examples/streaming_client.rb +95 -0
  195. data/skills/mcp-server/references/gotchas.md +183 -0
  196. data/skills/mcp-server/references/prompts.md +98 -0
  197. data/skills/mcp-server/references/resources.md +53 -0
  198. data/skills/mcp-server/references/server.md +140 -0
  199. data/skills/mcp-server/references/tools.md +146 -0
  200. data/skills/mcp-server/references/transport.md +104 -0
  201. data/skills/ratatui-ruby/SKILL.md +315 -0
  202. data/skills/ratatui-ruby/references/core-concepts.md +340 -0
  203. data/skills/ratatui-ruby/references/events.md +387 -0
  204. data/skills/ratatui-ruby/references/frameworks.md +522 -0
  205. data/skills/ratatui-ruby/references/layout.md +423 -0
  206. data/skills/ratatui-ruby/references/styling.md +268 -0
  207. data/skills/ratatui-ruby/references/testing.md +433 -0
  208. data/skills/ratatui-ruby/references/widgets.md +532 -0
  209. data/skills/rspec/SKILL.md +340 -0
  210. data/skills/rspec/examples/core/basic_structure.rb +69 -0
  211. data/skills/rspec/examples/core/configuration.rb +126 -0
  212. data/skills/rspec/examples/core/hooks.rb +126 -0
  213. data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
  214. data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
  215. data/skills/rspec/examples/core/shared_examples.rb +145 -0
  216. data/skills/rspec/examples/factory_bot/associations.rb +314 -0
  217. data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
  218. data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
  219. data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
  220. data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
  221. data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
  222. data/skills/rspec/examples/factory_bot/traits.rb +293 -0
  223. data/skills/rspec/examples/factory_bot/transients.rb +229 -0
  224. data/skills/rspec/examples/matchers/change.rb +115 -0
  225. data/skills/rspec/examples/matchers/collections.rb +154 -0
  226. data/skills/rspec/examples/matchers/comparisons.rb +79 -0
  227. data/skills/rspec/examples/matchers/composing.rb +155 -0
  228. data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
  229. data/skills/rspec/examples/matchers/equality.rb +58 -0
  230. data/skills/rspec/examples/matchers/errors.rb +136 -0
  231. data/skills/rspec/examples/matchers/output.rb +103 -0
  232. data/skills/rspec/examples/matchers/predicates.rb +87 -0
  233. data/skills/rspec/examples/matchers/truthiness.rb +101 -0
  234. data/skills/rspec/examples/matchers/types.rb +82 -0
  235. data/skills/rspec/examples/matchers/yield.rb +147 -0
  236. data/skills/rspec/examples/mocks/any_instance.rb +172 -0
  237. data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
  238. data/skills/rspec/examples/mocks/constants.rb +177 -0
  239. data/skills/rspec/examples/mocks/doubles.rb +139 -0
  240. data/skills/rspec/examples/mocks/expectations.rb +137 -0
  241. data/skills/rspec/examples/mocks/message_chains.rb +173 -0
  242. data/skills/rspec/examples/mocks/ordering.rb +144 -0
  243. data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
  244. data/skills/rspec/examples/mocks/responses.rb +223 -0
  245. data/skills/rspec/examples/mocks/spies.rb +149 -0
  246. data/skills/rspec/examples/mocks/stubbing.rb +133 -0
  247. data/skills/rspec/examples/rails/channels.rb +250 -0
  248. data/skills/rspec/examples/rails/controller_specs.rb +302 -0
  249. data/skills/rspec/examples/rails/helper_specs.rb +245 -0
  250. data/skills/rspec/examples/rails/job_specs.rb +256 -0
  251. data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
  252. data/skills/rspec/examples/rails/matchers.rb +374 -0
  253. data/skills/rspec/examples/rails/model_specs.rb +193 -0
  254. data/skills/rspec/examples/rails/request_specs.rb +275 -0
  255. data/skills/rspec/examples/rails/routing_specs.rb +276 -0
  256. data/skills/rspec/examples/rails/system_specs.rb +294 -0
  257. data/skills/rspec/examples/rails/transactions.rb +254 -0
  258. data/skills/rspec/examples/rails/view_specs.rb +252 -0
  259. data/skills/rspec/references/core.md +816 -0
  260. data/skills/rspec/references/factory_bot.md +641 -0
  261. data/skills/rspec/references/matchers.md +516 -0
  262. data/skills/rspec/references/mocks.md +381 -0
  263. data/skills/rspec/references/rails.md +528 -0
  264. data/templates/soul.md +40 -0
  265. data/workflows/commit.md +45 -0
  266. data/workflows/create_handoff.md +98 -0
  267. data/workflows/create_note.md +82 -0
  268. data/workflows/create_plan.md +457 -0
  269. data/workflows/decompose_ticket.md +109 -0
  270. data/workflows/feature.md +91 -0
  271. data/workflows/implement_plan.md +87 -0
  272. data/workflows/iterate_plan.md +247 -0
  273. data/workflows/research_codebase.md +210 -0
  274. data/workflows/resume_handoff.md +217 -0
  275. data/workflows/review_pr.md +320 -0
  276. data/workflows/thoughts_init.md +71 -0
  277. data/workflows/validate_plan.md +166 -0
  278. metadata +290 -3
  279. data/app/controllers/api/sessions_controller.rb +0 -25
  280. data/lib/events/subscribers/action_cable_bridge.rb +0 -59
@@ -0,0 +1,381 @@
1
+ # ActiveRecord Custom Validators Examples
2
+
3
+ # =============================================================================
4
+ # INLINE VALIDATION METHODS
5
+ # =============================================================================
6
+
7
+ class Invoice < ApplicationRecord
8
+ validate :total_matches_line_items
9
+ validate :due_date_in_future, on: :create
10
+ validate :cannot_exceed_credit_limit
11
+
12
+ private
13
+
14
+ def total_matches_line_items
15
+ return if line_items.empty?
16
+
17
+ calculated = line_items.sum(&:amount)
18
+ return if total == calculated
19
+
20
+ errors.add(:total, "doesn't match line items sum (expected #{calculated})")
21
+ end
22
+
23
+ def due_date_in_future
24
+ return if due_date.blank?
25
+ return if due_date > Date.current
26
+
27
+ errors.add(:due_date, "must be in the future")
28
+ end
29
+
30
+ def cannot_exceed_credit_limit
31
+ return if customer.nil?
32
+ return if total <= customer.available_credit
33
+
34
+ errors.add(:total, "exceeds customer's available credit of #{customer.available_credit}")
35
+ end
36
+ end
37
+
38
+ # =============================================================================
39
+ # EachValidator - ATTRIBUTE-LEVEL REUSABLE VALIDATOR
40
+ # =============================================================================
41
+
42
+ # app/validators/email_validator.rb
43
+ class EmailValidator < ActiveModel::EachValidator
44
+ def validate_each(record, attribute, value)
45
+ return if value.blank? && options[:allow_blank]
46
+
47
+ unless URI::MailTo::EMAIL_REGEXP.match?(value)
48
+ record.errors.add(attribute, options[:message] || "is not a valid email address")
49
+ end
50
+ end
51
+ end
52
+
53
+ # app/validators/phone_validator.rb
54
+ class PhoneValidator < ActiveModel::EachValidator
55
+ PHONE_REGEX = /\A\+?[\d\s\-()]{10,}\z/
56
+
57
+ def validate_each(record, attribute, value)
58
+ return if value.blank? && options[:allow_blank]
59
+
60
+ unless PHONE_REGEX.match?(value)
61
+ record.errors.add(attribute, options[:message] || "is not a valid phone number")
62
+ end
63
+
64
+ # Additional format check for specific country
65
+ if options[:country] == :us && value.present?
66
+ unless value.gsub(/\D/, "").length == 10
67
+ record.errors.add(attribute, "must be a 10-digit US phone number")
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ # app/validators/url_validator.rb
74
+ class UrlValidator < ActiveModel::EachValidator
75
+ def validate_each(record, attribute, value)
76
+ return if value.blank? && options[:allow_blank]
77
+
78
+ begin
79
+ uri = URI.parse(value)
80
+ valid = uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
81
+ valid &&= uri.host.present?
82
+
83
+ # Optional: require HTTPS
84
+ if options[:https_only] && !uri.is_a?(URI::HTTPS)
85
+ record.errors.add(attribute, "must use HTTPS")
86
+ return
87
+ end
88
+
89
+ record.errors.add(attribute, options[:message] || "is not a valid URL") unless valid
90
+ rescue URI::InvalidURIError
91
+ record.errors.add(attribute, options[:message] || "is not a valid URL")
92
+ end
93
+ end
94
+ end
95
+
96
+ # Usage in models:
97
+ class User < ApplicationRecord
98
+ validates :email, email: true
99
+ validates :backup_email, email: { allow_blank: true }
100
+ validates :phone, phone: { allow_blank: true }
101
+ validates :mobile, phone: { country: :us }
102
+ validates :website, url: { allow_blank: true }
103
+ validates :api_endpoint, url: { https_only: true }
104
+ end
105
+
106
+ # =============================================================================
107
+ # VALIDATOR CLASS - RECORD-LEVEL VALIDATION
108
+ # =============================================================================
109
+
110
+ # app/validators/date_range_validator.rb
111
+ class DateRangeValidator < ActiveModel::Validator
112
+ def validate(record)
113
+ start_attr = options[:start] || :start_date
114
+ end_attr = options[:end] || :end_date
115
+
116
+ start_date = record.send(start_attr)
117
+ end_date = record.send(end_attr)
118
+
119
+ return if start_date.blank? || end_date.blank?
120
+
121
+ if end_date <= start_date
122
+ record.errors.add(end_attr, "must be after #{start_attr.to_s.humanize.downcase}")
123
+ end
124
+
125
+ # Optional: check maximum duration
126
+ if options[:max_duration] && (end_date - start_date) > options[:max_duration]
127
+ record.errors.add(end_attr, "cannot be more than #{options[:max_duration].inspect} after start")
128
+ end
129
+ end
130
+ end
131
+
132
+ # Usage:
133
+ class Event < ApplicationRecord
134
+ validates_with DateRangeValidator
135
+ end
136
+
137
+ class Reservation < ApplicationRecord
138
+ validates_with DateRangeValidator,
139
+ start: :check_in,
140
+ end: :check_out,
141
+ max_duration: 30.days
142
+ end
143
+
144
+ # =============================================================================
145
+ # COMPLEX VALIDATOR WITH EXTERNAL DATA
146
+ # =============================================================================
147
+
148
+ # app/validators/profanity_validator.rb
149
+ class ProfanityValidator < ActiveModel::EachValidator
150
+ PROFANITY_LIST_PATH = Rails.root.join("config", "profanity_list.txt")
151
+
152
+ def validate_each(record, attribute, value)
153
+ return if value.blank?
154
+
155
+ profane_words = value.downcase.split(/\W+/) & profanity_list
156
+
157
+ if profane_words.any?
158
+ record.errors.add(attribute, options[:message] || "contains inappropriate language")
159
+ end
160
+ end
161
+
162
+ private
163
+
164
+ def profanity_list
165
+ @profanity_list ||= File.read(PROFANITY_LIST_PATH).split("\n").map(&:strip).map(&:downcase)
166
+ rescue Errno::ENOENT
167
+ Rails.logger.warn("Profanity list not found at #{PROFANITY_LIST_PATH}")
168
+ []
169
+ end
170
+ end
171
+
172
+ # Usage:
173
+ class Comment < ApplicationRecord
174
+ validates :body, profanity: true
175
+ validates :title, profanity: { message: "must not contain bad words" }
176
+ end
177
+
178
+ # =============================================================================
179
+ # VALIDATOR WITH DATABASE LOOKUP
180
+ # =============================================================================
181
+
182
+ # app/validators/reserved_word_validator.rb
183
+ class ReservedWordValidator < ActiveModel::EachValidator
184
+ def validate_each(record, attribute, value)
185
+ return if value.blank?
186
+
187
+ # Check against database table of reserved words
188
+ if ReservedWord.exists?(word: value.downcase)
189
+ record.errors.add(attribute, options[:message] || "is reserved and cannot be used")
190
+ end
191
+
192
+ # Also check hardcoded list
193
+ if hardcoded_reserved.include?(value.downcase)
194
+ record.errors.add(attribute, options[:message] || "is a system reserved word")
195
+ end
196
+ end
197
+
198
+ private
199
+
200
+ def hardcoded_reserved
201
+ %w[admin root system api www mail ftp]
202
+ end
203
+ end
204
+
205
+ # Usage:
206
+ class Organization < ApplicationRecord
207
+ validates :subdomain, reserved_word: true
208
+ end
209
+
210
+ # =============================================================================
211
+ # VALIDATOR WITH COMPLEX BUSINESS LOGIC
212
+ # =============================================================================
213
+
214
+ # app/validators/business_hours_validator.rb
215
+ class BusinessHoursValidator < ActiveModel::Validator
216
+ def validate(record)
217
+ validate_no_overlaps(record)
218
+ validate_within_business_hours(record)
219
+ validate_minimum_duration(record)
220
+ end
221
+
222
+ private
223
+
224
+ def validate_no_overlaps(record)
225
+ return unless record.start_time && record.end_time
226
+
227
+ overlapping = record.class.where.not(id: record.id)
228
+ .where(resource_id: record.resource_id)
229
+ .where(date: record.date)
230
+ .where("start_time < ? AND end_time > ?", record.end_time, record.start_time)
231
+
232
+ if overlapping.exists?
233
+ record.errors.add(:base, "Booking overlaps with an existing reservation")
234
+ end
235
+ end
236
+
237
+ def validate_within_business_hours(record)
238
+ return unless record.start_time && record.end_time
239
+
240
+ business_start = Time.zone.parse("09:00")
241
+ business_end = Time.zone.parse("17:00")
242
+
243
+ if record.start_time.seconds_since_midnight < business_start.seconds_since_midnight ||
244
+ record.end_time.seconds_since_midnight > business_end.seconds_since_midnight
245
+ record.errors.add(:base, "Booking must be within business hours (9 AM - 5 PM)")
246
+ end
247
+ end
248
+
249
+ def validate_minimum_duration(record)
250
+ return unless record.start_time && record.end_time
251
+
252
+ min_duration = 30.minutes
253
+
254
+ if (record.end_time - record.start_time) < min_duration
255
+ record.errors.add(:base, "Booking must be at least 30 minutes")
256
+ end
257
+ end
258
+ end
259
+
260
+ # Usage:
261
+ class Booking < ApplicationRecord
262
+ validates_with BusinessHoursValidator
263
+ end
264
+
265
+ # =============================================================================
266
+ # COMPOSITION - COMBINING VALIDATORS
267
+ # =============================================================================
268
+
269
+ # app/validators/strong_password_validator.rb
270
+ class StrongPasswordValidator < ActiveModel::EachValidator
271
+ MIN_LENGTH = 8
272
+ MAX_LENGTH = 72
273
+
274
+ REQUIREMENTS = {
275
+ lowercase: /[a-z]/,
276
+ uppercase: /[A-Z]/,
277
+ digit: /\d/,
278
+ special: /[!@#$%^&*(),.?":{}|<>]/
279
+ }.freeze
280
+
281
+ def validate_each(record, attribute, value)
282
+ return if value.blank? && options[:allow_blank]
283
+
284
+ validate_length(record, attribute, value)
285
+ validate_complexity(record, attribute, value)
286
+ validate_not_common(record, attribute, value)
287
+ validate_not_user_data(record, attribute, value)
288
+ end
289
+
290
+ private
291
+
292
+ def validate_length(record, attribute, value)
293
+ if value.length < MIN_LENGTH
294
+ record.errors.add(attribute, "must be at least #{MIN_LENGTH} characters")
295
+ end
296
+
297
+ if value.length > MAX_LENGTH
298
+ record.errors.add(attribute, "must be at most #{MAX_LENGTH} characters")
299
+ end
300
+ end
301
+
302
+ def validate_complexity(record, attribute, value)
303
+ required = options[:require] || REQUIREMENTS.keys
304
+
305
+ required.each do |requirement|
306
+ regex = REQUIREMENTS[requirement]
307
+ unless value.match?(regex)
308
+ record.errors.add(attribute, "must contain at least one #{requirement.to_s.humanize.downcase}")
309
+ end
310
+ end
311
+ end
312
+
313
+ def validate_not_common(record, attribute, value)
314
+ common_passwords = %w[password 123456 qwerty letmein]
315
+
316
+ if common_passwords.include?(value.downcase)
317
+ record.errors.add(attribute, "is too common")
318
+ end
319
+ end
320
+
321
+ def validate_not_user_data(record, attribute, value)
322
+ user_data = [
323
+ record.try(:email)&.split("@")&.first,
324
+ record.try(:username),
325
+ record.try(:name)&.downcase
326
+ ].compact
327
+
328
+ if user_data.any? { |data| value.downcase.include?(data.to_s.downcase) }
329
+ record.errors.add(attribute, "cannot contain your personal information")
330
+ end
331
+ end
332
+ end
333
+
334
+ # Usage:
335
+ class User < ApplicationRecord
336
+ validates :password, strong_password: true, on: :create
337
+ validates :password, strong_password: { allow_blank: true }, on: :update
338
+
339
+ # Or with custom requirements
340
+ validates :admin_password, strong_password: {
341
+ require: [:lowercase, :uppercase, :digit, :special]
342
+ }
343
+ end
344
+
345
+ # =============================================================================
346
+ # PRIVATE VALIDATION METHODS (BEST PRACTICE)
347
+ # =============================================================================
348
+
349
+ class Transaction < ApplicationRecord
350
+ validate :validate_amount
351
+ validate :validate_balance
352
+ validate :validate_daily_limit
353
+
354
+ private # All custom validation methods should be private
355
+
356
+ def validate_amount
357
+ return if amount.blank?
358
+
359
+ errors.add(:amount, "must be positive") if amount <= 0
360
+ errors.add(:amount, "exceeds maximum single transaction") if amount > 10_000
361
+ end
362
+
363
+ def validate_balance
364
+ return if account.nil? || amount.nil?
365
+
366
+ if amount > account.balance
367
+ errors.add(:amount, "exceeds available balance of #{account.balance}")
368
+ end
369
+ end
370
+
371
+ def validate_daily_limit
372
+ return if account.nil? || amount.nil?
373
+
374
+ daily_total = account.transactions.where(created_at: Date.current.all_day).sum(:amount)
375
+
376
+ if (daily_total + amount) > account.daily_limit
377
+ remaining = account.daily_limit - daily_total
378
+ errors.add(:amount, "would exceed daily limit. Remaining: #{remaining}")
379
+ end
380
+ end
381
+ end