use sha2::{Digest, Sha256};
use super::{App, DiffSource};
use travelagent_core::model::DiffFile;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AiSummaryStaleness {
NoSummary,
Unknown,
Fresh,
Stale { stored_short: String },
}
impl AiSummaryStaleness {
pub fn is_stale(&self) -> bool {
matches!(self, Self::Stale { .. })
}
}
impl App {
pub fn set_ai_summary(&mut self, markdown: String) {
let diff_sha = self.current_diff_sha();
self.ai.set(markdown, diff_sha);
}
pub fn clear_ai_summary(&mut self) {
self.ai.clear();
}
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);
}
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))
}
}
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(),
}
}
}
}
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");
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);
}
}