use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use navi_notifier_core::{Backfill, RuleConfig};
use serde::{Deserialize, Serialize};
#[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,
pub routes: Vec<RouteConfig>,
pub digest: DigestConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DigestConfig {
pub enabled: bool,
pub interval_secs: u64,
pub kinds: Vec<String>,
}
impl Default for DigestConfig {
fn default() -> Self {
Self {
enabled: false,
interval_secs: 3600,
kinds: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct General {
pub poll_interval_secs: u64,
pub log_level: String,
pub utc_offset_minutes: i32,
pub comment_min_age_secs: u64,
pub backfill: Backfill,
}
impl Default for General {
fn default() -> Self {
Self {
poll_interval_secs: 60,
log_level: "info".into(),
utc_offset_minutes: 0,
comment_min_age_secs: 0,
backfill: Backfill::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GitHubConfig {
pub enabled: bool,
pub token_env: String,
pub token: Option<String>,
pub api_base: Option<String>,
pub track_prs: bool,
pub mark_read: bool,
}
impl Default for GitHubConfig {
fn default() -> Self {
Self {
enabled: false,
token_env: "NAVI_GITHUB_TOKEN".into(),
token: None,
track_prs: true,
mark_read: false,
api_base: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GitLabConfig {
pub enabled: bool,
pub token_env: String,
pub token: Option<String>,
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 {
pub enabled: bool,
pub token_env: String,
pub token: Option<String>,
pub api_base: Option<String>,
pub track_prs: bool,
}
impl Default for GiteaConfig {
fn default() -> Self {
Self {
enabled: false,
token_env: "NAVI_GITEA_TOKEN".into(),
token: None,
api_base: None,
track_prs: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SlackConfig {
pub enabled: bool,
pub token_env: String,
pub token: Option<String>,
pub dm_to: String,
pub broadcast: Vec<String>,
}
impl Default for SlackConfig {
fn default() -> Self {
Self {
enabled: false,
token_env: "NAVI_SLACK_TOKEN".into(),
token: None,
dm_to: "self".into(),
broadcast: vec![
"merged".into(),
"closed".into(),
"review_dismissed".into(),
"review_approved".into(),
"review_changes_requested".into(),
],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DiscordConfig {
pub enabled: bool,
pub token_env: String,
pub token: Option<String>,
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 {
pub enabled: bool,
pub smtp_host: String,
pub smtp_port: u16,
pub tls: String,
pub username: Option<String>,
pub password_env: String,
pub password: Option<String>,
pub from: String,
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 {
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,
#[serde(alias = "notifier")]
pub destination: String,
#[serde(default)]
pub repos: Vec<String>,
#[serde(default)]
pub fallback: bool,
}
impl GitHubConfig {
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 {
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 {
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)
}
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(),
repos: r.repos.clone(),
fallback: r.fallback,
})
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Clone)]
pub struct Finding {
pub severity: Severity,
pub message: String,
}
impl Finding {
fn error(message: impl Into<String>) -> Self {
Self {
severity: Severity::Error,
message: message.into(),
}
}
fn warning(message: impl Into<String>) -> Self {
Self {
severity: Severity::Warning,
message: message.into(),
}
}
}
const SOURCE_IDS: [&str; 3] = ["github", "gitlab", "gitea"];
const DESTINATION_IDS: [&str; 3] = ["slack", "discord", "email"];
const KNOWN_TAGS: [&str; 14] = [
"review_requested",
"re_review_requested",
"review_submitted",
"review_dismissed",
"comment_reply",
"mentioned",
"merged",
"closed",
"ready_for_review",
"entered_merge_queue",
"removed_merge_queue",
"review_approved",
"review_changes_requested",
"review_commented",
];
pub fn validate(config: &Config) -> Vec<Finding> {
let mut out = Vec::new();
for r in &config.routes {
if !SOURCE_IDS.contains(&r.source.as_str()) {
out.push(Finding::error(format!(
"route source `{}` is not a known source (github|gitlab|gitea)",
r.source
)));
} else if !source_enabled(config, &r.source) {
out.push(Finding::warning(format!(
"route source `{}` is disabled; the route stays inert until you enable it",
r.source
)));
}
if !DESTINATION_IDS.contains(&r.destination.as_str()) {
out.push(Finding::error(format!(
"route destination `{}` is not a known destination (slack|discord|email)",
r.destination
)));
} else if !dest_enabled(config, &r.destination) {
out.push(Finding::warning(format!(
"route sends to `{}`, which is disabled; those events are dropped",
r.destination
)));
}
for pat in &r.repos {
if !is_valid_repo_glob(pat) {
out.push(Finding::error(format!(
"route repo glob `{pat}` is malformed (expected owner/name, e.g. acme/*)"
)));
}
}
}
if !config.routes.is_empty() {
for id in SOURCE_IDS {
if source_enabled(config, id) && !config.routes.iter().any(|r| r.source == id) {
out.push(Finding::warning(format!(
"source `{id}` is enabled but no route sends its events anywhere"
)));
}
}
}
if config.email.enabled {
for (field, val) in [
("smtp_host", &config.email.smtp_host),
("from", &config.email.from),
("to", &config.email.to),
] {
if val.trim().is_empty() {
out.push(Finding::error(format!(
"email is enabled but email.{field} is empty"
)));
}
}
}
if config.slack.enabled && config.slack.dm_to.trim().is_empty() {
out.push(Finding::error(
"slack is enabled but slack.dm_to is empty".to_string(),
));
}
if config.discord.enabled && config.discord.dm_to.trim().is_empty() {
out.push(Finding::error(
"discord is enabled but discord.dm_to is empty (set a webhook URL or user id)"
.to_string(),
));
}
if config.gitea.enabled
&& config
.gitea
.api_base
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
out.push(Finding::error(
"gitea is enabled but gitea.api_base is unset (needs …/api/v1)".to_string(),
));
}
for pat in config
.rules
.repos
.allow
.iter()
.chain(&config.rules.repos.deny)
{
if !is_valid_repo_glob(pat) {
out.push(Finding::error(format!(
"rules.repos glob `{pat}` is malformed (expected owner/name)"
)));
}
}
if config.rules.quiet_hours.enabled {
for (field, val) in [
("start", &config.rules.quiet_hours.start),
("end", &config.rules.quiet_hours.end),
] {
if !is_hhmm(val) {
out.push(Finding::error(format!(
"rules.quiet_hours.{field} `{val}` is not a HH:MM time"
)));
}
}
}
for tag in &config.slack.broadcast {
if !KNOWN_TAGS.contains(&tag.as_str()) {
out.push(Finding::warning(format!(
"slack.broadcast has unknown tag `{tag}`; it will never match"
)));
}
}
if config.digest.enabled {
for tag in &config.digest.kinds {
if !KNOWN_TAGS.contains(&tag.as_str()) {
out.push(Finding::warning(format!(
"digest.kinds has unknown tag `{tag}`"
)));
}
}
}
out
}
fn source_enabled(config: &Config, id: &str) -> bool {
match id {
"github" => config.github.enabled,
"gitlab" => config.gitlab.enabled,
"gitea" => config.gitea.enabled,
_ => false,
}
}
fn dest_enabled(config: &Config, id: &str) -> bool {
match id {
"slack" => config.slack.enabled,
"discord" => config.discord.enabled,
"email" => config.email.enabled,
_ => false,
}
}
fn is_valid_repo_glob(pattern: &str) -> bool {
let mut parts = pattern.split('/');
matches!(
(parts.next(), parts.next(), parts.next()),
(Some(owner), Some(name), None) if !owner.is_empty() && !name.is_empty()
)
}
fn is_hhmm(s: &str) -> bool {
matches!(s.split_once(':'), Some((h, m))
if h.len() == 2 && m.len() == 2
&& h.parse::<u8>().is_ok_and(|h| h < 24)
&& m.parse::<u8>().is_ok_and(|m| m < 60))
}
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"))
}
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"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_providers_default_to_disabled() {
assert!(!GitHubConfig::default().enabled);
assert!(!GitLabConfig::default().enabled);
assert!(!GiteaConfig::default().enabled);
assert!(!SlackConfig::default().enabled);
assert!(!DiscordConfig::default().enabled);
assert!(!EmailConfig::default().enabled);
}
fn route(source: &str, destination: &str, repos: Vec<String>) -> RouteConfig {
RouteConfig {
source: source.into(),
destination: destination.into(),
repos,
fallback: false,
}
}
fn errors(findings: &[Finding]) -> usize {
findings
.iter()
.filter(|f| f.severity == Severity::Error)
.count()
}
#[test]
fn default_config_validates_clean() {
assert!(validate(&Config::default()).is_empty());
}
#[test]
fn flags_unknown_and_disabled_route_targets() {
let mut c = Config::default();
c.github.enabled = true;
c.routes = vec![
route("github", "bogus", vec![]), route("github", "slack", vec![]), ];
let f = validate(&c);
assert!(f
.iter()
.any(|x| x.severity == Severity::Error && x.message.contains("bogus")));
assert!(f
.iter()
.any(|x| x.severity == Severity::Warning && x.message.contains("slack")));
}
#[test]
fn flags_missing_required_fields_and_bad_glob() {
let mut c = Config::default();
c.email.enabled = true; c.rules.repos.deny = vec!["not-a-glob".into()];
let f = validate(&c);
assert_eq!(errors(&f), 4);
assert!(f.iter().any(|x| x.message.contains("email.smtp_host")));
assert!(f.iter().any(|x| x.message.contains("not-a-glob")));
}
#[test]
fn flags_empty_discord_dm_to_and_multislash_glob() {
let mut c = Config::default();
c.discord.enabled = true; c.rules.repos.allow = vec!["acme/repo/sub".into()]; let f = validate(&c);
assert!(f
.iter()
.any(|x| x.severity == Severity::Error && x.message.contains("discord.dm_to")));
assert!(f
.iter()
.any(|x| x.severity == Severity::Error && x.message.contains("acme/repo/sub")));
}
#[test]
fn flags_bad_quiet_hours_time() {
let mut c = Config::default();
c.rules.quiet_hours.enabled = true;
c.rules.quiet_hours.start = "9am".into();
c.rules.quiet_hours.end = "08:00".into();
let f = validate(&c);
assert!(f
.iter()
.any(|x| x.severity == Severity::Error && x.message.contains("quiet_hours.start")));
assert!(!f.iter().any(|x| x.message.contains("quiet_hours.end")));
}
#[test]
fn default_broadcast_tags_are_all_known() {
let c = Config::default();
let f = validate(&c);
assert!(!f.iter().any(|x| x.message.contains("unknown tag")));
}
}