sxn 0.2.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 (156) hide show
  1. checksums.yaml +7 -0
  2. data/.gem_rbs_collection/addressable/2.8/.rbs_meta.yaml +9 -0
  3. data/.gem_rbs_collection/addressable/2.8/addressable.rbs +62 -0
  4. data/.gem_rbs_collection/async/2.12/.rbs_meta.yaml +9 -0
  5. data/.gem_rbs_collection/async/2.12/async.rbs +119 -0
  6. data/.gem_rbs_collection/async/2.12/kernel.rbs +5 -0
  7. data/.gem_rbs_collection/async/2.12/manifest.yaml +7 -0
  8. data/.gem_rbs_collection/bcrypt/3.1/.rbs_meta.yaml +9 -0
  9. data/.gem_rbs_collection/bcrypt/3.1/bcrypt.rbs +47 -0
  10. data/.gem_rbs_collection/bcrypt/3.1/manifest.yaml +2 -0
  11. data/.gem_rbs_collection/bigdecimal/3.1/.rbs_meta.yaml +9 -0
  12. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal-math.rbs +119 -0
  13. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal.rbs +1630 -0
  14. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
  15. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
  16. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +26 -0
  17. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
  18. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +65 -0
  19. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
  20. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
  21. data/.gem_rbs_collection/diff-lcs/1.5/.rbs_meta.yaml +9 -0
  22. data/.gem_rbs_collection/diff-lcs/1.5/diff-lcs.rbs +11 -0
  23. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
  24. data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
  25. data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
  26. data/.gem_rbs_collection/mini_mime/0.1/.rbs_meta.yaml +9 -0
  27. data/.gem_rbs_collection/mini_mime/0.1/mini_mime.rbs +14 -0
  28. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
  29. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
  30. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
  31. data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
  32. data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
  33. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
  34. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +822 -0
  35. data/.gem_rbs_collection/sqlite3/2.0/.rbs_meta.yaml +9 -0
  36. data/.gem_rbs_collection/sqlite3/2.0/database.rbs +20 -0
  37. data/.gem_rbs_collection/sqlite3/2.0/pragmas.rbs +5 -0
  38. data/.rspec +4 -0
  39. data/.rubocop.yml +121 -0
  40. data/.simplecov +51 -0
  41. data/CHANGELOG.md +49 -0
  42. data/Gemfile +24 -0
  43. data/Gemfile.lock +329 -0
  44. data/LICENSE.txt +21 -0
  45. data/README.md +225 -0
  46. data/Rakefile +54 -0
  47. data/Steepfile +50 -0
  48. data/bin/sxn +6 -0
  49. data/lib/sxn/CLI.rb +275 -0
  50. data/lib/sxn/commands/init.rb +137 -0
  51. data/lib/sxn/commands/projects.rb +350 -0
  52. data/lib/sxn/commands/rules.rb +435 -0
  53. data/lib/sxn/commands/sessions.rb +300 -0
  54. data/lib/sxn/commands/worktrees.rb +416 -0
  55. data/lib/sxn/commands.rb +13 -0
  56. data/lib/sxn/config/config_cache.rb +295 -0
  57. data/lib/sxn/config/config_discovery.rb +242 -0
  58. data/lib/sxn/config/config_validator.rb +562 -0
  59. data/lib/sxn/config.rb +259 -0
  60. data/lib/sxn/core/config_manager.rb +290 -0
  61. data/lib/sxn/core/project_manager.rb +307 -0
  62. data/lib/sxn/core/rules_manager.rb +306 -0
  63. data/lib/sxn/core/session_manager.rb +336 -0
  64. data/lib/sxn/core/worktree_manager.rb +281 -0
  65. data/lib/sxn/core.rb +13 -0
  66. data/lib/sxn/database/errors.rb +29 -0
  67. data/lib/sxn/database/session_database.rb +691 -0
  68. data/lib/sxn/database.rb +24 -0
  69. data/lib/sxn/errors.rb +76 -0
  70. data/lib/sxn/rules/base_rule.rb +367 -0
  71. data/lib/sxn/rules/copy_files_rule.rb +346 -0
  72. data/lib/sxn/rules/errors.rb +28 -0
  73. data/lib/sxn/rules/project_detector.rb +871 -0
  74. data/lib/sxn/rules/rules_engine.rb +485 -0
  75. data/lib/sxn/rules/setup_commands_rule.rb +307 -0
  76. data/lib/sxn/rules/template_rule.rb +262 -0
  77. data/lib/sxn/rules.rb +148 -0
  78. data/lib/sxn/runtime_validations.rb +96 -0
  79. data/lib/sxn/security/secure_command_executor.rb +364 -0
  80. data/lib/sxn/security/secure_file_copier.rb +478 -0
  81. data/lib/sxn/security/secure_path_validator.rb +258 -0
  82. data/lib/sxn/security.rb +15 -0
  83. data/lib/sxn/templates/common/gitignore.liquid +99 -0
  84. data/lib/sxn/templates/common/session-info.md.liquid +58 -0
  85. data/lib/sxn/templates/errors.rb +36 -0
  86. data/lib/sxn/templates/javascript/README.md.liquid +59 -0
  87. data/lib/sxn/templates/javascript/session-info.md.liquid +206 -0
  88. data/lib/sxn/templates/rails/CLAUDE.md.liquid +78 -0
  89. data/lib/sxn/templates/rails/database.yml.liquid +31 -0
  90. data/lib/sxn/templates/rails/session-info.md.liquid +144 -0
  91. data/lib/sxn/templates/template_engine.rb +346 -0
  92. data/lib/sxn/templates/template_processor.rb +279 -0
  93. data/lib/sxn/templates/template_security.rb +410 -0
  94. data/lib/sxn/templates/template_variables.rb +713 -0
  95. data/lib/sxn/templates.rb +28 -0
  96. data/lib/sxn/ui/output.rb +103 -0
  97. data/lib/sxn/ui/progress_bar.rb +91 -0
  98. data/lib/sxn/ui/prompt.rb +116 -0
  99. data/lib/sxn/ui/table.rb +183 -0
  100. data/lib/sxn/ui.rb +12 -0
  101. data/lib/sxn/version.rb +5 -0
  102. data/lib/sxn.rb +63 -0
  103. data/rbs_collection.lock.yaml +180 -0
  104. data/rbs_collection.yaml +39 -0
  105. data/scripts/test.sh +31 -0
  106. data/sig/external/liquid.rbs +116 -0
  107. data/sig/external/thor.rbs +99 -0
  108. data/sig/external/tty.rbs +71 -0
  109. data/sig/sxn/cli.rbs +46 -0
  110. data/sig/sxn/commands/init.rbs +38 -0
  111. data/sig/sxn/commands/projects.rbs +72 -0
  112. data/sig/sxn/commands/rules.rbs +95 -0
  113. data/sig/sxn/commands/sessions.rbs +62 -0
  114. data/sig/sxn/commands/worktrees.rbs +82 -0
  115. data/sig/sxn/commands.rbs +6 -0
  116. data/sig/sxn/config/config_cache.rbs +67 -0
  117. data/sig/sxn/config/config_discovery.rbs +64 -0
  118. data/sig/sxn/config/config_validator.rbs +64 -0
  119. data/sig/sxn/config.rbs +74 -0
  120. data/sig/sxn/core/config_manager.rbs +67 -0
  121. data/sig/sxn/core/project_manager.rbs +52 -0
  122. data/sig/sxn/core/rules_manager.rbs +54 -0
  123. data/sig/sxn/core/session_manager.rbs +59 -0
  124. data/sig/sxn/core/worktree_manager.rbs +50 -0
  125. data/sig/sxn/core.rbs +87 -0
  126. data/sig/sxn/database/errors.rbs +37 -0
  127. data/sig/sxn/database/session_database.rbs +151 -0
  128. data/sig/sxn/database.rbs +83 -0
  129. data/sig/sxn/errors.rbs +89 -0
  130. data/sig/sxn/rules/base_rule.rbs +137 -0
  131. data/sig/sxn/rules/copy_files_rule.rbs +65 -0
  132. data/sig/sxn/rules/errors.rbs +33 -0
  133. data/sig/sxn/rules/project_detector.rbs +115 -0
  134. data/sig/sxn/rules/rules_engine.rbs +118 -0
  135. data/sig/sxn/rules/setup_commands_rule.rbs +60 -0
  136. data/sig/sxn/rules/template_rule.rbs +44 -0
  137. data/sig/sxn/rules.rbs +287 -0
  138. data/sig/sxn/runtime_validations.rbs +16 -0
  139. data/sig/sxn/security/secure_command_executor.rbs +63 -0
  140. data/sig/sxn/security/secure_file_copier.rbs +79 -0
  141. data/sig/sxn/security/secure_path_validator.rbs +30 -0
  142. data/sig/sxn/security.rbs +128 -0
  143. data/sig/sxn/templates/errors.rbs +43 -0
  144. data/sig/sxn/templates/template_engine.rbs +50 -0
  145. data/sig/sxn/templates/template_processor.rbs +44 -0
  146. data/sig/sxn/templates/template_security.rbs +62 -0
  147. data/sig/sxn/templates/template_variables.rbs +103 -0
  148. data/sig/sxn/templates.rbs +104 -0
  149. data/sig/sxn/ui/output.rbs +50 -0
  150. data/sig/sxn/ui/progress_bar.rbs +39 -0
  151. data/sig/sxn/ui/prompt.rbs +38 -0
  152. data/sig/sxn/ui/table.rbs +43 -0
  153. data/sig/sxn/ui.rbs +63 -0
  154. data/sig/sxn/version.rbs +5 -0
  155. data/sig/sxn.rbs +29 -0
  156. metadata +635 -0
