goosedump 0.7.1

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

use crate::context::ir::{IrMessage, IrPart, build_tool_call, extract_text};
use crate::context::{ContextReader, for_each_jsonl_record};
use crate::message::{
    AssistantResponse, BashOutput, Context, ContextListing, ConversationMessage, Entry,
    MessageKind, ProviderId, TextContent, ToolResultData,
};
use anyhow::Context as _;
use chrono::{DateTime, NaiveDateTime, Utc};
use serde_json::Value;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};

/// The session id is the file's path relative to the Pi sessions directory
/// with the `.jsonl` extension stripped — e.g. `<encoded-cwd>/<timestamp>_<uuid>`
/// for a main session, or `<encoded-cwd>/<timestamp>_<uuid>/<run-hex>/run-N/session`
/// for a subagent sidechain. The bare file stem is only unique within a single
/// project directory: every subagent sidechain is named `session.jsonl`, so they
/// all collide on `session`. Qualifying the stem with the project-relative path
/// makes the `<provider>:<id>` tag unique across the whole store. The rendered
/// native session `id` uses just the final component (see `render_pi`);
/// resolution accepts either the full id or the bare stem. Falls back to the
/// bare stem if the file is not under the sessions directory.
fn session_id(file_path: &Path) -> String {
    let base = crate::resolver::pi_sessions_base();
    let Ok(rel) = file_path.strip_prefix(&base) else {
        return file_path.file_stem().map_or_else(
            || "unknown".to_string(),
            |stem| stem.to_string_lossy().into_owned(),
        );
    };
    let rel = rel.with_extension("");
    if rel.as_os_str().is_empty() {
        return "unknown".to_string();
    }
    rel.to_string_lossy().into_owned()
}

/// Parse the start timestamp from a Pi session filename.
/// Filenames follow `<timestamp>_<uuid>.jsonl` where timestamp is
/// `YYYY-MM-DDTHH-MM-SS-mmmZ`. Subagent files are named `session.jsonl`
/// and have no embedded timestamp.
fn pi_start_from_filename(file_path: &Path) -> Option<DateTime<Utc>> {
    let stem = file_path.file_stem()?.to_str()?;
    let ts_part = stem.split('_').next()?;
    NaiveDateTime::parse_from_str(ts_part, "%Y-%m-%dT%H-%M-%S-%3fZ")
        .ok()
        .map(|ndt| DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc))
}

pub struct JsonlReader {
    file_path: PathBuf,
}

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

impl ContextReader for JsonlReader {
    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().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(file_path);
        let cwd = session["cwd"].as_str().unwrap_or("").to_string();

        Ok(vec![ContextListing {
            native_id: id.clone(),
            id,
            provider_id: ProviderId {
                label: None,
                cwd: PathBuf::from(&cwd),
                count: 0,
                from: pi_start_from_filename(file_path),
                until: None,
            },
            path: file_path.clone(),
        }])
    }

    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 cwd = String::new();

        for_each_jsonl_record(&self.file_path, |_line_num, entry_json| {
            let entry_type = entry_json["type"].as_str().unwrap_or("").to_string();
            if entry_type == "session" {
                if cwd.is_empty() {
                    if let Some(value) = entry_json["cwd"].as_str() {
                        cwd = value.to_string();
                    }
                }
                return;
            }

            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 kind = jsonl_build_message_kind(&entry_type, raw_msg);
                messages.push(ConversationMessage::new(id.clone(), kind));
            }

            entries.push(Entry { id, parent_id });
        })?;

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

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 ir = IrMessage::new();
            if let Some(Value::Array(parts)) = content {
                for part in parts {
                    match part["type"].as_str() {
                        Some("text") => {
                            ir.push(IrPart::text(part["text"].as_str().unwrap_or("")));
                        }
                        Some("thinking" | "redacted_thinking") => {
                            if let Some(text) = part["text"].as_str() {
                                ir.push(IrPart::thinking(text));
                            }
                        }
                        Some("toolCall" | "tool_use") => {
                            let name = part["name"].as_str().unwrap_or("");
                            let id = part["id"].as_str().or_else(|| part["toolCallId"].as_str());
                            let arguments = part.get("arguments").or_else(|| part.get("input"));
                            ir.push(IrPart::tool_call(build_tool_call(
                                id.unwrap_or(""),
                                name,
                                arguments,
                            )));
                        }
                        _ => {}
                    }
                }
            }
            MessageKind::AssistantResponse(AssistantResponse {
                thinking: ir.thinking(),
                tool_calls: ir.tool_calls(),
                text: ir.text(),
            })
        }
        "toolResult" | "bashExecution" => {
            let content_str = extract_text(content);
            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);
                let call_id = msg["toolUseId"].as_str().unwrap_or("").to_string();
                MessageKind::ToolResultData(ToolResultData {
                    call_id,
                    tool_name,
                    content: content_str,
                    is_error,
                })
            }
        }
        _ => MessageKind::TextContent(TextContent {
            role,
            text: extract_text(content),
        }),
    }
}