factorix 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93bb2e6485db7f9e122f3b7380bfe4d4785f55f20aa7b9bf3db784ffd7a1b42d
4
- data.tar.gz: be85a4df980159362e3b3d774865b819375f89ee93c5d293fd234a45d7367d18
3
+ metadata.gz: 970e619a2c58b6ee5c7502adc6803469accf6ee618e41e580df5f950a8b2ed15
4
+ data.tar.gz: 6651e0dea495831ede26d577410722d5ea08542c6ee2516772c413ddec1b7b55
5
5
  SHA512:
6
- metadata.gz: d224c1f72f0d1311ca9afc8dbf8b56a04edbb7fdb1129b68515dc4c14a95cdf90ff0f444930e7d823ea3e60cf0b98c23a8e7c623eeca512a0bb408bae0a00f6b
7
- data.tar.gz: 062a2b3f368975e4dddbb3c9f1941ed68447af5301b5bf7a24d88afd5f36cecf499a3bf049be8f29ce80a62f7462d5fdfdfbd23327a12df70ed60c57062fe1d4
6
+ metadata.gz: 6d10a143153a82c1d2b969d928ce85dd22c8c9bf95c793fe27e3287cc5f7408a00fcec47d839295d40390655667a3b1b6e1ab8e76947c9ce98eec8455f87438f
7
+ data.tar.gz: 2feeb57d5fc6835ee674f3c024b7ef5a4710e3b4f496dc7ada5ecc76add61e1b0fc3dcc3c7e95aea2ae57d131b28d124da04c83bb7ca1c04869067cac251e4c7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.9.0] - 2026-02-20
4
+
5
+ ### Added
6
+
7
+ - Add `mod changelog add` command to add entries to MOD changelog
8
+ - Add `mod changelog check` command to validate MOD changelog structure
9
+ - Add `mod changelog release` command to convert Unreleased section to a versioned section
10
+
3
11
  ## [0.8.1] - 2026-02-17
4
12
 
5
13
  ### Added
@@ -20,7 +20,7 @@ _factorix() {
20
20
  local commands="version man launch path download mod cache completion"
21
21
 
22
22
  # mod subcommands
23
- local mod_commands="check list show enable disable install uninstall update download upload edit search sync image settings"
23
+ local mod_commands="changelog check list show enable disable install uninstall update download upload edit search sync image settings"
24
24
 
25
25
  # cache subcommands
26
26
  local cache_commands="stat evict"
@@ -170,6 +170,43 @@ _factorix() {
170
170
  COMPREPLY=($(compgen -f -X '!*.zip' -- "$cur"))
171
171
  fi
172
172
  ;;
173
+ changelog)
174
+ if [[ $cword -eq 3 ]]; then
175
+ COMPREPLY=($(compgen -W "add check release" -- "$cur"))
176
+ else
177
+ case "${words[3]}" in
178
+ add)
179
+ if [[ "$prev" == "--version" ]]; then
180
+ COMPREPLY=($(compgen -W "Unreleased" -- "$cur"))
181
+ elif [[ "$prev" == "--category" ]]; then
182
+ local categories="'Major Features' Features 'Minor Features' Graphics Sounds Optimizations Balancing 'Combat Balancing' 'Circuit Network' Changes Bugfixes Modding Scripting Gui Control Translation Debug 'Ease of use' Info Locale Compatibility"
183
+ COMPREPLY=($(compgen -W "$categories" -- "$cur"))
184
+ elif [[ "$prev" == "--changelog" ]]; then
185
+ COMPREPLY=($(compgen -f -- "$cur"))
186
+ elif [[ "$cur" == -* ]]; then
187
+ COMPREPLY=($(compgen -W "$global_opts --version --category --changelog" -- "$cur"))
188
+ fi
189
+ ;;
190
+ check)
191
+ if [[ "$prev" == "--changelog" ]] || [[ "$prev" == "--info-json" ]]; then
192
+ COMPREPLY=($(compgen -f -- "$cur"))
193
+ elif [[ "$cur" == -* ]]; then
194
+ COMPREPLY=($(compgen -W "$global_opts --release --changelog --info-json" -- "$cur"))
195
+ fi
196
+ ;;
197
+ release)
198
+ if [[ "$prev" == "--changelog" ]] || [[ "$prev" == "--info-json" ]]; then
199
+ COMPREPLY=($(compgen -f -- "$cur"))
200
+ elif [[ "$cur" == -* ]]; then
201
+ COMPREPLY=($(compgen -W "$global_opts --version --date --changelog --info-json" -- "$cur"))
202
+ fi
203
+ ;;
204
+ *)
205
+ COMPREPLY=($(compgen -W "$global_opts" -- "$cur"))
206
+ ;;
207
+ esac
208
+ fi
209
+ ;;
173
210
  image)
