travelagent 1.11.1

Agent-first TUI code review tool
//! Tour-guide session state, extracted from `App` in v1.6 and
//! encapsulated in v1.7.3.
//!
//! The pre-v1.6 `App` had five fields spread across unrelated clusters
//! for one lifecycle:
//!
//! - `tour`: active tour plan (set by `trv_tour_set_plan` + `trv_tour_goto_stop`)
//! - `tour_comment_meta`: structured metadata for comments added while
//!   a tour was active, keyed by `Comment.id`. Persists past `tour_end`.
//! - `tour_triage`: agent-assigned triage verdicts for tour comments.
//! - `tour_granularity_hint`: pending `=` / `-` human nudge, consumed
//!   once by `trv_tour_take_granularity_hint`.
//! - `tour_score_cache`: memoized per-commit scores keyed by SHA.
//!
//! All five move together on tour start/end/retarget. Grouping them
//! makes the tour lifecycle legible without surfing past ~60 unrelated
//! App fields.
//!
//! Encapsulation pass (v1.7.3): methods that only touch these five
//! fields now live on `TourSessionState`. App-level methods on `App`
//! in `app/tour.rs` that *also* need `&App` (diff-loading, VCS calls,
//! status messages) stay there.

use std::collections::HashMap;

use travelagent_core::model::{CommentTriage, TourCommentMeta, TourState as CoreTourState};
use travelagent_core::risk::ScoredCommit;

use super::GranularityHint;

/// See module docs for the cluster rationale.
#[derive(Debug, Default)]
pub struct TourSessionState {
    /// Active tour guide session, set by MCP `trv_tour_set_plan` +
    /// `trv_tour_goto_stop`.
    pub plan: Option<CoreTourState>,
    /// Structured metadata for comments added while a tour was active.
    /// Keyed by `Comment.id`. Persists across `tour_end` so the agent
    /// can triage at the end; cleared explicitly via
    /// `tour_clear_triage`.
    pub comment_meta: HashMap<String, TourCommentMeta>,
    /// Agent-assigned triage verdicts for tour comments, keyed by
    /// `Comment.id`. Populated via `trv_tour_set_triage`.
    pub triage: HashMap<String, CommentTriage>,
    /// A pending granularity nudge from the human (`=` / `-` keys),
    /// consumed once by the agent via
    /// `trv_tour_take_granularity_hint`.
    pub granularity_hint: Option<GranularityHint>,
    /// Memoized per-commit scores keyed by SHA. Populated lazily by
    /// `tour_score_commits`; avoids re-diffing every commit on
    /// `:set tour=<preset>` / `tour_set_aggressiveness` retargets.
    /// Cleared whenever scorer inputs change (risk config reload,
    /// session scope switch).
    pub score_cache: HashMap<String, ScoredCommit>,
    /// Cached formatted date-range string for the current stop — e.g.
    /// `"2026-04-15 → 2026-05-02"` (or `"2026-04-15"` for single-commit
    /// stops). Keyed by `(first_sha, last_sha)` so it auto-invalidates
    /// when `tour.index` moves. `None` until the first banner render
    /// populates it; rebuilt lazily from VCS on any change.
    pub date_range_cache: Option<(String, String, String)>,
}

impl TourSessionState {
    // --- granularity hint ---

    /// Set a one-shot granularity hint. Overwrites any pending hint —
    /// the latest nudge wins.
    pub fn set_granularity_hint(&mut self, hint: GranularityHint) {
        self.granularity_hint = Some(hint);
    }

    /// Consume the pending granularity hint, if any. Each hint is
    /// acted on exactly once.
    pub fn take_granularity_hint(&mut self) -> Option<GranularityHint> {
        self.granularity_hint.take()
    }

    // --- score cache ---

    /// Look up a cached score for `sha`.
    pub fn cached_score(&self, sha: &str) -> Option<&ScoredCommit> {
        self.score_cache.get(sha)
    }

    /// Insert a freshly-computed score into the cache.
    pub fn cache_score(&mut self, sha: String, scored: ScoredCommit) {
        self.score_cache.insert(sha, scored);
    }

    /// Drop all memoized scores. Call when scorer inputs change (risk
    /// config reload, session scope switch).
    pub fn invalidate_score_cache(&mut self) {
        self.score_cache.clear();
    }

    // --- comment metadata ---

