rutidy 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: e0d758de435d37cd2be15dc16e6865d0684de10a1c2e068e51ae2cf614dc7935
4
+ data.tar.gz: 527576fa67e3ce5f23660e7783f903daf23b4926b6c0905cdc03cd11eb48e8cb
5
+ SHA512:
6
+ metadata.gz: 4170b31d9a055204e77c1db26d36f6c3c82b75e00e379cbda4be2fa2b22508b0274fb9699b29fa104fa7494947593fcf6f8c1914f592fcd8546285f6b820e82f
7
+ data.tar.gz: 300a3e45166a8188afc0a82660501c4dec4fa97d0edb819beae01df55a11d0442abc6edc64bbd3478e9b049a0516016f6317f58afd2fa7eea65cf9280ef80915
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John Woodell
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,114 @@
1
+ # rutidy
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/rutidy)](https://rubygems.org/gems/rutidy)
4
+ [![CI](https://github.com/woodie/rutidy/actions/workflows/ci.yml/badge.svg)](https://github.com/woodie/rutidy/actions/workflows/ci.yml)
5
+ [![Release](https://img.shields.io/github/v/release/woodie/rutidy.svg)](https://github.com/woodie/rutidy/releases/latest)
6
+ [![License](https://img.shields.io/github/license/woodie/rutidy.svg)](LICENSE)
7
+
8
+ ![Example Screenshot](docs/example.png)
9
+
10
+ RSpec/Mocha/Vitest-style output for RSpec by hooking a real formatter into
11
+ the same tree the runner already builds. See at a glance which spec
12
+ actually failed, in whichever of the four familiar looks reads best for
13
+ your team, without giving up RSpec's own assertions or your existing suite.
14
+
15
+ ## Installation
16
+
17
+ ```
18
+ gem install rutidy
19
+ ```
20
+
21
+ Or add it to your `Gemfile`:
22
+
23
+ ```ruby
24
+ group :test do
25
+ gem "rutidy"
26
+ end
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ Run as a wrapper around `rspec`, passing any arguments through:
32
+
33
+ ```
34
+ rutidy
35
+ rutidy spec/
36
+ rutidy -fd spec/calculator_spec.rb
37
+ ```
38
+
39
+ Or read stdin, piping RSpec's own output through `rutidy`'s formatter first:
40
+
41
+ ```
42
+ rspec --require rutidy/formatter --format Rutidy::Formatter | rutidy -fs
43
+ ```
44
+
45
+ Or format an existing report file directly:
46
+
47
+ ```
48
+ rutidy report.json
49
+ ```
50
+
51
+ Flags select the output style -- see [Output styles](#output-styles) below.
52
+
53
+ ### Version
54
+
55
+ ```
56
+ rutidy --version
57
+ ```
58
+
59
+ Prints the installed version and exits immediately, without waiting on
60
+ stdin or running `rspec`.
61
+
62
+ ## Output styles
63
+
64
+ Four named styles, each matching a convention from a familiar test runner.
65
+ The first three end with the same shared footer; `-fv` ends with Vitest's
66
+ own footer shape instead.
67
+
68
+ | Flag | Convention | Look |
69
+ |---|---|---|
70
+ | | Our base formatter | Glyph + `name (N seconds)`, failures add `(FAILED - N)` |
71
+ | -fd | RSpec's own doc format | Plain colored name, yellow `(PENDING: reason)` |
72
+ | -fs | Mocha's spec format | Green `✔` + gray name, red `✗ name (FAILED - N)` |
73
+ | -fv | Vitest's own tree | Green `✓ name`, two-toned green `2ms`, red `× name`, dim gray `↓ name` |
74
+
75
+ `-fv`'s `Test Files` line counts distinct `file_path`s across all examples --
76
+ exact, since every example already carries its own real spec file path.
77
+
78
+ ## Things to know
79
+
80
+ ### Why a real formatter, not a text post-processor
81
+
82
+ `gorderly`/`xctidy` parse `go test`/`xcodebuild`'s raw text output because
83
+ that's all those tools expose. RSpec is different: its own `--format json`
84
+ only reports a flattened `full_description` string (every `describe`/
85
+ `context`/`it` joined with plain spaces, no separator at all) with no
86
+ per-level boundaries -- reconstructing a real tree from that would mean
87
+ guessing where one group's text ends and the next begins, the exact
88
+ problem `xctidy` already has to solve for Quick/Nimble's comma-joined
89
+ names, except worse, since RSpec doesn't even delimit with commas.
90
+
91
+ `Rutidy::Formatter` sidesteps that entirely by hooking the same
92
+ `example_group_started`/`example_group_finished` notifications RSpec's own
93
+ `--format documentation` formatter uses internally -- the hierarchy comes
94
+ from the real stack the runner already maintains, not a guess.
95
+
96
+ ### Limitations
97
+
98
+ - `Rutidy::Formatter` must run inside the same `rspec` process as your
99
+ suite (via `--require`/`--format`, or `rutidy`'s own CLI wrapping
100
+ `rspec` for you) -- it can't be retrofitted onto RSpec's own stock
101
+ `--format json` output after the fact.
102
+ - Shared examples (`it_behaves_like`) show up as their own nesting level
103
+ named after the shared group, matching what RSpec's own `--format
104
+ documentation` already does -- not a `rutidy`-specific quirk.
105
+
106
+ ## Development
107
+
108
+ ```
109
+ make build # gem build rutidy.gemspec
110
+ make install # rake install
111
+ make test # verbose, dogfoods rutidy on its own suite in -fd style
112
+ make lint # standardrb
113
+ make check # terse: silent on success, full log on failure
114
+ ```
data/exe/rutidy ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rutidy"
5
+
6
+ exit Rutidy::CLI.run(ARGV)
data/lib/rutidy/cli.rb ADDED
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tempfile"
5
+ require "rutidy/version"
6
+ require "rutidy/render"
7
+
8
+ module Rutidy
9
+ # `rutidy`'s two modes, matching `gomeleon`'s own "wrap the CLI, or read
10
+ # its JSON report directly" duality:
11
+ #
12
+ # rutidy # wraps `rspec` with no args
13
+ # rutidy spec/foo_spec.rb # wraps `rspec spec/foo_spec.rb`
14
+ # rutidy -fd spec/ # style flag + wrapped invocation
15
+ # rutidy report.json # formats an existing report file directly
16
+ # rspec ... | rutidy -fs # reads a piped report from stdin
17
+ #
18
+ # A single argument ending in `.json` is treated as an existing report to
19
+ # render; anything else on the command line is handed straight through to
20
+ # `rspec` (with `Formatter` inserted via `--require`/`--format`). Piped
21
+ # stdin with no arguments at all is read as a report too, the same way
22
+ # `gorderly`'s `openInput` treats piped stdin vs. shelling out to `go test`
23
+ # itself.
24
+ class CLI
25
+ STYLE_ALIASES = {
26
+ "documentation" => :fd,
27
+ "spec" => :fs,
28
+ "vitest" => :fv
29
+ }.freeze
30
+
31
+ def self.run(argv, out: $stdout, err: $stderr, stdin: $stdin)
32
+ new(argv, out: out, err: err, stdin: stdin).run
33
+ end
34
+
35
+ def initialize(argv, out:, err:, stdin:)
36
+ @argv = argv.dup
37
+ @out = out
38
+ @err = err
39
+ @stdin = stdin
40
+ @style = :classic
41
+ @color = @out.respond_to?(:tty?) && @out.tty? && ENV["NO_COLOR"].nil?
42
+ end
43
+
44
+ def run
45
+ if wants_version?
46
+ @out.puts("rutidy #{Rutidy::VERSION}")
47
+ return 0
48
+ end
49
+
50
+ rest = parse_flags!
51
+ examples = load_examples(rest)
52
+ return 1 if examples.nil?
53
+
54
+ Render.call(examples, style: @style, out: @out, color: @color).positive? ? 1 : 0
55
+ end
56
+
57
+ private
58
+
59
+ def wants_version?
60
+ @argv.include?("--version") || @argv.include?("-v")
61
+ end
62
+
63
+ def parse_flags!
64
+ rest = []
65
+ @argv.each do |arg|
66
+ case arg
67
+ when "-fd" then @style = :fd
68
+ when "-fs" then @style = :fs
69
+ when "-fv" then @style = :fv
70
+ when /\A--format[= ](\w+)\z/
71
+ @style = STYLE_ALIASES.fetch(Regexp.last_match(1)) do
72
+ @err.puts("rutidy: unknown --format #{Regexp.last_match(1)}")
73
+ @style
74
+ end
75
+ else rest << arg
76
+ end
77
+ end
78
+ rest
79
+ end
80
+
81
+ def load_examples(rest)
82
+ if rest.length == 1 && rest.first.end_with?(".json")
83
+ return parse_report(File.read(rest.first))
84
+ end
85
+
86
+ if rest.empty? && !@stdin.tty?
87
+ return parse_report(@stdin.read)
88
+ end
89
+
90
+ run_rspec(rest)
91
+ end
92
+
93
+ def parse_report(json_text)
94
+ JSON.parse(json_text, symbolize_names: true).fetch(:examples, [])
95
+ rescue JSON::ParserError => e
96
+ @err.puts("rutidy: couldn't parse report: #{e.message}")
97
+ nil
98
+ end
99
+
100
+ def run_rspec(args)
101
+ Tempfile.create(["rutidy", ".json"]) do |tmp|
102
+ cmd = [
103
+ "rspec", "--require", "rutidy/formatter", "--format", "Rutidy::Formatter",
104
+ "--out", tmp.path, *args
105
+ ]
106
+ # `rspec` exits non-zero whenever any example fails -- that's the
107
+ # normal case this tool exists to render, not an error. Only the
108
+ # report ending up empty (rspec never got far enough to write one --
109
+ # a load error, an unrecognized flag, no matching spec files) is a
110
+ # real problem worth surfacing here.
111
+ system(*cmd, out: @err, err: @err)
112
+
113
+ content = File.read(tmp.path)
114
+ if content.strip.empty?
115
+ @err.puts("rutidy: `#{cmd.join(" ")}` produced no report -- see rspec's own output above")
116
+ return nil
117
+ end
118
+
119
+ parse_report(content)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rspec/core/formatters"
5
+ require "rspec/core/formatters/base_formatter"
6
+ require "rutidy/version"
7
+
8
+ module Rutidy
9
+ # A real RSpec formatter (registered the same way RSpec's own built-in
10
+ # DocumentationFormatter is), not a post-processor of some other
11
+ # formatter's text output. `example_group_started`/`example_group_finished`
12
+ # fire as the runner actually descends/ascends the real describe/context
13
+ # tree, so `@hierarchy` always reflects genuine nesting -- unlike stock
14
+ # `rspec --format json`, whose `full_description` field is every group's
15
+ # description concatenated with plain spaces and no separator, which
16
+ # can't be split back into a tree without guessing. This formatter never
17
+ # needs to guess: every example gets its own `hierarchy` array straight
18
+ # from the stack RSpec itself maintains.
19
+ #
20
+ # Load with `rspec --require rutidy/formatter --format Rutidy::Formatter`,
21
+ # or let `rutidy`'s own CLI wrap that invocation for you.
22
+ class Formatter < RSpec::Core::Formatters::BaseFormatter
23
+ RSpec::Core::Formatters.register self,
24
+ :example_group_started, :example_group_finished,
25
+ :example_passed, :example_failed, :example_pending,
26
+ :dump_summary, :seed, :message, :close
27
+
28
+ def initialize(output)
29
+ super
30
+ @hierarchy = []
31
+ @output_hash = {version: Rutidy::VERSION}
32
+ end
33
+
34
+ def message(notification)
35
+ (@output_hash[:messages] ||= []) << notification.message
36
+ end
37
+
38
+ def example_group_started(notification)
39
+ @hierarchy << notification.group.description.strip
40
+ end
41
+
42
+ def example_group_finished(_notification)
43
+ @hierarchy.pop
44
+ end
45
+
46
+ def example_passed(notification)
47
+ record(notification.example)
48
+ end
49
+
50
+ def example_pending(notification)
51
+ record(notification.example)
52
+ end
53
+
54
+ def example_failed(notification)
55
+ record(notification.example, notification)
56
+ end
57
+
58
+ def dump_summary(summary)
59
+ @output_hash[:summary] = {
60
+ duration: summary.duration,
61
+ example_count: summary.example_count,
62
+ failure_count: summary.failure_count,
63
+ pending_count: summary.pending_count
64
+ }
65
+ @output_hash[:summary_line] = summary.totals_line
66
+ end
67
+
68
+ def seed(notification)
69
+ return unless notification.seed_used?
70
+
71
+ @output_hash[:seed] = notification.seed
72
+ end
73
+
74
+ def close(_notification)
75
+ output.write JSON.generate(@output_hash)
76
+ end
77
+
78
+ private
79
+
80
+ def record(example, failure_notification = nil)
81
+ (@output_hash[:examples] ||= []) << format_example(example, failure_notification)
82
+ end
83
+
84
+ # Same field set stock `rspec --format json` gives you (description,
85
+ # full_description, status, file_path, line_number, run_time,
86
+ # pending_message, exception) plus the one field it's missing:
87
+ # `hierarchy`, the real per-level nesting `render.rb` builds a tree from.
88
+ def format_example(example, failure_notification)
89
+ result = example.execution_result
90
+ hash = {
91
+ hierarchy: @hierarchy + [example.description.strip],
92
+ description: example.description,
93
+ full_description: example.full_description,
94
+ status: result.status.to_s,
95
+ file_path: example.metadata[:file_path],
96
+ line_number: example.metadata[:line_number],
97
+ run_time: result.run_time
98
+ }
99
+ hash[:pending_message] = result.pending_message if result.pending_message
100
+ hash[:exception] = format_exception(example.exception, failure_notification) if failure_notification
101
+ hash
102
+ end
103
+
104
+ def format_exception(exception, failure_notification)
105
+ {
106
+ class: exception.class.name,
107
+ message: exception.message,
108
+ backtrace: failure_notification.formatted_backtrace
109
+ }
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rutidy
4
+ # Walks the leaves `Formatter` (or a real `rspec --format json` report
5
+ # extended with a `hierarchy` array) produced and renders them as a nested
6
+ # RSpec `-fd`-style tree, the same dedupe-shared-prefix walk `gorderly`'s
7
+ # `render.go` and `xctidy`'s `Engine.swift` use, just over each example's
8
+ # real `hierarchy` array instead of a `/`-split or comma-disambiguated
9
+ # string. Four styles (`:classic`/`:fd`/`:fs`/`:fv`) share one closing
10
+ # footer, except `:fv`, which closes with Vitest's own `Test Files`/
11
+ # `Tests`/`Duration` shape.
12
+ #
13
+ # Unlike `gorderly`, there's no fake "package path" label above the tree --
14
+ # RSpec has no equivalent concept, so the first top-level `describe` is
15
+ # just the first real node. A blank line still goes before every
16
+ # top-level group, including the first -- that's not a `gorderly`-style
17
+ # workaround here, it's genuine RSpec `-fd` behavior (its own
18
+ # `DocumentationFormatter#example_group_started` does `output.puts if
19
+ # @group_level == 0` unconditionally).
20
+ module Render
21
+ ANSI_PREFIX = "\e["
22
+ COLORS = {
23
+ red: "31",
24
+ green: "32",
25
+ bright_green: "92",
26
+ yellow: "33",
27
+ cyan: "36",
28
+ gray: "90"
29
+ }.freeze
30
+
31
+ FailureEntry = Struct.new(:n, :full, :output)
32
+
33
+ module_function
34
+
35
+ # @return [Integer] the number of failed examples (0 means a clean run)
36
+ def call(examples, style:, out:, color: true)
37
+ colorize = ->(name, text) { color ? "#{ANSI_PREFIX}#{COLORS.fetch(name)}m#{text}#{ANSI_PREFIX}0m" : text }
38
+
39
+ total = 0
40
+ pending = 0
41
+ failed_count = 0
42
+ total_elapsed = 0.0
43
+ failures = []
44
+ files_total = {}
45
+ files_failed = {}
46
+ prev_path = []
47
+
48
+ examples.each do |example|
49
+ hierarchy = example.fetch(:hierarchy)
50
+ path = hierarchy[0..-2]
51
+ leaf_name = hierarchy.last
52
+
53
+ shared = 0
54
+ shared += 1 while shared < prev_path.length && shared < path.length && prev_path[shared] == path[shared]
55
+
56
+ out.puts if shared.zero?
57
+ (shared...path.length).each { |i| out.puts("#{" " * i}#{path[i]}") }
58
+
59
+ total += 1
60
+ total_elapsed += example[:run_time]
61
+ indent = " " * path.length
62
+ file = example[:file_path]
63
+ files_total[file] = true if file
64
+
65
+ case example[:status]
66
+ when "failed"
67
+ failed_count += 1
68
+ files_failed[file] = true if file
69
+ n = failures.length + 1
70
+ failures << FailureEntry.new(n, hierarchy, format_backtrace(example))
71
+ out.puts("#{indent}#{colorize_fail(style, leaf_name, example, n, colorize)}")
72
+ when "pending"
73
+ pending += 1
74
+ out.puts("#{indent}#{colorize_pending(style, leaf_name, example, colorize)}")
75
+ else
76
+ out.puts("#{indent}#{colorize_pass(style, leaf_name, example, colorize)}")
77
+ end
78
+
79
+ prev_path = path
80
+ end
81
+ out.puts
82
+
83
+ if failures.any?
84
+ out.puts("Failures:")
85
+ failures.each do |f|
86
+ out.puts
87
+ out.puts(" #{f.n}) #{f.full.join(" ")}")
88
+ f.output.each { |line| out.puts(" #{line}") }
89
+ end
90
+ out.puts
91
+ end
92
+
93
+ if style == :fv
94
+ write_vitest_footer(
95
+ out, colorize,
96
+ total: total, failed_count: failed_count, pending: pending, total_elapsed: total_elapsed,
97
+ files_total: files_total.size, files_failed: files_failed.size
98
+ )
99
+ else
100
+ write_shared_footer(
101
+ out, colorize,
102
+ total: total, failed_count: failed_count, pending: pending, total_elapsed: total_elapsed
103
+ )
104
+ end
105
+
106
+ failed_count
107
+ end
108
+
109
+ def write_shared_footer(out, colorize, total:, failed_count:, pending:, total_elapsed:)
110
+ verdict = failed_count.positive? ? "Test Failed" : "Test Succeeded"
111
+ verdict_color = failed_count.positive? ? :red : :green
112
+ out.puts(colorize.call(verdict_color, verdict))
113
+ out.puts(colorize.call(
114
+ verdict_color,
115
+ format("Tests Passed: %d failed, %d pending, %d total (%s seconds)",
116
+ failed_count, pending, total, format_seconds(total_elapsed))
117
+ ))
118
+ end
119
+
120
+ def write_vitest_footer(out, colorize, total:, failed_count:, pending:, total_elapsed:, files_total:, files_failed:)
121
+ passed = total - failed_count - pending
122
+ files_passed = files_total - files_failed
123
+ out.puts(vitest_summary_line("Test Files", colorize, failed: files_failed, passed: files_passed, pending: 0, total: files_total))
124
+ out.puts(vitest_summary_line("Tests", colorize, failed: failed_count, passed: passed, pending: pending, total: total))
125
+ out.puts(format("%11s %s", "Duration", format_vitest_duration(total_elapsed)))
126
+ end
127
+
128
+ def vitest_summary_line(label, colorize, failed:, passed:, pending:, total:)
129
+ parts = []
130
+ parts << colorize.call(:red, "#{failed} failed") if failed.positive?
131
+ parts << colorize.call(:green, "#{passed} passed") if passed.positive?
132
+ parts << colorize.call(:gray, "#{pending} pending") if pending.positive?
133
+ parts << "0 passed" if parts.empty?
134
+ format("%11s %s (%d)", label, parts.join(" | "), total)
135
+ end
136
+
137
+ def colorize_pass(style, name, example, colorize)
138
+ case style
139
+ when :classic
140
+ "#{colorize.call(:green, "✔")} #{name} (#{colorize.call(:green, format_seconds(example[:run_time]))} seconds)"
141
+ when :fs
142
+ "#{colorize.call(:green, "✔")} #{colorize.call(:gray, name)}"
143
+ when :fv
144
+ number, unit = format_vitest_duration_parts(example[:run_time])
145
+ "#{colorize.call(:green, "✓")} #{name} #{colorize.call(:green, number)}#{colorize.call(:bright_green, unit)}"
146
+ else # :fd
147
+ colorize.call(:green, name)
148
+ end
149
+ end
150
+
151
+ def colorize_fail(style, name, example, n, colorize)
152
+ case style
153
+ when :classic
154
+ "#{colorize.call(:red, "✖")} #{name} (FAILED - #{n}) (#{colorize.call(:red, format_seconds(example[:run_time]))} seconds)"
155
+ when :fs
156
+ colorize.call(:red, "✗ #{name} (FAILED - #{n})")
157
+ when :fv
158
+ colorize.call(:red, "× #{name} #{format_vitest_duration(example[:run_time])}")
159
+ else # :fd
160
+ colorize.call(:red, "#{name} (FAILED - #{n})")
161
+ end
162
+ end
163
+
164
+ # RSpec's own vocabulary is "pending" -- not "skipped" the way Go/Ginkgo
165
+ # call it -- so every style says PENDING, matching what `rspec`'s real
166
+ # `-fd`/`--format documentation` would say for the same example. Include
167
+ # the pending reason where RSpec itself would (`-fd`/`-fs`); `:classic`
168
+ # and `:fv` stay terse (glyph/name/time only), matching how `gorderly`'s
169
+ # classic and Vitest's own tree both treat a skip.
170
+ def colorize_pending(style, name, example, colorize)
171
+ case style
172
+ when :classic
173
+ "#{colorize.call(:cyan, "⊘")} #{name} (#{colorize.call(:cyan, format_seconds(example[:run_time]))} seconds)"
174
+ when :fs
175
+ colorize.call(:cyan, "- #{name} #{pending_suffix(example)}")
176
+ when :fv
177
+ colorize.call(:gray, "↓ #{name}")
178
+ else # :fd
179
+ colorize.call(:yellow, "#{name} #{pending_suffix(example)}")
180
+ end
181
+ end
182
+
183
+ def pending_suffix(example)
184
+ reason = example[:pending_message]
185
+ reason ? "(PENDING: #{reason})" : "(PENDING)"
186
+ end
187
+
188
+ def format_backtrace(example)
189
+ exception = example[:exception]
190
+ return [] unless exception
191
+
192
+ ["#{exception[:class]}: #{exception[:message]}"] + Array(exception[:backtrace])
193
+ end
194
+
195
+ def format_seconds(seconds)
196
+ (seconds < 1) ? format("%.4f", seconds) : format("%.2f", seconds)
197
+ end
198
+
199
+ def format_vitest_duration_parts(seconds)
200
+ ms = seconds * 1000
201
+ return [format("%.2f", ms / 1000), "s"] if ms > 1000
202
+
203
+ [ms.round.to_s, "ms"]
204
+ end
205
+
206
+ def format_vitest_duration(seconds)
207
+ number, unit = format_vitest_duration_parts(seconds)
208
+ "#{number}#{unit}"
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rutidy
4
+ VERSION = "0.1.0"
5
+ end
data/lib/rutidy.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rutidy/version"
4
+ require "rutidy/render"
5
+ require "rutidy/cli"
6
+
7
+ module Rutidy
8
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rutidy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - John Woodell
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-08-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.13'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.13'
27
+ description: A real RSpec formatter plus a small renderer that turns RSpec's own example
28
+ tree into the same classic/fd/fs/fv styles gorderly, kotidy, and xctidy already
29
+ share.
30
+ email:
31
+ - woodie@netpress.com
32
+ executables:
33
+ - rutidy
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - LICENSE
38
+ - README.md
39
+ - exe/rutidy
40
+ - lib/rutidy.rb
41
+ - lib/rutidy/cli.rb
42
+ - lib/rutidy/formatter.rb
43
+ - lib/rutidy/render.rb
44
+ - lib/rutidy/version.rb
45
+ homepage: https://github.com/woodie/rutidy
46
+ licenses:
47
+ - MIT
48
+ metadata:
49
+ homepage_uri: https://github.com/woodie/rutidy
50
+ source_code_uri: https://github.com/woodie/rutidy
51
+ post_install_message:
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '3.0'
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 3.5.22
67
+ signing_key:
68
+ specification_version: 4
69
+ summary: RSpec/Mocha/Vitest-style output for RSpec
70
+ test_files: []