exercism-rb 0.1.0 → 0.2.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: 1e3f086e7d56d3ef1314ac284fd277f39a34b6fba990e3979a20bb1a0a956673
4
- data.tar.gz: 7258ab70a894221f104f6acb044fdae965d6dcd73af1070e45e829e48e780931
3
+ metadata.gz: 52de31694fce53072b7cff336f8b31ee600c90dc10f847b2b73ff4b22616fc6b
4
+ data.tar.gz: 17c72ff5d2eea013d0e7edc0f8ac5a82533e5777ed9d555e8d35d10cfff45221
5
5
  SHA512:
6
- metadata.gz: 5ea881fb4335c63357ca7cbe2741c3ecda1d1c12a81363bb2d1354f35db9d6b7cea49fc098da57ccfbb659b164774b52214cbef972b97e80704baa8bb90df0ee
7
- data.tar.gz: 3537495bab0755c3521642d6014edd7a2f2ae633f8366f700f852bfc89d631495aaf56e3a0f12e874324db0de110c634be4020a86aca3c4dac5f72531d07109b
6
+ metadata.gz: 9372500b14d3b8e09a4e8bc48e54a19afb6a4eae9cd91730c70bd92974d78ab57db6c51d51bbff0a450e99e65684d7bdeb64914dc98085fb4174f05e1587dda9
7
+ data.tar.gz: 646c0ceebb0d862635cb4c17f10ba8aa9134437c5e512e3c9dc6e58f95d03aec96c38834b1d2eb9444c3c9d8e7e229967784d783ea491005ca0814f572af326b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.2.0 - 2026-05-06
11
+
12
+ ### Added
13
+
14
+ - Support for `.exercism/config.json` `files.solution` and `files.test` metadata.
15
+ - `--no-edit` for `xrb new` to download and save the current exercise without opening an editor.
16
+ - Repeatable `--file FILE` overrides for `xrb submit` and `xrb test`.
17
+ - Standard, Bundler Audit, Reek, RubyCritic, and opt-in SimpleCov coverage as development quality tools.
18
+
19
+ ### Changed
20
+
21
+ - `xrb submit` now delegates default file selection to the Exercism CLI when exercise config is available.
22
+ - `xrb test` can run multiple configured or explicitly selected test files in order.
23
+ - `xrb edit` and `xrb new` now require an explicit editor through `XRB_EDITOR`, `VISUAL`, or `EDITOR` instead of assuming `nvim`.
24
+ - CLI status, warning, and error output now uses color without log-style labels.
25
+ - Release process details moved from `README.md` to `CONTRIBUTING.md`.
26
+
27
+ ### Removed
28
+
29
+ - Removed the legacy source installer; RubyGems is now the only supported installation path.
30
+
31
+ ### Fixed
32
+
33
+ - `xrb new` now explains the likely workspace mismatch when download succeeds but the expected exercise directory is missing.
34
+ - Command execution errors now distinguish missing commands from commands that ran and returned a non-zero exit code.
35
+
10
36
  ## 0.1.0 - 2026-05-06
11
37
 
12
38
  ### Added
