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,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ # Mixin for commands that require user confirmation
7
+ #
8
+ # This module provides:
9
+ # - --yes option to skip confirmation prompts
10
+ # - confirm? method to ask for user confirmation
11
+ #
12
+ # Prepend this module to commands that need confirmation
13
+ # (e.g., enable, disable, install, uninstall)
14
+ module Confirmable
15
+ # Hook called when this module is prepended to a class
16
+ # @param base [Class] the class prepending this module
17
+ def self.prepended(base)
18
+ base.class_eval do
19
+ option :yes, type: :flag, default: false, aliases: ["-y"], desc: "Skip confirmation prompts"
20
+ end
21
+ end
22
+
23
+ # Store the --yes flag for use in confirm?
24
+ # @param options [Hash] command options
25
+ def call(**options)
26
+ @yes = options[:yes]
27
+ super
28
+ end
29
+
30
+ # Ask for user confirmation
31
+ #
32
+ # @param message [String] confirmation message to display
33
+ # @return [Boolean] true if user confirms, false otherwise
34
+ # @raise [InvalidOperationError] if in quiet mode without --yes flag
35
+ private def confirm?(message="Do you want to continue?")
36
+ # --yes flag skips confirmation
37
+ return true if @yes
38
+
39
+ # Cannot prompt in quiet mode
40
+ if quiet?
41
+ raise InvalidOperationError, "Cannot prompt for confirmation in quiet mode. Use --yes to proceed automatically."
42
+ end
43
+
44
+ print "#{message} [y/N] "
45
+ response = $stdin.gets&.strip&.downcase
46
+
47
+ # Only explicit y or yes means yes (default is no for safety)
48
+ response == "y" || response == "yes"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/executor/fixed_thread_pool"
4
+ require "concurrent/future"
5
+
6
+ module Factorix
7
+ class CLI
8
+ module Commands
9
+ # Provides common download functionality for MOD commands
10
+ #
11
+ # This module extracts the common download logic used across
12
+ # Download, Install, and Sync commands.
13
+ #
14
+ # @example
15
+ # class Install < Base
16
+ # include DownloadSupport
17
+ #
18
+ # def call(mod_specs:, **options)
19
+ # # ... build targets ...
20
+ # download_mods(targets, jobs)
21
+ # end
22
+ # end
23
+ module DownloadSupport
24
+ # Parse MOD specification into mod and version
25
+ #
26
+ # @param mod_spec [String] MOD specification (name@version or name@latest or name)
27
+ # @return [Hash] {mod:, version:} where version is MODVersion or :latest
28
+ private def parse_mod_spec(mod_spec)
29
+ parts = mod_spec.split("@", 2)
30
+ mod = Factorix::MOD[name: parts[0]]
31
+ version_spec = parts[1]
32
+ version = case version_spec
33
+ when nil, "", "latest" then :latest
34
+ else MODVersion.from_string(version_spec)
35
+ end
36
+ {mod:, version:}
37
+ end
38
+
39
+ # Find the appropriate release for a version
40
+ #
41
+ # @param mod_info [API::MODInfo] MOD information
42
+ # @param version [MODVersion, Symbol] Version or :latest
43
+ # @return [API::Release, nil] The release, or nil if not found
44
+ private def find_release(mod_info, version)
45
+ if version == :latest
46
+ mod_info.releases.max_by(&:released_at)
47
+ else
48
+ mod_info.releases.find {|r| r.version == version }
49
+ end
50
+ end
51
+
52
+ # Find a release compatible with a version requirement
53
+ #
54
+ # @param mod_info [API::MODInfo] MOD information
55
+ # @param version_requirement [Dependency::MODVersionRequirement, nil] Version requirement
56
+ # @return [API::Release, nil] Compatible release or nil
57
+ private def find_compatible_release(mod_info, version_requirement)
58
+ return mod_info.releases.max_by(&:released_at) if version_requirement.nil?
59
+
60
+ compatible_releases = mod_info.releases.select {|r| version_requirement.satisfied_by?(r.version) }
61
+
62
+ compatible_releases.max_by(&:released_at)
63
+ end
64
+
65
+ # Build install targets from MOD infos
66
+ #
67
+ # @param mod_infos [Array<Hash>] MOD infos, each containing:
68
+ # - :mod [Factorix::MOD] or :mod_name [String] MOD identifier
69
+ # - :mod_info [API::MODInfo] MOD information
70
+ # - :release [API::Release] Release to install
71
+ # @param output_dir [Pathname] Output directory for MOD files
72
+ # @return [Array<Hash>] Install targets
73
+ private def build_install_targets(mod_infos, output_dir)
74
+ mod_infos.map {|info|
75
+ {
76
+ mod: info[:mod] || Factorix::MOD[name: info[:mod_name]],
77
+ mod_info: info[:mod_info],
78
+ release: info[:release],
79
+ output_path: output_dir / info[:release].file_name,
80
+ category: info[:mod_info].category
81
+ }
82
+ }
83
+ end
84
+
85
+ # Download MODs in parallel
86
+ #
87
+ # @param targets [Array<Hash>] Download targets, each containing:
88
+ # - :mod [Factorix::MOD] MOD object
89
+ # - :release [API::Release] Release to download
90
+ # - :output_path [Pathname] Output file path
91
+ # @param jobs [Integer] Number of parallel downloads
92
+ # @return [void]
93
+ private def download_mods(targets, jobs)
94
+ multi_presenter = Progress::MultiPresenter.new(title: "\u{1F4E5}\u{FE0E} Downloads")
95
+
96
+ pool = Concurrent::FixedThreadPool.new(jobs)
97
+
98
+ futures = targets.map {|target|
99
+ Concurrent::Future.execute(executor: pool) do
100
+ thread_portal = Application[:portal]
101
+ thread_downloader = thread_portal.mod_download_api.downloader
102
+
103
+ presenter = multi_presenter.register(
104
+ target[:mod].name,
105
+ title: target[:release].file_name
106
+ )
107
+ handler = Progress::DownloadHandler.new(presenter)
108
+
109
+ thread_downloader.subscribe(handler)
110
+ thread_portal.download_mod(target[:release], target[:output_path])
111
+ thread_downloader.unsubscribe(handler)
112
+ end
113
+ }
114
+
115
+ futures.each(&:wait!)
116
+ ensure
117
+ pool&.shutdown
118
+ pool&.wait_for_termination
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ # Launch Factorio game
7
+ #
8
+ # This command launches the Factorio game executable with optional arguments.
9
+ # By default, the game is launched asynchronously (in the background), but certain
10
+ # options like --help and --dump-* are automatically detected and run synchronously.
11
+ class Launch < Base
12
+ require_game_stopped!
13
+ # Game options that require synchronous execution
14
+ #
15
+ # These options output information and exit immediately, so we should
16
+ # wait for them to complete rather than running them in the background.
17
+ SYNCHRONOUS_OPTIONS = %w[
18
+ --dump-data
19
+ --dump-icon-sprites
20
+ --dump-prototype-locale
21
+ --help
22
+ --version
23
+ ].freeze
24
+ private_constant :SYNCHRONOUS_OPTIONS
25
+
26
+ # @!parse
27
+ # # @return [Runtime::Base]
28
+ # attr_reader :runtime
29
+ # # @return [Dry::Logger::Dispatcher]
30
+ # attr_reader :logger
31
+ include Import[:runtime, :logger]
32
+
33
+ desc "Launch Factorio game"
34
+
35
+ example [
36
+ " # Launch Factorio",
37
+ "-- --help # Show Factorio help",
38
+ "-- --benchmark save.zip # Run benchmark"
39
+ ]
40
+
41
+ option :wait, type: :flag, default: false, aliases: ["-w"], desc: "Wait for the game to finish"
42
+
43
+ # Execute the launch command
44
+ #
45
+ # @param wait [Boolean] whether to wait for the game to finish
46
+ # @param args [Array<String>] additional arguments to pass to Factorio
47
+ # @return [void]
48
+ def call(wait: false, args: [], **)
49
+ logger.info("Launching Factorio", args:)
50
+
51
+ async = args.none? {|arg| SYNCHRONOUS_OPTIONS.include?(arg) }
52
+
53
+ runtime.launch(*args, async:)
54
+ logger.info("Factorio launched successfully", async:)
55
+
56
+ return unless async && wait
57
+
58
+ logger.debug("Waiting for game to start")
59
+ wait_while { !runtime.running? }
60
+ logger.debug("Game started, waiting for termination")
61
+ wait_while { runtime.running? }
62
+ logger.info("Game terminated")
63
+ end
64
+
65
+ # Wait while a condition is true
66
+ #
67
+ # @yield the condition to check
68
+ # @return [void]
69
+ private def wait_while
70
+ loop do
71
+ break unless yield
72
+
73
+ sleep 1
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ # Display the Factorix manual page
7
+ #
8
+ # This command opens the man page for factorix using the system's man command.
9
+ #
10
+ # @example
11
+ # $ factorix man
12
+ class Man < Base
13
+ desc "Display the Factorix manual page"
14
+
15
+ # Execute the man command
16
+ #
17
+ # @return [void]
18
+ # @raise [CommandNotFoundError] if man command is not available
19
+ def call(**)
20
+ system("command -v man > /dev/null 2>&1")
21
+ raise CommandNotFoundError, "man command is not available on this system" unless $?.success?
22
+
23
+ man_page = File.expand_path("../../../../doc/factorix.1", __dir__)
24
+ exec "man", man_page
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ module MOD
7
+ # Validate MOD dependencies without making changes
8
+ class Check < Base
9
+ # @!parse
10
+ # # @return [Dry::Logger::Dispatcher]
11
+ # attr_reader :logger
12
+ # # @return [Factorix::Runtime]
13
+ # attr_reader :runtime
14
+ include Import[:logger, :runtime]
15
+
16
+ desc "Validate MOD dependencies"
17
+
18
+ example [
19
+ " # Validate all MOD dependencies"
20
+ ]
21
+
22
+ # Execute the check command
23
+ #
24
+ # @return [void]
25
+ def call(**)
26
+ mod_list = MODList.load
27
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Scanning MOD(s)", output: $stderr)
28
+ handler = Progress::ScanHandler.new(presenter)
29
+ installed_mods = InstalledMOD.all(handler:)
30
+ graph = Dependency::Graph::Builder.build(installed_mods:, mod_list:)
31
+
32
+ validator = Dependency::Validator.new(graph:, mod_list:, installed_mods:)
33
+ result = validator.validate
34
+
35
+ display_result(result, graph)
36
+
37
+ raise ValidationError, "MOD dependency validation failed" unless result.valid?
38
+ end
39
+
40
+ private def display_result(result, graph)
41
+ say "Validating MOD dependencies...", prefix: :info
42
+
43
+ if result.valid? && !result.warnings?
44
+ display_success_messages
45
+ end
46
+
47
+ display_warnings(result) if result.warnings?
48
+ display_errors(result) if result.errors?
49
+ display_suggestions(result) if result.suggestions?
50
+
51
+ display_summary(result, graph)
52
+ end
53
+
54
+ private def display_success_messages
55
+ say "All enabled MOD(s) have their required dependencies satisfied", prefix: :success
56
+ say "No circular dependencies detected", prefix: :success
57
+ say "No conflicting MOD(s) are enabled simultaneously", prefix: :success
58
+ end
59
+
60
+ private def display_warnings(result)
61
+ say "Warnings:", prefix: :warn
62
+ result.warnings.each do |warning|
63
+ say " - #{warning.message}"
64
+ end
65
+ end
66
+
67
+ private def display_errors(result)
68
+ say "Errors:", prefix: :error
69
+ result.errors.each do |error|
70
+ say " - #{error.message}"
71
+ end
72
+ end
73
+
74
+ private def display_suggestions(result)
75
+ say "Suggestions:", prefix: :info
76
+ result.suggestions.each do |suggestion|
77
+ say " - #{suggestion.message}"
78
+ end
79
+ end
80
+
81
+ private def display_summary(result, graph)
82
+ enabled_count = graph.nodes.count(&:enabled?)
83
+ parts = ["#{enabled_count} enabled MOD#{"s" unless enabled_count == 1}"]
84
+
85
+ if result.errors?
86
+ parts << "#{result.errors.size} error#{"s" unless result.errors.size == 1}"
87
+ end
88
+
89
+ if result.warnings?
90
+ parts << "#{result.warnings.size} warning#{"s" unless result.warnings.size == 1}"
91
+ end
92
+
93
+ say "Summary: #{parts.join(", ")}", prefix: :info
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ module MOD
7
+ # Disable MODs in mod-list.json with reverse dependency resolution
8
+ class Disable < Base
9
+ confirmable!
10
+ require_game_stopped!
11
+ backup_support!
12
+
13
+ # @!parse
14
+ # # @return [Dry::Logger::Dispatcher]
15
+ # attr_reader :logger
16
+ # # @return [Factorix::Runtime]
17
+ # attr_reader :runtime
18
+ include Import[:logger, :runtime]
19
+
20
+ desc "Disable MOD(s) in mod-list.json (recursively disables dependent MOD(s))"
21
+
22
+ example [
23
+ "some-mod # Disable single MOD",
24
+ "mod-a mod-b # Disable multiple MOD(s)",
25
+ "--all # Disable all MOD(s) except base"
26
+ ]
27
+
28
+ argument :mod_names, type: :array, required: false, desc: "MOD names to disable"
29
+
30
+ option :all, type: :flag, default: false, desc: "Disable all MOD(s) (except base)"
31
+
32
+ # Execute the disable command
33
+ #
34
+ # @param mod_names [Array<String>] MOD names to disable
35
+ # @param all [Boolean] Whether to disable all MODs
36
+ # @return [void]
37
+ def call(mod_names: [], all: false, **)
38
+ validate_arguments(mod_names, all)
39
+
40
+ # Without validation to allow fixing issues
41
+ mod_list = MODList.load
42
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Scanning MOD(s)", output: $stderr)
43
+ handler = Progress::ScanHandler.new(presenter)
44
+ installed_mods = InstalledMOD.all(handler:)
45
+ graph = Dependency::Graph::Builder.build(installed_mods:, mod_list:)
46
+
47
+ target_mods = if all
48
+ plan_disable_all(graph)
49
+ else
50
+ mod_names.map {|name| Factorix::MOD[name:] }
51
+ end
52
+
53
+ validate_target_mods(target_mods, graph)
54
+ mods_to_disable = plan_with_dependents(target_mods, graph)
55
+
56
+ show_plan(mods_to_disable)
57
+ return if mods_to_disable.empty?
58
+ return unless confirm?("Do you want to disable these MOD(s)?")
59
+
60
+ execute_plan(mods_to_disable, mod_list)
61
+ backup_if_exists(runtime.mod_list_path)
62
+ mod_list.save
63
+ say "Disabled #{mods_to_disable.size} MOD(s)", prefix: :success
64
+ say "Saved mod-list.json", prefix: :success
65
+ logger.debug("Saved mod-list.json")
66
+ end
67
+
68
+ # Validate command arguments
69
+ #
70
+ # @param mod_names [Array<String>] MOD names from argument
71
+ # @param all [Boolean] Whether --all option is specified
72
+ # @return [void]
73
+ # @raise [InvalidArgumentError] if arguments are invalid
74
+ private def validate_arguments(mod_names, all)
75
+ if all && mod_names.any?
76
+ raise InvalidArgumentError, "Cannot specify MOD names with --all option"
77
+ end
78
+
79
+ return if all || mod_names.any?
80
+
81
+ raise InvalidArgumentError, "Must specify MOD names or use --all option"
82
+ end
83
+
84
+ # Plan which MODs to disable when --all is specified
85
+ #
86
+ # @param graph [Factorix::Dependency::Graph] Dependency graph
87
+ # @return [Array<Factorix::MOD>] MODs to disable (all except base)
88
+ private def plan_disable_all(graph)
89
+ graph.nodes.filter_map do |node|
90
+ mod = node.mod
91
+ next if mod.base?
92
+ next unless node.enabled?
93
+
94
+ mod
95
+ end
96
+ end
97
+
98
+ # Validate that all target MODs can be disabled
99
+ #
100
+ # @param target_mods [Array<Factorix::MOD>] MODs to validate
101
+ # @param graph [Factorix::Dependency::Graph] Dependency graph
102
+ # @return [void]
103
+ # @raise [InvalidOperationError] if any MOD cannot be disabled
104
+ private def validate_target_mods(target_mods, graph)
105
+ target_mods.each do |mod|
106
+ raise InvalidOperationError, "Cannot disable base MOD" if mod.base?
107
+
108
+ unless graph.node?(mod)
109
+ say "MOD not installed, skipping: #{mod}", prefix: :warn
110
+ logger.debug("MOD not installed", mod_name: mod.name)
111
+ end
112
+ end
113
+ end
114
+
115
+ # Plan disable with automatic dependent resolution
116
+ #
117
+ # Finds all enabled MODs that depend on the target MODs recursively.
118
+ #
119
+ # @param target_mods [Array<Factorix::MOD>] MODs to disable
120
+ # @param graph [Factorix::Dependency::Graph] Dependency graph
121
+ # @return [Array<Factorix::MOD>] MODs to disable (including dependents)
122
+ private def plan_with_dependents(target_mods, graph)
123
+ mods_to_disable = Set.new
124
+ to_process = target_mods.dup
125
+
126
+ while (mod = to_process.shift)
127
+ node = graph.node(mod)
128
+
129
+ unless node
130
+ logger.debug("MOD not installed", mod_name: mod.name)
131
+ next
132
+ end
133
+
134
+ unless node.enabled?
135
+ logger.debug("MOD already disabled", mod_name: mod.name)
136
+ next
137
+ end
138
+
139
+ next if mods_to_disable.include?(mod)
140
+
141
+ dependents = graph.find_enabled_dependents(mod)
142
+
143
+ dependents.each do |dependent_mod|
144
+ logger.debug("Found dependent MOD", dependent: dependent_mod.name, dependency: mod.name)
145
+ to_process << dependent_mod unless mods_to_disable.include?(dependent_mod)
146
+ end
147
+
148
+ mods_to_disable.add(mod)
149
+ end
150
+
151
+ mods_to_disable.to_a
152
+ end
153
+
154
+ # Show the disable plan to user
155
+ #
156
+ # @param mods_to_disable [Array<Factorix::MOD>] MODs to disable
157
+ # @return [void]
158
+ private def show_plan(mods_to_disable)
159
+ if mods_to_disable.empty?
160
+ say "All specified MOD(s) are already disabled", prefix: :info
161
+ return
162
+ end
163
+
164
+ say "Planning to disable #{mods_to_disable.size} MOD(s):", prefix: :info
165
+ mods_to_disable.each do |mod|
166
+ say " - #{mod}"
167
+ end
168
+ end
169
+
170
+ # Execute the disable plan
171
+ #
172
+ # @param mods_to_disable [Array<Factorix::MOD>] MODs to disable
173
+ # @param mod_list [Factorix::MODList] MOD list to modify
174
+ # @return [void]
175
+ private def execute_plan(mods_to_disable, mod_list)
176
+ return if mods_to_disable.empty?
177
+
178
+ mods_to_disable.each do |mod|
179
+ mod_list.disable(mod)
180
+ say "Disabled #{mod}", prefix: :success
181
+ logger.debug("Disabled MOD", mod_name: mod.name)
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end