nwg-notifications 0.4.0

D-Bus notification daemon + notification center for Hyprland and Sway. Claims org.freedesktop.Notifications, shows popup toasts, and ships a slide-out history panel with Do-Not-Disturb controls and optional waybar integration. Replaces mako; runs standalone.
//! `Notification`, `Urgency`, and the freedesktop-spec helpers for
//! body-markup stripping (`clean_markup`) and action-list parsing
//! (`parse_actions`).

use serde::{Deserialize, Serialize};
use std::time::SystemTime;

/// Urgency level per freedesktop notification specification.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum Urgency {
    Low = 0,
    Normal = 1,
    Critical = 2,
}

impl From<u8> for Urgency {
    fn from(val: u8) -> Self {
        match val {
            0 => Urgency::Low,
            2 => Urgency::Critical,
            _ => Urgency::Normal,
        }
    }
}

/// A single notification received via D-Bus.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Notification {
    pub(crate) id: u32,
    pub(crate) app_name: String,
    pub(crate) app_icon: String,
    pub(crate) summary: String,
    pub(crate) body: String,
    pub(crate) actions: Vec<(String, String)>,
    pub(crate) urgency: Urgency,
    pub(crate) timeout_ms: i32,
    pub(crate) timestamp: SystemTime,
    pub(crate) read: bool,
    pub(crate) desktop_entry: Option<String>,
}

/// Strips HTML tags and decodes common entities from notification text.
///
/// The freedesktop notification spec allows a subset of HTML in the body
/// (`<b>`, `<i>`, `<a href="...">`, `<br>`, etc.). We strip all tags and
/// decode entities so the text displays cleanly in our GTK labels.
/// Strips HTML-ish tags and decodes entities in notification body
/// text per the [freedesktop notification spec][spec].
///
/// **Tag stripping:** uses a simple `in_tag` boolean state machine,
/// so nested and interleaved tags (`<b><i>foo</i></b>`,
/// `<b><i>foo</b></i>`) work correctly.
///
/// **Unmatched `<`:** an unclosed `<` flips the state machine to
/// `in_tag` and never resets, so everything from the unmatched `<`
/// to end-of-string is dropped. This matches the spec's expectation
/// that body markup is valid; any other behavior would require
/// lookahead or a real parser, which the daemon doesn't carry.
///
/// **Entities:** decodes the five spec-listed named entities
/// (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&apos;`) plus `&#39;` for
/// legacy convenience. Other numeric entities (`&#34;`, `&#x27;`,
/// etc.) are not in the spec and pass through verbatim.
///
/// [spec]: https://specifications.freedesktop.org/notification-spec/latest/
pub(crate) fn clean_markup(text: &str) -> String {
    // Strip HTML tags
    let mut result = String::with_capacity(text.len());
    let mut in_tag = false;
    for ch in text.chars() {
        match ch {
            '<' => in_tag = true,
            '>' if in_tag => in_tag = false,
            _ if !in_tag => result.push(ch),
            _ => {}
        }
    }

    // Decode HTML entities
    result
        .replace("&amp;", "&")
        .replace("&lt;", "<")
        .replace("&gt;", ">")
        .replace("&quot;", "\"")
        .replace("&apos;", "'")
        .replace("&#39;", "'")
}

/// Parses the flat actions array from D-Bus into (key, label) pairs.
/// D-Bus format: ["action-id-1", "Label 1", "action-id-2", "Label 2"]
pub(crate) fn parse_actions(flat: &[String]) -> Vec<(String, String)> {
    flat.chunks(2)
        .filter_map(|chunk| {
            if chunk.len() == 2 {
                Some((chunk[0].clone(), chunk[1].clone()))
            } else {
                None
            }
        })
        .collect()
}

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

    #[test]
    fn urgency_from_u8() {
        assert_eq!(Urgency::from(0), Urgency::Low);
        assert_eq!(Urgency::from(1), Urgency::Normal);
        assert_eq!(Urgency::from(2), Urgency::Critical);
        assert_eq!(Urgency::from(255), Urgency::Normal);
    }

    #[test]
    fn parse_actions_pairs() {
        let flat = vec![
            "reply".into(),
            "Reply".into(),
            "dismiss".into(),
            "Dismiss".into(),
        ];
        let actions = parse_actions(&flat);
        assert_eq!(actions.len(), 2);
        assert_eq!(actions[0], ("reply".into(), "Reply".into()));
    }

    #[test]
    fn parse_actions_odd_length() {
        let flat = vec!["only-one".into()];
        let actions = parse_actions(&flat);
        assert!(actions.is_empty());
    }

    #[test]
    fn clean_markup_strips_tags() {
        assert_eq!(clean_markup("<b>bold</b> text"), "bold text");
        assert_eq!(
            clean_markup(r#"<a href="http://example.com">link</a>"#),
            "link"
        );
        assert_eq!(clean_markup("line1<br>line2"), "line1line2");
    }

    #[test]
    fn clean_markup_decodes_entities() {
        assert_eq!(clean_markup("a &amp; b"), "a & b");
        assert_eq!(clean_markup("&lt;user@mail.com&gt;"), "<user@mail.com>");
        assert_eq!(clean_markup("&quot;hello&quot;"), "\"hello\"");
        assert_eq!(clean_markup("it&#39;s"), "it's");
    }

    #[test]
    fn clean_markup_combined() {
        assert_eq!(
            clean_markup("From: <b>Alice</b> &lt;alice@example.com&gt;"),
            "From: Alice <alice@example.com>"
        );
    }

    #[test]
    fn clean_markup_handles_nested_tags() {
        // Properly nested
        assert_eq!(clean_markup("<b><i>foo</i></b>"), "foo");
        // Inner content with siblings
        assert_eq!(clean_markup("<b>x<i>y</i>z</b>"), "xyz");
    }

    #[test]
    fn clean_markup_handles_interleaved_tags() {
        // Malformed but recoverable: in_tag is just a boolean,
        // so the order of close-tags doesn't matter.
        assert_eq!(clean_markup("<b><i>foo</b></i>"), "foo");
        assert_eq!(clean_markup("<a><b>x</a></b>tail"), "xtail");
    }

    #[test]
    fn clean_markup_unmatched_lt_swallows_to_end() {
        // Documented behavior: an unclosed `<` flips in_tag and
        // never resets, so everything after the `<` is dropped.
        // Spec says body markup must be valid; this is the
        // simplest deterministic fallback for invalid input.
        assert_eq!(clean_markup("hello < world"), "hello ");
        // Even if a `>` appears later inside what was meant to be
        // a comparison, it gets consumed as a tag-close.
        assert_eq!(clean_markup("a < b > c"), "a  c");
    }

    #[test]
    fn clean_markup_passes_unsupported_numeric_entities_through() {
        // Spec lists the five named entities. We additionally
        // decode &#39; for legacy convenience but no other numeric
        // form. Confirm that &#34; (would-be ") and &#x27; (hex
        // single quote) survive untouched so a misbehaving app's
        // text isn't silently mangled.
        assert_eq!(clean_markup("a &#34; b"), "a &#34; b");
        assert_eq!(clean_markup("a &#x27; b"), "a &#x27; b");
        // The spec-listed forms still decode for sanity.
        assert_eq!(clean_markup("a &quot; b"), "a \" b");
        assert_eq!(clean_markup("a &#39; b"), "a ' b");
    }
}