htm 0.0.17 → 0.0.18

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.architecture/decisions/adrs/001-use-postgresql-timescaledb-storage.md +1 -1
  3. data/.architecture/decisions/adrs/011-database-side-embedding-generation-with-pgai.md +4 -4
  4. data/.architecture/decisions/adrs/012-llm-driven-ontology-topic-extraction.md +1 -1
  5. data/.envrc +12 -25
  6. data/.irbrc +7 -7
  7. data/.tbls.yml +2 -2
  8. data/CHANGELOG.md +71 -0
  9. data/README.md +1 -1
  10. data/Rakefile +8 -3
  11. data/SETUP.md +12 -12
  12. data/bin/htm_mcp +0 -4
  13. data/db/seed_data/README.md +2 -2
  14. data/db/seeds.rb +2 -2
  15. data/docs/api/database.md +37 -37
  16. data/docs/api/htm.md +1 -1
  17. data/docs/api/yard/HTM/ActiveRecordConfig.md +2 -2
  18. data/docs/api/yard/HTM/Configuration.md +26 -15
  19. data/docs/api/yard/HTM/Database.md +7 -8
  20. data/docs/api/yard/HTM/JobAdapter.md +1 -1
  21. data/docs/api/yard/HTM/Railtie.md +2 -2
  22. data/docs/architecture/adrs/001-postgresql-timescaledb.md +1 -1
  23. data/docs/architecture/adrs/011-pgai-integration.md +4 -4
  24. data/docs/database_rake_tasks.md +5 -5
  25. data/docs/development/rake-tasks.md +11 -11
  26. data/docs/development/setup.md +21 -21
  27. data/docs/development/testing.md +1 -1
  28. data/docs/getting-started/installation.md +20 -20
  29. data/docs/getting-started/quick-start.md +12 -12
  30. data/docs/guides/getting-started.md +2 -2
  31. data/docs/guides/long-term-memory.md +1 -1
  32. data/docs/guides/mcp-server.md +17 -17
  33. data/docs/guides/robot-groups.md +8 -8
  34. data/docs/index.md +4 -4
  35. data/docs/multi_framework_support.md +8 -8
  36. data/docs/setup_local_database.md +19 -19
  37. data/docs/using_rake_tasks_in_your_app.md +14 -14
  38. data/examples/README.md +50 -6
  39. data/examples/basic_usage.rb +31 -21
  40. data/examples/cli_app/README.md +8 -8
  41. data/examples/cli_app/htm_cli.rb +5 -5
  42. data/examples/config_file_example/README.md +256 -0
  43. data/examples/config_file_example/config/htm.local.yml +34 -0
  44. data/examples/config_file_example/custom_config.yml +22 -0
  45. data/examples/config_file_example/show_config.rb +125 -0
  46. data/examples/custom_llm_configuration.rb +7 -7
  47. data/examples/example_app/Rakefile +2 -2
  48. data/examples/example_app/app.rb +8 -8
  49. data/examples/file_loader_usage.rb +9 -9
  50. data/examples/mcp_client.rb +5 -5
  51. data/examples/rails_app/Gemfile.lock +48 -56
  52. data/examples/rails_app/README.md +1 -1
  53. data/examples/robot_groups/multi_process.rb +5 -5
  54. data/examples/robot_groups/robot_worker.rb +5 -5
  55. data/examples/robot_groups/same_process.rb +9 -9
  56. data/examples/sinatra_app/app.rb +1 -1
  57. data/examples/timeframe_demo.rb +1 -1
  58. data/lib/htm/active_record_config.rb +12 -25
  59. data/lib/htm/circuit_breaker.rb +0 -2
  60. data/lib/htm/config/defaults.yml +246 -0
  61. data/lib/htm/config.rb +888 -0
  62. data/lib/htm/database.rb +23 -27
  63. data/lib/htm/embedding_service.rb +0 -4
  64. data/lib/htm/integrations/sinatra.rb +3 -7
  65. data/lib/htm/job_adapter.rb +1 -15
  66. data/lib/htm/jobs/generate_embedding_job.rb +1 -7
  67. data/lib/htm/jobs/generate_propositions_job.rb +2 -12
  68. data/lib/htm/jobs/generate_tags_job.rb +1 -8
  69. data/lib/htm/loaders/defaults_loader.rb +143 -0
  70. data/lib/htm/loaders/xdg_config_loader.rb +116 -0
  71. data/lib/htm/mcp/cli.rb +200 -58
  72. data/lib/htm/mcp/server.rb +3 -3
  73. data/lib/htm/proposition_service.rb +2 -12
  74. data/lib/htm/railtie.rb +3 -4
  75. data/lib/htm/tag_service.rb +1 -8
  76. data/lib/htm/version.rb +1 -1
  77. data/lib/htm.rb +124 -5
  78. metadata +24 -4
  79. data/config/database.yml +0 -77
  80. data/lib/htm/configuration.rb +0 -799
