From 4d2ca42b2d19431ba842246026c978ee97d8de9c Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Tue, 7 Jul 2026 13:07:09 +0900 Subject: [PATCH 1/4] test: pin UnwrapException with value context for unwrap()/unwrapErr() unwrap() on Err and unwrapErr() on Ok should throw a dedicated UnwrapException (extending LogicException for BC) whose message includes a description of the contained value, mirroring Rust's panic message which includes the Debug representation. Currently RED: the implementation throws plain LogicException without value context. Claude-Session: https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK --- tests/ErrTest.php | 41 +++++++++++++++++++++++++++++++++++++++++ tests/OkTest.php | 25 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/tests/ErrTest.php b/tests/ErrTest.php index 30b5aa9..93aacb1 100644 --- a/tests/ErrTest.php +++ b/tests/ErrTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use Valbeat\Result\Err; use Valbeat\Result\Ok; +use Valbeat\Result\UnwrapException; class ErrTest extends TestCase { @@ -58,6 +59,46 @@ public function unwrap_throws_exception(): void $err->unwrap(); } + #[Test] + public function unwrap_throwsUnwrapException_withErrorValueInMessage(): void + { + $err = new Err('error'); + $this->expectException(UnwrapException::class); + $this->expectExceptionMessage("called Result::unwrap() on an Err value: 'error'"); + $err->unwrap(); + } + + #[Test] + public function unwrap_withThrowableError_includesClassAndMessage(): void + { + $err = new Err(new \RuntimeException('boom')); + $this->expectException(UnwrapException::class); + $this->expectExceptionMessage('called Result::unwrap() on an Err value: RuntimeException: boom'); + $err->unwrap(); + } + + #[Test] + public function unwrap_withArrayError_describesType(): void + { + $err = new Err(['code' => 500]); + $this->expectException(UnwrapException::class); + $this->expectExceptionMessage('called Result::unwrap() on an Err value: array'); + $err->unwrap(); + } + + #[Test] + public function unwrapException_remainsCatchableAsLogicException(): void + { + $err = new Err('error'); + + try { + $err->unwrap(); + $this->fail('expected an exception'); + } catch (\LogicException $e) { + $this->assertInstanceOf(UnwrapException::class, $e); + } + } + #[Test] public function unwrapErr_returns_error_value(): void { diff --git a/tests/OkTest.php b/tests/OkTest.php index 309282c..5235ab9 100644 --- a/tests/OkTest.php +++ b/tests/OkTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use Valbeat\Result\Err; use Valbeat\Result\Ok; +use Valbeat\Result\UnwrapException; class OkTest extends TestCase { @@ -89,6 +90,30 @@ public function unwrapErr_throws_exception(): void $ok->unwrapErr(); } + #[Test] + public function unwrapErr_throwsUnwrapException_withValueInMessage(): void + { + $ok = new Ok(42); + $this->expectException(UnwrapException::class); + $this->expectExceptionMessage('called Result::unwrapErr() on an Ok value: 42'); + $ok->unwrapErr(); + } + + #[Test] + public function unwrapErr_withStringableValue_includesClassAndString(): void + { + $value = new class () implements \Stringable { + public function __toString(): string + { + return 'stringable value'; + } + }; + $ok = new Ok($value); + $this->expectException(UnwrapException::class); + $this->expectExceptionMessage('stringable value'); + $ok->unwrapErr(); + } + #[Test] public function unwrapOr_returns_value(): void { From 67d62e8f02dad0908aceb3a62f70a0c0c52315ce Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Tue, 7 Jul 2026 13:09:02 +0900 Subject: [PATCH 2/4] feat: throw UnwrapException with value context from unwrap()/unwrapErr() Introduce a dedicated UnwrapException so callers can catch unwrap misuse specifically, and include a description of the contained value in the message (class + message for Throwables, class + string for Stringables, class name for other objects, var_export for scalars), mirroring Rust's panic output for Result::unwrap(). UnwrapException extends LogicException, so existing catch sites and the previous message prefix keep working. Claude-Session: https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK --- README.md | 6 +++--- src/Err.php | 2 +- src/Ok.php | 2 +- src/Result.php | 4 ++++ src/UnwrapException.php | 44 +++++++++++++++++++++++++++++++++++++++++ tests/ErrTest.php | 1 - 6 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 src/UnwrapException.php diff --git a/README.md b/README.md index f28649f..535fc44 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ if ($failure->isErr()) { // Unwrapping values (throws exception on error) $value = $success->unwrap(); // 42 -// $failure->unwrap(); // throws LogicException +// $failure->unwrap(); // throws UnwrapException // Safe unwrapping with default values $value = $failure->unwrapOr(0); // 0 @@ -202,8 +202,8 @@ All Result types (both Ok and Err) implement these methods: - `isErrAnd(callable $fn): bool` - Returns true if the Result is Err and the predicate returns true #### Value Extraction -- `unwrap(): mixed` - Returns the success value or throws LogicException -- `unwrapErr(): mixed` - Returns the error value or throws LogicException +- `unwrap(): mixed` - Returns the success value or throws UnwrapException (extends LogicException) +- `unwrapErr(): mixed` - Returns the error value or throws UnwrapException (extends LogicException) - `unwrapOr(mixed $default): mixed` - Returns the success value or a default - `unwrapOrElse(callable $fn): mixed` - Returns the success value or computes it from the error diff --git a/src/Err.php b/src/Err.php index 968d589..ade25ab 100644 --- a/src/Err.php +++ b/src/Err.php @@ -50,7 +50,7 @@ public function isErrAnd(callable $fn): bool #[Override] public function unwrap(): never { - throw new \LogicException('called Result::unwrap() on an Err value'); + throw UnwrapException::unwrapOnErr($this->value); } /** diff --git a/src/Ok.php b/src/Ok.php index fea4b7b..d494672 100644 --- a/src/Ok.php +++ b/src/Ok.php @@ -59,7 +59,7 @@ public function unwrap(): mixed #[Override] public function unwrapErr(): never { - throw new \LogicException('called Result::unwrapErr() on an Ok value'); + throw UnwrapException::unwrapErrOnOk($this->value); } /** diff --git a/src/Result.php b/src/Result.php index 67f2fd4..c5e72a1 100644 --- a/src/Result.php +++ b/src/Result.php @@ -60,6 +60,8 @@ public function isErrAnd(callable $fn): bool; * 成功値を返します。失敗の場合は例外を投げます. * * @return ($this is Ok ? T : never) + * + * @throws UnwrapException $this が Err の場合 */ public function unwrap(): mixed; @@ -67,6 +69,8 @@ public function unwrap(): mixed; * エラー値を返します。成功の場合は例外を投げます. * * @return ($this is Err ? E : never) + * + * @throws UnwrapException $this が Ok の場合 */ public function unwrapErr(): mixed; diff --git a/src/UnwrapException.php b/src/UnwrapException.php new file mode 100644 index 0000000..f8d09ba --- /dev/null +++ b/src/UnwrapException.php @@ -0,0 +1,44 @@ + \sprintf('%s: %s', $value::class, $value->getMessage()), + $value instanceof \Stringable => \sprintf('%s: %s', $value::class, (string) $value), + \is_object($value) => $value::class, + \is_scalar($value), null === $value => var_export($value, true), + default => get_debug_type($value), + }; + } +} diff --git a/tests/ErrTest.php b/tests/ErrTest.php index 93aacb1..83dc5c7 100644 --- a/tests/ErrTest.php +++ b/tests/ErrTest.php @@ -93,7 +93,6 @@ public function unwrapException_remainsCatchableAsLogicException(): void try { $err->unwrap(); - $this->fail('expected an exception'); } catch (\LogicException $e) { $this->assertInstanceOf(UnwrapException::class, $e); } From b9b473c28ff9e209c9079711f23b12ed06072565 Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Tue, 7 Jul 2026 13:38:36 +0900 Subject: [PATCH 3/4] test: pin describe() edge cases found in code review - a Stringable whose __toString() throws must not replace UnwrapException with the escaping exception - enum error values must keep their case name in the message - long string values must be truncated to bound message size - multiline string values must not break single-line log formats Currently RED. Claude-Session: https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK --- tests/ErrTest.php | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/ErrTest.php b/tests/ErrTest.php index 83dc5c7..364e35e 100644 --- a/tests/ErrTest.php +++ b/tests/ErrTest.php @@ -98,6 +98,54 @@ public function unwrapException_remainsCatchableAsLogicException(): void } } + #[Test] + public function unwrap_withThrowingStringableError_stillThrowsUnwrapException(): void + { + $stringable = new class implements \Stringable { + public function __toString(): string + { + throw new \RuntimeException('rendering failed'); + } + }; + $err = new Err($stringable); + $this->expectException(UnwrapException::class); + $err->unwrap(); + } + + #[Test] + public function unwrap_withEnumError_includesCaseName(): void + { + $err = new Err(SampleEnumError::NotFound); + $this->expectException(UnwrapException::class); + $this->expectExceptionMessage('SampleEnumError::NotFound'); + $err->unwrap(); + } + + #[Test] + public function unwrap_withLongStringError_truncatesMessage(): void + { + $err = new Err(str_repeat('a', 10000)); + + try { + $err->unwrap(); + } catch (UnwrapException $e) { + $this->assertLessThan(300, \strlen($e->getMessage())); + $this->assertStringContainsString('(truncated)', $e->getMessage()); + } + } + + #[Test] + public function unwrap_withMultilineStringError_keepsMessageSingleLine(): void + { + $err = new Err("line1\nline2"); + + try { + $err->unwrap(); + } catch (UnwrapException $e) { + $this->assertStringNotContainsString("\n", $e->getMessage()); + } + } + #[Test] public function unwrapErr_returns_error_value(): void { @@ -391,3 +439,11 @@ private static function asString(string $value): string return $value; } } + +/** + * UnwrapException のメッセージが enum のケース名を含むことを検証するためのフィクスチャ. + */ +enum SampleEnumError +{ + case NotFound; +} From 336e8ae4cce23fb4c3208e5db7b43fc6b9bc1fdf Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Tue, 7 Jul 2026 13:40:16 +0900 Subject: [PATCH 4/4] fix: harden UnwrapException::describe() against review findings - a Stringable whose __toString() throws no longer replaces UnwrapException with the escaping exception (falls back to the class name) - enum error values keep their case name (Status::NotFound) instead of collapsing to the bare class name - summaries are normalized to a single line and truncated at 120 chars, bounding message size and keeping log lines intact - anonymous class names drop the file-path suffix so the value part is not pushed past the truncation limit Claude-Session: https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK --- src/UnwrapException.php | 53 +++++++++++++++++++++++++++++++++++++---- tests/ErrTest.php | 2 +- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/UnwrapException.php b/src/UnwrapException.php index f8d09ba..6fb9eaf 100644 --- a/src/UnwrapException.php +++ b/src/UnwrapException.php @@ -9,9 +9,16 @@ * * \LogicException を継承しているため、既存の catch (\LogicException) はそのまま動作します. * メッセージには保持している値の要約が含まれます(Rust の panic メッセージに相当). + * 注意: スカラー値はメッセージにそのまま(切り詰めの上)現れるため、機微な文字列を + * エラー値に載せる場合はログ出力先に注意してください. */ final class UnwrapException extends \LogicException { + /** + * メッセージに埋め込む値要約の最大長(超過分は切り詰め). + */ + private const int MAX_SUMMARY_LENGTH = 120; + /** * Err に対して unwrap() が呼ばれた場合の例外を生成します. */ @@ -30,15 +37,53 @@ public static function unwrapErrOnOk(mixed $value): self /** * 例外メッセージ用に値の要約を生成します. + * + * 要約は単一行に正規化し、MAX_SUMMARY_LENGTH を超える部分は切り詰めます. */ private static function describe(mixed $value): string { - return match (true) { - $value instanceof \Throwable => \sprintf('%s: %s', $value::class, $value->getMessage()), - $value instanceof \Stringable => \sprintf('%s: %s', $value::class, (string) $value), - \is_object($value) => $value::class, + $summary = match (true) { + $value instanceof \Throwable => \sprintf('%s: %s', self::className($value), $value->getMessage()), + $value instanceof \UnitEnum => \sprintf('%s::%s', $value::class, $value->name), + $value instanceof \Stringable => self::describeStringable($value), + \is_object($value) => self::className($value), \is_scalar($value), null === $value => var_export($value, true), default => get_debug_type($value), }; + + $summary = str_replace(["\r\n", "\r", "\n"], '\n', $summary); + if (\strlen($summary) > self::MAX_SUMMARY_LENGTH) { + return substr($summary, 0, self::MAX_SUMMARY_LENGTH) . '... (truncated)'; + } + + return $summary; + } + + /** + * Stringable の要約を生成します。__toString() が例外を投げてもこの例外を + * 置き換えないよう、失敗時はクラス名のみへフォールバックします. + */ + private static function describeStringable(\Stringable $value): string + { + try { + return \sprintf('%s: %s', self::className($value), (string) $value); + } catch (\Throwable) { + return self::className($value); + } + } + + /** + * クラス名を返します。匿名クラスはファイルパス・行番号を除いた + * 「Foo@anonymous」形式に正規化します. + */ + private static function className(object $value): string + { + $class = $value::class; + $pos = strpos($class, '@anonymous'); + if ($pos === false) { + return $class; + } + + return substr($class, 0, $pos + \strlen('@anonymous')); } } diff --git a/tests/ErrTest.php b/tests/ErrTest.php index 364e35e..01b44af 100644 --- a/tests/ErrTest.php +++ b/tests/ErrTest.php @@ -101,7 +101,7 @@ public function unwrapException_remainsCatchableAsLogicException(): void #[Test] public function unwrap_withThrowingStringableError_stillThrowsUnwrapException(): void { - $stringable = new class implements \Stringable { + $stringable = new class () implements \Stringable { public function __toString(): string { throw new \RuntimeException('rendering failed');