malvin 0.2.4

Non-interactive research and coding agent
use super::{
    DEFAULT_MAX_LOOPS, DEFAULT_MAX_LOOPS_CODE, ensure_config_parent_dir, load_malvin_config,
    merge_missing_keys, open_malvin_config, parse_agent_config, parse_template_value,
    read_on_disk_config_value, write_config_value,
};
use crate::support_paths::DEFAULT_CLI_MODEL;
use crate::test_utils::with_isolated_home;
use crate::workspace_paths::malvin_config_path;

fn merge_missing_keys_adds_top_level_and_nested_tables() {
    let template = parse_template_value().expect("template");
    let mut partial: toml::Value = toml::from_str("mem_limit_gb = 6\n").expect("partial");
    assert!(merge_missing_keys(&mut partial, &template));
    let merged = partial.as_table().expect("table");
    assert_eq!(
        merged.get("mem_limit_gb").and_then(toml::Value::as_integer),
        Some(6)
    );
    assert!(merged.get("logs").is_some());
    assert!(merged.get("agent").is_some());
}

fn merge_missing_keys_is_idempotent() {
    let template = parse_template_value().expect("template");
    let mut value = template.clone();
    assert!(!merge_missing_keys(&mut value, &template));
}

fn open_malvin_config_creates_file_with_all_sections() {
    with_isolated_home(|work| {
        let path = malvin_config_path(work);
        assert!(!path.exists());
        let cfg = open_malvin_config(work).expect("open");
        assert!(path.is_file());
        let text = std::fs::read_to_string(&path).expect("read");
        assert!(text.contains("[logs]"));
        assert!(text.contains("[agent]"));
        assert!(text.contains("[agent.cursor.auto]"));
        assert!(!text.contains("mpc"));
        assert_eq!(cfg.agent.model, DEFAULT_CLI_MODEL);
        assert_eq!(cfg.agent.max_loops, DEFAULT_MAX_LOOPS);
        assert_eq!(cfg.agent.max_loops_code, DEFAULT_MAX_LOOPS_CODE);
        assert!(text.contains("theme"));
        assert!(text.contains("context_size"));
        assert_eq!(
            cfg.context_size,
            crate::malvin_config_file::DEFAULT_CONTEXT_SIZE
        );
        assert_eq!(cfg.theme, crate::terminal_palette::TerminalTheme::Dark);
    });
}

fn open_malvin_config_merges_missing_agent_in_memory_only() {
    with_isolated_home(|work| {
        let path = malvin_config_path(work);
        std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
        std::fs::write(
            &path,
            "mem_limit_gb = 6\n\n[logs]\nmax_age_days = 90\nmax_bytes = \"2GiB\"\n",
        )
        .expect("write");
        let before = std::fs::read_to_string(&path).expect("read before");
        let cfg = open_malvin_config(work).expect("open");
        let after = std::fs::read_to_string(&path).expect("read after");
        assert_eq!(
            before, after,
            "existing config.toml must never be rewritten"
        );
        assert_eq!(cfg.mem_limit_gb, 6);
        assert_eq!(
            cfg.context_size,
            crate::malvin_config_file::DEFAULT_CONTEXT_SIZE
        );
        assert_eq!(cfg.agent.model, DEFAULT_CLI_MODEL);
    });
}

fn parse_agent_config_reads_values() {
    let text = r#"
[agent]
model = "cursor:gpt-5"
max_loops = 3
max_acp_retries = 5
"#;
    let agent = parse_agent_config(text).expect("parse");
    assert_eq!(agent.model, "cursor:gpt-5");
    assert_eq!(agent.max_loops, 3);
    assert_eq!(agent.max_acp_retries, 5);
}

fn parse_agent_config_accepts_string_numbers() {
    let text = r#"
[agent]
model = "cursor:m"
max_loops = "2"
max_acp_retries = "4"
"#;
    let agent = parse_agent_config(text).expect("parse");
    assert_eq!(agent.max_loops, 2);
    assert_eq!(agent.max_acp_retries, 4);
}

fn parse_theme_accepts_dark_and_light() {
    use super::parse_theme;
    use crate::terminal_palette::TerminalTheme;

    assert_eq!(
        parse_theme("theme = \"dark\"").expect("dark"),
        TerminalTheme::Dark
    );
    assert_eq!(
        parse_theme("theme = \"light\"").expect("light"),
        TerminalTheme::Light
    );
    assert_eq!(
        parse_theme("mem_limit_gb = 4").expect("missing"),
        TerminalTheme::Dark
    );
    assert!(parse_theme("theme = \"neon\"").is_err());
}

