goosedump 0.9.4

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

use std::path::PathBuf;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;

/// A single conversation message: a role, optional per-message metadata, and an
/// ordered list of typed content [`Part`]s. Providers whose native records carry
/// interleaved text, reasoning, tool calls, and results map onto this shape
/// without losing order.
#[derive(Debug, Clone)]
pub struct ConversationMessage {
    pub entry_id: String,
    pub role: String,
    pub model: Option<String>,
    pub timestamp: Option<DateTime<Utc>>,
    pub parts: Vec<Part>,
}

impl ConversationMessage {
    #[must_use]
    pub fn new(entry_id: impl Into<String>, role: impl Into<String>, parts: Vec<Part>) -> Self {
        Self {
            entry_id: entry_id.into(),
            role: role.into(),
            model: None,
            timestamp: None,
            parts,
        }
    }

    /// The conversational role (`user`/`assistant`) this message carries.
    #[must_use]
    pub fn role(&self) -> &str {
        &self.role
    }

    /// A human-readable role label for structured search/grep results
    /// (`tool/<name>` for a tool result, `bash` for shell output).
    #[must_use]
    pub fn role_label(&self) -> String {
        if let [part] = self.parts.as_slice() {
            match part {
                Part::ToolResult(tr) => return format!("tool/{}", tr.tool_name),
                Part::Bash(_) => return "bash".to_string(),
                _ => {}
            }
        }
        if self.role.is_empty() {
            "unknown".to_string()
        } else {
            self.role.clone()
        }
    }

    /// True when this message is an assistant turn (may carry reasoning, text,
    /// and tool-call parts).
    #[must_use]
    pub fn is_assistant(&self) -> bool {
        self.role == "assistant"
    }

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

    /// Collect the text of every [`Part::Reasoning`] part.
    #[must_use]
    pub fn thinking(&self) -> Vec<String> {
        self.parts
            .iter()
            .filter_map(|part| match part {
                Part::Reasoning(r) => Some(r.text.clone()),
                _ => None,
            })
            .collect()
    }

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

    /// A collapsed, four-way projection of this message for renderers and the
    /// compaction analyzer that consume the conventional
    /// text / assistant / tool-result / bash shape rather than the raw parts.
    #[must_use]
    pub fn view(&self) -> MessageView<'_> {
        if self.role == "assistant" {
            return MessageView::Assistant {
                thinking: self.thinking(),
                text: self.text(),
                tool_calls: self.tool_calls(),
            };
        }
        match self.parts.as_slice() {
            [Part::ToolResult(result)] => MessageView::ToolResult(result),
            [Part::Bash(bash)] => MessageView::Bash(bash),
            _ => MessageView::Text {
                role: &self.role,
                text: self.text(),
            },
        }
    }
}

/// A collapsed view of a [`ConversationMessage`] mirroring the four native
/// message shapes, used by renderers and the compaction analyzer.
pub enum MessageView<'a> {
    Text {
        role: &'a str,
        text: String,
    },
    Assistant {
        thinking: Vec<String>,
        text: String,
        tool_calls: Vec<&'a ToolCall>,
    },
    ToolResult(&'a ToolResultData),
    Bash(&'a BashOutput),
}

/// A provider-agnostic, ordered content part of a [`ConversationMessage`].
#[derive(Debug, Clone)]
pub enum Part {
    Text(String),
    Reasoning(Reasoning),
    ToolCall(ToolCall),
    ToolResult(ToolResultData),
    Image(Image),
    Bash(BashOutput),
    /// A provider record type the reader does not model, preserved verbatim so
    /// nothing is silently dropped.
    Passthrough(Passthrough),
}

#[derive(Debug, Clone)]
pub struct Reasoning {
    pub text: String,
    /// Provider signature required to replay a thinking block (Anthropic);
    /// empty when the source carries none.
    pub signature: String,
    /// True for a redacted/encrypted reasoning block whose plaintext is absent.
    pub redacted: bool,
}

impl Reasoning {
    #[must_use]
    pub fn new(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            signature: String::new(),
            redacted: false,
        }
    }
}

#[derive(Debug, Clone)]
pub struct Image {
    pub mime_type: String,
    pub data: String,
}

#[derive(Debug, Clone)]
pub struct Passthrough {
    pub kind: String,
    pub raw: JsonValue,
}

#[derive(Debug, Clone)]
pub struct ToolResultData {
    /// Correlation id of the call this result answers (provider `tool_use_id`
    /// / `call_id`); empty when the source format does not address results by
    /// id. Lets renderers re-pair a result with its originating call.
    pub call_id: String,
    pub tool_name: String,
    pub content: String,
    pub is_error: bool,
}

#[derive(Debug, Clone)]
pub struct BashOutput {
    pub command: String,
    pub output: String,
}

#[derive(Debug, Clone)]
pub struct ToolCall {
    /// Provider call id (`tool_use.id` / `call_id`); empty when the source
    /// format does not assign one. Lets renderers correlate a later tool
    /// result back to this call.
    pub id: String,
    pub name: String,
    pub arguments: JsonValue,
}

#[derive(Debug, Clone)]
pub struct Entry {
    pub id: String,
    pub parent_id: String,
}

#[derive(Debug, Clone)]
pub struct Context {
    pub entries: Vec<Entry>,
    pub messages: Vec<ConversationMessage>,
    /// Working directory recorded in the session, when the source format
    /// carries one. Re-emitted by renderers whose native format has a `cwd`
    /// slot (e.g. the Pi/Codex session header).
    pub cwd: Option<String>,
}

#[derive(Debug, Clone)]
pub struct SearchHit {
    pub entry_id: String,
    pub score: f64,
}

#[derive(Debug, Clone)]
pub struct ContextListing {
    pub id: String,
    pub provider_id: ProviderId,
    pub path: PathBuf,
    pub parent_id: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderId {
    pub label: Option<String>,
    pub cwd: PathBuf,
    pub count: u32,
    pub from: Option<DateTime<Utc>>,
    pub until: Option<DateTime<Utc>>,
}