malvin 0.2.7

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

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

#[must_use]
pub(crate) fn tool_summary_from_pi(name: Option<&str>, args: Option<&Value>) -> Option<String> {
    let label = name.unwrap_or("tool").trim();
    if label.is_empty() {
        return None;
    }
    let n = label.to_ascii_lowercase();
    let args = args.and_then(Value::as_object);
    if n == "bash" || n == "shell" || n == "powershell" {
        return Some(bash_summary(args));
    }
    if n == "grep" || n == "find" || n == "search" {
        return Some(search_summary(args));
    }
    if n == "ls" {
        return Some(ls_summary(args));
    }
    if let Some(path) = path_arg(args)
        && let Some(summary) = path_tool_summary(&n, &path)
    {
        return Some(summary);
    }
    Some(fallback_summary(label, &n, args))
}

fn bash_summary(args: Option<&serde_json::Map<String, Value>>) -> String {
    let cmd = args
        .and_then(|a| a.get("command").or_else(|| a.get("cmd")))
        .and_then(Value::as_str)
        .map(flatten_ws)
        .filter(|s| !s.is_empty());
    cmd.map_or_else(
        || "Run".into(),
        |c| format!("Run {}", shorten_middle(&c, TOOL_DISPLAY_MAX_WIDTH)),
    )
}

fn search_summary(args: Option<&serde_json::Map<String, Value>>) -> String {
    let query = args
        .and_then(|a| {
            a.get("pattern")
                .or_else(|| a.get("query"))
                .or_else(|| a.get("glob"))
                .or_else(|| a.get("path"))
        })
        .and_then(Value::as_str)
        .map(flatten_ws)
        .filter(|s| !s.is_empty());
    query.map_or_else(
        || "Search".into(),
        |q| format!("Search {}", shorten_middle(&q, TOOL_DISPLAY_MAX_WIDTH)),
    )
}

fn ls_summary(args: Option<&serde_json::Map<String, Value>>) -> String {
    path_arg(args).map_or_else(
        || "List".into(),
        |p| format!("List {}", shorten_middle(&p, TOOL_DISPLAY_MAX_WIDTH)),
    )
}

fn path_arg(args: Option<&serde_json::Map<String, Value>>) -> Option<String> {
    args.and_then(|a| {
        a.get("path")
            .or_else(|| a.get("file_path"))
            .or_else(|| a.get("filePath"))
    })
    .and_then(Value::as_str)
    .map(flatten_ws)
    .filter(|s| !s.is_empty())
}

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

fn fallback_summary(
    label: &str,
    n: &str,
    args: Option<&serde_json::Map<String, Value>>,
) -> String {
    let title = titled_tool_name(n).unwrap_or_else(|| title_case_label(label));
    match primary_arg_snippet(args) {
        Some(snippet) => format!("{title} {snippet}"),
        None => title,
    }
}

fn titled_tool_name(n: &str) -> Option<String> {
    if n == "read" || n.starts_with("read_") {
        return Some("Read".into());
    }
    if n == "write" || n == "edit" || n.starts_with("write_") || n.starts_with("edit_") {
        return Some("Edit".into());
    }
    None
}

fn title_case_label(label: &str) -> String {
    let mut chars = label.chars();
    chars
        .next()
        .map_or_else(String::new, |first| first.to_uppercase().collect::<String>() + chars.as_str())
}

fn primary_arg_snippet(args: Option<&serde_json::Map<String, Value>>) -> Option<String> {
    let map = args.filter(|m| !m.is_empty())?;
    if let Some(path) = path_arg(Some(map)) {
        return Some(shorten_middle(&path, TOOL_DISPLAY_MAX_WIDTH));
    }
    for key in ["command", "cmd", "pattern", "query", "glob", "url", "text"] {
        if let Some(v) = map
            .get(key)
            .and_then(Value::as_str)
            .map(flatten_ws)
            .filter(|s| !s.is_empty())
        {
            return Some(shorten_middle(&v, TOOL_DISPLAY_MAX_WIDTH));
        }
    }
    let compact = serde_json::to_string(&Value::Object(map.clone())).ok()?;
    Some(shorten_middle(&compact, TOOL_DISPLAY_MAX_WIDTH))
}

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

#[cfg(test)]
mod flatten_ws_tests {
    use super::flatten_ws;

    #[test]
    fn flatten_ws_collapses_runs_of_whitespace() {
        assert_eq!(flatten_ws("echo   \t a\t\tb\n c"), "echo a b c");
    }

    #[test]
    fn flatten_ws_trims_and_handles_degenerate_inputs() {
        assert_eq!(flatten_ws("  padded  "), "padded");
        assert_eq!(flatten_ws("token"), "token");
        assert_eq!(flatten_ws(""), "");
        assert_eq!(flatten_ws(" \t\n "), "");
    }
}

#[cfg(test)]
mod tool_summary_from_pi_tests {
    use super::tool_summary_from_pi;
    use serde_json::json;

    #[test]
    fn read_includes_path_argument() {
        let summary = tool_summary_from_pi(Some("read"), Some(&json!({"path": "src/lib.rs"})));
        assert_eq!(summary.as_deref(), Some("Read src/lib.rs"));
    }

    #[test]
    fn read_without_args_is_titled_not_read_tool() {
        let summary = tool_summary_from_pi(Some("read"), None);
        assert_eq!(summary.as_deref(), Some("Read"));
        assert!(!summary.unwrap().contains("tool"));
    }

    #[test]
    fn bash_includes_command_argument() {
        let summary = tool_summary_from_pi(Some("bash"), Some(&json!({"command": "ls -la"})));
        assert_eq!(summary.as_deref(), Some("Run ls -la"));
    }

    #[test]
    fn grep_includes_pattern_argument() {
        let summary = tool_summary_from_pi(Some("grep"), Some(&json!({"pattern": "tool_call"})));
        assert_eq!(summary.as_deref(), Some("Search tool_call"));
    }

    #[test]
    fn write_maps_to_edit_with_path() {
        let summary = tool_summary_from_pi(Some("write"), Some(&json!({"path": "a.rs"})));
        assert_eq!(summary.as_deref(), Some("Edit a.rs"));
    }
}