goosedump 0.9.1

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

use crate::context::ir::{build_tool_call, build_tool_result, extract_text};
use crate::context::{ContextReader, for_each_jsonl_record};
use crate::message::{
    Context, ContextListing, ConversationMessage, Entry, Part, Passthrough, ProviderId, Reasoning,
    ToolCall,
};
use anyhow::Context as _;
use chrono::{DateTime, Utc};
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;

pub struct CodexReader {
    file_path: PathBuf,
}

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

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

        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("");
        Ok(vec![ContextListing {
            id,
            provider_id: ProviderId {
                label: None,
                cwd: PathBuf::from(cwd),
                count: 0,
                from: if timestamp.is_empty() {
                    None
                } else {
                    DateTime::parse_from_rfc3339(timestamp)
                        .ok()
                        .map(|dt| dt.with_timezone(&Utc))
                },
                until: None,
            },
            path: file_path.clone(),
            parent_id: None,
        }])
    }

    fn delete_context(&self, _context_id: &str) -> anyhow::Result<()> {
        fs::remove_file(&self.file_path)
            .with_context(|| format!("remove {}", self.file_path.display()))
    }

    fn read_context(&self, _context_id: &str) -> anyhow::Result<Context> {
        let mut entries = Vec::new();
        let mut messages = Vec::new();
        let mut previous_entry_id = String::new();
        let mut cwd = String::new();
        let mut tool_names: HashMap<String, String> = HashMap::new();

        for_each_jsonl_record(&self.file_path, |line_num, entry_json| {
            if entry_json["type"].as_str() == Some("session_meta") {
                if cwd.is_empty()
                    && let Some(value) = entry_json["payload"]["cwd"].as_str()
                {
                    cwd = value.to_string();
                }
                return;
            }

            let Some((role, parts)) = codex_message(entry_json, &mut tool_names) else {
                return;
            };

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

            messages.push(ConversationMessage::new(entry_id.clone(), role, parts));
            entries.push(Entry {
                id: entry_id.clone(),
                parent_id,
            });
            previous_entry_id = entry_id;
        })?;

        Ok(Context {
            entries,
            messages,
            cwd: (!cwd.is_empty()).then_some(cwd),
        })
    }
}

fn codex_message(
    entry: &Value,
    tool_names: &mut HashMap<String, String>,
) -> Option<(String, Vec<Part>)> {
    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" => {
            let call = codex_tool_call(payload);
            if !call.id.is_empty() {
                tool_names.insert(call.id.clone(), call.name.clone());
            }
            Some(("assistant".to_string(), vec![Part::ToolCall(call)]))
        }
        "reasoning" => {
            let parts = payload["summary"]
                .as_array()
                .into_iter()
                .flatten()
                .filter_map(|part| part["text"].as_str())
                .map(|text| Part::Reasoning(Reasoning::new(text)))
                .collect();
            Some(("assistant".to_string(), parts))
        }
        "function_call_output" | "custom_tool_call_output" => {
            let call_id = payload["call_id"].as_str().unwrap_or("").to_string();
            let tool_name = tool_names
                .get(&call_id)
                .cloned()
                .unwrap_or_else(|| "tool".to_string());
            let content = codex_output_text(payload);
            let is_error = payload["status"].as_str() == Some("failed");
            Some((
                "user".to_string(),
                vec![Part::ToolResult(build_tool_result(
                    call_id, tool_name, content, is_error,
                ))],
            ))
        }
        other => Some((
            "assistant".to_string(),
            vec![Part::Passthrough(Passthrough {
                kind: other.to_string(),
                raw: payload.clone(),
            })],
        )),
    }
}

fn codex_text_or_assistant(payload: &Value) -> (String, Vec<Part>) {
    let role = payload["role"].as_str().unwrap_or("unknown").to_string();
    let text = extract_text(Some(&payload["content"]));
    (role, vec![Part::Text(text)])
}

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 id = payload["call_id"].as_str().unwrap_or("").to_string();
    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 {
        let raw = payload.get("arguments").or_else(|| payload.get("input"));
        build_tool_call(id.clone(), name.clone(), raw).arguments
    };

    ToolCall {
        id,
        name,
        arguments,
    }
}

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 extract_text(Some(value));
        }
        if !value.is_null() {
            return value.to_string();
        }
    }

    String::new()
}