data/lib/htm/database.rb CHANGED
@@ -11,7 +11,7 @@ class HTM
11
11
  class << self
12
12
  # Set up the HTM database schema
13
13
  #
14
- # @param db_url [String] Database connection URL (uses ENV['HTM_DBURL'] if not provided)
14
+ # @param db_url [String] Database connection URL (uses ENV['HTM_DATABASE__URL'] if not provided)
15
15
  # @param run_migrations [Boolean] Whether to run migrations (default: true)
16
16
  # @param dump_schema [Boolean] Whether to dump schema to db/schema.sql after setup (default: false)
17
17
  # @return [void]
@@ -40,7 +40,7 @@ class HTM
40
40
 
41
41
  # Run pending database migrations
42
42
  #
43
- # @param db_url [String] Database connection URL (uses ENV['HTM_DBURL'] if not provided)
43
+ # @param db_url [String] Database connection URL (uses ENV['HTM_DATABASE__URL'] if not provided)
44
44
  # @return [void]
45
45
  #
46
46
  def migrate(db_url = nil)
@@ -57,7 +57,7 @@ class HTM
57
57
 
58
58
  # Show migration status
59
59
  #
60
- # @param db_url [String] Database connection URL (uses ENV['HTM_DBURL'] if not provided)
60
+ # @param db_url [String] Database connection URL (uses ENV['HTM_DATABASE__URL'] if not provided)
61
61
  # @return [void]
62
62
  #
63
63
  def migration_status(db_url = nil)
@@ -158,7 +158,7 @@ class HTM
158
158
  # All seeding logic is contained in db/seeds.rb and reads data
159
159
  # from markdown files in db/seed_data/ directory.
160
160
  #
161
- # @param db_url [String] Database connection URL (uses ENV['HTM_DBURL'] if not provided)
161
+ # @param db_url [String] Database connection URL (uses ENV['HTM_DATABASE__URL'] if not provided)
162
162
  # @return [void]
163
163
  #
164
164
  def seed(db_url = nil)
@@ -292,7 +292,7 @@ class HTM
292
292
  # - Index information
293
293
  # - Relationship diagrams
294
294
  #
295
- # @param db_url [String] Database connection URL (uses ENV['HTM_DBURL'] if not provided)
295
+ # @param db_url [String] Database connection URL (uses ENV['HTM_DATABASE__URL'] if not provided)
296
296
  # @return [void]
297
297
  #
298
298
  def generate_docs(db_url = nil)
@@ -319,8 +319,8 @@ class HTM
319
319
  end
320
320
 
321
321
  # Get database URL
322
- dsn = db_url || ENV['HTM_DBURL']
323
- raise "Database configuration not found. Set HTM_DBURL environment variable." unless dsn
322
+ dsn = db_url || ENV['HTM_DATABASE__URL']
323
+ raise "Database configuration not found. Set HTM_DATABASE__URL environment variable." unless dsn
324
324
 
325
325
  # Ensure sslmode is set for local development (tbls requires it)
326
326
  unless dsn.include?('sslmode=')
@@ -460,30 +460,29 @@ class HTM
460
460
  # @return [Hash, nil] Connection configuration hash
461
461
  #
462
462
  def parse_connection_params
463
- return nil unless ENV['HTM_DBNAME']
463
+ return nil unless ENV['HTM_DATABASE__NAME']
464
464
 
