Make [notifications.pushover] optional for non-pushover methods

The pushover config section is now only required when notifications.method
is "pushover". Validation at startup rejects the missing-section case so a
misconfiguration fails fast instead of erroring on every notification.

Updates the sample TOML, README and nix module for consistency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-04 17:45:02 -04:00
co-authored by Claude Opus 4.8
parent 6c4f8c22bb
commit be2a8fce58
6 changed files with 123 additions and 29 deletions
+18 -9
View File
@@ -185,9 +185,17 @@ fn send_notification(notification: Notification) -> Result<(), Box<dyn Error>> {
debug!("About to send notification ({notification}).");
match get_settings().notifications.method.as_str() {
"pushover" => {
pushover::send_message(notification.title, notification.body)?;
}
"pushover" => match &get_settings().notifications.pushover {
Some(pushover) => {
pushover::send_message(pushover, notification.title, notification.body)?;
}
None => {
error!(
"Notification method is 'pushover' but no [notifications.pushover] section is \
configured; cannot deliver notification."
);
}
},
other => {
warn!("Notification method set to '{other}'. Set logs to 'info' to see notifications.");
info!("Notification: {}", notification.body);
@@ -294,8 +302,12 @@ pub fn trigger_existing_device(
let vendor_changed_flag = vendor_changed(&existing_device.vendor, &new_device.vendor);
if ip_changed || vendor_changed_flag {
let (title, body) =
render_device_changed(&existing_device, &new_device, ip_changed, vendor_changed_flag);
let (title, body) = render_device_changed(
&existing_device,
&new_device,
ip_changed,
vendor_changed_flag,
);
let notification = Notification::new(
Utc::now(),
NotificationType::DeviceChanged,
@@ -411,10 +423,7 @@ mod tests {
let (title, body) = render_device_back_online(&device, "12d");
assert_eq!(
title,
"Device back online after 12d: bobs-iphone.local"
);
assert_eq!(title, "Device back online after 12d: bobs-iphone.local");
assert!(body.contains("Registered to Bob"));
assert!(body.contains("Absent for: 12d"));
}
+3 -7
View File
@@ -3,17 +3,13 @@ use pushover::API;
use pushover::requests::message::SendMessage;
use crate::events::error::DeliveryError;
use crate::settings::get_settings;
use crate::settings::Pushover;
pub fn send_message(title: String, body: String) -> Result<(), DeliveryError> {
pub fn send_message(config: &Pushover, title: String, body: String) -> Result<(), DeliveryError> {
debug!("About to send message via pushover ({body})");
let api = API::new();
let mut msg = SendMessage::new(
get_settings().notifications.pushover.token.as_str(),
get_settings().notifications.pushover.user_key.as_str(),
body,
);
let mut msg = SendMessage::new(config.token.as_str(), config.user_key.as_str(), body);
msg.set_title(title);
+80 -2
View File
@@ -180,7 +180,8 @@ pub struct Pushover {
#[derive(Debug, Deserialize, Clone)]
pub struct Notifications {
pub method: String,
pub pushover: Pushover,
// Only required when `method` is "pushover"; other methods leave this section out.
pub pushover: Option<Pushover>,
#[serde(default = "default_notify_when_not_seen_for")]
pub notify_when_not_seen_for: DurationString,
}
@@ -255,7 +256,22 @@ impl Settings {
.add_source(File::with_name(config_path.as_str()))
.build()?;
local_settings.try_deserialize()
let settings: Settings = local_settings.try_deserialize()?;
settings.validate()?;
Ok(settings)
}
// Cross-field checks that serde can't express on its own. Run once at load time so a
// misconfiguration fails fast at startup rather than on every notification attempt.
fn validate(&self) -> Result<(), ConfigError> {
if self.notifications.method == "pushover" && self.notifications.pushover.is_none() {
return Err(ConfigError::Message(
"notifications.method is \"pushover\" but the [notifications.pushover] section is \
missing; add it with your token and user_key, or change notifications.method."
.to_string(),
));
}
Ok(())
}
}
@@ -533,6 +549,68 @@ mod tests {
);
}
#[test]
fn validate_rejects_pushover_method_without_section() {
// method = "pushover" but no [notifications.pushover] section: must fail at load time.
const PUSHOVER_NO_SECTION: &str = r#"
[database]
path = "./oott.db"
[networking]
[log]
level = "info"
[notifications]
method = "pushover"
[web_server]
api_key = "test"
"#;
let settings = parse(PUSHOVER_NO_SECTION);
assert!(settings.validate().is_err());
}
#[test]
fn validate_accepts_non_pushover_method_without_section() {
let settings = parse(NO_PUSHOVER_CONFIG);
assert!(settings.validate().is_ok());
}
#[test]
fn validate_accepts_pushover_method_with_section() {
let settings = parse(BASE_CONFIG);
assert!(settings.validate().is_ok());
}
const NO_PUSHOVER_CONFIG: &str = r#"
[database]
path = "./oott.db"
[networking]
[log]
level = "info"
[notifications]
method = "none"
[web_server]
api_key = "test"
"#;
#[test]
fn pushover_section_is_optional_for_non_pushover_methods() {
// With a non-pushover method, the `[notifications.pushover]` section may be omitted
// entirely; it should deserialize to `None` rather than fail parsing.
let settings = parse(NO_PUSHOVER_CONFIG);
assert_eq!(settings.notifications.method, "none");
assert!(settings.notifications.pushover.is_none());
}
#[test]
fn pushover_section_is_parsed_when_present() {
let settings = parse(BASE_CONFIG);
let pushover = settings
.notifications
.pushover
.expect("pushover section should be parsed when present");
assert_eq!(pushover.token, "");
assert_eq!(pushover.user_key, "");
}
#[test]
fn networking_section_is_optional() {
// The `[networking]` section has no mandatory fields, so omitting it entirely