agent-team-mail-core 0.45.2

Core library for agent-team-mail: file-based messaging for AI agent teams
Documentation
//! Shared spawn UX helpers.

use anyhow::{Result, anyhow};
use std::path::Path;

/// Spawn pane placement mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneMode {
    NewPane,
    ExistingPane,
    CurrentPane,
}

impl PaneMode {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::NewPane => "new-pane",
            Self::ExistingPane => "existing-pane",
            Self::CurrentPane => "current-pane",
        }
    }
}

/// Color and model extracted from a `.claude/agents/<name>.md` frontmatter block.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AgentFrontmatter {
    pub color: Option<String>,
    pub model: Option<String>,
}

/// Read `color` and `model` from `.claude/agents/<agent_name>.md` frontmatter.
///
/// Returns `AgentFrontmatter::default()` (all `None`) if the file is absent or has no
/// recognisable frontmatter — callers should treat missing values as "no preference".
pub fn read_agent_frontmatter(home_dir: &Path, agent_name: &str) -> AgentFrontmatter {
    let path = home_dir
        .join(".claude")
        .join("agents")
        .join(format!("{agent_name}.md"));
    let Ok(text) = std::fs::read_to_string(&path) else {
        return AgentFrontmatter::default();
    };
    parse_agent_frontmatter(&text)
}

/// Parse `color` and `model` from a YAML frontmatter block (lines between `---` delimiters).
fn parse_agent_frontmatter(text: &str) -> AgentFrontmatter {
    let mut lines = text.lines();
    if lines.next().map(str::trim) != Some("---") {
        return AgentFrontmatter::default();
    }
    let mut color = None;
    let mut model = None;
    for line in lines {
        if line.trim() == "---" {
            break;
        }
        if let Some((key, val)) = line.split_once(':') {
            let key = key.trim();
            let val = val.trim().to_string();
            if val.is_empty() {
                continue;
            }
            match key {
                "color" => color = Some(val),
                "model" => model = Some(val),
                _ => {}
            }
        }
    }
    AgentFrontmatter { color, model }
}

/// Mutable spawn review state used by the interactive panel.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnDraft {
    pub team: String,
    pub member: String,
    pub model: String,
    pub agent_type: String,
    pub pane_mode: PaneMode,
    pub worktree: Option<String>,
    pub color: Option<String>,
}

/// Parse `pane-mode` into the canonical enum.
pub fn parse_pane_mode(raw: &str) -> Result<PaneMode> {
    match raw.trim().to_ascii_lowercase().as_str() {
        "new-pane" => Ok(PaneMode::NewPane),
        "existing-pane" => Ok(PaneMode::ExistingPane),
        "current-pane" => Ok(PaneMode::CurrentPane),
        _ => Err(anyhow!(
            "invalid pane mode '{raw}'. valid: new-pane, existing-pane, current-pane"
        )),
    }
}

/// Apply one or more `n=value` edits (comma-separated) to a draft.
pub fn apply_edits(draft: &mut SpawnDraft, edits: &str) -> Result<()> {
    for pair in edits.split(',') {
        let pair = pair.trim();
        if pair.is_empty() {
            continue;
        }
        let Some((idx_raw, value_raw)) = pair.split_once('=') else {
            return Err(anyhow!(
                "invalid edit '{pair}'. expected n=value (for example: 1=atm-dev)"
            ));
        };
        let idx: u8 = idx_raw
            .trim()
            .parse()
            .map_err(|_| anyhow!("invalid field index '{idx_raw}'"))?;
        let value = value_raw.trim();
        match idx {
            1 => draft.team = value.to_string(),
            2 => draft.member = value.to_string(),
            3 => draft.model = value.to_string(),
            4 => draft.agent_type = value.to_string(),
            5 => draft.pane_mode = parse_pane_mode(value)?,
            6 => {
                if value.is_empty() || value.eq_ignore_ascii_case("(none)") {
                    draft.worktree = None;
                } else {
                    draft.worktree = Some(value.to_string());
                }
            }
            7 => {
                if value.is_empty() || value.eq_ignore_ascii_case("(none)") {
                    draft.color = None;
                } else {
                    draft.color = Some(value.to_string());
                }
            }
            _ => return Err(anyhow!("unknown field index '{idx}'. valid fields: 1..7")),
        }
    }
    Ok(())
}

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

    fn base_draft() -> SpawnDraft {
        SpawnDraft {
            team: "atm-dev".to_string(),
            member: "arch-ctm".to_string(),
            model: "unknown".to_string(),
            agent_type: "general-purpose".to_string(),
            pane_mode: PaneMode::NewPane,
            worktree: None,
            color: None,
        }
    }

    #[test]
    fn test_parse_agent_frontmatter_extracts_color_and_model() {
        let text = "---\nname: atm-monitor\nmodel: haiku\ncolor: orange\n---\nbody";
        let fm = parse_agent_frontmatter(text);
        assert_eq!(fm.color.as_deref(), Some("orange"));
        assert_eq!(fm.model.as_deref(), Some("haiku"));
    }

    #[test]
    fn test_parse_agent_frontmatter_no_frontmatter_returns_default() {
        let fm = parse_agent_frontmatter("just body text");
        assert_eq!(fm, AgentFrontmatter::default());
    }

    #[test]
    fn test_parse_agent_frontmatter_missing_fields_returns_none() {
        let text = "---\nname: arch-ctm\ndescription: something\n---\nbody";
        let fm = parse_agent_frontmatter(text);
        assert!(fm.color.is_none());
        assert!(fm.model.is_none());
    }

    #[test]
    fn test_parse_pane_mode_validation() {
        assert_eq!(parse_pane_mode("new-pane").unwrap(), PaneMode::NewPane);
        assert_eq!(
            parse_pane_mode("existing-pane").unwrap(),
            PaneMode::ExistingPane
        );
        assert_eq!(
            parse_pane_mode("current-pane").unwrap(),
            PaneMode::CurrentPane
        );
        assert!(parse_pane_mode("invalid").is_err());
    }

    #[test]
    fn test_apply_edits_parses_comma_separated() {
        let mut draft = base_draft();
        apply_edits(
            &mut draft,
            "1=atm-qa,2=quality-mgr,3=claude-haiku-4-5,5=existing-pane",
        )
        .unwrap();
        assert_eq!(draft.team, "atm-qa");
        assert_eq!(draft.member, "quality-mgr");
        assert_eq!(draft.model, "claude-haiku-4-5");
        assert_eq!(draft.pane_mode, PaneMode::ExistingPane);
    }
}