174
211
  if [[ $cword -eq 3 ]]; then
175
212
  COMPREPLY=($(compgen -W "$image_commands" -- "$cur"))
@@ -64,6 +64,40 @@ function __factorix_using_subcommand
64
64
  return 1
65
65
  end
66
66
 
67
+ # Helper function to check 3-level nested subcommand (e.g., mod changelog add)
68
+ function __factorix_using_sub_subcommand
69
+ set -l cmd (commandline -opc)
70
+ set -l argc (count $cmd)
71
+
72
+ if test $argc -lt 4
73
+ return 1
74
+ end
75
+
76
+ set -l parent $argv[1]
77
+ set -l sub $argv[2]
78
+ set -l subsub $argv[3]
79
+
80
+ set -l level 0
81
+ for i in (seq 2 $argc)
82
+ switch $cmd[$i]
83
+ case '-*'
84
+ continue
85
+ case '*'
86
+ if test $level -eq 0
87
+ test "$cmd[$i]" = "$parent"; or return 1
88
+ set level 1
89
+ else if test $level -eq 1
90
+ test "$cmd[$i]" = "$sub"; or return 1
91
+ set level 2
92
+ else
93
+ test "$cmd[$i]" = "$subsub"; and return 0
94
+ return 1
95
+ end
96
+ end
97
+ end
98
+ return 1
99
+ end
100
+
67
101
  # Disable file completion by default
68
102
  complete -c factorix -f
69
103
 
@@ -125,6 +159,7 @@ complete -c factorix -n "__factorix_using_command mod" -a upload -d 'Upload MOD
125
159
  complete -c factorix -n "__factorix_using_command mod" -a edit -d 'Edit MOD metadata on Factorio MOD Portal'
126
160
  complete -c factorix -n "__factorix_using_command mod" -a search -d 'Search MODs on Factorio MOD Portal'
127
161
  complete -c factorix -n "__factorix_using_command mod" -a sync -d 'Sync MOD states from a save file'
162
+ complete -c factorix -n "__factorix_using_command mod" -a changelog -d 'MOD changelog management'
128
163
  complete -c factorix -n "__factorix_using_command mod" -a image -d 'MOD image management'
129
164
  complete -c factorix -n "__factorix_using_command mod" -a settings -d 'MOD settings management'
130
165
 
@@ -195,6 +230,27 @@ complete -c factorix -n "__factorix_using_subcommand mod sync" -s y -l yes -d 'S
195
230
  complete -c factorix -n "__factorix_using_subcommand mod sync" -s j -l jobs -d 'Number of parallel downloads' -r
196
231
  complete -c factorix -n "__factorix_using_subcommand mod sync" -ra '(__fish_complete_suffix .zip)'
197
232
 
