agent-notify-core 0.2.0

Core notification config and provider implementation for agent-notify.
Documentation
use std::path::PathBuf;

use thiserror::Error;

pub type Result<T> = std::result::Result<T, NotifyError>;

#[derive(Debug, Error)]
pub enum NotifyError {
    #[error("configuration file not found")]
    ConfigNotFound,
    #[error("failed to read configuration file {path}: {source}")]
    ConfigRead {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error("failed to parse configuration file {path}: {source}")]
    ConfigParse {
        path: PathBuf,
        source: toml::de::Error,
    },
    #[error("channel \"{0}\" was not found")]
    ChannelNotFound(String),
    #[error("default_channel is not configured")]
    DefaultChannelMissing,
    #[error("{0}")]
    InvalidInput(String),
    #[error("{0}")]
    Validation(String),
    #[error("channel \"{channel}\" is missing environment variable {env}")]
    MissingEnv { channel: String, env: String },
    #[error("channel type \"{0}\" is not available for sending yet")]
    UnsupportedProvider(String),
    #[error("channel type \"{channel_type}\" does not support attachments")]
    UnsupportedAttachment { channel_type: String },
    #[error("HTTP request failed: {0}")]
    Http(#[source] reqwest::Error),
    #[error("provider request failed: {0}")]
    Provider(String),
    #[error("I/O error at {path}: {source}")]
    Io {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error("failed to serialize JSON output: {0}")]
    Json(#[from] serde_json::Error),
}

impl From<reqwest::Error> for NotifyError {
    fn from(source: reqwest::Error) -> Self {
        Self::Http(source.without_url())
    }
}

impl NotifyError {
    pub fn code(&self) -> &'static str {
        match self {
            Self::ConfigNotFound => "CONFIG_NOT_FOUND",
            Self::ConfigRead { .. } => "CONFIG_READ",
            Self::ConfigParse { .. } => "CONFIG_PARSE",
            Self::ChannelNotFound(_) => "CHANNEL_NOT_FOUND",
            Self::DefaultChannelMissing => "DEFAULT_CHANNEL_MISSING",
            Self::InvalidInput(_) => "INVALID_INPUT",
            Self::Validation(_) => "VALIDATION",
            Self::MissingEnv { .. } => "MISSING_ENV",
            Self::UnsupportedProvider(_) => "UNSUPPORTED_PROVIDER",
            Self::UnsupportedAttachment { .. } => "UNSUPPORTED_ATTACHMENT",
            Self::Http(_) => "HTTP",
            Self::Provider(_) => "PROVIDER",
            Self::Io { .. } => "IO",
            Self::Json(_) => "JSON",
        }
    }
}

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

    #[tokio::test]
    async fn reqwest_errors_do_not_leak_request_url() {
        let secret_url = "http://127.0.0.1:1/discord-webhook/secret-token";
        let reqwest_error = reqwest::Client::new()
            .post(secret_url)
            .send()
            .await
            .unwrap_err();

        assert!(reqwest_error.to_string().contains(secret_url));

        let notify_error = NotifyError::from(reqwest_error);
        let message = notify_error.to_string();

        assert!(message.contains("HTTP request failed"));
        assert!(!message.contains(secret_url));
        assert!(!message.contains("secret-token"));
    }
}