Skip to main content

ralph_workflow/reducer/
ui_event.rs

1//! UI events for user-facing display.
2//!
3//! UIEvent is separate from PipelineEvent to maintain reducer purity.
4//! These events are emitted by effect handlers alongside PipelineEvents
5//! and are displayed to users but do not affect pipeline state or checkpoints.
6
7use super::event::PipelinePhase;
8use serde::{Deserialize, Serialize};
9
10/// Types of XML output for semantic rendering.
11///
12/// Each XML type has a dedicated renderer that transforms raw XML
13/// into user-friendly terminal output.
14#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
15pub enum XmlOutputType {
16    /// Development result XML (status, summary, files changed).
17    DevelopmentResult,
18    /// Development plan XML (steps, critical files, risks).
19    DevelopmentPlan,
20    /// Review issues XML (list of issues or no-issues-found).
21    ReviewIssues,
22    /// Fix result XML (status, summary of fixes).
23    FixResult,
24    /// Commit message XML (subject, body).
25    CommitMessage,
26}
27
28/// Context for XML output events.
29///
30/// Provides additional context like iteration or pass number
31/// for more informative rendering.
32#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
33pub struct XmlOutputContext {
34    /// Development iteration number (1-based).
35    pub iteration: Option<u32>,
36    /// Review pass number (1-based).
37    pub pass: Option<u32>,
38    /// Optional code snippets to enrich rendering (e.g., review issues).
39    ///
40    /// This allows semantic renderers to show relevant code context even when the
41    /// issue description itself does not embed a fenced code block.
42    #[serde(default)]
43    pub snippets: Vec<XmlCodeSnippet>,
44}
45
46/// A code snippet associated with a file and line range.
47#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
48pub struct XmlCodeSnippet {
49    /// File path (workspace-relative).
50    pub file: String,
51    /// 1-based starting line number (inclusive).
52    pub line_start: u32,
53    /// 1-based ending line number (inclusive).
54    pub line_end: u32,
55    /// Snippet content (may include newlines).
56    pub content: String,
57}
58
59/// UI events for user-facing display during pipeline execution.
60///
61/// These events do NOT affect pipeline state or checkpoints.
62/// They are purely for terminal display and programmatic observation.
63#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
64pub enum UIEvent {
65    /// Phase transition occurred.
66    PhaseTransition {
67        from: Option<PipelinePhase>,
68        to: PipelinePhase,
69    },
70
71    /// Development iteration progress.
72    IterationProgress { current: u32, total: u32 },
73
74    /// Review pass progress.
75    ReviewProgress { pass: u32, total: u32 },
76
77    /// Agent activity notification.
78    AgentActivity { agent: String, message: String },
79
80    /// XML output requiring semantic rendering.
81    ///
82    /// Phase functions emit raw XML content through this event,
83    /// and the event loop renders it with appropriate semantic formatting.
84    XmlOutput {
85        /// The type of XML output (determines renderer).
86        xml_type: XmlOutputType,
87        /// The raw XML content to render.
88        content: String,
89        /// Optional context like iteration or pass number.
90        context: Option<XmlOutputContext>,
91    },
92}
93
94impl UIEvent {
95    /// Get emoji indicator for phase.
96    pub fn phase_emoji(phase: &PipelinePhase) -> &'static str {
97        match phase {
98            PipelinePhase::Planning => "πŸ“‹",
99            PipelinePhase::Development => "πŸ”¨",
100            PipelinePhase::Review => "πŸ‘€",
101            PipelinePhase::CommitMessage => "πŸ“",
102            PipelinePhase::FinalValidation => "βœ…",
103            PipelinePhase::Finalizing => "πŸ”„",
104            PipelinePhase::Complete => "πŸŽ‰",
105            PipelinePhase::AwaitingDevFix => "πŸ”§",
106            PipelinePhase::Interrupted => "⏸️",
107        }
108    }
109
110    /// Format event for terminal display.
111    ///
112    /// This method delegates to the rendering module for actual formatting.
113    /// Prefer calling `rendering::render_ui_event()` directly in new code.
114    pub fn format_for_display(&self) -> String {
115        crate::rendering::render_ui_event(self)
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn test_phase_transition_display() {
125        let event = UIEvent::PhaseTransition {
126            from: Some(PipelinePhase::Planning),
127            to: PipelinePhase::Development,
128        };
129        let display = event.format_for_display();
130        assert!(display.contains("πŸ”¨"));
131        assert!(display.contains("Development"));
132    }
133
134    #[test]
135    fn test_iteration_progress_display() {
136        let event = UIEvent::IterationProgress {
137            current: 2,
138            total: 5,
139        };
140        let display = event.format_for_display();
141        assert!(display.contains("2/5"));
142    }
143
144    #[test]
145    fn test_review_progress_display() {
146        let event = UIEvent::ReviewProgress { pass: 1, total: 3 };
147        let display = event.format_for_display();
148        assert!(display.contains("1/3"));
149        assert!(display.contains("Review pass"));
150    }
151
152    #[test]
153    fn test_agent_activity_display() {
154        let event = UIEvent::AgentActivity {
155            agent: "claude".to_string(),
156            message: "Processing request".to_string(),
157        };
158        let display = event.format_for_display();
159        assert!(display.contains("[claude]"));
160        assert!(display.contains("Processing request"));
161    }
162
163    #[test]
164    fn test_ui_event_serialization() {
165        let event = UIEvent::PhaseTransition {
166            from: None,
167            to: PipelinePhase::Planning,
168        };
169        let json = serde_json::to_string(&event).unwrap();
170        let deserialized: UIEvent = serde_json::from_str(&json).unwrap();
171        assert_eq!(event, deserialized);
172    }
173
174    #[test]
175    fn test_phase_emoji_all_phases() {
176        // Exhaustive test ensures every phase has an emoji
177        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Planning), "πŸ“‹");
178        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Development), "πŸ”¨");
179        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Review), "πŸ‘€");
180        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::CommitMessage), "πŸ“");
181        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::FinalValidation), "βœ…");
182        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Finalizing), "πŸ”„");
183        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Complete), "πŸŽ‰");
184        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::AwaitingDevFix), "πŸ”§");
185        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Interrupted), "⏸️");
186    }
187
188    #[test]
189    fn test_phase_transition_from_none() {
190        // Test initial phase transition with no previous phase
191        let event = UIEvent::PhaseTransition {
192            from: None,
193            to: PipelinePhase::Planning,
194        };
195        let display = event.format_for_display();
196        assert!(display.contains("πŸ“‹"));
197        assert!(display.contains("Planning"));
198    }
199
200    // =========================================================================
201    // XmlOutput Tests
202    // =========================================================================
203
204    #[test]
205    fn test_xml_output_type_serialization() {
206        let xml_type = XmlOutputType::DevelopmentResult;
207        let json = serde_json::to_string(&xml_type).unwrap();
208        let deserialized: XmlOutputType = serde_json::from_str(&json).unwrap();
209        assert_eq!(xml_type, deserialized);
210    }
211
212    #[test]
213    fn test_xml_output_context_default() {
214        let context = XmlOutputContext::default();
215        assert!(context.iteration.is_none());
216        assert!(context.pass.is_none());
217        assert!(context.snippets.is_empty());
218    }
219
220    #[test]
221    fn test_xml_output_context_with_values() {
222        let context = XmlOutputContext {
223            iteration: Some(2),
224            pass: Some(1),
225            snippets: Vec::new(),
226        };
227        assert_eq!(context.iteration, Some(2));
228        assert_eq!(context.pass, Some(1));
229    }
230
231    #[test]
232    fn test_xml_output_event_serialization() {
233        let event = UIEvent::XmlOutput {
234            xml_type: XmlOutputType::ReviewIssues,
235            content: "<ralph-issues><ralph-issue>Test</ralph-issue></ralph-issues>".to_string(),
236            context: Some(XmlOutputContext {
237                iteration: None,
238                pass: Some(1),
239                snippets: Vec::new(),
240            }),
241        };
242        let json = serde_json::to_string(&event).unwrap();
243        let deserialized: UIEvent = serde_json::from_str(&json).unwrap();
244        assert_eq!(event, deserialized);
245    }
246
247    #[test]
248    fn test_xml_output_types_all_variants() {
249        // Ensure all variants are distinct
250        let variants = [
251            XmlOutputType::DevelopmentResult,
252            XmlOutputType::DevelopmentPlan,
253            XmlOutputType::ReviewIssues,
254            XmlOutputType::FixResult,
255            XmlOutputType::CommitMessage,
256        ];
257        for (i, v1) in variants.iter().enumerate() {
258            for (j, v2) in variants.iter().enumerate() {
259                if i == j {
260                    assert_eq!(v1, v2);
261                } else {
262                    assert_ne!(v1, v2);
263                }
264            }
265        }
266    }
267}