factorix 0.5.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 (202) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +105 -0
  5. data/completion/_factorix.bash +202 -0
  6. data/completion/_factorix.fish +197 -0
  7. data/completion/_factorix.zsh +376 -0
  8. data/doc/factorix.1 +377 -0
  9. data/exe/factorix +20 -0
  10. data/lib/factorix/api/category.rb +69 -0
  11. data/lib/factorix/api/image.rb +35 -0
  12. data/lib/factorix/api/license.rb +71 -0
  13. data/lib/factorix/api/mod_download_api.rb +66 -0
  14. data/lib/factorix/api/mod_info.rb +166 -0
  15. data/lib/factorix/api/mod_management_api.rb +237 -0
  16. data/lib/factorix/api/mod_portal_api.rb +204 -0
  17. data/lib/factorix/api/release.rb +49 -0
  18. data/lib/factorix/api/tag.rb +95 -0
  19. data/lib/factorix/api.rb +7 -0
  20. data/lib/factorix/api_credential.rb +54 -0
  21. data/lib/factorix/application.rb +218 -0
  22. data/lib/factorix/cache/file_system.rb +307 -0
  23. data/lib/factorix/cli/commands/backup_support.rb +46 -0
  24. data/lib/factorix/cli/commands/base.rb +90 -0
  25. data/lib/factorix/cli/commands/cache/evict.rb +180 -0
  26. data/lib/factorix/cli/commands/cache/stat.rb +201 -0
  27. data/lib/factorix/cli/commands/command_wrapper.rb +71 -0
  28. data/lib/factorix/cli/commands/completion.rb +83 -0
  29. data/lib/factorix/cli/commands/confirmable.rb +53 -0
  30. data/lib/factorix/cli/commands/download_support.rb +123 -0
  31. data/lib/factorix/cli/commands/launch.rb +79 -0
  32. data/lib/factorix/cli/commands/man.rb +29 -0
  33. data/lib/factorix/cli/commands/mod/check.rb +99 -0
  34. data/lib/factorix/cli/commands/mod/disable.rb +188 -0
  35. data/lib/factorix/cli/commands/mod/download.rb +291 -0
  36. data/lib/factorix/cli/commands/mod/edit.rb +114 -0
  37. data/lib/factorix/cli/commands/mod/enable.rb +216 -0
  38. data/lib/factorix/cli/commands/mod/image/add.rb +47 -0
  39. data/lib/factorix/cli/commands/mod/image/edit.rb +41 -0
  40. data/lib/factorix/cli/commands/mod/image/list.rb +74 -0
  41. data/lib/factorix/cli/commands/mod/install.rb +443 -0
  42. data/lib/factorix/cli/commands/mod/list.rb +372 -0
  43. data/lib/factorix/cli/commands/mod/search.rb +134 -0
  44. data/lib/factorix/cli/commands/mod/settings/dump.rb +88 -0
  45. data/lib/factorix/cli/commands/mod/settings/restore.rb +101 -0
  46. data/lib/factorix/cli/commands/mod/show.rb +202 -0
  47. data/lib/factorix/cli/commands/mod/sync.rb +299 -0
  48. data/lib/factorix/cli/commands/mod/uninstall.rb +325 -0
  49. data/lib/factorix/cli/commands/mod/update.rb +222 -0
  50. data/lib/factorix/cli/commands/mod/upload.rb +90 -0
  51. data/lib/factorix/cli/commands/path.rb +79 -0
  52. data/lib/factorix/cli/commands/requires_game_stopped.rb +32 -0
  53. data/lib/factorix/cli/commands/version.rb +25 -0
  54. data/lib/factorix/cli.rb +42 -0
  55. data/lib/factorix/dependency/edge.rb +89 -0
  56. data/lib/factorix/dependency/entry.rb +124 -0
  57. data/lib/factorix/dependency/graph/builder.rb +108 -0
  58. data/lib/factorix/dependency/graph.rb +210 -0
  59. data/lib/factorix/dependency/list.rb +244 -0
  60. data/lib/factorix/dependency/mod_version_requirement.rb +73 -0
  61. data/lib/factorix/dependency/node.rb +60 -0
  62. data/lib/factorix/dependency/parser.rb +148 -0
  63. data/lib/factorix/dependency/validation_result.rb +138 -0
  64. data/lib/factorix/dependency/validator.rb +190 -0
  65. data/lib/factorix/errors.rb +112 -0
  66. data/lib/factorix/formatting.rb +56 -0
  67. data/lib/factorix/game_version.rb +98 -0
  68. data/lib/factorix/http/cache_decorator.rb +106 -0
  69. data/lib/factorix/http/cached_response.rb +37 -0
  70. data/lib/factorix/http/client.rb +187 -0
  71. data/lib/factorix/http/response.rb +31 -0
  72. data/lib/factorix/http/retry_decorator.rb +59 -0
  73. data/lib/factorix/http/retry_strategy.rb +80 -0
  74. data/lib/factorix/info_json.rb +90 -0
  75. data/lib/factorix/installed_mod.rb +239 -0
  76. data/lib/factorix/mod.rb +55 -0
  77. data/lib/factorix/mod_list.rb +174 -0
  78. data/lib/factorix/mod_settings.rb +278 -0
  79. data/lib/factorix/mod_state.rb +34 -0
  80. data/lib/factorix/mod_version.rb +99 -0
  81. data/lib/factorix/portal.rb +185 -0
  82. data/lib/factorix/progress/download_handler.rb +46 -0
  83. data/lib/factorix/progress/multi_presenter.rb +45 -0
  84. data/lib/factorix/progress/presenter.rb +67 -0
  85. data/lib/factorix/progress/presenter_adapter.rb +46 -0
  86. data/lib/factorix/progress/scan_handler.rb +33 -0
  87. data/lib/factorix/progress/upload_handler.rb +33 -0
  88. data/lib/factorix/runtime/base.rb +233 -0
  89. data/lib/factorix/runtime/linux.rb +32 -0
  90. data/lib/factorix/runtime/mac_os.rb +53 -0
  91. data/lib/factorix/runtime/user_configurable.rb +69 -0
  92. data/lib/factorix/runtime/windows.rb +85 -0
  93. data/lib/factorix/runtime/wsl.rb +118 -0
  94. data/lib/factorix/runtime.rb +32 -0
  95. data/lib/factorix/save_file.rb +178 -0
  96. data/lib/factorix/ser_des/deserializer.rb +198 -0
  97. data/lib/factorix/ser_des/serializer.rb +231 -0
  98. data/lib/factorix/ser_des/signed_integer.rb +63 -0
  99. data/lib/factorix/ser_des/unsigned_integer.rb +65 -0
  100. data/lib/factorix/service_credential.rb +127 -0
  101. data/lib/factorix/transfer/downloader.rb +162 -0
  102. data/lib/factorix/transfer/uploader.rb +232 -0
  103. data/lib/factorix/version.rb +6 -0
  104. data/lib/factorix.rb +38 -0
  105. data/sig/dry/auto_inject.rbs +15 -0
  106. data/sig/dry/cli.rbs +19 -0
  107. data/sig/dry/configurable.rbs +13 -0
  108. data/sig/dry/core/container.rbs +17 -0
  109. data/sig/dry/events/publisher.rbs +22 -0
  110. data/sig/dry/logger.rbs +16 -0
  111. data/sig/factorix/api/category.rbs +15 -0
  112. data/sig/factorix/api/image.rbs +15 -0
  113. data/sig/factorix/api/license.rbs +20 -0
  114. data/sig/factorix/api/mod_download_api.rbs +18 -0
  115. data/sig/factorix/api/mod_info.rbs +67 -0
  116. data/sig/factorix/api/mod_management_api.rbs +25 -0
  117. data/sig/factorix/api/mod_portal_api.rbs +31 -0
  118. data/sig/factorix/api/release.rbs +27 -0
  119. data/sig/factorix/api/tag.rbs +15 -0
  120. data/sig/factorix/api.rbs +8 -0
  121. data/sig/factorix/api_credential.rbs +17 -0
  122. data/sig/factorix/application.rbs +86 -0
  123. data/sig/factorix/cache/file_system.rbs +35 -0
  124. data/sig/factorix/cli/commands/base.rbs +13 -0
  125. data/sig/factorix/cli/commands/cache/evict.rbs +17 -0
  126. data/sig/factorix/cli/commands/cache/stat.rbs +17 -0
  127. data/sig/factorix/cli/commands/command_wrapper.rbs +13 -0
  128. data/sig/factorix/cli/commands/completion/zsh.rbs +15 -0
  129. data/sig/factorix/cli/commands/confirmable.rbs +12 -0
  130. data/sig/factorix/cli/commands/download_support.rbs +12 -0
  131. data/sig/factorix/cli/commands/launch.rbs +15 -0
  132. data/sig/factorix/cli/commands/mod/check.rbs +18 -0
  133. data/sig/factorix/cli/commands/mod/disable.rbs +20 -0
  134. data/sig/factorix/cli/commands/mod/download.rbs +18 -0
  135. data/sig/factorix/cli/commands/mod/edit.rbs +30 -0
  136. data/sig/factorix/cli/commands/mod/enable.rbs +20 -0
  137. data/sig/factorix/cli/commands/mod/image/add.rbs +19 -0
  138. data/sig/factorix/cli/commands/mod/image/edit.rbs +19 -0
  139. data/sig/factorix/cli/commands/mod/image/list.rbs +19 -0
  140. data/sig/factorix/cli/commands/mod/install.rbs +19 -0
  141. data/sig/factorix/cli/commands/mod/list.rbs +30 -0
  142. data/sig/factorix/cli/commands/mod/search.rbs +18 -0
  143. data/sig/factorix/cli/commands/mod/settings/dump.rbs +17 -0
  144. data/sig/factorix/cli/commands/mod/settings/restore.rbs +17 -0
  145. data/sig/factorix/cli/commands/mod/sync.rbs +19 -0
  146. data/sig/factorix/cli/commands/mod/uninstall.rbs +20 -0
  147. data/sig/factorix/cli/commands/mod/update.rbs +19 -0
  148. data/sig/factorix/cli/commands/mod/upload.rbs +24 -0
  149. data/sig/factorix/cli/commands/path.rbs +18 -0
  150. data/sig/factorix/cli/commands/requires_game_stopped.rbs +13 -0
  151. data/sig/factorix/cli/commands/version.rbs +13 -0
  152. data/sig/factorix/cli.rbs +11 -0
  153. data/sig/factorix/dependency/edge.rbs +32 -0
  154. data/sig/factorix/dependency/entry.rbs +30 -0
  155. data/sig/factorix/dependency/graph/builder.rbs +17 -0
  156. data/sig/factorix/dependency/graph.rbs +39 -0
  157. data/sig/factorix/dependency/list.rbs +69 -0
  158. data/sig/factorix/dependency/mod_version_requirement.rbs +18 -0
  159. data/sig/factorix/dependency/node.rbs +24 -0
  160. data/sig/factorix/dependency/parser.rbs +11 -0
  161. data/sig/factorix/dependency/validation_result.rbs +56 -0
  162. data/sig/factorix/dependency/validator.rbs +13 -0
  163. data/sig/factorix/errors.rbs +132 -0
  164. data/sig/factorix/formatting.rbs +8 -0
  165. data/sig/factorix/game_version.rbs +24 -0
  166. data/sig/factorix/http/cache_decorator.rbs +64 -0
  167. data/sig/factorix/http/client.rbs +55 -0
  168. data/sig/factorix/http/response.rbs +28 -0
  169. data/sig/factorix/http/retry_decorator.rbs +44 -0
  170. data/sig/factorix/http/retry_strategy.rbs +42 -0
  171. data/sig/factorix/info_json.rbs +19 -0
  172. data/sig/factorix/installed_mod.rbs +34 -0
  173. data/sig/factorix/mod.rbs +20 -0
  174. data/sig/factorix/mod_list.rbs +44 -0
  175. data/sig/factorix/mod_settings.rbs +47 -0
  176. data/sig/factorix/mod_state.rbs +18 -0
  177. data/sig/factorix/mod_version.rbs +23 -0
  178. data/sig/factorix/portal.rbs +37 -0
  179. data/sig/factorix/progress/download_handler.rbs +19 -0
  180. data/sig/factorix/progress/multi_presenter.rbs +15 -0
  181. data/sig/factorix/progress/presenter.rbs +17 -0
  182. data/sig/factorix/progress/presenter_adapter.rbs +17 -0
  183. data/sig/factorix/progress/scan_handler.rbs +16 -0
  184. data/sig/factorix/progress/upload_handler.rbs +17 -0
  185. data/sig/factorix/runtime/base.rbs +45 -0
  186. data/sig/factorix/runtime/linux.rbs +15 -0
  187. data/sig/factorix/runtime/mac_os.rbs +15 -0
  188. data/sig/factorix/runtime/user_configurable.rbs +13 -0
  189. data/sig/factorix/runtime/windows.rbs +23 -0
  190. data/sig/factorix/runtime/wsl.rbs +19 -0
  191. data/sig/factorix/runtime.rbs +9 -0
  192. data/sig/factorix/save_file.rbs +40 -0
  193. data/sig/factorix/ser_des/deserializer.rbs +49 -0
  194. data/sig/factorix/ser_des/serializer.rbs +45 -0
  195. data/sig/factorix/ser_des/signed_integer.rbs +37 -0
  196. data/sig/factorix/ser_des/unsigned_integer.rbs +37 -0
  197. data/sig/factorix/service_credential.rbs +19 -0
  198. data/sig/factorix/transfer/downloader.rbs +15 -0
  199. data/sig/factorix/transfer/uploader.rbs +21 -0
  200. data/sig/factorix.rbs +9 -0
  201. data/sig/tty/progressbar.rbs +18 -0
  202. metadata +431 -0
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tsort"
4
+
5
+ module Factorix
6
+ module Dependency
7
+ # Directed graph of MOD dependencies using TSort
8
+ #
9
+ # This graph represents the dependency relationships between MODs.
10
+ # Nodes are MODs, edges are dependencies. The graph uses Ruby's TSort
11
+ # module for topological sorting and cycle detection.
12
+ class Graph
13
+ include TSort
14
+
15
+ # Create a new empty dependency graph
16
+ def initialize
17
+ @nodes = {} # MOD => Node
18
+ @edges = {} # MOD => [Edge]
19
+ end
20
+
21
+ # Add a node to the graph
22
+ #
23
+ # @param node [Factorix::Dependency::Node] The node to add
24
+ # @return [void]
25
+ # @raise [DependencyGraphError] if a node for this MOD already exists
26
+ def add_node(node)
27
+ mod = node.mod
28
+ raise DependencyGraphError, "Node for #{mod} already exists" if @nodes.key?(mod)
29
+
30
+ @nodes[mod] = node
31
+ @edges[mod] ||= []
32
+ end
33
+
34
+ # Set the planned operation for an existing node
35
+ #
36
+ # @param mod [Factorix::MOD] The MOD to update
37
+ # @param operation [Symbol, nil] The planned operation (:install, :enable, :disable, :uninstall, or nil)
38
+ # @return [Node, nil] The updated node, or nil if node doesn't exist
39
+ def set_node_operation(mod, operation)
40
+ node = @nodes[mod]
41
+ return unless node
42
+
43
+ @nodes[mod] = node.with(operation:)
44
+ end
45
+
46
+ # Add an edge to the graph
47
+ #
48
+ # @param edge [Factorix::Dependency::Edge] The edge to add
49
+ # @return [void]
50
+ # @raise [DependencyGraphError] if from_mod node doesn't exist
51
+ def add_edge(edge)
52
+ from_mod = edge.from_mod
53
+ raise DependencyGraphError, "Node for #{from_mod} doesn't exist" unless @nodes.key?(from_mod)
54
+
55
+ @edges[from_mod] ||= []
56
+ @edges[from_mod] << edge
57
+ end
58
+
59
+ # Add an uninstalled MOD (Category C) to the graph
60
+ #
61
+ # Creates a node for an uninstalled MOD and adds edges for its dependencies.
62
+ # Used by the install command to extend the graph with MODs fetched from the Portal API.
63
+ #
64
+ # @param mod_info [API::MODInfo] MOD information from Portal API
65
+ # @param release [API::Release] The release to install
66
+ # @param operation [Symbol] The operation to perform (default: :install)
67
+ # @return [void]
68
+ def add_uninstalled_mod(mod_info, release, operation: :install)
69
+ mod = MOD[name: mod_info.name]
70
+
71
+ existing_node = @nodes[mod]
72
+ if existing_node
73
+ # If already installed but disabled, mark for enabling
74
+ set_node_operation(mod, :enable) if existing_node.installed? && !existing_node.enabled?
75
+ return
76
+ end
77
+
78
+ node = Node.new(mod:, version: release.version, enabled: false, installed: false, operation:)
79
+ add_node(node)
80
+
81
+ dependencies = release.info_json[:dependencies] || []
82
+ parser = Dependency::Parser.new
83
+
84
+ dependencies.each do |dep_string|
85
+ dependency = parser.parse(dep_string)
86
+ next if dependency.mod.base?
87
+
88
+ edge = Edge.new(from_mod: mod, to_mod: dependency.mod, type: dependency.type, version_requirement: dependency.version_requirement)
89
+
90
+ add_edge(edge)
91
+ end
92
+ end
93
+
94
+ # Get a node by MOD
95
+ #
96
+ # @param mod [Factorix::MOD] The MOD identifier
97
+ # @return [Factorix::Dependency::Node, nil] The node or nil if not found
98
+ def node(mod) = @nodes[mod]
99
+
100
+ # Get all nodes
101
+ #
102
+ # @return [Array<Factorix::Dependency::Node>] All nodes in the graph
103
+ def nodes = @nodes.values
104
+
105
+ # Get edges from a MOD
106
+ #
107
+ # @param mod [Factorix::MOD] The MOD identifier
108
+ # @return [Array<Factorix::Dependency::Edge>] Edges from this MOD
109
+ def edges_from(mod) = @edges[mod] || []
110
+
111
+ # Get edges to a MOD
112
+ #
113
+ # @param mod [Factorix::MOD] The MOD identifier
114
+ # @return [Array<Factorix::Dependency::Edge>] Edges to this MOD
115
+ def edges_to(mod) = @edges.values.flatten.select {|edge| edge.to_mod == mod }
116
+
117
+ # Find all enabled MODs that have a required dependency on the given MOD
118
+ #
119
+ # @param mod [Factorix::MOD] The MOD to find dependents for
120
+ # @return [Array<Factorix::MOD>] MODs that depend on the given MOD
121
+ def find_enabled_dependents(mod)
122
+ dependents = []
123
+
124
+ nodes.each do |node|
125
+ next unless node.enabled?
126
+
127
+ edges_from(node.mod).each do |edge|
128
+ next unless edge.required? && edge.to_mod == mod
129
+
130
+ dependents << node.mod
131
+ break
132
+ end
133
+ end
134
+
135
+ dependents
136
+ end
137
+
138
+ # Get all edges in the graph
139
+ #
140
+ # @return [Array<Factorix::Dependency::Edge>] All edges
141
+ def edges = @edges.values.flatten
142
+
143
+ # Check if the graph contains a node for the given MOD
144
+ #
145
+ # @param mod [Factorix::MOD] The MOD identifier
146
+ # @return [Boolean]
147
+ def node?(mod) = @nodes.key?(mod)
148
+
149
+ # Get the number of nodes in the graph
150
+ #
151
+ # @return [Integer]
152
+ def size = @nodes.size
153
+
154
+ # Check if the graph is empty
155
+ #
156
+ # @return [Boolean]
157
+ def empty? = @nodes.empty?
158
+
159
+ # Check if the graph contains cycles
160
+ #
161
+ # @return [Boolean] true if the graph has cycles
162
+ def cyclic?
163
+ tsort
164
+ false
165
+ rescue TSort::Cyclic
166
+ true
167
+ end
168
+
169
+ # Find strongly connected components (cycles)
170
+ #
171
+ # @return [Array<Array<Factorix::MOD>>] Array of cycles
172
+ def strongly_connected_components = each_strongly_connected_component.to_a
173
+
174
+ # TSort interface: iterate over each node
175
+ #
176
+ # @yield [Factorix::MOD] Each MOD in the graph
177
+ # @return [void]
178
+ def tsort_each_node(&) = @nodes.each_key(&)
179
+
180
+ # TSort interface: iterate over children of a node
181
+ #
182
+ # @param mod [Factorix::MOD] The MOD to get children for
183
+ # @yield [Factorix::MOD] Each child MOD
184
+ # @return [void]
185
+ def tsort_each_child(mod)
186
+ edges_from(mod).each do |edge|
187
+ # Only follow required dependency edges for cycle detection
188
+ # Skip optional, incompatible, load-neutral, and hidden edges
189
+ # Optional cycles are allowed in Factorio
190
+ next unless edge.required?
191
+
192
+ yield edge.to_mod if @nodes.key?(edge.to_mod)
193
+ end
194
+ end
195
+
196
+ # Get a string representation of the graph
197
+ #
198
+ # @return [String]
199
+ def to_s = "#<#{self.class.name} nodes=#{@nodes.size} edges=#{edges.size}>"
200
+
201
+ # Detailed inspection string
202
+ #
203
+ # @return [String]
204
+ def inspect
205
+ node_list = @nodes.values.join(", ")
206
+ "#<#{self.class.name} [#{node_list}]>"
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tsort"
4
+
5
+ module Factorix
6
+ module Dependency
7
+ # Represents a collection of MOD dependencies
8
+ #
9
+ # This class manages a collection of Dependency::Entry objects, providing
10
+ # filtering, validation, and circular dependency detection capabilities.
11
+ #
12
+ # @example Creating from dependency strings
13
+ # deps = Dependency::List.from_strings(["base >= 1.0.0", "? optional-mod", "! bad-mod"])
14
+ # deps.required.each { |dep| puts dep.to_s }
15
+ # deps.optional.each { |dep| puts dep.to_s }
16
+ #
17
+ # @example Validating dependencies
18
+ # available = {"base" => MODVersion.from_string("1.1.0")}
19
+ # puts "Missing: #{deps.missing_required(available).join(", ")}"
20
+ #
21
+ # @example Detecting circular dependencies
22
+ # mod_deps_map = {
23
+ # "mod-a" => Dependency::List.from_strings(["mod-b"]),
24
+ # "mod-b" => Dependency::List.from_strings(["mod-a"])
25
+ # }
26
+ # cycles = Dependency::List.detect_circular(mod_deps_map)
27
+ # cycles.each { |cycle| puts "Cycle: #{cycle.join(" -> ")}" }
28
+ class List
29
+ include Enumerable
30
+
31
+ # TSort protocol implementation for dependency graph analysis
32
+ #
33
+ # This internal class wraps a dependency map and implements the TSort protocol
34
+ # to enable cycle detection using strongly connected components analysis.
35
+ class DependencyGraph
36
+ include TSort
37
+
38
+ # @param mod_deps_map [Hash<String, Dependency::List>] Map of MOD names to dependencies
39
+ def initialize(mod_deps_map) = @mod_deps_map = mod_deps_map
40
+
41
+ # Iterate through all nodes in the graph
42
+ #
43
+ # @yieldparam node [String] MOD name
44
+ def tsort_each_node(&) = @mod_deps_map.each_key(&)
45
+
46
+ # Iterate through children (dependencies) of a node
47
+ #
48
+ # @param node [String] MOD name
49
+ # @yieldparam child [String] Dependent MOD name
50
+ def tsort_each_child(node)
51
+ deps = @mod_deps_map[node]
52
+ return unless deps
53
+
54
+ deps.required.each do |dep|
55
+ yield(dep.mod.name) if @mod_deps_map.key?(dep.mod.name)
56
+ end
57
+ end
58
+ end
59
+ private_constant :DependencyGraph
60
+
61
+ # Create List from an array of dependency strings
62
+ #
63
+ # @param dependency_strings [Array<String>] Array of dependency strings from info.json
64
+ # @return [List] New instance with parsed dependencies
65
+ # @raise [ArgumentError] if any dependency string is invalid
66
+ #
67
+ # @example
68
+ # deps = Dependency::List.from_strings(["base", "? some-mod >= 1.2.0"])
69
+ def self.from_strings(dependency_strings)
70
+ parser = Parser.new
71
+ dependencies = dependency_strings.map {|str| parser.parse(str) }
72
+ new(dependencies)
73
+ end
74
+
75
+ # Detect circular dependencies in a collection of MOD dependencies
76
+ #
77
+ # Uses TSort to detect cycles in the dependency graph.
78
+ # Only considers required dependencies (optional and load-neutral are ignored).
79
+ #
80
+ # @param mod_dependencies_map [Hash<String, Dependency::List>] Map of MOD names to their dependencies
81
+ # @return [Array<Array<String>>] Array of circular dependency chains, or empty array if none found
82
+ #
83
+ # @example
84
+ # map = {
85
+ # "mod-a" => Dependency::List.from_strings(["mod-b"]),
86
+ # "mod-b" => Dependency::List.from_strings(["mod-a"])
87
+ # }
88
+ # cycles = Dependency::List.detect_circular(map)
89
+ # # => [["mod-a", "mod-b", "mod-a"]]
90
+ def self.detect_circular(mod_dependencies_map)
91
+ graph = DependencyGraph.new(mod_dependencies_map)
92
+ cycles = []
93
+
94
+ # Detect self-dependencies (not detected by strongly_connected_components)
95
+ mod_dependencies_map.each do |mod_name, deps|
96
+ if deps.required.any? {|dep| dep.mod.name == mod_name }
97
+ cycles << [mod_name, mod_name]
98
+ end
99
+ end
100
+
101
+ # Get strongly connected components (cycles are components with size > 1)
102
+ scc_cycles = graph.strongly_connected_components.filter_map {|component|
103
+ next if component.size <= 1
104
+
105
+ # Add first element at the end to make cycle explicit
106
+ component + [component.first]
107
+ }
108
+
109
+ cycles + scc_cycles
110
+ end
111
+
112
+ # Initialize a List collection
113
+ #
114
+ # @param dependencies [Array<Entry>] Array of parsed dependency objects
115
+ # @return [void]
116
+ # @raise [ArgumentError] if dependencies is not an Array
117
+ # @raise [ArgumentError] if any element is not a Dependency::Entry
118
+ def initialize(dependencies=[])
119
+ unless dependencies.is_a?(Array)
120
+ raise ArgumentError, "dependencies must be an Array, got #{dependencies.class}"
121
+ end
122
+
123
+ dependencies.each_with_index do |dep, index|
124
+ unless dep.is_a?(Entry)
125
+ raise ArgumentError, "dependencies[#{index}] must be a Dependency::Entry, got #{dep.class}"
126
+ end
127
+ end
128
+
129
+ @dependencies = dependencies.freeze
130
+ end
131
+
132
+ # Iterate through all dependencies
133
+ #
134
+ # @yieldparam dependency [Entry] Each dependency in the collection
135
+ # @return [Enumerator] if no block is given
136
+ # @return [List] if a block is given
137
+ def each(&block)
138
+ return @dependencies.to_enum unless block
139
+
140
+ @dependencies.each(&block)
141
+ self
142
+ end
143
+
144
+ # Get all required dependencies
145
+ #
146
+ # @return [Array<Entry>] Array of required dependencies
147
+ def required = @dependencies.select(&:required?)
148
+
149
+ # Get all optional dependencies (including hidden optional)
150
+ #
151
+ # @return [Array<Entry>] Array of optional dependencies
152
+ def optional = @dependencies.select(&:optional?)
153
+
154
+ # Get all incompatible dependencies
155
+ #
156
+ # @return [Array<Entry>] Array of incompatible dependencies
157
+ def incompatible = @dependencies.select(&:incompatible?)
158
+
159
+ # Get all load-neutral dependencies
160
+ #
161
+ # @return [Array<Entry>] Array of load-neutral dependencies
162
+ def load_neutral = @dependencies.select(&:load_neutral?)
163
+
164
+ # Check if this collection depends on a specific MOD
165
+ #
166
+ # @param mod_name_or_mod [String, MOD] MOD name or MOD instance to check
167
+ # @return [Boolean] true if depends on the MOD (not incompatible), false otherwise
168
+ def depends_on?(mod_name_or_mod)
169
+ mod_name = mod_name_or_mod.is_a?(MOD) ? mod_name_or_mod.name : mod_name_or_mod.to_s
170
+
171
+ @dependencies.any? {|dep| dep.mod.name == mod_name && !dep.incompatible? }
172
+ end
173
+
174
+ # Check if this collection marks a MOD as incompatible
175
+ #
176
+ # @param mod_name_or_mod [String, MOD] MOD name or MOD instance to check
177
+ # @return [Boolean] true if marked as incompatible, false otherwise
178
+ def incompatible_with?(mod_name_or_mod)
179
+ mod_name = mod_name_or_mod.is_a?(MOD) ? mod_name_or_mod.name : mod_name_or_mod.to_s
180
+
181
+ @dependencies.any? {|dep| dep.mod.name == mod_name && dep.incompatible? }
182
+ end
183
+
184
+ # Check if the collection is empty
185
+ #
186
+ # @return [Boolean] true if no dependencies, false otherwise
187
+ def empty? = @dependencies.empty?
188
+
189
+ # Get the total number of dependencies
190
+ #
191
+ # @return [Integer] Number of dependencies
192
+ def size = @dependencies.size
193
+
194
+ # Check if all required dependencies are satisfied
195
+ #
196
+ # @param available_mods [Hash<String, MODVersion>] Available MODs and their versions
197
+ # @return [Boolean] true if all required dependencies are satisfied
198
+ def satisfied_by?(available_mods) = required.all? {|dep| (version = available_mods[dep.mod.name]) && dep.satisfied_by?(version) }
199
+
200
+ # Get list of incompatible MODs that are present
201
+ #
202
+ # @param available_mods [Hash<String, MODVersion>] Available MODs and their versions
203
+ # @return [Array<String>] Array of conflicting MOD names
204
+ def conflicts_with?(available_mods) = incompatible.filter_map {|dep| dep.mod.name if available_mods.key?(dep.mod.name) }
205
+
206
+ # Get list of missing required dependencies
207
+ #
208
+ # @param available_mods [Hash<String, MODVersion>] Available MODs and their versions
209
+ # @return [Array<String>] Array of missing MOD names
210
+ def missing_required(available_mods) = required.filter_map {|dep| dep.mod.name unless available_mods.key?(dep.mod.name) }
211
+
212
+ # Get list of dependencies with unsatisfied version requirements
213
+ #
214
+ # @param available_mods [Hash<String, MODVersion>] Available MODs and their versions
215
+ # @return [Hash<String, Hash<Symbol, String>>] Hash of {mod_name => {required: ..., actual: ...}}
216
+ def unsatisfied_versions(available_mods)
217
+ result = {}
218
+
219
+ required.each do |dep|
220
+ next unless dep.version_requirement # Skip if no version requirement
221
+
222
+ version = available_mods[dep.mod.name]
223
+ next unless version # Skip if not available (covered by missing_required)
224
+
225
+ next if dep.satisfied_by?(version)
226
+
227
+ result[dep.mod.name] = {required: dep.version_requirement.to_s, actual: version.to_s}
228
+ end
229
+
230
+ result
231
+ end
232
+
233
+ # Convert to array of dependency strings
234
+ #
235
+ # @return [Array<String>] Array of dependency strings
236
+ def to_a = @dependencies.map(&:to_s)
237
+
238
+ # Convert to hash keyed by MOD name
239
+ #
240
+ # @return [Hash<String, Entry>] Hash of {mod_name => dependency}
241
+ def to_h = @dependencies.to_h {|dep| [dep.mod.name, dep] }
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ module Dependency
5
+ # Define MODVersionRequirement as an immutable data class
6
+ MODVersionRequirement = Data.define(:operator, :version)
7
+
8
+ # Represents a MOD version requirement with an operator and version
9
+ #
10
+ # This class is used in MOD dependencies to specify version constraints.
11
+ # It supports the following comparison operators: <, <=, =, >=, >
12
+ #
13
+ # @example Creating a version requirement
14
+ # requirement = MODVersionRequirement[operator: ">=", version: MODVersion.from_string("1.2.0")]
15
+ # requirement.satisfied_by?(MODVersion.from_string("1.3.0")) # => true
16
+ # requirement.satisfied_by?(MODVersion.from_string("1.1.0")) # => false
17
+ class MODVersionRequirement
18
+ # @!attribute [r] operator
19
+ # @return [String] Comparison operator (<, <=, =, >=, >)
20
+ # @!attribute [r] version
21
+ # @return [MODVersion] Version to compare against
22
+
23
+ # Valid comparison operators
24
+ VALID_OPERATORS = ["<", "<=", "=", ">=", ">"].freeze
25
+ private_constant :VALID_OPERATORS
26
+
27
+ # Create a new MODVersionRequirement
28
+ #
29
+ # @param operator [String] Comparison operator (<, <=, =, >=, >)
30
+ # @param version [MODVersion] Version to compare against
31
+ # @return [MODVersionRequirement]
32
+ # @raise [ArgumentError] if operator is not valid
33
+ # @raise [ArgumentError] if version is not a MODVersion
34
+ def initialize(operator:, version:)
35
+ unless VALID_OPERATORS.include?(operator)
36
+ raise ArgumentError, "Invalid operator: #{operator}. Must be one of: #{VALID_OPERATORS.join(", ")}"
37
+ end
38
+
39
+ unless version.is_a?(MODVersion)
40
+ raise ArgumentError, "version must be a MODVersion, got #{version.class}"
41
+ end
42
+
43
+ super
44
+ end
45
+
46
+ # Check if a given version satisfies this requirement
47
+ #
48
+ # @param mod_version [MODVersion] Version to check
49
+ # @return [Boolean] true if the version satisfies the requirement
50
+ def satisfied_by?(mod_version)
51
+ case operator
52
+ when "="
53
+ mod_version == version
54
+ when ">="
55
+ mod_version >= version
56
+ when ">"
57
+ mod_version > version
58
+ when "<="
59
+ mod_version <= version
60
+ when "<"
61
+ mod_version < version
62
+ else
63
+ raise ArgumentError, "Unexpected operator: #{operator}"
64
+ end
65
+ end
66
+
67
+ # String representation of the requirement
68
+ #
69
+ # @return [String] String representation (e.g., ">= 1.2.0")
70
+ def to_s = "#{operator} #{version}"
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ module Dependency
5
+ Node = Data.define(:mod, :version, :enabled, :installed, :operation)
6
+
7
+ # Represents a MOD node in the dependency graph
8
+ #
9
+ # Each node represents a MOD with its version, installation state,
10
+ # and enabled state. Operations can be planned on nodes (:enable,
11
+ # :disable, :install, :uninstall).
12
+ class Node
13
+ # @!attribute [r] mod
14
+ # @return [Factorix::MOD] The MOD identifier
15
+ # @!attribute [r] version
16
+ # @return [Factorix::MODVersion] The MOD version
17
+ # @!attribute [r] enabled
18
+ # @return [Boolean] Whether the MOD is enabled
19
+ # @!attribute [r] installed
20
+ # @return [Boolean] Whether the MOD is installed
21
+ # @!attribute [r] operation
22
+ # @return [Symbol, nil] Planned operation (:enable, :disable, :install, :uninstall, nil)
23
+
24
+ def initialize(mod:, version:, enabled: false, installed: false, operation: nil) = super
25
+
26
+ # Check if the MOD is enabled
27
+ #
28
+ # @return [Boolean]
29
+ def enabled? = enabled
30
+
31
+ # Check if the MOD is installed
32
+ #
33
+ # @return [Boolean]
34
+ def installed? = installed
35
+
36
+ # Check if an operation is planned for this node
37
+ #
38
+ # @return [Boolean]
39
+ def operation? = !operation.nil?
40
+
41
+ # String representation of the node
42
+ #
43
+ # @return [String]
44
+ def to_s
45
+ state_flags = []
46
+ state_flags << "enabled" if enabled
47
+ state_flags << "installed" if installed
48
+ state_flags << "op:#{operation}" if operation
49
+
50
+ state = state_flags.empty? ? "new" : state_flags.join(", ")
51
+ "#{mod} v#{version} (#{state})"
52
+ end
53
+
54
+ # Detailed inspection string
55
+ #
56
+ # @return [String]
57
+ def inspect = "#<#{self.class.name} #{self}>"
58
+ end
59
+ end
60
+ end