465
465
  {
466
- host: ENV['HTM_DBHOST'] || 'localhost',
467
- port: (ENV['HTM_DBPORT'] || 5432).to_i,
468
- dbname: ENV['HTM_DBNAME'],
469
- user: ENV['HTM_DBUSER'],
470
- password: ENV['HTM_DBPASS'],
471
- sslmode: ENV['HTM_DBSSLMODE'] || 'prefer'
466
+ host: ENV['HTM_DATABASE__HOST'] || 'localhost',
467
+ port: (ENV['HTM_DATABASE__PORT'] || 5432).to_i,
468
+ dbname: ENV['HTM_DATABASE__NAME'],
469
+ user: ENV['HTM_DATABASE__USER'],
470
+ password: ENV['HTM_DATABASE__PASSWORD'],
471
+ sslmode: ENV['HTM_DATABASE__SSLMODE'] || 'prefer'
472
472
  }
473
473
  end
474
474
 
475
- # Get default database configuration (respects RAILS_ENV)
475
+ # Get default database configuration
476
476
  #
477
- # Uses ActiveRecordConfig which reads from config/database.yml
478
- # and respects RAILS_ENV for environment-specific database selection.
477
+ # Uses HTM::Config for database settings.
479
478
  #
480
479
  # @return [Hash, nil] Connection configuration hash with PG-style keys
481
480
  #
482
481
  def default_config
483
- require_relative 'active_record_config'
482
+ htm_config = HTM.config
484
483
 
485
- begin
486
- ar_config = HTM::ActiveRecordConfig.load_database_config
484
+ if htm_config.database_configured?
485
+ ar_config = htm_config.database_config
487
486
 
488
487
  # Convert ActiveRecord config keys to PG-style keys
489
488
  {
@@ -494,13 +493,10 @@ class HTM
494
493
  password: ar_config[:password],
495
494
  sslmode: ar_config[:sslmode] || 'prefer'
496
495
  }
497
- rescue StandardError
498
- # Fallback to legacy behavior if ActiveRecordConfig fails
499
- if ENV['HTM_DBURL']
500
- parse_connection_url(ENV['HTM_DBURL'])
501
- elsif ENV['HTM_DBNAME']
502
- parse_connection_params
503
- end
496
+ elsif ENV['HTM_DATABASE__URL']
497
+ parse_connection_url(ENV['HTM_DATABASE__URL'])
498
+ elsif ENV['HTM_DATABASE__NAME']
499
+ parse_connection_params
504
500
  end
505
501
  end
506
502
 
@@ -68,8 +68,6 @@ class HTM
68
68
  # @raise [CircuitBreakerOpenError] If circuit breaker is open
69
69
  #
70
70
  def self.generate(text)
71
- HTM.logger.debug "EmbeddingService: Generating embedding for #{text.length} chars"
72
-
73
71
  # Use circuit breaker to protect against cascading failures
74
72
  raw_embedding = circuit_breaker.call do
75
73
  HTM.configuration.embedding_generator.call(text)
@@ -95,8 +93,6 @@ class HTM
95
93
  # Format for database storage
96
94
  storage_string = format_for_storage(storage_embedding)
97
95
 
