grafatui 0.1.11

A Grafana-like TUI for Prometheus
use std::collections::BTreeSet;

use chrono::{DateTime, Utc};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AnnotationTarget {
    All,
    PanelTitles(BTreeSet<String>),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AnnotationEvent {
    pub(crate) time: DateTime<Utc>,
    pub(crate) text: String,
    pub(crate) tags: Vec<String>,
    pub(crate) target: AnnotationTarget,
}

impl AnnotationEvent {
    pub(crate) fn timestamp_secs(&self) -> f64 {
        self.time.timestamp() as f64
            + f64::from(self.time.timestamp_subsec_nanos()) / 1_000_000_000.0
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct AnnotationSnapshot {
    events: Vec<AnnotationEvent>,
}

impl AnnotationSnapshot {
    pub(crate) fn new(mut events: Vec<AnnotationEvent>) -> Self {
        events.sort_by_key(|event| event.time);
        Self { events }
    }

    pub(crate) fn events(&self) -> &[AnnotationEvent] {
        &self.events
    }

    pub(crate) fn len(&self) -> usize {
        self.events.len()
    }
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct AnnotationPanelContext<'a> {
    pub(crate) index: usize,
    pub(crate) title: &'a str,
}

impl AnnotationTarget {
    pub(crate) fn applies_to(&self, panel: AnnotationPanelContext<'_>) -> bool {
        match self {
            Self::All => true,
            Self::PanelTitles(titles) => titles.contains(panel.title),
        }
    }
}

#[cfg(test)]
mod tests {
    use chrono::{DateTime, Utc};

    use super::{AnnotationEvent, AnnotationPanelContext, AnnotationSnapshot, AnnotationTarget};

    fn event(time: &str, text: &str) -> AnnotationEvent {
        AnnotationEvent {
            time: DateTime::parse_from_rfc3339(time)
                .unwrap()
                .with_timezone(&Utc),
            text: text.to_string(),
            tags: Vec::new(),
            target: AnnotationTarget::All,
        }
    }

    #[test]
    fn snapshot_sorts_events_stably() {
        let first = event("2026-07-23T14:30:00Z", "first");
        let earlier = event("2026-07-23T14:00:00Z", "earlier");
        let second = event("2026-07-23T14:30:00Z", "second");

        let snapshot = AnnotationSnapshot::new(vec![first, earlier, second]);
        let texts: Vec<_> = snapshot
            .events()
            .iter()
            .map(|event| event.text.as_str())
            .collect();

        assert_eq!(texts, vec!["earlier", "first", "second"]);
    }

    #[test]
    fn timestamp_secs_preserves_fraction_beyond_milliseconds() {
        let event = event("2026-07-23T14:30:00.123456789Z", "deploy");
        let expected = event.time.timestamp() as f64 + 0.123_456_789;

        assert!((event.timestamp_secs() - expected).abs() < 1e-7);
    }

    #[test]
    fn panel_title_target_matches_exactly_and_case_sensitively() {
        let target = AnnotationTarget::PanelTitles(["CPU".to_string()].into_iter().collect());

        assert!(target.applies_to(AnnotationPanelContext {
            index: 0,
            title: "CPU",
        }));
        assert!(!target.applies_to(AnnotationPanelContext {
            index: 1,
            title: "cpu",
        }));
    }
}