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,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parslet"
4
+
5
+ module Factorix
6
+ module Dependency
7
+ # Parser for MOD dependency strings using Parslet
8
+ #
9
+ # This class parses dependency strings from info.json files and converts them
10
+ # into Dependency::Entry objects using a PEG-based parser.
11
+ #
12
+ # @example Parsing various dependency formats
13
+ # parser = Dependency::Parser.new
14
+ #
15
+ # # Required dependency
16
+ # dep1 = parser.parse("base")
17
+ #
18
+ # # Optional dependency with version
19
+ # dep2 = parser.parse("? some-mod >= 1.2.0")
20
+ #
21
+ # # Incompatible MOD
22
+ # dep3 = parser.parse("! bad-mod")
23
+ #
24
+ # # Hidden optional
25
+ # dep4 = parser.parse("(?) hidden-mod")
26
+ #
27
+ # # Load-neutral
28
+ # dep5 = parser.parse("~ neutral-mod")
29
+ class Parser
30
+ # Parslet grammar for dependency strings
31
+ class Grammar < Parslet::Parser
32
+ rule(:space) { match['\s'].repeat(1) }
33
+ rule(:space?) { space.maybe }
34
+
35
+ # Prefix rules (longest first to avoid partial matches)
36
+ rule(:prefix) do
37
+ str("(?)").as(:hidden_optional) |
38
+ str("!").as(:incompatible) |
39
+ str("?").as(:optional) |
40
+ str("~").as(:load_neutral)
41
+ end
42
+
43
+ # MOD name: starts with alphanumeric, can contain spaces
44
+ # Cannot start with operators or contain only operators
45
+ rule(:mod_name_start) { match["a-zA-Z0-9_-"] }
46
+ rule(:mod_name_char) { match["a-zA-Z0-9_-"] | (space >> match["a-zA-Z0-9_-"].repeat(1)) }
47
+ rule(:mod_name) { (mod_name_start >> mod_name_char.repeat).as(:mod_name) }
48
+
49
+ # Version operators (longest first)
50
+ rule(:operator) do
51
+ (str(">=") | str("<=") | str(">") | str("<") | str("=")).as(:operator)
52
+ end
53
+
54
+ # Version: X.Y.Z or X.Y format
55
+ rule(:version) do
56
+ (match["0-9"].repeat(1) >> str(".") >> match["0-9"].repeat(1) >> (str(".") >> match["0-9"].repeat(1)).maybe).as(:version)
57
+ end
58
+
59
+ # Version requirement: operator space version
60
+ rule(:version_requirement) do
61
+ space? >> operator >> space? >> version
62
+ end
63
+
64
+ # Complete dependency: [prefix] [space] mod_name [version_requirement]
65
+ rule(:dependency) do
66
+ space? >>
67
+ prefix.maybe.as(:prefix) >>
68
+ space? >>
69
+ mod_name >>
70
+ version_requirement.maybe.as(:requirement) >>
71
+ space?
72
+ end
73
+
74
+ root(:dependency)
75
+ end
76
+
77
+ # Transform parsed tree into structured data
78
+ class Transform < Parslet::Transform
79
+ rule(mod_name: simple(:name)) { {mod_name: name.to_s.strip} }
80
+
81
+ rule(version: simple(:ver)) { {version: ver.to_s} }
82
+
83
+ rule(operator: simple(:op), version: simple(:ver)) do
84
+ {operator: op.to_s, version: ver.to_s}
85
+ end
86
+
87
+ rule(optional: simple(:_)) { {type: Entry::OPTIONAL} }
88
+ rule(hidden_optional: simple(:_)) { {type: Entry::HIDDEN_OPTIONAL} }
89
+ rule(incompatible: simple(:_)) { {type: Entry::INCOMPATIBLE} }
90
+ rule(load_neutral: simple(:_)) { {type: Entry::LOAD_NEUTRAL} }
91
+ end
92
+
93
+ def initialize
94
+ @grammar = Grammar.new
95
+ @transform = Transform.new
96
+ end
97
+
98
+ # Parse a dependency string into a Dependency::Entry object
99
+ #
100
+ # @param dependency_string [String] Dependency string to parse
101
+ # @return [Entry] Parsed dependency object
102
+ # @raise [DependencyParseError] if the dependency string is invalid
103
+ def parse(dependency_string)
104
+ raise DependencyParseError, "dependency_string cannot be nil or empty" if dependency_string.nil? || dependency_string.empty?
105
+
106
+ begin
107
+ tree = @grammar.parse(dependency_string)
108
+ data = @transform.apply(tree)
109
+
110
+ mod_name = data[:mod_name].to_s
111
+ mod = MOD[name: mod_name]
112
+ type = data.dig(:prefix, :type) || Entry::REQUIRED
113
+ version_requirement = build_version_requirement(data[:requirement])
114
+
115
+ Entry[mod:, type:, version_requirement:]
116
+ rescue Parslet::ParseFailed => e
117
+ raise DependencyParseError, parse_error_message(dependency_string, e)
118
+ end
119
+ end
120
+
121
+ private def build_version_requirement(requirement_data)
122
+ return nil if requirement_data.nil? || requirement_data.empty?
123
+
124
+ operator = requirement_data[:operator]
125
+ version_string = requirement_data[:version]
126
+
127
+ version = MODVersion.from_string(version_string)
128
+ MODVersionRequirement[operator:, version:]
129
+ rescue VersionParseError => e
130
+ # Skip version requirements with out-of-range version components
131
+ Application[:logger].warn("Skipping version requirement '#{version_string}': #{e.message}")
132
+ nil
133
+ end
134
+
135
+ private def parse_error_message(input, error)
136
+ if input.strip.match?(/^[><=]+/)
137
+ "Invalid dependency format: empty MOD name (input: #{input.inspect})"
138
+ elsif input.match?(/[><=]\s*$/)
139
+ "Invalid dependency format: empty version (input: #{input.inspect})"
140
+ elsif input.match?(/[><=]\s+\S+$/) && !input.match?(/[><=]\s+\d+\.\d+\.\d+/)
141
+ "Invalid version requirement: invalid version format (input: #{input.inspect})"
142
+ else
143
+ "Invalid dependency format (input: #{input.inspect}): #{error.parse_failure_cause.ascii_tree}"
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ module Dependency
5
+ # Represents the result of MOD dependency validation
6
+ #
7
+ # Holds errors and warnings found during validation.
8
+ class ValidationResult
9
+ Error = Data.define(:type, :message, :mod, :dependency)
10
+
11
+ # Validation error information
12
+ #
13
+ # Represents an error found during dependency validation.
14
+ class Error
15
+ # @!attribute [r] type
16
+ # @return [Symbol] error type
17
+ # @!attribute [r] message
18
+ # @return [String] error message
19
+ # @!attribute [r] mod
20
+ # @return [Factorix::MOD, nil] related MOD
21
+ # @!attribute [r] dependency
22
+ # @return [Factorix::MOD, nil] dependency MOD
23
+ end
24
+
25
+ Warning = Data.define(:type, :message, :mod)
26
+
27
+ # Validation warning information
28
+ #
29
+ # Represents a warning found during dependency validation.
30
+ class Warning
31
+ # @!attribute [r] type
32
+ # @return [Symbol] warning type
33
+ # @!attribute [r] message
34
+ # @return [String] warning message
35
+ # @!attribute [r] mod
36
+ # @return [Factorix::MOD, nil] related MOD
37
+ end
38
+
39
+ Suggestion = Data.define(:message, :mod, :version)
40
+
41
+ # Validation suggestion information
42
+ #
43
+ # Represents a suggestion for resolving dependency issues.
44
+ class Suggestion
45
+ # @!attribute [r] message
46
+ # @return [String] suggestion message
47
+ # @!attribute [r] mod
48
+ # @return [Factorix::MOD] related MOD
49
+ # @!attribute [r] version
50
+ # @return [Factorix::MODVersion] suggested version
51
+ end
52
+
53
+ # Error types
54
+ MISSING_DEPENDENCY = :missing_dependency
55
+ public_constant :MISSING_DEPENDENCY
56
+ DISABLED_DEPENDENCY = :disabled_dependency
57
+ public_constant :DISABLED_DEPENDENCY
58
+ VERSION_MISMATCH = :version_mismatch
59
+ public_constant :VERSION_MISMATCH
60
+ CONFLICT = :conflict
61
+ public_constant :CONFLICT
62
+ CIRCULAR_DEPENDENCY = :circular_dependency
63
+ public_constant :CIRCULAR_DEPENDENCY
64
+
65
+ # Warning types
66
+ MOD_IN_LIST_NOT_INSTALLED = :mod_in_list_not_installed
67
+ public_constant :MOD_IN_LIST_NOT_INSTALLED
68
+ MOD_INSTALLED_NOT_IN_LIST = :mod_installed_not_in_list
69
+ public_constant :MOD_INSTALLED_NOT_IN_LIST
70
+
71
+ def initialize
72
+ @errors = []
73
+ @warnings = []
74
+ @suggestions = []
75
+ end
76
+
77
+ # Add an error
78
+ #
79
+ # @param type [Symbol] Error type
80
+ # @param message [String] Error message
81
+ # @param mod [Factorix::MOD, nil] Related MOD
82
+ # @param dependency [Factorix::MOD, nil] Dependency MOD
83
+ # @return [void]
84
+ def add_error(type:, message:, mod: nil, dependency: nil) = @errors << Error.new(type:, message:, mod:, dependency:)
85
+
86
+ # Add a warning
87
+ #
88
+ # @param type [Symbol] Warning type
89
+ # @param message [String] Warning message
90
+ # @param mod [Factorix::MOD, nil] Related MOD
91
+ # @return [void]
92
+ def add_warning(type:, message:, mod: nil) = @warnings << Warning.new(type:, message:, mod:)
93
+
94
+ # Add a suggestion
95
+ #
96
+ # @param message [String] Suggestion message
97
+ # @param mod [Factorix::MOD] Related MOD
98
+ # @param version [Factorix::MODVersion] Suggested version
99
+ # @return [void]
100
+ def add_suggestion(message:, mod:, version:) = @suggestions << Suggestion.new(message:, mod:, version:)
101
+
102
+ # Get all errors
103
+ #
104
+ # @return [Array<Error>]
105
+ attr_reader :errors
106
+
107
+ # Get all warnings
108
+ #
109
+ # @return [Array<Warning>]
110
+ attr_reader :warnings
111
+
112
+ # Get all suggestions
113
+ #
114
+ # @return [Array<Suggestion>]
115
+ attr_reader :suggestions
116
+
117
+ # Check if there are any errors
118
+ #
119
+ # @return [Boolean]
120
+ def errors? = !@errors.empty?
121
+
122
+ # Check if there are any warnings
123
+ #
124
+ # @return [Boolean]
125
+ def warnings? = !@warnings.empty?
126
+
127
+ # Check if there are any suggestions
128
+ #
129
+ # @return [Boolean]
130
+ def suggestions? = !@suggestions.empty?
131
+
132
+ # Check if validation passed (no errors)
133
+ #
134
+ # @return [Boolean]
135
+ def valid? = !errors?
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ module Dependency
5
+ # Validates MOD dependencies in a graph
6
+ #
7
+ # Performs comprehensive validation of MOD dependencies including:
8
+ # - Required dependencies are installed and enabled
9
+ # - Version requirements are satisfied
10
+ # - No conflicts between enabled MODs
11
+ # - No circular dependencies
12
+ class Validator
13
+ # Initialize validator
14
+ #
15
+ # @param graph [Factorix::Dependency::Graph] The dependency graph
16
+ # @param mod_list [Factorix::MODList] The MOD list
17
+ # @param installed_mods [Array<Factorix::InstalledMOD>] The installed MODs
18
+ def initialize(graph:, mod_list:, installed_mods:)
19
+ @graph = graph
20
+ @mod_list = mod_list
21
+ @installed_mods = installed_mods
22
+ end
23
+
24
+ # Validate the graph
25
+ #
26
+ # @return [Factorix::Dependency::ValidationResult]
27
+ def validate
28
+ result = ValidationResult.new
29
+
30
+ validate_circular_dependencies(result)
31
+ validate_dependencies(result)
32
+ validate_conflicts(result)
33
+ validate_mod_list(result)
34
+
35
+ result
36
+ end
37
+
38
+ private attr_reader :graph, :mod_list, :installed_mods
39
+
40
+ private def validate_circular_dependencies(result)
41
+ return unless graph.cyclic?
42
+
43
+ components = graph.strongly_connected_components
44
+ cycles = components.select {|component| component.size > 1 }
45
+
46
+ cycles.each do |cycle|
47
+ mod_names = cycle.map(&:name).join(" -> ")
48
+ result.add_error(
49
+ type: ValidationResult::CIRCULAR_DEPENDENCY,
50
+ message: "Circular dependency detected: #{mod_names}"
51
+ )
52
+ end
53
+ end
54
+
55
+ # Validate dependencies for all enabled MODs
56
+ private def validate_dependencies(result)
57
+ graph.nodes.each do |node|
58
+ next unless node.enabled?
59
+
60
+ validate_node_dependencies(node, result)
61
+ end
62
+ end
63
+
64
+ # Validate dependencies for a single node
65
+ private def validate_node_dependencies(node, result)
66
+ graph.edges_from(node.mod).each do |edge|
67
+ next unless edge.required?
68
+
69
+ validate_required_dependency(node, edge, result)
70
+ end
71
+ end
72
+
73
+ # Validate a single required dependency
74
+ private def validate_required_dependency(node, edge, result)
75
+ dependency_node = graph.node(edge.to_mod)
76
+
77
+ unless dependency_node
78
+ result.add_error(
79
+ type: ValidationResult::MISSING_DEPENDENCY,
80
+ message: "MOD '#{node.mod}@#{node.version}' requires '#{edge.to_mod}' which is not installed",
81
+ mod: node.mod,
82
+ dependency: edge.to_mod
83
+ )
84
+ return
85
+ end
86
+
87
+ unless dependency_node.enabled?
88
+ result.add_error(
89
+ type: ValidationResult::DISABLED_DEPENDENCY,
90
+ message: "MOD '#{node.mod}@#{node.version}' requires '#{edge.to_mod}' which is not enabled",
91
+ mod: node.mod,
92
+ dependency: edge.to_mod
93
+ )
94
+ return
95
+ end
96
+
97
+ return if edge.satisfied_by?(dependency_node.version)
98
+
99
+ result.add_error(
100
+ type: ValidationResult::VERSION_MISMATCH,
101
+ message: "MOD '#{node.mod}@#{node.version}' requires '#{edge.to_mod}' version " \
102
+ "#{edge.version_requirement}, but version #{dependency_node.version} is installed",
103
+ mod: node.mod,
104
+ dependency: edge.to_mod
105
+ )
106
+
107
+ check_alternative_versions(edge, result)
108
+ end
109
+
110
+ # Validate that no conflicts exist between enabled MODs
111
+ private def validate_conflicts(result)
112
+ graph.nodes.each do |node|
113
+ next unless node.enabled?
114
+
115
+ validate_node_conflicts(node, result)
116
+ end
117
+ end
118
+
119
+ # Validate conflicts for a single node
120
+ private def validate_node_conflicts(node, result)
121
+ graph.edges_from(node.mod).each do |edge|
122
+ next unless edge.incompatible?
123
+
124
+ conflict_node = graph.node(edge.to_mod)
125
+ next unless conflict_node&.enabled?
126
+
127
+ result.add_error(
128
+ type: ValidationResult::CONFLICT,
129
+ message: "MOD '#{node.mod}@#{node.version}' conflicts with '#{edge.to_mod}@#{conflict_node.version}' but both are enabled",
130
+ mod: node.mod,
131
+ dependency: edge.to_mod
132
+ )
133
+ end
134
+ end
135
+
136
+ # Validate MOD list consistency
137
+ private def validate_mod_list(result)
138
+ validate_mods_in_list_not_installed(result)
139
+ validate_mods_installed_not_in_list(result)
140
+ end
141
+
142
+ # Warn about MODs in list but not installed
143
+ private def validate_mods_in_list_not_installed(result)
144
+ mod_list.each_mod do |mod|
145
+ next if graph.node?(mod)
146
+
147
+ result.add_warning(
148
+ type: ValidationResult::MOD_IN_LIST_NOT_INSTALLED,
149
+ message: "MOD '#{mod}' in mod-list.json is not installed",
150
+ mod:
151
+ )
152
+ end
153
+ end
154
+
155
+ # Warn about installed MODs not in list
156
+ private def validate_mods_installed_not_in_list(result)
157
+ graph.nodes.each do |node|
158
+ next if mod_list.exist?(node.mod)
159
+
160
+ result.add_warning(
161
+ type: ValidationResult::MOD_INSTALLED_NOT_IN_LIST,
162
+ message: "MOD '#{node.mod}' is installed but not in mod-list.json",
163
+ mod: node.mod
164
+ )
165
+ end
166
+ end
167
+
168
+ # Check for alternative installed versions that satisfy a requirement
169
+ #
170
+ # @param edge [Factorix::Dependency::Edge] The dependency edge with version requirement
171
+ # @param result [Factorix::Dependency::ValidationResult] The validation result to add suggestions to
172
+ # @return [void]
173
+ private def check_alternative_versions(edge, result)
174
+ return if installed_mods.empty?
175
+
176
+ alternative_versions = installed_mods.select {|im| im.mod == edge.to_mod }
177
+
178
+ alternative_versions.each do |installed_mod|
179
+ next unless edge.satisfied_by?(installed_mod.version)
180
+
181
+ result.add_suggestion(
182
+ message: "MOD '#{edge.to_mod}' version #{installed_mod.version} is installed and would satisfy this requirement",
183
+ mod: edge.to_mod,
184
+ version: installed_mod.version
185
+ )
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ # Base error class for Factorix
5
+ class Error < StandardError; end
6
+
7
+ # =====================================
8
+ # Infrastructure layer errors
9
+ # =====================================
10
+ class InfrastructureError < Error; end
11
+
12
+ # Configuration errors
13
+ class ConfigurationError < InfrastructureError; end
14
+
15
+ # Credential/environment variable errors
16
+ class CredentialError < ConfigurationError; end
17
+
18
+ # Shell/completion errors
19
+ class ShellError < ConfigurationError; end
20
+
21
+ # Directory not found errors
22
+ class DirectoryNotFoundError < ConfigurationError; end
23
+
24
+ # URL related errors
25
+ class URLError < InfrastructureError; end
26
+
27
+ # Platform-specific errors (WSL, etc.)
28
+ class PlatformError < InfrastructureError; end
29
+
30
+ # Unsupported platform errors
31
+ class UnsupportedPlatformError < PlatformError; end
32
+
33
+ # HTTP errors
34
+ class HTTPError < InfrastructureError; end
35
+
36
+ # HTTP client error (4xx) with optional API error details
37
+ class HTTPClientError < HTTPError
38
+ attr_reader :api_error
39
+ attr_reader :api_message
40
+
41
+ def initialize(message=nil, api_error: nil, api_message: nil)
42
+ @api_error = api_error
43
+ @api_message = api_message
44
+ super(message)
45
+ end
46
+ end
47
+
48
+ class HTTPNotFoundError < HTTPClientError; end
49
+ class HTTPServerError < HTTPError; end
50
+
51
+ # Digest verification errors
52
+ class DigestMismatchError < InfrastructureError; end
53
+
54
+ # External command not found
55
+ class CommandNotFoundError < InfrastructureError; end
56
+
57
+ # File format related errors
58
+ class FileFormatError < InfrastructureError; end
59
+
60
+ # Binary format errors
61
+ class BinaryFormatError < FileFormatError; end
62
+ class InvalidLengthError < BinaryFormatError; end
63
+ class UnknownPropertyType < BinaryFormatError; end
64
+ class ExtraDataError < BinaryFormatError; end
65
+
66
+ # MOD settings file errors
67
+ class MODSectionNotFoundError < FileFormatError; end
68
+
69
+ # =====================================
70
+ # Domain layer errors
71
+ # =====================================
72
+ class DomainError < Error; end
73
+
74
+ # MOD errors
75
+ class MODNotFoundError < DomainError; end
76
+ class MODNotOnPortalError < MODNotFoundError; end
77
+ class BundledMODError < DomainError; end
78
+
79
+ # MOD conflict errors
80
+ class MODConflictError < DomainError; end
81
+
82
+ # MOD settings errors
83
+ class MODSettingsError < DomainError; end
84
+
85
+ # Invalid operation errors (e.g., cannot disable base MOD)
86
+ class InvalidOperationError < DomainError; end
87
+
88
+ # CLI argument validation errors
89
+ class InvalidArgumentError < DomainError; end
90
+
91
+ # Version parsing errors
92
+ class VersionParseError < DomainError; end
93
+
94
+ # Dependency parsing errors
95
+ class DependencyParseError < DomainError; end
96
+
97
+ # Dependency graph errors
98
+ class DependencyGraphError < DomainError; end
99
+
100
+ # Dependency errors
101
+ class DependencyError < DomainError; end
102
+ class CircularDependencyError < DependencyError; end
103
+ class DependencyMissingError < DependencyError; end
104
+ class DependencyVersionError < DependencyError; end
105
+ class DependencyViolationError < DependencyError; end
106
+
107
+ # Dependency validation errors
108
+ class ValidationError < DomainError; end
109
+
110
+ # Game state errors
111
+ class GameRunningError < DomainError; end
112
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ # Formatting utilities for human-readable output
5
+ #
6
+ # This module provides methods for formatting sizes and durations
7
+ # in a human-readable format for CLI output.
8
+ #
9
+ # @example
10
+ # include Factorix::Formatting
11
+ # format_size(1536) # => "1.5 KiB"
12
+ # format_duration(3661) # => "1h 1m"
13
+ module Formatting
14
+ # Format size value for display using binary prefixes (IEC)
15
+ #
16
+ # @param size [Integer, nil] size in bytes
17
+ # @return [String] formatted size ("unlimited" if nil)
18
+ def format_size(size)
19
+ return "unlimited" if size.nil?
20
+ return "0 B" if size == 0
21
+
22
+ units = %w[B KiB MiB GiB TiB]
23
+ unit_index = 0
24
+ value = Float(size)
25
+
26
+ while value >= 1024 && unit_index < units.size - 1
27
+ value /= 1024
28
+ unit_index += 1
29
+ end
30
+
31
+ unit_index == 0 ? "#{size} B" : "#{"%.1f" % value} #{units[unit_index]}"
32
+ end
33
+
34
+ # Format duration value for display
35
+ #
36
+ # @param seconds [Integer, Float, nil] duration in seconds
37
+ # @return [String] formatted duration ("-" if nil)
38
+ def format_duration(seconds)
39
+ return "-" if seconds.nil?
40
+
41
+ seconds = Integer(seconds)
42
+ return "#{seconds}s" if seconds < 60
43
+
44
+ minutes = seconds / 60
45
+ return "#{minutes}m" if minutes < 60
46
+
47
+ hours = minutes / 60
48
+ remaining_minutes = minutes % 60
49
+ return "#{hours}h #{remaining_minutes}m" if hours < 24
50
+
51
+ days = hours / 24
52
+ remaining_hours = hours % 24
53
+ "#{days}d #{remaining_hours}h"
54
+ end
55
+ end
56
+ end