procyon 0.1.0

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
use crate::skills::{loader::Skill, SkillRegistry};

/// A persona discovered from a stellar-build skill with a customize.toml.
#[derive(Debug, Clone)]
pub struct Persona {
    /// Skill name (e.g. "tyler-architect").
    pub skill_name: String,
    /// Display name (e.g. "Tyler").
    pub name: String,
    /// Title (e.g. "System Architect").
    pub title: String,
    /// Icon emoji (e.g. "🏗️").
    pub icon: String,
    /// Role description.
    pub role: String,
    /// Identity description.
    pub identity: String,
    /// Communication style.
    pub communication_style: String,
    /// Principles (value system).
    pub principles: Vec<String>,
    /// Skill body (markdown instructions).
    pub body: String,
}

impl Persona {
    /// Builds a system prompt for a sub-agent using this persona.
    pub fn system_prompt(&self, workspace_context: &str) -> String {
        let mut prompt = format!(
            "# {}{}\n\n{}\n\n## Role\n\n{}\n\n## Identity\n\n{}\n\n## Communication Style\n\n{}\n\n## Principles\n\n",
            self.name, self.title, self.body, self.role, self.identity, self.communication_style
        );

        for p in &self.principles {
            prompt.push_str(&format!("- {}\n", p));
        }

        if !workspace_context.is_empty() {
            prompt.push_str(&format!(
                "\n## Workspace Context\n\n{}\n",
                workspace_context
            ));
        }

        prompt
    }
}

/// Registry of discovered personas.
pub struct PersonaRegistry {
    personas: Vec<Persona>,
}

impl PersonaRegistry {
    /// Creates an empty registry.
    pub fn new() -> Self {
        Self {
            personas: Vec::new(),
        }
    }

    /// Discovers personas from the skill registry.
    /// Personas are skills that have a customize.toml with an [agent] section.
    pub fn discover(skill_registry: &SkillRegistry) -> (Self, Vec<String>) {
        let mut personas = Vec::new();
        let mut warnings = Vec::new();

        for skill in skill_registry.all() {
            if !skill.has_customize {
                continue;
            }

            match Self::load_persona(skill) {
                Ok(persona) => personas.push(persona),
                Err(e) => {
                    warnings.push(format!("Failed to load persona from {}: {}", skill.name, e));
                }
            }
        }

        (Self { personas }, warnings)
    }

    /// Loads persona metadata from a skill's customize.toml.
    fn load_persona(skill: &Skill) -> Result<Persona, String> {
        let customize_path = skill.path.join("customize.toml");
        let content = match std::fs::read_to_string(&customize_path) {
            Ok(c) => c,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                return Err("No customize.toml found".to_string());
            }
            Err(e) => {
                return Err(format!("Failed to read customize.toml: {}", e));
            }
        };

        let config: toml::Value = toml::from_str(&content)
            .map_err(|e| format!("Failed to parse customize.toml: {}", e))?;

        let agent = config
            .get("agent")
            .ok_or_else(|| "No [agent] section in customize.toml".to_string())?;

        Ok(Persona {
            skill_name: skill.name.clone(),
            name: agent
                .get("name")
                .and_then(|v| v.as_str())
                .unwrap_or(&skill.name)
                .to_string(),
            title: agent
                .get("title")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            icon: agent
                .get("icon")
                .and_then(|v| v.as_str())
                .unwrap_or("🤖")
                .to_string(),
            role: agent
                .get("role")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            identity: agent
                .get("identity")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            communication_style: agent
                .get("communication_style")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            principles: agent
                .get("principles")
                .and_then(|v| v.as_array())
                .map(|arr| {
                    arr.iter()
                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
                        .collect()
                })
                .unwrap_or_default(),
            body: skill.body.clone(),
        })
    }

    /// Returns all loaded personas.
    pub fn all(&self) -> &[Persona] {
        &self.personas
    }

    /// Finds a persona by skill name.
    pub fn get(&self, name: &str) -> Option<&Persona> {
        self.personas
            .iter()
            .find(|p| p.skill_name == name || p.name.to_lowercase() == name.to_lowercase())
    }

    /// Searches personas by fuzzy matching.
    pub fn search(&self, query: &str) -> Vec<&Persona> {
        let query_lower = query.to_lowercase();
        self.personas
            .iter()
            .filter(|p| {
                p.skill_name.to_lowercase().contains(&query_lower)
                    || p.name.to_lowercase().contains(&query_lower)
                    || p.title.to_lowercase().contains(&query_lower)
            })
            .collect()
    }
}

impl Default for PersonaRegistry {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_persona(name: &str, title: &str) -> Persona {
        Persona {
            skill_name: format!("{}-test", name.to_lowercase()),
            name: name.to_string(),
            title: title.to_string(),
            icon: "🤖".to_string(),
            role: format!("{} role", name),
            identity: format!("{} identity", name),
            communication_style: "Professional".to_string(),
            principles: vec!["Be helpful".to_string()],
            body: format!("# {}{}\n\nBody content.", name, title),
        }
    }

    #[test]
    fn get_finds_by_skill_name() {
        let mut reg = PersonaRegistry::new();
        reg.personas.push(make_persona("Tyler", "Architect"));

        assert!(reg.get("tyler-test").is_some());
        assert!(reg.get("Tyler").is_some());
    }

    #[test]
    fn search_matches_name_and_title() {
        let mut reg = PersonaRegistry::new();
        reg.personas.push(make_persona("Tyler", "Architect"));
        reg.personas.push(make_persona("Nicole", "PM"));

        let results = reg.search("architect");
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].name, "Tyler");
    }

    #[test]
    fn search_is_case_insensitive() {
        let mut reg = PersonaRegistry::new();
        reg.personas.push(make_persona("Elliot", "Developer"));

        assert_eq!(reg.search("ELLIOT").len(), 1);
        assert_eq!(reg.search("developer").len(), 1);
    }

    #[test]
    fn system_prompt_includes_all_persona_fields() {
        let persona = make_persona("Tyler", "Architect");
        let prompt = persona.system_prompt("Workspace: /project");

        assert!(prompt.contains("Tyler"));
        assert!(prompt.contains("Architect"));
        assert!(prompt.contains("Tyler role"));
        assert!(prompt.contains("Tyler identity"));
        assert!(prompt.contains("Professional"));
        assert!(prompt.contains("Be helpful"));
        assert!(prompt.contains("Workspace: /project"));
    }

    #[test]
    fn system_prompt_with_empty_context() {
        let persona = make_persona("Nicole", "PM");
        let prompt = persona.system_prompt("");

        assert!(prompt.contains("Nicole"));
        assert!(!prompt.contains("Workspace Context"));
    }
}