98
- HTM.logger.debug "EmbeddingService: Generated #{actual_dimension}D embedding (padded to #{max_dim})"
99
-
100
96
  {
101
97
  embedding: raw_embedding,
102
98
  dimension: actual_dimension,
@@ -138,7 +138,6 @@ class HTM
138
138
  return if @@db_config
139
139
 
140
140
  @@db_config = HTM::ActiveRecordConfig.load_database_config
141
- HTM.logger.debug "HTM database config stored for thread-safe access"
142
141
  end
143
142
  end
144
143
 
@@ -160,15 +159,13 @@ class HTM
160
159
  # Re-establish connection using stored config
161
160
  if @@db_config
162
161
  ActiveRecord::Base.establish_connection(@@db_config)
163
- HTM.logger.debug "HTM database connection established for request thread"
164
162
  else
165
163
  raise "HTM database config not stored - call register_htm at app startup"
166
164
  end
167
- rescue ActiveRecord::ConnectionNotDefined, ActiveRecord::ConnectionNotEstablished => e
165
+ rescue ActiveRecord::ConnectionNotDefined, ActiveRecord::ConnectionNotEstablished
168
166
  # Pool doesn't exist, establish connection
169
167
  if @@db_config
170
168
  ActiveRecord::Base.establish_connection(@@db_config)
171
- HTM.logger.debug "HTM database connection established for request thread"
172
169
  else
173
170
  raise "HTM database config not stored - call register_htm at app startup"
174
171
  end
@@ -209,9 +206,9 @@ module ::Sinatra
209
206
 
210
207
  # Use Sidekiq if available, otherwise thread-based
211
208
  if defined?(::Sidekiq)
212
- config.job_backend = :sidekiq
209
+ config.job.backend = :sidekiq
213
210
  else
214
- config.job_backend = :thread
211
+ config.job.backend = :thread
215
212
  end
216
213
  end
217
214
 
@@ -226,7 +223,6 @@ module ::Sinatra
226
223
  end
227
224
 
228
225
  HTM.logger.info "HTM registered with Sinatra application"
229
- HTM.logger.debug "HTM job backend: #{HTM.configuration.job_backend}"
230
226
  end
231
227
  end
232
228
  end
@@ -14,7 +14,7 @@ class HTM
14
14
  #
15
15
  # @example Configure job backend
16
16
  # HTM.configure do |config|
17
- # config.job_backend = :active_job
17
+ # config.job.backend = :active_job
18
18
  # end
19
19
  #
20
20
  # @example Enqueue a job
@@ -60,8 +60,6 @@ class HTM
60
60
  # Convert job class to ActiveJob if needed
61
61
  active_job_class = to_active_job_class(job_class)
62
62
  active_job_class.perform_later(**params)
63
-
64
- HTM.logger.debug "Enqueued #{job_class.name} via ActiveJob with params: #{params.inspect}"
65
63
  end
66
64
 
67
65
  # Enqueue job using Sidekiq directly
@@ -76,38 +74,26 @@ class HTM
76
74
  # Sidekiq 7.x requires native JSON types - convert symbol keys to strings
77
75
  json_params = params.transform_keys(&:to_s)
78
76
  sidekiq_class.perform_async(json_params)
79
-
80
- HTM.logger.debug "Enqueued #{job_class.name} via Sidekiq with params: #{params.inspect}"
81
77
  end
82
78
 
83
79
  # Execute job inline (synchronously)
84
80
  def enqueue_inline(job_class, **params)
85
- HTM.logger.debug "Executing #{job_class.name} inline with params: #{params.inspect}"
86
-
87
81
  begin
88
82
  job_class.perform(**params)
89
- HTM.logger.debug "Completed #{job_class.name} inline execution"
90
83
  rescue StandardError => e
91
84
  HTM.logger.error "Inline job #{job_class.name} failed: #{e.class.name} - #{e.message}"
92
- HTM.logger.debug e.backtrace.first(5).join("\n")
93
85
  end
94
86
  end
95
87
 
96
88
  # Execute job in background thread (legacy)
97
89
  def enqueue_thread(job_class, **params)
98
90
  Thread.new do
99
- HTM.logger.debug "Executing #{job_class.name} in thread with params: #{params.inspect}"
100
-
101
91
  begin
102
92
  job_class.perform(**params)
103
- HTM.logger.debug "Completed #{job_class.name} thread execution"
104
93
  rescue StandardError => e
105
94
  HTM.logger.error "Thread job #{job_class.name} failed: #{e.class.name} - #{e.message}"
106
- HTM.logger.debug e.backtrace.first(5).join("\n")
107
95
  end
108
96
  end
109
-
110
- HTM.logger.debug "Started thread for #{job_class.name}"
111
97
  rescue StandardError => e
112
98
  HTM.logger.error "Failed to start thread for #{job_class.name}: #{e.message}"
113
99
  end
@@ -31,17 +31,12 @@ class HTM
31
31
  end
32
32
 
33
33
  # Skip if already has embedding
34
- if node.embedding.present?
35
- HTM.logger.debug "GenerateEmbeddingJob: Node #{node_id} already has embedding, skipping"
36
- return
37
- end
34
+ return if node.embedding.present?
38
35
 
39
36
  provider = HTM.configuration.embedding_provider.to_s
40
37
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
38
 
42
39
  begin
43
- HTM.logger.debug "GenerateEmbeddingJob: Generating embedding for node #{node_id}"
44
-
45
40
  # Generate and process embedding using EmbeddingService
46
41
  result = HTM::EmbeddingService.generate(node.content)
47
42
 
@@ -77,7 +72,6 @@ class HTM
77
72
 
78
73
  # Log unexpected errors
79
74
  HTM.logger.error "GenerateEmbeddingJob: Unexpected error for node #{node_id}: #{e.class.name} - #{e.message}"
80
- HTM.logger.debug e.backtrace.first(5).join("\n")
81
75
  end
82
76
  end
83
77
  end
@@ -33,21 +33,12 @@ class HTM
33
33
  end
34
34
 
35
35
  # Skip if this node is already a proposition (prevent recursion)
36
- if node.metadata&.dig('is_proposition')
37
- HTM.logger.debug "GeneratePropositionsJob: Node #{node_id} is a proposition, skipping"
38
- return
39
- end
36
+ return if node.metadata&.dig('is_proposition')
40
37
 
41
38
  begin
42
- HTM.logger.debug "GeneratePropositionsJob: Extracting propositions for node #{node_id}"
43
-
44
39
  # Extract propositions using PropositionService
45
40
  propositions = HTM::PropositionService.extract(node.content)
46
-
47
- if propositions.empty?
48
- HTM.logger.debug "GeneratePropositionsJob: No propositions extracted for node #{node_id}"
49
- return
50
- end
41
+ return if propositions.empty?
51
42
 
52
43
  HTM.logger.info "GeneratePropositionsJob: Extracted #{propositions.length} propositions for node #{node_id}"
53
44
 
@@ -95,7 +86,6 @@ class HTM
95
86
  rescue StandardError => e
96
87
  # Log unexpected errors
97
88
  HTM.logger.error "GeneratePropositionsJob: Unexpected error for node #{node_id}: #{e.class.name} - #{e.message}"
98
- HTM.logger.debug e.backtrace.first(5).join("\n")
99
89
  end
100
90
  end
101
91
  end
@@ -37,8 +37,6 @@ class HTM
37
37
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
38
38
 
39
39
  begin
40
- HTM.logger.debug "GenerateTagsJob: Extracting tags for node #{node_id}"
41
-
42
40
  # Get existing ontology for context (sample of recent tags)
43
41
  existing_ontology = HTM::Models::Tag
44
42
  .order(created_at: :desc)
@@ -47,11 +45,7 @@ class HTM
47
45
 
48
46
  # Extract and validate tags using TagService
49
47
  tag_names = HTM::TagService.extract(node.content, existing_ontology: existing_ontology)
50
-
51
- if tag_names.empty?
52
- HTM.logger.debug "GenerateTagsJob: No tags extracted for node #{node_id}"
53
- return
54
- end
48
+ return if tag_names.empty?
55
49
 
56
50
  # Create or find tags and associate with node
57
51
  tag_names.each do |tag_name|
@@ -102,7 +96,6 @@ class HTM
102
96
 
103
97
  # Log unexpected errors
104
98
  HTM.logger.error "GenerateTagsJob: Unexpected error for node #{node_id}: #{e.class.name} - #{e.message}"
105
- HTM.logger.debug e.backtrace.first(5).join("\n")
106
99
  end
107
100
  end
108
101
  end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'anyway_config'
4
+ require 'yaml'
5
+
6
+ class HTM
7
+ module Loaders
8
+ # Bundled Defaults Loader for Anyway Config
9
+ #
10
+ # Loads default configuration values from a YAML file bundled with the gem.
11
+ # This ensures defaults are always available regardless of where HTM is installed.
12
+ #
13
+ # The defaults.yml file has this structure:
14
+ # defaults: # Base values for all environments
15
+ # database:
16
+ # host: localhost
17
+ # port: 5432
18
+ # development: # Overrides for development
19
+ # database:
20
+ # name: htm_development
21
+ # test: # Overrides for test
22
+ # database:
23
+ # name: htm_test
24
+ # production: # Overrides for production
25
+ # database:
26
+ # sslmode: require
27
+ #
28
+ # This loader deep-merges `defaults` with the current environment's overrides.
29
+ #
30
+ # This loader runs at LOWEST priority (before XDG), so all other sources
31
+ # can override these bundled defaults:
32
+ # 1. Bundled defaults (this loader)
33
+ # 2. XDG user config (~/.config/htm/htm.yml)
34
+ # 3. Project config (./config/htm.yml)
35
+ # 4. Local overrides (./config/htm.local.yml)
36
+ # 5. Environment variables (HTM_*)
37
+ # 6. Programmatic (configure block)
38
+ #
39
+ class DefaultsLoader < Anyway::Loaders::Base
40
+ DEFAULTS_PATH = File.expand_path('../config/defaults.yml', __dir__).freeze
41
+
42
+ class << self
43
+ # Returns the path to the bundled defaults file
44
+ #
45
+ # @return [String] path to defaults.yml
46
+ def defaults_path
47
+ DEFAULTS_PATH
48
+ end
49
+
50
+ # Check if defaults file exists
51
+ #
52
+ # @return [Boolean]
53
+ def defaults_exist?
54
+ File.exist?(DEFAULTS_PATH)
55
+ end
56
+
57
+ # Load and parse the raw YAML content
58
+ #
59
+ # @return [Hash] parsed YAML with symbolized keys
60
+ def load_raw_yaml
61
+ return {} unless defaults_exist?
62
+
63
+ content = File.read(defaults_path)
64
+ YAML.safe_load(
65
+ content,
66
+ permitted_classes: [Symbol],
67
+ symbolize_names: true,
68
+ aliases: true
69
+ ) || {}
70
+ rescue Psych::SyntaxError => e
71
+ warn "HTM: Failed to parse bundled defaults #{defaults_path}: #{e.message}"
72
+ {}
73
+ end
74
+
75
+ # Extract the schema (attribute names) from the defaults section
76
+ #
77
+ # @return [Hash] the defaults section containing all attribute definitions
78
+ def schema
79
+ raw = load_raw_yaml
80
+ raw[:defaults] || {}
81
+ end
82
+ end
83
+
84
+ def call(name:, **_options)
85
+ return {} unless self.class.defaults_exist?
86
+
87
+ trace!(:bundled_defaults, path: self.class.defaults_path) do
88
+ load_and_merge_for_environment
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ # Load defaults and deep merge with environment-specific overrides
95
+ #
96
+ # @return [Hash] merged configuration for current environment
97
+ def load_and_merge_for_environment
98
+ raw = self.class.load_raw_yaml
99
+ return {} if raw.empty?
100
+
101
+ # Start with the defaults section
102
+ defaults = raw[:defaults] || {}
103
+
104
+ # Deep merge with environment-specific overrides
105
+ env = current_environment
106
+ env_overrides = raw[env.to_sym] || {}
107
+
108
+ deep_merge(defaults, env_overrides)
109
+ end
110
+
111
+ # Deep merge two hashes, with overlay taking precedence
112
+ #
113
+ # @param base [Hash] base configuration
114
+ # @param overlay [Hash] overlay configuration (takes precedence)
115
+ # @return [Hash] merged result
116
+ def deep_merge(base, overlay)
117
+ base.merge(overlay) do |_key, old_val, new_val|
118
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
119
+ deep_merge(old_val, new_val)
120
+ else
121
+ new_val
122
+ end
123
+ end
124
+ end
125
+
126
+ # Determine the current environment
127
+ #
128
+ # Priority: HTM_ENV > RAILS_ENV > RACK_ENV > 'development'
129
+ #
130
+ # @return [String] current environment name
131
+ def current_environment
132
+ ENV['HTM_ENV'] || ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ # Register the defaults loader at LOWEST priority (before :yml loader)
139
+ # This ensures bundled defaults are overridden by all other sources:
140
+ # - XDG user config (registered after this, also before :yml)
141
+ # - Project config (:yml loader)
142
+ # - Environment variables (:env loader)
143
+ Anyway.loaders.insert_before :yml, :bundled_defaults, HTM::Loaders::DefaultsLoader
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'anyway_config'
4
+ require 'yaml'
5
+
6
+ class HTM
7
+ module Loaders
8
+ # XDG Base Directory Specification loader for Anyway Config
9
+ #
10
+ # Loads configuration from XDG-compliant paths:
11
+ # 1. $XDG_CONFIG_HOME/htm/htm.yml (if XDG_CONFIG_HOME is set)
12
+ # 2. ~/.config/htm/htm.yml (XDG default fallback)
13
+ #
14
+ # On macOS, also checks:
15
+ # 3. ~/Library/Application Support/htm/htm.yml
16
+ #
17
+ # This loader runs BEFORE the project-local config loader,
18
+ # so project configs take precedence over user-global configs.
19
+ #
20
+ # @example XDG config file location
21
+ # ~/.config/htm/htm.yml
22
+ #
23
+ # @example Custom XDG_CONFIG_HOME
24
+ # export XDG_CONFIG_HOME=/my/config
25
+ # # Looks for /my/config/htm/htm.yml
26
+ #
27
+ class XdgConfigLoader < Anyway::Loaders::Base
28
+ class << self
29
+ # Returns all XDG config paths to check, in order of priority (lowest first)
30
+ #
31
+ # @return [Array<String>] list of potential config file paths
32
+ def config_paths
33
+ paths = []
34
+
35
+ # macOS Application Support (lowest priority for XDG loader)
36
+ if macos?
37
+ macos_path = File.expand_path('~/Library/Application Support/htm')
38
+ paths << macos_path if Dir.exist?(File.dirname(macos_path))
39
+ end
40
+
41
+ # XDG default: ~/.config/htm
42
+ xdg_default = File.expand_path('~/.config/htm')
43
+ paths << xdg_default
44
+
45
+ # XDG_CONFIG_HOME override (highest priority for XDG loader)
46
+ if ENV['XDG_CONFIG_HOME'] && !ENV['XDG_CONFIG_HOME'].empty?
47
+ xdg_home = File.join(ENV['XDG_CONFIG_HOME'], 'htm')
48
+ paths << xdg_home unless xdg_home == xdg_default
49
+ end
50
+
51
+ paths
52
+ end
53
+
54
+ # Find the first existing config file
55
+ #
56
+ # @param name [String] config name (e.g., 'htm')
57
+ # @return [String, nil] path to config file or nil if not found
58
+ def find_config_file(name)
59
+ config_paths.reverse_each do |dir|
60
+ file = File.join(dir, "#{name}.yml")
61
+ return file if File.exist?(file)
62
+ end
63
+ nil
64
+ end
65
+
66
+ private
67
+
68
+ def macos?
69
+ RUBY_PLATFORM.include?('darwin')
70
+ end
71
+ end
72
+
73
+ def call(name:, **_options)
74
+ config_file = self.class.find_config_file(name)
75
+ return {} unless config_file
76
+
77
+ trace!(:xdg, path: config_file) do
78
+ load_yaml(config_file, name)
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def load_yaml(path, name)
85
+ return {} unless File.exist?(path)
86
+
87
+ content = File.read(path)
88
+ parsed = YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true, aliases: true) || {}
89
+
90
+ # Support environment-specific configs
91
+ env = Anyway::Settings.current_environment ||
92
+ ENV['HTM_ENV'] ||
93
+ ENV['RAILS_ENV'] ||
94
+ ENV['RACK_ENV'] ||
95
+ 'development'
96
+
97
+ # Check for environment key first, fall back to root level
98
+ if parsed.key?(env.to_sym)
99
+ parsed[env.to_sym] || {}
100
+ elsif parsed.key?(env.to_s)
101
+ parsed[env.to_s] || {}
102
+ else
103
+ # No environment key, treat as flat config
104
+ parsed
105
+ end
106
+ rescue Psych::SyntaxError => e
107
+ warn "HTM: Failed to parse XDG config #{path}: #{e.message}"
108
+ {}
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ # Register the XDG loader with Anyway Config
115
+ # Insert before :yml so project-local config takes precedence
116
+ Anyway.loaders.insert_before :yml, :xdg, HTM::Loaders::XdgConfigLoader