goosedump 0.1.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, BashOutput, Context, ContextListing, ConversationMessage, Entry,
    MessageKind, TextContent, ToolCall, ToolResultData,
};
use anyhow::Context as _;
use serde_json::Value;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;

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

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

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

impl ContextReader for JsonlReader {
    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().to_string();
        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") {
            anyhow::bail!("first line is not a session header");
        }

        let id = session["id"].as_str().unwrap_or("unknown").to_string();
        let cwd = session["cwd"].as_str().unwrap_or("").to_string();
        let detail = if cwd.is_empty() {
            String::new()
        } else {
            format!("cwd: {cwd}")
        };

        Ok(vec![ContextListing {
            id,
            detail,
            path: self.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();

        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)
            })?;

            let entry_type = entry_json["type"].as_str().unwrap_or("").to_string();
            if entry_type == "session" {
                continue;
            }

            let id = entry_json["id"].as_str().unwrap_or("").to_string();
            let parent_id = entry_json["parentId"].as_str().unwrap_or("").to_string();

            if let Some(raw_msg) = entry_json.get("message") {
                let msg_type = entry_type.as_str();
                let kind = jsonl_build_message_kind(msg_type, raw_msg);
                messages.push(ConversationMessage {
                    entry_id: id.clone(),
                    kind,
                });
            }

            entries.push(Entry {
                line: line_num,
                r#type: entry_type,
                id,
                parent_id,
            });
        }

        Ok(Context {
            path: file_path.clone(),
            entries,
            messages,
        })
    }
}

fn jsonl_build_message_kind(msg_type: &str, msg: &Value) -> MessageKind {
    let role = msg["role"].as_str().unwrap_or(msg_type).to_string();
    let content = msg.get("content");

    match role.as_str() {
        "assistant" => {
            let mut thinking = Vec::new();
            let mut tool_calls = Vec::new();
            let mut text = String::new();

            if let Some(content) = content
                && let Some(arr) = content.as_array()
            {
                for part in arr {
                    match part["type"].as_str() {
                        Some("text") => {
                            text.push_str(part["text"].as_str().unwrap_or(""));
                        }
                        Some("thinking" | "redacted_thinking") => {
                            if let Some(t) = part["text"].as_str() {
                                thinking.push(t.to_string());
                            }
                        }
                        Some("toolCall" | "tool_use") => {
                            let tc_name = part["name"].as_str().unwrap_or("").to_string();
                            let tc_args = part
                                .get("arguments")
                                .or_else(|| part.get("input"))
                                .cloned()
                                .unwrap_or(Value::Null);
                            tool_calls.push(ToolCall {
                                name: tc_name,
                                arguments: tc_args,
                            });
                        }
                        _ => {}
                    }
                }
            }

            MessageKind::AssistantResponse(AssistantResponse {
                thinking,
                tool_calls,
                text,
            })
        }
        "toolResult" | "bashExecution" => {
            let content_str = if let Some(content) = content {
                if let Some(arr) = content.as_array() {
                    arr.iter()
                        .filter_map(|p| p["text"].as_str())
                        .collect::<Vec<_>>()
                        .join("\n")
                } else if let Some(s) = content.as_str() {
                    s.to_string()
                } else {
                    content.to_string()
                }
            } else if let Some(s) = msg["text"].as_str() {
                s.to_string()
            } else {
                String::new()
            };

            if role == "bashExecution" {
                let command = msg["command"].as_str().unwrap_or("").to_string();
                MessageKind::BashOutput(BashOutput {
                    command,
                    output: content_str,
                })
            } else {
                let tool_name = msg["toolName"].as_str().unwrap_or("").to_string();
                let is_error = msg["isError"].as_bool().unwrap_or(false);
                MessageKind::ToolResultData(ToolResultData {
                    tool_name,
                    content: content_str,
                    is_error,
                })
            }
        }
        _ => {
            let text = extract_text_content(content);
            MessageKind::TextContent(TextContent { role, text })
        }
    }
}

fn extract_text_content(content: Option<&Value>) -> String {
    match content {
        Some(Value::Array(arr)) => arr
            .iter()
            .filter(|p| p["type"].as_str() == Some("text"))
            .map(|p| p["text"].as_str().unwrap_or("").to_string())
            .collect::<Vec<_>>()
            .join("\n"),
        Some(Value::String(s)) => s.clone(),
        _ => String::new(),
    }
}