    /// Record tour-stop provenance for a comment.
    pub fn record_comment_meta(&mut self, comment_id: String, meta: TourCommentMeta) {
        self.comment_meta.insert(comment_id, meta);
    }

    /// True when the given comment was added during a tour (i.e. has
    /// recorded metadata).
    pub fn is_tour_comment(&self, comment_id: &str) -> bool {
        self.comment_meta.contains_key(comment_id)
    }

    /// Set or overwrite a triage verdict for a tour comment.
    pub fn set_triage(&mut self, comment_id: String, triage: CommentTriage) {
        self.triage.insert(comment_id, triage);
    }

    /// Summary of triage counts as `(live, likely_obsolete, moved)`
    /// across all tour comments.
    pub fn triage_counts(&self) -> (usize, usize, usize) {
        use travelagent_core::model::TourTriageVerdict;
        let mut live = 0;
        let mut obsolete = 0;
        let mut moved = 0;
        for t in self.triage.values() {
            match t.verdict {
                TourTriageVerdict::Live => live += 1,
                TourTriageVerdict::LikelyObsolete => obsolete += 1,
                TourTriageVerdict::Moved => moved += 1,
            }
        }
        (live, obsolete, moved)
    }

    // --- plan lifecycle ---

    /// Drop the active tour plan. Comment metadata and triage are
    /// preserved so the agent can finish triaging after exit.
    pub fn end_tour(&mut self) {
        self.plan = None;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use travelagent_core::model::{NewCommentLocation, TourTriageVerdict};

    fn triage_live() -> CommentTriage {
        CommentTriage {
            verdict: TourTriageVerdict::Live,
            reasoning: String::new(),
            new_location: None,
        }
    }

    fn triage_moved() -> CommentTriage {
        CommentTriage {
            verdict: TourTriageVerdict::Moved,
            reasoning: String::new(),
            new_location: Some(NewCommentLocation {
                file: "a".into(),
                line: 1,
            }),
        }
    }

    #[test]
    fn granularity_hint_round_trips_and_is_one_shot() {
        let mut s = TourSessionState::default();
        assert!(s.take_granularity_hint().is_none());
        s.set_granularity_hint(GranularityHint::Finer);
        assert_eq!(s.take_granularity_hint(), Some(GranularityHint::Finer));
        assert!(
            s.take_granularity_hint().is_none(),
            "hint must be consumed exactly once"
        );
    }

    #[test]
    fn score_cache_round_trip_and_invalidation() {
        let mut s = TourSessionState::default();
        let scored = ScoredCommit {
            sha: "abc".into(),
            risk: travelagent_core::risk::RiskScore::MIN,
            summary: String::new(),
        };
        s.cache_score("abc".into(), scored.clone());
        assert_eq!(
            s.cached_score("abc").map(|c| c.sha.clone()),
            Some("abc".into())
        );
        s.invalidate_score_cache();
        assert!(s.cached_score("abc").is_none());
    }

    #[test]
    fn comment_meta_roundtrips_and_triage_counts() {
        let mut s = TourSessionState::default();
        assert!(!s.is_tour_comment("x"));
        s.record_comment_meta(
            "x".into(),
            TourCommentMeta {
                stop_index: 0,
                stop_commit_shas: vec!["a".into()],
                file: "f".into(),
                line: 1,
            },
        );
        assert!(s.is_tour_comment("x"));

        s.set_triage("x".into(), triage_live());
        s.set_triage("y".into(), triage_moved());
        // Live=1, obsolete=0, moved=1
        assert_eq!(s.triage_counts(), (1, 0, 1));
    }

    #[test]
    fn end_tour_clears_plan_but_keeps_meta() {
        use travelagent_core::model::TourStop;
        use travelagent_core::risk::RiskScore;
        let mut s = TourSessionState {
            plan: Some(CoreTourState::new(vec![TourStop {
                commit_ids: vec!["a".into()],
                summary: "s".into(),
                risk: RiskScore::MIN,
            }])),
            ..Default::default()
        };
        s.record_comment_meta(
            "x".into(),
            TourCommentMeta {
                stop_index: 0,
                stop_commit_shas: vec!["a".into()],
                file: "f".into(),
                line: 1,
            },
        );
        s.end_tour();
        assert!(s.plan.is_none());
        assert!(
            s.is_tour_comment("x"),
            "comment meta must survive end_tour for post-tour triage"
        );
    }
}