agent-team-mail-core 1.3.0

Core library for local agent team mail workflows.
Documentation
use std::collections::BTreeMap;

use crate::boundary::{
    BuiltInNudgeTemplateKind, PostSendHookEvent, ResolvedBuiltInNudgeTemplate,
    TeamNudgeTemplateOverrideRow,
};
use crate::error::AtmError;

pub fn resolve_template(
    override_row: Option<TeamNudgeTemplateOverrideRow>,
    kind: BuiltInNudgeTemplateKind,
) -> ResolvedBuiltInNudgeTemplate {
    let body = match override_row {
        Some(row) => row.template_body().map(ToOwned::to_owned),
        None => Some(default_template(kind).to_string()),
    };
    ResolvedBuiltInNudgeTemplate { kind, body }
}

pub fn resolve_template_body(
    override_row: Option<TeamNudgeTemplateOverrideRow>,
    kind: BuiltInNudgeTemplateKind,
) -> Option<String> {
    resolve_template(override_row, kind).body
}

pub fn qualified_sender_identity(event: &PostSendHookEvent) -> String {
    format!("{}@{}", event.sender, event.sender_team)
}

pub fn render_resolved_built_in_nudge(
    event: &PostSendHookEvent,
    template: &ResolvedBuiltInNudgeTemplate,
) -> Result<Option<String>, AtmError> {
    let Some(template_body) = template.body.as_deref() else {
        return Ok(None);
    };
    render_template(template_body, &render_values(event)).map(Some)
}

pub fn render_template_body(
    event: &PostSendHookEvent,
    template_body: &str,
) -> Result<String, AtmError> {
    render_template(template_body, &render_values(event))
}

pub fn render_built_in_nudge(
    event: &PostSendHookEvent,
    template_body: &str,
) -> Result<String, AtmError> {
    render_template_body(event, template_body)
}

fn render_values(event: &PostSendHookEvent) -> BTreeMap<&'static str, String> {
    BTreeMap::from([
        ("from", qualified_sender_identity(event)),
        ("team", event.recipient_team.to_string()),
        ("message_id", event.message_id.to_string()),
        ("description", event.description.clone()),
        (
            "task_id",
            event
                .task_id
                .as_ref()
                .map(ToString::to_string)
                .unwrap_or_default(),
        ),
    ])
}

pub fn default_template(kind: BuiltInNudgeTemplateKind) -> &'static str {
    match kind {
        BuiltInNudgeTemplateKind::Delivery => {
            "<atm from=\"{{from}}\" message-id=\"{{message_id}}\">\n  <action>read atm --team {{team}}</action>\n  <description>{{description}}</description>\n  <action>execute the assigned task</action>\n  <when idle=\"immediate\" busy=\"after-current-task\"/>\n  <console announce=\"concise\" pause=\"false\"/>\n</atm>"
        }
        BuiltInNudgeTemplateKind::DeliveryAck => {
            "<atm from=\"{{from}}\" message-id=\"{{message_id}}\">\n  <action>read atm --team {{team}}</action>\n  <action>ack the message</action>\n  <description>{{description}}</description>\n  <action>execute the assigned task</action>\n  <when idle=\"immediate\" busy=\"after-current-task\"/>\n  <console announce=\"concise\" pause=\"false\"/>\n</atm>"
        }
        BuiltInNudgeTemplateKind::DeliveryTask => {
            "<atm from=\"{{from}}\" message-id=\"{{message_id}}\">\n  <action>read atm --team {{team}}</action>\n  <task id=\"{{task_id}}\">{{description}}</task>\n  <action>execute the assigned task</action>\n  <when idle=\"immediate\" busy=\"after-current-task\"/>\n  <console announce=\"concise\" pause=\"false\"/>\n</atm>"
        }
        BuiltInNudgeTemplateKind::DeliveryTaskAck => {
            "<atm from=\"{{from}}\" message-id=\"{{message_id}}\">\n  <action>read atm --team {{team}}</action>\n  <action>ack the message</action>\n  <task id=\"{{task_id}}\">{{description}}</task>\n  <action>execute the assigned task</action>\n  <when idle=\"immediate\" busy=\"after-current-task\"/>\n  <console announce=\"concise\" pause=\"false\"/>\n</atm>"
        }
        BuiltInNudgeTemplateKind::Acknowledge => {
            "<atm kind=\"ack\" from=\"{{from}}\" message-id=\"{{message_id}}\"/>"
        }
        BuiltInNudgeTemplateKind::AcknowledgeTask => {
            "<atm kind=\"ack\" from=\"{{from}}\" message-id=\"{{message_id}}\" task-id=\"{{task_id}}\"/>"
        }
    }
}

