git-next-core 0.14.1

core for git-next, the trunk-based development manager
Documentation
//

use std::{
    collections::BTreeMap,
    net::SocketAddr,
    ops::Deref,
    path::{Path, PathBuf},
    str::FromStr,
};

use derive_more::{Constructor, Display};
use kxio::fs::FileSystem;
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use tracing::info;

use crate::{
    config::{ForgeAlias, ForgeConfig, RepoAlias},
    newtype, s,
};

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("fs: {0}")]
    KxioFs(#[from] kxio::fs::Error),

    #[error("deserialise toml: {0}")]
    TomlDe(#[from] toml::de::Error),

    #[error("parse IP addres/port: {0}")]
    AddressParse(#[from] std::net::AddrParseError),
}

type Result<T> = core::result::Result<T, Error>;

/// Mapped from the `git-next-server.toml` file
#[derive(
    Clone,
    Debug,
    derive_more::From,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    derive_more::AsRef,
    serde::Deserialize,
    derive_more::Constructor,
)]
pub struct AppConfig {
    listen: Listen,
    shout: Shout,
    storage: Storage,
    pub forge: BTreeMap<String, ForgeConfig>,
}
impl AppConfig {
    #[tracing::instrument(skip_all)]
    pub fn load(fs: &FileSystem) -> Result<Self> {
        let file = fs.base().join("git-next-server.toml");
        info!(?file, "");
        let str = fs.file(&file).reader()?;
        Ok(toml::from_str(&s!(str))?)
    }

    pub fn forges(&self) -> impl Iterator<Item = (ForgeAlias, &ForgeConfig)> {
        self.forge
            .iter()
            .map(|(alias, forge)| (ForgeAlias::new(alias.clone()), forge))
    }

    #[must_use]
    pub const fn storage(&self) -> &Storage {
        &self.storage
    }

    #[must_use]
    pub const fn shout(&self) -> &Shout {
        &self.shout
    }

    #[must_use]
    pub const fn listen(&self) -> &Listen {
        &self.listen
    }

    /// Returns the `SocketAddr` to listen to for incoming webhooks.
    ///
    /// # Errors
    ///
    /// Will return an `Err` if the IP address or port from the config file are invalid.
    pub fn listen_socket_addr(&self) -> Result<SocketAddr> {
        self.listen.http.socket_addr()
    }
}

/// Defines how the server receives webhook notifications from forges.
#[derive(
    Clone,
    Debug,
    derive_more::From,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    derive_more::AsRef,
    serde::Deserialize,
    derive_more::Constructor,
)]
pub struct Listen {
    http: Http,
    url: ListenUrl,
}
impl Listen {
    // /// Returns the URL a Repo will listen to for updates from the Forge
    // pub fn repo_url(&self, forge_alias: ForgeAlias, repo_alias: RepoAlias) -> RepoListenUrl {
    //     self.url.repo_url(forge_alias, repo_alias)
    // }

    #[must_use]
    pub const fn url(&self) -> &ListenUrl {
        &self.url
    }
}

newtype!(
    ListenUrl,
    String,
    Serialize,
    Deserialize,
    PartialOrd,
    Ord,
    Display,
    "The base url for receiving all webhooks from all forges"
);
impl ListenUrl {
    #[must_use]
    pub fn repo_url(&self, forge_alias: ForgeAlias, repo_alias: RepoAlias) -> RepoListenUrl {
        RepoListenUrl::new((self.clone(), forge_alias, repo_alias))
    }
}

newtype!(ForgeWebhookUrl, String, "Raw URL from a forge Webhook");

newtype!(
    RepoListenUrl,
    (ListenUrl, ForgeAlias, RepoAlias),
    "URL to listen for webhook from forges"
);
impl Display for RepoListenUrl {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{}/{}/{}",
            self.deref().0,
            self.deref().1,
            self.deref().2
        )
    }
}
impl From<RepoListenUrl> for ForgeWebhookUrl {
    fn from(value: RepoListenUrl) -> Self {
        Self::new(s!(value))
    }
}