233
+ # mod changelog subcommands
234
+ complete -c factorix -n "__factorix_using_subcommand mod changelog" -a add -d 'Add an entry to MOD changelog'
235
+ complete -c factorix -n "__factorix_using_subcommand mod changelog" -a check -d 'Validate MOD changelog structure'
236
+ complete -c factorix -n "__factorix_using_subcommand mod changelog" -a release -d 'Release Unreleased changelog section with a version'
237
+
238
+ # mod changelog add options
239
+ complete -c factorix -n "__factorix_using_sub_subcommand mod changelog add" -l version -d 'Version (X.Y.Z or Unreleased)' -ra 'Unreleased'
240
+ complete -c factorix -n "__factorix_using_sub_subcommand mod changelog add" -l category -d 'Category name' -xa "'Major Features' Features 'Minor Features' Graphics Sounds Optimizations Balancing 'Combat Balancing' 'Circuit Network' Changes Bugfixes Modding Scripting Gui Control Translation Debug 'Ease of use' Info Locale Compatibility"
241
+ complete -c factorix -n "__factorix_using_sub_subcommand mod changelog add" -l changelog -d 'Path to changelog file' -rF
242
+
243
+ # mod changelog check options
244
+ complete -c factorix -n "__factorix_using_sub_subcommand mod changelog check" -l release -d 'Disallow Unreleased section'
245
+ complete -c factorix -n "__factorix_using_sub_subcommand mod changelog check" -l changelog -d 'Path to changelog file' -rF
246
+ complete -c factorix -n "__factorix_using_sub_subcommand mod changelog check" -l info-json -d 'Path to info.json file' -rF
247
+
248
+ # mod changelog release options
249
+ complete -c factorix -n "__factorix_using_sub_subcommand mod changelog release" -l version -d 'Version (X.Y.Z)' -r
250
+ complete -c factorix -n "__factorix_using_sub_subcommand mod changelog release" -l date -d 'Release date (YYYY-MM-DD)' -r
251
+ complete -c factorix -n "__factorix_using_sub_subcommand mod changelog release" -l changelog -d 'Path to changelog file' -rF
252
+ complete -c factorix -n "__factorix_using_sub_subcommand mod changelog release" -l info-json -d 'Path to info.json file' -rF
253
+
198
254
  # mod image subcommands
199
255
  complete -c factorix -n "__factorix_using_subcommand mod image" -a list -d 'List MOD images'
200
256
  complete -c factorix -n "__factorix_using_subcommand mod image" -a add -d 'Add an image to a MOD'
@@ -147,6 +147,7 @@ _factorix_mod() {
147
147
  'edit:Edit MOD metadata on Factorio MOD Portal'
148
148
  'search:Search MODs on Factorio MOD Portal'
149
149
  'sync:Sync MOD states from a save file'
150
+ 'changelog:MOD changelog management'
150
151
  'image:MOD image management'
151
152
  'settings:MOD settings management'
152
153
  )
@@ -257,6 +258,9 @@ _factorix_mod() {
257
258
  '(-j --jobs)'{-j,--jobs}'[Number of parallel downloads]:jobs:' \
258
259
  '1:save file:_files -g "*.zip"'
259
260
  ;;
261
+ changelog)
262
+ _factorix_mod_changelog
263
+ ;;
260
264
  image)
261
265
  _factorix_mod_image
262
266
  ;;
@@ -268,6 +272,61 @@ _factorix_mod() {
268
272
  esac
269
273
  }
270
274
 
