thirdshift 0.13.3

Turn a GitHub issue into a ready-for-review pull request, unattended
//! Normalize retained Claude and Codex events into one escaped conversation,
//! with each `https://` URL a link.
use super::filters::{escaped, linked};
use crate::logs::catalog::Kind;
use serde_json::Value;

enum Part {
    Message(String, String),
    Thinking(String),
    Tool(String, String),
    Plain(String),
}

pub(super) fn render(lines: &str, kind: Kind) -> String {
    if matches!(kind, Kind::Command) {
        return format!("<pre class=\"command-output\">{}</pre>", linked(lines));
    }
    let mut html = String::new();
    let mut tools_open = false;
    for part in lines.lines().flat_map(parts) {
        if matches!(part, Part::Tool(..)) {
            if !tools_open {
                html.push_str(
                    "<details class=\"tool-group\"><summary>Tools</summary><div class=\"tools\">",
                );
                tools_open = true;
            }
        } else if tools_open {
            html.push_str("</div></details>");
            tools_open = false;
        }
        match part {
            Part::Message(role, text) => html.push_str(&format!(
                "<article class=\"message\"><h3>{}</h3><pre>{}</pre></article>",
                escaped(&role),
                linked(&text)
            )),
            Part::Thinking(text) => html.push_str(&format!(
                "<details class=\"thinking\"><summary>Thinking</summary><pre>{}</pre></details>",
                linked(&text)
            )),
            Part::Tool(name, body) => html.push_str(&format!(
                "<article class=\"tool\" data-tool=\"{}\"><h3>{}</h3>{body}</article>",
                escaped(&name),
                escaped(&name)
            )),
            Part::Plain(text) => html.push_str(&format!(
                "<pre class=\"unknown-event\">{}</pre>",
                linked(&text)
            )),
        }
    }
    if tools_open {
        html.push_str("</div></details>");
    }
    html
}

fn parts(line: &str) -> Vec<Part> {
    let Ok(event) = serde_json::from_str::<Value>(line) else {
        return vec![Part::Plain(line.into())];
    };
    match event["type"].as_str() {
        Some("assistant" | "user") => {
            let role = text(&event, "type");
            match &event["message"]["content"] {
                Value::String(content) => vec![Part::Message(role.into(), content.clone())],
                Value::Array(blocks) if !blocks.is_empty() => blocks
                    .iter()
                    .map(|block| match block["type"].as_str() {
                        Some("text") => Part::Message(role.into(), text(block, "text").into()),
                        Some("thinking") => Part::Thinking(text(block, "thinking").into()),
                        Some("tool_use") => tool(text(block, "name"), &block["input"]),
                        Some("tool_result") => Part::Tool(
                            "Tool result".into(),
                            pre("result", &pretty(&block["content"])),
                        ),
                        _ => Part::Plain(pretty(block)),
                    })
                    .collect(),
                _ => vec![Part::Plain(line.into())],
            }
        }
        Some("item.started" | "item.updated" | "item.completed") => {
            let item = &event["item"];
            vec![match item["type"].as_str() {
                Some("agent_message") => {
                    Part::Message("assistant".into(), text(item, "text").into())
                }
                Some("reasoning") => Part::Thinking(text(item, "text").into()),
                Some("command_execution") => tool("Bash", item),
                Some("file_change") => tool("Edit", item),
                Some("mcp_tool_call") => tool(text(item, "tool"), item),
                Some("web_search") => tool("Grep", item),
                _ => Part::Plain(line.into()),
            }]
        }
        _ => vec![Part::Plain(line.into())],
    }
}

fn tool(name: &str, input: &Value) -> Part {
    let arguments = input.get("arguments").unwrap_or(input);
    let body = match name.to_ascii_lowercase().as_str() {
        "bash" | "exec_command" => format!(
            "{}{}",
            pre("bash", text(input, "command")),
            pre("result", &pretty(input))
        ),
        "edit" | "apply_patch" => {
            if input["changes"].is_array() {
                // Codex exec records paths and kinds, not before/after text.
                // Keep the actual event visible rather than inventing a patch.
                return Part::Tool(
                    name.into(),
                    format!(
                        "<p>Patch unavailable in this log.</p>{}",
                        pre("fallback", &pretty(input))
                    ),
                );
            }
            let diff = if arguments["old_string"].is_string() && arguments["new_string"].is_string()
            {
                let path = text(arguments, "file_path");
                let old = text(arguments, "old_string");
                let new = text(arguments, "new_string");
                format!(
                    "--- a/{path}\n+++ b/{path}\n@@ -1,{} +1,{} @@\n{}{}",
                    old.lines().count(),
                    new.lines().count(),
                    old.lines()
                        .map(|line| format!("-{line}\n"))
                        .collect::<String>(),
                    new.lines()
                        .map(|line| format!("+{line}\n"))
                        .collect::<String>()
                )
            } else {
                return Part::Tool(name.into(), pre("fallback", &pretty(input)));
            };
            pre("diff", &diff)
        }
        "read" | "read_file" => format!(
            "{}{}",
            pre("read", text(arguments, "file_path")),
            pre("result", &pretty(input))
        ),
        "grep" | "search" => format!(
            "{}{}",
            pre("grep", text(arguments, "pattern")),
            pre("result", &pretty(input))
        ),
        _ => pre("fallback", &pretty(input)),
    };
    Part::Tool(name.into(), body)
}

fn pre(class: &str, text: &str) -> String {
    format!("<pre class=\"{class}\">{}</pre>", linked(text))
}
fn text<'a>(value: &'a Value, key: &str) -> &'a str {
    value[key].as_str().unwrap_or("")
}
fn pretty(value: &Value) -> String {
    value
        .as_str()
        .map(str::to_owned)
        .unwrap_or_else(|| serde_json::to_string_pretty(value).unwrap_or_default())
}