goosedump 0.2.2

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

use crate::context::ContextReader;
use crate::message::{
    AssistantResponse, Context, ContextListing, ConversationMessage, Entry, MessageKind,
    TextContent, ToolCall, ToolResultData,
};
use crate::text;
use anyhow::Context as _;
use serde_json::Value;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;

pub struct CodexReader {
    file_path: Option<PathBuf>,
}

impl CodexReader {
    pub fn new(file_path: PathBuf) -> Self {
        Self {
            file_path: Some(file_path),
        }
    }

    pub fn empty() -> Self {
        Self { file_path: None }
    }
}

impl ContextReader for CodexReader {
    fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
        let Some(file_path) = &self.file_path else {
            return Ok(Vec::new());
        };

        let file =
            fs::File::open(file_path).with_context(|| format!("open {}", file_path.display()))?;
        let mut reader = BufReader::new(file);
        let mut first_line = String::new();
        reader
            .read_line(&mut first_line)
            .with_context(|| format!("read {}", file_path.display()))?;
        let first_line = first_line.trim_end();
        if first_line.is_empty() {
            anyhow::bail!("empty file");
        }

        let session: Value = serde_json::from_str(first_line)?;
        if session["type"].as_str() != Some("session_meta") {
            anyhow::bail!("first line is not a Codex session header");
        }

        let payload = &session["payload"];
        let id = payload["id"].as_str().unwrap_or("unknown").to_string();
        let cwd = payload["cwd"].as_str().unwrap_or("");
        let timestamp = payload["timestamp"]
            .as_str()
            .or_else(|| session["timestamp"].as_str())
            .unwrap_or("");
        let detail = match (timestamp.is_empty(), cwd.is_empty()) {
            (true, true) => String::new(),
            (false, true) => timestamp.to_string(),
            (true, false) => format!("cwd: {cwd}"),
            (false, false) => format!("{timestamp}, cwd: {cwd}"),
        };

        Ok(vec![ContextListing {
            id,
            detail,
            path: Some(file_path.clone()),
        }])
    }

    fn read_context(&self, _context_id: &str) -> anyhow::Result<Context> {
        let Some(file_path) = &self.file_path else {
            anyhow::bail!("no sessions found");
        };

        let file =
            fs::File::open(file_path).with_context(|| format!("open {}", file_path.display()))?;
        let reader = BufReader::new(file);

        let mut entries = Vec::new();
        let mut messages = Vec::new();
        let mut previous_entry_id = String::new();

        for (line_num, line_result) in reader.lines().enumerate() {
            let line = line_result
                .with_context(|| format!("{}:{}: read error", file_path.display(), line_num + 1))?;
            if line.trim().is_empty() {
                continue;
            }

            let entry_json: Value = serde_json::from_str(&line).with_context(|| {
                format!("{}:{}: JSON parse error", file_path.display(), line_num + 1)
            })?;

            if entry_json["type"].as_str() == Some("session_meta") {
                continue;
            }

            let Some(kind) = codex_message_kind(&entry_json) else {
                continue;
            };

            let payload = &entry_json["payload"];
            let entry_id = codex_entry_id(payload, line_num);
            let parent_id = previous_entry_id.clone();

            messages.push(ConversationMessage {
                entry_id: entry_id.clone(),
                kind,
            });
            entries.push(Entry {
                id: entry_id.clone(),
                parent_id,
            });
            previous_entry_id = entry_id;
        }

        Ok(Context { entries, messages })
    }
}

fn codex_message_kind(entry: &Value) -> Option<MessageKind> {
    if entry["type"].as_str() != Some("response_item") {
        return None;
    }

    let payload = &entry["payload"];
    match payload["type"].as_str()? {
        "message" => Some(codex_text_or_assistant(payload)),
        "function_call" | "custom_tool_call" | "local_shell_call" => {
            Some(MessageKind::AssistantResponse(AssistantResponse {
                thinking: Vec::new(),
                tool_calls: vec![codex_tool_call(payload)],
                text: String::new(),
            }))
        }
        "reasoning" => Some(MessageKind::AssistantResponse(AssistantResponse {
            thinking: codex_content_parts_text(&payload["summary"]),
            tool_calls: Vec::new(),
            text: String::new(),
        })),
        "function_call_output" | "custom_tool_call_output" => {
            Some(MessageKind::ToolResultData(ToolResultData {
                tool_name: payload["call_id"].as_str().unwrap_or("tool").to_string(),
                content: codex_output_text(payload),
                is_error: payload["status"].as_str() == Some("failed"),
            }))
        }
        _ => None,
    }
}

fn codex_text_or_assistant(payload: &Value) -> MessageKind {
    let role = payload["role"].as_str().unwrap_or("unknown").to_string();
    let text = codex_content_text(&payload["content"]);

    if role == "assistant" {
        MessageKind::AssistantResponse(AssistantResponse {
            thinking: Vec::new(),
            tool_calls: Vec::new(),
            text,
        })
    } else {
        MessageKind::TextContent(TextContent { role, text })
    }
}

fn codex_content_text(content: &Value) -> String {
    text::join_lines(&codex_content_parts_text(content), "\n")
}

fn codex_content_parts_text(content: &Value) -> Vec<String> {
    match content.as_array() {
        Some(parts) => parts
            .iter()
            .filter_map(|part| {
                part["text"]
                    .as_str()
                    .or_else(|| part["input_text"].as_str())
                    .or_else(|| part["output_text"].as_str())
            })
            .map(str::to_string)
            .collect(),
        None => content
            .as_str()
            .map(|s| vec![s.to_string()])
            .unwrap_or_default(),
    }
}

fn codex_entry_id(payload: &Value, line_num: usize) -> String {
    let fallback = || format!("codex-{line_num}");
    let Some(id) = payload["id"]
        .as_str()
        .or_else(|| payload["call_id"].as_str())
    else {
        return fallback();
    };

    if matches!(
        payload["type"].as_str(),
        Some("function_call_output" | "custom_tool_call_output")
    ) {
        format!("{id}-output")
    } else {
        id.to_string()
    }
}

fn codex_tool_call(payload: &Value) -> ToolCall {
    let name = payload["name"]
        .as_str()
        .or_else(|| payload["command"].as_str())
        .or_else(|| payload["type"].as_str())
        .unwrap_or("tool")
        .to_string();
    let arguments = if let Some(command) = payload["action"]["command"].as_str() {
        serde_json::json!({ "command": command })
    } else if let Some(arguments) = payload.get("arguments") {
        parse_arguments(arguments)
    } else if let Some(input) = payload.get("input") {
        parse_arguments(input)
    } else {
        Value::Null
    };

    ToolCall { name, arguments }
}

fn parse_arguments(arguments: &Value) -> Value {
    if let Some(s) = arguments.as_str()
        && let Ok(json) = serde_json::from_str(s)
    {
        return json;
    }

    arguments.clone()
}

fn codex_output_text(payload: &Value) -> String {
    for key in ["output", "content"] {
        let Some(value) = payload.get(key) else {
            continue;
        };

        if let Some(s) = value.as_str() {
            return s.to_string();
        }
        if value.is_array() {
            return codex_content_text(value);
        }
        if !value.is_null() {
            return value.to_string();
        }
    }

    String::new()
}