cflx 0.6.130

Conflux – a spec-driven parallel coding orchestrator that runs AI agents on git worktrees
use crate::tui::events::LogEntry;

use super::AppState;

impl AppState {
    pub(crate) fn handle_apply_output(
        &mut self,
        change_id: String,
        output: String,
        iteration: Option<u32>,
    ) {
        if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
            if matches!(change.display_status_cache.as_str(), "applying") {
                change.update_iteration_monotonic(iteration);
            }
        }

        self.add_log(
            LogEntry::info(output)
                .with_change_id(change_id)
                .with_operation("apply")
                .with_iteration(iteration.unwrap_or(1)),
        );
    }

    pub(crate) fn handle_archive_output(
        &mut self,
        change_id: String,
        output: String,
        iteration: u32,
    ) {
        if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
            if matches!(change.display_status_cache.as_str(), "archiving") {
                change.update_iteration_monotonic(Some(iteration));
            }
        }

        self.add_log(
            LogEntry::info(output)
                .with_change_id(change_id)
                .with_operation("archive")
                .with_iteration(iteration),
        );
    }

    pub(crate) fn handle_acceptance_output(
        &mut self,
        change_id: String,
        output: String,
        iteration: Option<u32>,
    ) {
        if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
            if matches!(change.display_status_cache.as_str(), "accepting") {
                change.update_iteration_monotonic(iteration);
            }
        }

        self.add_log(
            LogEntry::info(output)
                .with_change_id(change_id)
                .with_operation("acceptance")
                .with_iteration(iteration.unwrap_or(1)),
        );
    }

    pub(crate) fn handle_analysis_output(&mut self, output: String, iteration: u32) {
        self.add_log(
            LogEntry::info(output)
                .with_operation("analysis")
                .with_iteration(iteration),
        );
    }

    pub(crate) fn handle_resolve_output(
        &mut self,
        change_id: String,
        output: String,
        iteration: Option<u32>,
    ) {
        if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
            if matches!(change.display_status_cache.as_str(), "resolving") {
                change.update_iteration_monotonic(iteration);
            }
        }

        self.add_log(
            LogEntry::info(output)
                .with_change_id(&change_id)
                .with_operation("resolve")
                .with_iteration(iteration.unwrap_or(1)),
        );
    }

    pub(crate) fn handle_log(&mut self, entry: LogEntry) {
        self.add_log(entry);
    }

    pub(crate) fn handle_warning(&mut self, title: String, message: String) {
        if title != "Uncommitted Changes Detected" {
            self.show_warning_popup(title, message.clone());
        }
        self.add_log(LogEntry::warn(message));
    }

    pub(crate) fn handle_change_rejected(&mut self, change_id: String, reason: String) {
        self.reset_analysis_log_dedupe();
        if let Some(change) = self
            .changes
            .iter_mut()
            .find(|change| change.id == change_id)
        {
            change.set_display_status_cache("rejected");
            change.selected = false;
        }

        self.add_log(LogEntry::warn(format!(
            "Change rejected: {} ({})",
            change_id, reason
        )));
    }

    pub(crate) fn handle_parallel_start_rejected(
        &mut self,
        change_ids: Vec<String>,
        reason: String,
    ) {
        self.reset_analysis_log_dedupe();
        let mut reset_ids = Vec::new();
        for change in &mut self.changes {
            if change_ids.contains(&change.id)
                && matches!(change.display_status_cache.as_str(), "queued")
            {
                change.set_display_status_cache("not queued");
                reset_ids.push(change.id.clone());
            }
        }

        if reset_ids.is_empty() {
            return;
        }

        if let Some(shared) = &self.shared_orchestrator_state {
            if let Ok(mut guard) = shared.try_write() {
                for id in &reset_ids {
                    guard.apply_command(
                        crate::orchestration::state::ReducerCommand::RemoveFromQueue(id.clone()),
                    );
                }
            }
        }

        self.add_log(LogEntry::warn(format!(
            "Not started ({}): {}",
            reason,
            reset_ids.join(", ")
        )));
    }

    pub(crate) fn handle_error(&mut self, message: String) {
        self.reset_analysis_log_dedupe();
        self.add_log(LogEntry::error(message.clone()));
        self.mode = crate::tui::types::AppMode::Error;
        self.error_change_id = None;
        self.current_change = None;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::openspec::{Change, ProposalMetadata};
    use crate::remote::types::RemoteLogEntry;
    use crate::tui::events::{LogEntry, LogLevel, 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 warning_for_uncommitted_changes_is_logged_only() {
        let changes = vec![create_test_change("change-a", 0, 1)];
        let mut app = AppState::new(changes);

        app.handle_orchestrator_event(OrchestratorEvent::Warning {
            title: "Uncommitted Changes Detected".to_string(),
            message: "Warning: Uncommitted changes detected.".to_string(),
        });

        assert!(app.warning_popup.is_none());
        assert!(app
            .logs
            .iter()
            .any(|log| log.message.contains("Warning: Uncommitted")));
    }

    #[test]
    fn remote_change_update_keeps_progress_monotonic() {
        let changes = vec![create_test_change("MyProj/feat", 4, 5)];
        let mut app = AppState::new(changes);

        app.handle_orchestrator_event(OrchestratorEvent::RemoteChangeUpdate {
            id: "MyProj/feat".to_string(),
            completed_tasks: 2,
            total_tasks: 5,
            status: None,
            iteration_number: None,
        });

        assert_eq!(app.changes[0].completed_tasks, 4);
    }

    #[test]
    fn remote_change_update_keeps_iteration_monotonic() {
        let changes = vec![create_test_change("MyProj/feat", 1, 5)];
        let mut app = AppState::new(changes);

        app.handle_orchestrator_event(OrchestratorEvent::RemoteChangeUpdate {
            id: "MyProj/feat".to_string(),
            completed_tasks: 2,
            total_tasks: 5,
            status: None,
            iteration_number: Some(3),
        });
        app.handle_orchestrator_event(OrchestratorEvent::RemoteChangeUpdate {
            id: "MyProj/feat".to_string(),
            completed_tasks: 3,
            total_tasks: 5,
            status: None,
            iteration_number: Some(2),
        });

        assert_eq!(app.changes[0].iteration_number, Some(3));
    }

    #[test]
    fn remote_log_event_is_added() {
        let mut app = AppState::new(vec![create_test_change("proj/change-a", 0, 3)]);
        let initial = app.logs.len();

        let entry = LogEntry {
            timestamp: "12:00:00".to_string(),
            created_at: chrono::Utc::now(),
            message: "remote stdout: cargo build succeeded".to_string(),
            color: ratatui::style::Color::Reset,
            level: LogLevel::Info,
            change_id: Some("change-a".to_string()),
            operation: None,
            iteration: None,
            workspace_path: None,
        };

        app.handle_orchestrator_event(OrchestratorEvent::Log(entry.clone()));

        assert!(app.logs.len() > initial);
        let last = app.logs.last().expect("at least one log entry");
        assert_eq!(last.message, entry.message);
        assert_eq!(last.change_id, entry.change_id);
    }

    #[test]
    fn remote_log_entry_project_id_round_trip() {
        let entry = RemoteLogEntry {
            message: "stdout: tests passed".to_string(),
            level: "info".to_string(),
            change_id: None,
            timestamp: "2024-01-01T00:00:00Z".to_string(),
            project_id: Some("proj-abc123".to_string()),
            operation: Some("apply".to_string()),
            iteration: Some(2),
        };

        let json = serde_json::to_string(&entry).unwrap();
        let decoded: RemoteLogEntry = serde_json::from_str(&json).unwrap();

        assert_eq!(decoded.project_id, entry.project_id);
        assert_eq!(decoded.operation, entry.operation);
        assert_eq!(decoded.iteration, entry.iteration);
    }

    #[test]
    fn change_rejected_clears_only_target_selection() {
        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].selected = true;
        app.changes[1].selected = true;
        app.changes[0].display_status_cache = "queued".to_string();
        app.changes[1].display_status_cache = "queued".to_string();

        app.handle_change_rejected("change-a".to_string(), "blocked by review".to_string());

        assert_eq!(app.changes[0].display_status_cache, "rejected");
        assert!(!app.changes[0].selected);
        assert_eq!(app.changes[1].display_status_cache, "queued");
        assert!(app.changes[1].selected);
    }

    #[test]
    fn parallel_start_rejected_only_clears_target_rows() {
        let changes = vec![
            create_test_change("change-a", 0, 1),
            create_test_change("change-b", 0, 1),
        ];
        let mut app = AppState::new(changes);
        app.mode = AppMode::Running;
        app.changes[0].display_status_cache = "queued".to_string();
        app.changes[1].display_status_cache = "queued".to_string();

        app.handle_parallel_start_rejected(
            vec!["change-a".to_string()],
            "uncommitted or not in HEAD".to_string(),
        );

        assert_eq!(app.changes[0].display_status_cache, "not queued");
        assert_eq!(app.changes[1].display_status_cache, "queued");
    }
}