malvin 0.2.5

Non-interactive research and coding agent
use std::collections::HashMap;

#[allow(clippy::implicit_hasher)]
pub fn render_template(prompt_text: &str, context: &HashMap<String, String>) -> String {
    let mut keys: Vec<&String> = context.keys().collect();
    keys.sort_unstable();
    let mut translated = prompt_text.to_string();
    for key in keys {
        let needle = format!("{{{{ {key} }}}}");
        let dollar = format!("${key}");
        translated = translated.replace(&needle, &dollar);
    }
    substitute_template(&translated, context)
}

pub(crate) fn is_spaced_brace_placeholder_inner(raw: &str) -> bool {
    let key = raw.trim();
    raw.starts_with(' ')
        && raw.ends_with(' ')
        && !key.is_empty()
        && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}

#[must_use]
pub fn unresolved_template_placeholders(
    template: &str,
    context: &HashMap<String, String>,
) -> Vec<String> {
    unresolved_spaced_brace_placeholders(template)
        .into_iter()
        .filter(|placeholder| {
            let key = placeholder
                .trim_start_matches("{{")
                .trim_end_matches("}}")
                .trim();
            !context.contains_key(key)
        })
        .collect()
}

#[must_use]
pub fn unresolved_spaced_brace_placeholders(text: &str) -> Vec<String> {
    let mut unresolved = Vec::new();
    let mut search_from = 0usize;
    while let Some(rel) = text[search_from..].find("{{") {
        let open = search_from + rel;
        let after_open = open + 2;
        let Some(close_rel) = text[after_open..].find("}}") else {
            break;
        };
        let close = after_open + close_rel;
        let raw = &text[after_open..close];
        if is_spaced_brace_placeholder_inner(raw) {
            unresolved.push(format!("{{{{{raw}}}}}"));
        }
        search_from = close + 2;
    }
    unresolved
}

#[must_use]
pub fn malformed_brace_placeholders(text: &str) -> Vec<String> {
    let mut bad = Vec::new();
    let mut search_from = 0usize;
    while let Some(rel) = text[search_from..].find("{{") {
        let open = search_from + rel;
        let after_open = open + 2;
        let Some(close_rel) = text[after_open..].find("}}") else {
            bad.push(text[open..].to_string());
            break;
        };
        let close = after_open + close_rel;
        let raw = &text[after_open..close];
        if !is_spaced_brace_placeholder_inner(raw) {
            bad.push(format!("{{{{{raw}}}}}"));
        }
        search_from = close + 2;
    }
    bad
}

#[must_use]
pub fn substitute_template(template: &str, context: &HashMap<String, String>) -> String {
    let mut out = String::with_capacity(template.len());
    let chars: Vec<char> = template.chars().collect();
    let mut i = 0usize;
    while i < chars.len() {
        if chars[i] == '$' && i + 1 < chars.len() {
            if chars[i + 1] == '$' {
                out.push('$');
                i += 2;
                continue;
            }
            let start = i + 1;
            let mut end = start;
            while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_') {
                end += 1;
            }
            if end > start {
                let key: String = chars[start..end].iter().collect();
                if let Some(val) = context.get(&key) {
                    out.push_str(val);
                    i = end;
                    continue;
                }
            }
        }
        out.push(chars[i]);
        i += 1;
    }
    out
}

#[cfg(test)]
mod template_kiss {
    #[test]
    fn unresolved_template_placeholders_detects_missing_context_keys() {
        let mut ctx = std::collections::HashMap::new();
        ctx.insert("present".to_string(), "v".to_string());
        assert_eq!(
            super::unresolved_template_placeholders("x {{ present }} y {{ missing }} z", &ctx),
            vec!["{{ missing }}".to_string()]
        );
    }

    #[test]
    fn malformed_brace_placeholders_rejects_unspaced_key() {
        let _ = crate::prompts::render_mbc2_prompt;
        let _ = crate::prompts::render_header;
        let _ = super::is_spaced_brace_placeholder_inner;
        let bad = super::malformed_brace_placeholders("x {{plan_path}} y");
        assert_eq!(bad.len(), 1);
    }

    #[test]
    fn malformed_brace_placeholders_accepts_spaced_key() {
        let bad = super::malformed_brace_placeholders("x {{ plan_path }} y");
        assert!(bad.is_empty());
    }

    #[test]
    fn render_template_replaces_brace_and_dollar_keys() {
        let mut ctx = std::collections::HashMap::new();
        ctx.insert("name".to_string(), "world".to_string());
        let out = super::render_template("Hello {{ name }}", &ctx);
        assert_eq!(out, "Hello world");
    }

    #[test]
    fn unresolved_spaced_brace_placeholders_detects_only_spaced_keys() {
        assert_eq!(
            super::unresolved_spaced_brace_placeholders("x {{ y }} z"),
            vec!["{{ y }}".to_string()]
        );
        assert!(super::unresolved_spaced_brace_placeholders("x {{…}} z").is_empty());
    }

    #[test]
    fn substitute_template_treats_double_dollar_as_literal() {
        let mut ctx = std::collections::HashMap::new();
        ctx.insert("plan_path".to_string(), "/tmp/plan".to_string());
        assert_eq!(
            super::substitute_template("use $$plan_path", &ctx),
            "use $plan_path"
        );
    }
}