navi-notifier 0.1.5

Focused, configurable PR-review alerts from GitHub to Slack.
//! Top-level on-disk configuration for the `navi` binary.
//!
//! This composes provider auth sections with the provider-agnostic
//! [`RuleConfig`](navi_notifier_core::RuleConfig) from `navi-notifier-core`. Secrets are resolved from
//! environment variables by default (`*_env` fields) so tokens never need to sit in
//! the config file.

use std::path::{Path, PathBuf};

use anyhow::{anyhow, Context, Result};
use navi_notifier_core::RuleConfig;
use serde::{Deserialize, Serialize};

/// The full configuration tree.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
    pub general: General,
    pub github: GitHubConfig,
    pub gitlab: GitLabConfig,
    pub gitea: GiteaConfig,
    pub slack: SlackConfig,
    pub discord: DiscordConfig,
    pub email: EmailConfig,
    pub rules: RuleConfig,
    /// Source→destination wiring. Empty means "every source to every destination".
    pub routes: Vec<RouteConfig>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct General {
    /// Seconds between poll passes when running as a daemon.
    pub poll_interval_secs: u64,
    /// `tracing` filter, e.g. `"info"` or `"navi=debug,octocrab=warn"`.
    pub log_level: String,
    /// Offset from UTC in minutes, used only to evaluate quiet hours in local time
    /// (e.g. `-420` for US Pacific, `60` for CET). Determining the OS local offset
    /// reliably inside a multithreaded runtime is unsound, so we take it explicitly.
    pub utc_offset_minutes: i32,
}

