goosedump 0.8.2

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

use serde::Serialize;
use serde_json::Value;

use crate::index::IndexEntry;
use crate::message::{ConversationMessage, MessageView, ProviderId};
use crate::text;
use chrono::{DateTime, Utc};

#[derive(Serialize)]
struct SessionJson {
    id: String,
    parent: Option<String>,
    provider: String,
    cwd: String,
    count: u32,
    label: Option<String>,
    from: Option<DateTime<Utc>>,
    until: Option<DateTime<Utc>>,
}

impl From<&IndexEntry> for SessionJson {
    fn from(entry: &IndexEntry) -> Self {
        let ProviderId {
            label,
            cwd,
            count,
            from,
            until,
        } = &entry.provider_id;
        Self {
            id: entry.id.clone(),
            parent: entry.parent_id.clone(),
            provider: entry.provider.as_str().to_string(),
            cwd: cwd.display().to_string(),
            count: *count,
            label: label.clone(),
            from: *from,
            until: *until,
        }
    }
}

pub fn print_sessions(
    entries: &[&IndexEntry],
    writer: &mut dyn std::io::Write,
) -> anyhow::Result<()> {
    let rows: Vec<SessionJson> = entries.iter().map(|e| SessionJson::from(*e)).collect();
    serde_json::to_writer(&mut *writer, &rows)?;
    writeln!(writer)?;
    Ok(())
}

pub fn message_files(msg: &ConversationMessage) -> Vec<String> {
    msg.tool_calls()
        .iter()
        .filter_map(|tc| path_argument(&tc.arguments))
        .collect()
}

pub fn searchable_text(msg: &ConversationMessage) -> String {
    match msg.view() {
        MessageView::Text { text, .. } => text::sanitize(&text),
        MessageView::Assistant {
            thinking,
            text,
            tool_calls,
        } => {
            let mut parts: Vec<String> = thinking;
            if !text.is_empty() {
                parts.push(text);
            }
            for tc in &tool_calls {
                parts.push(format!(
                    "{} {}",
                    tc.name,
                    summarize_tool_args(&tc.arguments)
                ));
            }
            text::sanitize(&parts.join(" "))
        }
        MessageView::ToolResult(tr) => {
            if tr.content.is_empty() {
                text::sanitize(&tr.tool_name)
            } else {
                text::sanitize(&format!("{} {}", tr.tool_name, tr.content))
            }
        }
        MessageView::Bash(bo) => {
            if bo.command.is_empty() {
                text::sanitize(&bo.output)
            } else if bo.output.is_empty() {
                text::sanitize(&bo.command)
            } else {
                text::sanitize(&format!("{} {}", bo.command, bo.output))
            }
        }
    }
}

pub fn summarize_tool_args(arguments: &Value) -> String {
    if let Some(obj) = arguments.as_object() {
        if let Some(path) = path_argument(arguments) {
            return format!("path={path}");
        }

        for key in &["command", "query", "pattern", "description"] {
            if let Some(val) = obj.get(*key)
                && let Some(s) = val.as_str()
            {
                return format!("{}={}", key, text::clip(s, 160));
            }
        }

        if obj.is_empty() {
            return String::new();
        }

        let mut keys: Vec<&str> = obj.keys().map(std::string::String::as_str).collect();
        keys.sort_unstable();
        return keys.join(", ");
    }
    if let Some(s) = arguments.as_str() {
        return text::clip(s, 160);
    }
    if arguments.is_null() {
        return String::new();
    }
    text::clip(&arguments.to_string(), 160)
}

pub fn path_argument(arguments: &Value) -> Option<String> {
    let obj = arguments.as_object()?;
    for key in &["path", "file_path", "filePath", "file"] {
        if let Some(val) = obj.get(*key)
            && let Some(s) = val.as_str()
        {
            return Some(s.to_string());
        }
    }
    None
}