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,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ module API
5
+ Tag = Data.define(:value, :name, :description)
6
+
7
+ # Tag object from MOD Portal API
8
+ #
9
+ # Represents a MOD tag with value, name, and description.
10
+ # Uses flyweight pattern - same tag value returns same instance.
11
+ #
12
+ # @see https://wiki.factorio.com/Mod_portal_API#Tags
13
+ class Tag
14
+ # @!attribute [r] value
15
+ # @return [String] tag value (e.g., "transportation", "logistics")
16
+ # @!attribute [r] name
17
+ # @return [String] human-readable tag name (e.g., "Transportation", "Logistics")
18
+ # @!attribute [r] description
19
+ # @return [String] tag description
20
+
21
+ # Predefined tag instances
22
+ TRANSPORTATION = new(value: "transportation", name: "Transportation", description: "Transportation of the player, be it vehicles or teleporters.")
23
+ private_constant :TRANSPORTATION
24
+ LOGISTICS = new(value: "logistics", name: "Logistics", description: "Augmented or new ways of transporting materials - belts, inserters, pipes!")
25
+ private_constant :LOGISTICS
26
+ TRAINS = new(value: "trains", name: "Trains", description: "Trains are great, but what if they could do even more?")
27
+ private_constant :TRAINS
28
+ COMBAT = new(value: "combat", name: "Combat", description: "New ways to deal with enemies, be it attack or defense.")
29
+ private_constant :COMBAT
30
+ ARMOR = new(value: "armor", name: "Armor", description: "Armors or armor equipment.")
31
+ private_constant :ARMOR
32
+ ENEMIES = new(value: "enemies", name: "Enemies", description: "Changes to enemies or entirely new enemies to deal with.")
33
+ private_constant :ENEMIES
34
+ CHARACTER = new(value: "character", name: "Character", description: "Changes to the player's in-game appearance.")
35
+ private_constant :CHARACTER
36
+ ENVIRONMENT = new(value: "environment", name: "Environment", description: "Map generation and terrain modification.")
37
+ private_constant :ENVIRONMENT
38
+ PLANETS = new(value: "planets", name: "Planets", description: "New places to build more factories.")
39
+ private_constant :PLANETS
40
+ MINING = new(value: "mining", name: "Mining", description: "New ores and resources as well as machines.")
41
+ private_constant :MINING
42
+ FLUIDS = new(value: "fluids", name: "Fluids", description: "Things related to oil and other fluids.")
43
+ private_constant :FLUIDS
44
+ LOGISTIC_NETWORK = new(value: "logistic-network", name: "Logistics Network", description: "Related to roboports and logistic robots")
45
+ private_constant :LOGISTIC_NETWORK
46
+ CIRCUIT_NETWORK = new(value: "circuit-network", name: "Circuit network", description: "Entities which interact with the circuit network.")
47
+ private_constant :CIRCUIT_NETWORK
48
+ MANUFACTURING = new(value: "manufacturing", name: "Manufacture", description: "Furnaces, assembling machines, production chains")
49
+ private_constant :MANUFACTURING
50
+ POWER = new(value: "power", name: "Power Production", description: "Changes to power production and distribution.")
51
+ private_constant :POWER
52
+ STORAGE = new(value: "storage", name: "Storage", description: "More than just chests.")
53
+ private_constant :STORAGE
54
+ BLUEPRINTS = new(value: "blueprints", name: "Blueprints", description: "Change blueprint behavior.")
55
+ private_constant :BLUEPRINTS
56
+ CHEATS = new(value: "cheats", name: "Cheats", description: "Play it your way.")
57
+ private_constant :CHEATS
58
+
59
+ # Lookup table for flyweight pattern
60
+ TAGS = {
61
+ "transportation" => TRANSPORTATION,
62
+ "logistics" => LOGISTICS,
63
+ "trains" => TRAINS,
64
+ "combat" => COMBAT,
65
+ "armor" => ARMOR,
66
+ "enemies" => ENEMIES,
67
+ "character" => CHARACTER,
68
+ "environment" => ENVIRONMENT,
69
+ "planets" => PLANETS,
70
+ "mining" => MINING,
71
+ "fluids" => FLUIDS,
72
+ "logistic-network" => LOGISTIC_NETWORK,
73
+ "circuit-network" => CIRCUIT_NETWORK,
74
+ "manufacturing" => MANUFACTURING,
75
+ "power" => POWER,
76
+ "storage" => STORAGE,
77
+ "blueprints" => BLUEPRINTS,
78
+ "cheats" => CHEATS
79
+ }.freeze
80
+ private_constant :TAGS
81
+
82
+ # Get Tag instance for the given value
83
+ #
84
+ # Returns predefined instance for known tags (flyweight pattern).
85
+ # Raises an error for unknown tag values.
86
+ #
87
+ # @param value [String] tag value
88
+ # @return [Tag] Tag instance
89
+ # @raise [KeyError] if tag value is unknown
90
+ def self.for(value) = TAGS.fetch(value.to_s)
91
+
92
+ private_class_method :new, :[]
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ # API layer for Factorio MOD Portal
5
+ module API
6
+ end
7
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ APICredential = Data.define(:api_key)
5
+
6
+ # API credentials for Factorio MOD Portal management
7
+ #
8
+ # @see https://wiki.factorio.com/Mod_upload_API
9
+ # @see https://wiki.factorio.com/Mod_publish_API
10
+ # @see https://wiki.factorio.com/Mod_details_API
11
+ # @see https://wiki.factorio.com/Mod_images_API
12
+ class APICredential
13
+ # @!attribute [r] api_key
14
+ # @return [String] API key for Factorio MOD Portal
15
+
16
+ # Environment variable name for API key
17
+ ENV_API_KEY = "FACTORIO_API_KEY"
18
+ private_constant :ENV_API_KEY
19
+
20
+ # Load API credentials from environment variables
21
+ #
22
+ # @return [APICredential] new instance with API key from environment
23
+ # @raise [CredentialError] if API key is not set in environment
24
+ def self.load
25
+ logger = Application["logger"]
26
+ logger.debug "Loading API credentials from environment"
27
+
28
+ api_key = ENV.fetch(ENV_API_KEY, nil)
29
+ if api_key.nil?
30
+ logger.error("Failed to load API credentials", reason: "#{ENV_API_KEY} not set")
31
+ raise CredentialError, "#{ENV_API_KEY} environment variable is not set"
32
+ end
33
+
34
+ if api_key.empty?
35
+ logger.error("Failed to load API credentials", reason: "#{ENV_API_KEY} is empty")
36
+ raise CredentialError, "#{ENV_API_KEY} environment variable is empty"
37
+ end
38
+
39
+ logger.info("API credentials loaded successfully")
40
+ new(api_key:)
41
+ end
42
+
43
+ private_class_method :new, :[]
44
+
45
+ # @return [String] string representation with masked API key
46
+ def inspect = %[#<#{self.class} api_key="*****">]
47
+
48
+ alias to_s inspect
49
+
50
+ # @param pp [PP] pretty printer
51
+ # @return [void]
52
+ def pretty_print(pp) = pp.text(inspect)
53
+ end
54
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/configurable"
4
+ require "dry/core"
5
+ require "dry/logger"
6
+
7
+ module Factorix
8
+ # Application container and configuration
9
+ #
10
+ # Provides dependency injection container and configuration management
11
+ # using dry-core's Container and dry-configurable.
12
+ #
13
+ # @example Configure the application
14
+ # Factorix::Application.configure do |config|
15
+ # config.log_level = :debug
16
+ # config.http.connect_timeout = 10
17
+ # end
18
+ #
19
+ # @example Resolve dependencies
20
+ # runtime = Factorix::Application[:runtime]
21
+ class Application
22
+ extend Dry::Core::Container::Mixin
23
+ extend Dry::Configurable
24
+
25
+ # Some items are registered with memoize: false to support independent event handlers
26
+ # for each parallel download task (e.g., progress tracking).
27
+ # Items registered with memoize: false:
28
+ # - :downloader (event handlers for progress tracking)
29
+ # - :mod_download_api (contains :downloader)
30
+ # - :portal (contains :mod_download_api)
31
+
32
+ # Register runtime detector
33
+ register(:runtime, memoize: true) do
34
+ Runtime.detect
35
+ end
36
+
37
+ # Register logger
38
+ register(:logger, memoize: true) do
39
+ runtime = resolve(:runtime)
40
+ log_path = runtime.factorix_log_path
41
+
42
+ # Ensure log directory exists
43
+ log_path.dirname.mkpath unless log_path.dirname.exist?
44
+
45
+ # Create logger with file backend
46
+ # Dispatcher level set to DEBUG to allow all messages through
47
+ # Backend controls filtering based on --log-level option
48
+ Dry.Logger(:factorix, level: :debug) do |dispatcher|
49
+ dispatcher.add_backend(level: config.log_level, stream: log_path.to_s, template: "[%<time>s] %<severity>s: %<message>s %<payload>s")
50
+ end
51
+ end
52
+
53
+ # Register retry strategy for network operations
54
+ register(:retry_strategy, memoize: true) do
55
+ HTTP::RetryStrategy.new
56
+ end
57
+
58
+ # Register download cache
59
+ register(:download_cache, memoize: true) do
60
+ c = config.cache.download
61
+ Cache::FileSystem.new(c.dir, **c.to_h.except(:dir))
62
+ end
63
+
64
+ # Register API cache (with compression for JSON responses)
65
+ register(:api_cache, memoize: true) do
66
+ c = config.cache.api
67
+ Cache::FileSystem.new(c.dir, **c.to_h.except(:dir))
68
+ end
69
+
70
+ # Register info.json cache (for MOD metadata from ZIP files)
71
+ register(:info_json_cache, memoize: true) do
72
+ c = config.cache.info_json
73
+ Cache::FileSystem.new(c.dir, **c.to_h.except(:dir))
74
+ end
75
+
76
+ # Register base HTTP client
77
+ register(:http_client, memoize: true) do
78
+ HTTP::Client.new(masked_params: %w[username token secure])
79
+ end
80
+
81
+ # Register decorated HTTP client for downloads (with retry only)
82
+ # Note: Caching is handled by Downloader, not at HTTP client level
83
+ register(:download_http_client, memoize: true) do
84
+ client = resolve(:http_client)
85
+ retry_strategy = resolve(:retry_strategy)
86
+
87
+ # Decorate: Client -> Retry (no cache, handled by Downloader)
88
+ HTTP::RetryDecorator.new(client:, retry_strategy:)
89
+ end
90
+
91
+ # Register decorated HTTP client for API calls (with retry + cache)
92
+ register(:api_http_client, memoize: true) do
93
+ client = resolve(:http_client)
94
+ api_cache = resolve(:api_cache)
95
+ retry_strategy = resolve(:retry_strategy)
96
+
97
+ # Decorate: Client -> Cache -> Retry
98
+ cached = HTTP::CacheDecorator.new(client:, cache: api_cache)
99
+ HTTP::RetryDecorator.new(client: cached, retry_strategy:)
100
+ end
101
+
102
+ # Register decorated HTTP client for uploads (with retry only, no cache)
103
+ register(:upload_http_client, memoize: true) do
104
+ client = resolve(:http_client)
105
+ retry_strategy = resolve(:retry_strategy)
106
+
107
+ # Decorate: Client -> Retry (no cache for uploads)
108
+ HTTP::RetryDecorator.new(client:, retry_strategy:)
109
+ end
110
+
111
+ # Register downloader
112
+ register(:downloader, memoize: false) do
113
+ Transfer::Downloader.new
114
+ end
115
+
116
+ # Register uploader
117
+ register(:uploader, memoize: true) do
118
+ Transfer::Uploader.new
119
+ end
120
+
121
+ # Register service credential
122
+ register(:service_credential, memoize: true) { ServiceCredential.load }
123
+
124
+ # Register MOD Portal API client
125
+ register(:mod_portal_api, memoize: true) do
126
+ API::MODPortalAPI.new
127
+ end
128
+
129
+ # Register MOD Download API client
130
+ register(:mod_download_api, memoize: false) do
131
+ API::MODDownloadAPI.new
132
+ end
133
+
134
+ # Register API credential (for MOD upload/management)
135
+ register(:api_credential, memoize: true) { APICredential.load }
136
+
137
+ # Register MOD Management API client
138
+ register(:mod_management_api, memoize: true) do
139
+ api = API::MODManagementAPI.new
140
+ # Subscribe mod_portal_api to invalidate cache when MOD is changed on portal
141
+ api.subscribe(resolve(:mod_portal_api))
142
+ api
143
+ end
144
+
145
+ # Register portal (high-level API wrapper)
146
+ register(:portal, memoize: false) do
147
+ Portal.new
148
+ end
149
+
150
+ # Log level (:debug, :info, :warn, :error, :fatal)
151
+ setting :log_level, default: :info
152
+
153
+ # Runtime settings (optional overrides for auto-detection)
154
+ setting :runtime do
155
+ setting :executable_path, constructor: ->(v) { v ? Pathname(v) : nil }
156
+ setting :user_dir, constructor: ->(v) { v ? Pathname(v) : nil }
157
+ setting :data_dir, constructor: ->(v) { v ? Pathname(v) : nil }
158
+ end
159
+
160
+ # HTTP timeout settings
161
+ setting :http do
162
+ setting :connect_timeout, default: 5
163
+ setting :read_timeout, default: 30
164
+ setting :write_timeout, default: 30
165
+ end
166
+
167
+ # Cache settings
168
+ setting :cache do
169
+ # Download cache settings (for MOD files)
170
+ setting :download do
171
+ setting :dir, constructor: ->(value) { Pathname(value) }
172
+ setting :ttl, default: nil # nil for unlimited (MOD files are immutable)
173
+ setting :max_file_size, default: nil # nil for unlimited
174
+ setting :compression_threshold, default: nil # nil for no compression (binary files)
175
+ end
176
+
177
+ # API cache settings (for API responses)
178
+ setting :api do
179
+ setting :dir, constructor: ->(value) { Pathname(value) }
180
+ setting :ttl, default: 3600 # 1 hour (API responses may change)
181
+ setting :max_file_size, default: 10 * 1024 * 1024 # 10MiB (JSON responses)
182
+ setting :compression_threshold, default: 0 # always compress (JSON is highly compressible)
183
+ end
184
+
185
+ # info.json cache settings (for MOD metadata from ZIP files)
186
+ setting :info_json do
187
+ setting :dir, constructor: ->(value) { Pathname(value) }
188
+ setting :ttl, default: nil # nil for unlimited (info.json is immutable within a MOD ZIP)
189
+ setting :max_file_size, default: nil # nil for unlimited (info.json is small)
190
+ setting :compression_threshold, default: 0 # always compress (JSON is highly compressible)
191
+ end
192
+ end
193
+
194
+ # Load configuration from file
195
+ #
196
+ # @param path [Pathname, String, nil] configuration file path
197
+ # @return [void]
198
+ # @raise [ConfigurationError] if explicitly specified path does not exist
199
+ def self.load_config(path=nil)
200
+ if path
201
+ # Explicitly specified path must exist
202
+ config_path = Pathname(path)
203
+ raise ConfigurationError, "Configuration file not found: #{config_path}" unless config_path.exist?
204
+ else
205
+ # Default path is optional
206
+ config_path = resolve(:runtime).factorix_config_path
207
+ return unless config_path.exist?
208
+ end
209
+
210
+ instance_eval(config_path.read, config_path.to_s)
211
+ end
212
+
213
+ runtime = resolve(:runtime)
214
+ config.cache.download.dir = runtime.factorix_cache_dir / "download"
215
+ config.cache.api.dir = runtime.factorix_cache_dir / "api"
216
+ config.cache.info_json.dir = runtime.factorix_cache_dir / "info_json"
217
+ end
218
+ end
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "pathname"
6
+ require "zlib"
7
+
8
+ module Factorix
9
+ module Cache
10
+ # File system based cache storage implementation.
11
+ #
12
+ # Uses a two-level directory structure to store cached files,
13
+ # with file locking to handle concurrent access and TTL support
14
+ # for cache expiration.
15
+ class FileSystem
16
+ # @!parse
17
+ # # @return [Dry::Logger::Dispatcher]
18
+ # attr_reader :logger
19
+ include Import[:logger]
20
+
21
+ # Maximum lifetime of lock files in seconds.
22
+ # Lock files older than this will be considered stale and removed
23
+ LOCK_FILE_LIFETIME = 3600 # 1 hour in seconds
24
+ public_constant :LOCK_FILE_LIFETIME
25
+
26
+ # zlib CMF byte indicating DEFLATE compression with default window size.
27
+ # Used to detect if cached data is zlib-compressed
28
+ ZLIB_CMF_BYTE = 0x78
29
+ private_constant :ZLIB_CMF_BYTE
30
+
31
+ # Initialize a new file system cache storage.
32
+ # Creates the cache directory if it doesn't exist
33
+ #
34
+ # @param cache_dir [Pathname] path to the cache directory
35
+ # @param ttl [Integer, nil] time-to-live in seconds (nil for unlimited)
36
+ # @param max_file_size [Integer, nil] maximum file size in bytes (nil for unlimited)
37
+ # @param compression_threshold [Integer, nil] compress data larger than this size in bytes
38
+ # (nil: no compression, 0: always compress, N: compress if >= N bytes)
39
+ def initialize(cache_dir, ttl: nil, max_file_size: nil, compression_threshold: nil, logger: nil)
40
+ super(logger:)
41
+ @cache_dir = cache_dir
42
+ @ttl = ttl
43
+ @max_file_size = max_file_size
44
+ @compression_threshold = compression_threshold
45
+ @cache_dir.mkpath
46
+ logger.info("Initializing cache", dir: @cache_dir.to_s, ttl: @ttl, max_size: @max_file_size, compression_threshold: @compression_threshold)
47
+ end
48
+
49
+ # Generate a cache key for the given URL string.
50
+ # Uses SHA1 to create a unique, deterministic key
51
+ #
52
+ # @param url_string [String] URL string to generate key for
53
+ # @return [String] cache key
54
+ # Use Digest(:SHA1) instead of Digest::SHA1 for thread-safety (Ruby 2.2+)
55
+ def key_for(url_string) = Digest(:SHA1).hexdigest(url_string)
56
+
57
+ # Check if a cache entry exists and is not expired.
58
+ # A cache entry is considered to exist if its file exists and is not expired
59
+ #
60
+ # @param key [String] cache key to check
61
+ # @return [Boolean] true if the cache entry exists and is valid, false otherwise
62
+ def exist?(key)
63
+ return false unless cache_path_for(key).exist?
64
+ return true if @ttl.nil?
65
+
66
+ !expired?(key)
67
+ end
68
+
69
+ # Fetch a cached file and copy it to the output path.
70
+ # If the cache entry doesn't exist or is expired, returns false without modifying the output path.
71
+ # Automatically decompresses zlib-compressed cache entries.
72
+ #
73
+ # @param key [String] cache key to fetch
74
+ # @param output [Pathname] path to copy the cached file to
75
+ # @return [Boolean] true if the cache entry was found and copied, false otherwise
76
+ def fetch(key, output)
77
+ path = cache_path_for(key)
78
+ unless path.exist?
79
+ logger.debug("Cache miss", key:)
80
+ return false
81
+ end
82
+
83
+ if expired?(key)
84
+ logger.debug("Cache expired", key:, age_seconds: age(key))
85
+ return false
86
+ end
87
+
88
+ data = path.binread
89
+ if zlib_compressed?(data)
90
+ data = Zlib.inflate(data)
91
+ output.binwrite(data)
92
+ else
93
+ FileUtils.cp(path, output)
94
+ end
95
+ logger.debug("Cache hit", key:)
96
+ true
97
+ end
98
+
99
+ # Read a cached file as a string.
100
+ # If the cache entry doesn't exist or is expired, returns nil.
101
+ # Automatically decompresses zlib-compressed cache entries.
102
+ #
103
+ # @param key [String] cache key to read
104
+ # @param encoding [Encoding, String] encoding to use (default: ASCII-8BIT for binary)
105
+ # @return [String, nil] cached content or nil if not found/expired
106
+ def read(key, encoding: Encoding::ASCII_8BIT)
107
+ path = cache_path_for(key)
108
+ return nil unless path.exist?
109
+ return nil if expired?(key)
110
+
111
+ data = path.binread
112
+ data = Zlib.inflate(data) if zlib_compressed?(data)
113
+ data.force_encoding(encoding)
114
+ end
115
+
116
+ # Store a file in the cache.
117
+ # Creates necessary subdirectories and stores the file in the cache.
118
+ # Optionally compresses data based on compression_threshold setting.
119
+ # If the (possibly compressed) size exceeds max_file_size, skips caching and returns false.
120
+ #
121
+ # @param key [String] cache key to store under
122
+ # @param src [Pathname] path of the file to store
123
+ # @return [Boolean] true if cached successfully, false if skipped due to size limit
124
+ def store(key, src)
125
+ data = src.binread
126
+ original_size = data.bytesize
127
+
128
+ if should_compress?(original_size)
129
+ data = Zlib.deflate(data)
130
+ logger.debug("Compressed data", original_size:, compressed_size: data.bytesize)
131
+ end
132
+
133
+ if @max_file_size && data.bytesize > @max_file_size
134
+ logger.warn("File size exceeds cache limit, skipping", size_bytes: data.bytesize, limit_bytes: @max_file_size)
135
+ return false
136
+ end
137
+
138
+ path = cache_path_for(key)
139
+ path.dirname.mkpath
140
+ path.binwrite(data)
141
+ logger.debug("Stored in cache", key:, size_bytes: data.bytesize)
142
+ true
143
+ end
144
+
145
+ # Delete a specific cache entry.
146
+ #
147
+ # @param key [String] cache key to delete
148
+ # @return [Boolean] true if the entry was deleted, false if it didn't exist
149
+ def delete(key)
150
+ path = cache_path_for(key)
151
+ return false unless path.exist?
152
+
153
+ path.delete
154
+ logger.debug("Deleted from cache", key:)
155
+ true
156
+ end
157
+
158
+ # Clear all cache entries.
159
+ # Removes all files in the cache directory.
160
+ #
161
+ # @return [void]
162
+ def clear
163
+ logger.info("Clearing cache directory", dir: @cache_dir.to_s)
164
+ count = 0
165
+ @cache_dir.glob("**/*").each do |path|
166
+ if path.file?
167
+ path.delete
168
+ count += 1
169
+ end
170
+ end
171
+ logger.info("Cache cleared", files_removed: count)
172
+ end
173
+
174
+ # Get the age of a cache entry in seconds.
175
+ # Returns nil if the entry doesn't exist.
176
+ #
177
+ # @param key [String] cache key
178
+ # @return [Float, nil] age in seconds, or nil if entry doesn't exist
179
+ def age(key)
180
+ path = cache_path_for(key)
181
+ return nil unless path.exist?
182
+
183
+ Time.now - path.mtime
184
+ end
185
+
186
+ # Check if a cache entry has expired based on TTL.
187
+ # Returns false if TTL is not set (unlimited) or if entry doesn't exist.
188
+ #
189
+ # @param key [String] cache key
190
+ # @return [Boolean] true if expired, false otherwise
191
+ def expired?(key)
192
+ return false if @ttl.nil?
193
+
194
+ age_seconds = age(key)
195
+ return false if age_seconds.nil?
196
+
197
+ age_seconds > @ttl
198
+ end
199
+
200
+ # Get the size of a cached file in bytes.
201
+ # Returns nil if the entry doesn't exist or is expired.
202
+ #
203
+ # @param key [String] cache key
204
+ # @return [Integer, nil] file size in bytes, or nil if entry doesn't exist/expired
205
+ def size(key)
206
+ path = cache_path_for(key)
207
+ return nil unless path.exist?
208
+ return nil if expired?(key)
209
+
210
+ path.size
211
+ end
212
+
213
+ # Executes the given block with a file lock.
214
+ # Uses flock for process-safe file locking and automatically removes stale locks
215
+ #
216
+ # @param key [String] cache key to lock
217
+ # @yield Executes the block with exclusive file lock
218
+ # @return [void]
219
+ def with_lock(key)
220
+ lock_path = lock_path_for(key)
221
+ cleanup_stale_lock(lock_path)
222
+
223
+ lock_path.dirname.mkpath
224
+ lock_path.open(File::RDWR | File::CREAT) do |lock|
225
+ if lock.flock(File::LOCK_EX)
226
+ logger.debug("Acquired lock", key:)
227
+ begin
228
+ yield
229
+ ensure
230
+ lock.flock(File::LOCK_UN)
231
+ logger.debug("Released lock", key:)
232
+ begin
233
+ lock_path.unlink
234
+ rescue => e
235
+ logger.debug("Failed to remove lock file", path: lock_path.to_s, error: e.message)
236
+ nil
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
242
+
243
+ # Get the cache file path for the given key.
244
+ # Uses a two-level directory structure to avoid too many files in one directory
245
+ #
246
+ # @param key [String] cache key
247
+ # @return [Pathname] path to the cache file
248
+ private def cache_path_for(key)
249
+ prefix = key[0, 2]
250
+ @cache_dir.join(prefix, key[2..])
251
+ end
252
+
253
+ # Get the lock file path for the given key.
254
+ # Lock files are stored alongside cache files with a .lock extension
255
+ #
256
+ # @param key [String] cache key
257
+ # @return [Pathname] path to the lock file
258
+ private def lock_path_for(key)
259
+ cache_path_for(key).sub_ext(".lock")
260
+ end
261
+
262
+ # Check if data should be compressed based on compression_threshold setting.
263
+ #
264
+ # @param size [Integer] data size in bytes
265
+ # @return [Boolean] true if data should be compressed
266
+ private def should_compress?(size)
267
+ return false if @compression_threshold.nil?
268
+
269
+ size >= @compression_threshold
270
+ end
271
+
272
+ # Check if data is zlib-compressed by examining the CMF and FLG bytes.
273
+ # zlib header consists of CMF (byte 0) and FLG (byte 1) where
274
+ # (CMF * 256 + FLG) % 31 must equal 0.
275
+ #
276
+ # @param data [String] binary data to check
277
+ # @return [Boolean] true if data appears to be zlib-compressed
278
+ private def zlib_compressed?(data)
279
+ return false if data.bytesize < 2
280
+
281
+ cmf = data.getbyte(0)
282
+ flg = data.getbyte(1)
283
+ cmf == ZLIB_CMF_BYTE && ((cmf << 8) | flg) % 31 == 0
284
+ end
285
+
286
+ # Remove lock file if it exists and is older than LOCK_FILE_LIFETIME.
287
+ # This prevents orphaned locks from blocking the cache indefinitely
288
+ #
289
+ # @param lock_path [Pathname] path to the lock file
290
+ # @return [void]
291
+ private def cleanup_stale_lock(lock_path)
292
+ return unless lock_path.exist?
293
+
294
+ age = Time.now - lock_path.mtime
295
+ return if age <= LOCK_FILE_LIFETIME
296
+
297
+ begin
298
+ lock_path.unlink
299
+ logger.warn("Removed stale lock", path: lock_path.to_s, age_seconds: age)
300
+ rescue => e
301
+ logger.debug("Failed to remove stale lock", path: lock_path.to_s, error: e.message)
302
+ nil
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end