grafatui 0.1.12

A Grafana-like TUI for Prometheus
use std::ops::Range;

use super::{AnnotationEvent, TagCatalogueEntry, TagFilter};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AnnotationModal {
    Cluster(ClusterModalState),
    TagFilter(TagFilterModalState),
}

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

impl ClusterModalState {
    pub(crate) fn new(events: Vec<AnnotationEvent>) -> Option<Self> {
        (!events.is_empty()).then_some(Self {
            events,
            selected: 0,
        })
    }

    pub(crate) fn move_by(&mut self, delta: isize) {
        self.selected = moved_index(self.selected, self.events.len(), delta);
    }

    pub(crate) fn move_page(&mut self, direction: isize, rows: usize) {
        let page_delta = direction.unsigned_abs().saturating_mul(rows);
        if direction < 0 {
            self.selected = self.selected.saturating_sub(page_delta);
        } else {
            self.selected = self
                .selected
                .saturating_add(page_delta)
                .min(self.events.len().saturating_sub(1));
        }
    }

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

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

    pub(crate) fn selected_event(&self) -> Option<&AnnotationEvent> {
        self.events.get(self.selected)
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TagFilterModalState {
    entries: Vec<TagCatalogueEntry>,
    selected: usize,
    draft: TagFilter,
}

impl TagFilterModalState {
    pub(crate) fn new(entries: Vec<TagCatalogueEntry>, draft: TagFilter) -> Self {
        Self {
            entries,
            selected: 0,
            draft,
        }
    }

    pub(crate) fn move_by(&mut self, delta: isize) {
        self.selected = moved_index(self.selected, self.entries.len(), delta);
    }

    pub(crate) fn toggle_selected(&mut self) {
        if let Some(entry) = self.entries.get(self.selected) {
            self.draft.toggle(&entry.tag);
        }
    }

    pub(crate) fn clear(&mut self) {
        self.draft.clear();
    }

    pub(crate) fn entries(&self) -> &[TagCatalogueEntry] {
        &self.entries
    }

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

    pub(crate) fn draft(&self) -> &TagFilter {
        &self.draft
    }
}

pub(crate) fn visible_range(total: usize, selected: usize, rows: usize) -> Range<usize> {
    if total == 0 || rows == 0 {
        return 0..0;
    }
    let selected = selected.min(total - 1);
    let start = (selected / rows) * rows;
    start..start.saturating_add(rows).min(total)
}

fn moved_index(current: usize, len: usize, delta: isize) -> usize {
    if len == 0 {
        return 0;
    }
    if delta < 0 {
        current.saturating_sub(delta.unsigned_abs())
    } else {
        current.saturating_add(delta as usize).min(len - 1)
    }
}

#[cfg(test)]
mod tests {
    use super::{ClusterModalState, TagFilterModalState, visible_range};
    use crate::annotations::{TagCatalogueEntry, TagFilter};

    #[test]
    fn cluster_modal_owns_events_and_bounds_navigation() {
        let events = vec![
            crate::annotations::test_event_at(10.0, "one"),
            crate::annotations::test_event_at(11.0, "two"),
            crate::annotations::test_event_at(12.0, "three"),
        ];
        let mut modal = ClusterModalState::new(events).unwrap();

        modal.move_by(1);
        assert_eq!(modal.selected_event().unwrap().text, "two");
        modal.move_by(100);
        assert_eq!(modal.selected_event().unwrap().text, "three");
        modal.move_page(-1, 2);
        assert_eq!(modal.selected_event().unwrap().text, "one");
    }

    #[test]
    fn cluster_modal_rejects_empty_events() {
        assert!(ClusterModalState::new(Vec::new()).is_none());
    }

    #[test]
    fn cluster_modal_clamps_extreme_page_sizes_in_each_direction() {
        let events = vec![
            crate::annotations::test_event_at(10.0, "one"),
            crate::annotations::test_event_at(11.0, "two"),
            crate::annotations::test_event_at(12.0, "three"),
        ];
        let mut modal = ClusterModalState::new(events).unwrap();

        modal.move_page(1, usize::MAX);
        assert_eq!(modal.selected_event().unwrap().text, "three");
        modal.move_page(-1, usize::MAX);
        assert_eq!(modal.selected_event().unwrap().text, "one");
        modal.move_page(isize::MAX, usize::MAX);
        assert_eq!(modal.selected_event().unwrap().text, "three");
        modal.move_page(isize::MIN, usize::MAX);
        assert_eq!(modal.selected_event().unwrap().text, "one");
    }

    #[test]
    fn tag_modal_edits_a_draft_without_changing_the_applied_filter() {
        let applied = TagFilter::from_selected(["deploy".to_string()]);
        let entries = vec![
            TagCatalogueEntry {
                tag: "deploy".to_string(),
                count: 2,
            },
            TagCatalogueEntry {
                tag: "incident".to_string(),
                count: 1,
            },
        ];
        let mut modal = TagFilterModalState::new(entries, applied.clone());

        modal.move_by(1);
        modal.toggle_selected();
        assert_eq!(applied.summary(), "deploy");
        assert_eq!(modal.draft().summary(), "deploy|incident");
        modal.clear();
        assert!(modal.draft().is_empty());
    }

    #[test]
    fn tag_modal_ignores_toggles_for_an_empty_catalogue() {
        let applied = TagFilter::from_selected(["deploy".to_string()]);
        let mut modal = TagFilterModalState::new(Vec::new(), applied.clone());

        modal.toggle_selected();

        assert_eq!(modal.draft(), &applied);
    }

    #[test]
    fn visible_range_is_empty_without_items_or_rows_and_contains_selection() {
        assert_eq!(visible_range(0, 0, 3), 0..0);
        assert_eq!(visible_range(3, 0, 0), 0..0);
        assert_eq!(visible_range(5, 3, 2), 2..4);
        assert_eq!(visible_range(5, 100, 2), 4..5);
    }
}