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,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ # Mixin for commands that backup files before writing
7
+ #
8
+ # This module provides:
9
+ # - --backup-extension option to specify backup file extension
10
+ # - backup_if_exists method to backup a file before overwriting
11
+ #
12
+ # Prepend this module to commands that modify mod-list.json or mod-settings.dat
13
+ module BackupSupport
14
+ # Hook called when this module is prepended to a class
15
+ # @param base [Class] the class prepending this module
16
+ def self.prepended(base)
17
+ base.class_eval do
18
+ option :backup_extension, type: :string, default: ".bak", desc: "Backup file extension"
19
+ end
20
+ end
21
+
22
+ # Default backup extension
23
+ DEFAULT_BACKUP_EXTENSION = ".bak"
24
+ private_constant :DEFAULT_BACKUP_EXTENSION
25
+
26
+ # Store the --backup-extension option for use in backup_if_exists
27
+ # @param options [Hash] command options
28
+ def call(**options)
29
+ @backup_extension = options[:backup_extension] || DEFAULT_BACKUP_EXTENSION
30
+ super
31
+ end
32
+
33
+ # Backup existing file if it exists
34
+ #
35
+ # @param path [Pathname] File path to backup
36
+ # @return [void]
37
+ private def backup_if_exists(path)
38
+ return unless path.exist?
39
+
40
+ backup_path = Pathname("#{path}#{@backup_extension}")
41
+ path.rename(backup_path)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require "tint_me"
5
+
6
+ module Factorix
7
+ class CLI
8
+ module Commands
9
+ # Base class for all CLI commands
10
+ #
11
+ # This class provides common functionality for all commands:
12
+ # - Common options (--config-path, --log-level, --quiet)
13
+ # - Common helper methods (say, quiet?)
14
+ # - Pre-call setup and error handling (via CommandWrapper prepended module)
15
+ #
16
+ # All command classes should inherit from this base class instead of
17
+ # directly from Dry::CLI::Command.
18
+ #
19
+ # @example Define a command
20
+ # class MyCommand < Base
21
+ # desc "My command description"
22
+ #
23
+ # def call(**)
24
+ # say "Hello, world!"
25
+ # end
26
+ # end
27
+ class Base < Dry::CLI::Command
28
+ # Emoji prefix mapping for common message types
29
+ EMOJI_PREFIXES = {
30
+ success: "\u{2713}", # CHECK MARK
31
+ info: "\u{2139}", # INFORMATION SOURCE
32
+ warn: "\u{26A0}\u{FE0E}", # WARNING SIGN (text presentation)
33
+ error: "\u{2717}", # BALLOT X
34
+ fatal: "\u{2620}\u{FE0E}" # SKULL AND CROSSBONES (text presentation)
35
+ }.freeze
36
+ private_constant :EMOJI_PREFIXES
37
+
38
+ # Plain style (no-op) for default output
39
+ PLAIN = TIntMe::Style[]
40
+ private_constant :PLAIN
41
+
42
+ # Color styles for message prefixes
43
+ STYLES = {
44
+ success: TIntMe[:green],
45
+ info: TIntMe[:cyan],
46
+ warn: TIntMe[:magenta],
47
+ error: TIntMe[:red],
48
+ fatal: TIntMe[:red, :bold]
49
+ }.freeze
50
+ private_constant :STYLES
51
+
52
+ # Prepend CommandWrapper to each command class that inherits from Base
53
+ def self.inherited(subclass)
54
+ super
55
+ subclass.prepend CommandWrapper
56
+ end
57
+
58
+ # Require that the game is not running when this command executes
59
+ # @return [void]
60
+ def self.require_game_stopped! = prepend RequiresGameStopped
61
+
62
+ # Enable confirmation prompts with --yes option
63
+ # @return [void]
64
+ def self.confirmable! = prepend Confirmable
65
+
66
+ # Enable backup support for file modifications
67
+ # @return [void]
68
+ def self.backup_support! = prepend BackupSupport
69
+
70
+ # Common options available to all commands
71
+ option :config_path, type: :string, aliases: ["-c"], desc: "Path to configuration file"
72
+ option :log_level, type: :string, values: %w[debug info warn error fatal], desc: "Set log level"
73
+ option :quiet, type: :flag, default: false, aliases: ["-q"], desc: "Suppress non-essential output"
74
+
75
+ private def say(message, prefix: "")
76
+ return if quiet?
77
+
78
+ resolved_prefix = EMOJI_PREFIXES.fetch(prefix) { prefix.to_s }
79
+ output = resolved_prefix.empty? ? message : "#{resolved_prefix} #{message}"
80
+ style = STYLES.fetch(prefix, PLAIN)
81
+ puts style[output]
82
+ end
83
+
84
+ private def quiet?
85
+ @quiet == true
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ module Cache
7
+ # Evict cache entries
8
+ #
9
+ # This command removes cache entries based on the specified criteria.
10
+ # At least one of --all, --expired, or --older-than must be specified.
11
+ #
12
+ # @example
13
+ # $ factorix cache evict --expired
14
+ # $ factorix cache evict api --all
15
+ # $ factorix cache evict download --older-than 7d
16
+ class Evict < Base
17
+ # @!parse
18
+ # # @return [Dry::Logger::Dispatcher]
19
+ # attr_reader :logger
20
+ include Import[:logger]
21
+ include Formatting
22
+
23
+ # Valid cache names for the caches argument
24
+ VALID_CACHES = %w[download api info_json].freeze
25
+ private_constant :VALID_CACHES
26
+
27
+ desc "Evict cache entries"
28
+
29
+ example [
30
+ "--expired # Remove expired entries from all caches",
31
+ "api --all # Remove all entries from api cache",
32
+ "download --older-than 7d # Remove entries older than 7 days"
33
+ ]
34
+
35
+ argument :caches, type: :array, required: false, values: VALID_CACHES, desc: "Cache names"
36
+
37
+ option :all, type: :flag, default: false, desc: "Remove all entries"
38
+ option :expired, type: :flag, default: false, desc: "Remove expired entries only"
39
+ option :older_than, type: :string, default: nil, desc: "Remove entries older than AGE (e.g., 30s, 5m, 2h, 7d)"
40
+
41
+ # Execute the cache evict command
42
+ #
43
+ # @param caches [Array<String>, nil] cache names to evict
44
+ # @param all [Boolean] remove all entries
45
+ # @param expired [Boolean] remove expired entries only
46
+ # @param older_than [String, nil] remove entries older than this age
47
+ # @return [void]
48
+ def call(caches: nil, all: false, expired: false, older_than: nil, **)
49
+ validate_options!(all, expired, older_than)
50
+
51
+ @now = Time.now
52
+ @older_than_seconds = parse_age(older_than) if older_than
53
+
54
+ cache_names = resolve_cache_names(caches)
55
+ results = cache_names.to_h {|name| [name, evict_cache(name, all:, expired:)] }
56
+
57
+ output_results(results)
58
+ end
59
+
60
+ # Validate that exactly one eviction option is specified
61
+ #
62
+ # @param all [Boolean] --all option
63
+ # @param expired [Boolean] --expired option
64
+ # @param older_than [String, nil] --older-than option
65
+ # @return [void]
66
+ # @raise [InvalidArgumentError] if options are invalid
67
+ private def validate_options!(all, expired, older_than)
68
+ options_count = [all, expired, older_than].count {|opt| opt }
69
+
70
+ raise InvalidArgumentError, "One of --all, --expired, or --older-than must be specified" if options_count == 0
71
+ raise InvalidArgumentError, "Only one of --all, --expired, or --older-than can be specified" if options_count > 1
72
+ end
73
+
74
+ # Parse age string into seconds
75
+ #
76
+ # @param age [String] age string (e.g., "30s", "5m", "2h", "7d")
77
+ # @return [Integer] age in seconds
78
+ # @raise [InvalidArgumentError] if age format is invalid
79
+ DURATION_MULTIPLIERS = {"s" => 1, "m" => 60, "h" => 3600, "d" => 86400}.freeze
80
+ private_constant :DURATION_MULTIPLIERS
81
+
82
+ private def parse_age(age)
83
+ match = age.match(/\A(\d+)([smhd])\z/)
84
+ raise InvalidArgumentError, "Invalid age format: #{age}. Use format like 30s, 5m, 2h, 7d" unless match
85
+
86
+ value = Integer(match[1])
87
+ unit = match[2]
88
+
89
+ value * DURATION_MULTIPLIERS.fetch(unit)
90
+ end
91
+
92
+ # Resolve cache names from argument or return all
93
+ #
94
+ # @param caches [Array<String>, nil] cache names from argument
95
+ # @return [Array<Symbol>] resolved cache names
96
+ # @raise [InvalidArgumentError] if unknown cache name specified
97
+ private def resolve_cache_names(caches)
98
+ all_caches = Application.config.cache.values.keys
99
+
100
+ return all_caches if caches.nil? || caches.empty?
101
+
102
+ caches.map do |name|
103
+ sym = name.to_sym
104
+ raise InvalidArgumentError, "Unknown cache: #{name}. Valid caches: #{all_caches.join(", ")}" unless all_caches.include?(sym)
105
+
106
+ sym
107
+ end
108
+ end
109
+
110
+ # Evict entries from a single cache
111
+ #
112
+ # @param name [Symbol] cache name
113
+ # @param all [Boolean] remove all entries
114
+ # @param expired [Boolean] remove expired entries only
115
+ # @return [Hash] eviction result with :count and :size
116
+ private def evict_cache(name, all:, expired:)
117
+ config = Application.config.cache.public_send(name)
118
+ cache_dir = config.dir
119
+ ttl = config.ttl
120
+
121
+ return {count: 0, size: 0} unless cache_dir.exist?
122
+
123
+ count = 0
124
+ size = 0
125
+
126
+ cache_dir.glob("**/*").each do |path|
127
+ next unless path.file?
128
+ next if path.extname == ".lock"
129
+
130
+ next unless should_evict?(path, ttl, all:, expired:)
131
+
132
+ size += path.size
133
+ path.delete
134
+ count += 1
135
+ logger.debug("Evicted cache entry", path: path.to_s)
136
+ end
137
+
138
+ logger.info("Evicted cache entries", cache: name, count:, size:)
139
+ {count:, size:}
140
+ end
141
+
142
+ # Determine if a cache entry should be evicted
143
+ #
144
+ # @param path [Pathname] path to cache entry
145
+ # @param ttl [Integer, nil] cache TTL
146
+ # @param all [Boolean] remove all entries
147
+ # @param expired [Boolean] remove expired entries only
148
+ # @return [Boolean] true if entry should be evicted
149
+ private def should_evict?(path, ttl, all:, expired:)
150
+ return true if all
151
+
152
+ age_seconds = @now - path.mtime
153
+
154
+ if expired
155
+ return false if ttl.nil? # No TTL means never expires
156
+
157
+ age_seconds > ttl
158
+ else
159
+ # --older-than
160
+ age_seconds > @older_than_seconds
161
+ end
162
+ end
163
+
164
+ # Output eviction results
165
+ #
166
+ # @param results [Hash] results for each cache
167
+ # @return [void]
168
+ private def output_results(results)
169
+ # Calculate max width for alignment
170
+ max_name_width = results.keys.map {|k| k.to_s.length }.max
171
+
172
+ results.each do |name, data|
173
+ say "%-#{max_name_width}s: %3d entries removed (%s)" % [name, data[:count], format_size(data[:size])], prefix: :info
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Factorix
6
+ class CLI
7
+ module Commands
8
+ module Cache
9
+ # Display cache statistics
10
+ #
11
+ # This command outputs statistics for all cache stores
12
+ # in a human-readable or JSON format.
13
+ #
14
+ # @example
15
+ # $ factorix cache stat
16
+ # download:
17
+ # Directory: ~/.cache/factorix/download
18
+ # TTL: unlimited
19
+ # Entries: 42 / 42 (100.0% valid)
20
+ # ...
21
+ class Stat < Base
22
+ # @!parse
23
+ # # @return [Dry::Logger::Dispatcher]
24
+ # attr_reader :logger
25
+ include Import[:logger]
26
+ include Formatting
27
+
28
+ desc "Display cache statistics"
29
+
30
+ example [
31
+ " # Display statistics in text format",
32
+ "--json # Display statistics in JSON format"
33
+ ]
34
+
35
+ option :json, type: :flag, default: false, desc: "Output in JSON format"
36
+
37
+ # Execute the cache stat command
38
+ #
39
+ # @param json [Boolean] output in JSON format
40
+ # @return [void]
41
+ def call(json:, **)
42
+ logger.debug("Collecting cache statistics")
43
+
44
+ @now = Time.now
45
+ cache_names = Application.config.cache.values.keys
46
+ stats = cache_names.to_h {|name| [name, collect_stats(name)] }
47
+
48
+ if json
49
+ puts JSON.pretty_generate(stats)
50
+ else
51
+ output_text(stats)
52
+ end
53
+ end
54
+
55
+ private def collect_stats(name)
56
+ config = Application.config.cache.public_send(name)
57
+ cache_dir = config.dir
58
+
59
+ entries = scan_entries(cache_dir, config.ttl)
60
+
61
+ {
62
+ directory: cache_dir.to_s,
63
+ ttl: config.ttl,
64
+ max_file_size: config.max_file_size,
65
+ compression_threshold: config.compression_threshold,
66
+ entries: build_entry_stats(entries),
67
+ size: build_size_stats(entries),
68
+ age: build_age_stats(entries),
69
+ stale_locks: count_stale_locks(cache_dir)
70
+ }
71
+ end
72
+
73
+ # Scan cache directory and collect entry information
74
+ #
75
+ # @param cache_dir [Pathname] cache directory path
76
+ # @param ttl [Integer, nil] time-to-live in seconds
77
+ # @return [Array<Hash>] array of entry info hashes
78
+ private def scan_entries(cache_dir, ttl)
79
+ return [] unless cache_dir.exist?
80
+
81
+ entries = []
82
+ cache_dir.glob("**/*").each do |path|
83
+ next unless path.file?
84
+ next if path.extname == ".lock"
85
+
86
+ age_seconds = @now - path.mtime
87
+ expired = ttl ? age_seconds > ttl : false
88
+
89
+ entries << {size: path.size, age: age_seconds, expired:}
90
+ end
91
+ entries
92
+ end
93
+
94
+ # Build entry count statistics
95
+ #
96
+ # @param entries [Array<Hash>] entry info array
97
+ # @return [Hash] entry statistics
98
+ private def build_entry_stats(entries)
99
+ total = entries.size
100
+ valid = entries.count {|e| !e[:expired] }
101
+ expired = total - valid
102
+
103
+ {total:, valid:, expired:}
104
+ end
105
+
106
+ # Build size statistics
107
+ #
108
+ # @param entries [Array<Hash>] entry info array
109
+ # @return [Hash] size statistics
110
+ private def build_size_stats(entries)
111
+ return {total: 0, avg: 0, min: 0, max: 0} if entries.empty?
112
+
113
+ sizes = entries.map {|e| e[:size] }
114
+ {total: sizes.sum, avg: sizes.sum / sizes.size, min: sizes.min, max: sizes.max}
115
+ end
116
+
117
+ # Build age statistics
118
+ #
119
+ # @param entries [Array<Hash>] entry info array
120
+ # @return [Hash] age statistics
121
+ private def build_age_stats(entries)
122
+ return {oldest: nil, newest: nil, avg: nil} if entries.empty?
123
+
124
+ ages = entries.map {|e| e[:age] }
125
+ {oldest: ages.max, newest: ages.min, avg: ages.sum / ages.size}
126
+ end
127
+
128
+ # Count stale lock files
129
+ #
130
+ # @param cache_dir [Pathname] cache directory path
131
+ # @return [Integer] number of stale lock files
132
+ private def count_stale_locks(cache_dir)
133
+ return 0 unless cache_dir.exist?
134
+
135
+ lock_lifetime = Factorix::Cache::FileSystem::LOCK_FILE_LIFETIME
136
+ cache_dir.glob("**/*.lock").count {|path| @now - path.mtime > lock_lifetime }
137
+ end
138
+
139
+ # Output statistics in text format (ccache-style)
140
+ #
141
+ # @param stats [Hash] statistics for all caches
142
+ # @return [void]
143
+ private def output_text(stats)
144
+ stats.each_with_index do |(name, data), index|
145
+ puts if index > 0
146
+ puts "#{name}:"
147
+ output_cache_stats(data)
148
+ end
149
+ end
150
+
151
+ # Output statistics for a single cache
152
+ #
153
+ # @param data [Hash] cache statistics
154
+ # @return [void]
155
+ private def output_cache_stats(data)
156
+ puts " Directory: #{data[:directory]}"
157
+ puts " TTL: #{format_ttl(data[:ttl])}"
158
+ puts " Max file size: #{format_size(data[:max_file_size])}"
159
+ puts " Compression: #{format_compression(data[:compression_threshold])}"
160
+
161
+ entries = data[:entries]
162
+ valid_pct = entries[:total] > 0 ? (Float(entries[:valid]) / entries[:total] * 100) : 0.0
163
+ puts " Entries: #{entries[:valid]} / #{entries[:total]} (#{"%.1f" % valid_pct}% valid)"
164
+
165
+ size = data[:size]
166
+ puts " Size: #{format_size(size[:total])} (avg #{format_size(size[:avg])})"
167
+
168
+ age = data[:age]
169
+ if age[:oldest]
170
+ puts " Age: #{format_duration(age[:newest])} - #{format_duration(age[:oldest])} (avg #{format_duration(age[:avg])})"
171
+ else
172
+ puts " Age: -"
173
+ end
174
+
175
+ puts " Stale locks: #{data[:stale_locks]}"
176
+ end
177
+
178
+ # Format TTL value for display
179
+ #
180
+ # @param ttl [Integer, nil] TTL in seconds
181
+ # @return [String] formatted TTL
182
+ private def format_ttl(ttl)
183
+ ttl.nil? ? "unlimited" : format_duration(ttl)
184
+ end
185
+
186
+ # Format compression threshold for display
187
+ #
188
+ # @param threshold [Integer, nil] compression threshold in bytes
189
+ # @return [String] formatted compression setting
190
+ private def format_compression(threshold)
191
+ case threshold
192
+ when nil then "disabled"
193
+ when 0 then "enabled (always)"
194
+ else "enabled (>= #{format_size(threshold)})"
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Factorix
6
+ class CLI
7
+ module Commands
8
+ # Module that wraps command execution to perform setup and error handling
9
+ #
10
+ # This module is prepended to Base to ensure configuration loading, log level
11
+ # setup, and consistent error handling happen for every command execution.
12
+ module CommandWrapper
13
+ # Performs setup before command execution, then calls the command's implementation
14
+ # Catches exceptions and displays user-friendly error messages
15
+ #
16
+ # @param options [Hash] command options including :config_path and :log_level
17
+ def call(**options)
18
+ @quiet = options[:quiet]
19
+
20
+ load_config!(options[:config_path])
21
+ log_level!(options[:log_level]) if options[:log_level]
22
+
23
+ super
24
+ rescue Error => e
25
+ # Expected errors (validation failures, missing dependencies, etc.)
26
+ log = Application[:logger]
27
+ log.warn(e.message)
28
+ log.debug(e)
29
+ say "Error: #{e.message}", prefix: :error unless @quiet
30
+ raise # Re-raise for exe/factorix to handle exit code
31
+ rescue => e
32
+ # Unexpected errors (bugs, system failures, etc.)
33
+ log = Application[:logger]
34
+ log.error(e)
35
+ say "Unexpected error: #{e.message}", prefix: :error unless @quiet
36
+ raise # Re-raise for exe/factorix to handle exit code
37
+ end
38
+
39
+ private def load_config!(explicit_path)
40
+ path = resolve_config_path(explicit_path)
41
+ return unless path
42
+
43
+ Application.load_config(path)
44
+ end
45
+
46
+ # Resolves which config path to use
47
+ # @param explicit_path [String, nil] path specified via --config-path
48
+ # @return [Pathname, nil] path to load, or nil if none should be loaded
49
+ private def resolve_config_path(explicit_path)
50
+ return Pathname(explicit_path) if explicit_path
51
+ return Pathname(ENV.fetch("FACTORIX_CONFIG")) if ENV["FACTORIX_CONFIG"]
52
+
53
+ default_path = Application[:runtime].factorix_config_path
54
+ default_path.exist? ? default_path : nil
55
+ end
56
+
57
+ # Sets the application logger's level
58
+ # @param level [String] log level (debug, info, warn, error, fatal)
59
+ private def log_level!(level)
60
+ logger = Application[:logger]
61
+ level_constant = Logger.const_get(level.upcase)
62
+
63
+ # Change only the File backend (first backend) level
64
+ # Dispatcher is always set to DEBUG to allow all messages through
65
+ file_backend = logger.backends.first
66
+ file_backend.level = level_constant if file_backend.respond_to?(:level=)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ # Generate shell completion script for factorix
7
+ #
8
+ # This command outputs a shell completion script that can be evaluated
9
+ # to enable command-line completion for factorix.
10
+ #
11
+ # @example Enable completion (auto-detect shell)
12
+ # eval "$(factorix completion)"
13
+ # @example Enable completion for specific shell
14
+ # eval "$(factorix completion zsh)"
15
+ class Completion < Base
16
+ # Directory containing completion scripts
17
+ COMPLETION_DIR = Pathname(__dir__).join("../../../../completion").freeze
18
+ private_constant :COMPLETION_DIR
19
+
20
+ # Supported shells and their script filenames
21
+ SUPPORTED_SHELLS = {
22
+ "bash" => "_factorix.bash",
23
+ "fish" => "_factorix.fish",
24
+ "zsh" => "_factorix.zsh"
25
+ }.freeze
26
+ private_constant :SUPPORTED_SHELLS
27
+
28
+ desc "Generate shell completion script"
29
+
30
+ argument :shell,
31
+ type: :string,
32
+ required: false,
33
+ values: SUPPORTED_SHELLS.keys,
34
+ desc: "Shell type. Defaults to current shell from $SHELL"
35
+
36
+ example [
37
+ " # Output completion script for current shell",
38
+ "bash # Output bash completion script",
39
+ "fish # Output fish completion script",
40
+ "zsh # Output zsh completion script"
41
+ ]
42
+
43
+ # Execute the completion command
44
+ #
45
+ # @param shell [String, nil] Shell type (zsh, bash, fish)
46
+ # @return [void]
47
+ # @raise [InvalidArgumentError] if shell type cannot be detected or is unsupported
48
+ # @raise [ConfigurationError] if completion script not found
49
+ def call(shell: nil, **)
50
+ shell_type = shell || detect_shell
51
+ validate_shell!(shell_type)
52
+
53
+ script_path = COMPLETION_DIR / SUPPORTED_SHELLS[shell_type]
54
+ raise ConfigurationError, "#{shell_type.capitalize} completion script not found" unless script_path.exist?
55
+
56
+ puts script_path.read
57
+ end
58
+
59
+ # Detect shell type from SHELL environment variable
60
+ #
61
+ # @return [String] Detected shell type
62
+ private def detect_shell
63
+ shell_path = ENV.fetch("SHELL", "")
64
+ shell_name = File.basename(shell_path)
65
+
66
+ return shell_name if SUPPORTED_SHELLS.key?(shell_name)
67
+
68
+ raise InvalidArgumentError, "Cannot detect shell type from SHELL=#{shell_path}. Please specify: #{SUPPORTED_SHELLS.keys.join(", ")}"
69
+ end
70
+
71
+ # Validate shell type
72
+ #
73
+ # @param shell [String] Shell type to validate
74
+ # @raise [InvalidArgumentError] If shell type is not supported
75
+ private def validate_shell!(shell)
76
+ return if SUPPORTED_SHELLS.key?(shell)
77
+
78
+ raise InvalidArgumentError, "Unsupported shell: #{shell}. Supported shells: #{SUPPORTED_SHELLS.keys.join(", ")}"
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end