glancer 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 (142) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +96 -0
  3. data/.rubocop.yml +54 -0
  4. data/CHANGELOG.md +88 -0
  5. data/CLAUDE.md +115 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/README.md +354 -0
  8. data/app/assets/config/glancer_manifest.js +1 -0
  9. data/app/assets/javascripts/glancer/application.js +15 -0
  10. data/app/assets/javascripts/glancer/controllers/chat_controller.js +101 -0
  11. data/app/assets/javascripts/glancer/controllers/message_controller.js +1052 -0
  12. data/app/assets/javascripts/glancer/controllers/toast_controller.js +63 -0
  13. data/app/assets/stylesheets/glancer/application.css +350 -0
  14. data/app/assets/stylesheets/glancer/code-blocks.css +6 -0
  15. data/app/assets/stylesheets/glancer/list.css +31 -0
  16. data/app/assets/stylesheets/glancer/scrollbar.css +16 -0
  17. data/app/assets/stylesheets/glancer/table.css +97 -0
  18. data/app/controllers/glancer/application_controller.rb +33 -0
  19. data/app/controllers/glancer/chats_controller.rb +49 -0
  20. data/app/controllers/glancer/messages_controller.rb +144 -0
  21. data/app/controllers/glancer/schema_controller.rb +29 -0
  22. data/app/controllers/glancer/settings_controller.rb +23 -0
  23. data/app/helpers/glancer/application_helper.rb +17 -0
  24. data/app/jobs/glancer/application_job.rb +6 -0
  25. data/app/jobs/glancer/process_message_job.rb +38 -0
  26. data/app/models/glancer/audit.rb +12 -0
  27. data/app/models/glancer/chat.rb +8 -0
  28. data/app/models/glancer/code_version.rb +12 -0
  29. data/app/models/glancer/embedding.rb +6 -0
  30. data/app/models/glancer/message.rb +25 -0
  31. data/app/models/glancer/setting.rb +23 -0
  32. data/app/models/glancer/sql_version.rb +6 -0
  33. data/app/views/glancer/_data/_importmap.json.erb +7 -0
  34. data/app/views/glancer/chats/_chat_sidebar.html.erb +2 -0
  35. data/app/views/glancer/chats/_show.html.erb +52 -0
  36. data/app/views/glancer/chats/_sidebar_chat_list.html.erb +30 -0
  37. data/app/views/glancer/chats/index.html.erb +10 -0
  38. data/app/views/glancer/chats/show.html.erb +1 -0
  39. data/app/views/glancer/messages/_data_table.html.erb +268 -0
  40. data/app/views/glancer/messages/_execution_error.html.erb +26 -0
  41. data/app/views/glancer/messages/_form.html.erb +93 -0
  42. data/app/views/glancer/messages/_message.html.erb +206 -0
  43. data/app/views/glancer/messages/_message_info.html.erb +176 -0
  44. data/app/views/glancer/messages/_temp_form.html.erb +100 -0
  45. data/app/views/glancer/messages/create.turbo_stream.erb +25 -0
  46. data/app/views/glancer/schema/show.html.erb +123 -0
  47. data/app/views/glancer/settings/show.html.erb +306 -0
  48. data/app/views/glancer/shared/_icons.html.erb +126 -0
  49. data/app/views/layouts/glancer/application.html.erb +234 -0
  50. data/config/locales/glancer.en.yml +90 -0
  51. data/config/locales/glancer.es.yml +90 -0
  52. data/config/locales/glancer.pt-BR.yml +90 -0
  53. data/config/routes.rb +20 -0
  54. data/db/migrate/20250629212642_create_glancer_audits.rb +19 -0
  55. data/db/migrate/20250629212643_create_glancer_chats.rb +10 -0
  56. data/db/migrate/20250629212645_create_glancer_embeddings.rb +17 -0
  57. data/db/migrate/20250629212647_create_glancer_messages.rb +29 -0
  58. data/db/migrate/20260513204129_add_user_edited_sql_to_glancer_messages.rb +11 -0
  59. data/db/migrate/20260513210647_create_glancer_sql_versions.rb +18 -0
  60. data/db/migrate/20260513210648_add_message_id_to_glancer_audits.rb +8 -0
  61. data/db/migrate/20260513220000_create_glancer_settings.rb +12 -0
  62. data/db/migrate/20260514083509_add_llm_model_to_glancer_messages.rb +9 -0
  63. data/db/migrate/20260523120000_rename_code_columns_in_glancer_messages.rb +8 -0
  64. data/db/migrate/20260523120001_rename_code_column_in_glancer_audits.rb +7 -0
  65. data/db/migrate/20260523120002_add_code_type_to_glancer_tables.rb +10 -0
  66. data/db/migrate/20260523120003_rename_glancer_sql_versions_to_code_versions.rb +8 -0
  67. data/db/migrate/20260523130000_add_enriched_question_to_glancer_messages.rb +7 -0
  68. data/db/migrate/20260524100000_add_status_to_glancer_messages.rb +9 -0
  69. data/lib/generators/glancer/install/install_generator.rb +74 -0
  70. data/lib/generators/glancer/install/templates/glancer.rb +227 -0
  71. data/lib/generators/glancer/install/templates/llm_context.glancer.md +51 -0
  72. data/lib/glancer/async_runner.rb +50 -0
  73. data/lib/glancer/chart_analyzer.rb +230 -0
  74. data/lib/glancer/configuration.rb +372 -0
  75. data/lib/glancer/engine.rb +90 -0
  76. data/lib/glancer/indexer/context_indexer.rb +58 -0
  77. data/lib/glancer/indexer/model_indexer.rb +64 -0
  78. data/lib/glancer/indexer/schema_indexer.rb +171 -0
  79. data/lib/glancer/indexer.rb +50 -0
  80. data/lib/glancer/retriever.rb +114 -0
  81. data/lib/glancer/utils/logger.rb +83 -0
  82. data/lib/glancer/utils/markdown_helper.rb +56 -0
  83. data/lib/glancer/utils/result_formatter.rb +25 -0
  84. data/lib/glancer/utils/table_stats.rb +18 -0
  85. data/lib/glancer/utils/transaction.rb +59 -0
  86. data/lib/glancer/version.rb +5 -0
  87. data/lib/glancer/workflow/ar_executor.rb +104 -0
  88. data/lib/glancer/workflow/ar_extractor.rb +25 -0
  89. data/lib/glancer/workflow/ar_prompt_builder.rb +64 -0
  90. data/lib/glancer/workflow/ar_sanitizer.rb +88 -0
  91. data/lib/glancer/workflow/builder.rb +129 -0
  92. data/lib/glancer/workflow/cache.rb +55 -0
  93. data/lib/glancer/workflow/executor.rb +72 -0
  94. data/lib/glancer/workflow/llm.rb +123 -0
  95. data/lib/glancer/workflow/prompt_builder.rb +143 -0
  96. data/lib/glancer/workflow/query_enricher.rb +117 -0
  97. data/lib/glancer/workflow/sql_extractor.rb +42 -0
  98. data/lib/glancer/workflow/sql_sanitizer.rb +42 -0
  99. data/lib/glancer/workflow/sql_validator.rb +67 -0
  100. data/lib/glancer/workflow.rb +158 -0
  101. data/lib/glancer.rb +50 -0
  102. data/lib/tasks/glancer/tailwind.rake +8 -0
  103. data/lib/tasks/glancer.rake +99 -0
  104. data/spec/glancer_spec.rb +62 -0
  105. data/spec/lib/glancer/async_runner_spec.rb +133 -0
  106. data/spec/lib/glancer/chart_analyzer_spec.rb +296 -0
  107. data/spec/lib/glancer/configuration_spec.rb +858 -0
  108. data/spec/lib/glancer/engine_spec.rb +209 -0
  109. data/spec/lib/glancer/indexer/context_indexer_spec.rb +96 -0
  110. data/spec/lib/glancer/indexer/model_indexer_spec.rb +103 -0
  111. data/spec/lib/glancer/indexer/schema_indexer_spec.rb +382 -0
  112. data/spec/lib/glancer/indexer_spec.rb +95 -0
  113. data/spec/lib/glancer/retriever_spec.rb +179 -0
  114. data/spec/lib/glancer/utils/logger_spec.rb +85 -0
  115. data/spec/lib/glancer/utils/markdown_helper_spec.rb +92 -0
  116. data/spec/lib/glancer/utils/result_formatter_spec.rb +73 -0
  117. data/spec/lib/glancer/utils/table_stats_spec.rb +34 -0
  118. data/spec/lib/glancer/utils/transaction_spec.rb +73 -0
  119. data/spec/lib/glancer/workflow/ar_executor_spec.rb +155 -0
  120. data/spec/lib/glancer/workflow/ar_extractor_spec.rb +50 -0
  121. data/spec/lib/glancer/workflow/ar_prompt_builder_spec.rb +79 -0
  122. data/spec/lib/glancer/workflow/ar_sanitizer_spec.rb +175 -0
  123. data/spec/lib/glancer/workflow/builder_spec.rb +204 -0
  124. data/spec/lib/glancer/workflow/cache_spec.rb +142 -0
  125. data/spec/lib/glancer/workflow/executor_spec.rb +149 -0
  126. data/spec/lib/glancer/workflow/llm_spec.rb +124 -0
  127. data/spec/lib/glancer/workflow/prompt_builder_spec.rb +196 -0
  128. data/spec/lib/glancer/workflow/query_enricher_spec.rb +184 -0
  129. data/spec/lib/glancer/workflow/sql_extractor_spec.rb +82 -0
  130. data/spec/lib/glancer/workflow/sql_sanitizer_spec.rb +98 -0
  131. data/spec/lib/glancer/workflow/sql_validator_spec.rb +166 -0
  132. data/spec/lib/glancer/workflow_spec.rb +308 -0
  133. data/spec/models/glancer/audit_spec.rb +82 -0
  134. data/spec/models/glancer/chat_spec.rb +60 -0
  135. data/spec/models/glancer/code_version_spec.rb +71 -0
  136. data/spec/models/glancer/embedding_spec.rb +73 -0
  137. data/spec/models/glancer/message_spec.rb +144 -0
  138. data/spec/models/glancer/setting_spec.rb +88 -0
  139. data/spec/models/glancer/sql_version_spec.rb +4 -0
  140. data/spec/spec_helper.rb +128 -0
  141. data/spec/support/schema.rb +55 -0
  142. metadata +255 -0
