diff --git a/build.gradle b/build.gradle index 0431a46..3916828 100644 --- a/build.gradle +++ b/build.gradle @@ -204,12 +204,12 @@ task waitForCluster(type: Exec) { fi echo "Waiting for cluster to be ready..." - for i in {1..120}; do + for i in {1..300}; do if curl -s http://localhost:9200 > /dev/null 2>&1; then echo "Cluster is ready!" exit 0 fi - echo "Attempt $i/120: Cluster not ready yet, waiting..." + echo "Attempt $i/300: Cluster not ready yet, waiting..." sleep 2 done echo "Cluster failed to start within timeout" diff --git a/src/main/python/opensearchsql_cli/main.py b/src/main/python/opensearchsql_cli/main.py index 6a6c435..4adb7ba 100644 --- a/src/main/python/opensearchsql_cli/main.py +++ b/src/main/python/opensearchsql_cli/main.py @@ -124,6 +124,12 @@ def main( "-c", help="Display current configuration settings", ), + remote: bool = typer.Option( + False, + "--remote", + is_flag=True, + help="Connect directly to remote cluster without local Java gateway", + ), ): """ OpenSearch SQL CLI - Command Line Interface for OpenSearch SQL Plug-in @@ -184,18 +190,22 @@ def main( ) return - with console.status("Initializing SQL Library...", spinner="dots"): - if not self.sql_connection.initialize_sql_library( - host_port, username_password, ignore_ssl, aws_auth - ): - if ( - hasattr(self.sql_connection, "error_message") - and self.sql_connection.error_message + if not remote: + with console.status("Initializing SQL Library...", spinner="dots"): + if not self.sql_connection.initialize_sql_library( + host_port, username_password, ignore_ssl, aws_auth ): - console.print( - f"[bold red]ERROR:[/bold red] [red]{self.sql_connection.error_message}[/red]\n" - ) - return + if ( + hasattr(self.sql_connection, "error_message") + and self.sql_connection.error_message + ): + console.print( + f"[bold red]ERROR:[/bold red] [red]{self.sql_connection.error_message}[/red]\n" + ) + return + else: + # Set remote mode on the connection + self.sql_connection.set_remote_mode(True) # print Banner banner = pyfiglet.figlet_format("OpenSearch", font="slant") diff --git a/src/main/python/opensearchsql_cli/query/__init__.py b/src/main/python/opensearchsql_cli/query/__init__.py index ab0ff69..bbb8015 100644 --- a/src/main/python/opensearchsql_cli/query/__init__.py +++ b/src/main/python/opensearchsql_cli/query/__init__.py @@ -8,3 +8,4 @@ from .query_results import QueryResults from .saved_queries import SavedQueries from .explain_results import ExplainResults +from .error_formatter import ErrorFormatter diff --git a/src/main/python/opensearchsql_cli/query/error_formatter.py b/src/main/python/opensearchsql_cli/query/error_formatter.py new file mode 100644 index 0000000..fd61cde --- /dev/null +++ b/src/main/python/opensearchsql_cli/query/error_formatter.py @@ -0,0 +1,246 @@ +""" +Error Formatting + +This module provides functionality for formatting enhanced error reports from OpenSearch SQL/PPL. +Inspired by the color-eyre Rust crate for beautiful error display. +""" + +import json +from typing import Optional, Dict, Any, Tuple +from rich.console import Console +from rich.panel import Panel +from rich.text import Text +from rich.markup import escape + +# Create a console instance for rich formatting +console = Console() + + +class ErrorFormatter: + """ + Class for formatting enhanced error reports from OpenSearch SQL/PPL + """ + + @staticmethod + def is_error_report(error_json: Dict[str, Any]) -> bool: + """ + Check if the error JSON is an enhanced ErrorReport + + Args: + error_json: Parsed error JSON + + Returns: + bool: True if this is an enhanced ErrorReport, False otherwise + """ + if not isinstance(error_json, dict): + return False + + error = error_json.get("error", {}) + if not isinstance(error, dict): + return False + + return error.get("type") == "ErrorReport" + + @staticmethod + def format_query_with_cursor(query: str, position: Dict[str, int], offending_token: Optional[str] = None) -> Tuple[str, str]: + """ + Format a query with a cursor pointing at the error position + + Args: + query: The query string + position: Dictionary with 'line' and 'column' keys (1-indexed) + offending_token: Optional offending token to highlight + + Returns: + tuple: (query_line, cursor_line) formatted strings + """ + line_num = position.get("line", 1) + column = position.get("column", 1) + + # Split query into lines + lines = query.split("\n") + + # Get the line with the error (convert to 0-indexed) + if line_num > 0 and line_num <= len(lines): + error_line = lines[line_num - 1] + else: + # Fallback to the whole query if line number is invalid + error_line = query + + # Create the cursor line with spaces and carets pointing at the error + # Column is 1-indexed in the error report + # We want the cursor to point exactly at that column + cursor_position = column + + # If we have an offending token, span the cursor across it + if offending_token: + token_length = len(offending_token) + cursor_line = " " * cursor_position + "^" * token_length + else: + # Just use a single caret + cursor_line = " " * cursor_position + "^" + + return error_line, cursor_line + + @staticmethod + def format_error_report(error_json: Dict[str, Any], original_query: Optional[str] = None) -> Text: + """ + Format an enhanced ErrorReport into a beautiful, readable format + + Args: + error_json: Parsed error JSON containing an ErrorReport + original_query: Optional original query string (used if not in error context) + + Returns: + Text: Rich Text object with formatted error + """ + error = error_json.get("error", {}) + + # Extract error components + error_code = error.get("code", "UNKNOWN_ERROR") + reason = error.get("reason", "Error") + details = error.get("details", "") + location = error.get("location", []) + context = error.get("context", {}) + suggestion = error.get("suggestion", "") + + # Build the formatted output + result = Text() + + # Error header with code + result.append("Error", style="bold red") + result.append(f" [{error_code}]", style="bold yellow") + result.append("\n") + + # Location breadcrumb if available + if location and isinstance(location, list): + location_text = " → ".join(location) + result.append(" ", style="dim") + result.append(location_text, style="dim cyan") + result.append("\n\n") + + # Query with cursor if position is available + query = context.get("query") or original_query + position = context.get("position") + offending_token = context.get("offending_token") + field_name = context.get("field_name") + + if query and position: + result.append(" ", style="dim") + result.append("Query:\n", style="bold white") + + # Use field_name for cursor width if available and no offending_token + cursor_token = offending_token or field_name + query_line, cursor_line = ErrorFormatter.format_query_with_cursor(query, position, cursor_token) + + # Display the query line + result.append(" ") + result.append(query_line, style="white") + result.append("\n") + + # Display the cursor pointing at the error + result.append(" ") + result.append(cursor_line, style="bold red") + result.append("\n\n") + + # Error details in a hierarchical format + result.append(" ", style="dim") + result.append("Details:\n", style="bold white") + result.append(" ") + # No need to escape when using Text.append() - it doesn't interpret markup + result.append(details, style="red") + result.append("\n") + + # Additional context information + offending_token = context.get("offending_token") + if offending_token: + result.append("\n ") + result.append("Offending token: ", style="bold white") + result.append(f"'{offending_token}'", style="yellow") + result.append("\n") + + field_name = context.get("field_name") + if field_name: + result.append("\n ") + result.append("Field: ", style="bold white") + result.append(f"'{field_name}'", style="yellow") + result.append("\n") + + available_fields = context.get("available_fields") + if available_fields and isinstance(available_fields, list): + result.append("\n ") + result.append("Available fields: ", style="bold white") + # Show first few fields + fields_to_show = available_fields[:10] + result.append(", ".join(f"'{f}'" for f in fields_to_show), style="dim cyan") + if len(available_fields) > 10: + result.append(f", ...{len(available_fields) - 10} more", style="dim") + result.append("\n") + + fields_command_used = context.get("fields_command_used") + if fields_command_used: + result.append("\n ") + result.append("Note: ", style="bold yellow") + result.append("A 'fields' command was used earlier in the query, which limited the available fields.", style="yellow") + result.append("\n") + + # Suggestion at the end + if suggestion: + result.append("\n ") + result.append("Suggestion:\n", style="bold green") + result.append(" ") + # No need to escape when using Text.append() - it doesn't interpret markup + result.append(suggestion, style="green") + result.append("\n") + + return result + + @staticmethod + def format_error(error_response: str, print_function=None, original_query: Optional[str] = None) -> bool: + """ + Format an error response. If it's an enhanced ErrorReport, format it beautifully. + Otherwise, return False to use default error handling. + + Args: + error_response: Raw error response string (may include "Exception: " prefix) + print_function: Function to use for printing (default: console.print) + original_query: Optional original query string (used if not in error context) + + Returns: + bool: True if this was an ErrorReport and was formatted, False otherwise + """ + if print_function is None: + print_function = console.print + + try: + # Remove "Exception: " prefix if present + error_str = error_response + if error_str.startswith("Exception: "): + error_str = error_str[len("Exception: "):] + + # Try to parse as JSON + error_json = json.loads(error_str) + + # Check if it's an ErrorReport + if not ErrorFormatter.is_error_report(error_json): + return False + + # Format the error report + formatted = ErrorFormatter.format_error_report(error_json, original_query) + + # Create a panel with the formatted error + panel = Panel( + formatted, + title="[bold red]Query Error[/bold red]", + border_style="red", + padding=(1, 2), + ) + + # Print directly using the provided function + print_function(panel) + + return True + + except (json.JSONDecodeError, KeyError, TypeError): + # Not a JSON error or not an ErrorReport + return False diff --git a/src/main/python/opensearchsql_cli/query/execute_query.py b/src/main/python/opensearchsql_cli/query/execute_query.py index b959e99..bb5ba02 100644 --- a/src/main/python/opensearchsql_cli/query/execute_query.py +++ b/src/main/python/opensearchsql_cli/query/execute_query.py @@ -9,6 +9,7 @@ from rich.markup import escape from .query_results import QueryResults from .explain_results import ExplainResults +from .error_formatter import ErrorFormatter # Create a console instance for rich formatting console = Console() @@ -56,6 +57,12 @@ def execute_query( # Errors handling # print_function(f"Before format: \n" + escape(result) + "\n") if "Exception" in result: + # Try to format as an enhanced ErrorReport first + if ErrorFormatter.format_error(result, print_function, query): + # This was an enhanced ErrorReport and has been formatted + return False, result, result + + # Fall back to legacy error handling if "index_not_found_exception" in result: print_function("[bold red]Index does not exist[/bold red]") elif "SyntaxCheckException" in result: diff --git a/src/main/python/opensearchsql_cli/sql/sql_connection.py b/src/main/python/opensearchsql_cli/sql/sql_connection.py index f2f978f..c41037f 100644 --- a/src/main/python/opensearchsql_cli/sql/sql_connection.py +++ b/src/main/python/opensearchsql_cli/sql/sql_connection.py @@ -6,6 +6,9 @@ from py4j.java_gateway import JavaGateway, GatewayParameters import sys +import json +import requests +from requests.auth import HTTPBasicAuth from rich.console import Console from .sql_library_manager import sql_library_manager from .verify_cluster import VerifyCluster @@ -16,6 +19,99 @@ console = Console() +class DirectRestExecutor: + """ + Executor that sends queries directly to the OpenSearch cluster REST API + without using the local Java gateway + """ + + def __init__(self, url, username=None, password=None, verify_ssl=True): + """ + Initialize the DirectRestExecutor + + Args: + url: Base URL of the OpenSearch cluster (e.g., "https://localhost:9200") + username: Optional username for authentication + password: Optional password for authentication + verify_ssl: Whether to verify SSL certificates + """ + self.url = url + self.username = username + self.password = password + self.verify_ssl = verify_ssl + self.auth = HTTPBasicAuth(username, password) if username and password else None + + def execute_query(self, query, is_ppl=True, is_explain=False, format="json"): + """ + Execute a query directly against the cluster REST API + + Args: + query: The SQL or PPL query string + is_ppl: True if the query is PPL, False if SQL + is_explain: True if query is explain + format: Output format (json, table, csv) + + Returns: + Query result string formatted according to the specified format + """ + try: + # Determine the endpoint + endpoint = "/_plugins/_ppl" if is_ppl else "/_plugins/_sql" + + # Build the request body + body = {"query": query} + + # Add format parameter for non-explain queries + if not is_explain: + if format.lower() == "csv": + body["format"] = "csv" + elif format.lower() == "table" or format.lower() == "json": + body["format"] = "jdbc" + + # For explain queries, use the explain endpoint + if is_explain: + endpoint = f"{endpoint}/_explain" + + # Make the request + response = requests.post( + f"{self.url}{endpoint}", + json=body, + auth=self.auth, + verify=self.verify_ssl, + headers={"Content-Type": "application/json"} + ) + + # Check for HTTP errors + response.raise_for_status() + + # For CSV format, return the raw text + if format.lower() == "csv" and not is_explain: + return response.text + + # For other formats, parse JSON and format appropriately + result_json = response.json() + + # Handle explain responses + if is_explain: + return json.dumps(result_json, indent=2) + + # For regular queries, return formatted JSON + return json.dumps(result_json, indent=2) + + except requests.exceptions.RequestException as e: + # Handle connection errors + error_msg = f"Request failed: {str(e)}" + if hasattr(e, "response") and e.response is not None: + try: + error_json = e.response.json() + error_msg = json.dumps(error_json, indent=2) + except: + error_msg = f"HTTP {e.response.status_code}: {e.response.text}" + return f"Exception: {error_msg}" + except Exception as e: + return f"Exception: {str(e)}" + + class SqlConnection: """ SqlConnection class for managing SQL library and OpenSearch connections @@ -46,6 +142,11 @@ def __init__(self, port=25333): self.url = None self.client = None + # Remote mode (direct REST API) support + self.remote_mode = False + self.direct_executor = None + self.ignore_ssl = False + def verify_opensearch_connection( self, host_port=None, username_password=None, ignore_ssl=False, aws_auth=False ): @@ -62,6 +163,9 @@ def verify_opensearch_connection( bool: True if successful, False otherwise """ try: + # Store ignore_ssl for later use + self.ignore_ssl = ignore_ssl + # Parse username_password if provided if username_password and ":" in username_password: self.username, self.password = username_password.split(":", 1) @@ -228,9 +332,34 @@ def connect(self): self.sql_connected = False return False + def set_remote_mode(self, enabled=True): + """ + Enable or disable remote mode (direct REST API connection) + + Args: + enabled: Whether to enable remote mode + + Returns: + bool: True if successful, False otherwise + """ + self.remote_mode = enabled + + if enabled and self.url: + # Initialize the direct executor + self.direct_executor = DirectRestExecutor( + url=self.url, + username=self.username, + password=self.password, + verify_ssl=not self.ignore_ssl + ) + # Mark as connected in remote mode + self.opensearch_connected = True + return True + return False + def query_executor(self, query, is_ppl=True, is_explain=False, format="json"): """ - Execute a query through the SQL Library service + Execute a query through the SQL Library service or direct REST API Args: query: The SQL or PPL query string @@ -241,6 +370,11 @@ def query_executor(self, query, is_ppl=True, is_explain=False, format="json"): Returns: Query result string formatted according to the specified format """ + # Use direct REST API in remote mode + if self.remote_mode and self.direct_executor: + return self.direct_executor.execute_query(query, is_ppl, is_explain, format) + + # Otherwise use the Java gateway if not self.sql_connected or not self.sql_lib: console.print( "[bold red]ERROR:[/bold red] [red]Unable to connect to SQL library[/red]" diff --git a/src/main/python/opensearchsql_cli/tests/query/conftest.py b/src/main/python/opensearchsql_cli/tests/query/conftest.py index 4078b6e..c69d6c0 100644 --- a/src/main/python/opensearchsql_cli/tests/query/conftest.py +++ b/src/main/python/opensearchsql_cli/tests/query/conftest.py @@ -209,3 +209,117 @@ def mock_connection(): '{"schema":[{"name":"test"}],"datarows":[["value"]]}' ) return mock_connection + + +# Fixtures for enhanced error reports (ErrorReport format) +@pytest.fixture +def mock_error_report_syntax(): + """ + Fixture that returns a mock syntax error in ErrorReport format. + """ + return """Exception: { + "status": 400, + "error": { + "type": "ErrorReport", + "code": "SYNTAX_ERROR", + "reason": "Invalid Query", + "details": "[fieldz] is not a valid term at this part of the query: 'source=big5 | fieldz' <-- HERE. Expecting one of 48 possible tokens. Some examples: 'WHERE', 'FIELDS', 'TABLE', 'RENAME', 'STATS', ...", + "location": [ + "while parsing the query" + ], + "context": { + "query": "source=big5 | fieldz message", + "position": { + "line": 1, + "column": 14 + }, + "offending_token": "fieldz" + }, + "suggestion": "Expected one of 48 possible tokens. Examples: 'WHERE', 'FIELDS', 'TABLE', 'RENAME', 'STATS'" + } +}""" + + +@pytest.fixture +def mock_error_report_field(): + """ + Fixture that returns a mock field not found error in ErrorReport format. + """ + return """Exception: { + "status": 400, + "error": { + "type": "ErrorReport", + "code": "FIELD_NOT_FOUND", + "reason": "Invalid Query", + "details": "Field [messag] not found.", + "location": [ + "while resolving field references" + ], + "context": { + "field_name": "messag", + "position": { + "line": 1, + "column": 21 + }, + "available_fields": ["agent", "agent.ephemeral_id", "agent.id", "agent.name", "agent.type", "event.dataset", "host.name", "message"] + }, + "suggestion": "Did you mean: 'message'?" + } +}""" + + +@pytest.fixture +def mock_error_report_field_removed(): + """ + Fixture that returns a mock field removed by fields command error in ErrorReport format. + """ + return """Exception: { + "status": 400, + "error": { + "type": "ErrorReport", + "code": "FIELD_NOT_FOUND", + "reason": "Invalid Query", + "details": "Field [host.name] not found.", + "location": [ + "while resolving field references" + ], + "context": { + "field_name": "host.name", + "position": { + "line": 1, + "column": 37 + }, + "fields_command_used": true, + "available_fields": ["message"] + }, + "suggestion": "Field [host.name] not in current context. Note: A 'fields' command earlier in the query removed fields not explicitly listed. Current fields: 'message'" + } +}""" + + +@pytest.fixture +def mock_error_report_is_not_null(): + """ + Fixture that returns a mock IS NOT NULL syntax error in ErrorReport format. + """ + return """Exception: { + "status": 400, + "error": { + "type": "ErrorReport", + "code": "SYNTAX_ERROR", + "reason": "Invalid Query", + "details": "[is] is not a valid term at this part of the query: '...ig5 | where message is' <-- HERE. Expecting one of 24 possible tokens. Some examples: EOF, 'IN', 'NOT', 'OR', 'AND', ...", + "location": [ + "while parsing the query" + ], + "context": { + "query": "source=big5 | where message is not null", + "position": { + "line": 1, + "column": 28 + }, + "offending_token": "is" + }, + "suggestion": "PPL doesn't support 'IS NOT NULL' syntax. Use isnotnull(message) function instead." + } +}""" diff --git a/src/main/python/opensearchsql_cli/tests/query/test_error_formatter.py b/src/main/python/opensearchsql_cli/tests/query/test_error_formatter.py new file mode 100644 index 0000000..ad2f492 --- /dev/null +++ b/src/main/python/opensearchsql_cli/tests/query/test_error_formatter.py @@ -0,0 +1,232 @@ +""" +Tests for the error_formatter module. + +This module contains tests for the enhanced error formatting functionality. +""" + +import pytest +import json +from unittest.mock import MagicMock +from opensearchsql_cli.query.error_formatter import ErrorFormatter +from rich.text import Text + + +class TestErrorFormatter: + """ + Test class for ErrorFormatter methods. + """ + + def test_is_error_report_valid(self, mock_error_report_syntax): + """Test that is_error_report correctly identifies valid ErrorReports""" + error_json = json.loads(mock_error_report_syntax.split("Exception: ", 1)[1]) + assert ErrorFormatter.is_error_report(error_json) is True + + def test_is_error_report_legacy_error(self, mock_syntax_error_response): + """Test that is_error_report returns False for legacy errors""" + # Legacy errors are just strings, not JSON + assert ErrorFormatter.is_error_report(mock_syntax_error_response) is False + + def test_is_error_report_non_error_report_json(self): + """Test that is_error_report returns False for JSON that isn't an ErrorReport""" + non_error_json = { + "error": { + "type": "SomeOtherError", + "message": "Something went wrong" + } + } + assert ErrorFormatter.is_error_report(non_error_json) is False + + def test_is_error_report_invalid_structure(self): + """Test that is_error_report handles invalid structures gracefully""" + assert ErrorFormatter.is_error_report({}) is False + assert ErrorFormatter.is_error_report({"error": "string"}) is False + assert ErrorFormatter.is_error_report("not a dict") is False + assert ErrorFormatter.is_error_report(None) is False + + def test_format_query_with_cursor_simple(self): + """Test cursor formatting with simple single-line query""" + query = "source=big5 | fieldz message" + position = {"line": 1, "column": 14} + offending_token = "fieldz" + + query_line, cursor_line = ErrorFormatter.format_query_with_cursor( + query, position, offending_token + ) + + assert query_line == "source=big5 | fieldz message" + assert cursor_line == " ^^^^^^" # 14 spaces + 6 carets + + def test_format_query_with_cursor_no_token(self): + """Test cursor formatting without offending token (single caret)""" + query = "source=big5 | fields message" + position = {"line": 1, "column": 20} + + query_line, cursor_line = ErrorFormatter.format_query_with_cursor( + query, position, None + ) + + assert query_line == "source=big5 | fields message" + assert cursor_line == " ^" # 20 spaces + 1 caret + + def test_format_query_with_cursor_multiline(self): + """Test cursor formatting with multi-line query""" + query = "source=big5\n| where host.name = 'test'\n| fields message" + position = {"line": 2, "column": 8} + offending_token = "host.name" + + query_line, cursor_line = ErrorFormatter.format_query_with_cursor( + query, position, offending_token + ) + + assert query_line == "| where host.name = 'test'" + assert cursor_line == " ^^^^^^^^^" # 8 spaces + 9 carets + + def test_format_query_with_cursor_field_name(self): + """Test cursor formatting with field name (for field errors)""" + query = "source=big5 | fields messag" + position = {"line": 1, "column": 21} + field_name = "messag" + + query_line, cursor_line = ErrorFormatter.format_query_with_cursor( + query, position, field_name + ) + + assert query_line == "source=big5 | fields messag" + assert cursor_line == " ^^^^^^" # 21 spaces + 6 carets + + def test_format_error_report_syntax_error(self, mock_error_report_syntax): + """Test formatting of syntax error ErrorReport""" + error_json = json.loads(mock_error_report_syntax.split("Exception: ", 1)[1]) + + result = ErrorFormatter.format_error_report(error_json) + + assert isinstance(result, Text) + result_str = result.plain + assert "Error [SYNTAX_ERROR]" in result_str + assert "while parsing the query" in result_str + assert "source=big5 | fieldz message" in result_str + assert "^^^^^^" in result_str # Cursor for "fieldz" + assert "[fieldz] is not a valid term" in result_str + assert "Offending token: 'fieldz'" in result_str + assert "Expected one of 48 possible tokens" in result_str + + def test_format_error_report_field_error_with_query(self, mock_error_report_field): + """Test formatting of field error ErrorReport with original query""" + error_json = json.loads(mock_error_report_field.split("Exception: ", 1)[1]) + original_query = "source=big5 | fields messag" + + result = ErrorFormatter.format_error_report(error_json, original_query) + + assert isinstance(result, Text) + result_str = result.plain + assert "Error [FIELD_NOT_FOUND]" in result_str + assert "while resolving field references" in result_str + assert "source=big5 | fields messag" in result_str + assert "^^^^^^" in result_str # Cursor for "messag" + assert "Field [messag] not found" in result_str + assert "Field: 'messag'" in result_str + assert "Available fields:" in result_str + assert "Did you mean: 'message'?" in result_str + + def test_format_error_report_field_error_without_query(self, mock_error_report_field): + """Test formatting of field error ErrorReport without original query""" + error_json = json.loads(mock_error_report_field.split("Exception: ", 1)[1]) + + result = ErrorFormatter.format_error_report(error_json) + + assert isinstance(result, Text) + result_str = result.plain + assert "Error [FIELD_NOT_FOUND]" in result_str + # Should not have Query section or cursor + assert "Query:" not in result_str + assert "^^^^^^" not in result_str + + def test_format_error_report_field_removed(self, mock_error_report_field_removed): + """Test formatting of field removed by fields command error""" + error_json = json.loads(mock_error_report_field_removed.split("Exception: ", 1)[1]) + original_query = "source=big5 | fields message | where host.name = 'test'" + + result = ErrorFormatter.format_error_report(error_json, original_query) + + assert isinstance(result, Text) + result_str = result.plain + assert "Error [FIELD_NOT_FOUND]" in result_str + assert "Field: 'host.name'" in result_str + assert "Available fields: 'message'" in result_str + assert "Note:" in result_str + assert "fields' command" in result_str + + def test_format_error_success(self, mock_error_report_syntax): + """Test that format_error returns True for valid ErrorReport""" + mock_print = MagicMock() + + result = ErrorFormatter.format_error( + mock_error_report_syntax, + mock_print, + None + ) + + assert result is True + assert mock_print.call_count == 1 + # Check that a Panel was printed + call_args = mock_print.call_args[0] + assert len(call_args) > 0 + + def test_format_error_with_original_query(self, mock_error_report_field): + """Test that format_error passes original query correctly""" + mock_print = MagicMock() + original_query = "source=big5 | fields messag" + + result = ErrorFormatter.format_error( + mock_error_report_field, + mock_print, + original_query + ) + + assert result is True + assert mock_print.call_count == 1 + + def test_format_error_legacy_error(self, mock_syntax_error_response): + """Test that format_error returns False for legacy errors""" + mock_print = MagicMock() + + result = ErrorFormatter.format_error( + mock_syntax_error_response, + mock_print, + None + ) + + assert result is False + assert mock_print.call_count == 0 + + def test_format_error_invalid_json(self): + """Test that format_error returns False for invalid JSON""" + mock_print = MagicMock() + + result = ErrorFormatter.format_error( + "Exception: not valid json {bad}", + mock_print, + None + ) + + assert result is False + assert mock_print.call_count == 0 + + def test_format_error_non_error_report_json(self): + """Test that format_error returns False for non-ErrorReport JSON""" + mock_print = MagicMock() + error_response = """Exception: { + "error": { + "type": "SomeOtherError", + "message": "Something went wrong" + } + }""" + + result = ErrorFormatter.format_error( + error_response, + mock_print, + None + ) + + assert result is False + assert mock_print.call_count == 0