goosedump 0.9.8

Coding agent context data browser
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) Jarkko Sakkinen 2026

//! Shared JSON-to-part extraction helpers for the provider readers.
//!
//! Each provider reader converts its raw JSON shapes into [`Part`] values. The
//! helpers below are the single place where text, tool call, and tool result
//! extraction logic lives. How a role maps onto a message stays in the reader
//! that owns the schema; the JSON-to-part conversion is shared.

use crate::message::{ToolCall, ToolResultData};
use serde_json::Value;

/// Extract a textual field from a content part.
///
/// Recognizes `text`, `input_text`, and `output_text` so Codex's
/// `input_text`/`output_text` content parts and the providers' plain
/// `text` parts all flow through the same helper.
#[must_use]
pub fn part_text(part: &Value) -> Option<&str> {
    part["text"]
        .as_str()
        .or_else(|| part["input_text"].as_str())
        .or_else(|| part["output_text"].as_str())
}

/// Concatenate text from all content parts in a JSON content value.
///
/// Accepts an array of parts, a plain string, or anything else (returning empty).
#[must_use]
pub fn extract_text(content: Option<&Value>) -> String {
    match content {
        Some(Value::Array(parts)) => parts
            .iter()
            .filter_map(part_text)
            .map(str::to_string)
            .collect::<Vec<_>>()
            .join("\n"),
        Some(Value::String(text)) => text.clone(),
        _ => String::new(),
    }
}

/// Parse a tool-call `arguments` value, accepting both a JSON object and a
/// string containing JSON.
#[must_use]
pub fn parse_tool_arguments(raw: &Value) -> Value {
    if let Some(text) = raw.as_str()
        && let Ok(parsed) = serde_json::from_str::<Value>(text)
    {
        return parsed;
    }
    raw.clone()
}

/// Build a [`ToolCall`] from a call id, name, and optional arguments value.
///
/// `id` is the provider correlation id (`tool_use.id` / `call_id`); pass `""`
/// when the source format does not assign one.
#[must_use]
pub fn build_tool_call(
    id: impl Into<String>,
    name: impl Into<String>,
    arguments: Option<&Value>,
) -> ToolCall {
    let arguments = arguments.map_or(Value::Null, parse_tool_arguments);
    ToolCall {
        id: id.into(),
        name: name.into(),
        arguments,
    }
}

/// Build a [`ToolResultData`] from explicit fields.
///
/// `call_id` correlates the result to its originating call (`""` when the
/// source format does not address results by id). `fallback_name` is used when
/// the part has no explicit tool name field (Codex function-call outputs are
/// addressed by `call_id` only).
#[must_use]
pub fn build_tool_result(
    call_id: impl Into<String>,
    fallback_name: impl Into<String>,
    content_text: impl Into<String>,
    is_error: bool,
) -> ToolResultData {
    ToolResultData {
        call_id: call_id.into(),
        tool_name: fallback_name.into(),
        content: content_text.into(),
        is_error,
    }
}

/// Try to extract a single tool result from a content part.
///
/// Recognizes the field naming used by Codex, Goose, Pi, and Crush for
/// tool result content. Returns `None` if the part is not a tool result.
#[must_use]
pub fn extract_tool_result_from_part(part: &Value) -> Option<ToolResultData> {
    let is_result = matches!(
        part["type"].as_str(),
        Some("tool_result" | "toolResult" | "toolResponse")
    );
    if !is_result && part.get("toolResult").is_none() {
        return None;
    }

    let tool_name = part["name"]
        .as_str()
        .or_else(|| part["toolName"].as_str())
        .or_else(|| part["call_id"].as_str())
        .unwrap_or("tool")
        .to_string();

    let call_id = part["tool_use_id"]
        .as_str()
        .or_else(|| part["call_id"].as_str())
        .or_else(|| part["id"].as_str())
        .unwrap_or("")
        .to_string();

    let content = if let Some(content) = part.get("content") {
        extract_text(Some(content))
    } else if let Some(content) = part.get("output") {
        extract_text(Some(content))
    } else if let Some(tool_result_value) = part.get("toolResult") {
        extract_text(
            tool_result_value
                .get("value")
                .and_then(|v| v.get("content")),
        )
    } else {
        String::new()
    };

    let is_error = part["isError"].as_bool().unwrap_or(false)
        || part["is_error"].as_bool().unwrap_or(false)
        || matches!(
            part["status"].as_str(),
            Some("error" | "failed" | "failure")
        )
        || part["toolResult"]["status"]
            .as_str()
            .is_some_and(|status| status == "error");

    Some(build_tool_result(call_id, tool_name, content, is_error))
}