agent-notify-core 0.1.0

Core notification config and provider implementation for agent-notify.
Documentation
use std::{fs, path::Path};

use reqwest::{StatusCode, multipart};

use crate::{Attachment, NotifyError, NotifyMessage, Result};

pub(super) fn multipart_part(attachment: &Attachment, file_name: &str) -> Result<multipart::Part> {
    let bytes = fs::read(&attachment.path).map_err(|source| NotifyError::Io {
        path: attachment.path.clone(),
        source,
    })?;
    Ok(multipart::Part::bytes(bytes)
        .file_name(file_name.to_string())
        .mime_str(&attachment.mime_type)?)
}

pub(super) fn render_message(message: &NotifyMessage) -> String {
    match message.body.as_deref() {
        Some(body) if !body.is_empty() => {
            format!("[{}] {}\n\n{body}", message.priority, message.title)
        }
        _ => format!("[{}] {}", message.priority, message.title),
    }
}

pub(super) fn resolve_required_secret(
    channel_name: &str,
    field: &str,
    inline: &Option<String>,
    env_name: &Option<String>,
) -> Result<String> {
    resolve_secret(channel_name, field, inline, env_name, true)?.ok_or_else(|| {
        NotifyError::Validation(format!(
            "channel \"{channel_name}\" is missing {field} or {field}_env"
        ))
    })
}

pub(super) fn resolve_optional_secret(
    channel_name: &str,
    field: &str,
    inline: &Option<String>,
    env_name: &Option<String>,
) -> Result<Option<String>> {
    resolve_secret(channel_name, field, inline, env_name, false)
}

fn resolve_secret(
    channel_name: &str,
    field: &str,
    inline: &Option<String>,
    env_name: &Option<String>,
    required: bool,
) -> Result<Option<String>> {
    match (inline.as_deref(), env_name.as_deref()) {
        (Some(_), Some(_)) => Err(NotifyError::Validation(format!(
            "channel \"{channel_name}\" {field} and {field}_env cannot be set at the same time"
        ))),
        (Some(value), None) => Ok(Some(value.to_string())),
        (None, Some(env_name)) => {
            std::env::var(env_name)
                .map(Some)
                .map_err(|_| NotifyError::MissingEnv {
                    channel: channel_name.to_string(),
                    env: env_name.to_string(),
                })
        }
        (None, None) if required => Err(NotifyError::Validation(format!(
            "channel \"{channel_name}\" is missing {field} or {field}_env"
        ))),
        (None, None) => Ok(None),
    }
}

pub(super) fn ensure_success(provider: &str, status: StatusCode, body: String) -> Result<()> {
    if status.is_success() {
        Ok(())
    } else {
        Err(NotifyError::Provider(format!(
            "{provider} returned HTTP {status}: {}",
            trim_response_body(&body)
        )))
    }
}

pub(super) fn trim_response_body(body: &str) -> String {
    const MAX_LEN: usize = 240;
    let body = body.trim();
    if body.len() > MAX_LEN {
        let end = body
            .char_indices()
            .map(|(index, _)| index)
            .take_while(|index| *index <= MAX_LEN)
            .last()
            .unwrap_or(0);
        format!("{}...", &body[..end])
    } else {
        body.to_string()
    }
}

pub(super) fn path_to_string(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}

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

    #[test]
    fn secret_resolution_does_not_leak_value_in_conflict_error() {
        let error = resolve_required_secret(
            "team",
            "webhook_url",
            &Some("super-secret".to_string()),
            &Some("NOTIFY_SECRET".to_string()),
        )
        .unwrap_err();

        assert!(!error.to_string().contains("super-secret"));
        assert!(error.to_string().contains("webhook_url_env"));
    }

    #[test]
    fn response_body_trimming_keeps_utf8_boundaries() {
        let body = "상태".repeat(100);
        let trimmed = trim_response_body(&body);

        assert!(trimmed.ends_with("..."));
        assert!(trimmed.len() <= 243);
    }
}