goosedump 0.3.4

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

//! Provider-agnostic intermediate representation for context messages.
//!
//! Each provider reader converts its raw JSON shapes into [`IrPart`]
//! values. The shared helpers below are the single place where text,
//! thinking, tool call, and tool result extraction logic lives. Provider
//! dispatch (how the role maps to a [`MessageKind`]) stays in the reader that
//! owns the schema, but the JSON-to-part conversion is shared.
//!
//! Provider-specific fields that have no IR counterpart are preserved
//! on the [`ConversationMessage::metadata`](crate::message::ConversationMessage)
//! side-channel by the reader.

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

/// A provider-agnostic content part.
#[derive(Debug, Clone)]
pub enum IrPart {
    Text(String),
    Thinking(String),
    ToolCall(ToolCall),
}

impl IrPart {
    /// Wrap a plain string as an [`IrPart::Text`].
    #[must_use]
    pub fn text(text: impl Into<String>) -> Self {
        Self::Text(text.into())
    }

    /// Wrap a plain string as an [`IrPart::Thinking`].
    #[must_use]
    pub fn thinking(text: impl Into<String>) -> Self {
        Self::Thinking(text.into())
    }

    /// Wrap a [`ToolCall`] as an [`IrPart::ToolCall`].
    #[must_use]
    pub fn tool_call(call: ToolCall) -> Self {
        Self::ToolCall(call)
    }
}

/// A provider-agnostic message built from one or more [`IrPart`] values.
#[derive(Debug, Clone, Default)]
pub struct IrMessage {
    pub parts: Vec<IrPart>,
}

impl IrMessage {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    pub fn push(&mut self, part: IrPart) {
        self.parts.push(part);
    }

    /// Concatenate text from all [`IrPart::Text`] parts.
    #[must_use]
    pub fn text(&self) -> String {
        self.parts
            .iter()
            .filter_map(|part| match part {
                IrPart::Text(text) => Some(text.as_str()),
                _ => None,
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    /// Collect thinking text from all [`IrPart::Thinking`] parts.
    #[must_use]
    pub fn thinking(&self) -> Vec<String> {
        self.parts
            .iter()
            .filter_map(|part| match part {
                IrPart::Thinking(text) => Some(text.clone()),
                _ => None,
            })
            .collect()
    }

    /// Collect all [`IrPart::ToolCall`] parts.
    #[must_use]
    pub fn tool_calls(&self) -> Vec<ToolCall> {
        self.parts
            .iter()
            .filter_map(|part| match part {
                IrPart::ToolCall(call) => Some(call.clone()),
                _ => None,
            })
            .collect()
    }

    /// Return true if the message contains no parts.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.parts.is_empty()
    }
}

/// 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 name and optional arguments value.
#[must_use]
pub fn build_tool_call(name: impl Into<String>, arguments: Option<&Value>) -> ToolCall {
    let arguments = arguments.map_or(Value::Null, parse_tool_arguments);
    ToolCall {
        name: name.into(),
        arguments,
    }
}

/// Build a [`ToolResultData`] from explicit fields.
///
/// `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(
    fallback_name: impl Into<String>,
    content_text: impl Into<String>,
    is_error: bool,
) -> ToolResultData {
    ToolResultData {
        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 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(tool_name, content, is_error))
}