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::Interrupted => "⏸️",
106        }
107    }
108
109    /// Format event for terminal display.
110    pub fn format_for_display(&self) -> String {
111        match self {
112            UIEvent::PhaseTransition { to, .. } => {
113                format!("{} {}", Self::phase_emoji(to), to)
114            }
115            UIEvent::IterationProgress { current, total } => {
116                format!("πŸ”„ Development iteration {}/{}", current, total)
117            }
118            UIEvent::ReviewProgress { pass, total } => {
119                format!("πŸ‘ Review pass {}/{}", pass, total)
120            }
121            UIEvent::AgentActivity { agent, message } => {
122                format!("πŸ€– [{}] {}", agent, message)
123            }
124            UIEvent::XmlOutput {
125                xml_type,
126                content,
127                context,
128            } => {
129                // Delegate to semantic XML renderer
130                crate::reducer::xml_renderer::render_xml(xml_type, content, context)
131            }
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_phase_transition_display() {
142        let event = UIEvent::PhaseTransition {
143            from: Some(PipelinePhase::Planning),
144            to: PipelinePhase::Development,
145        };
146        let display = event.format_for_display();
147        assert!(display.contains("πŸ”¨"));
148        assert!(display.contains("Development"));
149    }
150
151    #[test]
152    fn test_iteration_progress_display() {
153        let event = UIEvent::IterationProgress {
154            current: 2,
155            total: 5,
156        };
157        let display = event.format_for_display();
158        assert!(display.contains("2/5"));
159    }
160
161    #[test]
162    fn test_review_progress_display() {
163        let event = UIEvent::ReviewProgress { pass: 1, total: 3 };
164        let display = event.format_for_display();
165        assert!(display.contains("1/3"));
166        assert!(display.contains("Review pass"));
167    }
168
169    #[test]
170    fn test_agent_activity_display() {
171        let event = UIEvent::AgentActivity {
172            agent: "claude".to_string(),
173            message: "Processing request".to_string(),
174        };
175        let display = event.format_for_display();
176        assert!(display.contains("[claude]"));
177        assert!(display.contains("Processing request"));
178    }
179
180    #[test]
181    fn test_ui_event_serialization() {
182        let event = UIEvent::PhaseTransition {
183            from: None,
184            to: PipelinePhase::Planning,
185        };
186        let json = serde_json::to_string(&event).unwrap();
187        let deserialized: UIEvent = serde_json::from_str(&json).unwrap();
188        assert_eq!(event, deserialized);
189    }
190
191    #[test]
192    fn test_phase_emoji_all_phases() {
193        // Verify all phases have emojis
194        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Planning), "πŸ“‹");
195        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Development), "πŸ”¨");
196        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Review), "πŸ‘€");
197        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::CommitMessage), "πŸ“");
198        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::FinalValidation), "βœ…");
199        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Finalizing), "πŸ”„");
200        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Complete), "πŸŽ‰");
201        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Interrupted), "⏸️");
202    }
203
204    #[test]
205    fn test_phase_transition_from_none() {
206        // Test initial phase transition with no previous phase
207        let event = UIEvent::PhaseTransition {
208            from: None,
209            to: PipelinePhase::Planning,
210        };
211        let display = event.format_for_display();
212        assert!(display.contains("πŸ“‹"));
213        assert!(display.contains("Planning"));
214    }
215
216    // =========================================================================
217    // XmlOutput Tests
218    // =========================================================================
219
220    #[test]
221    fn test_xml_output_type_serialization() {
222        let xml_type = XmlOutputType::DevelopmentResult;
223        let json = serde_json::to_string(&xml_type).unwrap();
224        let deserialized: XmlOutputType = serde_json::from_str(&json).unwrap();
225        assert_eq!(xml_type, deserialized);
226    }
227
228    #[test]
229    fn test_xml_output_context_default() {
230        let context = XmlOutputContext::default();
231        assert!(context.iteration.is_none());
232        assert!(context.pass.is_none());
233        assert!(context.snippets.is_empty());
234    }
235
236    #[test]
237    fn test_xml_output_context_with_values() {
238        let context = XmlOutputContext {
239            iteration: Some(2),
240            pass: Some(1),
241            snippets: Vec::new(),
242        };
243        assert_eq!(context.iteration, Some(2));
244        assert_eq!(context.pass, Some(1));
245    }
246
247    #[test]
248    fn test_xml_output_event_serialization() {
249        let event = UIEvent::XmlOutput {
250            xml_type: XmlOutputType::ReviewIssues,
251            content: "<ralph-issues><ralph-issue>Test</ralph-issue></ralph-issues>".to_string(),
252            context: Some(XmlOutputContext {
253                iteration: None,
254                pass: Some(1),
255                snippets: Vec::new(),
256            }),
257        };
258        let json = serde_json::to_string(&event).unwrap();
259        let deserialized: UIEvent = serde_json::from_str(&json).unwrap();
260        assert_eq!(event, deserialized);
261    }
262
263    #[test]
264    fn test_xml_output_types_all_variants() {
265        // Ensure all variants are distinct
266        let variants = [
267            XmlOutputType::DevelopmentResult,
268            XmlOutputType::DevelopmentPlan,
269            XmlOutputType::ReviewIssues,
270            XmlOutputType::FixResult,
271            XmlOutputType::CommitMessage,
272        ];
273        for (i, v1) in variants.iter().enumerate() {
274            for (j, v2) in variants.iter().enumerate() {
275                if i == j {
276                    assert_eq!(v1, v2);
277                } else {
278                    assert_ne!(v1, v2);
279                }
280            }
281        }
282    }
283}