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    /// Cloud-mode git push completed.
81    PushCompleted {
82        remote: String,
83        branch: String,
84        commit_sha: String,
85    },
86
87    /// Cloud-mode git push failed.
88    ///
89    /// The error string MUST already be redacted (no credentials).
90    PushFailed {
91        remote: String,
92        branch: String,
93        error: String,
94    },
95
96    /// Cloud-mode pull request created.
97    PullRequestCreated { url: String, number: u32 },
98
99    /// Cloud-mode pull request creation failed.
100    ///
101    /// The error string MUST already be redacted (no credentials).
102    PullRequestFailed { error: String },
103
104    /// XML output requiring semantic rendering.
105    ///
106    /// Phase functions emit raw XML content through this event,
107    /// and the event loop renders it with appropriate semantic formatting.
108    XmlOutput {
109        /// The type of XML output (determines renderer).
110        xml_type: XmlOutputType,
111        /// The raw XML content to render.
112        content: String,
113        /// Optional context like iteration or pass number.
114        context: Option<XmlOutputContext>,
115    },
116
117    /// Prompt replay observability event (RFC-007 Short-term #3).
118    ///
119    /// Emitted by handlers after each `get_stored_or_generate_prompt` call.
120    /// Allows audit, debugging, and detection of unexpected replay behavior.
121    ///
122    /// This event does NOT affect pipeline state or checkpoints.
123    PromptReplayHit {
124        /// String representation of the `PromptScopeKey` used for history lookup.
125        key: String,
126        /// `true` if the prompt was found in checkpoint history (replayed),
127        /// `false` if freshly generated.
128        was_replayed: bool,
129    },
130}
131
132impl UIEvent {
133    /// Get emoji indicator for phase.
134    #[must_use]
135    pub const fn phase_emoji(phase: &PipelinePhase) -> &'static str {
136        match phase {
137            PipelinePhase::Planning => "πŸ“‹",
138            PipelinePhase::Development => "πŸ”¨",
139            PipelinePhase::Review => "πŸ‘€",
140            PipelinePhase::CommitMessage => "πŸ“",
141            PipelinePhase::FinalValidation => "βœ…",
142            PipelinePhase::Finalizing => "πŸ”„",
143            PipelinePhase::Complete => "πŸŽ‰",
144            PipelinePhase::AwaitingDevFix => "πŸ”§",
145            PipelinePhase::Interrupted => "⏸️",
146        }
147    }
148
149    /// Format event for terminal display.
150    ///
151    /// This method delegates to the rendering module for actual formatting.
152    /// Prefer calling `rendering::render_ui_event()` directly in new code.
153    #[must_use]
154    pub fn format_for_display(&self) -> String {
155        crate::rendering::render_ui_event(self)
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_phase_transition_display() {
165        let event = UIEvent::PhaseTransition {
166            from: Some(PipelinePhase::Planning),
167            to: PipelinePhase::Development,
168        };
169        let display = event.format_for_display();
170        assert!(display.contains("πŸ”¨"));
171        assert!(display.contains("Development"));
172    }
173
174    #[test]
175    fn test_iteration_progress_display() {
176        let event = UIEvent::IterationProgress {
177            current: 2,
178            total: 5,
179        };
180        let display = event.format_for_display();
181        assert!(display.contains("2/5"));
182    }
183
184    #[test]
185    fn test_review_progress_display() {
186        let event = UIEvent::ReviewProgress { pass: 1, total: 3 };
187        let display = event.format_for_display();
188        assert!(display.contains("1/3"));
189        assert!(display.contains("Review pass"));
190    }
191
192    #[test]
193    fn test_agent_activity_display() {
194        let event = UIEvent::AgentActivity {
195            agent: "claude".to_string(),
196            message: "Processing request".to_string(),
197        };
198        let display = event.format_for_display();
199        assert!(display.contains("[claude]"));
200        assert!(display.contains("Processing request"));
201    }
202
203    #[test]
204    fn test_ui_event_serialization() {
205        let event = UIEvent::PhaseTransition {
206            from: None,
207            to: PipelinePhase::Planning,
208        };
209        let json = serde_json::to_string(&event).unwrap();
210        let deserialized: UIEvent = serde_json::from_str(&json).unwrap();
211        assert_eq!(event, deserialized);
212    }
213
214    #[test]
215    fn test_phase_emoji_all_phases() {
216        // Exhaustive test ensures every phase has an emoji
217        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Planning), "πŸ“‹");
218        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Development), "πŸ”¨");
219        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Review), "πŸ‘€");
220        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::CommitMessage), "πŸ“");
221        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::FinalValidation), "βœ…");
222        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Finalizing), "πŸ”„");
223        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Complete), "πŸŽ‰");
224        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::AwaitingDevFix), "πŸ”§");
225        assert_eq!(UIEvent::phase_emoji(&PipelinePhase::Interrupted), "⏸️");
226    }
227
228    #[test]
229    fn test_prompt_replay_hit_replayed_display() {
230        let event = UIEvent::PromptReplayHit {
231            key: "planning_1".to_string(),
232            was_replayed: true,
233        };
234        let display = event.format_for_display();
235        assert!(display.contains("planning_1"));
236        assert!(
237            display.contains("Replayed")
238                || display.contains("replay")
239                || display.contains("stored")
240        );
241    }
242
243    #[test]
244    fn test_prompt_replay_hit_fresh_display() {
245        let event = UIEvent::PromptReplayHit {
246            key: "development_2".to_string(),
247            was_replayed: false,
248        };
249        let display = event.format_for_display();
250        assert!(display.contains("development_2"));
251        assert!(
252            display.contains("fresh")
253                || display.contains("Generated")
254                || display.contains("prompt")
255        );
256    }
257
258    #[test]
259    fn test_prompt_replay_hit_serialization() {
260        let event = UIEvent::PromptReplayHit {
261            key: "commit_message_attempt_iter1_1".to_string(),
262            was_replayed: false,
263        };
264        let json = serde_json::to_string(&event).unwrap();
265        let deserialized: UIEvent = serde_json::from_str(&json).unwrap();
266        assert_eq!(event, deserialized);
267    }
268
269    #[test]
270    fn test_phase_transition_from_none() {
271        // Test initial phase transition with no previous phase
272        let event = UIEvent::PhaseTransition {
273            from: None,
274            to: PipelinePhase::Planning,
275        };
276        let display = event.format_for_display();
277        assert!(display.contains("πŸ“‹"));
278        assert!(display.contains("Planning"));
279    }
280
281    // =========================================================================
282    // XmlOutput Tests
283    // =========================================================================
284
285    #[test]
286    fn test_xml_output_type_serialization() {
287        let xml_type = XmlOutputType::DevelopmentResult;
288        let json = serde_json::to_string(&xml_type).unwrap();
289        let deserialized: XmlOutputType = serde_json::from_str(&json).unwrap();
290        assert_eq!(xml_type, deserialized);
291    }
292
293    #[test]
294    fn test_xml_output_context_default() {
295        let context = XmlOutputContext::default();
296        assert!(context.iteration.is_none());
297        assert!(context.pass.is_none());
298        assert!(context.snippets.is_empty());
299    }
300
301    #[test]
302    fn test_xml_output_context_with_values() {
303        let context = XmlOutputContext {
304            iteration: Some(2),
305            pass: Some(1),
306            snippets: Vec::new(),
307        };
308        assert_eq!(context.iteration, Some(2));
309        assert_eq!(context.pass, Some(1));
310    }
311
312    #[test]
313    fn test_xml_output_event_serialization() {
314        let event = UIEvent::XmlOutput {
315            xml_type: XmlOutputType::ReviewIssues,
316            content: "<ralph-issues><ralph-issue>Test</ralph-issue></ralph-issues>".to_string(),
317            context: Some(XmlOutputContext {
318                iteration: None,
319                pass: Some(1),
320                snippets: Vec::new(),
321            }),
322        };
323        let json = serde_json::to_string(&event).unwrap();
324        let deserialized: UIEvent = serde_json::from_str(&json).unwrap();
325        assert_eq!(event, deserialized);
326    }
327
328    #[test]
329    fn test_xml_output_types_all_variants() {
330        // Ensure all variants are distinct
331        let variants = [
332            XmlOutputType::DevelopmentResult,
333            XmlOutputType::DevelopmentPlan,
334            XmlOutputType::ReviewIssues,
335            XmlOutputType::FixResult,
336            XmlOutputType::CommitMessage,
337        ];
338        assert!(
339            variants.iter().enumerate().all(|(i, v1)| {
340                variants
341                    .iter()
342                    .enumerate()
343                    .all(|(j, v2)| i == j || v1 != v2)
344            }),
345            "All XmlOutputType variants should be distinct"
346        );
347    }
348}