malvin 0.2.6

Non-interactive research and coding agent
use std::process::Command;

use super::discover::{resolve_npm_pi_cli_entry, resolve_npm_pi_entry};

pub fn refresh_npm_pi_models() -> Result<(), String> {
    let entry = resolve_npm_pi_cli_entry()?;
    let node = crate::cursor_sdk::node_resolve::resolve_node_bin()?;
    let output = Command::new(node)
        .arg(&entry)
        .arg("update")
        .arg("--models")
        .output()
        .map_err(|e| format!("spawn npm pi update --models: {e}"))?;
    if !output.status.success() {
        let err = String::from_utf8_lossy(&output.stderr);
        let out = String::from_utf8_lossy(&output.stdout);
        let msg = if err.trim().is_empty() { out } else { err };
        return Err(format!(
            "npm pi update --models failed: {}",
            msg.trim().chars().take(240).collect::<String>()
        ));
    }
    Ok(())
}

pub fn list_npm_pi_display_models() -> Result<Vec<(String, String)>, String> {
    let entry = resolve_npm_pi_entry()?;
    let node = crate::cursor_sdk::node_resolve::resolve_node_bin()?;
    let output = Command::new(node)
        .arg(&entry)
        .arg("--list-models")
        .output()
        .map_err(|e| format!("spawn npm pi --list-models: {e}"))?;
    if !output.status.success() {
        let err = String::from_utf8_lossy(&output.stderr);
        return Err(format!(
            "npm pi --list-models failed: {}",
            err.trim().chars().take(240).collect::<String>()
        ));
    }
    Ok(parse_list_models_table(&list_models_table_text(
        &output.stdout,
        &output.stderr,
    )))
}

fn list_models_table_text(stdout: &[u8], stderr: &[u8]) -> String {
    let stdout = String::from_utf8_lossy(stdout);
    if stdout.trim().is_empty() {
        String::from_utf8_lossy(stderr).into_owned()
    } else {
        stdout.into_owned()
    }
}

fn parse_list_models_table(stdout: &str) -> Vec<(String, String)> {
    let mut out = Vec::new();
    for line in stdout.lines() {
        let line = line.trim_end();
        if line.is_empty() || line.starts_with("provider") {
            continue;
        }
        let cols: Vec<&str> = line.split_whitespace().collect();
        if cols.len() < 2 {
            continue;
        }
        let provider = cols[0];
        let model = cols[1];
        if provider.is_empty() || model.is_empty() {
            continue;
        }
        let id = format!("{provider}/{model}");
        let thinking = cols.get(4).copied().unwrap_or("no");
        let detail = if thinking == "yes" {
            format!("{model}\tthinking=yes")
        } else {
            format!("{model}\tthinking=no")
        };
        out.push((id, detail));
    }
    out
}

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

    #[test]
    fn parses_table_rows() {
        let sample = "\
provider    model        context  max-out  thinking  images
openai      gpt-4o       128K     16.4K    no        yes
anthropic   claude-4     200K     32K      yes       yes
";
        let rows = parse_list_models_table(sample);
        assert_eq!(rows.len(), 2);
        assert_eq!(rows[0].0, "openai/gpt-4o");
        assert!(rows[0].1.contains("thinking=no"));
        assert_eq!(rows[1].0, "anthropic/claude-4");
        assert!(rows[1].1.contains("thinking=yes"));
    }

    #[test]
    fn list_models_table_text_falls_back_to_stderr() {
        let from_stdout = list_models_table_text(
            b"provider model\nopenai gpt-4o 1 1 no yes\n",
            b"noise\n",
        );
        assert!(from_stdout.contains("openai"));
        let from_stderr =
            list_models_table_text(b"  \n", b"provider model\nopenai gpt-4o 1 1 no yes\n");
        assert!(from_stderr.contains("openai"));
    }
}