fn render_template(
    template: &str,
    values: &BTreeMap<&'static str, String>,
) -> Result<String, AtmError> {
    if template.contains("{%") || template.contains("%}") {
        return Err(AtmError::validation(
            "built-in nudge templates do not support Jinja or conditional blocks",
        )
        .with_recovery(
            "Use only the documented placeholder tokens in the stored template body before retrying built-in nudge rendering.",
        ));
    }

    let mut output = String::with_capacity(template.len());
    let mut rest = template;
    while let Some(start) = rest.find("{{") {
        output.push_str(&rest[..start]);
        let after_start = &rest[start + 2..];
        let Some(end) = after_start.find("}}") else {
            return Err(AtmError::validation("unterminated built-in nudge placeholder")
                .with_recovery(
                    "Close every built-in nudge placeholder with `}}` before retrying template rendering.",
                ));
        };
        let key = after_start[..end].trim();
        let Some(value) = values.get(key) else {
            return Err(AtmError::validation(format!(
                "unsupported built-in nudge placeholder `{{{{{key}}}}}`"
            ))
            .with_recovery(
                "Use only {{from}}, {{team}}, {{message_id}}, {{description}}, and {{task_id}} in built-in nudge templates.",
            ));
        };
        output.push_str(value);
        rest = &after_start[end + 2..];
    }
    output.push_str(rest);
    Ok(output)
}

#[cfg(test)]
mod tests {
    use super::{
        default_template, qualified_sender_identity, render_built_in_nudge, resolve_template,
        resolve_template_body,
    };
    use crate::boundary::{
        BuiltInNudgeTemplateKind, PostSendHookEvent, ResolvedBuiltInNudgeTemplate,
        TeamNudgeTemplateOverrideMode, TeamNudgeTemplateOverrideRow,
    };
    use crate::test_support::{TEST_ARCH_CTM, TEST_LEAD, TEST_TEAM};
    use crate::types::{AgentName, IsoTimestamp, PaneId, TeamName};

    fn base_event() -> PostSendHookEvent {
        PostSendHookEvent {
            sender: AgentName::from_validated(TEST_LEAD),
            sender_team: TeamName::from_validated(TEST_TEAM),
            recipient: AgentName::from_validated(TEST_ARCH_CTM),
            recipient_team: TeamName::from_validated(TEST_TEAM),
            message_id: "01KX1TEST00000000000000000".parse().expect("message id"),
            description: "review failing smoke lane".to_string(),
            requires_ack: false,
            is_ack: false,
            task_id: None,
            recipient_pane_id: Some(PaneId::from_cli("%9").expect("pane")),
        }
    }

    #[test]
    fn resolve_template_body_uses_default_when_no_override_exists() {
        assert_eq!(
            resolve_template_body(None, BuiltInNudgeTemplateKind::Delivery),
            Some(default_template(BuiltInNudgeTemplateKind::Delivery).to_string())
        );
    }

    #[test]
    fn resolve_template_body_treats_disabled_override_as_disabled() {
        let row = TeamNudgeTemplateOverrideRow {
            team_name: TeamName::from_validated(TEST_TEAM),
            kind: BuiltInNudgeTemplateKind::Delivery,
            mode: TeamNudgeTemplateOverrideMode::Disabled,
            updated_at: IsoTimestamp::now(),
        };
        assert_eq!(
            resolve_template_body(Some(row), BuiltInNudgeTemplateKind::Delivery),
            None
        );
    }

    #[test]
    fn resolve_template_uses_explicit_override_body() {
        let row = TeamNudgeTemplateOverrideRow {
            team_name: TeamName::from_validated(TEST_TEAM),
            kind: BuiltInNudgeTemplateKind::DeliveryAck,
            mode: TeamNudgeTemplateOverrideMode::Override {
                template_body: "<atm kind=\"override\"/>".to_string(),
            },
            updated_at: IsoTimestamp::now(),
        };
        assert_eq!(
            resolve_template(Some(row), BuiltInNudgeTemplateKind::DeliveryAck),
            ResolvedBuiltInNudgeTemplate {
                kind: BuiltInNudgeTemplateKind::DeliveryAck,
                body: Some("<atm kind=\"override\"/>".to_string()),
            }
        );
    }

    #[test]
    fn qualified_sender_identity_uses_sender_and_team() {
        assert_eq!(
            qualified_sender_identity(&base_event()),
            format!("{TEST_LEAD}@{TEST_TEAM}")
        );
    }

    #[test]
    fn render_built_in_nudge_populates_placeholders() {
        let rendered = render_built_in_nudge(
            &base_event(),
            default_template(BuiltInNudgeTemplateKind::DeliveryTaskAck),
        )
        .expect("rendered template");
        assert!(rendered.contains(&format!("{TEST_LEAD}@{TEST_TEAM}")));
        assert!(rendered.contains("01KX1TEST00000000000000000"));
    }
}