fn parse_context_size_reads_top_level_key() {
    use super::{DEFAULT_CONTEXT_SIZE, parse_context_size};
    assert_eq!(
        parse_context_size("context_size = 16384\n").expect("parse"),
        16384
    );
    assert_eq!(
        parse_context_size("mem_limit_gb = 4\n").expect("default"),
        DEFAULT_CONTEXT_SIZE
    );
    assert!(
        parse_context_size("context_size = 0\n")
            .expect_err("zero")
            .contains("positive")
    );
}

fn open_malvin_config_merges_theme_in_memory_only() {
    with_isolated_home(|work| {
        let path = malvin_config_path(work);
        std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
        std::fs::write(&path, "mem_limit_gb = 6\n").expect("write");
        let before = std::fs::read_to_string(&path).expect("read before");
        let cfg = open_malvin_config(work).expect("open");
        let after = std::fs::read_to_string(&path).expect("read after");
        assert_eq!(before, after);
        assert_eq!(cfg.theme, crate::terminal_palette::TerminalTheme::Dark);
    });
}

fn load_malvin_config_reads_light_theme() {
    with_isolated_home(|work| {
        let path = malvin_config_path(work);
        std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
        std::fs::write(&path, "theme = \"light\"\n").expect("write");
        let cfg = load_malvin_config(work);
        assert_eq!(cfg.theme, crate::terminal_palette::TerminalTheme::Light);
    });
}

fn parse_agent_config_reads_max_loops_code() {
    let text = r#"
[agent]
model = "cursor:m"
max_loops = 1
max_loops_code = 4
"#;
    let agent = parse_agent_config(text).expect("parse");
    assert_eq!(agent.max_loops, 1);
    assert_eq!(agent.max_loops_code, 4);
}

fn load_malvin_config_uses_defaults_for_invalid_on_disk_toml() {
    with_isolated_home(|work| {
        let path = malvin_config_path(work);
        std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
        std::fs::write(&path, "not valid {{{ toml").expect("write");
        let cfg = load_malvin_config(work);
        assert_eq!(cfg.agent.model, DEFAULT_CLI_MODEL);
    });
}

fn load_malvin_config_merges_partial_file_in_memory_only() {
    with_isolated_home(|work| {
        let path = malvin_config_path(work);
        std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
        std::fs::write(&path, "mem_limit_gb = 8\n").expect("write");
        let cfg = load_malvin_config(work);
        assert_eq!(cfg.mem_limit_gb, 8);
        let text = std::fs::read_to_string(&path).expect("read");
        assert!(!text.contains("[agent]"));
    });
}

fn config_io_helpers_read_missing_file_as_empty_table() {
    with_isolated_home(|work| {
        let path = malvin_config_path(work);
        let value = read_on_disk_config_value(&path).expect("read");
        assert!(value.as_table().expect("table").is_empty());
    });
}

fn config_io_helpers_write_and_read_round_trip() {
    with_isolated_home(|work| {
        let path = malvin_config_path(work);
        ensure_config_parent_dir(&path).expect("mkdir");
        let value: toml::Value = toml::from_str("mem_limit_gb = 3").expect("toml");
        write_config_value(&path, &value).expect("write");
        let read = read_on_disk_config_value(&path).expect("read");
        assert_eq!(read.get("mem_limit_gb"), value.get("mem_limit_gb"));
    });
}

#[test]
fn kiss_bundled_malvin_config_file_tests() {
    merge_missing_keys_adds_top_level_and_nested_tables();
    merge_missing_keys_is_idempotent();
    open_malvin_config_creates_file_with_all_sections();
    open_malvin_config_merges_missing_agent_in_memory_only();
    parse_agent_config_reads_values();
    parse_agent_config_accepts_string_numbers();
    parse_theme_accepts_dark_and_light();
    parse_context_size_reads_top_level_key();
    open_malvin_config_merges_theme_in_memory_only();
    load_malvin_config_reads_light_theme();
    parse_agent_config_reads_max_loops_code();
    load_malvin_config_uses_defaults_for_invalid_on_disk_toml();
    load_malvin_config_merges_partial_file_in_memory_only();
    config_io_helpers_read_missing_file_as_empty_table();
    config_io_helpers_write_and_read_round_trip();
}