malvin 0.2.5

Non-interactive research and coding agent
use std::fmt::Write as _;

use super::types::{ANSI_BOLD, ANSI_DIM, ANSI_RESET, ansi_accent, ansi_error, ansi_tool_name};

const DONE_VERBS: &[&str] = &[
    "Read",
    "Edit",
    "Search",
    "Run",
    "Glob",
    "Write",
    "Delete",
    "WebSearch",
    "Task",
];

pub fn tool_summary_stdout_display(plain: &str) -> String {
    if !crate::output::stdout_use_color() {
        return plain.to_string();
    }
    apply_tool_summary_ansi(plain)
}

pub(crate) fn split_outer_brackets(plain: &str) -> (&str, &str, &str) {
    plain
        .strip_prefix('[')
        .and_then(|s| s.strip_suffix(']'))
        .map_or(("", plain, ""), |inner| ("[", inner, "]"))
}

fn tool_name_bracket(bracket: &str) -> String {
    format!("{}{bracket}{ANSI_RESET}", ansi_tool_name())
}

pub(crate) fn apply_tool_summary_ansi(plain: &str) -> String {
    let (open, inner, close) = split_outer_brackets(plain);
    let mut out = if open.is_empty() {
        String::new()
    } else {
        tool_name_bracket(open)
    };
    let mut rest = inner;
    while let Some(idx) = rest.find('·') {
        let (left, right) = rest.split_at(idx);
        out.push_str(&ansi_style_tool_segment(left));
        let _ = write!(out, "{}·{ANSI_RESET}", ansi_accent());
        rest = right.trim_start_matches('·').trim_start();
    }
    out.push_str(&ansi_style_tool_segment(rest));
    if !close.is_empty() {
        out.push_str(&tool_name_bracket(close));
    }
    out
}

pub(crate) fn ansi_style_tool_segment(seg: &str) -> String {
    let seg = seg.trim();
    if seg.is_empty() {
        return String::new();
    }
    if seg.contains('') {
        return seg.replace('', &format!("{}{ANSI_RESET}", ansi_accent()));
    }
    if seg.contains('') {
        return seg.replace('', &format!("{}{ANSI_RESET}", ansi_error()));
    }
    ansi_style_tool_segment_running_or_path(seg)
}

pub(crate) fn tool_line_colon_prefix(seg: &str) -> (&str, &str) {
    if let Some(rest) = seg.strip_prefix(":: ") {
        return (":: ", rest);
    }
    if let Some(inner) = seg.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
        return ("[", inner);
    }
    ("", seg)
}

pub(crate) fn ansi_style_tool_name_verb(verb: &str) -> String {
    format!("{ANSI_BOLD}{}{verb}{ANSI_RESET}", ansi_tool_name())
}

pub(crate) fn ansi_style_running_verb(seg: &str) -> String {
    let (colon, body) = tool_line_colon_prefix(seg);
    let verb_end = body.find(' ').unwrap_or(body.len());
    let (verb, tail) = body.split_at(verb_end);
    format!(
        "{colon}{}{}",
        ansi_style_tool_name_verb(verb),
        ansi_style_path_tail(tail)
    )
}

pub(crate) fn ansi_style_done_verb(seg: &str) -> String {
    let (colon, body) = tool_line_colon_prefix(seg);
    if let Some((verb, tail)) = take_done_verb(body) {
        let mut out = format!("{colon}{}", ansi_style_tool_name_verb(verb));
        if !tail.is_empty() {
            out.push(' ');
            out.push_str(&ansi_style_path_tail(tail));
        }
        return out;
    }
    format!("{colon}{}", ansi_style_path_tail(body))
}

pub(crate) fn take_done_verb(body: &str) -> Option<(&str, &str)> {
    for verb in DONE_VERBS {
        let Some(rest) = body.strip_prefix(*verb) else {
            continue;
        };
        if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('·') {
            return Some((*verb, rest.trim_start()));
        }
    }
    None
}

pub(crate) fn ansi_style_tool_segment_running_or_path(seg: &str) -> String {
    let (_, body) = tool_line_colon_prefix(seg);
    if body.ends_with('')
        || body.starts_with("Reading ")
        || body.starts_with("Run ")
        || body.starts_with("Editing ")
        || body.starts_with("Searching")
    {
        return ansi_style_running_verb(seg);
    }
    if take_done_verb(body).is_some() {
        return ansi_style_done_verb(seg);
    }
    if is_tool_metadata_segment(seg) {
        return format!("{ANSI_DIM}{seg}{ANSI_RESET}");
    }
    ansi_style_path_tail(seg)
}

fn is_tool_metadata_segment(seg: &str) -> bool {
    let t = seg.trim();
    if t.contains("matches") || t.contains("exit ") {
        return true;
    }
    if t.ends_with("ms") {
        return true;
    }
    t.ends_with('s') && t.as_bytes().first().is_some_and(u8::is_ascii_digit)
}

pub(crate) fn is_byte_size_segment(seg: &str) -> bool {
    seg.ends_with(" B") || seg.ends_with(" KB") || seg.ends_with(" MB")
}

pub(crate) fn ansi_style_path_tail(seg: &str) -> String {
    if is_byte_size_segment(seg) {
        return format!("{ANSI_DIM}{seg}{ANSI_RESET}");
    }
    format!("{}{seg}{ANSI_RESET}", ansi_accent())
}

#[cfg(test)]
mod inline_tests {
    use super::{apply_tool_summary_ansi, take_done_verb};

    #[test]
    fn body_is_search_done_verb_covers_bare_and_spaced_forms() {
        assert_eq!(take_done_verb("Search"), Some(("Search", "")));
        assert_eq!(take_done_verb("Search "), Some(("Search", "")));
        assert_eq!(
            take_done_verb("Search · matches"),
            Some(("Search", "· matches"))
        );
        assert_eq!(
            take_done_verb("Search needle · 1ms"),
            Some(("Search", "needle · 1ms"))
        );
        assert!(take_done_verb("Searching").is_none());
        assert!(take_done_verb("Research").is_none());
        assert_eq!(take_done_verb("Glob **/*"), Some(("Glob", "**/*")));
        assert_eq!(take_done_verb("Glob"), Some(("Glob", "")));
    }

    #[test]
    fn bracket_wrapped_search_done_uses_dark_brackets() {
        let styled = apply_tool_summary_ansi("[Search · matches]");
        assert!(styled.contains('[') && styled.contains(']'));
        assert!(styled.contains("Search"));
    }
}

#[cfg(test)]
#[path = "ansi_tests.rs"]
mod ansi_tests;