travelagent 1.11.1

Agent-first TUI code review tool
//! Live-review-mode state bundle, extracted from `App` in v1.6 and
//! encapsulated in v1.7.3.
//!
//! Five fields that co-move on every live-mode lifecycle event (start,
//! rescan, reanchor, orphan selection): `active`, `last_refresh_at`,
//! `pending_rescan`, `cached_file_contents`, `last_selected_orphan`.
//! Grouping them into one struct makes the live-mode path legible
//! without surfing past ~50 unrelated App fields.
//!
//! Encapsulation pass (v1.7.3): mutation helpers that only touch
//! these five fields (`activate`, `deactivate`, `mark_refreshed`,
//! `request_rescan`, `drain_rescan`, `remember_orphan`, `take_orphan`)
//! live on the struct. App-level methods that also need `&App`
//! (watcher wiring, diff reload) continue to live in `main.rs` and
//! `app/mod.rs` — those call the struct helpers for the data-only
//! bits.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

/// See module docs for the cluster rationale.
#[derive(Debug, Default)]
pub struct LiveModeState {
    /// True when live review mode is active (file watcher running).
    /// Toggled by the `--live` CLI flag or the `:live` / `:live!`
    /// commands. The actual `notify` handle lives in the main event
    /// loop — this field only exposes the "is live" bit to the rest
    /// of the app (status bar, command dispatch) so handlers don't
    /// need to know about watcher plumbing.
    pub active: bool,
    /// Timestamp of the last live-mode-driven rescan, for the
    /// status-bar "LIVE · HH:MM:SS" indicator. `None` until the first
    /// rescan fires.
    pub last_refresh_at: Option<chrono::DateTime<chrono::Local>>,
    /// L3 rescan deferral: when a `LiveEvent::Rescan` arrives while
    /// the user is in a non-Normal input mode (Comment, Command,
    /// Search, etc.), we *don't* reload the diff under their cursor.
    /// Instead we set this bit; the next transition back to
    /// `InputMode::Normal` drains it by calling `reload_diff_files()`
    /// once. Multiple rescans while deferred collapse to a single
    /// pending flag — we always want to re-read the latest state,
    /// never replay.
    pub pending_rescan: bool,
    /// L2 rescan-race fix: cached last-known "new side" file contents
    /// keyed by display path. Seeded at App construction and refreshed
    /// at the end of every `reload_diff_files()` after re-anchoring
    /// has consumed the previous snapshot. Feeds
    /// `reanchor_comments_against_new_content` as `old_new_content`
    /// — reading disk at rescan start would only see the post-change
    /// bytes (the watcher fires *after* the write), which would turn
    /// the `AnchorMap` into an identity map and silently skip
    /// re-anchoring.
    pub cached_file_contents: HashMap<PathBuf, String>,
    /// L3 orphan-selection memory: when the cursor lands on an
    /// `AnnotatedLine::OrphanedComment`, we stash `(path, orphan_idx)`
    /// here. The next `A` / `:reanchor` press on a diff line consumes
    /// it so the user gets to pick *which* orphan gets re-anchored
    /// when a file has more than one. Cleared after consumption;
    /// untouched by cursor moves that land on non-orphan rows, so the
    /// selection survives the navigation from the Orphaned section
    /// down to the target diff line.
    pub last_selected_orphan: Option<(PathBuf, usize)>,
}

impl LiveModeState {
    // --- lifecycle ---

    /// Mark live mode active. Caller is responsible for spinning up
    /// the watcher thread.
    pub fn activate(&mut self) {
        self.active = true;
    }

    /// Mark live mode inactive and clear the refresh timestamp so the
    /// status-bar indicator disappears until the next `:live` starts
    /// a fresh session.
    pub fn deactivate(&mut self) {
        self.active = false;
        self.last_refresh_at = None;
    }

    /// Stamp the last-refresh timestamp to "now" in the local timezone.
    /// Called after a successful rescan so the status bar shows when
    /// the diff was last reloaded.
    pub fn mark_refreshed(&mut self) {
        self.last_refresh_at = Some(chrono::Local::now());
    }

    // --- rescan deferral ---

    /// Defer a rescan — the event loop arrived during a non-Normal
    /// input mode, so we don't want to reload the diff under the
    /// user's cursor. Multiple rescans collapse to a single pending
    /// bit (we always re-read the latest state, never replay).
    pub fn request_rescan(&mut self) {
        self.pending_rescan = true;
    }

