malvin 0.2.5

Non-interactive research and coding agent
use crate::output::{MALVIN_WHO, print_stdout_line, print_stdout_text};

pub(super) fn trim_trailing_tip_lines(text: &str) -> String {
    let lines: Vec<&str> = text.lines().collect();
    let mut end = lines.len();
    while end > 0 {
        let low = lines[end - 1].trim().to_ascii_lowercase();
        if low.is_empty() || looks_like_tip_banner_line(&low) {
            end -= 1;
        } else {
            break;
        }
    }
    lines[..end].join("\n")
}

pub(super) fn looks_like_tip_banner_line(lowercase_trimmed: &str) -> bool {
    if lowercase_trimmed.starts_with("tip:") {
        return true;
    }
    if let Some(after_tip_space) = lowercase_trimmed.strip_prefix("tip ") {
        return !after_tip_space.starts_with("of ");
    }
    false
}

#[cfg(test)]
pub(super) fn models_display_lines(text: &str, prefix: &str) -> Option<Vec<String>> {
    models_display_lines_filtered(text, prefix, None)
}

pub(super) fn models_display_lines_filtered(
    text: &str,
    prefix: &str,
    filter: Option<&str>,
) -> Option<Vec<String>> {
    let mut out = Vec::new();
    for line in text.lines() {
        let t = line.trim();
        if t.is_empty() || is_non_model_banner_line(t) {
            continue;
        }
        let row = if let Some((name, rest)) = parse_model_line(t) {
            format!("{prefix}{name}\t{rest}")
        } else {
            format!("{prefix}{t}")
        };
        if super::line_matches_prefix(&row, filter) {
            out.push(row);
        }
    }
    if out.is_empty() { None } else { Some(out) }
}

pub(super) fn is_non_model_banner_line(line: &str) -> bool {
    let low = line.trim().to_ascii_lowercase();
    low == "available models" || low.starts_with("no models")
}

pub(super) fn print_parsed_or_fallback_prefixed(text: &str, prefix: &str, filter: Option<&str>) {
    match models_display_lines_filtered(text, prefix, filter) {
        Some(lines) => {
            for line in lines {
                print_stdout_line(MALVIN_WHO, &line);
            }
        }
        None => {
            if filter.is_none() {
                print_stdout_text(MALVIN_WHO, text);
            }
        }
    }
}

pub(super) fn parse_model_line(line: &str) -> Option<(&str, String)> {
    if let Some((a, b)) = line.split_once("") {
        return Some((a.trim(), b.trim().to_string()));
    }
    if let Some((a, b)) = line.split_once(" - ") {
        let a = a.trim();
        let b = b.trim();
        if !a.is_empty() && !b.is_empty() {
            return Some((a, b.to_string()));
        }
    }
    let parts: Vec<&str> = line.split_whitespace().collect();
    if parts.len() >= 2 {
        let name = parts[0];
        let rest = parts[1..].join(" ");
        return Some((name, rest));
    }
    None
}

#[cfg(test)]
mod banner_tests {
    use super::{is_non_model_banner_line, models_display_lines};

    #[test]
    fn models_display_lines_skips_available_models_header() {
        let text = "Available models\n\nauto - Auto (current, default)\n";
        let lines = models_display_lines(text, "").expect("non-empty");
        assert_eq!(
            lines,
            vec!["auto\tAuto (current, default)".to_string()],
            "header must not become a fake model row: {lines:?}"
        );
        assert!(
            !lines
                .iter()
                .any(|l| l.to_ascii_lowercase().contains("available")),
            "must not keep Available header: {lines:?}"
        );
        assert!(is_non_model_banner_line("Available models"));
    }

    #[test]
    fn models_display_lines_skips_no_models_available_status() {
        let text = "No models available for this account.\n";
        assert!(
            models_display_lines(text, "cursor:").is_none(),
            "status line must not become a model row"
        );
        let mixed = "No models available for this account.\nauto - Auto (current, default)\n";
        let lines = models_display_lines(mixed, "cursor:").expect("keep real rows");
        assert_eq!(
            lines,
            vec!["cursor:auto\tAuto (current, default)".to_string()]
        );
        assert!(
            !lines.iter().any(|l| l.starts_with("cursor:No")),
            "must not keep No-models status as a row: {lines:?}"
        );
        assert!(is_non_model_banner_line(
            "No models available for this account."
        ));
    }
}