275
+ _factorix_mod_changelog() {
276
+ local context state state_descr line
277
+ typeset -A opt_args
278
+
279
+ local -a global_opts
280
+ global_opts=(
281
+ '(-c --config-path)'{-c,--config-path}'[Path to configuration file]:config file:_files'
282
+ '--log-level[Set log level]:level:(debug info warn error fatal)'
283
+ '(-q --quiet)'{-q,--quiet}'[Suppress non-essential output]'
284
+ )
285
+
286
+ _arguments -C \
287
+ '1:subcommand:->subcommand' \
288
+ '*::arg:->args'
289
+
290
+ case $state in
291
+ subcommand)
292
+ local -a subcommands
293
+ subcommands=(
294
+ 'add:Add an entry to MOD changelog'
295
+ 'check:Validate MOD changelog structure'
296
+ 'release:Release Unreleased changelog section with a version'
297
+ )
298
+ _describe -t subcommands 'changelog subcommand' subcommands
299
+ ;;
300
+ args)
301
+ case $line[1] in
302
+ add)
303
+ _arguments \
304
+ $global_opts \
305
+ '--version[Version (X.Y.Z or Unreleased)]:version:(Unreleased)' \
306
+ '--category[Category name]:category:(Major\ Features Features Minor\ Features Graphics Sounds Optimizations Balancing Combat\ Balancing Circuit\ Network Changes Bugfixes Modding Scripting Gui Control Translation Debug Ease\ of\ use Info Locale Compatibility)' \
307
+ '--changelog[Path to changelog file]:changelog file:_files' \
308
+ '*:entry text:'
309
+ ;;
310
+ check)
311
+ _arguments \
312
+ $global_opts \
313
+ '--release[Disallow Unreleased section]' \
314
+ '--changelog[Path to changelog file]:changelog file:_files' \
315
+ '--info-json[Path to info.json file]:info.json file:_files'
316
+ ;;
317
+ release)
318
+ _arguments \
319
+ $global_opts \
320
+ '--version[Version (X.Y.Z)]:version:' \
321
+ '--date[Release date (YYYY-MM-DD)]:date:' \
322
+ '--changelog[Path to changelog file]:changelog file:_files' \
323
+ '--info-json[Path to info.json file]:info.json file:_files'
324
+ ;;
325
+ esac
326
+ ;;
327
+ esac
328
+ }
329
+
271
330
  _factorix_mod_image() {
272
331
  local context state state_descr line
273
332
  typeset -A opt_args
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parslet"
4
+
5
+ module Factorix
6
+ # Parser and writer for Factorio MOD changelog.txt files
7
+ #
8
+ # @see https://wiki.factorio.com/Tutorial:Mod_changelog_format
9
+ class Changelog
10
+ SEPARATOR = ("-" * 99).freeze
11
+ public_constant :SEPARATOR
12
+
13
+ UNRELEASED = "Unreleased"
14
+ public_constant :UNRELEASED
15
+
16
+ Section = Data.define(:version, :date, :categories)
17
+ private_class_method :new
18
+
19
+ # Load a changelog from a file
20
+ #
21
+ # @param path [Pathname] path to changelog.txt
22
+ # @return [Changelog]
23
+ # @raise [ChangelogParseError] if the file content is malformed
24
+ def self.load(path)
25
+ return new([]) unless path.exist?
26
+
27
+ parse(path.read)
28
+ end
29
+
30
+ # Parse changelog text content
31
+ #
32
+ # @param text [String] changelog content
33
+ # @return [Changelog]
34
+ # @raise [ChangelogParseError] if the content is malformed
35
+ def self.parse(text)
36
+ tree = Grammar.new.parse(text)
37
+ sections = Transform.new.apply(tree)
38
+ new(Array(sections))
39
+ rescue Parslet::ParseFailed => e
40
+ raise ChangelogParseError, e.message
41
+ end
42
+
43
+ # @param sections [Array<Section>] changelog sections
44
+ def initialize(sections)
45
+ @sections = sections
46
+ end
47
+
48
+ # @return [Array<Section>]
49
+ attr_reader :sections
50
+
51
+ # Save the changelog to a file
52
+ #
53
+ # @param path [Pathname] path to write
54
+ # @return [void]
55
+ def save(path)
56
+ path.write(to_s)
57
+ end
58
+
59
+ # Add an entry to the changelog
60
+ #
61
+ # @param version [MODVersion, String] target version (or Changelog::UNRELEASED)
62
+ # @param category [String] category name
63
+ # @param entry [String] entry text
64
+ # @return [void]
65
+ # @raise [InvalidArgumentError] if the entry already exists
66
+ def add_entry(version, category, entry)
67
+ raise InvalidArgumentError, "entry must not be blank" if entry.match?(/\A[[:space:]]*\z/)
68
+
69
+ section = find_or_create_section(version)
70
+ entries = (section.categories[category] ||= [])
71
+ raise InvalidArgumentError, "duplicate entry: #{entry}" if entries.include?(entry)
72
+
73
+ entries << entry
74
+ end
75
+
76
+ # Replace the first section (Unreleased) with a versioned section
77
+ # @param version [MODVersion] target version
78
+ # @param date [String] release date (YYYY-MM-DD)
79
+ # @return [void]
80
+ def release_section(version, date:)
81
+ raise InvalidOperationError, "First section is not Unreleased" unless @sections.first&.version == UNRELEASED
82
+ raise InvalidOperationError, "Version #{version} already exists" if @sections.any? {|s| s.version == version }
83
+
84
+ unreleased = @sections.first
85
+ @sections[0] = Section[version:, date:, categories: unreleased.categories]
86
+ end
87
+
88
+ # Render the changelog as a string
89
+ #
90
+ # @return [String]
91
+ def to_s
92
+ @sections.map {|section| format_section(section) }.join("\n") + "\n"
93
+ end
94
+
95
+ private def find_or_create_section(version)
96
+ @sections.find {|s| s.version == version } || create_section(version)
97
+ end
98
+
99
+ private def create_section(version)
100
+ section = Section[version:, date: nil, categories: {}]
101
+ @sections.unshift(section)
102
+ section
103
+ end
104
+
105
+ private def format_section(section)
106
+ lines = [SEPARATOR]
107
+ lines << "Version: #{section.version}"
108
+ lines << "Date: #{section.date}" if section.date
109
+ section.categories.each do |cat, entries|
110
+ lines << " #{cat}:"
111
+ entries.each do |entry|
112
+ first, *rest = entry.split("\n")
113
+ lines << " - #{first}"
114
+ rest.each {|line| lines << " #{line}" }
115
+ end
116
+ end
117
+ lines.join("\n")
118
+ end
119
+
120
+ # Parslet grammar for Factorio changelog.txt
121
+ class Grammar < Parslet::Parser
122
+ rule(:newline) { str("\r\n") | str("\n") }
123
+ rule(:rest_of_line) { (newline.absent? >> any).repeat(1) }
124
+ rule(:blank_line) { match[' \t'].repeat >> newline }
125
+
126
+ rule(:separator) { str("-").repeat(99, 99) >> newline }
127
+
128
+ rule(:version_line) { str("Version: ") >> rest_of_line.as(:version) >> newline }
129
+ rule(:date_line) { str("Date: ") >> rest_of_line.as(:date) >> newline }
130
+
131
+ # Category name: everything between " " and ":\n", captured via negative lookahead
132
+ rule(:category_line) { str(" ") >> ((str(":\n") | str(":\r\n")).absent? >> any).repeat(1).as(:category) >> str(":") >> newline }
133
+
134
+ rule(:entry_first_line) { str(" - ") >> rest_of_line.as(:first) >> newline }
135
+ rule(:continuation_line) { str(" ") >> rest_of_line >> newline }
136
+ rule(:entry) { entry_first_line >> continuation_line.repeat.as(:rest) }
137
+
138
+ rule(:category_block) { category_line >> entry.repeat(1).as(:entries) }
139
+
140
+ rule(:section) do
141
+ separator >>
142
+ version_line >>
143
+ date_line.maybe.as(:date_line) >>
144
+ blank_line.repeat >>
145
+ category_block.repeat.as(:categories) >>
146
+ blank_line.repeat
147
+ end
148
+
149
+ rule(:changelog) { section.repeat(1).as(:sections) }
150
+
151
+ root(:changelog)
152
+ end
153
+ private_constant :Grammar
154
+
155
+ # Transform parsed tree into Section objects
156
+ class Transform < Parslet::Transform
157
+ rule(first: simple(:first), rest: subtree(:rest)) do
158
+ continuations = rest.is_a?(Array) ? rest : [rest]
159
+ parts = [first.to_s]
160
+ continuations.each {|c| parts << c.to_s.delete_prefix(" ") if c.is_a?(Parslet::Slice) }
161
+ parts.join("\n")
162
+ end
163
+
164
+ rule(category: simple(:cat), entries: subtree(:entries)) do
165
+ entry_list = entries.is_a?(Array) ? entries : [entries]
166
+ {cat.to_s => entry_list}
167
+ end
168
+
169
+ rule(version: simple(:ver), date_line: subtree(:date_data), categories: subtree(:cats)) do
170
+ ver_str = ver.to_s.strip
171
+ version = ver_str.casecmp("unreleased").zero? ? UNRELEASED : MODVersion.from_string(ver_str)
172
+
173
+ date = date_data.is_a?(Hash) ? date_data[:date].to_s.strip : nil
174
+
175
+ categories = {}
176
+ cat_list = cats.is_a?(Array) ? cats : [cats]
177
+ cat_list.each do |cat_hash|
178
+ next unless cat_hash.is_a?(Hash)
179
+
180
+ categories.merge!(cat_hash)
181
+ end
182
+
183
+ Section[version:, date:, categories:]
184
+ end
185
+
186
+ rule(sections: subtree(:secs)) do
187
+ secs.is_a?(Array) ? secs : [secs]
188
+ end
189
+ end
190
+ private_constant :Transform
191
+ end
192
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ module MOD
7
+ module Changelog
8
+ # Add an entry to a MOD's changelog.txt
9
+ class Add < Base
10
+ desc "Add an entry to MOD changelog"
11
+
12
+ option :version, default: "Unreleased", desc: "Version (X.Y.Z or Unreleased)"
13
+ option :category, required: true, desc: "Category (e.g., Features, Bugfixes)"
14
+ option :changelog, default: "changelog.txt", desc: "Path to changelog file"
15
+
16
+ argument :entry, type: :array, required: true, desc: "Entry text"
17
+
18
+ # @param version [String] version string (X.Y.Z or Unreleased)
19
+ # @param category [String] category name
20
+ # @param entry [Array<String>] entry text words
21
+ # @param changelog [String] path to changelog file
22
+ # @return [void]
23
+ def call(version:, category:, entry:, changelog: "changelog.txt", **)
24
+ target_version = version.casecmp("unreleased").zero? ? Factorix::Changelog::UNRELEASED : MODVersion.from_string(version)
25
+ path = Pathname(changelog)
26
+ log = Factorix::Changelog.load(path)
27
+ log.add_entry(target_version, category, entry.join(" "))
28
+ log.save(path)
29
+ say "Added entry to #{target_version} [#{category}]", prefix: :success
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ module MOD
7
+ module Changelog
8
+ # Validate the structure of a MOD changelog file
9
+ class Check < Base
10
+ desc "Validate MOD changelog structure"
11
+
12
+ option :release, type: :flag, default: false, desc: "Disallow Unreleased section"
13
+ option :changelog, default: "changelog.txt", desc: "Path to changelog file"
14
+ option :info_json, default: "info.json", desc: "Path to info.json file"
15
+
16
+ # @param release [Boolean] disallow Unreleased section
17
+ # @param changelog [String] path to changelog file
18
+ # @param info_json [String] path to info.json file
19
+ # @return [void]
20
+ def call(release: false, changelog: "changelog.txt", info_json: "info.json", **)
21
+ errors = []
22
+
23
+ log = parse_changelog(Pathname(changelog), errors)
24
+ return report(errors) unless log
25
+
26
+ validate_unreleased_position(log, errors)
27
+ validate_version_order(log, errors)
28
+
29
+ if release
30
+ validate_release_mode(log, Pathname(info_json), errors)
31
+ end
32
+
33
+ report(errors)
34
+ end
35
+
36
+ private def parse_changelog(path, errors)
37
+ Factorix::Changelog.load(path)
38
+ rescue ChangelogParseError => e
39
+ errors << "Failed to parse changelog: #{e.message}"
40
+ nil
41
+ end
42
+
43
+ private def validate_unreleased_position(log, errors)
44
+ log.sections.each_with_index do |section, index|
45
+ next unless section.version == Factorix::Changelog::UNRELEASED
46
+ next if index.zero?
47
+
48
+ errors << "Unreleased section must be the first section"
49
+ break
50
+ end
51
+ end
52
+
53
+ private def validate_version_order(log, errors)
54
+ versioned = log.sections.select {|s| s.version.is_a?(MODVersion) }
55
+ versioned.each_cons(2) do |a, b|
56
+ next if a.version > b.version
57
+
58
+ errors << "Versions are not in descending order: #{a.version} should be greater than #{b.version}"
59
+ end
60
+ end
61
+
62
+ private def validate_release_mode(log, info_json_path, errors)
63
+ if log.sections.any? {|s| s.version == Factorix::Changelog::UNRELEASED }
64
+ errors << "Unreleased section is not allowed in release mode"
65
+ end
66
+
67
+ validate_info_json_version(log, info_json_path, errors)
68
+ end
69
+
70
+ private def validate_info_json_version(log, info_json_path, errors)
71
+ unless info_json_path.exist?
72
+ errors << "info.json not found: #{info_json_path}"
73
+ return
74
+ end
75
+
76
+ info = InfoJSON.from_json(info_json_path.read)
77
+ first_versioned = log.sections.find {|s| s.version.is_a?(MODVersion) }
78
+ return unless first_versioned
79
+
80
+ return if info.version == first_versioned.version
81
+
82
+ errors << "info.json version (#{info.version}) does not match first changelog version (#{first_versioned.version})"
83
+ end
84
+
85
+ private def report(errors)
86
+ if errors.empty?
87
+ say "Changelog is valid", prefix: :success
88
+ else
89
+ say "Changelog validation failed:", prefix: :error
90
+ errors.each {|msg| say " - #{msg}" }
91
+ raise ValidationError, "Changelog validation failed"
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ module MOD
7
+ module Changelog
8
+ # Convert the Unreleased section to a versioned release
9
+ class Release < Base
10
+ desc "Convert Unreleased changelog section to a versioned section"
11
+
12
+ option :version, desc: "Version (X.Y.Z, default: from info.json)"
13
+ option :date, desc: "Release date (YYYY-MM-DD, default: today UTC)"
14
+ option :changelog, default: "changelog.txt", desc: "Path to changelog file"
15
+ option :info_json, default: "info.json", desc: "Path to info.json file"
16
+
17
+ # @param version [String, nil] version string (X.Y.Z)
18
+ # @param date [String, nil] release date (YYYY-MM-DD)
19
+ # @param changelog [String] path to changelog file
20
+ # @param info_json [String] path to info.json file
21
+ # @return [void]
22
+ def call(version: nil, date: nil, changelog: "changelog.txt", info_json: "info.json", **)
23
+ parsed_version = resolve_version(version, Pathname(info_json))
24
+ release_date = date || Time.now.utc.strftime("%Y-%m-%d")
25
+ path = Pathname(changelog)
26
+ log = Factorix::Changelog.load(path)
27
+ log.release_section(parsed_version, date: release_date)
28
+ log.save(path)
29
+ say "Converted Unreleased to #{parsed_version} (#{release_date})", prefix: :success
30
+ end
31
+
32
+ private def resolve_version(version, info_json_path)
33
+ if version
34
+ MODVersion.from_string(version)
35
+ else
36
+ info = InfoJSON.from_json(info_json_path.read)
37
+ info.version
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
data/lib/factorix/cli.rb CHANGED
@@ -19,6 +19,9 @@ module Factorix
19
19
  register "path", Commands::Path
20
20
  register "download", Commands::Download
21
21
  register "completion", Commands::Completion
22
+ register "mod changelog add", Commands::MOD::Changelog::Add
23
+ register "mod changelog check", Commands::MOD::Changelog::Check
24
+ register "mod changelog release", Commands::MOD::Changelog::Release
22
25
  register "mod check", Commands::MOD::Check
23
26
  register "mod list", Commands::MOD::List
24
27
  register "mod show", Commands::MOD::Show
@@ -69,6 +69,8 @@ module Factorix
69
69
  # MOD settings file errors
70
70
  class MODSectionNotFoundError < FileFormatError; end
71
71
 
72
+ class ChangelogParseError < FileFormatError; end
73
+
72
74
  # =====================================
73
75
  # Domain layer errors
74
76
  # =====================================
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Factorix
4
- VERSION = "0.8.1"
4
+ VERSION = "0.9.0"
5
5
  public_constant :VERSION
6
6
  end
@@ -0,0 +1,34 @@
1
+ # RBS type signature file
2
+ # NOTE: Do not include private method definitions in RBS files.
3
+ # Only public interfaces should be documented here.
4
+
5
+ module Factorix
6
+ class Changelog
7
+ class Section < Data
8
+ attr_reader version: MODVersion | String
9
+ attr_reader date: String?
10
+ attr_reader categories: Hash[String, Array[String]]
11
+
12
+ def self.[]: (version: MODVersion | String, date: String?, categories: Hash[String, Array[String]]) -> Section
13
+ end
14
+
15
+ SEPARATOR: String
16
+ UNRELEASED: String
17
+
18
+ def self.load: (Pathname path) -> Changelog
19
+
20
+ def self.parse: (String text) -> Changelog
21
+
22
+ private def self.new: (Array[Section] sections) -> instance
23
+
24
+ def save: (Pathname path) -> void
25
+
26
+ def add_entry: (MODVersion | String version, String category, String entry) -> void
27
+
28
+ def release_section: (MODVersion version, date: String) -> void
29
+
30
+ attr_reader sections: Array[Section]
31
+
32
+ def to_s: () -> String
33
+ end
34
+ end
@@ -0,0 +1,17 @@
1
+ # RBS type signature file
2
+ # NOTE: Do not include private method definitions in RBS files.
3
+ # Only public interfaces should be documented here.
4
+
5
+ module Factorix
6
+ class CLI
7
+ module Commands
8
+ module MOD
9
+ module Changelog
10
+ class Add < Base
11
+ def call: (?version: String, category: String, entry: Array[String], ?changelog: String, **untyped) -> void
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # RBS type signature file
2
+ # NOTE: Do not include private method definitions in RBS files.
3
+ # Only public interfaces should be documented here.
4
+
5
+ module Factorix
6
+ class CLI
7
+ module Commands
8
+ module MOD
9
+ module Changelog
10
+ class Check < Base
11
+ def call: (?release: bool, ?changelog: String, ?info_json: String, **untyped) -> void
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # RBS type signature file
2
+ # NOTE: Do not include private method definitions in RBS files.
3
+ # Only public interfaces should be documented here.
4
+
5
+ module Factorix
6
+ class CLI
7
+ module Commands
8
+ module MOD
9
+ module Changelog
10
+ class Release < Base
11
+ def call: (?version: String?, ?date: String?, ?changelog: String, ?info_json: String, **untyped) -> void
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -76,6 +76,9 @@ module Factorix
76
76
  class MODSectionNotFoundError < FileFormatError
77
77
  end
78
78
 
79
+ class ChangelogParseError < FileFormatError
80
+ end
81
+
79
82
  # =====================================
80
83
  # Domain layer errors
81
84
  # =====================================
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: factorix
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - OZAWA Sakuro
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-17 00:00:00.000000000 Z
11
+ date: 2026-02-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -255,6 +255,7 @@ files:
255
255
  - lib/factorix/cache/file_system.rb
256
256
  - lib/factorix/cache/redis.rb
257
257
  - lib/factorix/cache/s3.rb
258
+ - lib/factorix/changelog.rb
258
259
  - lib/factorix/cli.rb
259
260
  - lib/factorix/cli/commands/backup_support.rb
260
261
  - lib/factorix/cli/commands/base.rb
@@ -267,6 +268,9 @@ files:
267
268
  - lib/factorix/cli/commands/download_support.rb
268
269
  - lib/factorix/cli/commands/launch.rb
269
270
  - lib/factorix/cli/commands/man.rb
271
+ - lib/factorix/cli/commands/mod/changelog/add.rb
272
+ - lib/factorix/cli/commands/mod/changelog/check.rb
273
+ - lib/factorix/cli/commands/mod/changelog/release.rb
270
274
  - lib/factorix/cli/commands/mod/check.rb
271
275
  - lib/factorix/cli/commands/mod/disable.rb
272
276
  - lib/factorix/cli/commands/mod/download.rb
@@ -362,6 +366,7 @@ files:
362
366
  - sig/factorix/cache/file_system.rbs
363
367
  - sig/factorix/cache/redis.rbs
364
368
  - sig/factorix/cache/s3.rbs
369
+ - sig/factorix/changelog.rbs
365
370
  - sig/factorix/cli.rbs
366
371
  - sig/factorix/cli/commands/base.rbs
367
372
  - sig/factorix/cli/commands/cache/evict.rbs
@@ -371,6 +376,9 @@ files:
371
376
  - sig/factorix/cli/commands/confirmable.rbs
372
377
  - sig/factorix/cli/commands/download_support.rbs
373
378
  - sig/factorix/cli/commands/launch.rbs
379
+ - sig/factorix/cli/commands/mod/changelog/add.rbs
380
+ - sig/factorix/cli/commands/mod/changelog/check.rbs
381
+ - sig/factorix/cli/commands/mod/changelog/release.rbs
374
382
  - sig/factorix/cli/commands/mod/check.rbs
375
383
  - sig/factorix/cli/commands/mod/disable.rbs
376
384
  - sig/factorix/cli/commands/mod/download.rbs