strata-cli 0.1.0.beta

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 (79) hide show
  1. checksums.yaml +7 -0
  2. data/.standard.yml +3 -0
  3. data/CHANGELOG.md +5 -0
  4. data/CLAUDE.md +65 -0
  5. data/LICENSE +21 -0
  6. data/README.md +465 -0
  7. data/Rakefile +10 -0
  8. data/exe/strata +6 -0
  9. data/lib/strata/cli/ai/client.rb +63 -0
  10. data/lib/strata/cli/ai/configuration.rb +48 -0
  11. data/lib/strata/cli/ai/services/table_generator.rb +282 -0
  12. data/lib/strata/cli/api/client.rb +170 -0
  13. data/lib/strata/cli/api/connection_error_handler.rb +54 -0
  14. data/lib/strata/cli/configuration.rb +135 -0
  15. data/lib/strata/cli/credentials.rb +83 -0
  16. data/lib/strata/cli/descriptions/create/migration.txt +25 -0
  17. data/lib/strata/cli/descriptions/create/relation.txt +14 -0
  18. data/lib/strata/cli/descriptions/create/table.txt +23 -0
  19. data/lib/strata/cli/descriptions/datasource/add.txt +15 -0
  20. data/lib/strata/cli/descriptions/datasource/auth.txt +14 -0
  21. data/lib/strata/cli/descriptions/datasource/exec.txt +7 -0
  22. data/lib/strata/cli/descriptions/datasource/meta.txt +11 -0
  23. data/lib/strata/cli/descriptions/datasource/tables.txt +12 -0
  24. data/lib/strata/cli/descriptions/datasource/test.txt +8 -0
  25. data/lib/strata/cli/descriptions/deploy/deploy.txt +24 -0
  26. data/lib/strata/cli/descriptions/deploy/status.txt +9 -0
  27. data/lib/strata/cli/descriptions/init.txt +14 -0
  28. data/lib/strata/cli/generators/datasource.rb +83 -0
  29. data/lib/strata/cli/generators/group.rb +13 -0
  30. data/lib/strata/cli/generators/migration.rb +71 -0
  31. data/lib/strata/cli/generators/project.rb +190 -0
  32. data/lib/strata/cli/generators/relation.rb +64 -0
  33. data/lib/strata/cli/generators/table.rb +143 -0
  34. data/lib/strata/cli/generators/templates/adapters/athena.yml +53 -0
  35. data/lib/strata/cli/generators/templates/adapters/druid.yml +42 -0
  36. data/lib/strata/cli/generators/templates/adapters/duckdb.yml +36 -0
  37. data/lib/strata/cli/generators/templates/adapters/mysql.yml +45 -0
  38. data/lib/strata/cli/generators/templates/adapters/postgres.yml +48 -0
  39. data/lib/strata/cli/generators/templates/adapters/snowflake.yml +69 -0
  40. data/lib/strata/cli/generators/templates/adapters/sqlserver.yml +45 -0
  41. data/lib/strata/cli/generators/templates/adapters/trino.yml +56 -0
  42. data/lib/strata/cli/generators/templates/datasources.yml +4 -0
  43. data/lib/strata/cli/generators/templates/migration.rename.yml +15 -0
  44. data/lib/strata/cli/generators/templates/migration.swap.yml +13 -0
  45. data/lib/strata/cli/generators/templates/project.yml +36 -0
  46. data/lib/strata/cli/generators/templates/rel.domain.yml +43 -0
  47. data/lib/strata/cli/generators/templates/strata.yml +24 -0
  48. data/lib/strata/cli/generators/templates/table.table_name.yml +118 -0
  49. data/lib/strata/cli/generators/templates/test.yml +34 -0
  50. data/lib/strata/cli/generators/test.rb +48 -0
  51. data/lib/strata/cli/guard.rb +21 -0
  52. data/lib/strata/cli/helpers/color_helper.rb +103 -0
  53. data/lib/strata/cli/helpers/command_context.rb +41 -0
  54. data/lib/strata/cli/helpers/datasource_helper.rb +62 -0
  55. data/lib/strata/cli/helpers/description_helper.rb +18 -0
  56. data/lib/strata/cli/helpers/project_helper.rb +85 -0
  57. data/lib/strata/cli/helpers/prompts.rb +42 -0
  58. data/lib/strata/cli/helpers/table_filter.rb +48 -0
  59. data/lib/strata/cli/main.rb +71 -0
  60. data/lib/strata/cli/sub_commands/audit.rb +262 -0
  61. data/lib/strata/cli/sub_commands/create.rb +419 -0
  62. data/lib/strata/cli/sub_commands/datasource.rb +353 -0
  63. data/lib/strata/cli/sub_commands/deploy.rb +433 -0
  64. data/lib/strata/cli/sub_commands/project.rb +38 -0
  65. data/lib/strata/cli/sub_commands/table.rb +58 -0
  66. data/lib/strata/cli/terminal.rb +102 -0
  67. data/lib/strata/cli/ui/autocomplete.rb +93 -0
  68. data/lib/strata/cli/ui/field_editor.rb +215 -0
  69. data/lib/strata/cli/utils/archive.rb +137 -0
  70. data/lib/strata/cli/utils/deployment_monitor.rb +445 -0
  71. data/lib/strata/cli/utils/git.rb +253 -0
  72. data/lib/strata/cli/utils/import_manager.rb +190 -0
  73. data/lib/strata/cli/utils/test_reporter.rb +131 -0
  74. data/lib/strata/cli/utils/yaml_import_resolver.rb +91 -0
  75. data/lib/strata/cli/utils.rb +39 -0
  76. data/lib/strata/cli/version.rb +7 -0
  77. data/lib/strata/cli.rb +36 -0
  78. data/sig/strata/cli.rbs +6 -0
  79. metadata +306 -0
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../client"
4
+ require "yaml"
5
+
6
+ module Strata
7
+ module CLI
8
+ module AI
9
+ module Services
10
+ # Generates semantic model fields from database table metadata using AI.
11
+ # Returns structured JSON for use in the field editor.
12
+ class TableGenerator
13
+ SYSTEM_PROMPT = <<~PROMPT
14
+ You are a semantic modeling expert for analytics and BI systems.
15
+ Analyze database columns and generate semantic field definitions.
16
+
17
+ For each column, determine:
18
+ - name: Human-friendly field name (e.g., "customer_id" → "Customer ID")
19
+ - description: Brief description of what this field represents
20
+ - schema_type: "dimension" for categorical/text, "measure" for numeric aggregations
21
+ - data_type: string, integer, bigint, decimal, date, date_time, boolean
22
+ - expression: SQL expression (for measures include aggregation like "sum(amount)")
23
+
24
+ Output ONLY valid JSON array, no explanations.
25
+ PROMPT
26
+
27
+ attr_reader :client
28
+
29
+ def initialize(client: Client.new)
30
+ @client = client
31
+ end
32
+
33
+ # Generate field definitions from table metadata
34
+ # @param table_name [String] Full table name (schema.table)
35
+ # @param columns [Array<Hash>] Column metadata with :name, :type
36
+ # @param datasource [String] Datasource key
37
+ # @param user_context [Hash, nil] Optional user-provided context with :description
38
+ # @return [Hash, nil] Generated model data or nil if AI disabled
39
+ def call(table_name:, columns:, datasource:, user_context: nil)
40
+ unless ai_available?
41
+ warn "AI not available - using rule-based fallback"
42
+ return fallback_fields(columns)
43
+ end
44
+
45
+ existing_models = load_existing_models_context
46
+ prompt = build_prompt(
47
+ table_name: table_name,
48
+ columns: columns,
49
+ existing_models_context: existing_models,
50
+ user_context: user_context
51
+ )
52
+
53
+ begin
54
+ response = @client.complete(prompt, system_prompt: SYSTEM_PROMPT)
55
+ parse_response(response, columns)
56
+ rescue => e
57
+ # Fallback to basic field generation on AI error
58
+ warn "AI failed to generate fields: #{e.message} - performing basic field generation"
59
+ fallback_fields(columns)
60
+ end
61
+ end
62
+
63
+ # Generate fields with user prompt for modification/regeneration
64
+ # @param table_name [String] Full table name
65
+ # @param columns [Array<Hash>] Column metadata
66
+ # @param datasource [String] Datasource key
67
+ # @param user_prompt [String] User's natural language prompt
68
+ # @param current_fields [Array<Hash>] Current field definitions
69
+ # @return [Array<Hash>] Regenerated fields
70
+ def call_with_prompt(table_name:, columns:, datasource:, user_prompt:, current_fields:)
71
+ unless ai_available?
72
+ warn "AI not available - could not use prompt mode"
73
+ return nil
74
+ end
75
+
76
+ prompt = build_prompt_with_user_input(
77
+ table_name: table_name,
78
+ columns: columns,
79
+ user_prompt: user_prompt,
80
+ current_fields: current_fields
81
+ )
82
+
83
+ begin
84
+ response = @client.complete(prompt, system_prompt: SYSTEM_PROMPT)
85
+ parse_response(response, columns)
86
+ rescue => e
87
+ warn "AI failed to generate fields: #{e.message}"
88
+ nil
89
+ end
90
+ end
91
+
92
+ # Check if AI is available
93
+ def ai_available?
94
+ @client.enabled?
95
+ end
96
+
97
+ private
98
+
99
+ def build_prompt(table_name:, columns:, existing_models_context: nil, user_context: nil)
100
+ column_list = columns.map do |col|
101
+ name = col[:name] || col["name"]
102
+ type = col[:type] || col["type"]
103
+ "- #{name}: #{type}"
104
+ end.join("\n")
105
+
106
+ context_section = if existing_models_context && !existing_models_context.empty?
107
+ "\n\nExisting model patterns for consistency:\n#{existing_models_context}"
108
+ else
109
+ ""
110
+ end
111
+
112
+ user_context_section = if user_context && !user_context[:description].to_s.empty?
113
+ "\n\nBusiness context provided by user: #{user_context[:description]}"
114
+ else
115
+ ""
116
+ end
117
+
118
+ <<~PROMPT
119
+ Analyze these columns from table "#{table_name}" and generate semantic field definitions:
120
+
121
+ #{column_list}#{context_section}#{user_context_section}
122
+
123
+ Return JSON array with objects containing: name, description, schema_type, data_type, expression
124
+ Example: [{"name": "Order ID", "description": "Primary key", "schema_type": "dimension", "data_type": "bigint", "expression": "id"}]
125
+ PROMPT
126
+ end
127
+
128
+ def build_prompt_with_user_input(table_name:, columns:, user_prompt:, current_fields:)
129
+ column_list = columns.map do |col|
130
+ name = col[:name] || col["name"]
131
+ type = col[:type] || col["type"]
132
+ "- #{name}: #{type}"
133
+ end.join("\n")
134
+
135
+ current_fields_summary = current_fields.map do |f|
136
+ "- #{f[:name] || f["name"]}: #{f[:schema_type] || f["schema_type"]} (#{f[:expression] || f["expression"]})"
137
+ end.join("\n")
138
+
139
+ existing_models = load_existing_models_context
140
+ context_section = if existing_models && !existing_models.empty?
141
+ "\n\nExisting model patterns for consistency:\n#{existing_models}"
142
+ else
143
+ ""
144
+ end
145
+
146
+ <<~PROMPT
147
+ User request: #{user_prompt}
148
+
149
+ Current fields:
150
+ #{current_fields_summary}
151
+
152
+ Table columns:
153
+ #{column_list}#{context_section}
154
+
155
+ Based on the user's request, regenerate or modify the field definitions.
156
+ Return JSON array with objects containing: name, description, schema_type, data_type, expression
157
+ PROMPT
158
+ end
159
+
160
+ def load_existing_models_context
161
+ return nil unless Dir.exist?("models")
162
+
163
+ model_files = Dir.glob("models/tbl_*.yml").first(5) # Limit to 5 for performance
164
+ return nil if model_files.empty?
165
+
166
+ contexts = model_files.map do |file|
167
+ model = YAML.safe_load_file(file, permitted_classes: [Date, Time], aliases: true) || {}
168
+ fields_summary = (model["fields"] || []).map do |f|
169
+ "#{f["name"]} (#{f["schema_type"]})"
170
+ end.join(", ")
171
+ "Model: #{model["name"]} - Fields: #{fields_summary}"
172
+ rescue => e
173
+ warn "Failed to load model file #{file}: #{e.message}" if ENV["DEBUG"]
174
+ nil
175
+ end.compact
176
+
177
+ contexts.join("\n")
178
+ end
179
+
180
+ def parse_response(response, columns)
181
+ # Clean up response - remove markdown if present
182
+ json_str = response.gsub(/```json\n?/, "").gsub(/```\n?/, "").strip
183
+
184
+ fields = JSON.parse(json_str, symbolize_names: true)
185
+
186
+ # Ensure each field has column reference
187
+ fields.each_with_index do |field, idx|
188
+ col = columns[idx]
189
+ field[:column] = col[:name] || col["name"] if col
190
+ end
191
+
192
+ fields
193
+ rescue JSON::ParserError => e
194
+ raise AIError, "Failed to parse AI response as JSON: #{e.message}"
195
+ end
196
+
197
+ def fallback_fields(columns)
198
+ columns.map do |col|
199
+ name = col[:name] || col["name"]
200
+ type = col[:type] || col["type"]
201
+
202
+ is_measure = should_be_measure?(name, type)
203
+
204
+ {
205
+ column: name,
206
+ name: humanize(name),
207
+ description: "",
208
+ schema_type: is_measure ? "measure" : "dimension",
209
+ data_type: map_data_type(type),
210
+ expression: is_measure ? "sum(#{name})" : name
211
+ }
212
+ end
213
+ end
214
+
215
+ def humanize(str)
216
+ str.to_s.tr("_", " ").gsub(/\b\w/, &:upcase)
217
+ end
218
+
219
+ # Smarter logic: only true metrics should be measures
220
+ def should_be_measure?(name, type)
221
+ name_lower = name.to_s.downcase
222
+
223
+ # These patterns indicate dimensions, not measures
224
+ dimension_patterns = [
225
+ /_sk$/, # Surrogate keys (TPC-DS pattern)
226
+ /_id$/, # Foreign/primary keys
227
+ /_key$/, # Key columns
228
+ /_code$/, # Code columns
229
+ /_flag$/, # Boolean flags
230
+ /_date/, # Date columns
231
+ /_year$/, # Year (attribute, not sum)
232
+ /_month$/, # Month (attribute, not sum)
233
+ /_day$/, # Day (attribute, not sum)
234
+ /^id$/, # Just "id"
235
+ /created_at/, # Timestamps
236
+ /updated_at/,
237
+ /deleted_at/
238
+ ]
239
+
240
+ return false if dimension_patterns.any? { |pattern| name_lower.match?(pattern) }
241
+
242
+ # Measure patterns (things you actually sum)
243
+ measure_patterns = [
244
+ /amount/,
245
+ /price/,
246
+ /cost/,
247
+ /total/,
248
+ /qty/,
249
+ /quantity/,
250
+ /count/,
251
+ /sum/,
252
+ /revenue/,
253
+ /sales/,
254
+ /fee/,
255
+ /tax/,
256
+ /discount/
257
+ ]
258
+
259
+ # If name matches measure pattern and is numeric, it's a measure
260
+ numeric_type?(type) && measure_patterns.any? { |pattern| name_lower.match?(pattern) }
261
+ end
262
+
263
+ def numeric_type?(type)
264
+ type.to_s.downcase.match?(/int|decimal|numeric|float|double|number/)
265
+ end
266
+
267
+ def map_data_type(type)
268
+ case type.to_s.downcase
269
+ when /bigint/, /int8/ then "bigint"
270
+ when /int/ then "integer"
271
+ when /decimal/, /numeric/, /float/, /double/ then "decimal"
272
+ when /bool/ then "boolean"
273
+ when /timestamp/, /datetime/ then "date_time"
274
+ when /date/ then "date"
275
+ else "string"
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/multipart"
5
+ require "json"
6
+ require_relative "connection_error_handler"
7
+
8
+ module Strata
9
+ module CLI
10
+ module API
11
+ class Client
12
+ include ConnectionErrorHandler
13
+
14
+ def initialize(server_url, api_key)
15
+ @server_url = server_url.chomp("/")
16
+ @api_key = api_key
17
+ end
18
+
19
+ def last_successful_deployment(project_id, branch_id)
20
+ url = deployment_url(project_id, branch_id)
21
+ response = with_connection_error_handling(@server_url) { connection.get(url) }
22
+ deployments = handle_response(response)
23
+
24
+ return nil unless deployments.is_a?(Array)
25
+
26
+ # Find the first successful deployment
27
+ deployments.find { |d| d["status"] == "succeeded" }
28
+ end
29
+
30
+ def create_deployment(project_id, branch_id, archive_path, metadata)
31
+ url = deployment_url(project_id, branch_id)
32
+ file = Faraday::Multipart::FilePart.new(
33
+ archive_path,
34
+ "application/gzip"
35
+ )
36
+
37
+ response = with_connection_error_handling(@server_url) do
38
+ connection.post(url) do |req|
39
+ req.body = {
40
+ "deployment[archive]" => file,
41
+ "deployment[commit]" => metadata[:commit],
42
+ "deployment[commit_message]" => metadata[:commit_message],
43
+ "deployment[committer_name]" => metadata[:committer_name],
44
+ "deployment[committer_email]" => metadata[:committer_email],
45
+ "deployment[file_modifications]" => metadata[:file_modifications].to_json
46
+ }
47
+ end
48
+ end
49
+
50
+ handle_response(response)
51
+ end
52
+
53
+ def get_deployment(project_id, branch_id, deployment_id)
54
+ url = "#{deployment_url(project_id, branch_id)}/#{deployment_id}"
55
+ response = with_connection_error_handling(@server_url) { connection.get(url) }
56
+ handle_response(response)
57
+ end
58
+
59
+ def latest_deployment(project_id, branch_id)
60
+ url = deployment_url(project_id, branch_id)
61
+ response = with_connection_error_handling(@server_url) { connection.get(url) }
62
+ deployments = handle_response(response)
63
+ return nil unless deployments.is_a?(Array) && deployments.any?
64
+
65
+ deployments.first
66
+ end
67
+
68
+ def create_project(name, uid, description: nil, git: nil, production_branch: "main")
69
+ url = "#{@server_url}/api/v1/projects"
70
+ response = with_connection_error_handling(@server_url) do
71
+ connection.post(url) do |req|
72
+ req.headers["Content-Type"] = "application/json"
73
+ req.body = {
74
+ project: {
75
+ name: name,
76
+ uid: uid,
77
+ description: description,
78
+ git: git
79
+ },
80
+ production_branch: production_branch
81
+ }.to_json
82
+ end
83
+ end
84
+
85
+ handle_response(response)
86
+ end
87
+
88
+ private
89
+
90
+ def connection
91
+ @connection ||= Faraday.new(url: @server_url) do |f|
92
+ f.request :multipart
93
+ f.request :authorization, "Bearer", @api_key
94
+ f.response :json
95
+ f.adapter Faraday.default_adapter
96
+ end
97
+ end
98
+
99
+ def handle_response(response)
100
+ case response.status
101
+ when 200..299
102
+ response.body
103
+ when 401
104
+ raise Strata::CommandError, "Authentication failed. Check your API key."
105
+ when 403
106
+ raise Strata::CommandError, "Access denied. You don't have permission for this action."
107
+ when 404
108
+ raise Strata::CommandError, "Resource not found."
109
+ when 422
110
+ errors = extract_errors(response.body)
111
+ raise Strata::CommandError, "Validation failed: #{errors}"
112
+ when 500..599
113
+ raise Strata::CommandError, "Server error: #{response.status}"
114
+ else
115
+ raise Strata::CommandError, "Unexpected response: #{response.status}"
116
+ end
117
+ rescue Faraday::Error => e
118
+ raise Strata::CommandError, "Network error: #{e.message}"
119
+ end
120
+
121
+ def extract_errors(body)
122
+ # Handle nil or empty body
123
+ return "Unknown error" if body.nil? || body.empty?
124
+
125
+ # Check if it's an HTML error page (Rails error page)
126
+ if body.is_a?(String) && body.include?("<!DOCTYPE html>")
127
+ # Try to extract error message from HTML
128
+ if body =~ /<h1[^>]*>(.*?)<\/h1>/
129
+ return "Server error: #{$1.strip}"
130
+ elsif body =~ /<title[^>]*>(.*?)<\/title>/
131
+ return "Server error: #{$1.strip}"
132
+ else
133
+ return "Server returned an HTML error page. Check server logs for details."
134
+ end
135
+ end
136
+
137
+ # Parse JSON string if needed
138
+ parsed_body = if body.is_a?(String)
139
+ begin
140
+ JSON.parse(body)
141
+ rescue JSON::ParserError
142
+ return body[0..200] # Return first 200 chars if not JSON
143
+ end
144
+ else
145
+ body
146
+ end
147
+
148
+ # Extract error messages from various formats
149
+ if parsed_body.is_a?(Hash)
150
+ if parsed_body["errors"].is_a?(Array)
151
+ parsed_body["errors"].join(", ")
152
+ elsif parsed_body["error"].is_a?(String)
153
+ parsed_body["error"]
154
+ elsif parsed_body["message"].is_a?(String)
155
+ parsed_body["message"]
156
+ else
157
+ parsed_body.to_json
158
+ end
159
+ else
160
+ parsed_body.to_s[0..200] # Limit length
161
+ end
162
+ end
163
+
164
+ def deployment_url(project_id, branch_id)
165
+ "#{@server_url}/api/v1/projects/#{project_id}/b/#{branch_id}/deploys"
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module Strata
6
+ module CLI
7
+ module API
8
+ # Handles connection errors and provides user-friendly error messages
9
+ module ConnectionErrorHandler
10
+ def with_connection_error_handling(server_url)
11
+ yield
12
+ rescue Faraday::ConnectionFailed => e
13
+ handle_connection_error(server_url, e)
14
+ rescue Faraday::TimeoutError => e
15
+ handle_connection_error(server_url, e, timeout: true)
16
+ rescue Faraday::Error => e
17
+ # Check if it's a connection-related error
18
+ if e.message.include?("Connection refused") ||
19
+ e.message.include?("Failed to open TCP connection") ||
20
+ e.message.include?("getaddrinfo") ||
21
+ e.message.include?("No route to host")
22
+ handle_connection_error(server_url, e)
23
+ else
24
+ raise Strata::CommandError, "Network error: #{e.message}"
25
+ end
26
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EHOSTUNREACH => e
27
+ handle_connection_error(server_url, e)
28
+ end
29
+
30
+ private
31
+
32
+ def handle_connection_error(server_url, error, timeout: false)
33
+ server_display = server_url
34
+
35
+ message = "\n"
36
+ message += "Cannot connect to Strata server\n\n"
37
+ message += "Server: #{server_display}\n"
38
+
39
+ if timeout
40
+ message += "Error: Connection timeout\n\n"
41
+ message += "The server did not respond in time. This could mean:\n"
42
+ message += " • The server is overloaded or slow\n"
43
+ message += " • Network connectivity issues\n"
44
+ else
45
+ message += "Error: Connection refused\n\n"
46
+ message += "The server appears to be offline or unreachable. Please check:\n"
47
+ end
48
+
49
+ raise Strata::CommandError, message
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,135 @@
1
+ require "yaml"
2
+
3
+ module Strata
4
+ module CLI
5
+ class Configuration
6
+ STRATA_CONFIG_FILE = ".strata"
7
+ PROJECT_CONFIG_FILE = "project.yml"
8
+
9
+ # Default configuration
10
+ DEFAULT = {
11
+ "api_key" => "",
12
+ "server" => "http://localhost:3030",
13
+ "log_level" => "info"
14
+ }.freeze
15
+
16
+ attr_reader :data
17
+
18
+ def initialize
19
+ @data = DEFAULT.merge({})
20
+ load_config
21
+ end
22
+
23
+ def [](key)
24
+ @data[key]
25
+ end
26
+
27
+ def method_missing(method_name, *args, &block)
28
+ if @data.key?(method_name.to_s)
29
+ @data[method_name.to_s]
30
+ else
31
+ super
32
+ end
33
+ end
34
+
35
+ def respond_to_missing?(method_name, include_private = false)
36
+ @data.key?(method_name.to_s) || super
37
+ end
38
+
39
+ def strata_project?
40
+ File.exist?(project_config_file_path)
41
+ end
42
+
43
+ def config_file_path
44
+ @config_file_path ||= File.expand_path(STRATA_CONFIG_FILE)
45
+ end
46
+
47
+ def project_config_file_path
48
+ @project_config_file_path ||= File.expand_path(PROJECT_CONFIG_FILE)
49
+ end
50
+
51
+ # Get configuration value with type conversion
52
+ def get(key, type: nil)
53
+ value = self[key]
54
+ return value unless type
55
+
56
+ case type
57
+ when :boolean
58
+ convert_to_boolean(value)
59
+ when :integer
60
+ Integer(value)
61
+ when :float
62
+ Float(value)
63
+ when :array
64
+ Array(value)
65
+ else
66
+ value
67
+ end
68
+ rescue ArgumentError, TypeError => e
69
+ raise StrataError, "Invalid #{type} value for '#{key}': #{value} \n\t#{e.message}"
70
+ end
71
+
72
+ def reload!
73
+ @data.clear
74
+ load_config
75
+ end
76
+
77
+ def get_for_environment(env)
78
+ root_config = extract_root_config
79
+ return root_config if env.nil? || env.empty?
80
+
81
+ env_data = @data[env.to_s]
82
+ root_config.merge(env_data)
83
+ end
84
+
85
+ private
86
+
87
+ def load_config
88
+ return unless strata_project?
89
+
90
+ strata_data = load_yaml_file(config_file_path)
91
+ project_data = load_yaml_file(project_config_file_path)
92
+
93
+ @data = DEFAULT.merge(strata_data).merge(project_data)
94
+
95
+ @api_key = @data["api_key"]
96
+ @server = @data["server"]
97
+ end
98
+
99
+ def load_yaml_file(file_path)
100
+ return {} unless File.exist?(file_path)
101
+
102
+ YAML.safe_load_file(file_path, permitted_classes: [Date, Time], aliases: true) || {}
103
+ end
104
+
105
+ def extract_root_config
106
+ root_config = {}
107
+ @data.each do |key, value|
108
+ next if is_environment_key?(key, value)
109
+
110
+ root_config[key] = value
111
+ end
112
+ root_config
113
+ end
114
+
115
+ def is_environment_key?(key, value)
116
+ return false unless value.is_a?(Hash)
117
+
118
+ value.key?("api_key") || value.key?("server")
119
+ end
120
+
121
+ def convert_to_boolean(value)
122
+ case value
123
+ when true, false
124
+ value
125
+ when "true", "yes", "1", 1
126
+ true
127
+ when "false", "no", "0", 0, nil
128
+ false
129
+ else
130
+ raise ArgumentError, "Cannot convert #{value.inspect} to boolean"
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end