use travelagent_core::error::{Result, TrvError};
use travelagent_core::model::build_tour_stops;
use travelagent_core::risk::{RiskScore, ScoredCommit, score_commit};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::{App, DiffSource, DiffState, FileListState, GranularityHint, InputMode};
use travelagent_core::model::{
CommentTriage, NewCommentLocation, TourAggressiveness, TourCommentMeta, TourState, TourStop,
TourTriageVerdict,
};
#[derive(Debug, Clone)]
pub struct FileRiskDetail {
pub file: String,
pub risk: RiskScore,
}
#[derive(Debug, Clone)]
pub struct CommitRiskDetail {
pub sha: String,
pub risk: RiskScore,
pub change_types: Vec<travelagent_core::risk::ChangeType>,
pub file_scores: Vec<FileRiskDetail>,
}
impl App {
pub fn tour_resolve_revset(&self, revset: &str) -> Result<Vec<String>> {
self.vcs.resolve_revisions(revset)
}
pub fn tour_start(&mut self, stops: Vec<TourStop>) -> Result<()> {
if stops.is_empty() {
return Err(TrvError::UnsupportedOperation(
"Tour plan must contain at least one stop".into(),
));
}
self.tour.plan = Some(TourState::new(stops));
self.tour_reload_current_stop()?;
if let Some(tour) = self.tour.plan.as_ref()
&& let Some(stop) = tour.current()
{
let n = tour.stops.len();
self.set_message(format!("Tour 1/{n}: {}", Self::tour_short_summary(stop)));
}
Ok(())
}
fn tour_short_summary(stop: &TourStop) -> String {
const MAX_WIDTH: usize = 80;
let s = stop.summary.trim();
if s.width() <= MAX_WIDTH {
return s.to_string();
}
let budget = MAX_WIDTH.saturating_sub(1);
let mut out = String::new();
let mut used = 0usize;
for ch in s.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + w > budget {
break;
}
out.push(ch);
used += w;
}
out.push('\u{2026}');
out
}
pub fn tour_goto(&mut self, index: usize) -> Result<()> {
let Some(tour) = self.tour.plan.as_mut() else {
return Err(TrvError::UnsupportedOperation(
"No active tour — call trv_tour_set_plan first".into(),
));
};
let total = tour.stops.len();
if index >= total {
return Err(TrvError::UnsupportedOperation(format!(
"Stop {index} out of range (tour has {total} stops)"
)));
}
tour.index = index;
self.tour_reload_current_stop()?;
if let Some(stop) = self.tour.plan.as_ref().and_then(|t| t.current()) {
self.set_message(format!(
"Tour {}/{}: {}",
index + 1,
total,
Self::tour_short_summary(stop)
));
}
Ok(())
}
pub fn tour_next(&mut self) -> Result<()> {
let Some(tour) = self.tour.plan.as_ref() else {
return Ok(());
};
if tour.index + 1 >= tour.stops.len() {
self.set_message("Tour complete — at last stop");
return Ok(());
}
let next = tour.index + 1;
self.tour_goto(next)
}
pub fn tour_prev(&mut self) -> Result<()> {
let Some(tour) = self.tour.plan.as_ref() else {
return Ok(());
};
if tour.index == 0 {
self.set_message("At first stop");
return Ok(());
}
let prev = tour.index - 1;
self.tour_goto(prev)
}
pub fn tour_rewind(&mut self) -> Result<()> {
let Some(tour) = self.tour.plan.as_ref() else {
self.set_message("No active tour");
return Ok(());
};
if tour.index == 0 {
self.set_message("Already at the first stop");
return Ok(());
}
self.tour_goto(0)
}
pub fn tour_end(&mut self) {
self.tour.end_tour();
self.set_message("Tour ended");
}
pub fn tour_record_comment(&mut self, comment_id: String, file: String, line: u32) {
let Some(tour) = self.tour.plan.as_ref() else {
return;
};
let Some(stop) = tour.current() else {
return;
};
self.tour.record_comment_meta(
comment_id,
TourCommentMeta {
stop_index: tour.index,
stop_commit_shas: stop.commit_ids.clone(),
file,
line,
},
);
}
pub fn tour_set_triage(
&mut self,
comment_id: &str,
verdict: TourTriageVerdict,
reasoning: String,
new_location: Option<NewCommentLocation>,
) -> Result<()> {
if !self.tour.is_tour_comment(comment_id) {
return Err(TrvError::UnsupportedOperation(format!(
"Comment {comment_id} is not a tour comment (or was not added via MCP)"
)));
}
if matches!(verdict, TourTriageVerdict::Moved) && new_location.is_none() {
return Err(TrvError::UnsupportedOperation(
"Moved verdict requires a new_location".into(),
));
}
self.tour.set_triage(
comment_id.to_string(),
CommentTriage {
verdict,
reasoning,
new_location,
},
);
Ok(())
}
pub fn tour_take_granularity_hint(&mut self) -> Option<GranularityHint> {
self.tour.take_granularity_hint()
}
pub fn tour_set_granularity_hint(&mut self, hint: GranularityHint) {
self.tour.set_granularity_hint(hint);
let label = match hint {
GranularityHint::Coarser => "coarser (agent will batch more commits)",
GranularityHint::Finer => "finer (agent will split batched stops)",
};
self.set_message(format!("Tour granularity hint: {label}"));
}
pub fn sync_tour_to_session(&mut self) {
let session = self.engine.session_mut();
session.tour = self.tour.plan.clone();
session.tour_comment_meta = self.tour.comment_meta.clone();
session.tour_triage = self.tour.triage.clone();
}
pub fn tour_triage_counts(&self) -> (usize, usize, usize) {
self.tour.triage_counts()
}
pub fn tour_get_threshold(&self) -> Option<RiskScore> {
self.tour.plan.as_ref().map(|t| t.threshold)
}
pub fn tour_date_range(&mut self) -> Option<String> {
let stop = self.tour.plan.as_ref()?.current()?;
let first = stop.first_sha().to_string();
let last = stop.last_sha().to_string();
if first.is_empty() {
return None;
}
if let Some((f, l, cached)) = &self.tour.date_range_cache
&& f == &first
&& l == &last
{
return Some(cached.clone());
}
let ids = if first == last {
vec![first.clone()]
} else {
vec![first.clone(), last.clone()]
};
let infos = self.vcs.get_commits_info(&ids).ok()?;
if infos.is_empty() {
return None;
}
let first_date = infos.first()?.time.format("%Y-%m-%d").to_string();
let last_date = infos.last()?.time.format("%Y-%m-%d").to_string();
let formatted = if first_date == last_date {
first_date
} else {
format!("{first_date} → {last_date}")
};
self.tour.date_range_cache = Some((first, last, formatted.clone()));
Some(formatted)
}
pub fn tour_commit_risk_detail(&self, sha: &str) -> Result<CommitRiskDetail> {
let diff_files = self.vcs.get_commit_range_diff(&[sha.to_string()])?;
let mut file_scores = Vec::with_capacity(diff_files.len());
let mut type_set: std::collections::HashSet<travelagent_core::risk::ChangeType> =
std::collections::HashSet::new();
let mut files_for_commit: Vec<(
std::path::PathBuf,
Vec<travelagent_core::model::DiffHunk>,
)> = Vec::with_capacity(diff_files.len());
for df in &diff_files {
let path = df.display_path_lossy().clone();
let hunks = df.hunks.clone();
for hunk in &hunks {
for kind in
travelagent_core::risk::detect_change_types(&path, hunk, &self.risk_config)
{
type_set.insert(kind);
}
}
let risk = travelagent_core::risk::score_file(&path, &hunks, &self.risk_config);
file_scores.push(FileRiskDetail {
file: path.to_string_lossy().into_owned(),
risk,
});
files_for_commit.push((path, hunks));
}
let risk = score_commit(&files_for_commit, &self.risk_config);
let mut change_types: Vec<_> = type_set.into_iter().collect();
change_types.sort_by_key(|c| c.id());
Ok(CommitRiskDetail {
sha: sha.to_string(),
risk,
change_types,
file_scores,
})
}
pub fn tour_score_commits(&mut self, shas: &[String]) -> Result<Vec<ScoredCommit>> {
let mut out = Vec::with_capacity(shas.len());
for sha in shas {
if let Some(cached) = self.tour.cached_score(sha) {
out.push(cached.clone());
continue;
}
let diff_files = self.vcs.get_commit_range_diff(std::slice::from_ref(sha))?;
let files_for_commit: Vec<(std::path::PathBuf, Vec<_>)> = diff_files
.iter()
.map(|df| (df.display_path_lossy().clone(), df.hunks.clone()))
.collect();
let risk = score_commit(&files_for_commit, &self.risk_config);
let scored = ScoredCommit {
sha: sha.clone(),
risk,
summary: String::new(),
};
self.tour.cache_score(sha.clone(), scored.clone());
out.push(scored);
}
Ok(out)
}
pub fn invalidate_tour_score_cache(&mut self) {
self.tour.invalidate_score_cache();
}
pub fn tour_set_aggressiveness(&mut self, agg: TourAggressiveness) -> Result<usize> {
let Some(tour) = self.tour.plan.as_ref() else {
return Err(TrvError::UnsupportedOperation(
"No active tour to retarget".into(),
));
};
let mut shas: Vec<String> = Vec::new();
for stop in &tour.stops {
for sha in &stop.commit_ids {
shas.push(sha.clone());
}
}
let scored = self.tour_score_commits(&shas)?;
let threshold = agg.threshold();
let new_stops = build_tour_stops(&scored, threshold);
self.tour.plan = Some(TourState::new_with_threshold(new_stops, threshold));
let count = self
.tour
.plan
.as_ref()
.map(|t| t.stops.len())
.unwrap_or_default();
if count > 0 {
self.tour_reload_current_stop()?;
}
Ok(count)
}
pub fn tour_set_threshold(&mut self, level: u8) -> Result<usize> {
if level > 5 {
return Err(TrvError::UnsupportedOperation(
"threshold must be 0..=5".into(),
));
}
self.tour_set_aggressiveness(TourAggressiveness::Custom(RiskScore::new(level)))
}
fn tour_reload_current_stop(&mut self) -> Result<()> {
let stop = match self.tour.plan.as_ref().and_then(|t| t.current()) {
Some(s) => s.clone(),
None => return Ok(()),
};
let highlighter = self.theme.syntax_highlighter();
let mut diff_files = self.vcs.get_commit_range_diff(&stop.commit_ids)?;
travelagent_core::syntax::decorate_diff_files(&mut diff_files, highlighter);
self.engine.apply_diff_files(&diff_files);
self.diff_files = diff_files;
self.diff_source = DiffSource::CommitRange(stop.commit_ids.clone());
self.nav.input_mode = InputMode::Normal;
self.diff_state = DiffState::default();
self.file_list_state = FileListState::default();
self.clear_expanded_gaps();
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use travelagent_core::risk::RiskScore;
use unicode_width::UnicodeWidthStr;
fn stop_with(summary: &str) -> TourStop {
TourStop {
commit_ids: vec!["deadbeef".into()],
summary: summary.into(),
risk: RiskScore::MIN,
}
}
#[test]
fn tour_short_summary_ascii_under_limit_is_unchanged() {
let stop = stop_with("fix off-by-one in paginator");
let got = App::tour_short_summary(&stop);
assert_eq!(got, "fix off-by-one in paginator");
assert!(!got.contains('\u{2026}'));
}
#[test]
fn tour_short_summary_ascii_over_limit_is_truncated_with_ellipsis() {
let long = "a".repeat(120);
let stop = stop_with(&long);
let got = App::tour_short_summary(&stop);
assert!(
got.ends_with('\u{2026}'),
"expected trailing ellipsis, got: {got:?}"
);
assert!(
got.width() <= 80,
"truncated summary wider than 80 cols: width={}",
got.width()
);
let body: String = got.chars().filter(|c| *c != '\u{2026}').collect();
assert!(body.chars().all(|c| c == 'a'));
}
#[test]
fn tour_short_summary_emoji_does_not_split_codepoint() {
let short = stop_with("🎉 ship v1 🚀");
let got = App::tour_short_summary(&short);
assert_eq!(got, "🎉 ship v1 🚀");
let long_emoji: String = "🎉".repeat(100); let stop = stop_with(&long_emoji);
let got = App::tour_short_summary(&stop);
assert!(got.ends_with('\u{2026}'));
assert!(
got.width() <= 80,
"emoji truncation exceeded 80 cols: width={}",
got.width()
);
for ch in got.chars().filter(|c| *c != '\u{2026}') {
assert_eq!(ch, '🎉');
}
}
#[test]
fn tour_short_summary_cjk_does_not_panic() {
let stop = stop_with("修复了一个错误的bug");
let got = App::tour_short_summary(&stop);
assert_eq!(got, "修复了一个错误的bug");
let long_cjk: String = "修".repeat(100);
let stop = stop_with(&long_cjk);
let got = App::tour_short_summary(&stop);
assert!(got.ends_with('\u{2026}'));
assert!(
got.width() <= 80,
"CJK truncation exceeded 80 cols: width={}",
got.width()
);
for ch in got.chars().filter(|c| *c != '\u{2026}') {
assert_eq!(ch, '修');
}
}
}
#[cfg(test)]
mod tour_score_cache_tests {
use super::*;
use crate::app::{DiffSource, InputMode};
use crate::theme::Theme;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use travelagent_core::model::{
DiffFile, DiffLine, FileStatus, ReviewSession, SessionDiffSource,
};
use travelagent_core::vcs::{VcsBackend, VcsInfo, VcsType};
struct CountingVcs {
info: VcsInfo,
calls: Arc<AtomicUsize>,
}
impl VcsBackend for CountingVcs {
fn info(&self) -> &VcsInfo {
&self.info
}
fn get_working_tree_diff(&self) -> Result<Vec<DiffFile>> {
Err(TrvError::NoChanges)
}
fn get_commit_range_diff(&self, _commit_ids: &[String]) -> Result<Vec<DiffFile>> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(Vec::new())
}
fn fetch_context_lines(
&self,
_file_path: &Path,
_file_status: FileStatus,
_start_line: u32,
_end_line: u32,
) -> Result<Vec<DiffLine>> {
Ok(Vec::new())
}
}
fn build_app_with_counter() -> (App, Arc<AtomicUsize>) {
let calls = Arc::new(AtomicUsize::new(0));
let vcs_info = VcsInfo {
root_path: PathBuf::from("/tmp"),
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,
);
let app = App::build(
Box::new(CountingVcs {
info: vcs_info.clone(),
calls: calls.clone(),
}),
vcs_info,
Theme::dark(),
None,
false,
Vec::new(),
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");
(app, calls)
}
#[test]
fn tour_score_commits_hits_cache_on_repeat_sha() {
let (mut app, calls) = build_app_with_counter();
let shas = vec!["aaa".to_string()];
let first = app.tour_score_commits(&shas).unwrap();
assert_eq!(first.len(), 1);
assert_eq!(calls.load(Ordering::SeqCst), 1);
let second = app.tour_score_commits(&shas).unwrap();
assert_eq!(second.len(), 1);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"cached SHA must not trigger a second diff call"
);
assert_eq!(first[0].sha, second[0].sha);
}
#[test]
fn tour_score_commits_caches_each_unique_sha() {
let (mut app, calls) = build_app_with_counter();
let shas = vec!["aaa".to_string(), "bbb".to_string()];
app.tour_score_commits(&shas).unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 2);
app.tour_score_commits(&shas).unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert_eq!(app.tour.score_cache.len(), 2);
}
#[test]
fn invalidate_tour_score_cache_forces_rescore() {
let (mut app, calls) = build_app_with_counter();
let shas = vec!["aaa".to_string()];
app.tour_score_commits(&shas).unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
app.risk_config = travelagent_core::risk::RiskConfig::default();
app.invalidate_tour_score_cache();
assert!(app.tour.score_cache.is_empty());
app.tour_score_commits(&shas).unwrap();
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"invalidation must force a re-diff"
);
}
fn three_stop_plan() -> Vec<TourStop> {
vec![
TourStop {
commit_ids: vec!["aaa".into()],
summary: "stop one".into(),
risk: travelagent_core::risk::RiskScore::MIN,
},
TourStop {
commit_ids: vec!["bbb".into()],
summary: "stop two".into(),
risk: travelagent_core::risk::RiskScore::MIN,
},
TourStop {
commit_ids: vec!["ccc".into()],
summary: "stop three".into(),
risk: travelagent_core::risk::RiskScore::MIN,
},
]
}
#[test]
fn tour_rewind_jumps_to_first_stop() {
let (mut app, _) = build_app_with_counter();
app.tour_start(three_stop_plan()).unwrap();
app.tour_next().unwrap();
app.tour_next().unwrap();
assert_eq!(app.tour.plan.as_ref().unwrap().index, 2, "at last stop");
app.tour_rewind().unwrap();
assert_eq!(
app.tour.plan.as_ref().unwrap().index,
0,
"rewind returns to the first stop"
);
}
#[test]
fn tour_rewind_noop_without_active_tour() {
let (mut app, _) = build_app_with_counter();
app.tour_rewind().unwrap();
assert!(app.tour.plan.is_none());
}
#[test]
fn tour_goto_is_zero_based_internally() {
let (mut app, _) = build_app_with_counter();
app.tour_start(three_stop_plan()).unwrap();
app.tour_goto(1).unwrap();
assert_eq!(app.tour.plan.as_ref().unwrap().index, 1);
assert!(app.tour_goto(99).is_err());
}
}