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,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "database/errors"
4
+ require_relative "database/session_database"
5
+
6
+ module Sxn
7
+ # Database layer for session storage and management
8
+ #
9
+ # This module provides SQLite-based storage for session metadata,
10
+ # replacing filesystem scanning with O(1) indexed lookups.
11
+ #
12
+ # Features:
13
+ # - High-performance SQLite with optimized indexes
14
+ # - ACID transactions with rollback support
15
+ # - Full-text search capabilities
16
+ # - JSON metadata storage
17
+ # - Connection pooling and concurrent access handling
18
+ # - Automatic schema migrations
19
+ #
20
+ # This module follows Ruby gem best practices by using explicit requires
21
+ # instead of autoload for better loading performance and dependency clarity.
22
+ module Database
23
+ end
24
+ end
data/lib/sxn/errors.rb ADDED
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sxn
4
+ # Base error class for all Sxn-specific errors
5
+ class Error < StandardError
6
+ attr_reader :exit_code
7
+
8
+ def initialize(message = nil, exit_code: 1)
9
+ super(message)
10
+ @exit_code = exit_code
11
+ end
12
+ end
13
+
14
+ # Configuration-related errors
15
+ class ConfigurationError < Error; end
16
+
17
+ # Session management errors
18
+ class SessionError < Error; end
19
+ class SessionNotFoundError < SessionError; end
20
+ class SessionAlreadyExistsError < SessionError; end
21
+ class SessionExistsError < SessionError; end
22
+ class SessionHasChangesError < SessionError; end
23
+ class InvalidSessionNameError < SessionError; end
24
+ class NoActiveSessionError < SessionError; end
25
+
26
+ # Project management errors
27
+ class ProjectError < Error; end
28
+ class ProjectNotFoundError < ProjectError; end
29
+ class ProjectAlreadyExistsError < ProjectError; end
30
+ class ProjectExistsError < ProjectError; end
31
+ class ProjectInUseError < ProjectError; end
32
+ class InvalidProjectNameError < ProjectError; end
33
+ class InvalidProjectPathError < ProjectError; end
34
+
35
+ # Git operation errors
36
+ class GitError < Error; end
37
+ class WorktreeError < GitError; end
38
+ class WorktreeExistsError < WorktreeError; end
39
+ class WorktreeNotFoundError < WorktreeError; end
40
+ class WorktreeCreationError < WorktreeError; end
41
+ class WorktreeRemovalError < WorktreeError; end
42
+ class BranchError < GitError; end
43
+
44
+ # Security-related errors
45
+ class SecurityError < Error; end
46
+ class PathValidationError < SecurityError; end
47
+ class CommandExecutionError < SecurityError; end
48
+
49
+ # Rule execution errors
50
+ class RuleError < Error; end
51
+ class RuleValidationError < RuleError; end
52
+ class RuleExecutionError < RuleError; end
53
+ class RuleNotFoundError < RuleError; end
54
+ class InvalidRuleTypeError < RuleError; end
55
+ class InvalidRuleConfigError < RuleError; end
56
+
57
+ # Generic validation and application errors
58
+ class ValidationError < Error; end
59
+ class ApplicationError < Error; end
60
+ class RollbackError < Error; end
61
+
62
+ # Template processing errors
63
+ class TemplateError < Error; end
64
+ class TemplateNotFoundError < TemplateError; end
65
+ class TemplateProcessingError < TemplateError; end
66
+
67
+ # Database errors
68
+ class DatabaseError < Error; end
69
+ class DatabaseConnectionError < DatabaseError; end
70
+ class DatabaseMigrationError < DatabaseError; end
71
+
72
+ # MCP server errors
73
+ class MCPError < Error; end
74
+ class MCPServerError < MCPError; end
75
+ class MCPValidationError < MCPError; end
76
+ end
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sxn
4
+ module Rules
5
+ # BaseRule is the abstract base class for all rule types in the sxn system.
6
+ # It defines the common interface that all rules must implement and provides
7
+ # shared functionality for validation, dependency management, and error handling.
8
+ #
9
+ # Rules are the building blocks of session setup automation. They can copy files,
10
+ # execute commands, process templates, or perform other project initialization tasks.
11
+ #
12
+ # @example Implementing a custom rule
13
+ # class MyCustomRule < BaseRule
14
+ # def validate
15
+ # raise ValidationError, "Custom validation failed" unless valid?
16
+ # end
17
+ #
18
+ # def apply
19
+ # # Perform the rule's action
20
+ # track_change(:file_created, "/path/to/file")
21
+ # end
22
+ #
23
+ # def rollback
24
+ # # Undo the rule's action
25
+ # File.unlink("/path/to/file") if File.exist?("/path/to/file")
26
+ # end
27
+ # end
28
+ #
29
+ class BaseRule
30
+ # Rule execution states
31
+ module States
32
+ PENDING = :pending
33
+ VALIDATING = :validating
34
+ VALIDATED = :validated
35
+ APPLYING = :applying
36
+ APPLIED = :applied
37
+ ROLLING_BACK = :rolling_back
38
+ ROLLED_BACK = :rolled_back
39
+ FAILED = :failed
40
+ end
41
+
42
+ include States
43
+
44
+ attr_reader :name, :config, :project_path, :session_path, :state, :dependencies, :changes, :errors
45
+
46
+ # Initialize a new rule instance
47
+ #
48
+ # @param name [String] Unique name for this rule instance (old format) or project_path (new format)
49
+ # @param config_or_session_path [Hash|String] Rule configuration (old) or session_path (new)
50
+ # @param project_path [String] Absolute path to the project root (old format)
51
+ # @param session_path [String] Absolute path to the session directory (old format)
52
+ # @param dependencies [Array<String>] Names of rules this rule depends on
53
+ def initialize(arg1 = nil, arg2 = nil, arg3 = nil, arg4 = nil, dependencies: [])
54
+ # Handle both old and new initialization formats
55
+ if (arg1.is_a?(String) || arg1.nil?) && arg2.is_a?(Hash) && arg3.is_a?(String) && arg4.is_a?(String)
56
+ # Old format: (name, config, project_path, session_path, dependencies: [])
57
+ @name = arg1 || "base_rule"
58
+ @config = arg2.dup.freeze
59
+ @project_path = File.realpath(arg3)
60
+ @session_path = File.realpath(arg4)
61
+ elsif arg1.is_a?(Hash) && arg2.is_a?(String) && arg3.is_a?(String)
62
+ # Special format: (config, project_path, session_path, name)
63
+ @name = arg4 || "base_rule"
64
+ @config = arg1.dup.freeze
65
+ @project_path = File.realpath(arg2)
66
+ @session_path = File.realpath(arg3)
67
+ elsif arg1.is_a?(String) && arg2.is_a?(String)
68
+ # New format: (project_path, session_path, config = {}, dependencies: [])
69
+ @name = "base_rule"
70
+ # Store the config as-is for validation, only freeze if it's a Hash
71
+ @config = if arg3.nil?
72
+ {}.freeze
73
+ elsif arg3.is_a?(Hash)
74
+ arg3.dup.freeze
75
+ else
76
+ # Store non-hash config as-is for validation to catch
77
+ arg3
78
+ end
79
+ @project_path = File.realpath(arg1)
80
+ @session_path = File.realpath(arg2)
81
+ else
82
+ raise ArgumentError,
83
+ "Invalid arguments. Expected (name, config, project_path, session_path) or (project_path, session_path, config={})"
84
+ end
85
+
86
+ @dependencies = dependencies.freeze
87
+ @state = PENDING
88
+ @changes = []
89
+ @errors = []
90
+ @start_time = nil
91
+ @end_time = nil
92
+
93
+ validate_paths!
94
+ rescue Errno::ENOENT => e
95
+ raise ArgumentError, "Invalid path provided: #{e.message}"
96
+ end
97
+
98
+ # Validate the rule configuration and dependencies
99
+ # This method should be overridden by subclasses to implement specific validation logic
100
+ #
101
+ # @return [Boolean] true if validation passes
102
+ # @raise [ValidationError] if validation fails
103
+ def validate
104
+ change_state!(VALIDATING)
105
+
106
+ begin
107
+ validate_config!
108
+ validate_dependencies!
109
+ validate_rule_specific!
110
+
111
+ change_state!(VALIDATED)
112
+ true
113
+ rescue StandardError => e
114
+ @errors << e
115
+ change_state!(FAILED)
116
+ raise
117
+ end
118
+ end
119
+
120
+ # Apply the rule's action
121
+ # This method must be overridden by subclasses to implement the actual rule logic
122
+ #
123
+ # @param context [Hash] Optional execution context
124
+ # @return [Boolean] true if application succeeds
125
+ # @raise [ApplicationError] if application fails
126
+ def apply(context = {})
127
+ raise NotImplementedError, "#{self.class} must implement #apply"
128
+ end
129
+
130
+ # Rollback the rule's changes
131
+ # This method should be overridden by subclasses to implement rollback logic
132
+ #
133
+ # @return [Boolean] true if rollback succeeds
134
+ # @raise [RollbackError] if rollback fails
135
+ def rollback
136
+ return true if @state == PENDING || @state == FAILED
137
+
138
+ change_state!(ROLLING_BACK)
139
+
140
+ begin
141
+ rollback_changes!
142
+ change_state!(ROLLED_BACK)
143
+ true
144
+ rescue StandardError => e
145
+ @errors << e
146
+ change_state!(FAILED)
147
+ raise Sxn::Rules::RollbackError, "Failed to rollback rule #{@name}: #{e.message}"
148
+ end
149
+ end
150
+
151
+ # Check if this rule can be executed (all dependencies are satisfied)
152
+ #
153
+ # @param completed_rules [Array<String>] List of rule names that have been completed
154
+ # @return [Boolean] true if all dependencies are satisfied
155
+ def can_execute?(completed_rules)
156
+ @dependencies.all? { |dep| completed_rules.include?(dep) }
157
+ end
158
+
159
+ # Get rule execution duration in seconds
160
+ #
161
+ # @return [Float, nil] Execution duration or nil if not completed
162
+ def duration
163
+ return nil unless @start_time && @end_time
164
+
165
+ @end_time - @start_time
166
+ end
167
+
168
+ # Get rule type
169
+ #
170
+ # @return [String] Rule type based on class name
171
+ def type
172
+ self.class.name.split("::").last.downcase.gsub(/rule$/, "")
173
+ end
174
+
175
+ # Check if rule is required
176
+ #
177
+ # @return [Boolean] true if rule is required
178
+ def required?
179
+ true
180
+ end
181
+
182
+ # Validate rule configuration (public method expected by tests)
183
+ #
184
+ # @param config [Hash] Configuration to validate
185
+ # @return [Boolean] true if valid
186
+ def validate_config_hash(config = @config)
187
+ return true if config.nil? || config.empty?
188
+
189
+ config.is_a?(Hash)
190
+ end
191
+
192
+ # Get rule description
193
+ #
194
+ # @return [String] Description of the rule
195
+ def description
196
+ "Base rule for #{type} operations"
197
+ end
198
+
199
+ # Check if rule has been successfully applied
200
+ #
201
+ # @return [Boolean] true if rule is in applied state
202
+ def applied?
203
+ @state == APPLIED
204
+ end
205
+
206
+ # Check if rule has failed
207
+ #
208
+ # @return [Boolean] true if rule is in failed state
209
+ def failed?
210
+ @state == FAILED
211
+ end
212
+
213
+ # Check if rule can be rolled back
214
+ #
215
+ # @return [Boolean] true if rule can be rolled back
216
+ def rollbackable?
217
+ @state == APPLIED && @changes.any?
218
+ end
219
+
220
+ # Get a hash representation of the rule for serialization
221
+ #
222
+ # @return [Hash] Rule data
223
+ def to_h
224
+ {
225
+ name: @name,
226
+ type: self.class.name.split("::").last,
227
+ state: @state,
228
+ config: @config,
229
+ dependencies: @dependencies,
230
+ changes: @changes.map(&:to_h),
231
+ errors: @errors.map(&:message),
232
+ duration: duration,
233
+ applied_at: @end_time&.iso8601
234
+ }
235
+ end
236
+
237
+ protected
238
+
239
+ # Track a change made by this rule for rollback purposes
240
+ #
241
+ # @param type [Symbol] Type of change (:file_created, :file_modified, :directory_created, etc.)
242
+ # @param target [String] Path or identifier of what was changed
243
+ # @param metadata [Hash] Additional metadata about the change
244
+ def track_change(type, target, metadata = {})
245
+ change = RuleChange.new(type, target, metadata)
246
+ @changes << change
247
+ change
248
+ end
249
+
250
+ # Get the logger instance
251
+ #
252
+ # @return [Logger] Logger for this rule
253
+ def logger
254
+ @logger ||= Sxn.logger
255
+ end
256
+
257
+ # Log a message with rule context
258
+ #
259
+ # @param level [Symbol] Log level (:debug, :info, :warn, :error)
260
+ # @param message [String] Message to log
261
+ # @param metadata [Hash] Additional metadata
262
+ def log(level, message, metadata = {})
263
+ logger.send(level, "[Rule:#{@name}] #{message}") do
264
+ metadata.merge(rule_name: @name, rule_type: self.class.name)
265
+ end
266
+ end
267
+
268
+ private
269
+
270
+ # Change the rule state and track timing
271
+ def change_state!(new_state)
272
+ old_state = @state
273
+ @state = new_state
274
+
275
+ case new_state
276
+ when APPLYING
277
+ @start_time = Time.now
278
+ when APPLIED, FAILED, ROLLED_BACK
279
+ @end_time = Time.now
280
+ end
281
+
282
+ log(:debug, "State changed from #{old_state} to #{new_state}")
283
+ end
284
+
285
+ # Validate that required paths exist and are accessible
286
+ def validate_paths!
287
+ raise ArgumentError, "Project path is not a directory: #{@project_path}" unless File.directory?(@project_path)
288
+
289
+ raise ArgumentError, "Session path is not a directory: #{@session_path}" unless File.directory?(@session_path)
290
+
291
+ # Ensure session path is writable
292
+ return if File.writable?(@session_path)
293
+
294
+ raise ArgumentError, "Session path is not writable: #{@session_path}"
295
+ end
296
+
297
+ # Validate basic rule configuration
298
+ def validate_config!
299
+ raise ValidationError, "Config must be a Hash" unless @config.is_a?(Hash)
300
+
301
+ # Subclasses should override this method for specific validation
302
+ validate_rule_specific!
303
+ end
304
+
305
+ # Validate rule dependencies
306
+ def validate_dependencies!
307
+ @dependencies.each do |dep|
308
+ raise ValidationError, "Invalid dependency: #{dep.inspect}" unless dep.is_a?(String) && !dep.empty?
309
+ end
310
+ end
311
+
312
+ # Validate rule-specific configuration
313
+ # Override this method in subclasses
314
+ def validate_rule_specific!
315
+ # Default implementation does nothing
316
+ true
317
+ end
318
+
319
+ # Rollback all tracked changes in reverse order
320
+ def rollback_changes!
321
+ @changes.reverse_each(&:rollback)
322
+ @changes.clear
323
+ true
324
+ end
325
+
326
+ # Represents a single change made by a rule
327
+ class RuleChange
328
+ attr_reader :type, :target, :metadata, :timestamp
329
+
330
+ def initialize(type, target, metadata = {})
331
+ @type = type
332
+ @target = target
333
+ @metadata = metadata.freeze
334
+ @timestamp = Time.now
335
+ end
336
+
337
+ # Rollback this specific change
338
+ def rollback
339
+ case @type
340
+ when :file_created
341
+ FileUtils.rm_f(@target)
342
+ when :file_modified
343
+ FileUtils.mv(@metadata[:backup_path], @target) if @metadata[:backup_path] && File.exist?(@metadata[:backup_path])
344
+ when :directory_created
345
+ Dir.rmdir(@target) if File.directory?(@target) && Dir.empty?(@target)
346
+ when :symlink_created
347
+ File.unlink(@target) if File.symlink?(@target)
348
+ when :command_executed
349
+ # Command execution cannot be rolled back
350
+ # This is logged for audit purposes only
351
+ else
352
+ raise Sxn::Rules::RollbackError, "Unknown change type for rollback: #{@type}"
353
+ end
354
+ end
355
+
356
+ def to_h
357
+ {
358
+ type: @type,
359
+ target: @target,
360
+ metadata: @metadata,
361
+ timestamp: @timestamp.iso8601
362
+ }
363
+ end
364
+ end
365
+ end
366
+ end
367
+ end