exercism-rb 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1e3f086e7d56d3ef1314ac284fd277f39a34b6fba990e3979a20bb1a0a956673
4
+ data.tar.gz: 7258ab70a894221f104f6acb044fdae965d6dcd73af1070e45e829e48e780931
5
+ SHA512:
6
+ metadata.gz: 5ea881fb4335c63357ca7cbe2741c3ecda1d1c12a81363bb2d1354f35db9d6b7cea49fc098da57ccfbb659b164774b52214cbef972b97e80704baa8bb90df0ee
7
+ data.tar.gz: 3537495bab0755c3521642d6014edd7a2f2ae633f8366f700f852bfc89d631495aaf56e3a0f12e874324db0de110c634be4020a86aca3c4dac5f72531d07109b
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## Unreleased
9
+
10
+ ## 0.1.0 - 2026-05-06
11
+
12
+ ### Added
13
+
14
+ - `xrb` CLI for downloading, selecting, opening, testing, inspecting, and submitting Exercism Ruby exercises.
15
+ - Source installer for users who want to install directly from the repository.
16
+ - RubyGems release preparation with CI, packaged gem smoke tests, and Trusted Publishing workflow.
17
+ - Colorized CLI output with explicit `XRB_COLOR`, `NO_COLOR`, and `CLICOLOR_FORCE` controls.
18
+ - Project documentation for development, security, contribution, and release practices.
19
+
20
+ ### Changed
21
+
22
+ - Installation documentation now treats RubyGems as the primary distribution channel.
23
+
24
+ ### Fixed
25
+
26
+ - State saves now use `Process.pid` for temporary files, avoiding Ruby warnings from an uninitialized global variable.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hvpaiva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # exercism-rb
2
+
3
+ `xrb` is a small CLI that removes friction from the Exercism Ruby workflow.
4
+
5
+ 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.
6
+
7
+ This is an independent helper for the Exercism Ruby track, not an official Exercism project.
8
+
9
+ ## Install
10
+
11
+ Install from RubyGems:
12
+
13
+ ```bash
14
+ gem install exercism-rb
15
+ ```
16
+
17
+ Make sure your RubyGems executable directory is in your `PATH`, then verify the CLI:
18
+
19
+ ```bash
20
+ xrb version
21
+ ```
22
+
23
+ To update:
24
+
25
+ ```bash
26
+ gem update exercism-rb
27
+ ```
28
+
29
+ ## Requirements
30
+
31
+ - Ruby 3.2+
32
+ - Exercism CLI for `xrb new` and `xrb submit`
33
+ - An editor available on `PATH`
34
+
35
+ Configure the Exercism CLI separately:
36
+
37
+ ```bash
38
+ exercism configure --token=<your-api-token>
39
+ ```
40
+
41
+ ## Main Flow
42
+
43
+ ```bash
44
+ xrb new assembly-line
45
+ xrb test
46
+ xrb irb
47
+ xrb edit
48
+ xrb submit
49
+ ```
50
+
51
+ ## Commands
52
+
53
+ ```bash
54
+ xrb new <exercise> # download, save as current, and open the editor
55
+ xrb edit [exercise] # open the editor for an exercise
56
+ xrb test [exercise] # run the exercise test file with minitest/pride
57
+ xrb irb [exercise] # open irb -r ./<solution>.rb --simple-prompt
58
+ xrb submit [exercise] # submit the solution .rb file
59
+ xrb use <exercise> # save a downloaded exercise as current
60
+ xrb current # show the current exercise
61
+ xrb path [exercise] # print the exercise path
62
+ xrb list # list downloaded exercises
63
+ xrb clear # clear saved state
64
+ ```
65
+
66
+ Exercise resolution priority:
67
+
68
+ 1. Explicit slug, for example `xrb test assembly-line`
69
+ 2. Current working directory when inside `XRB_ROOT`
70
+ 3. Saved state from the previous `xrb new` or `xrb use`
71
+
72
+ `xrb test` expects a single `*_test.rb` file in the exercise directory and reports an ambiguity if more than one is present.
73
+
74
+ ## Output And Color
75
+
76
+ `xrb` uses color automatically when stdout is a terminal. It stays plain when output is redirected, piped, or captured by tests.
77
+
78
+ Color controls:
79
+
80
+ ```bash
81
+ XRB_COLOR=auto # default
82
+ XRB_COLOR=always # force ANSI color
83
+ XRB_COLOR=never # disable ANSI color
84
+ NO_COLOR=1 # disable color in auto mode
85
+ CLICOLOR_FORCE=1 # force color in auto mode
86
+ ```
87
+
88
+ `xrb path` intentionally prints only the resolved path so it can be used in scripts.
89
+
90
+ ## State
91
+
92
+ The current exercise is stored as flat TOML:
93
+
94
+ ```text
95
+ ~/.local/state/exercism-rb/state.toml
96
+ ```
97
+
98
+ Example:
99
+
100
+ ```toml
101
+ track = "ruby"
102
+ exercise = "assembly-line"
103
+ path = "/home/hvpaiva/exercism/ruby/assembly-line"
104
+ updated_at = "2026-05-05T12:00:00Z"
105
+ ```
106
+
107
+ ## Configuration
108
+
109
+ ```bash
110
+ XRB_ROOT=~/exercism/ruby # exercise directory
111
+ XRB_TRACK=ruby # Exercism track
112
+ XRB_EDITOR=nvim # editor used by xrb new/edit
113
+ XRB_STATE=~/.local/state/exercism-rb/state.toml
114
+ XRB_COLOR=auto # auto, always, or never
115
+ ```
116
+
117
+ `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
+
119
+ Editor commands are split with shell-like quoting, so this works:
120
+
121
+ ```bash
122
+ XRB_EDITOR="code --wait" xrb edit
123
+ ```
124
+
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
+ ## Development
160
+
161
+ Install development dependencies:
162
+
163
+ ```bash
164
+ bundle install
165
+ ```
166
+
167
+ Run the default test suite:
168
+
169
+ ```bash
170
+ bundle exec rake
171
+ ```
172
+
173
+ Run the full verification suite:
174
+
175
+ ```bash
176
+ bundle exec rake ci
177
+ ```
178
+
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.
180
+
181
+ Useful individual tasks:
182
+
183
+ ```bash
184
+ bundle exec rake test
185
+ bundle exec rake syntax
186
+ bundle exec rake warnings
187
+ bundle exec rake smoke:bin
188
+ bundle exec rake smoke:gem
189
+ ```
190
+
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.
194
+
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
202
+ ```
203
+
204
+ Release checklist:
205
+
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"`
211
+
212
+ The `Release` workflow runs only for `v*` tags. It verifies the project and publishes the gem through `rubygems/release-gem@v1`.
data/bin/xrb ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+
6
+ require "exercism/rb"
7
+
8
+ exit Exercism::Rb::CLI.start(ARGV)
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Exercism
6
+ module Rb
7
+ class CLI
8
+ COMMANDS = %w[new edit test irb submit use current path list clear help version].freeze
9
+
10
+ def self.start(argv, out: $stdout, err: $stderr)
11
+ new(argv, out: out, err: err).start
12
+ end
13
+
14
+ def initialize(argv, out: $stdout, err: $stderr)
15
+ @argv = argv.dup
16
+ @ui = UI.new(out: out, err: err)
17
+ @state = State.new
18
+ @runner = CommandRunner.new(ui: @ui)
19
+ @track = Config.track
20
+ @root = Config.root(@track)
21
+ @resolver = Resolver.new(state: @state, track: @track, root: @root)
22
+ end
23
+
24
+ def start
25
+ command = @argv.shift || "help"
26
+
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
43
+
44
+ 0
45
+ rescue Error => e
46
+ @ui.error(e.message)
47
+ 1
48
+ rescue Interrupt
49
+ @ui.error("Interrupted.")
50
+ 130
51
+ end
52
+
53
+ private
54
+
55
+ def new_command
56
+ exercise = exercise_from_required_arg("new <exercise>", require_existing: false)
57
+
58
+ @ui.info("Downloading #{@ui.highlight(exercise.slug)}...")
59
+ @runner.run("exercism", "download", "--track=#{exercise.track}", "--exercise=#{exercise.slug}")
60
+ exercise.ensure_exists!
61
+ save_current(exercise)
62
+ @ui.key_value("Path", @ui.path(exercise.path))
63
+ edit_exercise(exercise)
64
+ end
65
+
66
+ def edit_command
67
+ edit_exercise(@resolver.resolve(optional_arg))
68
+ end
69
+
70
+ def test_command
71
+ exercise = @resolver.resolve(optional_arg)
72
+ test_file = exercise.test_file
73
+
74
+ @ui.info("Testing #{@ui.highlight(exercise.slug)}...")
75
+ @runner.run("ruby", "-r", "minitest/pride", test_file, chdir: exercise.path)
76
+ end
77
+
78
+ def irb_command
79
+ exercise = @resolver.resolve(optional_arg)
80
+ solution_file = exercise.solution_file
81
+
82
+ @ui.info("Opening IRB for #{@ui.highlight(exercise.slug)}...")
83
+ @runner.run("irb", "-r", "./#{solution_file}", "--simple-prompt", chdir: exercise.path)
84
+ end
85
+
86
+ def submit_command
87
+ exercise = @resolver.resolve(optional_arg)
88
+ solution_file = exercise.solution_file
89
+
90
+ @ui.info("Submitting #{@ui.highlight(exercise.slug)}...")
91
+ @runner.run("exercism", "submit", solution_file, chdir: exercise.path)
92
+ end
93
+
94
+ def use_command
95
+ exercise = exercise_from_required_arg("use <exercise>")
96
+ save_current(exercise)
97
+ @ui.success("Current exercise: #{@ui.highlight(exercise.slug)}")
98
+ @ui.command(@ui.path(exercise.path))
99
+ end
100
+
101
+ def current_command
102
+ data = @state.load
103
+ raise Error, "No current exercise saved." if data.empty? || blank?(data["exercise"])
104
+
105
+ path = data["path"]
106
+ @ui.title("Current exercise")
107
+ @ui.key_value("Exercise", @ui.highlight(data.fetch("exercise")))
108
+ @ui.key_value("Track", data.fetch("track", @track))
109
+ @ui.key_value("Path", @ui.path(path))
110
+ @ui.key_value("State", @ui.path(@state.path))
111
+ @ui.warn("The saved directory no longer exists.") if path && !Dir.exist?(path)
112
+ end
113
+
114
+ def path_command
115
+ exercise = @resolver.resolve(optional_arg)
116
+ @ui.say(exercise.path)
117
+ end
118
+
119
+ def list_command
120
+ raise Error, "Exercise directory not found: #{@root}" unless Dir.exist?(@root)
121
+
122
+ current = @state.load["exercise"]
123
+ exercises = Dir.children(@root)
124
+ .select { |name| File.directory?(File.join(@root, name)) && !name.start_with?(".") }
125
+ .sort
126
+
127
+ if exercises.empty?
128
+ @ui.warn("No exercises downloaded in #{@root}.")
129
+ return
130
+ end
131
+
132
+ @ui.section("Downloaded exercises")
133
+ exercises.each do |slug|
134
+ marker = slug == current ? "*" : " "
135
+ label = slug == current ? @ui.highlight(slug) : slug
136
+ suffix = slug == current ? " #{@ui.muted('current')}" : ""
137
+ @ui.say("#{marker} #{label}#{suffix}")
138
+ end
139
+ end
140
+
141
+ def clear_command
142
+ @state.clear
143
+ @ui.success("State cleared: #{@state.path}")
144
+ end
145
+
146
+ def help_command
147
+ @ui.say(<<~HELP)
148
+ #{@ui.bold('xrb')} - Exercism Ruby helper
149
+
150
+ Usage:
151
+ xrb new <exercise> download, save as current, and open the editor
152
+ xrb edit [exercise] open the editor for an exercise
153
+ xrb test [exercise] run the exercise test file with minitest/pride
154
+ xrb irb [exercise] open irb -r ./<solution>.rb --simple-prompt
155
+ xrb submit [exercise] submit the solution .rb file
156
+ xrb use <exercise> save a downloaded exercise as current
157
+ xrb current show the current exercise
158
+ xrb path [exercise] print the exercise path
159
+ xrb list list downloaded exercises
160
+ xrb clear clear saved state
161
+
162
+ State:
163
+ #{@state.path}
164
+
165
+ Environment:
166
+ XRB_ROOT exercise directory (current: #{@root})
167
+ XRB_TRACK Exercism track (current: #{@track})
168
+ XRB_EDITOR editor used by xrb edit/new (default: nvim)
169
+ XRB_STATE TOML state file
170
+ XRB_COLOR color output: auto, always, or never
171
+ HELP
172
+ end
173
+
174
+ def version_command
175
+ @ui.say("xrb #{VERSION}")
176
+ end
177
+
178
+ def edit_exercise(exercise)
179
+ target = editable_target(exercise)
180
+ editor_args = editor_args_from_config
181
+ raise Error, "Invalid editor in XRB_EDITOR/VISUAL/EDITOR." if editor_args.empty?
182
+ ensure_editor_available!(editor_args.first, chdir: exercise.path)
183
+
184
+ @ui.info("Opening #{@ui.highlight(exercise.slug)}...")
185
+ @runner.run(*editor_args, target, chdir: exercise.path)
186
+ end
187
+
188
+ 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}"
192
+ end
193
+
194
+ def ensure_editor_available!(command, chdir:)
195
+ return if editor_command_available?(command, chdir: chdir)
196
+
197
+ raise Error, "Editor not found: #{command}. Set XRB_EDITOR, VISUAL, or EDITOR to an installed executable."
198
+ end
199
+
200
+ def editor_command_available?(command, chdir:)
201
+ if command_path?(command)
202
+ return executable_file?(File.absolute_path(command, chdir))
203
+ end
204
+
205
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
206
+ candidate_dir = dir.empty? ? "." : dir
207
+ executable_file?(File.absolute_path(File.join(candidate_dir, command), chdir))
208
+ end
209
+ end
210
+
211
+ def command_path?(command)
212
+ command.include?(File::SEPARATOR) || (File::ALT_SEPARATOR && command.include?(File::ALT_SEPARATOR))
213
+ end
214
+
215
+ def executable_file?(path)
216
+ File.file?(path) && File.executable?(path)
217
+ end
218
+
219
+ def editable_target(exercise)
220
+ exercise.solution_file
221
+ rescue Error
222
+ "."
223
+ end
224
+
225
+ def save_current(exercise)
226
+ @state.save(track: exercise.track, exercise: exercise.slug, path: exercise.path)
227
+ end
228
+
229
+ def exercise_from_required_arg(usage, require_existing: true)
230
+ slug = @argv.shift
231
+ raise Error, "Usage: xrb #{usage}" if blank?(slug)
232
+ raise Error, "Too many arguments: #{@argv.join(' ')}" unless @argv.empty?
233
+
234
+ Exercise.new(slug: slug, track: @track, root: @root).tap do |exercise|
235
+ exercise.ensure_exists! if require_existing
236
+ end
237
+ end
238
+
239
+ def optional_arg
240
+ slug = @argv.shift
241
+ raise Error, "Too many arguments: #{@argv.join(' ')}" unless @argv.empty?
242
+
243
+ slug
244
+ end
245
+
246
+ def blank?(value)
247
+ value.nil? || value.to_s.strip.empty?
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Exercism
6
+ module Rb
7
+ class CommandRunner
8
+ def initialize(ui: UI.new)
9
+ @ui = ui
10
+ end
11
+
12
+ def run(*args, chdir: nil)
13
+ printable = Shellwords.join(args)
14
+ printable = "cd #{Shellwords.escape(chdir)} && #{printable}" if chdir
15
+ @ui.command("$ #{printable}")
16
+
17
+ ok = if chdir
18
+ Dir.chdir(chdir) { system(*args) }
19
+ else
20
+ system(*args)
21
+ end
22
+
23
+ raise Error, "Command failed: #{Shellwords.join(args)}" unless ok
24
+
25
+ true
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exercism
4
+ module Rb
5
+ module Config
6
+ module_function
7
+
8
+ def track
9
+ ENV.fetch("XRB_TRACK", "ruby")
10
+ end
11
+
12
+ def root(track_name = track)
13
+ File.expand_path(ENV.fetch("XRB_ROOT", File.join(Dir.home, "exercism", track_name)))
14
+ end
15
+
16
+ def state_path
17
+ File.expand_path(ENV.fetch("XRB_STATE", File.join(Dir.home, ".local", "state", "exercism-rb", "state.toml")))
18
+ end
19
+
20
+ def editor
21
+ ENV["XRB_EDITOR"] || ENV["VISUAL"] || ENV["EDITOR"] || "nvim"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exercism
4
+ module Rb
5
+ class Error < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exercism
4
+ module Rb
5
+ class Exercise
6
+ SLUG_PATTERN = /\A[a-z0-9][a-z0-9-]*\z/
7
+
8
+ attr_reader :slug, :track, :root, :path
9
+
10
+ def initialize(slug:, track: Config.track, root: Config.root(track), path: nil)
11
+ slug = slug.to_s.strip
12
+ raise Error, "Exercise is required." if slug.empty?
13
+ raise Error, "Invalid slug: #{slug.inspect}. Use something like assembly-line." unless slug.match?(SLUG_PATTERN)
14
+
15
+ @slug = slug
16
+ @track = track
17
+ @root = File.expand_path(root)
18
+ @path = File.expand_path(path || File.join(@root, @slug))
19
+ end
20
+
21
+ def exists?
22
+ Dir.exist?(@path)
23
+ end
24
+
25
+ def ensure_exists!
26
+ return self if exists?
27
+
28
+ raise Error, "Exercise not found: #{@path}"
29
+ end
30
+
31
+ def test_file
32
+ ensure_exists!
33
+ pick_one(files_matching { |name| name.end_with?("_test.rb") }, kind: "test file (*_test.rb)")
34
+ end
35
+
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)")
39
+ end
40
+
41
+ private
42
+
43
+ def files_matching
44
+ Dir.children(@path)
45
+ .select { |name| File.file?(File.join(@path, name)) && yield(name) }
46
+ .sort
47
+ end
48
+
49
+ def pick_one(files, kind:)
50
+ case files.length
51
+ when 0
52
+ raise Error, "Could not find #{kind} in #{@path}"
53
+ when 1
54
+ files.first
55
+ else
56
+ raise Error, "Found more than one #{kind} in #{@path}: #{files.join(', ')}"
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exercism
4
+ module Rb
5
+ class Resolver
6
+ def initialize(state:, track: Config.track, root: Config.root(track))
7
+ @state = state
8
+ @track = track
9
+ @root = File.expand_path(root)
10
+ end
11
+
12
+ def resolve(slug = nil, require_existing: true)
13
+ exercise = if present?(slug)
14
+ Exercise.new(slug: slug, track: @track, root: @root)
15
+ else
16
+ from_current_directory || from_state
17
+ end
18
+
19
+ exercise.ensure_exists! if require_existing
20
+ exercise
21
+ end
22
+
23
+ private
24
+
25
+ def from_current_directory
26
+ cwd = File.expand_path(Dir.pwd)
27
+ root_with_separator = @root.end_with?(File::SEPARATOR) ? @root : "#{@root}#{File::SEPARATOR}"
28
+ return nil unless cwd.start_with?(root_with_separator)
29
+
30
+ relative = cwd.delete_prefix(root_with_separator)
31
+ slug = relative.split(File::SEPARATOR).first
32
+ return nil unless present?(slug)
33
+
34
+ Exercise.new(slug: slug, track: @track, root: @root)
35
+ end
36
+
37
+ def from_state
38
+ data = @state.load
39
+ slug = data["exercise"]
40
+ raise Error, "No current exercise. Use `xrb new <exercise>` or `xrb use <exercise>`." unless present?(slug)
41
+
42
+ Exercise.new(
43
+ slug: slug,
44
+ track: data["track"] || @track,
45
+ root: File.dirname(data["path"] || File.join(@root, slug)),
46
+ path: data["path"]
47
+ )
48
+ end
49
+
50
+ def present?(value)
51
+ !value.nil? && !value.to_s.strip.empty?
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+
6
+ module Exercism
7
+ module Rb
8
+ class State
9
+ KEYS = %w[track exercise path updated_at].freeze
10
+
11
+ attr_reader :path
12
+
13
+ def initialize(path: Config.state_path)
14
+ @path = File.expand_path(path)
15
+ end
16
+
17
+ def load
18
+ return {} unless File.file?(@path)
19
+
20
+ parse(File.read(@path))
21
+ end
22
+
23
+ def save(track:, exercise:, path:)
24
+ data = {
25
+ "track" => track,
26
+ "exercise" => exercise,
27
+ "path" => File.expand_path(path),
28
+ "updated_at" => Time.now.utc.iso8601
29
+ }
30
+
31
+ FileUtils.mkdir_p(File.dirname(@path))
32
+ tmp_path = "#{@path}.tmp.#{Process.pid}"
33
+ File.write(tmp_path, to_toml(data))
34
+ File.rename(tmp_path, @path)
35
+ data
36
+ end
37
+
38
+ def clear
39
+ File.delete(@path) if File.file?(@path)
40
+ end
41
+
42
+ private
43
+
44
+ def parse(content)
45
+ data = {}
46
+
47
+ content.each_line.with_index(1) do |line, number|
48
+ stripped = line.strip
49
+ next if stripped.empty? || stripped.start_with?("#")
50
+
51
+ match = stripped.match(/\A([A-Za-z0-9_-]+)\s*=\s*(.+)\z/)
52
+ raise Error, "Invalid state in #{@path}: line #{number}" unless match
53
+
54
+ key = match[1]
55
+ next unless KEYS.include?(key)
56
+
57
+ data[key] = parse_value(match[2].strip)
58
+ end
59
+
60
+ data
61
+ end
62
+
63
+ def parse_value(raw)
64
+ quoted_start = raw.start_with?("\"")
65
+ quoted_end = raw.end_with?("\"")
66
+ raise Error, "Invalid quoted value in #{@path}" if quoted_start != quoted_end
67
+
68
+ return unquote(raw) if raw.start_with?("\"") && raw.end_with?("\"")
69
+
70
+ raw
71
+ end
72
+
73
+ def unquote(raw)
74
+ value = raw[1...-1]
75
+ value.gsub(/\\(["\\nrt])/) do
76
+ case Regexp.last_match(1)
77
+ when '"' then '"'
78
+ when "\\" then "\\"
79
+ when "n" then "\n"
80
+ when "r" then "\r"
81
+ when "t" then "\t"
82
+ end
83
+ end
84
+ end
85
+
86
+ def to_toml(data)
87
+ KEYS.filter_map do |key|
88
+ next unless data.key?(key)
89
+
90
+ "#{key} = #{quote(data.fetch(key))}"
91
+ end.join("\n") + "\n"
92
+ end
93
+
94
+ def quote(value)
95
+ escaped = value.to_s
96
+ .gsub("\\", "\\\\")
97
+ .gsub('"', '\\"')
98
+ .gsub("\n", "\\n")
99
+ .gsub("\r", "\\r")
100
+ .gsub("\t", "\\t")
101
+
102
+ %("#{escaped}")
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exercism
4
+ module Rb
5
+ class UI
6
+ COLORS = {
7
+ blue: "\e[34m",
8
+ cyan: "\e[36m",
9
+ green: "\e[32m",
10
+ red: "\e[31m",
11
+ yellow: "\e[33m",
12
+ gray: "\e[90m",
13
+ bold: "\e[1m",
14
+ reset: "\e[0m"
15
+ }.freeze
16
+
17
+ def initialize(out: $stdout, err: $stderr, color: nil)
18
+ @out = out
19
+ @err = err
20
+ @color = color.nil? ? default_color? : color
21
+ end
22
+
23
+ def say(message = "")
24
+ @out.puts(message)
25
+ end
26
+
27
+ def title(message)
28
+ @out.puts(bold(message))
29
+ end
30
+
31
+ def section(message)
32
+ @out.puts(paint(message, :cyan))
33
+ end
34
+
35
+ def key_value(key, value, width: 8)
36
+ label = key.to_s.ljust(width)
37
+ @out.puts("#{paint(label, :gray)} #{value}")
38
+ end
39
+
40
+ def path(value)
41
+ paint(value, :blue)
42
+ end
43
+
44
+ def highlight(value)
45
+ paint(value, :bold)
46
+ end
47
+
48
+ def muted(value)
49
+ paint(value, :gray)
50
+ end
51
+
52
+ def info(message)
53
+ @out.puts("#{paint('info', :blue)} #{message}")
54
+ end
55
+
56
+ def success(message)
57
+ @out.puts("#{paint('done', :green)} #{message}")
58
+ end
59
+
60
+ def warn(message)
61
+ @err.puts("#{paint('warn', :yellow)} #{message}")
62
+ end
63
+
64
+ def error(message)
65
+ @err.puts("#{paint('error', :red)} #{message}")
66
+ end
67
+
68
+ def command(message)
69
+ @out.puts(paint(message, :gray))
70
+ end
71
+
72
+ def bold(message)
73
+ paint(message, :bold)
74
+ end
75
+
76
+ private
77
+
78
+ def default_color?
79
+ color_mode = ENV.fetch("XRB_COLOR", "").strip.downcase
80
+
81
+ return true if %w[always force true yes 1].include?(color_mode)
82
+ return false if %w[never none false no 0].include?(color_mode)
83
+ return false if ENV.key?("NO_COLOR")
84
+ return true if force_color?
85
+ return false if ENV.fetch("CLICOLOR", nil) == "0"
86
+
87
+ @out.tty?
88
+ end
89
+
90
+ def paint(message, color)
91
+ return message unless @color
92
+
93
+ "#{COLORS.fetch(color)}#{message}#{COLORS.fetch(:reset)}"
94
+ end
95
+
96
+ def force_color?
97
+ value = ENV.fetch("CLICOLOR_FORCE", nil)
98
+ !value.nil? && !value.empty? && value != "0"
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exercism
4
+ module Rb
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rb/version"
4
+ require_relative "rb/config"
5
+ require_relative "rb/error"
6
+ require_relative "rb/ui"
7
+ require_relative "rb/state"
8
+ require_relative "rb/exercise"
9
+ require_relative "rb/resolver"
10
+ require_relative "rb/command_runner"
11
+ require_relative "rb/cli"
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: exercism-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - hvpaiva
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.25'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.25'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.2'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.2'
41
+ description: A small command-line helper that keeps Exercism Ruby exercises easy to
42
+ open, test, inspect, and submit.
43
+ email:
44
+ - hvpaiva@users.noreply.github.com
45
+ executables:
46
+ - xrb
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE
52
+ - README.md
53
+ - bin/xrb
54
+ - lib/exercism/rb.rb
55
+ - lib/exercism/rb/cli.rb
56
+ - lib/exercism/rb/command_runner.rb
57
+ - lib/exercism/rb/config.rb
58
+ - lib/exercism/rb/error.rb
59
+ - lib/exercism/rb/exercise.rb
60
+ - lib/exercism/rb/resolver.rb
61
+ - lib/exercism/rb/state.rb
62
+ - lib/exercism/rb/ui.rb
63
+ - lib/exercism/rb/version.rb
64
+ homepage: https://github.com/hvpaiva/exercism-rb
65
+ licenses:
66
+ - MIT
67
+ metadata:
68
+ allowed_push_host: https://rubygems.org
69
+ bug_tracker_uri: https://github.com/hvpaiva/exercism-rb/issues
70
+ changelog_uri: https://github.com/hvpaiva/exercism-rb/blob/main/CHANGELOG.md
71
+ homepage_uri: https://github.com/hvpaiva/exercism-rb
72
+ rubygems_mfa_required: 'true'
73
+ source_code_uri: https://github.com/hvpaiva/exercism-rb
74
+ post_install_message:
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 3.2.0
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 3.4.19
90
+ signing_key:
91
+ specification_version: 4
92
+ summary: Exercism Ruby workflow helper CLI
93
+ test_files: []