use crate::tui::events::{LogEntry, TuiCommand};
use crate::tui::state::WarningPopup;
use super::AppState;
impl AppState {
pub(crate) fn handle_processing_error(&mut self, id: String, error: String) {
if let Some(change) = self.changes.iter_mut().find(|c| c.id == id) {
change.set_error_message_cache(error.clone());
change.selected = false;
if let Some(started) = change.started_at {
change.elapsed_time = Some(started.elapsed());
}
}
self.add_log(LogEntry::error(format!("Error in {}: {}", id, error)));
self.error_change_id = Some(id.clone());
self.current_change = None;
}
pub(crate) fn handle_apply_failed(&mut self, change_id: String, error: String) {
if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
change.set_error_message_cache(error.clone());
change.selected = false;
if let Some(started) = change.started_at {
change.elapsed_time = Some(started.elapsed());
}
}
self.add_log(LogEntry::error(format!(
"Apply failed for {}: {}",
change_id, error
)));
}
pub(crate) fn handle_archive_failed(&mut self, change_id: String, error: String) {
if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
change.set_error_message_cache(error.clone());
change.selected = false;
if let Some(started) = change.started_at {
change.elapsed_time = Some(started.elapsed());
}
}
self.add_log(LogEntry::error(format!(
"Archive failed for {}: {}",
change_id, error
)));
}
pub(crate) fn handle_resolve_failed(&mut self, change_id: String, error: String) {
self.is_resolving = false;
if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
if change.display_status_cache == "merged" {
self.add_log(LogEntry::info(format!(
"Ignoring ResolveFailed for '{}': already Merged",
change_id
)));
return;
}
change.set_display_status_cache("merge wait");
if let Some(started) = change.started_at {
change.elapsed_time = Some(started.elapsed());
}
}
let message = format!("Failed to resolve merge for '{}': {}", change_id, error);
self.warning_popup = Some(WarningPopup {
title: "Merge resolve failed".to_string(),
message: message.clone(),
});
self.add_log(LogEntry::error(message));
self.try_transition_to_select();
}
pub(crate) fn handle_change_stop_failed(&mut self, change_id: String, error: String) {
self.add_log(LogEntry::error(format!(
"Failed to stop {}: {}",
change_id, error
)));
}
pub(crate) fn handle_hook_failed(
&mut self,
change_id: String,
hook_type: String,
error: String,
) {
if hook_type == "on_merged" {
if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
if change.display_status_cache != "merged" {
change.set_display_status_cache("merge wait");
change.selected = false;
if let Some(started) = change.started_at {
change.elapsed_time = Some(started.elapsed());
}
}
}
let message = format!(
"on_merged hook failed for '{}'; merged transition blocked: {}",
change_id, error
);
self.warning_popup = Some(WarningPopup {
title: "on_merged hook failed".to_string(),
message: message.clone(),
});
self.add_log(LogEntry::error(message));
self.try_transition_to_select();
} else {
self.add_log(LogEntry::error(format!(
"Hook '{}' failed for {}: {}",
hook_type, change_id, error
)));
}
}
pub(crate) fn handle_merge_deferred(
&mut self,
change_id: String,
reason: String,
auto_resumable: bool,
) -> Option<TuiCommand> {
if self.is_resolving {
let is_current_resolving = self
.changes
.iter()
.any(|c| c.id == change_id && c.display_status_cache == "resolving");
if is_current_resolving {
self.add_log(LogEntry::warn(format!(
"Merge deferred for '{}' (currently resolving, not queued): {}",
change_id, reason
)));
} else {
if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
change.set_display_status_cache("resolve pending");
}
if self.add_to_resolve_queue(&change_id) {
self.add_log(LogEntry::warn(format!(
"Merge deferred for '{}' (queued for resolve): {}",
change_id, reason
)));
} else {
self.add_log(LogEntry::warn(format!(
"Merge deferred for '{}' (already queued): {}",
change_id, reason
)));
}
}
None
} else if auto_resumable {
if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
change.set_display_status_cache("resolve pending");
}
self.add_to_resolve_queue(&change_id);
self.add_log(LogEntry::warn(format!(
"Merge deferred for '{}' (auto-resumable, queued scheduler retry intent): {}",
change_id, reason
)));
Some(TuiCommand::ResolveMerge(change_id))
} else {
if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
change.set_display_status_cache("merge wait");
}
self.add_log(LogEntry::warn(format!(
"Merge deferred for {}: {}",
change_id, reason
)));
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openspec::{Change, ProposalMetadata};
use crate::tui::events::OrchestratorEvent;
use crate::tui::types::AppMode;
fn create_test_change(id: &str, completed: u32, total: u32) -> Change {
Change {
id: id.to_string(),
completed_tasks: completed,
total_tasks: total,
last_modified: "now".to_string(),
dependencies: Vec::new(),
metadata: ProposalMetadata::default(),
}
}
#[test]
fn processing_error_keeps_app_mode() {
let changes = vec![create_test_change("test-change", 0, 1)];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.current_change = Some("test-change".to_string());
app.changes[0].selected = true;
app.handle_processing_error("test-change".to_string(), "Test error message".to_string());
assert_eq!(app.mode, AppMode::Running);
let change = app.changes.iter().find(|c| c.id == "test-change").unwrap();
assert_eq!(change.display_status_cache, "error");
assert!(!change.selected);
assert_eq!(app.error_change_id, Some("test-change".to_string()));
assert_eq!(app.current_change, None);
}
#[test]
fn processing_error_from_select_mode() {
let changes = vec![create_test_change("test-change", 0, 1)];
let mut app = AppState::new(changes);
app.mode = AppMode::Select;
app.changes[0].selected = true;
app.handle_processing_error("test-change".to_string(), "Test error message".to_string());
assert_eq!(app.mode, AppMode::Select);
let change = app.changes.iter().find(|c| c.id == "test-change").unwrap();
assert_eq!(change.display_status_cache, "error");
assert!(!change.selected);
}
#[test]
fn handle_on_merged_hook_failed_surfaces_merge_wait_not_merged() {
let changes = vec![create_test_change("change-a", 1, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "archived".to_string();
app.handle_orchestrator_event(OrchestratorEvent::HookFailed {
change_id: "change-a".to_string(),
hook_type: "on_merged".to_string(),
error: "git index lock held".to_string(),
});
assert_eq!(app.changes[0].display_status_cache, "merge wait");
assert!(app.warning_popup.is_some());
assert!(app
.logs
.iter()
.any(|log| log.message.contains("merged transition blocked")));
}
#[test]
fn handle_resolve_failed_does_not_demote_merged() {
let changes = vec![create_test_change("change-a", 1, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "merged".to_string();
app.handle_orchestrator_event(OrchestratorEvent::ResolveFailed {
change_id: "change-a".to_string(),
error: "archive check failed".to_string(),
});
assert_eq!(app.changes[0].display_status_cache, "merged");
assert!(app
.logs
.iter()
.any(|log| log.message.contains("already Merged")));
}
#[test]
fn merge_deferred_transitions_to_resolve_wait_when_resolving() {
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "resolving".to_string();
app.is_resolving = true;
app.changes[1].display_status_cache = "archived".to_string();
app.handle_merge_deferred(
"change-b".to_string(),
"Base branch has uncommitted changes".to_string(),
true,
);
assert_eq!(app.changes[1].display_status_cache, "resolve pending");
assert!(app.resolve_queue_set.contains("change-b"));
}
#[test]
fn merge_deferred_does_not_queue_current_resolving_change() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "resolving".to_string();
app.is_resolving = true;
app.handle_merge_deferred(
"change-a".to_string(),
"Base branch has uncommitted changes".to_string(),
true,
);
assert_eq!(app.changes[0].display_status_cache, "resolving");
assert!(!app.resolve_queue_set.contains("change-a"));
}
#[test]
fn merge_deferred_queues_other_change_while_resolving() {
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "resolving".to_string();
app.is_resolving = true;
app.changes[1].display_status_cache = "archived".to_string();
app.handle_merge_deferred(
"change-b".to_string(),
"Base branch has uncommitted changes".to_string(),
true,
);
assert_eq!(app.changes[1].display_status_cache, "resolve pending");
assert!(app.resolve_queue_set.contains("change-b"));
}
#[test]
fn merge_deferred_maintains_merge_wait_when_not_resolving() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.is_resolving = false;
app.changes[0].display_status_cache = "archived".to_string();
app.handle_merge_deferred(
"change-a".to_string(),
"Base branch has uncommitted changes".to_string(),
false,
);
assert_eq!(app.changes[0].display_status_cache, "merge wait");
assert!(!app.resolve_queue_set.contains("change-a"));
}
#[test]
fn auto_resumable_merge_deferred_shows_resolve_wait_not_merge_wait() {
let changes = vec![create_test_change("change-b", 0, 1)];
let mut app = AppState::new(changes);
app.is_resolving = false;
app.changes[0].display_status_cache = "archived".to_string();
let cmd = app.handle_merge_deferred(
"change-b".to_string(),
"Merge in progress (MERGE_HEAD exists)".to_string(),
true,
);
assert_eq!(app.changes[0].display_status_cache, "resolve pending");
assert!(app.resolve_queue_set.contains("change-b"));
assert!(matches!(cmd, Some(TuiCommand::ResolveMerge(ref id)) if id == "change-b"));
}
#[test]
fn auto_resumable_merge_deferred_starts_resolve_when_idle() {
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
];
let mut app = AppState::new(changes);
app.is_resolving = false;
app.changes[0].display_status_cache = "merged".to_string();
app.changes[1].display_status_cache = "archived".to_string();
let cmd = app.handle_merge_deferred(
"change-b".to_string(),
"Base is dirty: Merge in progress (MERGE_HEAD exists)".to_string(),
true,
);
assert_eq!(app.changes[1].display_status_cache, "resolve pending");
assert!(matches!(cmd, Some(TuiCommand::ResolveMerge(ref id)) if id == "change-b"));
assert!(app.resolve_queue_set.contains("change-b"));
}
#[test]
fn auto_resumable_merge_deferred_queues_when_resolving() {
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
];
let mut app = AppState::new(changes);
app.is_resolving = true;
app.changes[0].display_status_cache = "resolving".to_string();
app.changes[1].display_status_cache = "archived".to_string();
let cmd = app.handle_merge_deferred(
"change-b".to_string(),
"Merge in progress (MERGE_HEAD exists)".to_string(),
true,
);
assert_eq!(app.changes[1].display_status_cache, "resolve pending");
assert!(cmd.is_none());
assert!(app.resolve_queue_set.contains("change-b"));
}
#[test]
fn manual_resolve_merge_in_progress_tui_shows_resolve_wait() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "merge wait".to_string();
app.is_resolving = false;
app.handle_merge_deferred(
"change-a".to_string(),
"Base is dirty: Merge in progress (MERGE_HEAD exists)".to_string(),
true,
);
assert_eq!(app.changes[0].display_status_cache, "resolve pending");
}
#[test]
fn manual_resolve_uncommitted_changes_tui_shows_merge_wait() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "merge wait".to_string();
app.is_resolving = false;
app.handle_resolve_failed(
"change-a".to_string(),
"Base is dirty: Working tree has uncommitted changes".to_string(),
);
assert_eq!(app.changes[0].display_status_cache, "merge wait");
}
#[test]
fn resolve_failed_transitions_to_select_when_no_active() {
let changes = vec![create_test_change("change-a", 3, 3)];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].display_status_cache = "resolving".to_string();
app.is_resolving = true;
app.handle_resolve_failed("change-a".to_string(), "conflict".to_string());
assert_eq!(app.mode, AppMode::Select);
}
}