/// Defines the port the server will listen to for incoming webhooks messages
#[derive(
    Clone,
    Debug,
    derive_more::From,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    derive_more::AsRef,
    serde::Deserialize,
    derive_more::Constructor,
)]
pub struct Http {
    addr: String,
    port: u16,
}
impl Http {
    fn socket_addr(&self) -> Result<SocketAddr> {
        Ok(SocketAddr::from_str(&format!(
            "{}:{}",
            self.addr, self.port
        ))?)
    }
}

/// The directory to store server data, such as cloned repos
#[derive(
    Clone,
    Debug,
    derive_more::From,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    derive_more::AsRef,
    serde::Deserialize,
    derive_more::Constructor,
)]
pub struct Storage {
    path: PathBuf,
}
impl Storage {
    #[must_use]
    pub fn path(&self) -> &Path {
        self.path.as_path()
    }
}

/// Defines the Webhook Forges should send updates to
/// Must be an address that is accessible from the remote forge
#[derive(
    Clone,
    Debug,
    Default,
    derive_more::From,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    derive_more::AsRef,
    serde::Deserialize,
    Constructor,
)]
pub struct Shout {
    webhook: Option<OutboundWebhook>,
    email: Option<EmailConfig>,
    desktop: Option<bool>,
}
impl Shout {
    #[must_use]
    pub const fn webhook(&self) -> Option<&OutboundWebhook> {
        self.webhook.as_ref()
    }

    #[cfg(test)]
    pub(crate) fn webhook_url(&self) -> Option<String> {
        self.webhook.clone().map(|x| x.url)
    }

    pub fn webhook_secret(&self) -> Option<SecretString> {
        self.webhook
            .clone()
            .map(|x| x.secret)
            .map(SecretString::from)
    }

    #[must_use]
    pub const fn email(&self) -> Option<&EmailConfig> {
        self.email.as_ref()
    }

    #[must_use]
    pub const fn desktop(&self) -> Option<bool> {
        self.desktop
    }
}

#[derive(
    Clone,
    Debug,
    derive_more::From,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    serde::Deserialize,
    derive_more::Constructor,
)]
pub struct OutboundWebhook {
    url: String,
    secret: String,
}
impl OutboundWebhook {
    #[must_use]
    pub fn url(&self) -> &str {
        self.url.as_ref()
    }
    #[must_use]
    pub fn secret(&self) -> SecretString {
        SecretString::from(self.secret.clone())
    }
}

#[derive(
    Clone,
    Debug,
    derive_more::From,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    serde::Deserialize,
    derive_more::Constructor,
)]
pub struct EmailConfig {
    from: String,
    to: String,
    // email will be sent via sendmail, unless smtp is specified
    smtp: Option<SmtpConfig>,
}
impl EmailConfig {
    #[must_use]
    pub fn from(&self) -> &str {
        &self.from
    }

    #[must_use]
    pub fn to(&self) -> &str {
        &self.to
    }

    #[must_use]
    pub const fn smtp(&self) -> Option<&SmtpConfig> {
        self.smtp.as_ref()
    }
}

#[derive(
    Clone,
    Debug,
    derive_more::From,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    serde::Deserialize,
    derive_more::Constructor,
)]
pub struct SmtpConfig {
    hostname: String,
    username: String,
    password: String,
}
impl SmtpConfig {
    #[must_use]
    pub fn username(&self) -> &str {
        &self.username
    }

    #[must_use]
    pub fn password(&self) -> &str {
        &self.password
    }

    #[must_use]
    pub fn hostname(&self) -> &str {
        &self.hostname
    }
}

#[cfg(test)]
mod tests {
    const fn is_sendable<T: Send>() {}

    #[test]
    const fn normal() {
        is_sendable::<super::AppConfig>();
    }
}