    /// Take and clear the pending-rescan bit. Returns `true` iff a
    /// rescan was pending, so the caller can conditionally invoke
    /// `reload_diff_files()` + `mark_refreshed()`.
    pub fn drain_rescan(&mut self) -> bool {
        std::mem::replace(&mut self.pending_rescan, false)
    }

    // --- orphan selection ---

    /// Remember which orphan the cursor is on so the next
    /// `A` / `:reanchor` press can target it. Overwrites any prior
    /// selection (the cursor only lands on one orphan at a time).
    pub fn remember_orphan(&mut self, path: PathBuf, orphan_idx: usize) {
        self.last_selected_orphan = Some((path, orphan_idx));
    }

    /// Consume the orphan selection, if any. Each selection is used
    /// exactly once per `:reanchor`.
    pub fn take_orphan(&mut self) -> Option<(PathBuf, usize)> {
        self.last_selected_orphan.take()
    }

    /// Clear the orphan selection without consuming it (e.g. after a
    /// failed reanchor that doesn't want to retry against the same
    /// target).
    pub fn clear_orphan(&mut self) {
        self.last_selected_orphan = None;
    }

    // --- cached file contents ---

    /// Look up the previous "new side" contents for a display path,
    /// used by re-anchoring before the rescan reads the post-change
    /// bytes from disk.
    pub fn cached_contents(&self, path: &Path) -> Option<&String> {
        self.cached_file_contents.get(path)
    }

    /// Replace the entire cached-contents map. Called at the end of
    /// each `reload_diff_files()` with the freshly-read snapshot, so
    /// the *next* rescan sees the pre-change bytes as `old_new`.
    pub fn replace_cached_contents(&mut self, snapshot: HashMap<PathBuf, String>) {
        self.cached_file_contents = snapshot;
    }
}

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

    #[test]
    fn activate_and_deactivate_flip_and_clear_timestamp() {
        let mut s = LiveModeState::default();
        s.activate();
        s.mark_refreshed();
        assert!(s.active);
        assert!(s.last_refresh_at.is_some());

        s.deactivate();
        assert!(!s.active);
        assert!(
            s.last_refresh_at.is_none(),
            "deactivate must clear the refresh timestamp so the status bar indicator disappears"
        );
    }

    #[test]
    fn drain_rescan_is_one_shot_and_collapses_multiple_requests() {
        let mut s = LiveModeState::default();
        assert!(!s.drain_rescan(), "empty drain returns false");

        s.request_rescan();
        s.request_rescan();
        s.request_rescan();
        assert!(s.drain_rescan(), "first drain returns true");
        assert!(
            !s.drain_rescan(),
            "second drain returns false — requests don't replay"
        );
    }

    #[test]
    fn orphan_selection_round_trips_and_is_one_shot() {
        let mut s = LiveModeState::default();
        assert!(s.take_orphan().is_none());

        s.remember_orphan(PathBuf::from("a.rs"), 3);
        assert_eq!(s.take_orphan(), Some((PathBuf::from("a.rs"), 3)));
        assert!(
            s.take_orphan().is_none(),
            "selection must be consumed exactly once"
        );
    }

    #[test]
    fn remember_orphan_overwrites_prior_selection() {
        let mut s = LiveModeState::default();
        s.remember_orphan(PathBuf::from("a.rs"), 0);
        s.remember_orphan(PathBuf::from("b.rs"), 2);
        assert_eq!(s.take_orphan(), Some((PathBuf::from("b.rs"), 2)));
    }

    #[test]
    fn clear_orphan_drops_without_returning() {
        let mut s = LiveModeState::default();
        s.remember_orphan(PathBuf::from("a.rs"), 0);
        s.clear_orphan();
        assert!(s.take_orphan().is_none());
    }

    #[test]
    fn cached_contents_replace_and_lookup() {
        let mut s = LiveModeState::default();
        let mut snap = HashMap::new();
        snap.insert(PathBuf::from("a.rs"), "hello".into());
        snap.insert(PathBuf::from("b.rs"), "world".into());
        s.replace_cached_contents(snap);

        assert_eq!(
            s.cached_contents(&PathBuf::from("a.rs"))
                .map(String::as_str),
            Some("hello")
        );
        assert!(s.cached_contents(&PathBuf::from("missing.rs")).is_none());
    }
}