"""Shared CLI helpers for pagination, warnings, diagnostics, and provenance. These helpers are used across CLI modules without creating circular imports. """ from __future__ import annotations import json import platform as _platform from typing import Any from binary_analysis import __version__ as _cli_version # --------------------------------------------------------------------------- # Constants (matching main.py) # --------------------------------------------------------------------------- SCHEMA_VERSION = "1.0.0" PAGE_SIZE_DEFAULT = 100 PAGE_SIZE_MAX = 1000 # --------------------------------------------------------------------------- # Provenance helpers # --------------------------------------------------------------------------- def default_provenance() -> dict[str, Any]: """Return default provenance metadata (base 7 fields) for all commands. Every response must include: cli_version, schema_version, adapter, adapter_version, backend, backend_version, platform. """ return { "cli_version": _cli_version, "schema_version": SCHEMA_VERSION, "adapter": "none", "adapter_version": "0.1.0", "backend": "none", "backend_version": "0.1.0", "platform": f"{_platform.system()}-{_platform.machine()}-python{_platform.python_version()}", } def enrich_provenance( provenance: dict[str, Any] | None = None, *, project_id: str | None = None, binary_id: str | None = None, binary_sha256: str | None = None, architecture: str | None = None, analysis_profile: str | None = None, ) -> dict[str, Any]: """Enrich provenance with optional context fields. Args: provenance: Base provenance dict (uses default if None). project_id: UUID of the project, added for project-context commands. binary_id: UUID of the binary, added for binary-context commands. binary_sha256: SHA-256 of the binary, added for binary-context commands. architecture: Language/processor spec (e.g., "x86:LE:64:default"). analysis_profile: Profile name (e.g., "standard", "quick", "deep"). Returns: The enriched provenance dict. """ if provenance is None: provenance = default_provenance() if project_id is not None: provenance["project_id"] = project_id if binary_id is not None: provenance["binary_id"] = binary_id if binary_sha256 is not None: provenance["binary_sha256"] = binary_sha256 if architecture is not None: provenance["architecture"] = architecture if analysis_profile is not None: provenance["analysis_profile"] = analysis_profile return provenance # --------------------------------------------------------------------------- # Pagination helpers # --------------------------------------------------------------------------- def clamp_page_size(limit: int | None) -> tuple[int, str | None]: """Clamp a page size to the valid range [1, PAGE_SIZE_MAX]. None or values <= 0 default to PAGE_SIZE_DEFAULT. Values above PAGE_SIZE_MAX are clamped to PAGE_SIZE_MAX. Args: limit: Requested page size, or None for default. Returns: Tuple of (clamped_page_size, warning_message_or_None). The warning message is present only when clamping occurred. """ if limit is None or limit < 1: return PAGE_SIZE_DEFAULT, None if limit > PAGE_SIZE_MAX: warning = ( f"Requested page size {limit} exceeds maximum {PAGE_SIZE_MAX}. " f"Clamped to {PAGE_SIZE_MAX}." ) return PAGE_SIZE_MAX, warning return limit, None def build_paginated_response( items: list[dict[str, Any]], total: int, offset: int, limit: int, *, cursor_encoder: Any | None = None, ) -> dict[str, Any]: """Build a paginated response with opaque next_page_token. Args: items: The sliced page of items. total: Total number of items across all pages. offset: Starting offset of this page within the total set. limit: Page size used for this slice. cursor_encoder: Optional callable(dict) -> str for cursor encoding. Returns: A dict with items, total, page_size, has_more, and next_page_token. """ import base64 as _b64 has_more = (offset + limit) < total next_page_token: str | None = None if has_more: if cursor_encoder is not None: next_page_token = cursor_encoder({"offset": offset + limit}) else: cursor_data = json.dumps({"offset": offset + limit}).encode("utf-8") next_page_token = _b64.b64encode(cursor_data).decode("ascii") return { "items": items, "total": total, "page_size": limit, "has_more": has_more, "next_page_token": next_page_token, } # --------------------------------------------------------------------------- # Warning and diagnostics helpers # --------------------------------------------------------------------------- def make_warning( message: str, severity: str = "WARNING", category: str = "general", ) -> dict[str, Any]: """Create a structured warning entry with severity, message, and category. Warnings are structurally distinct from diagnostics. They appear in the `warnings` array, not `diagnostics`. Args: message: Human-readable warning description. severity: Severity from DiagnosticSeverity enum (INFO, WARNING, ERROR). category: Classification domain (e.g., "pagination", "staleness", "truncation"). Returns: A dict with severity, message, and category keys. """ return { "severity": severity, "message": message, "category": category, } def make_diagnostic( message: str, severity: str = "ERROR", category: str = "general", *, component: str | None = None, remediation: str | None = None, recoverable: bool | None = None, ) -> dict[str, Any]: """Create a structured diagnostic entry. Args: message: Human-readable diagnostic description. severity: Severity from DiagnosticSeverity enum. category: Classification domain. component: Optional component name (e.g., "Java", "Ghidra"). remediation: Optional remediation hint. recoverable: Whether retrying could resolve this. Returns: A dict with standard diagnostic fields; None-valued optional fields omitted. """ diag: dict[str, Any] = { "severity": severity, "message": message, "category": category, } if component is not None: diag["component"] = component if remediation is not None: diag["remediation"] = remediation if recoverable is not None: diag["recoverable"] = recoverable return diag def ensure_collection(data: Any) -> list[Any]: """Guarantee that a collection value is a list, never None. Empty collection results must be [], never null and never absent. Args: data: A list or None. Returns: The original list, or an empty list if data is None. """ if data is None: return [] if isinstance(data, list): return data return list(data) def make_partial_success( data: Any, diagnostics: list[dict[str, Any]], warnings: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """Build a partial success result envelope fragment. Partial success means: success=false, partial=true, non-empty diagnostics, and data containing whatever partial results are available. Args: data: The partial result payload. diagnostics: Non-empty list of diagnostic entries. warnings: Optional warning entries. Returns: A dict with success, partial, warnings, diagnostics, data keys. """ return { "success": False, "partial": True, "warnings": warnings or [], "diagnostics": diagnostics, "data": data, }