diff --git a/src/codeweaver/cli/ui/error_handler.py b/src/codeweaver/cli/ui/error_handler.py index e33a25db..17fefcf6 100644 --- a/src/codeweaver/cli/ui/error_handler.py +++ b/src/codeweaver/cli/ui/error_handler.py @@ -13,6 +13,7 @@ from typing import TYPE_CHECKING from codeweaver.core import CodeWeaverError +from codeweaver.core.exceptions import _BULLET from codeweaver.core.utils import get_codeweaver_prefix @@ -20,11 +21,113 @@ from codeweaver.cli.ui.status_display import StatusDisplay +# --------------------------------------------------------------------------- +# Exception-chain helpers +# --------------------------------------------------------------------------- + +_MAX_CHAIN_DEPTH = 10 + + +def _collect_codeweaver_chain(exc: BaseException) -> list[CodeWeaverError]: + """Walk ``__cause__`` / ``__context__`` and return all CodeWeaverError nodes. + + Returns nodes in outermost-first order (the exception itself is first). + Stops following the chain when it hits a non-CodeWeaverError or exceeds + ``_MAX_CHAIN_DEPTH`` to guard against unexpectedly long or cyclic chains. + + Args: + exc: The exception to start from. + + Returns: + List of CodeWeaverError instances from outermost to root. + """ + chain: list[CodeWeaverError] = [] + seen: set[int] = set() + current: BaseException | None = exc + while current is not None and len(chain) < _MAX_CHAIN_DEPTH: + exc_id = id(current) + if exc_id in seen: + break + seen.add(exc_id) + if not isinstance(current, CodeWeaverError): + # Stop walking once we leave the CodeWeaverError chain so we don't + # traverse an arbitrarily long external __cause__/__context__ chain. + break + chain.append(current) + # Explicit cause (raise X from Y) takes priority; fall back to implicit + # context only when it has not been suppressed by ``raise X from None``. + next_exc: BaseException | None = current.__cause__ or ( + current.__context__ if not current.__suppress_context__ else None + ) + current = next_exc + return chain + + +def _get_external_root(exc: BaseException) -> BaseException | None: + """Return the first non-CodeWeaverError exception at the root of the chain. + + This surfaces the original third-party or built-in exception (e.g. + ``OSError``, ``ImportError``) that triggered the CodeWeaver chain so users + can see what actually went wrong at the lowest level. + + Args: + exc: The outermost exception. + + Returns: + The non-CodeWeaverError root exception, or ``None`` if the entire chain + is made up of CodeWeaverError instances. + """ + seen: set[int] = set() + current: BaseException | None = exc + while current is not None: + exc_id = id(current) + if exc_id in seen: + break + seen.add(exc_id) + next_exc: BaseException | None = current.__cause__ or ( + current.__context__ if not current.__suppress_context__ else None + ) + # We found an external exception in the chain (not the root of the walk) + if not isinstance(current, CodeWeaverError) and current is not exc: + return current + current = next_exc + return None + + +def _deduplicate_suggestions(suggestions: list[str]) -> list[str]: + """Return *suggestions* with duplicates removed, preserving original order. + + Args: + suggestions: Possibly-duplicated suggestion strings. + + Returns: + De-duplicated list in original order. + """ + seen: set[str] = set() + result: list[str] = [] + for s in suggestions: + if s not in seen: + seen.add(s) + result.append(s) + return result + + +# --------------------------------------------------------------------------- +# Error handler +# --------------------------------------------------------------------------- + + class CLIErrorHandler: """Unified error handling for CLI commands. Provides consistent error display across all CLI commands with appropriate detail levels based on error type and verbosity flags. + + When a ``CodeWeaverError`` exception chain is displayed, each node in the + chain contributes its message and location, but suggestions are aggregated + and de-duplicated across the whole chain, and the issue-reporting + boilerplate is printed exactly once. This prevents walls of identical + advice when multiple CodeWeaver exceptions wrap one another. """ def __init__( @@ -66,22 +169,66 @@ def handle_error(self, error: Exception, context: str, *, exit_code: int = 1) -> sys.exit(exit_code) def _handle_codeweaver_error(self, error: CodeWeaverError, context: str) -> None: - """Display CodeWeaver-specific errors. + """Display a CodeWeaver exception chain without repeating boilerplate. + + Walks the full ``__cause__`` / ``__context__`` chain and: + + * Shows the outermost error in full (message, location, details). + * Shows each deeper cause condensed to a single line. + * Surfaces the first non-CodeWeaverError root cause if present. + * Aggregates all suggestions across the chain, de-duplicates them, and + displays them once. + * Prints the issue-reporting boilerplate exactly once at the end. Args: - error: CodeWeaverError to display - context: Context description + error: The outermost CodeWeaverError to display. + context: Human-readable context for the failure (e.g. "Indexing"). + """ + chain = _collect_codeweaver_chain(error) + + self.display.console.print(f"\n{self.prefix}\n [bold red]✗ {context} failed[/bold red]\n") + self._print_primary_error(error) + + if len(chain) > 1: + self._print_cause_chain(chain[1:]) + + ext_root = _get_external_root(error) + if ext_root: + self.display.console.print( + f"[dim]Underlying cause: {type(ext_root).__name__}: {ext_root}[/dim]\n" + ) + + all_suggestions = _deduplicate_suggestions([s for exc in chain for s in exc.suggestions]) + if all_suggestions: + self.display.console.print("[yellow]Suggestions:[/yellow]") + for suggestion in all_suggestions: + self.display.console.print(f" {_BULLET} {suggestion}") + self.display.console.print() + + for line in CodeWeaverError.issue_information: + self.display.console.print(line) + + if self.verbose or self.debug: + self.display.console.print("\n[dim]Full traceback:[/dim]") + self.display.console.print_exception(show_locals=self.debug) + + def _print_primary_error(self, error: CodeWeaverError) -> None: + """Print the outermost error with its message, location, and details. + + Args: + error: The primary CodeWeaverError to render. """ from pydantic_core import to_json - # Print header with error context - self.display.console.print(f"\n{self.prefix} \n [bold red]✗ {context} failed[/bold red]\n") + from codeweaver.core.utils.environment import format_file_link - # Print error message - self.display.console.print(f"[bold red]Error:[/bold red] {error}\n") + self.display.console.print(f"[bold red]Error:[/bold red] {error.message}") + if error.location and error.location.filename: + link = format_file_link(error.location.filename, error.location.line_number) + self.display.console.print(f" [dim]in '{error.location.module_name}' at {link}[/dim]") + self.display.console.print() - # Show details if available - if hasattr(error, "details") and error.details: + if error.details: self.display.console.print("[yellow]Details:[/yellow]") if isinstance(error.details, dict): self.display.console.print( @@ -91,17 +238,28 @@ def _handle_codeweaver_error(self, error: CodeWeaverError, context: str) -> None self.display.console.print(str(error.details)) self.display.console.print() - # Show suggestions if available - if hasattr(error, "suggestions") and error.suggestions: - self.display.console.print("[yellow]Suggestions:[/yellow]") - for suggestion in error.suggestions: - self.display.console.print(f" • {suggestion}") - self.display.console.print() + def _print_cause_chain(self, causes: list[CodeWeaverError]) -> None: + """Print a condensed cause chain (all nodes except the outermost). - # Show full traceback in verbose/debug mode - if self.verbose or self.debug: - self.display.console.print("[dim]Full traceback:[/dim]") - self.display.console.print_exception(show_locals=self.debug) + Each cause is rendered on a single ``→ ExcType: message (location)`` + line so users can follow the chain without reading repeated boilerplate. + + Args: + causes: Chain nodes in outermost-to-root order, excluding the + primary node already rendered by ``_print_primary_error``. + """ + self.display.console.print("[dim]Caused by:[/dim]") + for cause in causes: + location_str = "" + if cause.location and cause.location.filename: + location_str = ( + f" [dim](in '{cause.location.module_name}', " + f"line {cause.location.line_number})[/dim]" + ) + self.display.console.print( + f" [dim]→ {type(cause).__name__}: {cause.message}{location_str}[/dim]" + ) + self.display.console.print() def _handle_unexpected_error(self, error: Exception, context: str) -> None: """Display unexpected errors. diff --git a/src/codeweaver/core/exceptions.py b/src/codeweaver/core/exceptions.py index 38742bb8..5c25d818 100644 --- a/src/codeweaver/core/exceptions.py +++ b/src/codeweaver/core/exceptions.py @@ -18,6 +18,9 @@ from typing import Any, ClassVar, NamedTuple +_BULLET = "\u2022" + + class LocationInfo(NamedTuple): """Location information for where an exception was raised. @@ -100,9 +103,14 @@ class CodeWeaverError(Exception): Provides structured error information including details and suggestions for resolution. + + Use ``format_for_display()`` to render a single exception node with full + context for the user. Use ``log_record()`` to get a structured dict for + structured-logging systems. ``__str__`` returns a concise message + appropriate for tracebacks and plain log lines. """ - _issue_information: ClassVar[tuple[str, ...]] = _get_issue_information() + issue_information: ClassVar[tuple[str, ...]] = _get_issue_information() def __init__( self, @@ -118,6 +126,7 @@ def __init__( message: Human-readable error message details: Additional context about the error suggestions: Actionable suggestions for resolving the error + location: Where the exception was raised (auto-detected when omitted) """ super().__init__(message) self.message = message @@ -126,24 +135,60 @@ def __init__( self.location = location or LocationInfo.from_frame(2) def __str__(self) -> str: - """Return descriptive error message with context details.""" + """Return a concise error message suitable for logs and tracebacks. + + Returns only the error message and a brief location hint so that + exception chains don't repeat boilerplate at every level. For the + full user-facing display (details, suggestions, issue links) call + ``format_for_display()`` instead. + """ + if self.location and self.location.filename: + return ( + f"{self.message} " + f"(in '{self.location.module_name}', line {self.location.line_number})" + ) + return self.message + + def format_for_display( + self, + *, + include_suggestions: bool = True, + include_details: bool = True, + include_issue_info: bool = False, + ) -> str: + """Format this exception node for user-facing display. + + Unlike ``__str__``, this produces richly formatted output with all + contextual information attached to *this* exception. When displaying + an exception chain, call this only on the node you want to show in + full; use ``include_issue_info=True`` only on the outermost display + call so that the reporting boilerplate appears exactly once. + + Args: + include_suggestions: Include the suggestions list. + include_details: Include the details dict. + include_issue_info: Append the alpha/issue-reporting boilerplate. + + Returns: + Formatted string for display to the user. + """ from codeweaver.core.utils.environment import format_file_link from codeweaver.core.utils.environment import is_tty as _is_tty - if _is_tty(): - location_info = ( - f"\n[bold red]Encountered error[/bold red] in '{self.location.module_name}' at {format_file_link(self.location.filename, self.location.line_number)}\n" - if self.location and self.location.filename - else "" - ) - else: - location_info = ( - f"\nEncountered error in '{self.location.module_name}' at {format_file_link(self.location.filename, self.location.line_number)}\n" - if self.location and self.location.filename - else "" - ) - parts: list[str] = [self.message, location_info] - if self.details: + tty = _is_tty() + parts: list[str] = [self.message] + + if self.location and self.location.filename: + link = format_file_link(self.location.filename, self.location.line_number) + if tty: + parts.append( + f"[bold red]Encountered error[/bold red] in " + f"'{self.location.module_name}' at {link}" + ) + else: + parts.append(f"Encountered error in '{self.location.module_name}' at {link}") + + if include_details and self.details: detail_parts: list[str] = [] if "file_path" in self.details: detail_parts.append(f"file: {self.details['file_path']}") @@ -164,9 +209,44 @@ def __str__(self) -> str: ) if detail_parts: parts.append(_get_reporting_info(detail_parts)) - parts.extend(type(self)._issue_information) + + if include_suggestions and self.suggestions: + parts.append("\n".join(f" {_BULLET} {s}" for s in self.suggestions)) + + if include_issue_info: + parts.extend(type(self).issue_information) + return "\n".join(parts) + def log_record(self) -> dict[str, Any]: + """Return a structured record for use with structured logging systems. + + Produces a plain ``dict`` containing all exception data so that + logging back-ends (structlog, Python logging with a JSON formatter, + etc.) can emit fully-structured log lines without parsing strings. + + Example:: + + logger.error("Indexing failed", **error.log_record()) + + Returns: + Dict with ``error_type``, ``message``, ``details``, + ``suggestions``, and ``location`` keys. + """ + return { + "error_type": type(self).__name__, + "message": self.message, + "details": dict(self.details), + "suggestions": list(self.suggestions), + "location": { + "filename": self.location.filename, + "line_number": self.location.line_number, + "module_name": self.location.module_name, + } + if self.location + else None, + } + class InitializationError(CodeWeaverError): """Initialization and startup errors. diff --git a/tests/unit/core/test_exceptions.py b/tests/unit/core/test_exceptions.py new file mode 100644 index 00000000..fa9da2c4 --- /dev/null +++ b/tests/unit/core/test_exceptions.py @@ -0,0 +1,441 @@ +# SPDX-FileCopyrightText: 2026 Knitli Inc. +# SPDX-FileContributor: Adam Poulemanos +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +"""Unit tests for CodeWeaverError and associated exception infrastructure. + +Covers: +- ``__str__`` conciseness (message + location only) +- ``log_record()`` dict shape and values +- ``format_for_display()`` inclusion flags +- ``issue_information`` public ClassVar +- ``_collect_codeweaver_chain`` chain walking and depth cap +- ``_get_external_root`` external exception detection +- ``_deduplicate_suggestions`` order-preserving dedup +- ``CLIErrorHandler`` chain rendering: deduplicated suggestions, condensed causes, + boilerplate printed once +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from codeweaver.cli.ui.error_handler import ( + CLIErrorHandler, + _collect_codeweaver_chain, + _deduplicate_suggestions, + _get_external_root, +) +from codeweaver.core.exceptions import CodeWeaverError, IndexingError, LocationInfo + + +pytestmark = [pytest.mark.unit] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_error( + msg: str = "something failed", + *, + details: dict | None = None, + suggestions: list[str] | None = None, +) -> CodeWeaverError: + """Build a CodeWeaverError with a stable synthetic location.""" + return CodeWeaverError( + msg, + details=details, + suggestions=suggestions, + location=LocationInfo(filename="/app/main.py", line_number=42, module_name="app.main"), + ) + + +def _make_chain(depth: int = 3) -> CodeWeaverError: + """Build a raise-from chain of ``depth`` CodeWeaverError nodes.""" + root = CodeWeaverError( + "root cause", + suggestions=["fix root"], + location=LocationInfo(filename="/app/root.py", line_number=1, module_name="root"), + ) + current: Exception = root + for i in range(1, depth): + next_err = CodeWeaverError( + f"level {i}", + suggestions=[f"fix level {i}", "fix root"], # intentional duplicate of root + location=LocationInfo( + filename=f"/app/level{i}.py", line_number=i * 10, module_name=f"app.level{i}" + ), + ) + try: + raise next_err from current + except CodeWeaverError as exc: + current = exc + assert isinstance(current, CodeWeaverError) + return current + + +# --------------------------------------------------------------------------- +# CodeWeaverError.__str__ +# --------------------------------------------------------------------------- + + +@pytest.mark.mock_only +@pytest.mark.unit +class TestCodeWeaverErrorStr: + """__str__ returns a concise message with location, no boilerplate.""" + + def test_includes_message(self) -> None: + """String representation contains the error message.""" + err = _make_error("disk full") + assert "disk full" in str(err) + + def test_includes_module_and_line(self) -> None: + """String representation includes module name and line number.""" + err = _make_error() + s = str(err) + assert "app.main" in s + assert "42" in s + + def test_no_issue_links(self) -> None: + """String representation does not include issue/GitHub links.""" + err = _make_error() + s = str(err) + assert "github" not in s.lower() + assert "alpha" not in s.lower() + + def test_no_suggestions(self) -> None: + """String representation does not include suggestions.""" + err = _make_error(suggestions=["do this", "do that"]) + assert "do this" not in str(err) + + def test_no_location_when_missing(self) -> None: + """String representation falls back to message only when no location.""" + err = CodeWeaverError("bare message", location=None) + # Force no location (from_frame might capture something, override) + err.location = None + assert str(err) == "bare message" + + +# --------------------------------------------------------------------------- +# CodeWeaverError.log_record +# --------------------------------------------------------------------------- + + +@pytest.mark.mock_only +@pytest.mark.unit +class TestLogRecord: + """log_record() returns a well-formed dict for structured logging.""" + + def test_returns_dict(self) -> None: + """log_record returns a plain dict.""" + err = _make_error() + record = err.log_record() + assert isinstance(record, dict) + + def test_required_keys(self) -> None: + """log_record contains all required top-level keys.""" + err = _make_error() + record = err.log_record() + assert set(record.keys()) == {"error_type", "message", "details", "suggestions", "location"} + + def test_error_type_is_class_name(self) -> None: + """error_type is the concrete exception class name.""" + err = IndexingError("file missing") + assert err.log_record()["error_type"] == "IndexingError" + + def test_message_matches(self) -> None: + """message field matches the original message.""" + err = _make_error("the message") + assert err.log_record()["message"] == "the message" + + def test_details_is_copy(self) -> None: + """details in log_record is a separate copy.""" + original = {"key": "val"} + err = _make_error(details=original) + record = err.log_record() + record["details"]["key"] = "mutated" + assert err.details["key"] == "val" # original unchanged + + def test_suggestions_is_copy(self) -> None: + """suggestions in log_record is a separate copy.""" + err = _make_error(suggestions=["a", "b"]) + record = err.log_record() + record["suggestions"].append("c") + assert err.suggestions == ["a", "b"] + + def test_location_dict_shape(self) -> None: + """location sub-dict has filename, line_number, module_name.""" + err = _make_error() + loc = err.log_record()["location"] + assert isinstance(loc, dict) + assert loc["filename"] == "/app/main.py" + assert loc["line_number"] == 42 + assert loc["module_name"] == "app.main" + + def test_location_none_when_missing(self) -> None: + """location is None when the exception has no location info.""" + err = CodeWeaverError("no location") + err.location = None + assert err.log_record()["location"] is None + + def test_empty_collections_when_no_extras(self) -> None: + """details and suggestions are empty collections when not provided.""" + err = _make_error() + record = err.log_record() + assert record["details"] == {} + assert record["suggestions"] == [] + + +# --------------------------------------------------------------------------- +# CodeWeaverError.issue_information (public ClassVar) +# --------------------------------------------------------------------------- + + +@pytest.mark.mock_only +@pytest.mark.unit +class TestIssueInformation: + """issue_information is a public ClassVar accessible without leading underscore.""" + + def test_accessible_on_class(self) -> None: + """issue_information is accessible via the class.""" + info = CodeWeaverError.issue_information + assert isinstance(info, tuple) + + def test_non_empty(self) -> None: + """issue_information contains at least one string.""" + assert len(CodeWeaverError.issue_information) > 0 + + def test_contains_url(self) -> None: + """issue_information contains a GitHub URL.""" + combined = " ".join(CodeWeaverError.issue_information) + assert "https://github.com/knitli/codeweaver" in combined + + +# --------------------------------------------------------------------------- +# _collect_codeweaver_chain +# --------------------------------------------------------------------------- + + +@pytest.mark.mock_only +@pytest.mark.unit +class TestCollectCodeWeaverChain: + """_collect_codeweaver_chain walks __cause__/__context__ chains correctly.""" + + def test_single_exception_returns_one_element(self) -> None: + """Single CodeWeaverError returns a list with one element.""" + err = _make_error() + chain = _collect_codeweaver_chain(err) + assert len(chain) == 1 + assert chain[0] is err + + def test_chain_order_outermost_first(self) -> None: + """Chain is returned outermost-first.""" + outer = _make_chain(depth=3) + chain = _collect_codeweaver_chain(outer) + # The outermost is level 2, then level 1, then root + assert chain[0] is outer + + def test_chain_length(self) -> None: + """All CodeWeaverError nodes in the chain are collected.""" + outer = _make_chain(depth=3) + chain = _collect_codeweaver_chain(outer) + assert len(chain) == 3 + + def test_stops_at_non_codeweaver_error(self) -> None: + """Chain walking stops when it hits a non-CodeWeaverError.""" + external = OSError("disk full") + cw_err = CodeWeaverError("wrapping", location=LocationInfo("/a.py", 1, "a")) + try: + raise cw_err from external + except CodeWeaverError as exc: + chain = _collect_codeweaver_chain(exc) + # Only the CodeWeaverError node; OSError terminates the walk + assert len(chain) == 1 + assert isinstance(chain[0], CodeWeaverError) + + def test_depth_cap(self) -> None: + """Chain is capped at _MAX_CHAIN_DEPTH even for very deep chains.""" + from codeweaver.cli.ui.error_handler import _MAX_CHAIN_DEPTH + + outer = _make_chain(depth=_MAX_CHAIN_DEPTH + 5) + chain = _collect_codeweaver_chain(outer) + assert len(chain) <= _MAX_CHAIN_DEPTH + + def test_non_codeweaver_root_not_included(self) -> None: + """Non-CodeWeaverError root exception does not appear in the chain.""" + external = ValueError("bad value") + cw = CodeWeaverError("wrap", location=LocationInfo("/a.py", 1, "a")) + try: + raise cw from external + except CodeWeaverError as exc: + chain = _collect_codeweaver_chain(exc) + assert all(isinstance(e, CodeWeaverError) for e in chain) + + +# --------------------------------------------------------------------------- +# _get_external_root +# --------------------------------------------------------------------------- + + +@pytest.mark.mock_only +@pytest.mark.unit +class TestGetExternalRoot: + """_get_external_root surfaces the first non-CodeWeaverError in the chain.""" + + def test_all_codeweaver_returns_none(self) -> None: + """Returns None when the entire chain is CodeWeaverError nodes.""" + outer = _make_chain(depth=2) + assert _get_external_root(outer) is None + + def test_finds_external_cause(self) -> None: + """Returns the external exception when one exists.""" + external = OSError("disk full") + cw = CodeWeaverError("wrap", location=LocationInfo("/a.py", 1, "a")) + try: + raise cw from external + except CodeWeaverError as exc: + root = _get_external_root(exc) + assert root is external + + def test_does_not_return_starting_exception(self) -> None: + """Does not return the exception itself even if it is not a CodeWeaverError.""" + non_cw = OSError("start") + assert _get_external_root(non_cw) is None + + +# --------------------------------------------------------------------------- +# _deduplicate_suggestions +# --------------------------------------------------------------------------- + + +@pytest.mark.mock_only +@pytest.mark.unit +class TestDeduplicateSuggestions: + """_deduplicate_suggestions removes duplicates while preserving order.""" + + def test_no_duplicates_unchanged(self) -> None: + """List without duplicates is returned unchanged.""" + suggestions = ["a", "b", "c"] + assert _deduplicate_suggestions(suggestions) == ["a", "b", "c"] + + def test_duplicates_removed(self) -> None: + """Duplicate entries are removed.""" + result = _deduplicate_suggestions(["a", "b", "a", "c", "b"]) + assert result == ["a", "b", "c"] + + def test_order_preserved(self) -> None: + """First occurrence order is preserved.""" + result = _deduplicate_suggestions(["z", "a", "z"]) + assert result == ["z", "a"] + + def test_empty_list(self) -> None: + """Empty list returns empty list.""" + assert _deduplicate_suggestions([]) == [] + + +# --------------------------------------------------------------------------- +# CLIErrorHandler chain rendering +# --------------------------------------------------------------------------- + + +def _make_rich_console() -> MagicMock: + """Return a mock console that records print calls.""" + console = MagicMock() + console.print = MagicMock() + console.print_exception = MagicMock() + return console + + +def _make_display(console: MagicMock) -> MagicMock: + """Return a mock StatusDisplay wrapping *console*.""" + display = MagicMock() + display.console = console + return display + + +def _all_printed(console: MagicMock) -> str: + """Collect all text passed to console.print into one string.""" + parts = [] + for call in console.print.call_args_list: + args = call.args + if args: + parts.append(str(args[0])) + return "\n".join(parts) + + +@pytest.mark.mock_only +@pytest.mark.unit +class TestCLIErrorHandlerChainRendering: + """CLIErrorHandler._handle_codeweaver_error renders chains correctly.""" + + def _handler(self, *, verbose: bool = False) -> tuple[CLIErrorHandler, MagicMock]: + console = _make_rich_console() + display = _make_display(console) + handler = CLIErrorHandler(display, verbose=verbose, prefix="[cw]") + return handler, console + + def test_single_error_shows_message(self) -> None: + """Primary error message is always displayed.""" + handler, console = self._handler() + err = _make_error("primary message") + handler._handle_codeweaver_error(err, "Indexing") + output = _all_printed(console) + assert "primary message" in output + + def test_context_label_in_header(self) -> None: + """Context label appears in the failure header.""" + handler, console = self._handler() + err = _make_error() + handler._handle_codeweaver_error(err, "My Context") + output = _all_printed(console) + assert "My Context" in output + + def test_causes_rendered_as_arrows(self) -> None: + """Deeper causes are shown as condensed → lines, not full boilerplate.""" + handler, console = self._handler() + outer = _make_chain(depth=3) + handler._handle_codeweaver_error(outer, "Startup") + output = _all_printed(console) + assert "→" in output + + def test_suggestions_deduplicated_once(self) -> None: + """Duplicate suggestions across the chain appear only once.""" + handler, console = self._handler() + outer = _make_chain(depth=3) # chain has duplicate "fix root" suggestions + handler._handle_codeweaver_error(outer, "Test") + output = _all_printed(console) + # "fix root" should appear exactly once in suggestions + assert output.count("fix root") == 1 + + def test_issue_information_once(self) -> None: + """Issue-reporting boilerplate appears exactly once regardless of chain depth. + + The issue_information tuple itself contains two github.com URLs (issues + discussions). + We verify the boilerplate block appears only once by checking the issues URL count. + """ + handler, console = self._handler() + outer = _make_chain(depth=4) + handler._handle_codeweaver_error(outer, "Test") + output = _all_printed(console) + # The issues URL (distinct from the discussions URL) appears exactly once + issues_url_count = output.count("knitli/codeweaver/issues") + assert issues_url_count == 1 + + def test_no_traceback_without_verbose(self) -> None: + """print_exception is not called unless verbose or debug is set.""" + handler, console = self._handler(verbose=False) + err = _make_error() + handler._handle_codeweaver_error(err, "Test") + console.print_exception.assert_not_called() + + def test_traceback_with_verbose(self) -> None: + """print_exception is called when verbose is True.""" + handler, console = self._handler(verbose=True) + err = _make_error() + handler._handle_codeweaver_error(err, "Test") + console.print_exception.assert_called_once()