looprs 0.4.0

Concise coding assistant REPL — core library
Documentation
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AgentDefinition {
    pub name: String,
    #[serde(default)]
    pub role: Option<String>,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub system_prompt: Option<String>,
    #[serde(default)]
    pub tools: Vec<String>,
    #[serde(default)]
    pub skills: Vec<String>,
    #[serde(default)]
    pub constraints: Vec<String>,
    #[serde(default)]
    pub triggers: Vec<String>,
}

impl AgentDefinition {
    pub fn matches_prompt(&self, prompt: &str) -> bool {
        if self.triggers.is_empty() {
            return false;
        }

        let lower = prompt.to_lowercase();
        self.triggers
            .iter()
            .any(|trigger| lower.contains(&trigger.to_lowercase()))
    }
}

#[derive(Debug, Clone, Default)]
pub struct AgentRegistry {
    agents: HashMap<String, AgentDefinition>,
}

impl AgentRegistry {
    pub fn new() -> Self {
        Self {
            agents: HashMap::new(),
        }
    }

    // TODO: add built-in agent definitions — registry is fully wired but
    // .looprs/agents/ only has a README. Define at minimum: reviewer, planner,
    // debugger. Each needs name, role, description, system_prompt, tools, and
    // optional skills/constraints fields. Validates the delegation loop end-to-end.
    pub fn bundled_agents() -> Vec<AgentDefinition> {
        unimplemented!("return reviewer, planner, debugger agent definitions")
    }

    pub fn register(&mut self, agent: AgentDefinition) {
        self.agents.insert(agent.name.clone(), agent);
    }

    pub fn get(&self, name: &str) -> Option<&AgentDefinition> {
        self.agents.get(name)
    }

    pub fn is_empty(&self) -> bool {
        self.agents.is_empty()
    }

    pub fn list(&self) -> Vec<&AgentDefinition> {
        let mut list: Vec<&AgentDefinition> = self.agents.values().collect();
        list.sort_by_key(|a| &a.name);
        list
    }

    pub fn select_for_prompt(
        &self,
        prompt: &str,
        default_agent: Option<&str>,
        delegate_by_default: bool,
    ) -> Option<&AgentDefinition> {
        if let Some(found) = self
            .list()
            .into_iter()
            .find(|agent| agent.matches_prompt(prompt))
        {
            return Some(found);
        }

        if let Some(default_name) = default_agent
            && let Some(default) = self.get(default_name)
        {
            return Some(default);
        }

        if delegate_by_default {
            return self.list().into_iter().next();
        }

        None
    }

    // qual:allow(iosp) reason: "I/O boundary — reads agent YAML files from directory"
    pub fn load_from_directory(dir: &PathBuf) -> anyhow::Result<Self> {
        let mut registry = Self::new();

        if !dir.exists() {
            return Ok(registry);
        }

        for entry in fs::read_dir(dir)? {
            let entry = entry?;
            let path = entry.path();

            if path.extension().and_then(|s| s.to_str()) != Some("yaml")
                && path.extension().and_then(|s| s.to_str()) != Some("yml")
            {
                continue;
            }

            match Self::parse_agent(&path) {
                Ok(agent) => registry.register(agent),
                Err(e) => crate::ui::warn(format!(
                    "Warning: Failed to load agent {}: {}",
                    path.display(),
                    e
                )),
            }
        }

        Ok(registry)
    }

    // qual:allow(iosp) reason: "I/O boundary — loads agents from user + repo directories"
    pub fn load_dual_source(
        user_dir: Option<&PathBuf>,
        repo_dir: Option<&PathBuf>,
    ) -> anyhow::Result<Self> {
        let mut registry = Self::new();

        if let Some(user_path) = user_dir {
            let user = Self::load_from_directory(user_path)?;
            for agent in user.list() {
                registry.register(agent.clone());
            }
        }

        if let Some(repo_path) = repo_dir {
            let repo = Self::load_from_directory(repo_path)?;
            for agent in repo.list() {
                registry.register(agent.clone());
            }
        }

        Ok(registry)
    }

    fn parse_agent(path: &std::path::Path) -> anyhow::Result<AgentDefinition> {
        let content = fs::read_to_string(path)?;
        let agent: AgentDefinition = serde_yaml::from_str(&content)?;

        if agent.name.trim().is_empty() {
            anyhow::bail!("Agent name cannot be empty");
        }

        Ok(agent)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::TempDir;

    #[test]
    fn select_by_trigger_first() {
        let mut registry = AgentRegistry::new();
        registry.register(AgentDefinition {
            name: "reviewer".to_string(),
            role: None,
            description: None,
            system_prompt: None,
            tools: vec![],
            skills: vec![],
            constraints: vec![],
            triggers: vec!["review".to_string()],
        });

        let selected = registry
            .select_for_prompt("please review this", None, false)
            .unwrap();
        assert_eq!(selected.name, "reviewer");
    }

    #[test]
    fn default_agent_applies_when_no_trigger_match() {
        let mut registry = AgentRegistry::new();
        registry.register(AgentDefinition {
            name: "generalist".to_string(),
            role: None,
            description: None,
            system_prompt: None,
            tools: vec![],
            skills: vec![],
            constraints: vec![],
            triggers: vec![],
        });

        let selected = registry
            .select_for_prompt("hello", Some("generalist"), false)
            .unwrap();
        assert_eq!(selected.name, "generalist");
    }

    #[test]
    fn load_from_directory_parses_yaml() {
        let tmp = TempDir::new().unwrap();
        let file_path = tmp.path().join("reviewer.yaml");
        let mut file = std::fs::File::create(file_path).unwrap();
        writeln!(
            file,
            r#"name: reviewer
role: Senior Reviewer
triggers:
  - review
  - lint"#
        )
        .unwrap();

        let registry = AgentRegistry::load_from_directory(&tmp.path().to_path_buf()).unwrap();
        assert!(registry.get("reviewer").is_some());
    }
}