malvin 0.2.4

Non-interactive research and coding agent
use serde_json::Value;

use crate::tool_summary::{TOOL_DISPLAY_MAX_WIDTH, shorten_middle};

pub(super) fn tool_name_summary(ty: &str, item: &Value) -> Option<(String, String)> {
    match ty {
        "commandExecution" => Some(command_summary(item)),
        "fileChange" => Some(file_change_summary(item)),
        "mcpToolCall" | "dynamicToolCall" => Some(named_tool_summary(item)),
        "webSearch" => Some(web_search_summary(item)),
        "imageView" => Some(image_view_summary(item)),
        _ => misc_tool_summary(ty, item),
    }
}

fn misc_tool_summary(ty: &str, item: &Value) -> Option<(String, String)> {
    match ty {
        "sleep" => Some(("sleep".into(), "Sleep".into())),
        "imageGeneration" => Some(("imageGeneration".into(), "Image generation".into())),
        "collabAgentToolCall" => Some(collab_summary(item)),
        _ => None,
    }
}

fn command_summary(item: &Value) -> (String, String) {
    item.get("command")
        .and_then(Value::as_str)
        .map(codex_flatten_ws)
        .filter(|s| !s.is_empty())
        .map_or_else(|| ("shell".into(), "Run".into()), labeled_bash)
}

fn labeled_bash(cmd: String) -> (String, String) {
    use crate::tool_summary::{BashToolKind, classify_bash_command};
    let inner = inner_cmd(&cmd);
    let short = shorten_middle(inner, TOOL_DISPLAY_MAX_WIDTH);
    match classify_bash_command(inner) {
        BashToolKind::Read => ("read".into(), format!("Read {short}")),
        BashToolKind::Search => ("grep".into(), format!("Search {short}")),
        BashToolKind::Edit => ("edit".into(), format!("Edit {short}")),
        BashToolKind::Run => ("shell".into(), format!("Run {short}")),
    }
}

fn inner_cmd(cmd: &str) -> &str {
    cmd.strip_prefix("/bin/bash -lc ")
        .or_else(|| cmd.strip_prefix("bash -lc "))
        .or_else(|| cmd.strip_prefix("/bin/sh -lc "))
        .or_else(|| cmd.strip_prefix("sh -lc "))
        .map_or(cmd, |rest| rest.trim_matches(|c| c == '\'' || c == '"'))
}

fn file_change_summary(item: &Value) -> (String, String) {
    let path = item
        .get("changes")
        .and_then(Value::as_array)
        .and_then(|changes| changes.first())
        .and_then(|change| change.get("path"))
        .and_then(Value::as_str)
        .map(codex_flatten_ws)
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "files".into());
    (
        "edit".into(),
        format!("Edit {}", shorten_middle(&path, TOOL_DISPLAY_MAX_WIDTH)),
    )
}

fn named_tool_summary(item: &Value) -> (String, String) {
    let name = item
        .get("tool")
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())
        .unwrap_or("tool");
    let n = name.to_ascii_lowercase();
    if n == "bash" || n == "shell" {
        let Some(cmd) = command_from_args(item.get("arguments")) else {
            return ("shell".into(), "Run".into());
        };
        return labeled_bash(cmd);
    }
    (name.into(), named_tool_label(name, item.get("arguments")))
}

fn named_tool_label(name: &str, args: Option<&Value>) -> String {
    let n = name.to_ascii_lowercase();
    if let Some(path) = path_from_args(args) {
        let short = shorten_middle(&path, TOOL_DISPLAY_MAX_WIDTH);
        if n == "read" || n.starts_with("read_") {
            return format!("Read {short}");
        }
        if n == "write" || n == "edit" || n.starts_with("write_") || n.starts_with("edit_") {
            return format!("Edit {short}");
        }
    }
    name.into()
}

fn web_search_summary(item: &Value) -> (String, String) {
    let query = item
        .get("query")
        .and_then(Value::as_str)
        .map(codex_flatten_ws)
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "web".into());
    (
        "webSearch".into(),
        format!("Search {}", shorten_middle(&query, TOOL_DISPLAY_MAX_WIDTH)),
    )
}

fn image_view_summary(item: &Value) -> (String, String) {
    let path = item
        .get("path")
        .and_then(Value::as_str)
        .map(codex_flatten_ws)
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "image".into());
    (
        "read".into(),
        format!("Read {}", shorten_middle(&path, TOOL_DISPLAY_MAX_WIDTH)),
    )
}

fn collab_summary(item: &Value) -> (String, String) {
    let tool = item
        .get("tool")
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())
        .unwrap_or("collab");
    (tool.into(), format!("Collab {tool}"))
}

fn command_from_args(args: Option<&Value>) -> Option<String> {
    args.and_then(Value::as_object)
        .and_then(|a| a.get("command").or_else(|| a.get("cmd")))
        .and_then(Value::as_str)
        .map(codex_flatten_ws)
        .filter(|s| !s.is_empty())
}

fn path_from_args(args: Option<&Value>) -> Option<String> {
    args.and_then(Value::as_object)
        .and_then(|a| {
            a.get("path")
                .or_else(|| a.get("file_path"))
                .or_else(|| a.get("filePath"))
        })
        .and_then(Value::as_str)
        .map(codex_flatten_ws)
        .filter(|s| !s.is_empty())
}

fn codex_flatten_ws(s: &str) -> String {
    s.split_whitespace().collect::<Vec<_>>().join(" ")
}