rho-coding-agent 0.25.0

A lightweight agent harness inspired by Pi
Documentation
use std::{
    collections::{BTreeMap, BTreeSet},
    path::Path,
};

pub type PromptTemplates = BTreeMap<String, String>;

pub fn discover(cwd: &Path) -> PromptTemplates {
    discover_with_home(cwd, crate::paths::home_dir().as_deref())
}

fn discover_with_home(cwd: &Path, home: Option<&Path>) -> PromptTemplates {
    let mut roots = Vec::new();
    if let Some(home) = home {
        roots.push(home.join(".rho").join("prompts"));
    }
    roots.extend(
        crate::workspace::project_ancestor_dirs(cwd)
            .into_iter()
            .rev()
            .map(|path| path.join(".rho").join("prompts")),
    );

    let mut templates = PromptTemplates::new();
    for root in roots {
        for path in template_paths(&root) {
            let Some(name) = path.file_stem().and_then(|name| name.to_str()) else {
                continue;
            };
            let Ok(template) = std::fs::read_to_string(&path) else {
                continue;
            };
            if validate_entry(name, &template).is_ok() {
                insert_override(
                    &mut templates,
                    name.to_string(),
                    template.trim().to_string(),
                );
            }
        }
    }
    templates
}

fn template_paths(root: &Path) -> Vec<std::path::PathBuf> {
    let Ok(entries) = std::fs::read_dir(root) else {
        return Vec::new();
    };
    let mut paths = entries
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| {
            path.is_file()
                && matches!(
                    path.extension().and_then(|ext| ext.to_str()),
                    Some("md" | "txt")
                )
        })
        .collect::<Vec<_>>();
    paths.sort();
    paths
}

pub fn validate(templates: &PromptTemplates) -> anyhow::Result<()> {
    let mut names = BTreeSet::new();
    for (name, template) in templates {
        validate_entry(name, template)?;
        if !names.insert(name.to_ascii_lowercase()) {
            anyhow::bail!(
                "prompt template name '/{name}' conflicts with another template name (names are case-insensitive)"
            );
        }
    }
    Ok(())
}

pub fn merge(templates: &mut PromptTemplates, overrides: PromptTemplates) {
    for (name, template) in overrides {
        insert_override(templates, name, template);
    }
}

fn insert_override(templates: &mut PromptTemplates, name: String, template: String) {
    if let Some(existing) = templates
        .keys()
        .find(|existing| existing.eq_ignore_ascii_case(&name))
        .cloned()
    {
        templates.remove(&existing);
    }
    templates.insert(name, template);
}

fn validate_entry(name: &str, template: &str) -> anyhow::Result<()> {
    if name.is_empty()
        || !name
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
    {
        anyhow::bail!(
            "invalid prompt template name '{name}': use only letters, numbers, '-' and '_'"
        );
    }
    if crate::commands::COMMANDS
        .iter()
        .any(|command| command.name.eq_ignore_ascii_case(name))
    {
        anyhow::bail!("prompt template '/{name}' conflicts with a built-in command");
    }
    if template.trim().is_empty() {
        anyhow::bail!("prompt template '/{name}' cannot be empty");
    }
    Ok(())
}

pub fn description(template: &str) -> String {
    template.split_whitespace().collect::<Vec<_>>().join(" ")
}

pub fn matches_search(name: &str, search: &str) -> bool {
    let name = name.to_ascii_lowercase();
    name.starts_with(search) || format!("prompt:{name}").starts_with(search)
}

pub fn find<'a>(templates: &'a PromptTemplates, name: &str) -> Option<&'a str> {
    templates
        .iter()
        .find(|(template_name, _)| template_name.eq_ignore_ascii_case(name))
        .map(|(_, template)| template.as_str())
}

pub fn expand(template: &str, trailing_text: &str) -> String {
    let trailing_text = trailing_text.trim();
    if trailing_text.is_empty() {
        template.to_string()
    } else {
        format!("{template} {trailing_text}")
    }
}

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