malvin 0.2.4

Non-interactive research and coding agent
use std::collections::BTreeMap;
use std::path::Path;

use crate::log_gc_config::LogsGcConfig;
use crate::output::print_log_warning;
use crate::support_paths::{DEFAULT_CLI_MODEL, DEFAULT_MAX_ACP_RETRIES};
use crate::terminal_palette::TerminalTheme;
use crate::workspace_paths::malvin_config_path;

#[path = "malvin_config_agent.rs"]
mod malvin_config_agent;
#[path = "malvin_config_default_workflow.rs"]
mod malvin_config_default_workflow;
#[path = "malvin_config_open.rs"]
mod malvin_config_open;
#[path = "malvin_config_parse.rs"]
mod malvin_config_parse;
#[path = "malvin_config_review.rs"]
mod malvin_config_review;
#[path = "malvin_config_top.rs"]
mod malvin_config_top;
pub(crate) use malvin_config_agent::parse_agent_config;
pub(crate) use malvin_config_default_workflow::parse_default_workflow_config;
use malvin_config_open::create_malvin_config_from_template;
pub use malvin_config_open::{
    ensure_malvin_config_file_if_missing, load_agent_config_lenient, load_agent_config_strict,
};
pub(crate) use malvin_config_parse::{
    parse_malvin_config, read_f64, read_string, read_u32, read_u64, read_usize,
};
pub(crate) use malvin_config_review::parse_review_config;
pub use malvin_config_top::{DEFAULT_CONTEXT_SIZE, TokenCostRates};
pub(crate) use malvin_config_top::{parse_context_size, parse_model_token_cost_rates, parse_theme};

pub const DEFAULT_MAX_HYPOTHESES: usize = 5;
pub const DEFAULT_MAX_LOOPS: usize = 1;
pub const DEFAULT_MAX_LOOPS_CODE: usize = 3;
pub const DEFAULT_WRITE_MAX_HYPOTHESES: usize = 10;

const DEFAULT_MALVIN_CONFIG_TEMPLATE: &str = include_str!(concat!(
    env!("CARGO_MANIFEST_DIR"),
    "/assets/default_malvin_home_config.toml"
));

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentConfig {
    pub model: String,
    pub max_hypotheses: usize,
    pub max_loops: usize,
    pub max_loops_code: usize,
    pub max_acp_retries: u32,
}

impl Default for AgentConfig {
    fn default() -> Self {
        Self {
            model: DEFAULT_CLI_MODEL.to_string(),
            max_hypotheses: DEFAULT_MAX_HYPOTHESES,
            max_loops: DEFAULT_MAX_LOOPS,
            max_loops_code: DEFAULT_MAX_LOOPS_CODE,
            max_acp_retries: DEFAULT_MAX_ACP_RETRIES,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ReviewConfig {
    pub max_hypotheses: Option<usize>,
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct DefaultWorkflowConfig {
    pub max_hypotheses: Option<usize>,
}

impl DefaultWorkflowConfig {
    #[must_use]
    pub fn max_hypotheses_or_default(&self) -> usize {
        self.max_hypotheses.unwrap_or(DEFAULT_MAX_HYPOTHESES)
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct MalvinConfig {
    pub mem_limit_gb: u64,
    pub context_size: u32,
    pub theme: TerminalTheme,
    pub token_cost_rates: BTreeMap<String, TokenCostRates>,
    pub logs: LogsGcConfig,
    pub agent: AgentConfig,
    pub review: ReviewConfig,
    pub default_workflow: DefaultWorkflowConfig,
}

impl MalvinConfig {
    #[must_use]
    pub fn token_cost_rates_for(&self, model: &str) -> TokenCostRates {
        self.token_cost_rates
            .get(model)
            .copied()
            .unwrap_or_default()
    }
}

pub fn ensure_malvin_config_file(work_dir: &Path) -> Result<(), String> {
    let _ = open_malvin_config(work_dir)?;
    Ok(())
}

pub fn load_malvin_config(work_dir: &Path) -> MalvinConfig {
    let path = malvin_config_path(work_dir);
    let Ok(text) = std::fs::read_to_string(&path) else {
        return parse_malvin_config(DEFAULT_MALVIN_CONFIG_TEMPLATE);
    };
    let Ok(template) = parse_template_value() else {
        return parse_malvin_config(&text);
    };
    let Ok(mut on_disk) = text.parse::<toml::Value>() else {
        print_log_warning(&format!("invalid TOML in {}", path.display()));
        return parse_malvin_config(DEFAULT_MALVIN_CONFIG_TEMPLATE);
    };
    let _ = merge_missing_keys(&mut on_disk, &template);
    let merged = toml::to_string(&on_disk).unwrap_or(text);
    parse_malvin_config(&merged)
}

pub fn open_malvin_config(work_dir: &Path) -> Result<MalvinConfig, String> {
    let path = malvin_config_path(work_dir);
    ensure_config_parent_dir(&path)?;
    let template = parse_template_value()?;
    if path.is_file() {
        let meta = std::fs::metadata(&path).map_err(|e| format!("stat {}: {e}", path.display()))?;
        if meta.len() == 0 {
            std::fs::remove_file(&path)
                .map_err(|e| format!("remove empty {}: {e}", path.display()))?;
        }
    }
    if !path.is_file() {
        return create_malvin_config_from_template(&path, &template);
    }
    let mut on_disk = read_on_disk_config_value(&path)?;
    merge_missing_keys(&mut on_disk, &template);
    Ok(parse_malvin_config(
        &toml::to_string(&on_disk).map_err(|e| e.to_string())?,
    ))
}

pub(crate) fn ensure_config_parent_dir(path: &Path) -> Result<(), String> {
    if path == crate::workspace_paths::malvin_home_config_path() {
        crate::workspace_paths::assert_home_malvin_config_disk_io_allowed("create")?;
    }
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
    }
    Ok(())
}

pub(crate) fn read_on_disk_config_value(path: &Path) -> Result<toml::Value, String> {
    if !path.is_file() {
        return Ok(toml::Value::Table(toml::map::Map::new()));
    }
    let text =
        std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
    text.parse::<toml::Value>()
        .map_err(|e| format!("invalid TOML in {}: {e}", path.display()))
}

pub(crate) fn write_config_value(path: &Path, value: &toml::Value) -> Result<(), String> {
    if path == crate::workspace_paths::malvin_home_config_path() {
        crate::workspace_paths::assert_home_malvin_config_disk_io_allowed("write")?;
    }
    let serialized =
        toml::to_string_pretty(value).map_err(|e| format!("serialize {}: {e}", path.display()))?;
    let mut content = serialized;
    if !content.ends_with('\n') {
        content.push('\n');
    }
    std::fs::write(path, &content).map_err(|e| format!("write {}: {e}", path.display()))
}

pub(crate) fn parse_template_value() -> Result<toml::Value, String> {
    DEFAULT_MALVIN_CONFIG_TEMPLATE
        .parse()
        .map_err(|e| format!("invalid bundled config template: {e}"))
}

pub(crate) fn merge_missing_keys(into: &mut toml::Value, template: &toml::Value) -> bool {
    match (into, template) {
        (toml::Value::Table(into_table), toml::Value::Table(template_table)) => {
            let mut changed = false;
            for (key, template_value) in template_table {
                if !into_table.contains_key(key) {
                    into_table.insert(key.clone(), template_value.clone());
                    changed = true;
                    continue;
                }
                if let Some(existing) = into_table.get_mut(key)
                    && merge_missing_keys(existing, template_value) {
                        changed = true;
                    }
            }
            changed
        }
        _ => false,
    }
}

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

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

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

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