data/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # exercism-rb
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/exercism-rb.svg)](https://rubygems.org/gems/exercism-rb)
4
+ [![CI](https://github.com/hvpaiva/exercism-rb/actions/workflows/ci.yml/badge.svg)](https://github.com/hvpaiva/exercism-rb/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/github/license/hvpaiva/exercism-rb)](LICENSE)
6
+ [![Downloads](https://img.shields.io/gem/dt/exercism-rb)](https://rubygems.org/gems/exercism-rb)
7
+
3
8
  `xrb` is a small CLI that removes friction from the Exercism Ruby workflow.
4
9
 
5
10
  It remembers the current exercise, runs commands from the right exercise directory, opens your editor, starts IRB with the solution file loaded, runs tests, and submits the solution file without requiring manual `cd` work.
@@ -30,7 +35,7 @@ gem update exercism-rb
30
35
 
31
36
  - Ruby 3.2+
32
37
  - Exercism CLI for `xrb new` and `xrb submit`
33
- - An editor available on `PATH`
38
+ - An editor available on `PATH` and configured through `XRB_EDITOR`, `VISUAL`, or `EDITOR` for `xrb edit` and default `xrb new`
34
39
 
35
40
  Configure the Exercism CLI separately:
36
41
 
@@ -53,9 +58,9 @@ xrb submit
53
58
  ```bash
54
59
  xrb new <exercise> # download, save as current, and open the editor
55
60
  xrb edit [exercise] # open the editor for an exercise
56
- xrb test [exercise] # run the exercise test file with minitest/pride
61
+ xrb test [exercise] # run configured or selected test files
57
62
  xrb irb [exercise] # open irb -r ./<solution>.rb --simple-prompt
58
- xrb submit [exercise] # submit the solution .rb file
63
+ xrb submit [exercise] # submit through the Exercism CLI
59
64
  xrb use <exercise> # save a downloaded exercise as current
60
65
  xrb current # show the current exercise
61
66
  xrb path [exercise] # print the exercise path
@@ -69,7 +74,18 @@ Exercise resolution priority:
69
74
  2. Current working directory when inside `XRB_ROOT`
70
75
  3. Saved state from the previous `xrb new` or `xrb use`
71
76
 
72
- `xrb test` expects a single `*_test.rb` file in the exercise directory and reports an ambiguity if more than one is present.
77
+ `xrb test` reads `.exercism/config.json` when available and runs each file listed in `files.test`. Without that config, it preserves the older fallback of requiring a single `*_test.rb` file.
78
+
79
+ `xrb submit` lets the Exercism CLI choose default solution files when `.exercism/config.json` is present. Without that config, it preserves the older fallback of requiring a single solution `.rb` file.
80
+
81
+ Both `xrb test` and `xrb submit` accept a repeatable explicit override:
82
+
83
+ ```bash
84
+ xrb test --file custom_test.rb
85
+ xrb submit two-fer --file two_fer.rb --file helper.rb
86
+ ```
87
+
88
+ Use `xrb new <exercise> --no-edit` to download and save the exercise as current without opening an editor.
73
89
 
74
90
  ## Output And Color
75
91
 
@@ -109,53 +125,19 @@ updated_at = "2026-05-05T12:00:00Z"
109
125
  ```bash
110
126
  XRB_ROOT=~/exercism/ruby # exercise directory
111
127
  XRB_TRACK=ruby # Exercism track
112
- XRB_EDITOR=nvim # editor used by xrb new/edit
128
+ XRB_EDITOR="code --wait" # editor used by xrb new/edit
113
129
  XRB_STATE=~/.local/state/exercism-rb/state.toml
114
130
  XRB_COLOR=auto # auto, always, or never
115
131
  ```
116
132
 
117
133
  `xrb new` uses `exercism download`, which downloads into the workspace configured in the Exercism CLI. If you customize `XRB_ROOT`, configure the Exercism workspace so its track directory matches it, for example `exercism configure --workspace ~/exercism` for `XRB_ROOT=~/exercism/ruby`.
118
134
 
119
- Editor commands are split with shell-like quoting, so this works:
135
+ Set `XRB_EDITOR`, `VISUAL`, or `EDITOR` before running `xrb edit` or `xrb new` without `--no-edit`. Editor commands are split with shell-like quoting, so this works:
120
136
 
121
137
  ```bash
122
138
  XRB_EDITOR="code --wait" xrb edit
123
139
  ```
124
140
 
125
- ## Source Installer
126
-
127
- RubyGems is the recommended installation path. The repository also keeps a source installer for users who want to install directly from `main`:
128
-
129
- ```bash
130
- curl -fsSL https://raw.githubusercontent.com/hvpaiva/exercism-rb/main/install.rb | ruby
131
- ```
132
-
133
- The installer clones or updates the repository at `~/.local/share/exercism-rb` and creates this symlink:
134
-
135
- ```text
136
- ~/.local/bin/xrb -> ~/.local/share/exercism-rb/bin/xrb
137
- ```
138
-
139
- It also installs the Exercism CLI into `~/.local/bin/exercism` when `exercism` is not already available.
140
-
141
- To skip the Exercism CLI install:
142
-
143
- ```bash
144
- curl -fsSL https://raw.githubusercontent.com/hvpaiva/exercism-rb/main/install.rb | ruby - --no-exercism
145
- ```
146
-
147
- To force-install or update the Exercism CLI:
148
-
149
- ```bash
150
- curl -fsSL https://raw.githubusercontent.com/hvpaiva/exercism-rb/main/install.rb | ruby - --with-exercism
151
- ```
152
-
153
- If `~/.local/bin/xrb` is an existing symlink, the installer replaces the symlink without deleting the old target. If it is a real file or directory, the installer refuses to replace it unless you opt in:
154
-
155
- ```bash
156
- curl -fsSL https://raw.githubusercontent.com/hvpaiva/exercism-rb/main/install.rb | XRB_INSTALL_OVERWRITE=1 ruby
157
- ```
158
-
159
141
  ## Development
160
142
 
161
143
  Install development dependencies:
@@ -176,7 +158,7 @@ Run the full verification suite:
176
158
  bundle exec rake ci
177
159
  ```
178
160
 
179
- The CI task checks syntax, runs tests, runs tests with Ruby warnings enabled, smoke-tests the checkout executable, builds the gem, installs it into an isolated `GEM_HOME`, and smoke-tests the installed `xrb` executable.
161
+ The CI task checks syntax, runs tests, runs tests with Ruby warnings enabled, runs required quality checks, smoke-tests the checkout executable, builds the gem, installs it into an isolated `GEM_HOME`, and smoke-tests the installed `xrb` executable.
180
162
 
181
163
  Useful individual tasks:
182
164
 
@@ -184,29 +166,23 @@ Useful individual tasks:
184
166
  bundle exec rake test
185
167
  bundle exec rake syntax
186
168
  bundle exec rake warnings
169
+ bundle exec rake style
170
+ bundle exec rake audit
171
+ bundle exec rake quality
172
+ bundle exec rake coverage
187
173
  bundle exec rake smoke:bin
188
174
  bundle exec rake smoke:gem
189
175
  ```
190
176
 
191
- ## Release
192
-
193
- Publishing is automated through RubyGems Trusted Publishing and GitHub Actions. No long-lived `RUBYGEMS_AUTH_TOKEN` is required for the recommended release path.
177
+ Optional maintenance reports:
194
178
 
195
- Before the first release, configure a pending trusted publisher on RubyGems.org:
196
-
197
- ```text
198
- Gem name: exercism-rb
199
- GitHub repository: hvpaiva/exercism-rb
200
- Workflow filename: release.yml
201
- Environment: release
179
+ ```bash
180
+ bundle exec rake smells
181
+ bundle exec rake critic
202
182
  ```
203
183
 
204
- Release checklist:
184
+ `bundle exec rake critic` writes its report to `tmp/rubycritic/`, which is ignored by Git.
205
185
 
206
- 1. Update `lib/exercism/rb/version.rb`
207
- 2. Update `CHANGELOG.md`
208
- 3. Run `bundle exec rake ci`
209
- 4. Commit the release changes
210
- 5. Create and push a tag that matches the version, for example `v0.1.0` for `VERSION = "0.1.0"`
186
+ ## Release
211
187
 
212
- The `Release` workflow runs only for `v*` tags. It verifies the project and publishes the gem through `rubygems/release-gem@v1`.
188
+ Release process details are maintainer documentation and live in `CONTRIBUTING.md`.
@@ -6,6 +6,24 @@ module Exercism
6
6
  module Rb
7
7
  class CLI
8
8
  COMMANDS = %w[new edit test irb submit use current path list clear help version].freeze
9
+ COMMAND_METHODS = {
10
+ "new" => :new_command,
11
+ "edit" => :edit_command,
12
+ "test" => :test_command,
13
+ "irb" => :irb_command,
14
+ "submit" => :submit_command,
15
+ "use" => :use_command,
16
+ "current" => :current_command,
17
+ "path" => :path_command,
18
+ "list" => :list_command,
19
+ "clear" => :clear_command,
20
+ "help" => :help_command,
21
+ "-h" => :help_command,
22
+ "--help" => :help_command,
23
+ "version" => :version_command,
24
+ "-v" => :version_command,
25
+ "--version" => :version_command
26
+ }.freeze
9
27
 
10
28
  def self.start(argv, out: $stdout, err: $stderr)
11
29
  new(argv, out: out, err: err).start
@@ -23,27 +41,13 @@ module Exercism
23
41
 
24
42
  def start
25
43
  command = @argv.shift || "help"
44
+ handler = COMMAND_METHODS.fetch(command) { raise Error, "Unknown command: #{command}. Use `xrb help`." }
26
45
 
27
- case command
28
- when "new" then new_command
29
- when "edit" then edit_command
30
- when "test" then test_command
31
- when "irb" then irb_command
32
- when "submit" then submit_command
33
- when "use" then use_command
34
- when "current" then current_command
35
- when "path" then path_command
36
- when "list" then list_command
37
- when "clear" then clear_command
38
- when "help", "-h", "--help" then help_command
39
- when "version", "-v", "--version" then version_command
40
- else
41
- raise Error, "Unknown command: #{command}. Use `xrb help`."
42
- end
46
+ __send__(handler)
43
47
 
44
48
  0
45
- rescue Error => e
46
- @ui.error(e.message)
49
+ rescue Error => error
50
+ @ui.error(error.message)
47
51
  1
48
52
  rescue Interrupt
49
53
  @ui.error("Interrupted.")
@@ -53,14 +57,15 @@ module Exercism
53
57
  private
54
58
 
55
59
  def new_command
60
+ skip_editor = extract_no_edit_option!
56
61
  exercise = exercise_from_required_arg("new <exercise>", require_existing: false)
57
62
 
58
63
  @ui.info("Downloading #{@ui.highlight(exercise.slug)}...")
59
64
  @runner.run("exercism", "download", "--track=#{exercise.track}", "--exercise=#{exercise.slug}")
60
- exercise.ensure_exists!
65
+ ensure_download_created_exercise!(exercise)
61
66
  save_current(exercise)
62
67
  @ui.key_value("Path", @ui.path(exercise.path))
63
- edit_exercise(exercise)
68
+ edit_exercise(exercise) unless skip_editor
64
69
  end
65
70
 
66
71
  def edit_command
@@ -68,11 +73,15 @@ module Exercism
68
73
  end
69
74
 
70
75
  def test_command
76
+ files = extract_file_options!
71
77
  exercise = @resolver.resolve(optional_arg)
72
- test_file = exercise.test_file
78
+ test_files = files.empty? ? exercise.test_files(ambiguity_hint: "Use --file FILE to choose test files explicitly.") : files
79
+ ensure_test_files_exist!(exercise, test_files)
73
80
 
74
81
  @ui.info("Testing #{@ui.highlight(exercise.slug)}...")
75
- @runner.run("ruby", "-r", "minitest/pride", test_file, chdir: exercise.path)
82
+ test_files.each do |test_file|
83
+ @runner.run("ruby", "-r", "minitest/pride", test_file, chdir: exercise.path)
84
+ end
76
85
  end
77
86
 
78
87
  def irb_command
@@ -84,11 +93,16 @@ module Exercism
84
93
  end
85
94
 
86
95
  def submit_command
96
+ files = extract_file_options!
87
97
  exercise = @resolver.resolve(optional_arg)
88
- solution_file = exercise.solution_file
89
98
 
90
99
  @ui.info("Submitting #{@ui.highlight(exercise.slug)}...")
91
- @runner.run("exercism", "submit", solution_file, chdir: exercise.path)
100
+ if files.empty? && exercise.exercism_config?
101
+ @runner.run("exercism", "submit", chdir: exercise.path)
102
+ else
103
+ files = [exercise.solution_file(ambiguity_hint: "Use --file FILE to choose files explicitly.")] if files.empty?
104
+ @runner.run("exercism", "submit", *files, chdir: exercise.path)
105
+ end
92
106
  end
93
107
 
94
108
  def use_command
@@ -121,8 +135,8 @@ module Exercism
121
135
 
122
136
  current = @state.load["exercise"]
123
137
  exercises = Dir.children(@root)
124
- .select { |name| File.directory?(File.join(@root, name)) && !name.start_with?(".") }
125
- .sort
138
+ .select { |name| File.directory?(File.join(@root, name)) && !name.start_with?(".") }
139
+ .sort
126
140
 
127
141
  if exercises.empty?
128
142
  @ui.warn("No exercises downloaded in #{@root}.")
@@ -131,9 +145,9 @@ module Exercism
131
145
 
132
146
  @ui.section("Downloaded exercises")
133
147
  exercises.each do |slug|
134
- marker = slug == current ? "*" : " "
135
- label = slug == current ? @ui.highlight(slug) : slug
136
- suffix = slug == current ? " #{@ui.muted('current')}" : ""
148
+ marker = (slug == current) ? "*" : " "
149
+ label = (slug == current) ? @ui.highlight(slug) : slug
150
+ suffix = (slug == current) ? " #{@ui.muted("current")}" : ""
137
151
  @ui.say("#{marker} #{label}#{suffix}")
138
152
  end
139
153
  end
@@ -145,27 +159,31 @@ module Exercism
145
159
 
146
160
  def help_command
147
161
  @ui.say(<<~HELP)
148
- #{@ui.bold('xrb')} - Exercism Ruby helper
162
+ #{@ui.bold("xrb")} - Exercism Ruby helper
149
163
 
150
164
  Usage:
151
165
  xrb new <exercise> download, save as current, and open the editor
152
166
  xrb edit [exercise] open the editor for an exercise
153
- xrb test [exercise] run the exercise test file with minitest/pride
167
+ xrb test [exercise] run exercise tests with minitest/pride
154
168
  xrb irb [exercise] open irb -r ./<solution>.rb --simple-prompt
155
- xrb submit [exercise] submit the solution .rb file
169
+ xrb submit [exercise] submit the exercise solution
156
170
  xrb use <exercise> save a downloaded exercise as current
157
171
  xrb current show the current exercise
158
172
  xrb path [exercise] print the exercise path
159
173
  xrb list list downloaded exercises
160
174
  xrb clear clear saved state
161
175
 
176
+ Options:
177
+ --no-edit skip opening the editor after xrb new
178
+ --file FILE test or submit an explicit file; may be repeated
179
+
162
180
  State:
163
181
  #{@state.path}
164
182
 
165
183
  Environment:
166
184
  XRB_ROOT exercise directory (current: #{@root})
167
185
  XRB_TRACK Exercism track (current: #{@track})
168
- XRB_EDITOR editor used by xrb edit/new (default: nvim)
186
+ XRB_EDITOR editor used by xrb edit/new
169
187
  XRB_STATE TOML state file
170
188
  XRB_COLOR color output: auto, always, or never
171
189
  HELP
@@ -185,10 +203,82 @@ module Exercism
185
203
  @runner.run(*editor_args, target, chdir: exercise.path)
186
204
  end
187
205
 
206
+ def ensure_download_created_exercise!(exercise)
207
+ return if exercise.exists?
208
+
209
+ raise Error, <<~MESSAGE.chomp
210
+ Download completed, but xrb could not find the expected exercise directory:
211
+ #{exercise.path}
212
+
213
+ The Exercism CLI probably downloaded to another workspace. Configure it to match XRB_ROOT:
214
+ exercism configure --workspace #{File.dirname(@root)}
215
+ MESSAGE
216
+ end
217
+
218
+ def ensure_test_files_exist!(exercise, test_files)
219
+ test_files.each do |file|
220
+ next if File.file?(File.absolute_path(file, exercise.path))
221
+
222
+ raise Error, "Test file not found: #{file}"
223
+ end
224
+ end
225
+
226
+ def extract_file_options!
227
+ files = []
228
+ remaining = []
229
+
230
+ until @argv.empty?
231
+ arg = @argv.shift
232
+
233
+ case arg
234
+ when "--file"
235
+ value = @argv.shift
236
+ raise Error, "Missing value for --file" if blank?(value)
237
+
238
+ files << value
239
+ when /\A--file=(.*)\z/
240
+ value = Regexp.last_match(1)
241
+ raise Error, "Missing value for --file" if blank?(value)
242
+
243
+ files << value
244
+ else
245
+ raise Error, "Unknown option: #{arg}" if arg.start_with?("-")
246
+
247
+ remaining << arg
248
+ end
249
+ end
250
+
251
+ @argv = remaining
252
+ files
253
+ end
254
+
255
+ def extract_no_edit_option!
256
+ skip_editor = false
257
+ remaining = []
258
+
259
+ until @argv.empty?
260
+ arg = @argv.shift
261
+
262
+ if arg == "--no-edit"
263
+ skip_editor = true
264
+ else
265
+ raise Error, "Unknown option: #{arg}" if arg.start_with?("-")
266
+
267
+ remaining << arg
268
+ end
269
+ end
270
+
271
+ @argv = remaining
272
+ skip_editor
273
+ end
274
+
188
275
  def editor_args_from_config
189
- Shellwords.split(Config.editor)
190
- rescue ArgumentError => e
191
- raise Error, "Invalid editor in XRB_EDITOR/VISUAL/EDITOR: #{e.message}"
276
+ editor = Config.editor
277
+ raise Error, "No editor configured. Set XRB_EDITOR, VISUAL, or EDITOR." if blank?(editor)
278
+
279
+ Shellwords.split(editor)
280
+ rescue ArgumentError => error
281
+ raise Error, "Invalid editor in XRB_EDITOR/VISUAL/EDITOR: #{error.message}"
192
282
  end
193
283
 
194
284
  def ensure_editor_available!(command, chdir:)
@@ -229,7 +319,7 @@ module Exercism
229
319
  def exercise_from_required_arg(usage, require_existing: true)
230
320
  slug = @argv.shift
231
321
  raise Error, "Usage: xrb #{usage}" if blank?(slug)
232
- raise Error, "Too many arguments: #{@argv.join(' ')}" unless @argv.empty?
322
+ raise Error, "Too many arguments: #{@argv.join(" ")}" unless @argv.empty?
233
323
 
234
324
  Exercise.new(slug: slug, track: @track, root: @root).tap do |exercise|
235
325
  exercise.ensure_exists! if require_existing
@@ -238,7 +328,7 @@ module Exercism
238
328
 
239
329
  def optional_arg
240
330
  slug = @argv.shift
241
- raise Error, "Too many arguments: #{@argv.join(' ')}" unless @argv.empty?
331
+ raise Error, "Too many arguments: #{@argv.join(" ")}" unless @argv.empty?
242
332
 
243
333
  slug
244
334
  end
@@ -15,12 +15,19 @@ module Exercism
15
15
  @ui.command("$ #{printable}")
16
16
 
17
17
  ok = if chdir
18
- Dir.chdir(chdir) { system(*args) }
19
- else
20
- system(*args)
21
- end
18
+ Dir.chdir(chdir) { system(*args) }
19
+ else
20
+ system(*args)
21
+ end
22
22
 
23
- raise Error, "Command failed: #{Shellwords.join(args)}" unless ok
23
+ case ok
24
+ when true
25
+ true
26
+ when nil
27
+ raise Error, "Command not found: #{args.first}. Install it or ensure it is on PATH."
28
+ else
29
+ raise Error, "Command failed: #{Shellwords.join(args)}"
30
+ end
24
31
 
25
32
  true
26
33
  end
@@ -18,7 +18,7 @@ module Exercism
18
18
  end
19
19
 
20
20
  def editor
21
- ENV["XRB_EDITOR"] || ENV["VISUAL"] || ENV["EDITOR"] || "nvim"
21
+ ENV["XRB_EDITOR"] || ENV["VISUAL"] || ENV["EDITOR"]
22
22
  end
23
23
  end
24
24
  end
@@ -1,9 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Exercism
4
6
  module Rb
5
7
  class Exercise
6
8
  SLUG_PATTERN = /\A[a-z0-9][a-z0-9-]*\z/
9
+ FILE_KINDS = {
10
+ "test" => {config: "test file", fallback: "test file (*_test.rb)"},
11
+ "solution" => {config: "solution file", fallback: "solution file (.rb)"}
12
+ }.freeze
7
13
 
8
14
  attr_reader :slug, :track, :root, :path
9
15
 
@@ -16,6 +22,7 @@ module Exercism
16
22
  @track = track
17
23
  @root = File.expand_path(root)
18
24
  @path = File.expand_path(path || File.join(@root, @slug))
25
+ @exercism_config = nil
19
26
  end
20
27
 
21
28
  def exists?
@@ -28,32 +35,124 @@ module Exercism
28
35
  raise Error, "Exercise not found: #{@path}"
29
36
  end
30
37
 
31
- def test_file
32
- ensure_exists!
33
- pick_one(files_matching { |name| name.end_with?("_test.rb") }, kind: "test file (*_test.rb)")
38
+ def exercism_config?
39
+ File.file?(config_path)
34
40
  end
35
41
 
36
- def solution_file
37
- ensure_exists!
38
- pick_one(files_matching { |name| name.end_with?(".rb") && !name.end_with?("_test.rb") }, kind: "solution file (.rb)")
42
+ def test_files(ambiguity_hint: nil)
43
+ exercise_files("test", ambiguity_hint: ambiguity_hint) do
44
+ fallback_test_files
45
+ end
46
+ end
47
+
48
+ def solution_files(ambiguity_hint: nil)
49
+ exercise_files("solution", ambiguity_hint: ambiguity_hint) do
50
+ fallback_solution_files
51
+ end
52
+ end
53
+
54
+ def test_file(ambiguity_hint: nil)
55
+ pick_one(test_files(ambiguity_hint: ambiguity_hint), kind: "test file (*_test.rb)", ambiguity_hint: ambiguity_hint)
56
+ end
57
+
58
+ def solution_file(ambiguity_hint: nil)
59
+ pick_one(solution_files(ambiguity_hint: ambiguity_hint), kind: "solution file (.rb)", ambiguity_hint: ambiguity_hint)
39
60
  end
40
61
 
41
62
  private
42
63
 
64
+ def config_path
65
+ File.join(@path, ".exercism", "config.json")
66
+ end
67
+
68
+ def exercise_files(config_name, ambiguity_hint:)
69
+ ensure_exists!
70
+
71
+ kinds = FILE_KINDS.fetch(config_name)
72
+ configured_files(config_name, kind: kinds.fetch(:config)) || [pick_one(yield, kind: kinds.fetch(:fallback), ambiguity_hint: ambiguity_hint)]
73
+ end
74
+
75
+ def exercism_config
76
+ return nil unless exercism_config?
77
+ return @exercism_config unless @exercism_config.nil?
78
+
79
+ config = JSON.parse(File.read(config_path))
80
+ raise_invalid_config("root must be an object") unless config.is_a?(Hash)
81
+
82
+ @exercism_config = config
83
+ rescue JSON::ParserError => error
84
+ raise Error, "Invalid Exercism config: #{config_path}: #{error.message}"
85
+ end
86
+
87
+ def configured_files(name, kind:)
88
+ config = exercism_config
89
+ return nil if config.nil?
90
+
91
+ values = configured_file_values(config, name)
92
+ return nil if values.nil?
93
+
94
+ ensure_configured_files_exist(values, kind: kind)
95
+ end
96
+
97
+ def configured_file_values(config, name)
98
+ files = config.fetch("files", nil)
99
+ return nil if files.nil?
100
+ raise_invalid_config("files must be an object") unless files.is_a?(Hash)
101
+ return nil unless files.key?(name)
102
+
103
+ values = files.fetch(name)
104
+ validate_configured_file_list(name, values)
105
+ end
106
+
107
+ def validate_configured_file_list(name, values)
108
+ raise_invalid_config("files.#{name} must be an array") unless values.is_a?(Array)
109
+ raise_invalid_config("files.#{name} must not be empty") if values.empty?
110
+
111
+ values.each do |file|
112
+ raise_invalid_config("files.#{name} contains an empty path") unless file.is_a?(String) && !file.strip.empty?
113
+ end
114
+
115
+ values
116
+ end
117
+
118
+ def ensure_configured_files_exist(files, kind:)
119
+ files.each do |file|
120
+ next if File.file?(File.absolute_path(file, @path))
121
+
122
+ raise Error, "Configured #{kind} not found: #{file}"
123
+ end
124
+
125
+ files
126
+ end
127
+
128
+ def raise_invalid_config(message)
129
+ raise Error, "Invalid Exercism config: #{config_path}: #{message}"
130
+ end
131
+
132
+ def fallback_test_files
133
+ files_matching { |name| name.end_with?("_test.rb") }
134
+ end
135
+
136
+ def fallback_solution_files
137
+ files_matching { |name| name.end_with?(".rb") && !name.end_with?("_test.rb") }
138
+ end
139
+
43
140
  def files_matching
44
141
  Dir.children(@path)
45
- .select { |name| File.file?(File.join(@path, name)) && yield(name) }
46
- .sort
142
+ .select { |name| File.file?(File.join(@path, name)) && yield(name) }
143
+ .sort
47
144
  end
48
145
 
49
- def pick_one(files, kind:)
146
+ def pick_one(files, kind:, ambiguity_hint: nil)
50
147
  case files.length
51
148
  when 0
52
149
  raise Error, "Could not find #{kind} in #{@path}"
53
150
  when 1
54
151
  files.first
55
152
  else
56
- raise Error, "Found more than one #{kind} in #{@path}: #{files.join(', ')}"
153
+ message = "Found more than one #{kind} in #{@path}: #{files.join(", ")}"
154
+ message = "#{message}. #{ambiguity_hint}" if ambiguity_hint
155
+ raise Error, message
57
156
  end
58
157
  end
59
158
  end
@@ -11,10 +11,10 @@ module Exercism
11
11
 
12
12
  def resolve(slug = nil, require_existing: true)
13
13
  exercise = if present?(slug)
14
- Exercise.new(slug: slug, track: @track, root: @root)
15
- else
16
- from_current_directory || from_state
17
- end
14
+ Exercise.new(slug: slug, track: @track, root: @root)
15
+ else
16
+ from_current_directory || from_state
17
+ end
18
18
 
19
19
  exercise.ensure_exists! if require_existing
20
20
  exercise
@@ -93,11 +93,11 @@ module Exercism
93
93
 
94
94
  def quote(value)
95
95
  escaped = value.to_s
96
- .gsub("\\", "\\\\")
97
- .gsub('"', '\\"')
98
- .gsub("\n", "\\n")
99
- .gsub("\r", "\\r")
100
- .gsub("\t", "\\t")
96
+ .gsub("\\", "\\\\")
97
+ .gsub('"', '\\"')
98
+ .gsub("\n", "\\n")
99
+ .gsub("\r", "\\r")
100
+ .gsub("\t", "\\t")
101
101
 
102
102
  %("#{escaped}")
103
103
  end
@@ -50,19 +50,19 @@ module Exercism
50
50
  end
51
51
 
52
52
  def info(message)
53
- @out.puts("#{paint('info', :blue)} #{message}")
53
+ @out.puts(paint(message, :blue))
54
54
  end
55
55
 
56
56
  def success(message)
57
- @out.puts("#{paint('done', :green)} #{message}")
57
+ @out.puts(paint(message, :green))
58
58
  end
59
59
 
60
60
  def warn(message)
61
- @err.puts("#{paint('warn', :yellow)} #{message}")
61
+ @err.puts(paint(message, :yellow))
62
62
  end
63
63
 
64
64
  def error(message)
65
- @err.puts("#{paint('error', :red)} #{message}")
65
+ @err.puts(paint(message, :red))
66
66
  end
67
67
 
68
68
  def command(message)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Exercism
4
4
  module Rb
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exercism-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - hvpaiva
@@ -10,6 +10,20 @@ bindir: bin
10
10
  cert_chain: []
11
11
  date: 2026-05-06 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler-audit
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.9'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.9'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: minitest
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -38,6 +52,62 @@ dependencies:
38
52
  - - "~>"
39
53
  - !ruby/object:Gem::Version
40
54
  version: '13.2'
55
+ - !ruby/object:Gem::Dependency
56
+ name: reek
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubycritic
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: simplecov
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.22'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.22'
97
+ - !ruby/object:Gem::Dependency
98
+ name: standard
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.40'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.40'
41
111
  description: A small command-line helper that keeps Exercism Ruby exercises easy to
42
112
  open, test, inspect, and submit.
43
113
  email: