agent-notify-core 0.1.0

Core notification config and provider implementation for agent-notify.
Documentation
use std::{
    fmt,
    fs::File,
    io::Read,
    path::{Path, PathBuf},
    str::FromStr,
};

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use time::OffsetDateTime;
use ulid::Ulid;

use crate::{NotifyError, Result};

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Priority {
    #[default]
    Info,
    Success,
    Warning,
    Error,
    Critical,
}

impl fmt::Display for Priority {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let value = match self {
            Self::Info => "info",
            Self::Success => "success",
            Self::Warning => "warning",
            Self::Error => "error",
            Self::Critical => "critical",
        };
        f.write_str(value)
    }
}

impl FromStr for Priority {
    type Err = NotifyError;

    fn from_str(value: &str) -> Result<Self> {
        match value {
            "info" => Ok(Self::Info),
            "success" => Ok(Self::Success),
            "warning" => Ok(Self::Warning),
            "error" => Ok(Self::Error),
            "critical" => Ok(Self::Critical),
            _ => Err(NotifyError::InvalidInput(format!(
                "invalid priority \"{value}\""
            ))),
        }
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageFormat {
    #[default]
    Text,
    Markdown,
}

impl fmt::Display for MessageFormat {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let value = match self {
            Self::Text => "text",
            Self::Markdown => "markdown",
        };
        f.write_str(value)
    }
}

impl FromStr for MessageFormat {
    type Err = NotifyError;

    fn from_str(value: &str) -> Result<Self> {
        match value {
            "text" => Ok(Self::Text),
            "markdown" => Ok(Self::Markdown),
            _ => Err(NotifyError::InvalidInput(format!(
                "invalid format \"{value}\""
            ))),
        }
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct NotifyMessage {
    pub id: String,
    #[serde(with = "time::serde::rfc3339")]
    pub timestamp: OffsetDateTime,
    pub title: String,
    pub body: Option<String>,
    pub format: MessageFormat,
    pub priority: Priority,
    pub tags: Vec<String>,
    pub attachments: Vec<Attachment>,
}

impl NotifyMessage {
    pub fn new(
        title: String,
        body: Option<String>,
        format: MessageFormat,
        priority: Priority,
        tags: Vec<String>,
        attachments: Vec<Attachment>,
    ) -> Result<Self> {
        if title.trim().is_empty() {
            return Err(NotifyError::InvalidInput("title required".to_string()));
        }
        if body.as_deref().unwrap_or("").is_empty() && attachments.is_empty() {
            return Err(NotifyError::InvalidInput(
                "body or file required".to_string(),
            ));
        }

        Ok(Self {
            id: Ulid::new().to_string(),
            timestamp: OffsetDateTime::now_utc(),
            title,
            body,
            format,
            priority,
            tags,
            attachments,
        })
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct Attachment {
    pub path: PathBuf,
    pub name: String,
    pub mime_type: String,
    pub size_bytes: u64,
    pub sha256: String,
}

impl Attachment {
    pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
        let path = path.as_ref();
        let metadata = std::fs::metadata(path).map_err(|source| NotifyError::Io {
            path: path.to_path_buf(),
            source,
        })?;

        if metadata.is_dir() {
            return Err(NotifyError::InvalidInput(format!(
                "{} is a directory, not a file",
                path.display()
            )));
        }

        let name = path
            .file_name()
            .and_then(|value| value.to_str())
            .ok_or_else(|| {
                NotifyError::InvalidInput(format!("invalid attachment path {}", path.display()))
            })?
            .to_string();
        let mime_type = mime_guess::from_path(path)
            .first_or_octet_stream()
            .essence_str()
            .to_string();
        let sha256 = sha256_file(path)?;

        Ok(Self {
            path: path.to_path_buf(),
            name,
            mime_type,
            size_bytes: metadata.len(),
            sha256,
        })
    }
}

fn sha256_file(path: &Path) -> Result<String> {
    let mut file = File::open(path).map_err(|source| NotifyError::Io {
        path: path.to_path_buf(),
        source,
    })?;
    let mut hasher = Sha256::new();
    let mut buffer = [0_u8; 8192];

    loop {
        let read = file.read(&mut buffer).map_err(|source| NotifyError::Io {
            path: path.to_path_buf(),
            source,
        })?;
        if read == 0 {
            break;
        }
        hasher.update(&buffer[..read]);
    }

    Ok(format!("{:x}", hasher.finalize()))
}

#[cfg(test)]
mod tests {
    use std::fs;

    use tempfile::tempdir;

    use super::*;

    #[test]
    fn message_rejects_blank_title() {
        let error = NotifyMessage::new(
            "   ".to_string(),
            Some("body".to_string()),
            MessageFormat::Text,
            Priority::Info,
            Vec::new(),
            Vec::new(),
        )
        .unwrap_err();

        assert_eq!(error.code(), "INVALID_INPUT");
        assert_eq!(error.to_string(), "title required");
    }

    #[test]
    fn attachment_from_path_records_metadata() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("report.txt");
        fs::write(&path, "hello").unwrap();

        let attachment = Attachment::from_path(&path).unwrap();

        assert_eq!(attachment.name, "report.txt");
        assert_eq!(attachment.mime_type, "text/plain");
        assert_eq!(attachment.size_bytes, 5);
        assert_eq!(
            attachment.sha256,
            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
        );
    }

    #[test]
    fn attachment_rejects_directories() {
        let dir = tempdir().unwrap();

        let error = Attachment::from_path(dir.path()).unwrap_err();

        assert_eq!(error.code(), "INVALID_INPUT");
        assert!(error.to_string().contains("is a directory"));
    }
}