@@ -0,0 +1,562 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../errors"
5
+
6
+ module Sxn
7
+ module Config
8
+ # Validates configuration structure and values
9
+ #
10
+ # Features:
11
+ # - Schema validation for configuration structure
12
+ # - Value validation with helpful error messages
13
+ # - Support for configuration migrations from older versions
14
+ # - Type checking and constraint validation
15
+ class ConfigValidator
16
+ # Current schema version
17
+ CURRENT_VERSION = 1
18
+
19
+ # Configuration schema definition
20
+ SCHEMA = {
21
+ "version" => {
22
+ type: :integer,
23
+ required: true,
24
+ min: 1,
25
+ max: CURRENT_VERSION
26
+ },
27
+ "sessions_folder" => {
28
+ type: :string,
29
+ required: true,
30
+ min_length: 1
31
+ },
32
+ "current_session" => {
33
+ type: :string,
34
+ required: false
35
+ },
36
+ "projects" => {
37
+ type: :hash,
38
+ required: true,
39
+ default: {},
40
+ value_schema: {
41
+ "path" => {
42
+ type: :string,
43
+ required: true,
44
+ min_length: 1
45
+ },
46
+ "type" => {
47
+ type: :string,
48
+ required: false,
49
+ allowed_values: %w[rails ruby javascript typescript react nextjs vue angular unknown]
50
+ },
51
+ "default_branch" => {
52
+ type: :string,
53
+ required: false,
54
+ default: "main"
55
+ },
56
+ "package_manager" => {
57
+ type: :string,
58
+ required: false,
59
+ allowed_values: %w[npm yarn pnpm]
60
+ },
61
+ "rules" => {
62
+ type: :hash,
63
+ required: false,
64
+ default: {},
65
+ value_schema: {
66
+ "copy_files" => {
67
+ type: :array,
68
+ required: false,
69
+ item_schema: {
70
+ "source" => {
71
+ type: :string,
72
+ required: true,
73
+ min_length: 1
74
+ },
75
+ "strategy" => {
76
+ type: :string,
77
+ required: false,
78
+ default: "copy",
79
+ allowed_values: %w[copy symlink]
80
+ },
81
+ "permissions" => {
82
+ type: :integer,
83
+ required: false,
84
+ min: 0,
85
+ max: 0o777
86
+ },
87
+ "encrypt" => {
88
+ type: :boolean,
89
+ required: false,
90
+ default: false
91
+ }
92
+ }
93
+ },
94
+ "setup_commands" => {
95
+ type: :array,
96
+ required: false,
97
+ item_schema: {
98
+ "command" => {
99
+ type: :array,
100
+ required: true,
101
+ min_length: 1,
102
+ item_type: :string
103
+ },
104
+ "environment" => {
105
+ type: :hash,
106
+ required: false,
107
+ value_type: :string
108
+ },
109
+ "condition" => {
110
+ type: :string,
111
+ required: false,
112
+ allowed_values: %w[always db_not_exists file_not_exists]
113
+ }
114
+ }
115
+ },
116
+ "templates" => {
117
+ type: :array,
118
+ required: false,
119
+ item_schema: {
120
+ "source" => {
121
+ type: :string,
122
+ required: true,
123
+ min_length: 1
124
+ },
125
+ "destination" => {
126
+ type: :string,
127
+ required: true,
128
+ min_length: 1
129
+ },
130
+ "process" => {
131
+ type: :boolean,
132
+ required: false,
133
+ default: true
134
+ },
135
+ "engine" => {
136
+ type: :string,
137
+ required: false,
138
+ default: "liquid",
139
+ allowed_values: %w[liquid erb mustache]
140
+ }
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+ },
147
+ "settings" => {
148
+ type: :hash,
149
+ required: false,
150
+ default: {},
151
+ value_schema: {
152
+ "auto_cleanup" => {
153
+ type: :boolean,
154
+ required: false,
155
+ default: true
156
+ },
157
+ "max_sessions" => {
158
+ type: :integer,
159
+ required: false,
160
+ default: 10,
161
+ min: 1,
162
+ max: 100
163
+ },
164
+ "worktree_cleanup_days" => {
165
+ type: :integer,
166
+ required: false,
167
+ default: 30,
168
+ min: 1,
169
+ max: 365
170
+ },
171
+ "default_rules" => {
172
+ type: :hash,
173
+ required: false,
174
+ default: {},
175
+ value_schema: {
176
+ "templates" => {
177
+ type: :array,
178
+ required: false,
179
+ item_schema: {
180
+ "source" => {
181
+ type: :string,
182
+ required: true,
183
+ min_length: 1
184
+ },
185
+ "destination" => {
186
+ type: :string,
187
+ required: true,
188
+ min_length: 1
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }.freeze
197
+
198
+ attr_reader :errors
199
+
200
+ def initialize
201
+ @errors = []
202
+ end
203
+
204
+ # Validate configuration against schema
205
+ # @param config [Hash] Configuration to validate
206
+ # @return [Boolean] True if valid
207
+ def valid?(config)
208
+ @errors = []
209
+
210
+ unless config.is_a?(Hash)
211
+ @errors << "Configuration must be a hash, got #{config.class}"
212
+ return false
213
+ end
214
+
215
+ validate_against_schema(config, SCHEMA, "")
216
+
217
+ @errors.empty?
218
+ end
219
+
220
+ # Validate and migrate configuration if needed
221
+ # @param config [Hash] Configuration to validate and migrate
222
+ # @return [Hash] Validated and migrated configuration
223
+ # @raise [ConfigurationError] If validation fails
224
+ def validate_and_migrate(config)
225
+ # First, migrate if needed
226
+ migrated_config = migrate_config(config)
227
+
228
+ # Debug output
229
+ # puts "DEBUG: Original config: #{config.inspect}"
230
+ # puts "DEBUG: Migrated config: #{migrated_config.inspect}"
231
+
232
+ # Then validate the migrated config
233
+ unless valid?(migrated_config)
234
+ error_message = format_errors
235
+ raise ConfigurationError, "Configuration validation failed:\n#{error_message}"
236
+ end
237
+
238
+ # Apply defaults for missing values
239
+ apply_defaults(migrated_config)
240
+ end
241
+
242
+ # Get formatted error messages
243
+ # @return [String] Formatted error messages
244
+ def format_errors
245
+ return "No errors" if @errors.empty?
246
+
247
+ @errors.map.with_index(1) do |error, index|
248
+ " #{index}. #{error}"
249
+ end.join("\n")
250
+ end
251
+
252
+ # Migrate configuration from older versions
253
+ # @param config [Hash] Configuration to migrate
254
+ # @return [Hash] Migrated configuration
255
+ def migrate_config(config)
256
+ return config unless config.is_a?(Hash)
257
+
258
+ version = config["version"] || 0
259
+ migrated_config = config.dup
260
+
261
+ # Check if this is a v0 config that got merged with system defaults (version = 1)
262
+ # but still has v0 structure (projects without paths)
263
+ # Handle invalid version types safely
264
+ needs_v0_migration = (version.is_a?(Integer) && version.zero?) || needs_v0_to_v1_migration?(config)
265
+
266
+ case version
267
+ when 0
268
+ # Migrate from unversioned to version 1
269
+ migrated_config = migrate_v0_to_v1(migrated_config)
270
+ when 1
271
+ if needs_v0_migration
272
+ # This is a v0 config that was merged with v1 defaults, migrate it
273
+ migrated_config = migrate_v0_to_v1(migrated_config)
274
+ end
275
+ end
276
+
277
+ migrated_config
278
+ end
279
+
280
+ private
281
+
282
+ # Check if config needs v0 to v1 migration based on structure
283
+ # @param config [Hash] Configuration to check
284
+ # @return [Boolean] True if needs migration
285
+ def needs_v0_to_v1_migration?(config)
286
+ return false unless config.is_a?(Hash)
287
+ return false unless config["projects"].is_a?(Hash)
288
+
289
+ # Check if any project is missing a path field (indicating v0 structure)
290
+ config["projects"].any? do |_project_name, project_config|
291
+ project_config.is_a?(Hash) &&
292
+ (project_config["path"].nil? || project_config["path"].empty?)
293
+ end
294
+ end
295
+
296
+ # Validate configuration against schema recursively
297
+ # @param config [Hash] Configuration to validate
298
+ # @param schema [Hash] Schema to validate against
299
+ # @param path [String] Current path for error reporting
300
+ def validate_against_schema(config, schema, path)
301
+ schema.each do |key, field_schema|
302
+ field_path = path.empty? ? key : "#{path}.#{key}"
303
+ value = config[key]
304
+
305
+ # Check required fields
306
+ if field_schema[:required] && (value.nil? || (value.is_a?(String) && value.empty?))
307
+ @errors << "Required field '#{field_path}' is missing or empty"
308
+ next
309
+ end
310
+
311
+ # Skip validation if field is not present and not required
312
+ next if value.nil? && !field_schema[:required]
313
+
314
+ validate_field_type(value, field_schema, field_path)
315
+
316
+ # Only validate constraints and nested schemas if type is correct
317
+ if value_has_correct_type?(value, field_schema)
318
+ validate_field_constraints(value, field_schema, field_path)
319
+ validate_nested_schema(value, field_schema, field_path)
320
+ end
321
+ end
322
+ end
323
+
324
+ # Check if value has the correct type according to schema
325
+ # @param value [Object] Value to check
326
+ # @param schema [Hash] Field schema
327
+ # @return [Boolean] True if value has correct type
328
+ def value_has_correct_type?(value, schema)
329
+ expected_type = schema[:type]
330
+ return true unless expected_type
331
+
332
+ case expected_type
333
+ when :string
334
+ value.is_a?(String)
335
+ when :integer
336
+ value.is_a?(Integer)
337
+ when :boolean
338
+ [true, false].include?(value)
339
+ when :array
340
+ value.is_a?(Array)
341
+ when :hash
342
+ value.is_a?(Hash)
343
+ else
344
+ false
345
+ end
346
+ end
347
+
348
+ # Validate field type
349
+ # @param value [Object] Value to validate
350
+ # @param schema [Hash] Field schema
351
+ # @param path [String] Field path for error reporting
352
+ def validate_field_type(value, schema, path)
353
+ expected_type = schema[:type]
354
+ return unless expected_type
355
+
356
+ return if value_has_correct_type?(value, schema)
357
+
358
+ @errors << "Field '#{path}' must be of type #{expected_type}, got #{value.class.name.downcase}"
359
+ end
360
+
361
+ # Validate field constraints
362
+ # @param value [Object] Value to validate
363
+ # @param schema [Hash] Field schema
364
+ # @param path [String] Field path for error reporting
365
+ def validate_field_constraints(value, schema, path)
366
+ # String constraints
367
+ if value.is_a?(String)
368
+ if schema[:min_length] && value.length < schema[:min_length]
369
+ @errors << "Field '#{path}' must be at least #{schema[:min_length]} characters long"
370
+ end
371
+
372
+ if schema[:max_length] && value.length > schema[:max_length]
373
+ @errors << "Field '#{path}' must be at most #{schema[:max_length]} characters long"
374
+ end
375
+ end
376
+
377
+ # Integer constraints
378
+ if value.is_a?(Integer)
379
+ @errors << "Field '#{path}' must be at least #{schema[:min]}" if schema[:min] && value < schema[:min]
380
+
381
+ @errors << "Field '#{path}' must be at most #{schema[:max]}" if schema[:max] && value > schema[:max]
382
+ end
383
+
384
+ # Array constraints
385
+ if value.is_a?(Array)
386
+ @errors << "Field '#{path}' must have at least #{schema[:min_length]} items" if schema[:min_length] && value.length < schema[:min_length]
387
+
388
+ @errors << "Field '#{path}' must have at most #{schema[:max_length]} items" if schema[:max_length] && value.length > schema[:max_length]
389
+ end
390
+
391
+ # Allowed values constraint
392
+ return unless schema[:allowed_values] && !schema[:allowed_values].include?(value)
393
+
394
+ @errors << "Field '#{path}' must be one of: #{schema[:allowed_values].join(", ")}"
395
+ end
396
+
397
+ # Validate nested schemas
398
+ # @param value [Object] Value to validate
399
+ # @param schema [Hash] Field schema
400
+ # @param path [String] Field path for error reporting
401
+ def validate_nested_schema(value, schema, path)
402
+ return unless value
403
+
404
+ # Validate array items
405
+ if schema[:item_schema] && value.is_a?(Array)
406
+ value.each_with_index do |item, index|
407
+ item_path = "#{path}[#{index}]"
408
+ validate_against_schema(item, schema[:item_schema], item_path) if item.is_a?(Hash)
409
+ end
410
+ end
411
+
412
+ # Validate array item types
413
+ if schema[:item_type] && value.is_a?(Array)
414
+ value.each_with_index do |item, index|
415
+ item_path = "#{path}[#{index}]"
416
+ validate_field_type(item, { type: schema[:item_type] }, item_path)
417
+ end
418
+ end
419
+
420
+ # Validate hash values
421
+ if schema[:value_schema] && value.is_a?(Hash)
422
+ # Check if this is a structured hash (like settings/rules) or dynamic keys hash (like projects)
423
+ # Structured hashes have their keys defined in value_schema
424
+ # Dynamic hashes apply value_schema to each value
425
+ is_structured = schema[:value_schema].is_a?(Hash) &&
426
+ schema[:value_schema].keys.any? { |k| k.is_a?(String) }
427
+
428
+ if is_structured && (path.include?("settings") || path.include?("rules") || path.include?("default_rules"))
429
+ # For structured hashes like settings/rules, validate the hash itself against value_schema
430
+ validate_against_schema(value, schema[:value_schema], path)
431
+ else
432
+ # For dynamic key hashes like projects, validate each value
433
+ value.each do |key, nested_value|
434
+ nested_path = "#{path}.#{key}"
435
+ validate_against_schema(nested_value, schema[:value_schema], nested_path) if nested_value.is_a?(Hash)
436
+ end
437
+ end
438
+ end
439
+
440
+ # Validate hash value types
441
+ return unless schema[:value_type] && value.is_a?(Hash)
442
+
443
+ value.each do |key, nested_value|
444
+ nested_path = "#{path}.#{key}"
445
+ validate_field_type(nested_value, { type: schema[:value_type] }, nested_path)
446
+ end
447
+ end
448
+
449
+ # Apply default values to configuration
450
+ # @param config [Hash] Configuration to apply defaults to
451
+ # @return [Hash] Configuration with defaults applied
452
+ def apply_defaults(config)
453
+ apply_defaults_recursive(config, SCHEMA, config)
454
+ end
455
+
456
+ # Apply defaults recursively
457
+ # @param config [Hash] Configuration to modify
458
+ # @param schema [Hash] Schema with defaults
459
+ # @param root_config [Hash] Root configuration for reference
460
+ # @return [Hash] Configuration with defaults applied
461
+ def apply_defaults_recursive(config, schema, root_config)
462
+ schema.each do |key, field_schema|
463
+ # Apply default value if field is missing
464
+ if config[key].nil? && field_schema.key?(:default)
465
+ config[key] = begin
466
+ field_schema[:default].dup
467
+ rescue StandardError
468
+ field_schema[:default]
469
+ end
470
+ end
471
+
472
+ # For hash fields with specific structure schema
473
+ if config[key].is_a?(Hash) && field_schema[:value_schema]
474
+ # For direct structured hash (like settings)
475
+ if field_schema[:type] == :hash
476
+ apply_defaults_recursive(config[key], field_schema[:value_schema], root_config)
477
+ else
478
+ # For hash with dynamic keys (like projects)
479
+ config[key].each_value do |nested_value|
480
+ apply_defaults_recursive(nested_value, field_schema[:value_schema], root_config) if nested_value.is_a?(Hash)
481
+ end
482
+ end
483
+ end
484
+
485
+ # Apply defaults to array items
486
+ next unless config[key].is_a?(Array) && field_schema[:item_schema]
487
+
488
+ config[key].each do |item|
489
+ apply_defaults_recursive(item, field_schema[:item_schema], root_config) if item.is_a?(Hash)
490
+ end
491
+ end
492
+
493
+ config
494
+ end
495
+
496
+ # Migrate from version 0 (unversioned) to version 1
497
+ # @param config [Hash] Configuration to migrate
498
+ # @return [Hash] Migrated configuration
499
+ def migrate_v0_to_v1(config)
500
+ migrated = config.dup
501
+
502
+ # Add version field
503
+ migrated["version"] = 1
504
+
505
+ # Ensure required fields have defaults
506
+ migrated["sessions_folder"] ||= ".sessions"
507
+ migrated["projects"] ||= {}
508
+ migrated["settings"] ||= {}
509
+
510
+ # Migrate old setting names if they exist
511
+ migrated["settings"]["auto_cleanup"] = migrated.delete("auto_cleanup") if migrated.key?("auto_cleanup")
512
+
513
+ migrated["settings"]["max_sessions"] = migrated.delete("max_sessions") if migrated.key?("max_sessions")
514
+
515
+ migrated["settings"]["worktree_cleanup_days"] = migrated.delete("worktree_cleanup_days") if migrated.key?("worktree_cleanup_days")
516
+
517
+ # Ensure projects have required fields
518
+ if migrated["projects"].is_a?(Hash)
519
+ migrated["projects"].each do |project_name, project_config|
520
+ next unless project_config.is_a?(Hash)
521
+
522
+ # Ensure path is present
523
+ migrated["projects"][project_name]["path"] = "./#{project_name}" if project_config["path"].nil? || project_config["path"].empty?
524
+
525
+ # Convert old rule formats if needed
526
+ migrate_rules_v0_to_v1(project_config["rules"]) if project_config["rules"]
527
+ end
528
+ end
529
+
530
+ migrated
531
+ end
532
+
533
+ # Migrate rules from version 0 to version 1
534
+ # @param rules [Hash] Rules to migrate
535
+ def migrate_rules_v0_to_v1(rules)
536
+ return unless rules.is_a?(Hash)
537
+
538
+ # Convert old copy_files format
539
+ if rules["copy_files"].is_a?(Array)
540
+ rules["copy_files"].map! do |rule|
541
+ if rule.is_a?(String)
542
+ { "source" => rule, "strategy" => "copy" }
543
+ else
544
+ rule
545
+ end
546
+ end
547
+ end
548
+
549
+ # Convert old setup_commands format
550
+ return unless rules["setup_commands"].is_a?(Array)
551
+
552
+ rules["setup_commands"].map! do |command|
553
+ if command.is_a?(String)
554
+ { "command" => command.split }
555
+ else
556
+ command
557
+ end
558
+ end
559
+ end
560
+ end
561
+ end
562
+ end