use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use ag_git::GitClient;
use tokio::sync::mpsc;
use super::core::AppEvent;
use super::task;
use crate::app::session_state::SessionState;
use crate::domain::agent::{AgentModel, AgentSelection};
use crate::domain::session::{SessionId, Status};
use crate::domain::session_message::SessionTranscript;
use crate::infra::db::SessionFocusedReviewRow;
#[derive(Debug)]
pub(crate) enum ReviewCacheEntry {
Loading {
diff_hash: u64,
},
Ready {
diff_hash: u64,
text: String,
},
Failed {
diff_hash: u64,
error: String,
},
Suppressed {
diff_hash: u64,
},
}
impl ReviewCacheEntry {
pub(crate) fn diff_hash(&self) -> u64 {
match self {
Self::Loading { diff_hash }
| Self::Ready { diff_hash, .. }
| Self::Failed { diff_hash, .. }
| Self::Suppressed { diff_hash } => *diff_hash,
}
}
pub(crate) fn from_result(diff_hash: u64, result: &Result<String, String>) -> Self {
match result {
Ok(review_text) => Self::Ready {
diff_hash,
text: review_text.clone(),
},
Err(error) => Self::Failed {
diff_hash,
error: error.clone(),
},
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ReviewUpdate {
pub(crate) diff_hash: u64,
pub(crate) result: Result<String, String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct FocusedReviewPersistence {
pub(crate) diff_hash: Option<u64>,
pub(crate) session_id: SessionId,
pub(crate) text: Option<String>,
}
const REVIEW_LOADING_MESSAGE_PREFIX: &str = "Reviewing changes with";
pub(crate) fn diff_content_hash(diff: &str) -> u64 {
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
diff.as_bytes().iter().fold(FNV_OFFSET_BASIS, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME)
})
}
pub(crate) fn is_review_loading_status_message(status_message: &str) -> bool {
status_message.starts_with(REVIEW_LOADING_MESSAGE_PREFIX)
}
pub(crate) fn review_loading_message(review_model: AgentModel) -> String {
format!("{REVIEW_LOADING_MESSAGE_PREFIX} {}", review_model.as_str())
}
pub(crate) fn review_view_state<'a>(
review_cache: &'a HashMap<SessionId, ReviewCacheEntry>,
session_id: &str,
review_model: AgentModel,
) -> (Option<String>, Option<&'a str>) {
let Some(cache_entry) = review_cache.get(session_id) else {
return (None, None);
};
match cache_entry {
ReviewCacheEntry::Loading { .. } => (Some(review_loading_message(review_model)), None),
ReviewCacheEntry::Ready { text, .. } => (None, Some(text.as_str())),
ReviewCacheEntry::Failed { error, .. } => (
Some(format!("Review assist unavailable: {}", error.trim())),
None,
),
ReviewCacheEntry::Suppressed { .. } => (None, None),
}
}
pub(crate) fn review_cache_from_rows(
focused_review_rows: Vec<SessionFocusedReviewRow>,
) -> HashMap<SessionId, ReviewCacheEntry> {
focused_review_rows
.into_iter()
.filter_map(|row| {
let diff_hash = row.diff_hash.parse::<u64>().ok()?;
Some((
SessionId::from(row.session_id),
ReviewCacheEntry::Ready {
diff_hash,
text: row.text,
},
))
})
.collect()
}
pub(crate) fn cancel_pending_review(
review_cache: &mut HashMap<SessionId, ReviewCacheEntry>,
session_id: &str,
) -> bool {
let Some(ReviewCacheEntry::Loading { .. }) = review_cache.get(session_id) else {
return false;
};
review_cache.remove(session_id);
true
}
pub(crate) fn start_review_assist(
app_event_tx: mpsc::UnboundedSender<AppEvent>,
review_selection: AgentSelection,
session_id: &str,
session_folder: &Path,
diff_hash: u64,
review_diff: &str,
session_chat_history: Option<&str>,
) {
task::TaskService::spawn_review_assist_task(task::ReviewAssistTaskInput {
app_event_tx,
diff_hash,
review_diff: review_diff.to_string(),
review_selection,
session_chat_history: session_chat_history.map(str::to_string),
session_folder: session_folder.to_path_buf(),
session_id: SessionId::from(session_id),
});
}
pub(crate) fn mark_session_agent_review(session_state: &mut SessionState, session_id: &str) {
update_transient_review_status(
session_state,
session_id,
Status::Review,
Status::AgentReview,
);
}
pub(crate) fn apply_review_updates(
review_cache: &mut HashMap<SessionId, ReviewCacheEntry>,
session_state: &mut SessionState,
review_updates: HashMap<SessionId, ReviewUpdate>,
) -> Vec<FocusedReviewPersistence> {
let mut persistence_updates = Vec::new();
for (session_id, review_update) in review_updates {
if let Some(persistence_update) =
apply_review_update(review_cache, session_state, &session_id, review_update)
{
persistence_updates.push(persistence_update);
}
}
persistence_updates
}
pub(crate) async fn auto_start_reviews(
review_cache: &mut HashMap<SessionId, ReviewCacheEntry>,
session_ids: &HashSet<SessionId>,
session_state: &mut SessionState,
git_client: Arc<dyn GitClient>,
app_event_tx: mpsc::UnboundedSender<AppEvent>,
review_selection: AgentSelection,
) {
for session_id in session_ids {
let Some(session) = session_state
.sessions()
.iter()
.find(|session| session.id == *session_id)
else {
continue;
};
let current_status = session.status;
if current_status == Status::InProgress {
review_cache.remove(session_id);
continue;
}
if current_status != Status::Review {
continue;
}
if matches!(
review_cache.get(session_id),
Some(ReviewCacheEntry::Suppressed { .. })
) {
continue;
}
let base_branch = session.base_branch.clone();
let session_chat_history = session
.transcript
.as_ref()
.and_then(SessionTranscript::conversation_replay_text);
let session_folder = session.folder.clone();
let diff = git_client
.diff(session_folder.clone(), base_branch)
.await
.unwrap_or_default();
if diff.trim().is_empty() || diff.starts_with("Failed to run git diff:") {
continue;
}
let new_hash = diff_content_hash(&diff);
if review_cache
.get(session_id)
.is_some_and(|entry| entry.diff_hash() == new_hash)
{
continue;
}
review_cache.insert(
session_id.clone(),
ReviewCacheEntry::Loading {
diff_hash: new_hash,
},
);
mark_session_agent_review(session_state, session_id);
start_review_assist(
app_event_tx.clone(),
review_selection,
session_id,
&session_folder,
new_hash,
&diff,
session_chat_history.as_deref(),
);
}
}
fn apply_review_update(
review_cache: &mut HashMap<SessionId, ReviewCacheEntry>,
session_state: &mut SessionState,
session_id: &str,
review_update: ReviewUpdate,
) -> Option<FocusedReviewPersistence> {
let ReviewUpdate { diff_hash, result } = review_update;
let cache_entry = review_cache.get(session_id)?;
if !matches!(cache_entry, ReviewCacheEntry::Loading { .. })
|| cache_entry.diff_hash() != diff_hash
{
return None;
}
let persistence_update = FocusedReviewPersistence {
diff_hash: result.as_ref().ok().map(|_| diff_hash),
session_id: SessionId::from(session_id),
text: result.as_ref().ok().cloned(),
};
review_cache.insert(
SessionId::from(session_id),
ReviewCacheEntry::from_result(diff_hash, &result),
);
restore_session_review_status(session_state, session_id);
Some(persistence_update)
}
fn restore_session_review_status(session_state: &mut SessionState, session_id: &str) {
update_transient_review_status(
session_state,
session_id,
Status::AgentReview,
Status::Review,
);
}
fn update_transient_review_status(
session_state: &mut SessionState,
session_id: &str,
current_status: Status,
next_status: Status,
) {
if let Some(session) = session_state
.sessions_mut()
.iter_mut()
.find(|session| session.id == session_id)
&& session.status == current_status
{
session.status = next_status;
}
if let Some(handles) = session_state.handles().get(session_id)
&& let Ok(mut handle_status) = handles.status.lock()
&& *handle_status == current_status
{
*handle_status = next_status;
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use ratatui::widgets::TableState;
use super::*;
use crate::app::session_state::SessionState;
use crate::infra::clock::RealClock;
fn empty_session_state() -> SessionState {
SessionState::new(
HashMap::new(),
Vec::new(),
TableState::default(),
Arc::new(RealClock),
0,
0,
)
}
fn loading_review_cache(
session_id: &SessionId,
diff_hash: u64,
) -> HashMap<SessionId, ReviewCacheEntry> {
HashMap::from([(session_id.clone(), ReviewCacheEntry::Loading { diff_hash })])
}
fn successful_review_update(
session_id: &SessionId,
diff_hash: u64,
review_text: &str,
) -> HashMap<SessionId, ReviewUpdate> {
HashMap::from([(
session_id.clone(),
ReviewUpdate {
diff_hash,
result: Ok(review_text.to_string()),
},
)])
}
#[test]
fn review_loading_message_uses_requested_model_name() {
let review_model = AgentModel::Gpt55;
let message = review_loading_message(review_model);
assert_eq!(message, "Reviewing changes with gpt-5.5");
}
#[test]
fn is_review_loading_status_message_matches_model_aware_copy() {
let status_message = review_loading_message(AgentModel::ClaudeOpus48);
let is_loading = is_review_loading_status_message(&status_message);
assert!(is_loading);
}
#[test]
fn review_view_state_uses_loading_message_for_cached_review_generation() {
let mut review_cache = HashMap::new();
review_cache.insert(
"session-id".into(),
ReviewCacheEntry::Loading { diff_hash: 7 },
);
let (review_status_message, review_text) =
review_view_state(&review_cache, "session-id", AgentModel::ClaudeSonnet5);
assert_eq!(
review_status_message.as_deref(),
Some("Reviewing changes with claude-sonnet-5")
);
assert_eq!(review_text, None);
}
#[test]
fn review_view_state_hides_suppressed_auto_review() {
let mut review_cache = HashMap::new();
review_cache.insert(
"session-id".into(),
ReviewCacheEntry::Suppressed { diff_hash: 7 },
);
let (review_status_message, review_text) =
review_view_state(&review_cache, "session-id", AgentModel::ClaudeSonnet5);
assert_eq!(review_status_message, None);
assert_eq!(review_text, None);
}
#[test]
fn review_cache_from_rows_restores_persisted_ready_review() {
let focused_review_rows = vec![SessionFocusedReviewRow {
diff_hash: "42".to_string(),
session_id: "session-id".to_string(),
text: "## Review\nPersisted finding.".to_string(),
}];
let review_cache = review_cache_from_rows(focused_review_rows);
assert!(matches!(
review_cache.get("session-id"),
Some(ReviewCacheEntry::Ready { diff_hash: 42, text })
if text == "## Review\nPersisted finding."
));
}
#[test]
fn apply_review_updates_returns_success_for_persistence() {
let session_id = SessionId::from("session-persist-review");
let diff_hash = 19;
let review_text = "## Review\nPersist this finding.";
let mut review_cache = loading_review_cache(&session_id, diff_hash);
let mut session_state = empty_session_state();
let review_updates = successful_review_update(&session_id, diff_hash, review_text);
let persistence_updates =
apply_review_updates(&mut review_cache, &mut session_state, review_updates);
assert_eq!(
persistence_updates,
vec![FocusedReviewPersistence {
diff_hash: Some(diff_hash),
session_id,
text: Some(review_text.to_string()),
}]
);
}
#[test]
fn apply_review_updates_returns_clear_for_failed_regeneration() {
let session_id = SessionId::from("session-failed-review");
let diff_hash = 29;
let mut review_cache = loading_review_cache(&session_id, diff_hash);
let mut session_state = empty_session_state();
let review_updates = HashMap::from([(
session_id.clone(),
ReviewUpdate {
diff_hash,
result: Err("provider failed".to_string()),
},
)]);
let persistence_updates =
apply_review_updates(&mut review_cache, &mut session_state, review_updates);
assert_eq!(
persistence_updates,
vec![FocusedReviewPersistence {
diff_hash: None,
session_id,
text: None,
}]
);
}
#[test]
fn apply_review_updates_writes_success_to_cache() {
let session_id = SessionId::from("session-cache-review");
let diff_hash = 11;
let review_text = "## Review\nCache-backed finding.";
let mut review_cache = loading_review_cache(&session_id, diff_hash);
let mut session_state = empty_session_state();
let review_updates = successful_review_update(&session_id, diff_hash, review_text);
apply_review_updates(&mut review_cache, &mut session_state, review_updates);
assert!(matches!(
review_cache.get(session_id.as_str()),
Some(ReviewCacheEntry::Ready { text, .. }) if text == review_text
));
}
#[test]
fn apply_review_updates_ignores_suppressed_auto_review_entry() {
let session_id = SessionId::from("session-suppressed-review");
let diff_hash = 23;
let mut review_cache = HashMap::from([(
session_id.clone(),
ReviewCacheEntry::Suppressed { diff_hash },
)]);
let mut session_state = empty_session_state();
let review_updates =
successful_review_update(&session_id, diff_hash, "## Review\nShould not be rendered.");
apply_review_updates(&mut review_cache, &mut session_state, review_updates);
assert!(matches!(
review_cache.get(session_id.as_str()),
Some(ReviewCacheEntry::Suppressed { diff_hash: cached_hash })
if *cached_hash == diff_hash
));
}
}