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,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_rule"
4
+ require_relative "errors"
5
+ require_relative "../security/secure_command_executor"
6
+
7
+ module Sxn
8
+ module Rules
9
+ # SetupCommandsRule executes project setup commands safely using the SecureCommandExecutor.
10
+ # It supports environment variables, conditional execution, and proper error handling.
11
+ #
12
+ # Configuration format:
13
+ # {
14
+ # "commands" => [
15
+ # {
16
+ # "command" => ["bundle", "install"],
17
+ # "env" => { "RAILS_ENV" => "development" },
18
+ # "timeout" => 120,
19
+ # "condition" => "file_missing:Gemfile.lock",
20
+ # "description" => "Install Ruby dependencies",
21
+ # "required" => true,
22
+ # "working_directory" => "."
23
+ # }
24
+ # ],
25
+ # "continue_on_failure" => false
26
+ # }
27
+ #
28
+ # @example Basic usage
29
+ # rule = SetupCommandsRule.new(
30
+ # "rails_setup",
31
+ # {
32
+ # "commands" => [
33
+ # { "command" => ["bundle", "install"] },
34
+ # { "command" => ["bin/rails", "db:create"] },
35
+ # { "command" => ["bin/rails", "db:migrate"] }
36
+ # ]
37
+ # },
38
+ # "/path/to/project",
39
+ # "/path/to/session"
40
+ # )
41
+ # rule.validate
42
+ # rule.apply
43
+ #
44
+ class SetupCommandsRule < BaseRule
45
+ # Supported condition types for conditional execution
46
+ CONDITION_TYPES = {
47
+ "file_exists" => :file_exists?,
48
+ "file_missing" => :file_missing?,
49
+ "directory_exists" => :directory_exists?,
50
+ "directory_missing" => :directory_missing?,
51
+ "command_available" => :command_available?,
52
+ "env_var_set" => :env_var_set?,
53
+ "always" => :always_true
54
+ }.freeze
55
+
56
+ # Default command timeout in seconds
57
+ DEFAULT_TIMEOUT = 60
58
+
59
+ # Maximum allowed timeout in seconds
60
+ MAX_TIMEOUT = 1800 # 30 minutes
61
+
62
+ # Initialize the setup commands rule
63
+ def initialize(arg1 = nil, arg2 = nil, arg3 = nil, arg4 = nil, dependencies: [])
64
+ super
65
+ @command_executor = Security::SecureCommandExecutor.new(@session_path, logger: logger)
66
+ @executed_commands = []
67
+ end
68
+
69
+ # Validate the rule configuration
70
+
71
+ # Apply the command execution operations
72
+ def apply(_context = {})
73
+ change_state!(APPLYING)
74
+ continue_on_failure = @config.fetch("continue_on_failure", false)
75
+
76
+ begin
77
+ @config["commands"].each_with_index do |command_config, index|
78
+ apply_command(command_config, index, continue_on_failure)
79
+ end
80
+
81
+ change_state!(APPLIED)
82
+ log(:info, "Successfully executed #{@executed_commands.size} commands")
83
+ true
84
+ rescue StandardError => e
85
+ @errors << e
86
+ change_state!(FAILED)
87
+ raise ApplicationError, "Failed to execute setup commands: #{e.message}"
88
+ end
89
+ end
90
+
91
+ # Get summary of executed commands
92
+ def execution_summary
93
+ @executed_commands.map do |cmd_result|
94
+ {
95
+ command: cmd_result[:command],
96
+ success: cmd_result[:result].success?,
97
+ duration: cmd_result[:result].duration,
98
+ exit_status: cmd_result[:result].exit_status
99
+ }
100
+ end
101
+ end
102
+
103
+ protected
104
+
105
+ # Validate rule-specific configuration
106
+ def validate_rule_specific!
107
+ raise ValidationError, "SetupCommandsRule requires 'commands' configuration" unless @config.key?("commands")
108
+
109
+ raise ValidationError, "SetupCommandsRule 'commands' must be an array" unless @config["commands"].is_a?(Array)
110
+
111
+ raise ValidationError, "SetupCommandsRule 'commands' cannot be empty" if @config["commands"].empty?
112
+
113
+ @config["commands"].each_with_index do |command_config, index|
114
+ validate_command_config!(command_config, index)
115
+ end
116
+
117
+ # Validate global options
118
+ return unless @config.key?("continue_on_failure")
119
+ return if [true, false].include?(@config["continue_on_failure"])
120
+
121
+ raise ValidationError, "continue_on_failure must be true or false"
122
+ end
123
+
124
+ private
125
+
126
+ # Validate individual command configuration
127
+ def validate_command_config!(command_config, index)
128
+ raise ValidationError, "Command config #{index} must be a hash" unless command_config.is_a?(Hash)
129
+
130
+ raise ValidationError, "Command config #{index} must have a 'command' field" unless command_config.key?("command")
131
+
132
+ command = command_config["command"]
133
+ raise ValidationError, "Command config #{index} 'command' must be a non-empty array" unless command.is_a?(Array) && !command.empty?
134
+
135
+ # Validate that command is whitelisted
136
+ raise ValidationError, "Command config #{index}: command not whitelisted: #{command.first}" unless @command_executor.command_allowed?(command)
137
+
138
+ # Validate timeout
139
+ if command_config.key?("timeout")
140
+ timeout = command_config["timeout"]
141
+ unless timeout.is_a?(Integer) && timeout.positive? && timeout <= MAX_TIMEOUT
142
+ raise ValidationError, "Command config #{index}: timeout must be positive integer <= #{MAX_TIMEOUT}"
143
+ end
144
+ end
145
+
146
+ # Validate environment variables
147
+ if command_config.key?("env")
148
+ env = command_config["env"]
149
+ raise ValidationError, "Command config #{index}: env must be a hash" unless env.is_a?(Hash)
150
+
151
+ env.each do |key, value|
152
+ raise ValidationError, "Command config #{index}: env keys and values must be strings" unless key.is_a?(String) && value.is_a?(String)
153
+ end
154
+ end
155
+
156
+ # Validate condition
157
+ if command_config.key?("condition")
158
+ condition = command_config["condition"]
159
+ raise ValidationError, "Command config #{index}: invalid condition format: #{condition}" unless valid_condition?(condition)
160
+ end
161
+
162
+ # Validate working directory
163
+ return unless command_config.key?("working_directory")
164
+
165
+ working_dir = command_config["working_directory"]
166
+ raise ValidationError, "Command config #{index}: working_directory must be a string" unless working_dir.is_a?(String)
167
+
168
+ full_path = File.expand_path(working_dir, @session_path)
169
+ return if full_path.start_with?(@session_path)
170
+
171
+ raise ValidationError, "Command config #{index}: working_directory must be within session path"
172
+ end
173
+
174
+ # Check if condition format is valid
175
+ def valid_condition?(condition)
176
+ return true if condition.nil? || condition == "always"
177
+
178
+ condition.is_a?(String) && condition.include?(":") &&
179
+ CONDITION_TYPES.key?(condition.split(":", 2).first)
180
+ end
181
+
182
+ # Apply a single command operation
183
+ def apply_command(command_config, index, continue_on_failure)
184
+ command = command_config["command"]
185
+ description = command_config.fetch("description", command.join(" "))
186
+
187
+ log(:debug, "Evaluating command #{index}: #{description}")
188
+
189
+ # Check condition
190
+ unless should_execute_command?(command_config)
191
+ log(:info, "Skipping command due to condition: #{description}")
192
+ return
193
+ end
194
+
195
+ log(:info, "Executing command: #{description}")
196
+
197
+ begin
198
+ result = execute_command_safely(command_config)
199
+
200
+ @executed_commands << {
201
+ index: index,
202
+ command: command,
203
+ description: description,
204
+ result: result
205
+ }
206
+
207
+ track_change(:command_executed, command.join(" "), {
208
+ working_directory: determine_working_directory(command_config),
209
+ env: command_config.fetch("env", {}),
210
+ exit_status: result.exit_status,
211
+ duration: result.duration
212
+ })
213
+
214
+ if result.failure?
215
+ error_msg = "Command failed: #{description} (exit status: #{result.exit_status})"
216
+
217
+ error_msg += "\nSTDERR: #{result.stderr}" if result.stderr && !result.stderr.empty?
218
+
219
+ raise ApplicationError, error_msg unless continue_on_failure
220
+
221
+ log(:warn, error_msg)
222
+
223
+ else
224
+ log(:debug, "Command completed successfully", {
225
+ exit_status: result.exit_status,
226
+ duration: result.duration
227
+ })
228
+ end
229
+ rescue StandardError => e
230
+ error_msg = "Failed to execute command: #{description} - #{e.message}"
231
+
232
+ raise ApplicationError, error_msg unless continue_on_failure
233
+
234
+ log(:error, error_msg)
235
+ end
236
+ end
237
+
238
+ # Execute a command with the security layer
239
+ def execute_command_safely(command_config)
240
+ command = command_config["command"]
241
+ env = command_config.fetch("env", {})
242
+ timeout = command_config.fetch("timeout", DEFAULT_TIMEOUT)
243
+ working_dir = determine_working_directory(command_config)
244
+
245
+ @command_executor.execute(
246
+ command,
247
+ env: env,
248
+ timeout: timeout,
249
+ chdir: working_dir
250
+ )
251
+ end
252
+
253
+ # Determine the working directory for command execution
254
+ def determine_working_directory(command_config)
255
+ if command_config.key?("working_directory")
256
+ File.expand_path(command_config["working_directory"], @session_path)
257
+ else
258
+ @session_path
259
+ end
260
+ end
261
+
262
+ # Check if command should be executed based on its condition
263
+ def should_execute_command?(command_config)
264
+ condition = command_config["condition"]
265
+ return true if condition.nil? || condition == "always"
266
+
267
+ condition_type, condition_arg = condition.split(":", 2)
268
+ method_name = CONDITION_TYPES[condition_type]
269
+
270
+ return true unless method_name
271
+
272
+ send(method_name, condition_arg)
273
+ end
274
+
275
+ # Condition evaluation methods
276
+ def file_exists?(path)
277
+ full_path = File.expand_path(path, @session_path)
278
+ File.exist?(full_path)
279
+ end
280
+
281
+ def file_missing?(path)
282
+ !file_exists?(path)
283
+ end
284
+
285
+ def directory_exists?(path)
286
+ full_path = File.expand_path(path, @session_path)
287
+ File.directory?(full_path)
288
+ end
289
+
290
+ def directory_missing?(path)
291
+ !directory_exists?(path)
292
+ end
293
+
294
+ def command_available?(command_name)
295
+ @command_executor.command_allowed?([command_name])
296
+ end
297
+
298
+ def env_var_set?(var_name)
299
+ ENV.key?(var_name) && !ENV[var_name].to_s.empty?
300
+ end
301
+
302
+ def always_true(_arg = nil)
303
+ true
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_rule"
4
+ require_relative "errors"
5
+ require_relative "../templates/template_processor"
6
+
7
+ module Sxn
8
+ module Rules
9
+ # TemplateRule processes and applies template files using the secure template processor.
10
+ # It supports variable substitution, multiple template engines, and safe file generation.
11
+ #
12
+ # Configuration format:
13
+ # {
14
+ # "templates" => [
15
+ # {
16
+ # "source" => ".sxn/templates/session-info.md.liquid",
17
+ # "destination" => "README.md",
18
+ # "variables" => { "custom_var" => "value" },
19
+ # "engine" => "liquid",
20
+ # "required" => true,
21
+ # "overwrite" => false
22
+ # }
23
+ # ]
24
+ # }
25
+ #
26
+ # @example Basic usage
27
+ # rule = TemplateRule.new(
28
+ # "generate_docs",
29
+ # {
30
+ # "templates" => [
31
+ # {
32
+ # "source" => ".sxn/templates/CLAUDE.md.liquid",
33
+ # "destination" => "CLAUDE.md"
34
+ # }
35
+ # ]
36
+ # },
37
+ # "/path/to/project",
38
+ # "/path/to/session"
39
+ # )
40
+ # rule.validate
41
+ # rule.apply
42
+ #
43
+ class TemplateRule < BaseRule
44
+ # Supported template engines
45
+ SUPPORTED_ENGINES = %w[liquid].freeze
46
+
47
+ # Initialize the template rule
48
+ def initialize(arg1 = nil, arg2 = nil, arg3 = nil, arg4 = nil, dependencies: [])
49
+ super
50
+ @template_processor = Templates::TemplateProcessor.new
51
+ @template_variables = Templates::TemplateVariables.new
52
+ end
53
+
54
+ # Validate the rule configuration
55
+
56
+ # Apply the template processing operations
57
+ def apply
58
+ change_state!(APPLYING)
59
+
60
+ begin
61
+ @config["templates"].each_with_index do |template_config, index|
62
+ apply_template(template_config, index)
63
+ end
64
+
65
+ change_state!(APPLIED)
66
+ log(:info, "Successfully processed #{@config["templates"].size} templates")
67
+ true
68
+ rescue StandardError => e
69
+ @errors << e
70
+ change_state!(FAILED)
71
+ raise ApplicationError, "Failed to process templates: #{e.message}"
72
+ end
73
+ end
74
+
75
+ protected
76
+
77
+ # Validate rule-specific configuration
78
+ def validate_rule_specific!
79
+ raise ValidationError, "TemplateRule requires 'templates' configuration" unless @config.key?("templates")
80
+
81
+ raise ValidationError, "TemplateRule 'templates' must be an array" unless @config["templates"].is_a?(Array)
82
+
83
+ raise ValidationError, "TemplateRule 'templates' cannot be empty" if @config["templates"].empty?
84
+
85
+ @config["templates"].each_with_index do |template_config, index|
86
+ validate_template_config!(template_config, index)
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ # Validate individual template configuration
93
+ def validate_template_config!(template_config, index)
94
+ raise ValidationError, "Template config #{index} must be a hash" unless template_config.is_a?(Hash)
95
+
96
+ unless template_config.key?("source") && template_config["source"].is_a?(String)
97
+ raise ValidationError, "Template config #{index} must have a 'source' string"
98
+ end
99
+
100
+ unless template_config.key?("destination") && template_config["destination"].is_a?(String)
101
+ raise ValidationError, "Template config #{index} must have a 'destination' string"
102
+ end
103
+
104
+ # Validate engine
105
+ if template_config.key?("engine")
106
+ engine = template_config["engine"]
107
+ unless SUPPORTED_ENGINES.include?(engine)
108
+ raise ValidationError,
109
+ "Template config #{index} has unsupported engine '#{engine}'. Supported: #{SUPPORTED_ENGINES.join(", ")}"
110
+ end
111
+ end
112
+
113
+ # Validate variables
114
+ if template_config.key?("variables")
115
+ variables = template_config["variables"]
116
+ raise ValidationError, "Template config #{index} 'variables' must be a hash" unless variables.is_a?(Hash)
117
+ end
118
+
119
+ # Validate source template exists if required
120
+ source_path = File.join(@project_path, template_config["source"])
121
+ required = template_config.fetch("required", true)
122
+
123
+ raise ValidationError, "Required template file does not exist: #{template_config["source"]}" if required && !File.exist?(source_path)
124
+
125
+ # Validate destination path is safe
126
+ destination = template_config["destination"]
127
+ return unless destination.include?("..") || destination.start_with?("/")
128
+
129
+ raise ValidationError, "Template config #{index} destination path is not safe: #{destination}"
130
+ end
131
+
132
+ # Apply a single template operation
133
+ def apply_template(template_config, _index)
134
+ source = template_config["source"]
135
+ destination = template_config["destination"]
136
+ required = template_config.fetch("required", true)
137
+ overwrite = template_config.fetch("overwrite", false)
138
+
139
+ source_path = File.join(@project_path, source)
140
+ destination_path = File.join(@session_path, destination)
141
+
142
+ # Skip if source doesn't exist and is not required
143
+ unless File.exist?(source_path)
144
+ raise ApplicationError, "Required template file does not exist: #{source}" if required
145
+
146
+ log(:debug, "Skipping optional missing template: #{source}")
147
+ return
148
+
149
+ end
150
+
151
+ # Check if destination already exists
152
+ if File.exist?(destination_path) && !overwrite
153
+ log(:warn, "Destination file already exists, skipping: #{destination}")
154
+ return
155
+ end
156
+
157
+ log(:debug, "Processing template: #{source} -> #{destination}")
158
+
159
+ begin
160
+ # Prepare template variables
161
+ variables = build_template_variables(template_config)
162
+
163
+ # Validate template syntax first
164
+ template_content = File.read(source_path)
165
+ @template_processor.validate_syntax(template_content)
166
+
167
+ # Process the template
168
+ processed_content = @template_processor.process(template_content, variables)
169
+
170
+ # Create destination directory if needed
171
+ destination_dir = File.dirname(destination_path)
172
+ FileUtils.mkdir_p(destination_dir) unless File.directory?(destination_dir)
173
+
174
+ # Create backup if file exists and we're overwriting
175
+ backup_path = nil
176
+ if File.exist?(destination_path) && overwrite
177
+ backup_path = "#{destination_path}.backup.#{Time.now.to_i}"
178
+ FileUtils.cp(destination_path, backup_path)
179
+ end
180
+
181
+ # Write processed content
182
+ File.write(destination_path, processed_content)
183
+
184
+ # Set appropriate permissions
185
+ File.chmod(0o644, destination_path)
186
+
187
+ track_change(:file_created, destination_path, {
188
+ source: source_path,
189
+ template: true,
190
+ backup_path: backup_path,
191
+ variables_used: extract_used_variables(template_content)
192
+ })
193
+
194
+ log(:debug, "Template processed successfully", {
195
+ source: source,
196
+ destination: destination,
197
+ size: processed_content.bytesize
198
+ })
199
+ rescue Templates::Errors::TemplateSyntaxError => e
200
+ raise ApplicationError, "Template syntax error in #{source}: #{e.message}"
201
+ rescue Templates::Errors::TemplateProcessingError => e
202
+ raise ApplicationError, "Template processing error for #{source}: #{e.message}"
203
+ rescue StandardError => e
204
+ raise ApplicationError, "Failed to process template #{source}: #{e.message}"
205
+ end
206
+ end
207
+
208
+ # Build variables hash for template processing
209
+ def build_template_variables(template_config)
210
+ # Start with system-generated variables
211
+ variables = @template_variables.build_variables
212
+
213
+ # Add any custom variables from configuration
214
+ if template_config.key?("variables")
215
+ custom_vars = template_config["variables"]
216
+ variables = deep_merge(variables, custom_vars)
217
+ end
218
+
219
+ # Add template-specific metadata
220
+ variables[:template] = {
221
+ source: template_config["source"],
222
+ destination: template_config["destination"],
223
+ processed_at: Time.now.iso8601
224
+ }
225
+
226
+ variables
227
+ end
228
+
229
+ # Deep merge two hashes, with the second hash taking precedence
230
+ # Handles mixed symbol/string keys by preserving both when merging
231
+ def deep_merge(hash1, hash2)
232
+ result = hash1.dup
233
+
234
+ hash2.each do |key, value|
235
+ # Check if there's an existing key with the same string representation
236
+ existing_key = result.keys.find { |k| k.to_s == key.to_s }
237
+
238
+ if existing_key && result[existing_key].is_a?(Hash) && value.is_a?(Hash)
239
+ # Merge the hashes and set the new key (preserving the incoming key type)
240
+ merged_value = deep_merge(result[existing_key], value)
241
+ result.delete(existing_key) if existing_key != key # Remove old key if different
242
+ result[key] = merged_value
243
+ else
244
+ # For non-hash values or new keys, just set the value
245
+ result.delete(existing_key) if existing_key && existing_key != key
246
+ result[key] = value
247
+ end
248
+ end
249
+
250
+ result
251
+ end
252
+
253
+ # Extract variable names used in a template
254
+ def extract_used_variables(template_content)
255
+ @template_processor.extract_variables(template_content)
256
+ rescue StandardError => e
257
+ log(:warn, "Could not extract variables from template: #{e.message}")
258
+ []
259
+ end
260
+ end
261
+ end
262
+ end