From 1496e735e414e617e84a44f79d61613c68b6e79e Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Thu, 10 Sep 2026 10:25:15 -0500 Subject: [PATCH 01/12] Refuse to_s on an Option again 0.9.0 made Option#to_s render as inspect does, to keep a raise out of the rescue block that interpolates a wrapper into its log line. In a mass conversion of every model in an application, every site that built a string from a reader then wrote wrapper text into data with no exception: a UNIQUE identity column, a hostname, a hashed auth token and a customer-facing page. Each of those raised under 0.8.3. The raise comes back as the same Errgonomic::SerializeError, so an existing rescue still matches, and its message now carries the remedy the log-line case needs: the value via the bounded inspect, that to_s is refused, and inspect for a log line or unwrap_or / expect! for the value. The bounded inspect moves into its own helper so the to_json refusal and this one name the value the same way. --- lib/errgonomic/option.rb | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index 1b5f721..736f717 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -678,18 +678,22 @@ def zip_with(other, &block) Some(other) end - # Render as inspect does. Rust gives Option a Debug and no Display, so - # refusing was faithful, but a to_s that raises replaces the real - # exception while a rescue builds its log line, and the rendered form - # says plainly that a wrapper arrived where a value was meant. - # - # @example - # Some(1).to_s # => "Some(1)" - # Some("x").to_s # => "Some(\"x\")" - # None().to_s # => "None" - # "value: #{Some(1)}" # => "value: Some(1)" + # Refuse to render as a String. Rust gives Option a Debug and no + # Display: a wrapper that reaches a string went unhandled, and a string + # is where it turns into data, a hostname, a hash key or a page. The + # refusal names the value and says how to log it or take it. + # + # @example + # Some(1).to_s # => raise Errgonomic::SerializeError, "Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # None().to_s # => raise Errgonomic::SerializeError, "None refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # "value: #{Some(1)}" # => raise Errgonomic::SerializeError, "Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # [Some("org"), Some("metrics")].join("/") # => raise Errgonomic::SerializeError, "Some(\"org\") refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # format("%s", None()) # => raise Errgonomic::SerializeError, "None refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # String(Some(1)) # => raise Errgonomic::SerializeError, "Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # Some("a" * 100).to_s # => raise Errgonomic::SerializeError, "Some(\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # Some(1).inspect # => "Some(1)" def to_s - inspect + raise Errgonomic::SerializeError, to_s_refusal end # Refuse to serialize an unwrapped Option as JSON. Not only should we @@ -773,12 +777,19 @@ def xor(other) private + def to_s_refusal + "#{bounded_inspect} refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + end + + def serialize_refusal + "cannot serialize an unwrapped #{bounded_inspect}" + end + # Name the value the caller failed to handle, bounded: an inspect of a # record or a long payload would bury the message carrying it. - def serialize_refusal + def bounded_inspect rendered = inspect - rendered = "#{rendered[0, 57]}..." if rendered.length > 60 - "cannot serialize an unwrapped #{rendered}" + rendered.length > 60 ? "#{rendered[0, 57]}..." : rendered end def presence_nudge(from, to) From 504c56d642a71e664e1279f6b1b62d82a6e692f5 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Thu, 10 Sep 2026 10:25:15 -0500 Subject: [PATCH 02/12] Refuse to_s on a Result again The same reversal as for Option, with the same message shape: the value via a bounded inspect, that to_s is refused, and the spellings to reach for. Result had no bounded inspect helper, so one is added privately for the to_s refusal; the to_json and as_json refusals keep their fixed message. --- lib/errgonomic/result.rb | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb index c7c598b..0398711 100644 --- a/lib/errgonomic/result.rb +++ b/lib/errgonomic/result.rb @@ -405,18 +405,22 @@ def map_err(&block) Err(block.call(value)) end - # Render as inspect does. Rust gives Result a Debug and no Display, so - # refusing was faithful, but a to_s that raises replaces the real - # exception while a rescue builds its log line, and the rendered form - # says plainly that a wrapper arrived where a value was meant. + # Refuse to render as a String. Rust gives Result a Debug and no + # Display: a wrapper that reaches a string went unhandled, and a string + # is where it turns into data, a hostname, a hash key or a page. The + # refusal names the value and says how to log it or take it. # # @example - # Ok(1).to_s # => "Ok(1)" - # Err(:nope).to_s # => "Err(:nope)" - # Err().to_s # => "Err()" - # "outcome: #{Ok(1)}" # => "outcome: Ok(1)" + # Ok(1).to_s # => raise Errgonomic::SerializeError, "Ok(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # Err(:nope).to_s # => raise Errgonomic::SerializeError, "Err(:nope) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # "outcome: #{Ok(1)}" # => raise Errgonomic::SerializeError, "Ok(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # [Ok(1), Err(:x)].join(",") # => raise Errgonomic::SerializeError, "Ok(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # format("%s", Err(:x)) # => raise Errgonomic::SerializeError, "Err(:x) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # String(Ok(1)) # => raise Errgonomic::SerializeError, "Ok(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # Err("a" * 100).to_s # => raise Errgonomic::SerializeError, "Err(\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # Err(:nope).inspect # => "Err(:nope)" def to_s - inspect + raise Errgonomic::SerializeError, to_s_refusal end # Refuse to serialize an unwrapped Result as JSON. Not only should we @@ -471,6 +475,17 @@ def deconstruct private + def to_s_refusal + "#{bounded_inspect} refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + end + + # Name the value the caller failed to handle, bounded: an inspect of a + # record or a long payload would bury the message carrying it. + def bounded_inspect + rendered = inspect + rendered.length > 60 ? "#{rendered[0, 57]}..." : rendered + end + def strict_equality!(other, operator) return unless Errgonomic.strict_equality? return if other.is_a?(Errgonomic::Result::Any) From bf01682697e7a50734a939bc116978bc9fb3f4ec Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Thu, 10 Sep 2026 10:25:15 -0500 Subject: [PATCH 03/12] Cover the string, ERB and hash-key paths that reach to_s Each reproduction from the 0.9.0 field reports is a test: join, interpolation, format and String() for both containers; a bare ERB <%= %> through ActionView, whose template error carries the refusal as its cause; and an Option or Result in a Hash key, which both the json gem and ActiveSupport's as_json stringify through to_s, so the raising to_s closes the key position that 0.9.0 opened. A None in a bare ERB tag renders as nothing rather than raising: the Rails integration's None answers nil?, and ActionView's output buffer skips a nil before it asks for to_s. The test records that as it is. --- test/rails_test.rb | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/rails_test.rb b/test/rails_test.rb index 9b05219..246f536 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -896,6 +896,50 @@ def test_nested_options_and_results_refuse_to_serialize assert_raises(Errgonomic::SerializeError) { [Ok(5)].to_json } end + # A string is where a wrapper turns into data: a join builds an identity + # column, an interpolation a hostname, a format a node role. Each reaches + # the value through to_s, so each refuses. + def test_a_wrapper_refuses_to_become_a_string + assert_raises(Errgonomic::SerializeError) { [Some('org'), Some('metrics')].join('/') } + assert_raises(Errgonomic::SerializeError) { "#{None()}.us-east-1.example" } + assert_raises(Errgonomic::SerializeError) { "data_#{Some('hot')}" } + assert_raises(Errgonomic::SerializeError) { None().to_s.split(',') } + assert_raises(Errgonomic::SerializeError) { format('%s', Some(1)) } + assert_raises(Errgonomic::SerializeError) { String(Some(1)) } + assert_raises(Errgonomic::SerializeError) { "outcome: #{Ok(1)}" } + assert_raises(Errgonomic::SerializeError) { [Err(:x), Ok(1)].join(',') } + assert_raises(Errgonomic::SerializeError) { format('%s', Err(:x)) } + assert_raises(Errgonomic::SerializeError) { String(Ok(1)) } + assert_equal 'Some("hot")', Some('hot').inspect + assert_equal 'Err(:x)', Err(:x).inspect + end + + # ActionView's output buffer appends a value through to_s, so a bare + # <%= reader %> of a Some raises rather than shipping Some("...") + # to a page; the template wraps the refusal as its cause. A None answers + # nil? under the Rails integration, and the buffer skips a nil before it + # asks for to_s, so a None renders as nothing. + def test_a_bare_erb_tag_refuses_a_wrapper + view = ActionView::Base.with_empty_template_cache.empty + [Some('visible'), Ok(1), Err(:x)].each do |wrapper| + error = assert_raises(ActionView::Template::Error) { view.render(inline: '<%= value %>', locals: { value: wrapper }) } + assert_kind_of Errgonomic::SerializeError, error.cause + end + assert_equal '', view.render(inline: '<%= value %>', locals: { value: None() }) + end + + # The json gem and ActiveSupport's as_json both stringify a Hash key with + # to_s, so the refusal reaches a key position through to_s alone. + def test_an_option_in_a_hash_key_refuses_to_serialize + assert_raises(Errgonomic::SerializeError) { { Some(1) => 2 }.to_json } + assert_raises(Errgonomic::SerializeError) { { Some(1) => 2 }.as_json } + assert_raises(Errgonomic::SerializeError) { JSON.generate({ Some(1) => 2 }) } + assert_raises(Errgonomic::SerializeError) { [1, 2, 3].group_by { |i| i.even? ? Some(:even) : None() }.to_json } + assert_raises(Errgonomic::SerializeError) { [1, 2, 3].group_by { |i| i.even? ? Some(:even) : None() }.as_json } + assert_raises(Errgonomic::SerializeError) { { Ok(1) => 2 }.as_json } + assert_raises(Errgonomic::SerializeError) { JSON.generate({ Err(:x) => 2 }) } + end + # A conversion changes what a reader returns, not what a record serializes: # the payload has to match the model that was never converted, key for key. def test_a_converted_record_serializes_as_the_unconverted_one_does From 4c0797cf9eef90ddd7ebb19cab7cd5607f471a7e Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Thu, 10 Sep 2026 10:25:15 -0500 Subject: [PATCH 04/12] Say in the README that to_s refuses, and what to call instead --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 03efbef..7469246 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,9 @@ in Errgonomic::Option::None end ``` -An unhandled Option refuses to leak into your output: `to_json` and `as_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `#` to a user. The refusal names what it was carrying (`cannot serialize an unwrapped Some("cell-a1b2")`), so a payload built out of many values says which one went unhandled; the value's `inspect` is bounded to 60 characters, with an ellipsis past that. The refusal covers `as_json` because Hash and Array serialization recurses through that method, and an Option nested in a payload would otherwise serialize as `{"value": ...}`. A converted ActiveRecord model is the one exception, at the model boundary: it unwraps each attribute as it serializes, so a record's own `as_json` says what an unconverted record's says. See [Rails integration](#rails-integration). +An unhandled Option refuses to leak into your output: `to_s`, `to_json` and `as_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `Some("...")` to a user. The refusal names what it was carrying (`cannot serialize an unwrapped Some("cell-a1b2")`), so a payload built out of many values says which one went unhandled; the value's `inspect` is bounded to 60 characters, with an ellipsis past that. The refusal covers `as_json` because Hash and Array serialization recurses through that method, and an Option nested in a payload would otherwise serialize as `{"value": ...}`. A converted ActiveRecord model is the one exception, at the model boundary: it unwraps each attribute as it serializes, so a record's own `as_json` says what an unconverted record's says. See [Rails integration](#rails-integration). -`to_s` renders rather than refusing: `Some(1).to_s` is `"Some(1)"` and `None().to_s` is `"None"`, matching `inspect`, and the same holds for `Ok` and `Err`. Rust gives `Option` a `Debug` and no `Display`, so raising was the faithful reading, but a `to_s` that raises replaces the real exception while a `rescue` builds its log line, which is the worst possible place to be strict. The rendered form is unambiguous: a `Some(1)` in a log says a wrapper arrived where a value was meant. +The `to_s` refusal is the loudest guard of the three, because a string is where a wrapper turns into data: string interpolation, `Array#join`, `format`, `String()`, a bare ERB `<%= %>` and the key of a Hash on its way to JSON all reach the value through `to_s`, and every one of them raises rather than writing `Some("...")` into a hostname, a column or a page. Rust gives `Option` a `Debug` and no `Display`, and `inspect` is the `Debug` here: it renders `Some(1)`, and it is what a log line or a `rescue` should call (`"got #{opt.inspect}"`). The refusal says so: `Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value`. `expect!` also takes a block, on an Option and a Result alike, so a message that interpolates is built only on the branch that raises: `tier.expect! { "no tier for #{account.id}" }`. `present_or_raise!` takes one on the same terms. The positional form is unchanged. @@ -177,7 +177,7 @@ in Errgonomic::Result::Err, Exception => e end ``` -Like Options, unwrapped Results refuse `to_json` and `as_json`, and render `to_s` as `inspect` does. And `Object#result?` / `Object#assert_result!` help enforce at runtime that a value is a Result. +Like Options, unwrapped Results refuse `to_s`, `to_json` and `as_json`, and render through `inspect`. And `Object#result?` / `Object#assert_result!` help enforce at runtime that a value is a Result. ### Optional collections From ae73b8b3bb17b5dfaddfceb9d29c7e6fa968a376 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Thu, 10 Sep 2026 10:26:02 -0500 Subject: [PATCH 05/12] Release 0.9.1 The changelog names the to_s reversal as a behavior change with an upgrading note for anyone who wrote against 0.9.0's rendering. The 0.9.0 section stays as history. --- CHANGELOG.md | 13 +++++++++++++ Gemfile.lock | 4 ++-- lib/errgonomic/version.rb | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e8afc3..1eccc0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ ## [Unreleased] +## [0.9.1] - 2026-09-10 + +This release reverts the 0.9.0 change that made `to_s` render an Option or a Result. The raise is back, with a message that says what to call instead. + +### Upgrading from 0.9.0 + +`to_s` on an Option or a Result raises `Errgonomic::SerializeError` again, so a string built from a wrapped reader fails where it is built rather than writing `Some("...")` or `None` into it. Code written against 0.9.0's rendering, whether a string interpolation, an `Array#join`, a `format`, a `String()` or a bare ERB `<%= %>`, has to take the value first: `unwrap_or` or `expect!` for the value, or `inspect` for a log line. A `rescue` that interpolates a wrapper into its message writes `inspect` there. A `rescue Errgonomic::SerializeError` written against 0.8.x still matches. + +### Changes + +- [Behavior change] `to_s` on an Option or a Result raises `Errgonomic::SerializeError` where 0.9.0 rendered it as `inspect` does. The message names the value with its `inspect`, bounded to 60 characters, says `to_s` is refused, and names `inspect` for a log line and `unwrap_or` / `expect!` for the value: `Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value`. 0.9.0's rendering wrote wrapper text into data at every site that built a string from a wrapped reader, with no exception to find the site by: a UNIQUE identity column, a hostname, a hashed auth token and a customer-facing page. A raise that names the remedy serves the log-line case 0.9.0 traded for, and `inspect` is unchanged +- An Option or a Result in a Hash key raises on its way to JSON again. The json gem and ActiveSupport's `as_json` both stringify a key with `to_s`, so 0.9.0's rendering let `{ Some(1) => 2 }.as_json` write `{"Some(1)" => 2}` where a value position had always raised + ## [0.9.0] - 2026-09-08 This release turns the ActiveRecord integration from a set of wrapped readers into a full set of boundaries, covering readers, writers, query binds, validation and serialization, with the behavior changes named in the bullets below. diff --git a/Gemfile.lock b/Gemfile.lock index 36d970e..efedd05 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - errgonomic (0.9.0) + errgonomic (0.9.1) concurrent-ruby (~> 1.0) GEM @@ -385,7 +385,7 @@ CHECKSUMS diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 erb (6.0.6) sha256=a9b24986700f5bf127c4f297c5403c3ca41b83b0a316c0cd09a096b56e644ae5 - errgonomic (0.9.0) + errgonomic (0.9.1) erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 highline (3.0.1) sha256=ca18b218fd581b1fae832f89bfeaf2b34d3a93429c44fd4411042ffce286f009 diff --git a/lib/errgonomic/version.rb b/lib/errgonomic/version.rb index 5f4ba0a..db78023 100644 --- a/lib/errgonomic/version.rb +++ b/lib/errgonomic/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Errgonomic - VERSION = '0.9.0' + VERSION = '0.9.1' end From a58414891a5459d597960cfb9c4fe672eb4edd61 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Thu, 10 Sep 2026 10:33:59 -0500 Subject: [PATCH 06/12] Check the operand of and, xor, zip and zip_with on every variant or already refused a bare operand with Errgonomic::ArgumentError. and, xor, zip and zip_with did not: and handed the bare operand back out of the Option chain, xor and zip fell into a NoMethodError on some? or none?, and whether anything fired at all depended on the receiver's variant, so a None-heavy path never learned its operand was wrong. The check now runs before the discriminant is consulted, on all five combinators, with or's message. --- lib/errgonomic/option.rb | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index 736f717..7d1ac9a 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -594,10 +594,10 @@ def ok_or_else(&block) # @example # None().or(Some(1)) # => Some(1) # Some(2).or(Some(3)) # => Some(2) - # None().or(2) # => raise Errgonomic::ArgumentError.new, "other must be an Option, was Integer" + # None().or(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" + # Some(1).or(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" def or(other) - raise ArgumentError, "other must be an Option, was #{other.class.name}" unless other.is_a?(Any) - + option_operand!(other) return self if some? other @@ -620,12 +620,17 @@ def or_else(&block) val end - # If self is Some, return the provided other Option. + # If self is Some, return the provided other Option. The operand is + # checked on both variants, so a None-heavy path still learns that it + # was handed a bare value. # # @example # None().and(Some(1)) # => None() # Some(2).and(Some(3)) # => Some(3) + # Some(2).and(3) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" + # None().and(3) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" def and(other) + option_operand!(other) return self if none? other @@ -657,7 +662,10 @@ def and_then(&block) # None().zip(Some(1)) # => None() # Some(1).zip(None()) # => None() # Some(2).zip(Some(3)) # => Some([2, 3]) + # Some(1).zip(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" + # None().zip(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" def zip(other) + option_operand!(other) return None() unless some? && other.some? Some([value, other.value]) @@ -671,7 +679,10 @@ def zip(other) # None().zip_with(Some(1)) { |a, b| a + b } # => None() # Some(1).zip_with(None()) { |a, b| a + b } # => None() # Some(2).zip_with(Some(3)) { |a, b| a + b } # => Some(5) + # Some(1).zip_with(2) { |a, b| a + b } # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" + # None().zip_with(2) { |a, b| a + b } # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" def zip_with(other, &block) + option_operand!(other) return None() unless some? && other.some? other = block.call(value, other.value) @@ -767,8 +778,10 @@ def flatten # Some(:left).xor(Some(:right)) # => None() # Some(:left).xor(None()) #=> Some(:left) # None().xor(Some(:right)) #=> Some(:right) - # + # Some(:left).xor(:right) # => raise Errgonomic::ArgumentError, "other must be an Option, was Symbol" + # None().xor(:right) # => raise Errgonomic::ArgumentError, "other must be an Option, was Symbol" def xor(other) + option_operand!(other) return self if some? && other.none? return other if other.some? && none? @@ -777,6 +790,14 @@ def xor(other) private + # Checked before the discriminant is consulted, so a None-heavy path + # learns about a bare operand as soon as a Some-heavy one would. + def option_operand!(other) + return if other.is_a?(Errgonomic::Option::Any) + + raise Errgonomic::ArgumentError, "other must be an Option, was #{other.class.name}" + end + def to_s_refusal "#{bounded_inspect} refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" end From 775ec1a50053b45cdfaed6f7efbb3d7b9a85119a Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Thu, 10 Sep 2026 10:54:07 -0500 Subject: [PATCH 07/12] Release 0.9.2 --- CHANGELOG.md | 12 ++++++++++++ Gemfile.lock | 4 ++-- lib/errgonomic/version.rb | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eccc0b..4d3fb9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ ## [Unreleased] +## [0.9.2] - 2026-09-10 + +This release makes `Option#and`, `#xor`, `#zip` and `#zip_with` check their operand the way `or` already did. + +### Upgrading from 0.9.1 + +`Option#and`, `#xor`, `#zip` and `#zip_with` raise `Errgonomic::ArgumentError` on a bare operand, on a `None` receiver as well as a `Some`. Code that passed a bare value to `and` and read it back has to wrap it. + +### Changes + +- [Behavior change] `Option#and`, `#xor`, `#zip` and `#zip_with` check their operand the way `or` already did, raising `Errgonomic::ArgumentError` (`other must be an Option, was Integer`) before the receiver's variant is consulted. 0.9.x let `Some(2).and(3)` hand back the bare `3`, let `None().and(3)` and `None().zip(2)` accept the operand silently, and let `Some(1).zip(2)` and `Some(:l).xor(:r)` fall into a bare `NoMethodError` on `some?` or `none?` + ## [0.9.1] - 2026-09-10 This release reverts the 0.9.0 change that made `to_s` render an Option or a Result. The raise is back, with a message that says what to call instead. diff --git a/Gemfile.lock b/Gemfile.lock index efedd05..2777e2a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - errgonomic (0.9.1) + errgonomic (0.9.2) concurrent-ruby (~> 1.0) GEM @@ -385,7 +385,7 @@ CHECKSUMS diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 erb (6.0.6) sha256=a9b24986700f5bf127c4f297c5403c3ca41b83b0a316c0cd09a096b56e644ae5 - errgonomic (0.9.1) + errgonomic (0.9.2) erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 highline (3.0.1) sha256=ca18b218fd581b1fae832f89bfeaf2b34d3a93429c44fd4411042ffce286f009 diff --git a/lib/errgonomic/version.rb b/lib/errgonomic/version.rb index db78023..7fd5b80 100644 --- a/lib/errgonomic/version.rb +++ b/lib/errgonomic/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Errgonomic - VERSION = '0.9.1' + VERSION = '0.9.2' end From 5e7da625833f5005a197c53aecb52773cd01b54a Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Thu, 10 Sep 2026 12:27:57 -0500 Subject: [PATCH 08/12] Name an Ok's value by its inspect when unwrap_err! refuses it unwrap_err! passed the Ok's value as the exception message, so the message was the value's to_s. With to_s refused on an Option or a Result, Ok(Some(1)).unwrap_err! printed only the class name and its message raised. The message is now the value's inspect, bounded as the to_s refusal bounds it, and the error carries the value as unwrap!'s does. --- CHANGELOG.md | 1 + lib/errgonomic/result.rb | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eccc0b..3326d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This release reverts the 0.9.0 change that made `to_s` render an Option or a Res - [Behavior change] `to_s` on an Option or a Result raises `Errgonomic::SerializeError` where 0.9.0 rendered it as `inspect` does. The message names the value with its `inspect`, bounded to 60 characters, says `to_s` is refused, and names `inspect` for a log line and `unwrap_or` / `expect!` for the value: `Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value`. 0.9.0's rendering wrote wrapper text into data at every site that built a string from a wrapped reader, with no exception to find the site by: a UNIQUE identity column, a hostname, a hashed auth token and a customer-facing page. A raise that names the remedy serves the log-line case 0.9.0 traded for, and `inspect` is unchanged - An Option or a Result in a Hash key raises on its way to JSON again. The json gem and ActiveSupport's `as_json` both stringify a key with `to_s`, so 0.9.0's rendering let `{ Some(1) => 2 }.as_json` write `{"Some(1)" => 2}` where a value position had always raised +- `Result#unwrap_err!` on an `Ok` raises an `Errgonomic::UnwrapError` whose message is the Ok's value as `inspect` renders it, bounded to 60 characters, and whose `value` is the value itself. The message used to be the value's `to_s`, so once `to_s` refuses, `Ok(Some(1)).unwrap_err!` printed only the class name and its `message` raised. A String value is quoted in the message, as `inspect` quotes it ## [0.9.0] - 2026-09-08 diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb index 0398711..59447bf 100644 --- a/lib/errgonomic/result.rb +++ b/lib/errgonomic/result.rb @@ -241,12 +241,16 @@ def expect!(msg = nil, &block) end # Return the inner value of an Err, else raise an exception when Ok. + # The message is the Ok's value as inspect renders it, bounded, so an + # Ok holding an Option or a Result still has a message to print. # # @example - # Ok(1).unwrap_err! # => raise Errgonomic::UnwrapError, 1 + # Ok(1).unwrap_err! # => raise Errgonomic::UnwrapError, "1" + # Ok(Some(1)).unwrap_err! # => raise Errgonomic::UnwrapError, "Some(1)" + # Ok("a" * 100).unwrap_err! # => raise Errgonomic::UnwrapError, "\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa..." # Err(:e).unwrap_err! # => :e def unwrap_err! - raise Errgonomic::UnwrapError, value unless err? + raise Errgonomic::UnwrapError.new(bounded_inspect(value), value) unless err? @value end @@ -481,8 +485,8 @@ def to_s_refusal # Name the value the caller failed to handle, bounded: an inspect of a # record or a long payload would bury the message carrying it. - def bounded_inspect - rendered = inspect + def bounded_inspect(object = self) + rendered = object.inspect rendered.length > 60 ? "#{rendered[0, 57]}..." : rendered end From 185c6c1534eff695780f0a4465bcf92d60bbf47d Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Thu, 10 Sep 2026 12:30:30 -0500 Subject: [PATCH 09/12] Pin the two #75 reproduction lines as the issue writes them The join test covered the same shape with different values, and the group_by test grouped bare Options rather than a converted model's reader. Both lines now appear as #75 writes them: a join of a Some and a None, and a group_by over a converted model's optional association on its way to JSON. --- test/rails_test.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/rails_test.rb b/test/rails_test.rb index 246f536..c48a6a3 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -901,6 +901,7 @@ def test_nested_options_and_results_refuse_to_serialize # the value through to_s, so each refuses. def test_a_wrapper_refuses_to_become_a_string assert_raises(Errgonomic::SerializeError) { [Some('org'), Some('metrics')].join('/') } + assert_raises(Errgonomic::SerializeError) { [Some(1), None()].join(',') } assert_raises(Errgonomic::SerializeError) { "#{None()}.us-east-1.example" } assert_raises(Errgonomic::SerializeError) { "data_#{Some('hot')}" } assert_raises(Errgonomic::SerializeError) { None().to_s.split(',') } @@ -940,6 +941,16 @@ def test_an_option_in_a_hash_key_refuses_to_serialize assert_raises(Errgonomic::SerializeError) { JSON.generate({ Err(:x) => 2 }) } end + # A converted model's optional association keys a group_by by its reader, + # and the grouped payload is what reaches JSON. + def test_a_group_by_over_a_wrapped_reader_refuses_to_serialize + author = Author.create!(name: 'Ursula K. Le Guin') + Book.create!(title: 'The Dispossessed', author_id: author.id) + Book.create!(title: 'Anonymous') + + assert_raises(Errgonomic::SerializeError) { Book.all.group_by(&:author).to_json } + end + # A conversion changes what a reader returns, not what a record serializes: # the payload has to match the model that was never converted, key for key. def test_a_converted_record_serializes_as_the_unconverted_one_does From b99842f21765a1e1bda3d547eb4fd1ff8a26c843 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Thu, 10 Sep 2026 12:30:48 -0500 Subject: [PATCH 10/12] Test the json gem's key path without ActiveSupport in the way With ActiveSupport loaded, Hash#to_json reaches a key through as_json, which refused an Option under 0.9.0 as well, so the two to_json assertions in the hash-key test passed before the to_s change and did not test it. They move to a child process that loads the json gem and errgonomic alone, where 0.9.0 rendered {"Some(1)":2} and a raising to_s refuses. --- test/rails_test.rb | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/test/rails_test.rb b/test/rails_test.rb index c48a6a3..112ab7a 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -14,6 +14,8 @@ require 'stringio' require 'tmpdir' require 'fileutils' +require 'open3' +require 'rbconfig' require_relative '../lib/errgonomic/rails' @@ -932,10 +934,8 @@ def test_a_bare_erb_tag_refuses_a_wrapper # The json gem and ActiveSupport's as_json both stringify a Hash key with # to_s, so the refusal reaches a key position through to_s alone. def test_an_option_in_a_hash_key_refuses_to_serialize - assert_raises(Errgonomic::SerializeError) { { Some(1) => 2 }.to_json } assert_raises(Errgonomic::SerializeError) { { Some(1) => 2 }.as_json } assert_raises(Errgonomic::SerializeError) { JSON.generate({ Some(1) => 2 }) } - assert_raises(Errgonomic::SerializeError) { [1, 2, 3].group_by { |i| i.even? ? Some(:even) : None() }.to_json } assert_raises(Errgonomic::SerializeError) { [1, 2, 3].group_by { |i| i.even? ? Some(:even) : None() }.as_json } assert_raises(Errgonomic::SerializeError) { { Ok(1) => 2 }.as_json } assert_raises(Errgonomic::SerializeError) { JSON.generate({ Err(:x) => 2 }) } @@ -951,6 +951,28 @@ def test_a_group_by_over_a_wrapped_reader_refuses_to_serialize assert_raises(Errgonomic::SerializeError) { Book.all.group_by(&:author).to_json } end + # ActiveSupport's to_json reaches a key through as_json, which refuses + # whatever to_s does, so the json gem's own key path runs in a child + # process that loads the json gem and nothing of ActiveSupport. + def test_the_json_gem_alone_refuses_an_option_in_a_hash_key + script = <<~CHILD + [-> { { Some(1) => 2 }.to_json }, + -> { [1, 2, 3].group_by { |i| i.even? ? Some(:even) : None() }.to_json }].each do |call| + puts call.call + rescue Errgonomic::SerializeError => e + puts e.message + end + puts defined?(ActiveSupport).inspect + CHILD + lib = File.expand_path('../lib', __dir__) + out, err, status = Open3.capture3(RbConfig.ruby, '-I', lib, '-rjson', '-rerrgonomic', '-e', script) + + assert status.success?, err + assert_equal ['Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value', + 'None refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value', + 'nil'], out.lines(chomp: true) + end + # A conversion changes what a reader returns, not what a record serializes: # the payload has to match the model that was never converted, key for key. def test_a_converted_record_serializes_as_the_unconverted_one_does From 4e13575f619bbfa83db2f643b278b2c01c3cfa55 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Thu, 10 Sep 2026 12:33:50 -0500 Subject: [PATCH 11/12] Document the unmatched case/in whose message refuses to_s A case/in with several branches and none matching raises NoMatchingPatternError with the subject itself as its message, so on an Option or a Result the message refuses. The top-level printer then shows only the class name, and Minitest's reporter crashes before it names the test. The README says so and that the fix is the missing branch, and a test pins each of the three behaviors so the text cannot drift from them. The json-gem child test shares the new run_ruby helper. --- README.md | 3 +++ test/rails_test.rb | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7469246..a59973f 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,9 @@ An unhandled Option refuses to leak into your output: `to_s`, `to_json` and `as_ The `to_s` refusal is the loudest guard of the three, because a string is where a wrapper turns into data: string interpolation, `Array#join`, `format`, `String()`, a bare ERB `<%= %>` and the key of a Hash on its way to JSON all reach the value through `to_s`, and every one of them raises rather than writing `Some("...")` into a hostname, a column or a page. Rust gives `Option` a `Debug` and no `Display`, and `inspect` is the `Debug` here: it renders `Some(1)`, and it is what a log line or a `rescue` should call (`"got #{opt.inspect}"`). The refusal says so: `Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value`. +Two places reach `to_s` on your behalf, and read badly when it refuses. The first is a `case/in` with more than one `in` branch where none matches. Ruby raises `NoMatchingPatternError` with the unmatched Option or Result itself as the message, and printing that message calls `to_s`. Uncaught, it prints as a bare `NoMatchingPatternError` with no subject, and inside a Minitest test the reporter crashes on it with `Some(1) refuses to_s; …` before it prints the test's name or the run summary. The subject is a variant the `case` has no branch for, such as a Result handed to Option patterns, so the fix is the missing `in` branch, or an `else`; an `inspect` has nowhere to go. A single-branch `case/in` and a rightward `=>` build their message with `inspect` and print normally. + + `expect!` also takes a block, on an Option and a Result alike, so a message that interpolates is built only on the branch that raises: `tier.expect! { "no tier for #{account.id}" }`. `present_or_raise!` takes one on the same terms. The positional form is unchanged. `unwrap!` and `expect!` are for tests and consoles, not application code: they raise on `None`, which is exactly the ambiguous failure the type exists to prevent. Application code should always have a combinator or pattern match that handles the `None` branch explicitly; if none fits, that is a gap worth an issue rather than a reason to unwrap. diff --git a/test/rails_test.rb b/test/rails_test.rb index 112ab7a..992a237 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -917,6 +917,30 @@ def test_a_wrapper_refuses_to_become_a_string assert_equal 'Err(:x)', Err(:x).inspect end + # A case/in with several branches and none matching raises + # NoMatchingPatternError with the subject itself as its message, so + # printing it reaches to_s. The README says what that looks like. + def test_an_unmatched_case_in_over_a_wrapper_has_a_message_that_refuses + error = assert_raises(NoMatchingPatternError) do + case Some(1) + in Errgonomic::Result::Ok, v then v + in Errgonomic::Result::Err, e then e + end + end + assert_raises(Errgonomic::SerializeError) { error.message } + unmatched = 'case Some(1); in Errgonomic::Result::Ok, v then v; in Errgonomic::Result::Err, e then e; end' + + _, printed, = run_ruby(unmatched) + assert_match(/in '
': NoMatchingPatternError$/, printed) + refute_includes printed, 'Some(1)' + + suite = "class T < Minitest::Test; def test_unmatched = #{unmatched}; end" + reported, crashed, = run_ruby(suite, 'minitest/autorun') + assert_includes crashed, 'Some(1) refuses to_s' + refute_match(/\d+ runs, \d+ assertions/, reported) + refute_includes reported + crashed, 'test_unmatched' + end + # ActionView's output buffer appends a value through to_s, so a bare # <%= reader %> of a Some raises rather than shipping Some("...") # to a page; the template wraps the refusal as its cause. A None answers @@ -964,8 +988,7 @@ def test_the_json_gem_alone_refuses_an_option_in_a_hash_key end puts defined?(ActiveSupport).inspect CHILD - lib = File.expand_path('../lib', __dir__) - out, err, status = Open3.capture3(RbConfig.ruby, '-I', lib, '-rjson', '-rerrgonomic', '-e', script) + out, err, status = run_ruby(script, 'json') assert status.success?, err assert_equal ['Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value', @@ -2216,6 +2239,13 @@ def render_note_form(record) end end + # Run a script in a fresh Ruby that loads errgonomic and the named + # libraries and nothing of this process's Rails. + def run_ruby(script, *libraries) + requires = [*libraries, 'errgonomic'].map { |library| "-r#{library}" } + Open3.capture3(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), *requires, '-e', script) + end + def capture_stderr original = $stderr $stderr = StringIO.new From fd66b4f3a670b8006c09f87e963b2395e746a5ea Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Thu, 10 Sep 2026 12:34:15 -0500 Subject: [PATCH 12/12] Document that a tagged logger refuses a wrapper a plain one inspects Logger's own formatter inspects a message that is not a String, so logger.info(opt) renders. ActiveSupport::TaggedLogging interpolates the message once a tag is set, and Rails tags every production request with its request_id, so the same call passes in tests and raises in production. The README and the 0.9.1 upgrading note say to write logger.info(opt.inspect), and a test pins both loggers' behavior. --- CHANGELOG.md | 2 +- README.md | 1 + test/rails_test.rb | 12 ++++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3326d4e..99a08fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This release reverts the 0.9.0 change that made `to_s` render an Option or a Res ### Upgrading from 0.9.0 -`to_s` on an Option or a Result raises `Errgonomic::SerializeError` again, so a string built from a wrapped reader fails where it is built rather than writing `Some("...")` or `None` into it. Code written against 0.9.0's rendering, whether a string interpolation, an `Array#join`, a `format`, a `String()` or a bare ERB `<%= %>`, has to take the value first: `unwrap_or` or `expect!` for the value, or `inspect` for a log line. A `rescue` that interpolates a wrapper into its message writes `inspect` there. A `rescue Errgonomic::SerializeError` written against 0.8.x still matches. +`to_s` on an Option or a Result raises `Errgonomic::SerializeError` again, so a string built from a wrapped reader fails where it is built rather than writing `Some("...")` or `None` into it. Code written against 0.9.0's rendering, whether a string interpolation, an `Array#join`, a `format`, a `String()` or a bare ERB `<%= %>`, has to take the value first: `unwrap_or` or `expect!` for the value, or `inspect` for a log line. A `rescue` that interpolates a wrapper into its message writes `inspect` there. A `rescue Errgonomic::SerializeError` written against 0.8.x still matches. A `logger.info(opt)` that rendered through 0.9.0 still renders through a plain `Logger`, but raises under Rails' `TaggedLogging` once a tag such as `request_id` is set, so it can pass in tests and raise in production: write `logger.info(opt.inspect)`. The README's Option section describes this and a `case/in` that matches nothing on a wrapper, whose `NoMatchingPatternError` no longer prints its subject. ### Changes diff --git a/README.md b/README.md index a59973f..bcd9db2 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ The `to_s` refusal is the loudest guard of the three, because a string is where Two places reach `to_s` on your behalf, and read badly when it refuses. The first is a `case/in` with more than one `in` branch where none matches. Ruby raises `NoMatchingPatternError` with the unmatched Option or Result itself as the message, and printing that message calls `to_s`. Uncaught, it prints as a bare `NoMatchingPatternError` with no subject, and inside a Minitest test the reporter crashes on it with `Some(1) refuses to_s; …` before it prints the test's name or the run summary. The subject is a variant the `case` has no branch for, such as a Result handed to Option patterns, so the fix is the missing `in` branch, or an `else`; an `inspect` has nowhere to go. A single-branch `case/in` and a rightward `=>` build their message with `inspect` and print normally. +The second is a logger. `logger.info(opt)` renders `Some(1)` through a plain `Logger`, whose formatter calls `inspect` on a message that is not a String, but raises through Rails' `ActiveSupport::TaggedLogging` once a tag is set, because the tagged formatter interpolates the message. Rails' generated `production.rb` tags every request with `log_tags = [:request_id]`, so the same line passes in tests and raises in production. Write `logger.info(opt.inspect)`. `expect!` also takes a block, on an Option and a Result alike, so a message that interpolates is built only on the branch that raises: `tier.expect! { "no tier for #{account.id}" }`. `present_or_raise!` takes one on the same terms. The positional form is unchanged. diff --git a/test/rails_test.rb b/test/rails_test.rb index 992a237..d45ca2a 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -941,6 +941,18 @@ def test_an_unmatched_case_in_over_a_wrapper_has_a_message_that_refuses refute_includes reported + crashed, 'test_unmatched' end + # Logger's own formatter inspects a message that is not a String, and the + # tagged formatter interpolates it, so one call behaves two ways. + def test_a_tagged_logger_refuses_a_wrapper_that_a_plain_logger_inspects + io = StringIO.new + logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(io)) + + logger.info(Some(1)) + assert_raises(Errgonomic::SerializeError) { logger.tagged('req-1') { logger.info(Some(1)) } } + logger.tagged('req-1') { logger.info(Some(1).inspect) } + assert_equal "Some(1)\n[req-1] Some(1)\n", io.string + end + # ActionView's output buffer appends a value through to_s, so a bare # <%= reader %> of a Some raises rather than shipping Some("...") # to a page; the template wraps the refusal as its cause. A None answers