malvin 0.2.4

Non-interactive research and coding agent
use std::time::Duration;

use super::{
    TOOL_DISPLAY_MAX_WIDTH, escape_tool_subject_fragment, humanize_duration, shorten_middle,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BashToolKind {
    Read,
    Search,
    Edit,
    Run,
}

pub fn classify_bash_command(cmd: &str) -> BashToolKind {
    let trimmed = cmd.trim();
    if trimmed.contains("sed -i") || trimmed.contains(">>") || trimmed.contains(" tee ") {
        return BashToolKind::Edit;
    }
    let first = trimmed.split_whitespace().next().unwrap_or("");
    match first {
        "cat" | "head" | "tail" => BashToolKind::Read,
        "rg" | "grep" | "find" => BashToolKind::Search,
        _ if trimmed.starts_with("sed -n") => BashToolKind::Read,
        _ => BashToolKind::Run,
    }
}

fn extract_read_subject(cmd: &str) -> String {
    let parts: Vec<&str> = cmd.split_whitespace().collect();
    if parts.first() == Some(&"sed") {
        return parts.last().copied().unwrap_or("file").to_string();
    }
    parts.get(1).copied().unwrap_or("file").to_string()
}

fn extract_search_subject(cmd: &str) -> String {
    let trimmed = cmd.trim();
    if trimmed.starts_with("find ") {
        return "files".to_string();
    }
    let parts: Vec<&str> = trimmed.split_whitespace().collect();
    parts.get(1).copied().unwrap_or("pattern").to_string()
}

fn extract_edit_subject(cmd: &str) -> String {
    if let Some(after) = cmd.split(">>").nth(1) {
        let path = after.split_whitespace().next().unwrap_or("file");
        return path.to_string();
    }
    cmd.split_whitespace().last().unwrap_or("file").to_string()
}

pub const TOOL_COMMENT_LOG_PREFIX_CHARS: usize = 30;

pub fn tool_comment_log_prefix(comment: &str) -> Option<String> {
    let normalized: String = comment.split_whitespace().collect::<Vec<_>>().join(" ");
    if normalized.is_empty() {
        return None;
    }
    Some(
        normalized
            .chars()
            .take(TOOL_COMMENT_LOG_PREFIX_CHARS)
            .collect(),
    )
}

#[derive(Debug, Clone, Copy)]
pub struct ClassifiedToolLineInput<'a> {
    pub kind: BashToolKind,
    pub command: &'a str,
    pub exit_code: i32,
    pub elapsed: Duration,
    pub comment: Option<&'a str>,
}

fn classified_tool_subject(kind: BashToolKind, command: &str) -> String {
    match kind {
        BashToolKind::Read => {
            let path = extract_read_subject(command);
            shorten_middle(&escape_tool_subject_fragment(&path), TOOL_DISPLAY_MAX_WIDTH)
        }
        BashToolKind::Search => {
            let q = extract_search_subject(command);
            shorten_middle(&escape_tool_subject_fragment(&q), TOOL_DISPLAY_MAX_WIDTH)
        }
        BashToolKind::Edit => {
            let path = extract_edit_subject(command);
            shorten_middle(&escape_tool_subject_fragment(&path), TOOL_DISPLAY_MAX_WIDTH)
        }
        BashToolKind::Run => {
            let flattened = escape_tool_subject_fragment(command.trim());
            shorten_middle(&flattened, TOOL_DISPLAY_MAX_WIDTH)
        }
    }
}

const fn classified_tool_prefix(kind: BashToolKind) -> &'static str {
    match kind {
        BashToolKind::Read => "Read",
        BashToolKind::Search => "Search",
        BashToolKind::Edit => "Edit",
        BashToolKind::Run => "Run",
    }
}

fn classified_tool_status_line(
    head: &str,
    dur: &str,
    exit_code: i32,
    comment: Option<&str>,
) -> String {
    let comment_seg = comment.and_then(tool_comment_log_prefix);
    match (exit_code == 0, comment_seg.as_deref()) {
        (true, Some(c)) => format!("{head} · {c} · {dur} · ✓"),
        (true, None) => format!("{head} · {dur} · ✓"),
        (false, Some(c)) => format!("{head} · {c} · exit {exit_code} · {dur} · ✗"),
        (false, None) => format!("{head} · exit {exit_code} · {dur} · ✗"),
    }
}

pub fn format_classified_tool_line(input: ClassifiedToolLineInput<'_>) -> String {
    let dur = humanize_duration(input.elapsed);
    let subject = classified_tool_subject(input.kind, input.command);
    let prefix = classified_tool_prefix(input.kind);
    let head = format!("{prefix} {subject}");
    classified_tool_status_line(&head, &dur, input.exit_code, input.comment)
}

pub const fn bash_kind_wire_name(kind: BashToolKind) -> &'static str {
    match kind {
        BashToolKind::Read => "read",
        BashToolKind::Search => "search",
        BashToolKind::Edit => "edit",
        BashToolKind::Run => "execute",
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classify_read_commands() {
        assert_eq!(classify_bash_command("cat file.txt"), BashToolKind::Read);
        assert_eq!(classify_bash_command("head -n 5 foo"), BashToolKind::Read);
        assert_eq!(
            classify_bash_command("sed -n '1,5p' bar"),
            BashToolKind::Read
        );
    }

    #[test]
    fn classify_search_commands() {
        assert_eq!(classify_bash_command("rg pattern"), BashToolKind::Search);
        assert_eq!(classify_bash_command("grep foo *.rs"), BashToolKind::Search);
        assert_eq!(
            classify_bash_command("find . -name '*.rs'"),
            BashToolKind::Search
        );
    }

    #[test]
    fn classify_edit_commands() {
        assert_eq!(
            classify_bash_command("sed -i 's/a/b/' f"),
            BashToolKind::Edit
        );
        assert_eq!(
            classify_bash_command("echo x >> out.txt"),
            BashToolKind::Edit
        );
    }

    #[test]
    fn classify_pipeline_falls_back_to_run() {
        assert_eq!(
            classify_bash_command("curl https://x | jq ."),
            BashToolKind::Run
        );
    }

    #[test]
    fn format_read_line() {
        let line = format_classified_tool_line(ClassifiedToolLineInput {
            kind: BashToolKind::Read,
            command: "cat README.md",
            exit_code: 0,
            elapsed: Duration::from_millis(10),
            comment: None,
        });
        assert!(line.starts_with("Read README.md"));
        assert!(line.contains(""));
    }

    #[test]
    fn tool_comment_log_prefix_truncates_to_30_chars() {
        let long = "abcdefghijklmnopqrstuvwxyz0123456789";
        assert_eq!(
            tool_comment_log_prefix(long).as_deref(),
            Some("abcdefghijklmnopqrstuvwxyz0123")
        );
        assert_eq!(
            tool_comment_log_prefix("  hi   there  ").as_deref(),
            Some("hi there")
        );
        assert!(tool_comment_log_prefix("   ").is_none());
    }

    #[test]
    fn format_line_inserts_comment_segment_before_duration() {
        let line = format_classified_tool_line(ClassifiedToolLineInput {
            kind: BashToolKind::Run,
            command: "git status",
            exit_code: 0,
            elapsed: Duration::from_millis(4),
            comment: Some("Check working tree before commit"),
        });
        assert_eq!(
            line,
            "Run git status · Check working tree before comm · 4ms · ✓"
        );
    }
}