travelagent 1.11.1

Agent-first TUI code review tool
use sha2::{Digest, Sha256};

use super::{App, DiffSource};
use travelagent_core::model::DiffFile;

/// Staleness verdict for the AI summary relative to the current diff.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AiSummaryStaleness {
    /// No summary is set, so staleness is not applicable.
    NoSummary,
    /// The summary has no recorded diff sha (e.g. legacy state), so we can't
    /// compare — treated as not stale to avoid a false positive.
    Unknown,
    /// Summary was written against the current diff.
    Fresh,
    /// Summary was written against a different diff. `stored_short` is the
    /// first 8 characters of the sha the summary was written for.
    Stale { stored_short: String },
}

impl AiSummaryStaleness {
    /// Convenience predicate used by tests and callers that only care whether
    /// the "stale" banner should be shown.
    pub fn is_stale(&self) -> bool {
        matches!(self, Self::Stale { .. })
    }
}

impl App {
    /// Store a new AI-generated markdown summary. Thin delegation to
    /// `AiSummaryState::set` plus `current_diff_sha` (which needs
    /// `&App` for the remote / local branch).
    pub fn set_ai_summary(&mut self, markdown: String) {
        let diff_sha = self.current_diff_sha();
        self.ai.set(markdown, diff_sha);
    }

    /// Drop the AI summary and close its panel if it was open.
    pub fn clear_ai_summary(&mut self) {
        self.ai.clear();
    }

    /// Toggle the AI summary panel. Opening the panel clears the unread flag.
    pub fn toggle_ai_summary(&mut self) {
        self.ai.toggle_panel();
    }

    pub fn ai_summary_scroll_down(&mut self, lines: usize) {
        self.ai.scroll_down(lines);
    }

    pub fn ai_summary_scroll_up(&mut self, lines: usize) {
        self.ai.scroll_up(lines);
    }

    /// Compute a fingerprint for the diff the summary is being written for.
    /// In remote PR mode this is the PR head SHA. In local mode it is a
    /// SHA-256 hash over the concatenated diff text, which changes whenever
    /// the working tree / staged / commit-range diff content changes.
    pub fn current_diff_sha(&self) -> Option<String> {
        if matches!(self.diff_source, DiffSource::Remote { .. }) {
            self.remote()
                .and_then(|r| r.pr_metadata.as_ref().map(|m| m.head_sha.clone()))
        } else {
            Some(hash_local_diff(&self.diff_files))
        }
    }

    /// Compare the stored diff sha against the current diff's sha and
    /// classify the summary's freshness. Exposed so renderers and MCP tools
    /// can surface the same status.
    pub fn ai_summary_staleness(&self) -> AiSummaryStaleness {
        if self.ai.summary.is_none() {
            return AiSummaryStaleness::NoSummary;
        }
        let Some(stored) = self.ai.diff_sha.as_deref() else {
            return AiSummaryStaleness::Unknown;
        };
        let Some(current) = self.current_diff_sha() else {
            return AiSummaryStaleness::Unknown;
        };
        if stored == current {
            AiSummaryStaleness::Fresh
        } else {
            AiSummaryStaleness::Stale {
                stored_short: stored.chars().take(8).collect(),
            }
        }
    }
}

