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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/completion/_factorix.bash +202 -0
- data/completion/_factorix.fish +197 -0
- data/completion/_factorix.zsh +376 -0
- data/doc/factorix.1 +377 -0
- data/exe/factorix +20 -0
- data/lib/factorix/api/category.rb +69 -0
- data/lib/factorix/api/image.rb +35 -0
- data/lib/factorix/api/license.rb +71 -0
- data/lib/factorix/api/mod_download_api.rb +66 -0
- data/lib/factorix/api/mod_info.rb +166 -0
- data/lib/factorix/api/mod_management_api.rb +237 -0
- data/lib/factorix/api/mod_portal_api.rb +204 -0
- data/lib/factorix/api/release.rb +49 -0
- data/lib/factorix/api/tag.rb +95 -0
- data/lib/factorix/api.rb +7 -0
- data/lib/factorix/api_credential.rb +54 -0
- data/lib/factorix/application.rb +218 -0
- data/lib/factorix/cache/file_system.rb +307 -0
- data/lib/factorix/cli/commands/backup_support.rb +46 -0
- data/lib/factorix/cli/commands/base.rb +90 -0
- data/lib/factorix/cli/commands/cache/evict.rb +180 -0
- data/lib/factorix/cli/commands/cache/stat.rb +201 -0
- data/lib/factorix/cli/commands/command_wrapper.rb +71 -0
- data/lib/factorix/cli/commands/completion.rb +83 -0
- data/lib/factorix/cli/commands/confirmable.rb +53 -0
- data/lib/factorix/cli/commands/download_support.rb +123 -0
- data/lib/factorix/cli/commands/launch.rb +79 -0
- data/lib/factorix/cli/commands/man.rb +29 -0
- data/lib/factorix/cli/commands/mod/check.rb +99 -0
- data/lib/factorix/cli/commands/mod/disable.rb +188 -0
- data/lib/factorix/cli/commands/mod/download.rb +291 -0
- data/lib/factorix/cli/commands/mod/edit.rb +114 -0
- data/lib/factorix/cli/commands/mod/enable.rb +216 -0
- data/lib/factorix/cli/commands/mod/image/add.rb +47 -0
- data/lib/factorix/cli/commands/mod/image/edit.rb +41 -0
- data/lib/factorix/cli/commands/mod/image/list.rb +74 -0
- data/lib/factorix/cli/commands/mod/install.rb +443 -0
- data/lib/factorix/cli/commands/mod/list.rb +372 -0
- data/lib/factorix/cli/commands/mod/search.rb +134 -0
- data/lib/factorix/cli/commands/mod/settings/dump.rb +88 -0
- data/lib/factorix/cli/commands/mod/settings/restore.rb +101 -0
- data/lib/factorix/cli/commands/mod/show.rb +202 -0
- data/lib/factorix/cli/commands/mod/sync.rb +299 -0
- data/lib/factorix/cli/commands/mod/uninstall.rb +325 -0
- data/lib/factorix/cli/commands/mod/update.rb +222 -0
- data/lib/factorix/cli/commands/mod/upload.rb +90 -0
- data/lib/factorix/cli/commands/path.rb +79 -0
- data/lib/factorix/cli/commands/requires_game_stopped.rb +32 -0
- data/lib/factorix/cli/commands/version.rb +25 -0
- data/lib/factorix/cli.rb +42 -0
- data/lib/factorix/dependency/edge.rb +89 -0
- data/lib/factorix/dependency/entry.rb +124 -0
- data/lib/factorix/dependency/graph/builder.rb +108 -0
- data/lib/factorix/dependency/graph.rb +210 -0
- data/lib/factorix/dependency/list.rb +244 -0
- data/lib/factorix/dependency/mod_version_requirement.rb +73 -0
- data/lib/factorix/dependency/node.rb +60 -0
- data/lib/factorix/dependency/parser.rb +148 -0
- data/lib/factorix/dependency/validation_result.rb +138 -0
- data/lib/factorix/dependency/validator.rb +190 -0
- data/lib/factorix/errors.rb +112 -0
- data/lib/factorix/formatting.rb +56 -0
- data/lib/factorix/game_version.rb +98 -0
- data/lib/factorix/http/cache_decorator.rb +106 -0
- data/lib/factorix/http/cached_response.rb +37 -0
- data/lib/factorix/http/client.rb +187 -0
- data/lib/factorix/http/response.rb +31 -0
- data/lib/factorix/http/retry_decorator.rb +59 -0
- data/lib/factorix/http/retry_strategy.rb +80 -0
- data/lib/factorix/info_json.rb +90 -0
- data/lib/factorix/installed_mod.rb +239 -0
- data/lib/factorix/mod.rb +55 -0
- data/lib/factorix/mod_list.rb +174 -0
- data/lib/factorix/mod_settings.rb +278 -0
- data/lib/factorix/mod_state.rb +34 -0
- data/lib/factorix/mod_version.rb +99 -0
- data/lib/factorix/portal.rb +185 -0
- data/lib/factorix/progress/download_handler.rb +46 -0
- data/lib/factorix/progress/multi_presenter.rb +45 -0
- data/lib/factorix/progress/presenter.rb +67 -0
- data/lib/factorix/progress/presenter_adapter.rb +46 -0
- data/lib/factorix/progress/scan_handler.rb +33 -0
- data/lib/factorix/progress/upload_handler.rb +33 -0
- data/lib/factorix/runtime/base.rb +233 -0
- data/lib/factorix/runtime/linux.rb +32 -0
- data/lib/factorix/runtime/mac_os.rb +53 -0
- data/lib/factorix/runtime/user_configurable.rb +69 -0
- data/lib/factorix/runtime/windows.rb +85 -0
- data/lib/factorix/runtime/wsl.rb +118 -0
- data/lib/factorix/runtime.rb +32 -0
- data/lib/factorix/save_file.rb +178 -0
- data/lib/factorix/ser_des/deserializer.rb +198 -0
- data/lib/factorix/ser_des/serializer.rb +231 -0
- data/lib/factorix/ser_des/signed_integer.rb +63 -0
- data/lib/factorix/ser_des/unsigned_integer.rb +65 -0
- data/lib/factorix/service_credential.rb +127 -0
- data/lib/factorix/transfer/downloader.rb +162 -0
- data/lib/factorix/transfer/uploader.rb +232 -0
- data/lib/factorix/version.rb +6 -0
- data/lib/factorix.rb +38 -0
- data/sig/dry/auto_inject.rbs +15 -0
- data/sig/dry/cli.rbs +19 -0
- data/sig/dry/configurable.rbs +13 -0
- data/sig/dry/core/container.rbs +17 -0
- data/sig/dry/events/publisher.rbs +22 -0
- data/sig/dry/logger.rbs +16 -0
- data/sig/factorix/api/category.rbs +15 -0
- data/sig/factorix/api/image.rbs +15 -0
- data/sig/factorix/api/license.rbs +20 -0
- data/sig/factorix/api/mod_download_api.rbs +18 -0
- data/sig/factorix/api/mod_info.rbs +67 -0
- data/sig/factorix/api/mod_management_api.rbs +25 -0
- data/sig/factorix/api/mod_portal_api.rbs +31 -0
- data/sig/factorix/api/release.rbs +27 -0
- data/sig/factorix/api/tag.rbs +15 -0
- data/sig/factorix/api.rbs +8 -0
- data/sig/factorix/api_credential.rbs +17 -0
- data/sig/factorix/application.rbs +86 -0
- data/sig/factorix/cache/file_system.rbs +35 -0
- data/sig/factorix/cli/commands/base.rbs +13 -0
- data/sig/factorix/cli/commands/cache/evict.rbs +17 -0
- data/sig/factorix/cli/commands/cache/stat.rbs +17 -0
- data/sig/factorix/cli/commands/command_wrapper.rbs +13 -0
- data/sig/factorix/cli/commands/completion/zsh.rbs +15 -0
- data/sig/factorix/cli/commands/confirmable.rbs +12 -0
- data/sig/factorix/cli/commands/download_support.rbs +12 -0
- data/sig/factorix/cli/commands/launch.rbs +15 -0
- data/sig/factorix/cli/commands/mod/check.rbs +18 -0
- data/sig/factorix/cli/commands/mod/disable.rbs +20 -0
- data/sig/factorix/cli/commands/mod/download.rbs +18 -0
- data/sig/factorix/cli/commands/mod/edit.rbs +30 -0
- data/sig/factorix/cli/commands/mod/enable.rbs +20 -0
- data/sig/factorix/cli/commands/mod/image/add.rbs +19 -0
- data/sig/factorix/cli/commands/mod/image/edit.rbs +19 -0
- data/sig/factorix/cli/commands/mod/image/list.rbs +19 -0
- data/sig/factorix/cli/commands/mod/install.rbs +19 -0
- data/sig/factorix/cli/commands/mod/list.rbs +30 -0
- data/sig/factorix/cli/commands/mod/search.rbs +18 -0
- data/sig/factorix/cli/commands/mod/settings/dump.rbs +17 -0
- data/sig/factorix/cli/commands/mod/settings/restore.rbs +17 -0
- data/sig/factorix/cli/commands/mod/sync.rbs +19 -0
- data/sig/factorix/cli/commands/mod/uninstall.rbs +20 -0
- data/sig/factorix/cli/commands/mod/update.rbs +19 -0
- data/sig/factorix/cli/commands/mod/upload.rbs +24 -0
- data/sig/factorix/cli/commands/path.rbs +18 -0
- data/sig/factorix/cli/commands/requires_game_stopped.rbs +13 -0
- data/sig/factorix/cli/commands/version.rbs +13 -0
- data/sig/factorix/cli.rbs +11 -0
- data/sig/factorix/dependency/edge.rbs +32 -0
- data/sig/factorix/dependency/entry.rbs +30 -0
- data/sig/factorix/dependency/graph/builder.rbs +17 -0
- data/sig/factorix/dependency/graph.rbs +39 -0
- data/sig/factorix/dependency/list.rbs +69 -0
- data/sig/factorix/dependency/mod_version_requirement.rbs +18 -0
- data/sig/factorix/dependency/node.rbs +24 -0
- data/sig/factorix/dependency/parser.rbs +11 -0
- data/sig/factorix/dependency/validation_result.rbs +56 -0
- data/sig/factorix/dependency/validator.rbs +13 -0
- data/sig/factorix/errors.rbs +132 -0
- data/sig/factorix/formatting.rbs +8 -0
- data/sig/factorix/game_version.rbs +24 -0
- data/sig/factorix/http/cache_decorator.rbs +64 -0
- data/sig/factorix/http/client.rbs +55 -0
- data/sig/factorix/http/response.rbs +28 -0
- data/sig/factorix/http/retry_decorator.rbs +44 -0
- data/sig/factorix/http/retry_strategy.rbs +42 -0
- data/sig/factorix/info_json.rbs +19 -0
- data/sig/factorix/installed_mod.rbs +34 -0
- data/sig/factorix/mod.rbs +20 -0
- data/sig/factorix/mod_list.rbs +44 -0
- data/sig/factorix/mod_settings.rbs +47 -0
- data/sig/factorix/mod_state.rbs +18 -0
- data/sig/factorix/mod_version.rbs +23 -0
- data/sig/factorix/portal.rbs +37 -0
- data/sig/factorix/progress/download_handler.rbs +19 -0
- data/sig/factorix/progress/multi_presenter.rbs +15 -0
- data/sig/factorix/progress/presenter.rbs +17 -0
- data/sig/factorix/progress/presenter_adapter.rbs +17 -0
- data/sig/factorix/progress/scan_handler.rbs +16 -0
- data/sig/factorix/progress/upload_handler.rbs +17 -0
- data/sig/factorix/runtime/base.rbs +45 -0
- data/sig/factorix/runtime/linux.rbs +15 -0
- data/sig/factorix/runtime/mac_os.rbs +15 -0
- data/sig/factorix/runtime/user_configurable.rbs +13 -0
- data/sig/factorix/runtime/windows.rbs +23 -0
- data/sig/factorix/runtime/wsl.rbs +19 -0
- data/sig/factorix/runtime.rbs +9 -0
- data/sig/factorix/save_file.rbs +40 -0
- data/sig/factorix/ser_des/deserializer.rbs +49 -0
- data/sig/factorix/ser_des/serializer.rbs +45 -0
- data/sig/factorix/ser_des/signed_integer.rbs +37 -0
- data/sig/factorix/ser_des/unsigned_integer.rbs +37 -0
- data/sig/factorix/service_credential.rbs +19 -0
- data/sig/factorix/transfer/downloader.rbs +15 -0
- data/sig/factorix/transfer/uploader.rbs +21 -0
- data/sig/factorix.rbs +9 -0
- data/sig/tty/progressbar.rbs +18 -0
- 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
|