malvin 0.2.9

Non-interactive research and coding agent
use std::env;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};

const ADVICE_DIR: &str = "default_prompts/advice";
const MAX_TAG_LEN: usize = 32;
const MAX_DESC_WORDS: usize = 7;

pub fn generate() {
    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
    let advice_dir = manifest_dir.join(ADVICE_DIR);
    let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));

    println!("cargo:rerun-if-changed={}", advice_dir.display());

    let mut entries = collect_entries(&advice_dir);
    entries.sort_by(|a, b| a.tag.cmp(&b.tag));
    assert_no_duplicate_tags(&entries);

    let dest = out_dir.join("advice_registry.rs");
    let code = render_registry(&entries);
    fs::write(&dest, code).unwrap_or_else(|e| panic!("write {}: {e}", dest.display()));
}

struct AdviceBuildEntry {
    tag: String,
    rel_path: String,
    description: String,
}

fn collect_entries(advice_dir: &Path) -> Vec<AdviceBuildEntry> {
    assert!(
        advice_dir.is_dir(),
        "missing advice directory {}",
        advice_dir.display()
    );
    let mut entries = Vec::new();
    let read =
        fs::read_dir(advice_dir).unwrap_or_else(|e| panic!("read {}: {e}", advice_dir.display()));
    for entry in read {
        let entry = entry.unwrap_or_else(|e| panic!("dir entry: {e}"));
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("md") {
            continue;
        }
        println!("cargo:rerun-if-changed={}", path.display());
        let stem = path
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or_else(|| panic!("non-utf8 stem: {}", path.display()));
        validate_tag(stem);
        let content =
            fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
        let description = description_for(&content, stem);
        entries.push(AdviceBuildEntry {
            tag: stem.to_string(),
            rel_path: format!("{ADVICE_DIR}/{stem}.md"),
            description,
        });
    }
    assert!(
        !entries.is_empty(),
        "no .md files under {}",
        advice_dir.display()
    );
    entries
}

fn validate_tag(tag: &str) {
    let mut chars = tag.chars();
    let Some(first) = chars.next() else {
        panic!("advice tag must not be empty");
    };
    assert!(
        first.is_ascii_lowercase(),
        "advice tag `{tag}` must start with a lowercase letter"
    );
    assert!(
        tag.len() <= MAX_TAG_LEN,
        "advice tag `{tag}` exceeds {MAX_TAG_LEN} characters"
    );
    assert!(
        chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
        "advice tag `{tag}` must be [a-z][a-z0-9_]* (lowercase letters, digits, underscore)"
    );
}

fn assert_no_duplicate_tags(entries: &[AdviceBuildEntry]) {
    let mut seen = std::collections::HashSet::new();
    for e in entries {
        assert!(
            seen.insert(e.tag.as_str()),
            "duplicate advice tag `{}`",
            e.tag
        );
    }
}

fn description_for(content: &str, tag: &str) -> String {
    let first_line = content.lines().next().unwrap_or("");
    let Some(value) = first_line.strip_prefix("description:") else {
        panic!("advice `{tag}`: first line must be `description: ...`, got: {first_line:?}");
    };
    let desc = strip_optional_quotes(value.trim());
    assert!(
        !desc.is_empty(),
        "advice `{tag}`: description after `description:` is empty"
    );
    assert!(
        word_count(desc) <= MAX_DESC_WORDS,
        "advice `{tag}`: description has more than {MAX_DESC_WORDS} words: {desc}"
    );
    desc.to_string()
}

fn strip_optional_quotes(s: &str) -> &str {
    if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
        || (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
    {
        &s[1..s.len() - 1]
    } else {
        s
    }
}

fn word_count(s: &str) -> usize {
    s.split_whitespace().count()
}

fn render_registry(entries: &[AdviceBuildEntry]) -> String {
    let mut out = String::from("static ADVICE_ENTRIES: &[AdviceEntry] = &[\n");
    for e in entries {
        out.push_str("    AdviceEntry {\n");
        let _ = writeln!(out, "        tag: \"{}\",", escape_rust_str(&e.tag));
        let _ = writeln!(
            out,
            "        body: include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/{}\")),",
            escape_rust_str(&e.rel_path)
        );
        let _ = writeln!(
            out,
            "        description: \"{}\",",
            escape_rust_str(&e.description)
        );
        out.push_str("    },\n");
    }
    out.push_str("];\n");
    out
}

fn escape_rust_str(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if c.is_control() => {
                let _ = write!(out, "\\u{{{:x}}}", c as u32);
            }
            c => out.push(c),
        }
    }
    out
}