@@ -0,0 +1,858 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Configuration do
6
+ subject(:config) { described_class.new }
7
+
8
+ # ── Constants ─────────────────────────────────────────────────────────────────
9
+
10
+ describe "constants" do
11
+ it "defines ADAPTERS_SUPPORTED" do
12
+ expect(described_class::ADAPTERS_SUPPORTED).to contain_exactly(:postgres, :mysql, :mysql2, :sqlite)
13
+ end
14
+
15
+ it "defines LLM_PROVIDERS" do
16
+ expect(described_class::LLM_PROVIDERS).to contain_exactly(:gemini, :openai, :openrouter)
17
+ end
18
+
19
+ it "defines LOG_VERBOSITY_LEVELS" do
20
+ expect(described_class::LOG_VERBOSITY_LEVELS).to contain_exactly(:silent, :none, :info, :debug)
21
+ end
22
+
23
+ it "defines EMBEDDING_DEFAULTS for each LLM provider" do
24
+ expect(described_class::EMBEDDING_DEFAULTS.keys).to contain_exactly(:gemini, :openai, :openrouter)
25
+ end
26
+
27
+ it "defines QUERY_MODES" do
28
+ expect(described_class::QUERY_MODES).to contain_exactly(:sql, :activerecord)
29
+ end
30
+ end
31
+
32
+ # ── Default values ────────────────────────────────────────────────────────────
33
+
34
+ describe "default values" do
35
+ it "sets llm_provider to :gemini" do
36
+ expect(config.llm_provider).to eq(:gemini)
37
+ end
38
+
39
+ it "sets llm_model to gemini-2.0-flash" do
40
+ expect(config.llm_model).to eq("gemini-2.0-flash")
41
+ end
42
+
43
+ it "sets schema_permission to false" do
44
+ expect(config.schema_permission).to be(false)
45
+ end
46
+
47
+ it "sets models_permission to false" do
48
+ expect(config.models_permission).to be(false)
49
+ end
50
+
51
+ it "sets log_verbosity to :info" do
52
+ expect(config.log_verbosity).to eq(:info)
53
+ end
54
+
55
+ it "sets k to 5" do
56
+ expect(config.k).to eq(5)
57
+ end
58
+
59
+ it "sets min_score to 0.6" do
60
+ expect(config.min_score).to eq(0.6)
61
+ end
62
+
63
+ it "sets schema_documents_weight to 1.3" do
64
+ expect(config.schema_documents_weight).to eq(1.3)
65
+ end
66
+
67
+ it "sets context_documents_weight to 1.2" do
68
+ expect(config.context_documents_weight).to eq(1.2)
69
+ end
70
+
71
+ it "sets models_documents_weight to 1.1" do
72
+ expect(config.models_documents_weight).to eq(1.1)
73
+ end
74
+
75
+ it "sets chunk_size to 1000" do
76
+ expect(config.chunk_size).to eq(1000)
77
+ end
78
+
79
+ it "sets chunk_overlap to 150" do
80
+ expect(config.chunk_overlap).to eq(150)
81
+ end
82
+
83
+ it "sets history_limit to 6" do
84
+ expect(config.history_limit).to eq(6)
85
+ end
86
+
87
+ it "sets embedding_provider to nil" do
88
+ expect(config.embedding_provider).to be_nil
89
+ end
90
+
91
+ it "sets embedding_model to nil" do
92
+ expect(config.embedding_model).to be_nil
93
+ end
94
+
95
+ it "sets code_provider to nil" do
96
+ expect(config.code_provider).to be_nil
97
+ end
98
+
99
+ it "sets code_model to nil" do
100
+ expect(config.code_model).to be_nil
101
+ end
102
+
103
+ it "sets chat_provider to nil" do
104
+ expect(config.chat_provider).to be_nil
105
+ end
106
+
107
+ it "sets chat_model to nil" do
108
+ expect(config.chat_model).to be_nil
109
+ end
110
+
111
+ it "sets blazer_path to nil" do
112
+ expect(config.blazer_path).to be_nil
113
+ end
114
+
115
+ it "sets query_mode to :sql" do
116
+ expect(config.query_mode).to eq(:sql)
117
+ end
118
+
119
+ it "sets context_file_path to a string" do
120
+ expect(config.context_file_path).to be_a(String)
121
+ end
122
+
123
+ it "sets api_key to nil" do
124
+ expect(config.api_key).to be_nil
125
+ end
126
+
127
+ it "sets gemini_api_key to nil" do
128
+ expect(config.gemini_api_key).to be_nil
129
+ end
130
+
131
+ it "sets openai_api_key to nil" do
132
+ expect(config.openai_api_key).to be_nil
133
+ end
134
+
135
+ it "sets openrouter_api_key to nil" do
136
+ expect(config.openrouter_api_key).to be_nil
137
+ end
138
+
139
+ it "sets log_output_path to nil" do
140
+ expect(config.log_output_path).to be_nil
141
+ end
142
+
143
+ it "sets read_only_db to nil" do
144
+ expect(config.read_only_db).to be_nil
145
+ end
146
+ end
147
+
148
+ # ── adapter= ─────────────────────────────────────────────────────────────────
149
+
150
+ describe "#adapter=" do
151
+ it "accepts :sqlite" do
152
+ config.adapter = :sqlite
153
+ expect(config.adapter).to eq(:sqlite)
154
+ end
155
+
156
+ it "accepts :postgres" do
157
+ config.adapter = :postgres
158
+ expect(config.adapter).to eq(:postgres)
159
+ end
160
+
161
+ it "accepts :mysql" do
162
+ config.adapter = :mysql
163
+ expect(config.adapter).to eq(:mysql)
164
+ end
165
+
166
+ it "accepts :mysql2" do
167
+ config.adapter = :mysql2
168
+ expect(config.adapter).to eq(:mysql2)
169
+ end
170
+
171
+ it "raises ArgumentError for an unsupported adapter" do
172
+ expect { config.adapter = :oracle }.to raise_error(ArgumentError, /adapter must be/)
173
+ end
174
+ end
175
+
176
+ # ── read_only_db= ─────────────────────────────────────────────────────────────
177
+
178
+ describe "#read_only_db=" do
179
+ it "accepts nil" do
180
+ config.read_only_db = nil
181
+ expect(config.read_only_db).to be_nil
182
+ end
183
+
184
+ it "accepts a connection URL string" do
185
+ config.read_only_db = "sqlite3:///tmp/test.db"
186
+ expect(config.read_only_db).to eq("sqlite3:///tmp/test.db")
187
+ end
188
+
189
+ it "accepts :read_only symbol" do
190
+ config.read_only_db = :read_only
191
+ expect(config.read_only_db).to eq(:read_only)
192
+ end
193
+
194
+ it "raises ArgumentError for an integer" do
195
+ expect { config.read_only_db = 42 }.to raise_error(ArgumentError)
196
+ end
197
+ end
198
+
199
+ # ── llm_provider= ────────────────────────────────────────────────────────────
200
+
201
+ describe "#llm_provider=" do
202
+ described_class::LLM_PROVIDERS.each do |provider|
203
+ it "accepts #{provider}" do
204
+ config.llm_provider = provider
205
+ expect(config.llm_provider).to eq(provider)
206
+ end
207
+ end
208
+
209
+ it "raises ArgumentError for an unsupported provider" do
210
+ expect { config.llm_provider = :anthropic }.to raise_error(ArgumentError, /llm_provider must be/)
211
+ end
212
+ end
213
+
214
+ # ── llm_model= ───────────────────────────────────────────────────────────────
215
+
216
+ describe "#llm_model=" do
217
+ it "accepts a String" do
218
+ config.llm_model = "gpt-4o"
219
+ expect(config.llm_model).to eq("gpt-4o")
220
+ end
221
+
222
+ it "raises ArgumentError for a non-String" do
223
+ expect { config.llm_model = 123 }.to raise_error(ArgumentError, /llm_model must be a String/)
224
+ end
225
+ end
226
+
227
+ # ── schema_permission= ───────────────────────────────────────────────────────
228
+
229
+ describe "#schema_permission=" do
230
+ it "accepts true" do
231
+ config.schema_permission = true
232
+ expect(config.schema_permission).to be(true)
233
+ end
234
+
235
+ it "accepts false" do
236
+ config.schema_permission = false
237
+ expect(config.schema_permission).to be(false)
238
+ end
239
+
240
+ it "raises ArgumentError for non-boolean" do
241
+ expect { config.schema_permission = "yes" }.to raise_error(ArgumentError)
242
+ end
243
+ end
244
+
245
+ # ── models_permission= ───────────────────────────────────────────────────────
246
+
247
+ describe "#models_permission=" do
248
+ it "accepts true and false" do
249
+ config.models_permission = true
250
+ expect(config.models_permission).to be(true)
251
+ config.models_permission = false
252
+ expect(config.models_permission).to be(false)
253
+ end
254
+
255
+ it "raises ArgumentError for non-boolean" do
256
+ expect { config.models_permission = 1 }.to raise_error(ArgumentError)
257
+ end
258
+ end
259
+
260
+ # ── workflow_cache_ttl= ──────────────────────────────────────────────────────
261
+
262
+ describe "#workflow_cache_ttl=" do
263
+ it "accepts a numeric" do
264
+ config.workflow_cache_ttl = 300
265
+ expect(config.workflow_cache_ttl).to eq(300)
266
+ end
267
+
268
+ it "raises ArgumentError for something that doesn't respond to to_i" do
269
+ expect { config.workflow_cache_ttl = Object.new }.to raise_error(ArgumentError)
270
+ end
271
+ end
272
+
273
+ # ── context_file_path= ───────────────────────────────────────────────────────
274
+
275
+ describe "#context_file_path=" do
276
+ it "accepts a String path" do
277
+ config.context_file_path = "config/my.md"
278
+ expect(config.context_file_path).to eq("config/my.md")
279
+ end
280
+
281
+ it "raises ArgumentError for a non-String" do
282
+ expect { config.context_file_path = :symbol }.to raise_error(ArgumentError)
283
+ end
284
+ end
285
+
286
+ # ── api_key= / gemini_api_key= / openai_api_key= / openrouter_api_key= ───────
287
+
288
+ %i[api_key gemini_api_key openai_api_key openrouter_api_key].each do |attr|
289
+ describe "##{attr}=" do
290
+ it "accepts nil" do
291
+ config.public_send(:"#{attr}=", nil)
292
+ expect(config.public_send(attr)).to be_nil
293
+ end
294
+
295
+ it "accepts a String" do
296
+ config.public_send(:"#{attr}=", "sk-abc123")
297
+ expect(config.public_send(attr)).to eq("sk-abc123")
298
+ end
299
+
300
+ it "raises ArgumentError for a non-String / non-nil" do
301
+ expect { config.public_send(:"#{attr}=", 42) }.to raise_error(ArgumentError)
302
+ end
303
+ end
304
+ end
305
+
306
+ # ── log_output_path= ─────────────────────────────────────────────────────────
307
+
308
+ describe "#log_output_path=" do
309
+ it "accepts nil" do
310
+ config.log_output_path = nil
311
+ expect(config.log_output_path).to be_nil
312
+ end
313
+
314
+ it "accepts a String path" do
315
+ config.log_output_path = "/var/log/glancer.log"
316
+ expect(config.log_output_path).to eq("/var/log/glancer.log")
317
+ end
318
+
319
+ it "raises ArgumentError for non-String / non-nil" do
320
+ expect { config.log_output_path = true }.to raise_error(ArgumentError)
321
+ end
322
+ end
323
+
324
+ # ── log_verbosity= ───────────────────────────────────────────────────────────
325
+
326
+ describe "#log_verbosity=" do
327
+ described_class::LOG_VERBOSITY_LEVELS.each do |level|
328
+ it "accepts :#{level}" do
329
+ config.log_verbosity = level
330
+ expect(config.log_verbosity).to eq(level)
331
+ end
332
+ end
333
+
334
+ it "raises ArgumentError for an invalid verbosity" do
335
+ expect { config.log_verbosity = :verbose }.to raise_error(ArgumentError)
336
+ end
337
+ end
338
+
339
+ # ── k= ───────────────────────────────────────────────────────────────────────
340
+
341
+ describe "#k=" do
342
+ it "accepts an integer >= 1" do
343
+ config.k = 10
344
+ expect(config.k).to eq(10)
345
+ end
346
+
347
+ it "accepts 1 (minimum)" do
348
+ config.k = 1
349
+ expect(config.k).to eq(1)
350
+ end
351
+
352
+ it "raises ArgumentError for 0" do
353
+ expect { config.k = 0 }.to raise_error(ArgumentError, /k must be an integer/)
354
+ end
355
+
356
+ it "raises ArgumentError for a float" do
357
+ expect { config.k = 2.5 }.to raise_error(ArgumentError)
358
+ end
359
+ end
360
+
361
+ # ── min_score= ───────────────────────────────────────────────────────────────
362
+
363
+ describe "#min_score=" do
364
+ it "accepts 0.0" do
365
+ config.min_score = 0.0
366
+ expect(config.min_score).to eq(0.0)
367
+ end
368
+
369
+ it "accepts 1.0" do
370
+ config.min_score = 1.0
371
+ expect(config.min_score).to eq(1.0)
372
+ end
373
+
374
+ it "accepts a value in between" do
375
+ config.min_score = 0.75
376
+ expect(config.min_score).to eq(0.75)
377
+ end
378
+
379
+ it "raises ArgumentError above 1.0" do
380
+ expect { config.min_score = 1.1 }.to raise_error(ArgumentError)
381
+ end
382
+
383
+ it "raises ArgumentError below 0.0" do
384
+ expect { config.min_score = -0.1 }.to raise_error(ArgumentError)
385
+ end
386
+
387
+ it "raises ArgumentError for a non-Numeric" do
388
+ expect { config.min_score = "0.5" }.to raise_error(ArgumentError)
389
+ end
390
+ end
391
+
392
+ # ── weight setters ────────────────────────────────────────────────────────────
393
+
394
+ %i[schema_documents_weight context_documents_weight models_documents_weight].each do |attr|
395
+ describe "##{attr}=" do
396
+ it "accepts a numeric >= 1" do
397
+ config.public_send(:"#{attr}=", 1.5)
398
+ expect(config.public_send(attr)).to eq(1.5)
399
+ end
400
+
401
+ it "accepts exactly 1" do
402
+ config.public_send(:"#{attr}=", 1)
403
+ expect(config.public_send(attr)).to eq(1)
404
+ end
405
+
406
+ it "raises ArgumentError for a value below 1" do
407
+ expect { config.public_send(:"#{attr}=", 0.9) }.to raise_error(ArgumentError)
408
+ end
409
+
410
+ it "raises ArgumentError for a non-Numeric" do
411
+ expect { config.public_send(:"#{attr}=", "big") }.to raise_error(ArgumentError)
412
+ end
413
+ end
414
+ end
415
+
416
+ # ── chunk_size= ──────────────────────────────────────────────────────────────
417
+
418
+ describe "#chunk_size=" do
419
+ it "accepts 100 (minimum)" do
420
+ config.chunk_size = 100
421
+ expect(config.chunk_size).to eq(100)
422
+ end
423
+
424
+ it "raises ArgumentError for 99" do
425
+ expect { config.chunk_size = 99 }.to raise_error(ArgumentError)
426
+ end
427
+
428
+ it "raises ArgumentError for a float" do
429
+ expect { config.chunk_size = 200.5 }.to raise_error(ArgumentError)
430
+ end
431
+ end
432
+
433
+ # ── chunk_overlap= ───────────────────────────────────────────────────────────
434
+
435
+ describe "#chunk_overlap=" do
436
+ it "accepts 0" do
437
+ config.chunk_overlap = 0
438
+ expect(config.chunk_overlap).to eq(0)
439
+ end
440
+
441
+ it "accepts positive integers" do
442
+ config.chunk_overlap = 50
443
+ expect(config.chunk_overlap).to eq(50)
444
+ end
445
+
446
+ it "raises ArgumentError for negative values" do
447
+ expect { config.chunk_overlap = -1 }.to raise_error(ArgumentError)
448
+ end
449
+ end
450
+
451
+ # ── history_limit= ───────────────────────────────────────────────────────────
452
+
453
+ describe "#history_limit=" do
454
+ it "accepts 1" do
455
+ config.history_limit = 1
456
+ expect(config.history_limit).to eq(1)
457
+ end
458
+
459
+ it "raises ArgumentError for 0" do
460
+ expect { config.history_limit = 0 }.to raise_error(ArgumentError)
461
+ end
462
+ end
463
+
464
+ # ── statement_timeout= ───────────────────────────────────────────────────────
465
+
466
+ describe "#statement_timeout=" do
467
+ it "accepts a numeric" do
468
+ config.statement_timeout = 60
469
+ expect(config.statement_timeout).to eq(60)
470
+ end
471
+
472
+ it "raises ArgumentError for an object that doesn't respond to to_i" do
473
+ expect { config.statement_timeout = Object.new }.to raise_error(ArgumentError)
474
+ end
475
+ end
476
+
477
+ # ── embedding_provider= ──────────────────────────────────────────────────────
478
+
479
+ describe "#embedding_provider=" do
480
+ it "accepts nil" do
481
+ config.embedding_provider = nil
482
+ expect(config.embedding_provider).to be_nil
483
+ end
484
+
485
+ described_class::LLM_PROVIDERS.each do |p|
486
+ it "accepts :#{p}" do
487
+ config.embedding_provider = p
488
+ expect(config.embedding_provider).to eq(p)
489
+ end
490
+ end
491
+
492
+ it "raises ArgumentError for an invalid provider" do
493
+ expect { config.embedding_provider = :unknown }.to raise_error(ArgumentError)
494
+ end
495
+ end
496
+
497
+ # ── embedding_model= ─────────────────────────────────────────────────────────
498
+
499
+ describe "#embedding_model=" do
500
+ it "accepts nil" do
501
+ config.embedding_model = nil
502
+ expect(config.embedding_model).to be_nil
503
+ end
504
+
505
+ it "accepts a String" do
506
+ config.embedding_model = "text-embedding-3-small"
507
+ expect(config.embedding_model).to eq("text-embedding-3-small")
508
+ end
509
+
510
+ it "raises ArgumentError for non-String / non-nil" do
511
+ expect { config.embedding_model = 42 }.to raise_error(ArgumentError)
512
+ end
513
+ end
514
+
515
+ # ── code_provider= / code_model= ───────────────────────────────────────────────
516
+
517
+ describe "#code_provider=" do
518
+ it "accepts nil" do
519
+ config.code_provider = nil
520
+ expect(config.code_provider).to be_nil
521
+ end
522
+
523
+ it "accepts a valid LLM provider" do
524
+ config.code_provider = :openai
525
+ expect(config.code_provider).to eq(:openai)
526
+ end
527
+
528
+ it "raises ArgumentError for invalid provider" do
529
+ expect { config.code_provider = :bad }.to raise_error(ArgumentError)
530
+ end
531
+ end
532
+
533
+ describe "#code_model=" do
534
+ it "accepts nil" do
535
+ config.code_model = nil
536
+ expect(config.code_model).to be_nil
537
+ end
538
+
539
+ it "accepts a String" do
540
+ config.code_model = "gpt-4o"
541
+ expect(config.code_model).to eq("gpt-4o")
542
+ end
543
+
544
+ it "raises ArgumentError for non-String / non-nil" do
545
+ expect { config.code_model = 1 }.to raise_error(ArgumentError)
546
+ end
547
+ end
548
+
549
+ # ── chat_provider= / chat_model= ─────────────────────────────────────────────
550
+
551
+ describe "#chat_provider=" do
552
+ it "accepts nil" do
553
+ config.chat_provider = nil
554
+ expect(config.chat_provider).to be_nil
555
+ end
556
+
557
+ it "accepts a valid LLM provider" do
558
+ config.chat_provider = :openrouter
559
+ expect(config.chat_provider).to eq(:openrouter)
560
+ end
561
+
562
+ it "raises ArgumentError for invalid provider" do
563
+ expect { config.chat_provider = :bad }.to raise_error(ArgumentError)
564
+ end
565
+ end
566
+
567
+ describe "#chat_model=" do
568
+ it "accepts nil and String" do
569
+ config.chat_model = nil
570
+ expect(config.chat_model).to be_nil
571
+ config.chat_model = "claude-3"
572
+ expect(config.chat_model).to eq("claude-3")
573
+ end
574
+
575
+ it "raises ArgumentError for non-String / non-nil" do
576
+ expect { config.chat_model = true }.to raise_error(ArgumentError)
577
+ end
578
+ end
579
+
580
+ # ── blazer_path= ─────────────────────────────────────────────────────────────
581
+
582
+ describe "#blazer_path=" do
583
+ it "accepts nil" do
584
+ config.blazer_path = nil
585
+ expect(config.blazer_path).to be_nil
586
+ end
587
+
588
+ it "accepts a String" do
589
+ config.blazer_path = "/analytics"
590
+ expect(config.blazer_path).to eq("/analytics")
591
+ end
592
+
593
+ it "raises ArgumentError for non-String / non-nil" do
594
+ expect { config.blazer_path = :auto }.to raise_error(ArgumentError)
595
+ end
596
+ end
597
+
598
+ # ── Resolved methods ──────────────────────────────────────────────────────────
599
+
600
+ describe "#resolved_embedding_provider" do
601
+ it "falls back to llm_provider when embedding_provider is nil" do
602
+ config.embedding_provider = nil
603
+ config.llm_provider = :openai
604
+ expect(config.resolved_embedding_provider).to eq(:openai)
605
+ end
606
+
607
+ it "returns embedding_provider when set" do
608
+ config.llm_provider = :gemini
609
+ config.embedding_provider = :openai
610
+ expect(config.resolved_embedding_provider).to eq(:openai)
611
+ end
612
+ end
613
+
614
+ describe "#resolved_embedding_model" do
615
+ it "returns the configured embedding_model when set" do
616
+ config.embedding_model = "my-custom-model"
617
+ expect(config.resolved_embedding_model).to eq("my-custom-model")
618
+ end
619
+
620
+ it "falls back to EMBEDDING_DEFAULTS for gemini" do
621
+ config.embedding_model = nil
622
+ config.embedding_provider = :gemini
623
+ expect(config.resolved_embedding_model).to eq(described_class::EMBEDDING_DEFAULTS[:gemini])
624
+ end
625
+
626
+ it "falls back to EMBEDDING_DEFAULTS for openai" do
627
+ config.embedding_model = nil
628
+ config.embedding_provider = :openai
629
+ expect(config.resolved_embedding_model).to eq(described_class::EMBEDDING_DEFAULTS[:openai])
630
+ end
631
+ end
632
+
633
+ describe "#resolved_code_provider" do
634
+ it "falls back to llm_provider when code_provider is nil" do
635
+ config.code_provider = nil
636
+ config.llm_provider = :openai
637
+ expect(config.resolved_code_provider).to eq(:openai)
638
+ end
639
+
640
+ it "returns code_provider when set" do
641
+ config.code_provider = :openrouter
642
+ expect(config.resolved_code_provider).to eq(:openrouter)
643
+ end
644
+ end
645
+
646
+ describe "#resolved_code_model" do
647
+ it "falls back to llm_model when code_model is nil" do
648
+ config.code_model = nil
649
+ config.llm_model = "base-model"
650
+ expect(config.resolved_code_model).to eq("base-model")
651
+ end
652
+
653
+ it "returns code_model when set" do
654
+ config.code_model = "sql-specialist"
655
+ expect(config.resolved_code_model).to eq("sql-specialist")
656
+ end
657
+ end
658
+
659
+ describe "#resolved_chat_provider" do
660
+ it "falls back to llm_provider when chat_provider is nil" do
661
+ config.chat_provider = nil
662
+ config.llm_provider = :openai
663
+ expect(config.resolved_chat_provider).to eq(:openai)
664
+ end
665
+
666
+ it "returns chat_provider when set" do
667
+ config.chat_provider = :openrouter
668
+ expect(config.resolved_chat_provider).to eq(:openrouter)
669
+ end
670
+ end
671
+
672
+ describe "#resolved_chat_model" do
673
+ it "falls back to llm_model when chat_model is nil" do
674
+ config.chat_model = nil
675
+ config.llm_model = "base-model"
676
+ expect(config.resolved_chat_model).to eq("base-model")
677
+ end
678
+
679
+ it "returns chat_model when set" do
680
+ config.chat_model = "chat-specialist"
681
+ expect(config.resolved_chat_model).to eq("chat-specialist")
682
+ end
683
+ end
684
+
685
+ describe "#resolved_blazer_path" do
686
+ it "returns nil when Blazer is not defined" do
687
+ hide_const("Blazer::Engine") if defined?(Blazer::Engine)
688
+ config.blazer_path = nil
689
+ expect(config.resolved_blazer_path).to be_nil
690
+ end
691
+
692
+ it "returns explicitly set blazer_path over auto-detection" do
693
+ config.blazer_path = "/my-blazer"
694
+ expect(config.resolved_blazer_path).to eq("/my-blazer")
695
+ end
696
+ end
697
+
698
+ describe "#resolved_adapter" do
699
+ it "returns the configured adapter" do
700
+ config.adapter = :sqlite
701
+ expect(config.resolved_adapter).to eq(:sqlite)
702
+ end
703
+
704
+ it "normalises 'postgresql' string to :postgres" do
705
+ allow(config).to receive(:adapter).and_return("postgresql")
706
+ expect(config.resolved_adapter).to eq(:postgres)
707
+ end
708
+ end
709
+
710
+ # ── Class methods ─────────────────────────────────────────────────────────────
711
+
712
+ describe ".infer_adapter" do
713
+ it "returns :sqlite when using the test SQLite connection" do
714
+ adapter = described_class.infer_adapter
715
+ expect(adapter).to eq(:sqlite)
716
+ end
717
+
718
+ it "returns nil when ActiveRecord raises" do
719
+ allow(ActiveRecord::Base).to receive(:connection).and_raise(StandardError)
720
+ expect(described_class.infer_adapter).to be_nil
721
+ end
722
+ end
723
+
724
+ describe ".valid_table_name?" do
725
+ it "returns true for a table that exists (glancer_chats)" do
726
+ expect(described_class.valid_table_name?("glancer_chats")).to be(true)
727
+ end
728
+
729
+ it "returns false for a table that does not exist" do
730
+ expect(described_class.valid_table_name?("nonexistent_table")).to be(false)
731
+ end
732
+
733
+ it "returns false when ActiveRecord raises" do
734
+ allow(ActiveRecord::Base).to receive(:connection).and_raise(StandardError)
735
+ expect(described_class.valid_table_name?("anything")).to be(false)
736
+ end
737
+ end
738
+
739
+ # ── query_mode= ──────────────────────────────────────────────────────────────
740
+
741
+ describe "#query_mode=" do
742
+ it "accepts :sql" do
743
+ config.query_mode = :sql
744
+ expect(config.query_mode).to eq(:sql)
745
+ end
746
+
747
+ it "accepts :activerecord" do
748
+ config.query_mode = :activerecord
749
+ expect(config.query_mode).to eq(:activerecord)
750
+ end
751
+
752
+ it "raises ArgumentError for an invalid mode" do
753
+ expect { config.query_mode = :graphql }
754
+ .to raise_error(ArgumentError, /query_mode must be one of/)
755
+ end
756
+
757
+ it "raises ArgumentError for a String value" do
758
+ expect { config.query_mode = "sql" }.to raise_error(ArgumentError)
759
+ end
760
+ end
761
+
762
+ # ── query_enrichment_enabled= ─────────────────────────────────────────────
763
+
764
+ describe "#query_enrichment_enabled=" do
765
+ it "defaults to false" do
766
+ expect(config.query_enrichment_enabled).to be(false)
767
+ end
768
+
769
+ it "accepts true" do
770
+ config.query_enrichment_enabled = true
771
+ expect(config.query_enrichment_enabled).to be(true)
772
+ end
773
+
774
+ it "accepts false" do
775
+ config.query_enrichment_enabled = true
776
+ config.query_enrichment_enabled = false
777
+ expect(config.query_enrichment_enabled).to be(false)
778
+ end
779
+
780
+ it "raises ArgumentError for non-boolean values" do
781
+ expect { config.query_enrichment_enabled = "yes" }.to raise_error(ArgumentError)
782
+ end
783
+ end
784
+
785
+ # ── enrichment_provider= ──────────────────────────────────────────────────
786
+
787
+ describe "#enrichment_provider=" do
788
+ it "defaults to nil" do
789
+ expect(config.enrichment_provider).to be_nil
790
+ end
791
+
792
+ it "accepts a valid provider" do
793
+ config.enrichment_provider = :openai
794
+ expect(config.enrichment_provider).to eq(:openai)
795
+ end
796
+
797
+ it "accepts nil" do
798
+ config.enrichment_provider = :gemini
799
+ config.enrichment_provider = nil
800
+ expect(config.enrichment_provider).to be_nil
801
+ end
802
+
803
+ it "raises ArgumentError for invalid providers" do
804
+ expect { config.enrichment_provider = :unknown }.to raise_error(ArgumentError)
805
+ end
806
+ end
807
+
808
+ # ── enrichment_model= ─────────────────────────────────────────────────────
809
+
810
+ describe "#enrichment_model=" do
811
+ it "defaults to nil" do
812
+ expect(config.enrichment_model).to be_nil
813
+ end
814
+
815
+ it "accepts a String model name" do
816
+ config.enrichment_model = "gemini-2.0-flash"
817
+ expect(config.enrichment_model).to eq("gemini-2.0-flash")
818
+ end
819
+
820
+ it "accepts nil" do
821
+ config.enrichment_model = "some-model"
822
+ config.enrichment_model = nil
823
+ expect(config.enrichment_model).to be_nil
824
+ end
825
+
826
+ it "raises ArgumentError for non-string values" do
827
+ expect { config.enrichment_model = :flash }.to raise_error(ArgumentError)
828
+ end
829
+ end
830
+
831
+ # ── resolved_enrichment_provider / resolved_enrichment_model ──────────────
832
+
833
+ describe "#resolved_enrichment_provider" do
834
+ it "falls back to llm_provider when enrichment_provider is nil" do
835
+ config.llm_provider = :openai
836
+ config.enrichment_provider = nil
837
+ expect(config.resolved_enrichment_provider).to eq(:openai)
838
+ end
839
+
840
+ it "returns enrichment_provider when set" do
841
+ config.enrichment_provider = :openrouter
842
+ expect(config.resolved_enrichment_provider).to eq(:openrouter)
843
+ end
844
+ end
845
+
846
+ describe "#resolved_enrichment_model" do
847
+ it "falls back to llm_model when enrichment_model is nil" do
848
+ config.llm_model = "gpt-4o"
849
+ config.enrichment_model = nil
850
+ expect(config.resolved_enrichment_model).to eq("gpt-4o")
851
+ end
852
+
853
+ it "returns enrichment_model when set" do
854
+ config.enrichment_model = "gemini-2.0-flash"
855
+ expect(config.resolved_enrichment_model).to eq("gemini-2.0-flash")
856
+ end
857
+ end
858
+ end