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,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Engine do
6
+ # ── configure_provider_key ────────────────────────────────────────────────
7
+
8
+ describe ".configure_provider_key" do
9
+ let(:llm_config) { double("RubyLLM::Config") }
10
+
11
+ context "gemini provider" do
12
+ it "sets gemini_api_key from gemini_api_key config" do
13
+ Glancer.configuration.gemini_api_key = "g-key"
14
+ allow(llm_config).to receive(:gemini_api_key=)
15
+ described_class.configure_provider_key(llm_config, Glancer.configuration, :gemini)
16
+ expect(llm_config).to have_received(:gemini_api_key=).with("g-key")
17
+ end
18
+
19
+ it "falls back to api_key when gemini_api_key is nil" do
20
+ Glancer.configuration.gemini_api_key = nil
21
+ Glancer.configuration.api_key = "fallback-key"
22
+ allow(llm_config).to receive(:gemini_api_key=)
23
+ described_class.configure_provider_key(llm_config, Glancer.configuration, :gemini)
24
+ expect(llm_config).to have_received(:gemini_api_key=).with("fallback-key")
25
+ end
26
+
27
+ it "raises Glancer::Error when no API key is available" do
28
+ Glancer.configuration.gemini_api_key = nil
29
+ Glancer.configuration.api_key = nil
30
+ expect { described_class.configure_provider_key(llm_config, Glancer.configuration, :gemini) }
31
+ .to raise_error(Glancer::Error, /Gemini API key/)
32
+ end
33
+ end
34
+
35
+ context "openai provider" do
36
+ it "sets openai_api_key" do
37
+ Glancer.configuration.openai_api_key = "oai-key"
38
+ allow(llm_config).to receive(:openai_api_key=)
39
+ described_class.configure_provider_key(llm_config, Glancer.configuration, :openai)
40
+ expect(llm_config).to have_received(:openai_api_key=).with("oai-key")
41
+ end
42
+
43
+ it "raises Glancer::Error when no API key is available" do
44
+ Glancer.configuration.openai_api_key = nil
45
+ Glancer.configuration.api_key = nil
46
+ expect { described_class.configure_provider_key(llm_config, Glancer.configuration, :openai) }
47
+ .to raise_error(Glancer::Error, /OpenAI API key/)
48
+ end
49
+ end
50
+
51
+ context "openrouter provider" do
52
+ it "sets openrouter_api_key" do
53
+ Glancer.configuration.openrouter_api_key = "or-key"
54
+ allow(llm_config).to receive(:openrouter_api_key=)
55
+ described_class.configure_provider_key(llm_config, Glancer.configuration, :openrouter)
56
+ expect(llm_config).to have_received(:openrouter_api_key=).with("or-key")
57
+ end
58
+
59
+ it "raises Glancer::Error when no API key is available" do
60
+ Glancer.configuration.openrouter_api_key = nil
61
+ Glancer.configuration.api_key = nil
62
+ expect { described_class.configure_provider_key(llm_config, Glancer.configuration, :openrouter) }
63
+ .to raise_error(Glancer::Error, /OpenRouter API key/)
64
+ end
65
+ end
66
+
67
+ context "unsupported provider" do
68
+ it "raises Glancer::Error" do
69
+ expect { described_class.configure_provider_key(llm_config, Glancer.configuration, :unknown_llm) }
70
+ .to raise_error(Glancer::Error, /Unsupported LLM provider/)
71
+ end
72
+ end
73
+ end
74
+
75
+ # ── Initializer registration ──────────────────────────────────────────────
76
+
77
+ describe "registered initializers" do
78
+ let(:initializer_names) { described_class.initializers.map { |i| i.name.to_s } }
79
+
80
+ it "registers glancer.append_migrations" do
81
+ expect(initializer_names).to include("glancer.append_migrations")
82
+ end
83
+
84
+ it "registers glancer.assets" do
85
+ expect(initializer_names).to include("glancer.assets")
86
+ end
87
+
88
+ it "registers glancer.load_tasks" do
89
+ expect(initializer_names).to include("glancer.load_tasks")
90
+ end
91
+
92
+ it "registers glancer.configure_ruby_llm" do
93
+ expect(initializer_names).to include("glancer.configure_ruby_llm")
94
+ end
95
+ end
96
+
97
+ # ── glancer.configure_ruby_llm initializer body ───────────────────────────
98
+
99
+ describe "glancer.configure_ruby_llm initializer" do
100
+ let(:ruby_llm_config) { double("RubyLLM::Config") }
101
+
102
+ before do
103
+ allow(ruby_llm_config).to receive(:gemini_api_key=)
104
+ allow(ruby_llm_config).to receive(:default_embedding_model=)
105
+ allow(ruby_llm_config).to receive(:default_embedding_model).and_return("text-embedding-004")
106
+ allow(RubyLLM).to receive(:configure).and_yield(ruby_llm_config)
107
+ end
108
+
109
+ def run_llm_initializer
110
+ init = Glancer::Engine.initializers.find { |i| i.name.to_s == "glancer.configure_ruby_llm" }
111
+ init.run(nil)
112
+ end
113
+
114
+ it "calls RubyLLM.configure" do
115
+ expect(RubyLLM).to receive(:configure).and_yield(ruby_llm_config)
116
+ run_llm_initializer
117
+ end
118
+
119
+ it "sets gemini_api_key on the RubyLLM config" do
120
+ run_llm_initializer
121
+ expect(ruby_llm_config).to have_received(:gemini_api_key=).with("test-api-key")
122
+ end
123
+
124
+ it "sets default_embedding_model on the RubyLLM config" do
125
+ run_llm_initializer
126
+ expect(ruby_llm_config).to have_received(:default_embedding_model=)
127
+ end
128
+
129
+ it "raises Glancer::Error when configure_provider_key raises" do
130
+ allow(RubyLLM).to receive(:configure).and_raise(StandardError, "LLM error")
131
+ expect { run_llm_initializer }.to raise_error(Glancer::Error, /RubyLLM configuration failed/)
132
+ end
133
+
134
+ it "skips when Glancer.configuration is nil" do
135
+ Glancer.configuration = nil
136
+ expect(RubyLLM).not_to receive(:configure)
137
+ expect { run_llm_initializer }.not_to raise_error
138
+ ensure
139
+ Glancer.configuration = Glancer::Configuration.new.tap do |c|
140
+ c.adapter = :sqlite
141
+ c.llm_provider = :gemini
142
+ c.llm_model = "test-model"
143
+ c.gemini_api_key = "test-api-key"
144
+ c.log_verbosity = :none
145
+ end
146
+ end
147
+ end
148
+
149
+ # ── glancer.append_migrations initializer body ───────────────────────────
150
+
151
+ describe "glancer.append_migrations initializer" do
152
+ def run_migrations_initializer(app)
153
+ init = Glancer::Engine.initializers.find { |i| i.name.to_s == "glancer.append_migrations" }
154
+ init.bind(Glancer::Engine).run(app)
155
+ end
156
+
157
+ it "skips migration append when app root matches engine root" do
158
+ app = double("app")
159
+ allow(app).to receive(:root).and_return(Glancer::Engine.root)
160
+ expect(app).not_to receive(:config)
161
+ run_migrations_initializer(app)
162
+ end
163
+
164
+ it "appends migration paths when app root differs from engine root" do
165
+ app = double("app")
166
+ allow(app).to receive(:root).and_return(Pathname.new("/some/other/app"))
167
+ migrate_paths = double("migrate_paths")
168
+ allow(migrate_paths).to receive(:<<)
169
+ app_paths = double("app_paths")
170
+ allow(app_paths).to receive(:[]).with("db/migrate").and_return(migrate_paths)
171
+ allow(app).to receive(:config).and_return(double("app_config", paths: app_paths))
172
+ expect { run_migrations_initializer(app) }.not_to raise_error
173
+ end
174
+ end
175
+
176
+ # ── glancer.assets initializer body ──────────────────────────────────────
177
+
178
+ describe "glancer.assets initializer" do
179
+ def run_assets_initializer(app)
180
+ init = Glancer::Engine.initializers.find { |i| i.name.to_s == "glancer.assets" }
181
+ init.bind(Glancer::Engine).run(app)
182
+ end
183
+
184
+ it "registers asset paths and precompile list without raising" do
185
+ asset_paths = []
186
+ precompile = []
187
+ assets = double("assets")
188
+ allow(assets).to receive(:paths).and_return(asset_paths)
189
+ allow(assets).to receive(:precompile).and_return(precompile)
190
+ allow(assets).to receive(:precompile=)
191
+ app = double("app", config: double("config", assets: assets))
192
+ expect { run_assets_initializer(app) }.not_to raise_error
193
+ end
194
+ end
195
+
196
+ # ── glancer.load_tasks initializer body ──────────────────────────────────
197
+
198
+ describe "glancer.load_tasks initializer" do
199
+ def run_load_tasks_initializer
200
+ init = Glancer::Engine.initializers.find { |i| i.name.to_s == "glancer.load_tasks" }
201
+ init.bind(Glancer::Engine).run(nil)
202
+ end
203
+
204
+ it "loads rake task files without raising" do
205
+ allow(Dir).to receive(:[]).and_return([])
206
+ expect { run_load_tasks_initializer }.not_to raise_error
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Indexer::ContextIndexer do
6
+ let(:context_path) { "/fake/config/context.md" }
7
+ let(:context_content) do
8
+ <<~MD
9
+ # Domain Context
10
+ This application manages e-commerce orders and customers.
11
+
12
+ ## Users
13
+ Users have email, name, and created_at attributes.
14
+
15
+ ## Orders
16
+ Orders belong to users and have a total and status.
17
+ MD
18
+ end
19
+
20
+ before do
21
+ Glancer.configuration.context_file_path = context_path
22
+ allow(File).to receive(:exist?).with(context_path).and_return(true)
23
+ allow(File).to receive(:read).with(context_path).and_return(context_content)
24
+ end
25
+
26
+ # ── index! ────────────────────────────────────────────────────────────────
27
+
28
+ describe ".index!" do
29
+ it "returns an array of chunk hashes" do
30
+ result = described_class.index!
31
+ expect(result).to be_an(Array)
32
+ expect(result).not_to be_empty
33
+ end
34
+
35
+ it "sets source_type to 'context' on all chunks" do
36
+ result = described_class.index!
37
+ expect(result.map { |c| c[:source_type] }).to all(eq("context"))
38
+ end
39
+
40
+ it "sets source_path to the configured context_file_path" do
41
+ result = described_class.index!
42
+ expect(result.map { |c| c[:source_path] }).to all(eq(context_path))
43
+ end
44
+
45
+ it "includes the content from the context file" do
46
+ result = described_class.index!
47
+ combined = result.map { |c| c[:content] }.join
48
+ expect(combined).to include("Domain Context")
49
+ end
50
+
51
+ it "returns [] when the file starts with --glancer-ignore" do
52
+ allow(File).to receive(:read).with(context_path).and_return("--glancer-ignore\nsome content")
53
+ expect(described_class.index!).to eq([])
54
+ end
55
+
56
+ it "raises Glancer::Error when the file does not exist" do
57
+ allow(File).to receive(:exist?).with(context_path).and_return(false)
58
+ expect { described_class.index! }.to raise_error(Glancer::Error, /Context file not found/)
59
+ end
60
+
61
+ it "raises Glancer::Error when context_file_path is nil" do
62
+ Glancer.configuration.context_file_path = "non_existent_file.md"
63
+ allow(File).to receive(:exist?).and_return(false)
64
+ expect { described_class.index! }.to raise_error(Glancer::Error)
65
+ end
66
+
67
+ it "raises Glancer::Error when File.read raises" do
68
+ allow(File).to receive(:read).and_raise(IOError, "unreadable file")
69
+ expect { described_class.index! }.to raise_error(Glancer::Error, /Context indexing failed/)
70
+ end
71
+ end
72
+
73
+ # ── split_into_chunks ─────────────────────────────────────────────────────
74
+
75
+ describe ".split_into_chunks" do
76
+ before do
77
+ Glancer.configuration.chunk_size = 100
78
+ Glancer.configuration.chunk_overlap = 10
79
+ end
80
+
81
+ it "returns one chunk for text shorter than chunk_size" do
82
+ result = described_class.split_into_chunks("short")
83
+ expect(result.size).to eq(1)
84
+ end
85
+
86
+ it "splits long text into multiple chunks" do
87
+ result = described_class.split_into_chunks("x" * 300)
88
+ expect(result.size).to be > 1
89
+ end
90
+
91
+ it "produces chunks of at most chunk_size characters" do
92
+ result = described_class.split_into_chunks("y" * 300)
93
+ result.each { |chunk| expect(chunk.length).to be <= 100 }
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Indexer::ModelIndexer do
6
+ let(:fake_root) { Pathname.new("/fake/app") }
7
+ let(:model_content) do
8
+ <<~RUBY
9
+ class User < ApplicationRecord
10
+ has_many :orders
11
+ validates :email, presence: true
12
+ end
13
+ RUBY
14
+ end
15
+ let(:model_file) { "/fake/app/app/models/user.rb" }
16
+
17
+ before do
18
+ allow(Rails).to receive(:root).and_return(fake_root)
19
+ allow(Dir).to receive(:[]).with(fake_root.join("app/models/**/*.rb")).and_return([model_file])
20
+ allow(File).to receive(:read).with(model_file).and_return(model_content)
21
+ end
22
+
23
+ # ── index! ────────────────────────────────────────────────────────────────
24
+
25
+ describe ".index!" do
26
+ it "returns an array of chunk hashes" do
27
+ result = described_class.index!
28
+ expect(result).to be_an(Array)
29
+ expect(result).not_to be_empty
30
+ end
31
+
32
+ it "sets source_type to 'models' on all chunks" do
33
+ result = described_class.index!
34
+ expect(result.map { |c| c[:source_type] }).to all(eq("models"))
35
+ end
36
+
37
+ it "sets source_path to the file path" do
38
+ result = described_class.index!
39
+ expect(result.map { |c| c[:source_path] }).to all(eq(model_file))
40
+ end
41
+
42
+ it "includes the content of the model file" do
43
+ result = described_class.index!
44
+ combined = result.map { |c| c[:content] }.join
45
+ expect(combined).to include("class User")
46
+ end
47
+
48
+ it "returns [] when no model files are found" do
49
+ allow(Dir).to receive(:[]).and_return([])
50
+ expect(described_class.index!).to eq([])
51
+ end
52
+
53
+ it "handles multiple model files" do
54
+ second_file = "/fake/app/app/models/order.rb"
55
+ second_content = "class Order < ApplicationRecord; end"
56
+ allow(Dir).to receive(:[]).and_return([model_file, second_file])
57
+ allow(File).to receive(:read).with(second_file).and_return(second_content)
58
+
59
+ result = described_class.index!
60
+ paths = result.map { |c| c[:source_path] }.uniq
61
+ expect(paths).to include(model_file, second_file)
62
+ end
63
+
64
+ it "raises Glancer::Error when File.read raises" do
65
+ allow(File).to receive(:read).and_raise(IOError, "unreadable")
66
+ expect { described_class.index! }.to raise_error(Glancer::Error, /Model indexing failed/)
67
+ end
68
+ end
69
+
70
+ # ── split_into_chunks ─────────────────────────────────────────────────────
71
+
72
+ describe ".split_into_chunks" do
73
+ before do
74
+ Glancer.configuration.chunk_size = 100
75
+ Glancer.configuration.chunk_overlap = 20
76
+ end
77
+
78
+ it "returns at least one chunk for any non-empty text" do
79
+ result = described_class.split_into_chunks("x" * 50)
80
+ expect(result.size).to be >= 1
81
+ end
82
+
83
+ it "splits long text into multiple chunks" do
84
+ long_text = "a" * 250
85
+ result = described_class.split_into_chunks(long_text)
86
+ expect(result.size).to be > 1
87
+ end
88
+
89
+ it "respects chunk_size from configuration" do
90
+ Glancer.configuration.chunk_size = 200
91
+ Glancer.configuration.chunk_overlap = 0 # no overlap so the text fits in exactly 1 chunk
92
+ result = described_class.split_into_chunks("b" * 200)
93
+ expect(result.size).to eq(1)
94
+ end
95
+
96
+ it "applies overlap so successive chunks share content" do
97
+ text = "a" * 180
98
+ result = described_class.split_into_chunks(text)
99
+ # With size=100 and overlap=20, second chunk starts at index 80
100
+ expect(result[1]).to start_with("a" * 20)
101
+ end
102
+ end
103
+ end