impl Default for General {
    fn default() -> Self {
        Self {
            poll_interval_secs: 60,
            log_level: "info".into(),
            utc_offset_minutes: 0,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GitHubConfig {
    /// Whether the GitHub source is active.
    pub enabled: bool,
    /// Name of the environment variable holding the personal access token.
    pub token_env: String,
    /// Inline token (discouraged; prefer `token_env`). Overrides `token_env` if set.
    pub token: Option<String>,
    /// API base, override for GitHub Enterprise Server.
    pub api_base: Option<String>,
}

impl Default for GitHubConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            token_env: "NAVI_GITHUB_TOKEN".into(),
            token: None,
            api_base: None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GitLabConfig {
    /// Whether the GitLab source is active. Off by default; opt in.
    pub enabled: bool,
    pub token_env: String,
    pub token: Option<String>,
    /// API base, e.g. `https://gitlab.example.com/api/v4` for self-hosted.
    pub api_base: Option<String>,
}

impl Default for GitLabConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            token_env: "NAVI_GITLAB_TOKEN".into(),
            token: None,
            api_base: None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GiteaConfig {
    /// Whether the Gitea/Forgejo source is active. Off by default; opt in.
    pub enabled: bool,
    pub token_env: String,
    pub token: Option<String>,
    /// API base, e.g. `https://gitea.example.com/api/v1` (Gitea or Forgejo).
    pub api_base: Option<String>,
}

impl Default for GiteaConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            token_env: "NAVI_GITEA_TOKEN".into(),
            token: None,
            api_base: None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SlackConfig {
    pub enabled: bool,
    /// Name of the environment variable holding the Slack bot token (`xoxb-…`).
    pub token_env: String,
    pub token: Option<String>,
    /// DM target: a Slack user id (`U…`) or the literal `"self"` to DM the user the
    /// bot token's `auth.test` resolves to.
    pub dm_to: String,
}

impl Default for SlackConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            token_env: "NAVI_SLACK_TOKEN".into(),
            token: None,
            dm_to: "self".into(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DiscordConfig {
    /// Whether the Discord destination is active. Off by default; opt in.
    pub enabled: bool,
    /// Bot token env var (needed only for user-DM mode, not webhook mode).
    pub token_env: String,
    pub token: Option<String>,
    /// A webhook URL (`https://discord.com/api/webhooks/...`) or a user id to DM.
    pub dm_to: String,
}

impl Default for DiscordConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            token_env: "NAVI_DISCORD_TOKEN".into(),
            token: None,
            dm_to: String::new(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EmailConfig {
    /// Whether the email destination is active. Off by default; opt in.
    pub enabled: bool,
    pub smtp_host: String,
    pub smtp_port: u16,
    /// `"none"` (local sink), `"starttls"`, or `"implicit"`.
    pub tls: String,
    pub username: Option<String>,
    /// Env var holding the SMTP password.
    pub password_env: String,
    pub password: Option<String>,
    /// Sender, e.g. `navi <navi@example.com>`.
    pub from: String,
    /// Recipient, e.g. `you <you@example.com>`.
    pub to: String,
}

impl Default for EmailConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            smtp_host: String::new(),
            smtp_port: 587,
            tls: "starttls".into(),
            username: None,
            password_env: "NAVI_EMAIL_PASSWORD".into(),
            password: None,
            from: String::new(),
            to: String::new(),
        }
    }
}

impl EmailConfig {
    /// SMTP password from the inline value or env var.
    pub fn resolve_password(&self) -> Option<String> {
        if let Some(p) = self.password.as_deref().filter(|p| !p.is_empty()) {
            return Some(p.to_string());
        }
        std::env::var(&self.password_env)
            .ok()
            .filter(|v| !v.is_empty())
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteConfig {
    pub source: String,
    /// `alias` keeps configs that used the older `notifier` key working.
    #[serde(alias = "notifier")]
    pub destination: String,
}

impl GitHubConfig {
    /// Resolve the token from the inline value or the named env var.
    pub fn resolve_token(&self) -> Result<String> {
        resolve_secret("github", self.token.as_deref(), &self.token_env)
    }
}

impl GitLabConfig {
    pub fn resolve_token(&self) -> Result<String> {
        resolve_secret("gitlab", self.token.as_deref(), &self.token_env)
    }
}

impl GiteaConfig {
    pub fn resolve_token(&self) -> Result<String> {
        resolve_secret("gitea", self.token.as_deref(), &self.token_env)
    }
}

impl SlackConfig {
    pub fn resolve_token(&self) -> Result<String> {
        resolve_secret("slack", self.token.as_deref(), &self.token_env)
    }
}

impl DiscordConfig {
    /// Optional token from the inline value or env var. `None` in webhook mode.
    pub fn resolve_token(&self) -> Option<String> {
        if let Some(t) = self.token.as_deref().filter(|t| !t.is_empty()) {
            return Some(t.to_string());
        }
        std::env::var(&self.token_env)
            .ok()
            .filter(|v| !v.is_empty())
    }
}

fn resolve_secret(what: &str, inline: Option<&str>, env_var: &str) -> Result<String> {
    if let Some(tok) = inline.filter(|t| !t.is_empty()) {
        return Ok(tok.to_string());
    }
    let val = std::env::var(env_var).map_err(|_| {
        anyhow!("{what} token not found: set env var `{env_var}` (or the inline `token` field)")
    })?;
    if val.is_empty() {
        return Err(anyhow!("{what} token env var `{env_var}` is empty"));
    }
    Ok(val)
}

impl Config {
    /// Load and parse the config file at `path`.
    pub fn load(path: &Path) -> Result<Self> {
        let text = std::fs::read_to_string(path)
            .with_context(|| format!("reading config at {}", path.display()))?;
        let cfg: Config = toml::from_str(&text)
            .with_context(|| format!("parsing config at {}", path.display()))?;
        Ok(cfg)
    }

    /// Convert config routes into engine routes.
    pub fn engine_routes(&self) -> Vec<navi_notifier_core::Route> {
        self.routes
            .iter()
            .map(|r| navi_notifier_core::Route {
                source: r.source.clone(),
                destination: r.destination.clone(),
            })
            .collect()
    }
}

/// Resolve the config file path: explicit `--config`, else the platform config dir
/// (`~/.config/navi/config.toml` on Linux).
pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
    if let Some(p) = explicit {
        return Ok(p);
    }
    let dirs = directories::ProjectDirs::from("dev", "navi", "navi")
        .ok_or_else(|| anyhow!("could not determine a config directory for this platform"))?;
    Ok(dirs.config_dir().join("config.toml"))
}

/// Resolve the state (database) file path under the platform data dir.
pub fn resolve_state_path() -> Result<PathBuf> {
    let dirs = directories::ProjectDirs::from("dev", "navi", "navi")
        .ok_or_else(|| anyhow!("could not determine a data directory for this platform"))?;
    Ok(dirs.data_dir().join("navi.sqlite3"))
}