travelagent 1.11.1

Agent-first TUI code review tool
//! Bounded error-ring buffer for the TUI (H8).
//!
//! Before H8, `App::set_error` clobbered a single `message: Option<Message>`
//! slot — so a burst of live-mode watcher errors could overwrite a
//! critical forge-auth error before the user had a chance to read it. The
//! ring buffer keeps the last N errors; `set_error` both updates the
//! status-bar slot (existing UX) *and* appends to the ring. A new
//! `:errors` command cycles through the ring into the same status slot
//! so the user can recall what burst past.
//!
//! Scope kept deliberately small: no modal UI (would overlap the H9
//! `ui/app_layout.rs` split). When H9 lands, a proper panel can render
//! straight from `entries()`.
//!
//! Only `Error` messages append — warnings and info are intentionally
//! transient and clobber-safe.

use std::collections::VecDeque;

use super::Message;

/// Default ring capacity. Large enough to retain a plausible burst of
/// watcher errors without masking older critical ones; small enough that
/// scroll-back via `:errors` stays usable.
pub const DEFAULT_ERROR_LOG_CAPACITY: usize = 32;

/// Bounded ring of recent error messages. Newest entries at the back.
#[derive(Debug, Clone)]
pub struct ErrorLog {
    entries: VecDeque<Message>,
    max: usize,
    /// Cursor into the ring used by `:errors` to step through entries
    /// newest-to-oldest. `None` means "start from the newest next time."
    cursor: Option<usize>,
}

impl Default for ErrorLog {
    fn default() -> Self {
        Self::with_capacity(DEFAULT_ERROR_LOG_CAPACITY)
    }
}

impl ErrorLog {
    #[must_use]
    pub fn with_capacity(max: usize) -> Self {
        Self {
            entries: VecDeque::with_capacity(max.max(1)),
            max: max.max(1),
            cursor: None,
        }
    }

    /// Append `msg` to the ring, evicting the oldest entry when the ring
    /// is full. Resets the `:errors` cycle cursor so the next recall
    /// starts from the new entry.
    pub fn push(&mut self, msg: Message) {
        if self.entries.len() == self.max {
            self.entries.pop_front();
        }
        self.entries.push_back(msg);
        self.cursor = None;
    }

    #[must_use]
    #[allow(dead_code)] // consumed by tests + future modal panel (H9 part 2+)
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    #[must_use]
    #[allow(dead_code)] // consumed by tests + future modal panel
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    #[must_use]
    #[allow(dead_code)] // consumed by tests + future modal panel
    pub fn capacity(&self) -> usize {
        self.max
    }

    /// Iterate entries newest-first (reverse insertion order).
    #[allow(dead_code)] // consumed by tests + future modal panel
    pub fn iter_newest_first(&self) -> impl Iterator<Item = &Message> {
        self.entries.iter().rev()
    }

    /// Step the recall cursor and return the pointed-to entry. First call
    /// returns the newest entry; subsequent calls walk older; wraps back
    /// to newest after the oldest entry. Returns `None` if the log is
    /// empty.
    ///
    /// This is the state machine behind the `:errors` command: each
    /// invocation shows the next older error in the status bar, so a
    /// burst of 10 watcher errors followed by a critical forge-auth
    /// error is still reachable by pressing `:errors` repeatedly.
    pub fn next_recall(&mut self) -> Option<&Message> {
        if self.entries.is_empty() {
            return None;
        }
        // Ring is insertion-ordered oldest→newest; recall is
        // newest→oldest, so reverse the index.
        let last_idx = self.entries.len() - 1;
        let next = match self.cursor {
            None => last_idx,
            Some(0) => last_idx, // wrapped past oldest → back to newest
            Some(n) => n - 1,
        };
        self.cursor = Some(next);
        self.entries.get(next)
    }
}

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

    fn err(s: &str) -> Message {
        Message {
            content: s.to_string(),
            message_type: MessageType::Error,
        }
    }

    #[test]
    fn new_log_is_empty() {
        let log = ErrorLog::default();
        assert!(log.is_empty());
        assert_eq!(log.len(), 0);
        assert_eq!(log.capacity(), DEFAULT_ERROR_LOG_CAPACITY);
    }

    #[test]
    fn push_appends_and_len_tracks() {
        let mut log = ErrorLog::with_capacity(4);
        log.push(err("a"));
        log.push(err("b"));
        assert_eq!(log.len(), 2);
    }

    #[test]
    fn push_evicts_oldest_when_full() {
        let mut log = ErrorLog::with_capacity(2);
        log.push(err("a"));
        log.push(err("b"));
        log.push(err("c"));
        assert_eq!(log.len(), 2);
        let bodies: Vec<&str> = log
            .iter_newest_first()
            .map(|m| m.content.as_str())
            .collect();
        assert_eq!(bodies, vec!["c", "b"], "oldest evicted; order newest-first");
    }

    #[test]
    fn capacity_zero_is_promoted_to_one() {
        // Defensive: prevent a misconfigured capacity from producing a
        // permanently-empty ring.
        let mut log = ErrorLog::with_capacity(0);
        assert_eq!(log.capacity(), 1);
        log.push(err("only"));
        assert_eq!(log.len(), 1);
    }

    #[test]
    fn iter_newest_first_reverses_insertion_order() {
        let mut log = ErrorLog::with_capacity(3);
        log.push(err("a"));
        log.push(err("b"));
        log.push(err("c"));
        let bodies: Vec<&str> = log
            .iter_newest_first()
            .map(|m| m.content.as_str())
            .collect();
        assert_eq!(bodies, vec!["c", "b", "a"]);
    }

    #[test]
    fn next_recall_walks_newest_then_older() {
        let mut log = ErrorLog::with_capacity(3);
        log.push(err("oldest"));
        log.push(err("middle"));
        log.push(err("newest"));
        assert_eq!(log.next_recall().unwrap().content, "newest");
        assert_eq!(log.next_recall().unwrap().content, "middle");
        assert_eq!(log.next_recall().unwrap().content, "oldest");
        // Wraps back to newest.
        assert_eq!(log.next_recall().unwrap().content, "newest");
    }

    #[test]
    fn next_recall_returns_none_when_empty() {
        let mut log = ErrorLog::default();
        assert!(log.next_recall().is_none());
    }

    #[test]
    fn push_resets_recall_cursor() {
        let mut log = ErrorLog::with_capacity(4);
        log.push(err("a"));
        log.push(err("b"));
        // Walk to 'a'.
        assert_eq!(log.next_recall().unwrap().content, "b");
        assert_eq!(log.next_recall().unwrap().content, "a");
        // New error arrives → next recall starts from it again.
        log.push(err("c"));
        assert_eq!(log.next_recall().unwrap().content, "c");
    }
}