/// SHA-256 over the concatenated text of every diff file. We include the
/// display path, status marker, and every line's origin/content/line numbers
/// so any semantic change to the diff (new file, added hunk, edited line,
/// line-number shift) rolls the hash.
fn hash_local_diff(files: &[DiffFile]) -> String {
    let mut hasher = Sha256::new();
    for file in files {
        hasher.update(file.display_path_lossy().to_string_lossy().as_bytes());
        hasher.update([0u8]);
        hasher.update([file.status.as_char() as u8]);
        hasher.update([0u8]);
        for hunk in &file.hunks {
            hasher.update(hunk.header.as_bytes());
            hasher.update([0u8]);
            for line in &hunk.lines {
                let origin_byte: u8 = match line.origin {
                    travelagent_core::model::LineOrigin::Context => b' ',
                    travelagent_core::model::LineOrigin::Addition => b'+',
                    travelagent_core::model::LineOrigin::Deletion => b'-',
                };
                hasher.update([origin_byte]);
                hasher.update(line.old_lineno.unwrap_or(0).to_le_bytes());
                hasher.update(line.new_lineno.unwrap_or(0).to_le_bytes());
                hasher.update(line.content.as_bytes());
                hasher.update([b'\n']);
            }
        }
        hasher.update([b'\n']);
    }
    format!("{:x}", hasher.finalize())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::{DiffSource, InputMode};
    use crate::theme::Theme;
    use std::path::{Path, PathBuf};
    use travelagent_core::error::Result as CoreResult;
    use travelagent_core::error::TrvError;
    use travelagent_core::model::{
        DiffHunk, DiffLine, FileStatus, LineOrigin, ReviewSession, SessionDiffSource,
    };
    use travelagent_core::vcs::{VcsBackend, VcsInfo, VcsType};

    struct StubVcs {
        info: VcsInfo,
    }

    impl VcsBackend for StubVcs {
        fn info(&self) -> &VcsInfo {
            &self.info
        }
        fn get_working_tree_diff(&self) -> CoreResult<Vec<DiffFile>> {
            Err(TrvError::NoChanges)
        }
        fn fetch_context_lines(
            &self,
            _f: &Path,
            _s: FileStatus,
            _a: u32,
            _b: u32,
        ) -> CoreResult<Vec<DiffLine>> {
            Ok(Vec::new())
        }
    }

    fn make_file(path: &str, content: &str) -> DiffFile {
        DiffFile {
            old_path: None,
            new_path: Some(PathBuf::from(path)),
            status: FileStatus::Modified,
            hunks: vec![DiffHunk {
                header: "@@ -1,0 +1,1 @@".to_string(),
                lines: vec![DiffLine {
                    origin: LineOrigin::Addition,
                    content: content.to_string(),
                    old_lineno: None,
                    new_lineno: Some(1),
                    highlighted_spans: None,
                }],
                old_start: 1,
                old_count: 0,
                new_start: 1,
                new_count: 1,
            }],
            is_binary: false,
            is_too_large: false,
            is_commit_message: false,
        }
    }

    fn build_app(files: Vec<DiffFile>) -> App {
        let vcs_info = VcsInfo {
            root_path: PathBuf::from("/tmp/trv-stale-test"),
            head_commit: "head".to_string(),
            branch_name: Some("main".to_string()),
            vcs_type: VcsType::Git,
        };
        let session = ReviewSession::new(
            vcs_info.root_path.clone(),
            vcs_info.head_commit.clone(),
            vcs_info.branch_name.clone(),
            SessionDiffSource::WorkingTree,
        );
        App::build(
            Box::new(StubVcs {
                info: vcs_info.clone(),
            }),
            vcs_info,
            Theme::dark(),
            None,
            false,
            files,
            session,
            DiffSource::WorkingTree,
            InputMode::Normal,
            Vec::new(),
            None,
            crate::test_support::runtime_handle(),
            crate::app::AppMode::Local(crate::app::LocalState::default()),
        )
        .expect("failed to build test app")
    }

    #[test]
    fn staleness_is_no_summary_when_nothing_set() {
        let app = build_app(vec![make_file("a.txt", "hello")]);
        assert_eq!(app.ai_summary_staleness(), AiSummaryStaleness::NoSummary);
    }

    #[test]
    fn staleness_is_fresh_when_diff_matches() {
        let mut app = build_app(vec![make_file("a.txt", "hello")]);
        app.set_ai_summary("summary".to_string());
        assert_eq!(app.ai_summary_staleness(), AiSummaryStaleness::Fresh);
    }

    #[test]
    fn staleness_reports_stale_with_short_sha_when_diff_changes() {
        let mut app = build_app(vec![make_file("a.txt", "hello")]);
        app.set_ai_summary("summary".to_string());
        let stored_full = app
            .ai
            .diff_sha
            .clone()
            .expect("diff sha captured at set time");

        // Mutate the diff so the hash changes.
        app.diff_files = vec![make_file("a.txt", "world")];
        let staleness = app.ai_summary_staleness();
        match staleness {
            AiSummaryStaleness::Stale { stored_short } => {
                assert_eq!(stored_short.len(), 8);
                assert!(
                    stored_full.starts_with(&stored_short),
                    "short sha {stored_short:?} should prefix stored sha {stored_full:?}"
                );
                assert!(app.ai_summary_staleness().is_stale());
            }
            other => panic!("expected Stale, got {other:?}"),
        }
    }

    #[test]
    fn clear_ai_summary_resets_diff_sha() {
        let mut app = build_app(vec![make_file("a.txt", "hello")]);
        app.set_ai_summary("summary".to_string());
        assert!(app.ai.diff_sha.is_some());
        app.clear_ai_summary();
        assert!(app.ai.diff_sha.is_none());
        assert_eq!(app.ai_summary_staleness(), AiSummaryStaleness::NoSummary);
    }
}