Skip to main content

ralph_workflow/
banner.rs

1//! Banner and UI output utilities.
2//!
3//! This module contains presentation logic for the pipeline's visual output,
4//! including the welcome banner and the final summary display.
5
6use crate::logger::Colors;
7use crate::logger::Loggable;
8
9/// Summary data for pipeline completion display.
10///
11/// All metrics MUST derive from the final `PipelineState.metrics` to ensure
12/// consistency and prevent drift between runtime counters and actual progress.
13///
14/// # Single Source of Truth
15///
16/// The reducer is the authoritative source for all execution statistics.
17/// This struct is purely a presentation layer that receives reducer-derived
18/// metrics and formats them for display.
19///
20/// Decouples the banner presentation logic from the actual pipeline types.
21pub struct PipelineSummary {
22    /// Total elapsed time formatted as "Xm YYs"
23    pub total_time: String,
24    /// Number of developer iterations completed (from reducer metrics)
25    pub dev_runs_completed: usize,
26    /// Total configured developer iterations (from reducer metrics)
27    pub dev_runs_total: usize,
28    /// Number of review passes completed (from reducer metrics)
29    pub review_passes_completed: usize,
30    /// Total configured review passes (from reducer metrics)
31    pub review_passes_total: usize,
32    /// Number of reviewer runs completed (from reducer metrics)
33    pub review_runs: usize,
34    /// Number of commits created during pipeline (from reducer metrics)
35    pub changes_detected: usize,
36    /// Whether isolation mode is enabled
37    pub isolation_mode: bool,
38    /// Whether to show verbose output
39    pub verbose: bool,
40    /// Optional review metrics summary
41    pub review_summary: Option<ReviewSummary>,
42    /// Number of connectivity interruptions (offline windows) during the run.
43    /// None means no connectivity interruptions occurred.
44    pub connectivity_interruptions: Option<usize>,
45}
46
47/// Review metrics summary for display.
48pub struct ReviewSummary {
49    /// One-line summary of review results
50    pub summary: String,
51    /// Number of unresolved issues
52    pub unresolved_count: usize,
53    /// Number of unresolved blocking issues
54    pub blocking_count: usize,
55    /// Optional detailed breakdown (for verbose mode)
56    pub detailed_breakdown: Option<String>,
57    /// Optional sample unresolved issues (for verbose mode)
58    pub samples: Vec<String>,
59}
60
61/// Print the welcome banner for the Ralph pipeline.
62///
63/// Displays a styled ASCII box with the pipeline name and agent information.
64///
65/// # Arguments
66///
67/// * `colors` - Color configuration for terminal output
68/// * `developer_agent` - Name of the developer agent
69/// * `reviewer_agent` - Name of the reviewer agent
70pub fn print_welcome_banner(colors: Colors, developer_agent: &str, reviewer_agent: &str) {
71    let _ = print_welcome_banner_to(colors, developer_agent, reviewer_agent, std::io::stdout());
72}
73
74pub fn print_welcome_banner_to<W: std::io::Write>(
75    colors: Colors,
76    developer_agent: &str,
77    reviewer_agent: &str,
78    output: W,
79) -> std::io::Result<()> {
80    let content = build_welcome_banner_content(colors, developer_agent, reviewer_agent);
81    write_banner_to(output, &content)
82}
83
84fn build_welcome_banner_content(
85    colors: Colors,
86    developer_agent: &str,
87    reviewer_agent: &str,
88) -> String {
89    let lines = [
90        "",
91        &format!(
92            "{}{}╭────────────────────────────────────────────────────────────╮{}",
93            colors.bold(),
94            colors.cyan(),
95            colors.reset()
96        ),
97        &format!(
98            "{}{}│{}  {}{}🤖 Ralph{} {}─ PROMPT-driven agent orchestrator{}              {}{}│{}",
99            colors.bold(),
100            colors.cyan(),
101            colors.reset(),
102            colors.bold(),
103            colors.white(),
104            colors.reset(),
105            colors.dim(),
106            colors.reset(),
107            colors.bold(),
108            colors.cyan(),
109            colors.reset()
110        ),
111        &format!(
112            "{}{}│{}  {}{} × {} pipeline for autonomous development{}                 {}{}│{}",
113            colors.bold(),
114            colors.cyan(),
115            colors.reset(),
116            colors.dim(),
117            developer_agent,
118            reviewer_agent,
119            colors.reset(),
120            colors.bold(),
121            colors.cyan(),
122            colors.reset()
123        ),
124        &format!(
125            "{}{}╰────────────────────────────────────────────────────────────╯{}",
126            colors.bold(),
127            colors.cyan(),
128            colors.reset()
129        ),
130        "",
131        "",
132    ];
133    lines.join("\n")
134}
135
136/// Print the final summary after pipeline completion.
137///
138/// Displays statistics about the pipeline run including timing, run counts,
139/// and review metrics if available.
140///
141/// # Arguments
142///
143/// * `colors` - Color configuration for terminal output
144/// * `summary` - Pipeline summary data
145/// * `logger` - Logger for final success message (via Loggable trait)
146pub fn print_final_summary<L: Loggable>(colors: Colors, summary: &PipelineSummary, logger: &L) {
147    let _ = print_final_summary_to(colors, summary, logger, std::io::stdout());
148}
149
150pub fn print_final_summary_to<L: Loggable, W: std::io::Write>(
151    colors: Colors,
152    summary: &PipelineSummary,
153    logger: &L,
154    output: W,
155) -> std::io::Result<()> {
156    logger.header("Pipeline Complete", crate::logger::Colors::green);
157
158    let content = build_final_summary_content(colors, summary);
159
160    write_banner_to(output, &content)?;
161
162    // Use the Loggable trait's success method
163    logger.success("Ralph pipeline completed successfully!");
164
165    // Log additional status messages via Loggable trait
166    if summary.review_runs > 0 {
167        logger.info(&format!("Completed {} review run(s)", summary.review_runs));
168    }
169    if summary.changes_detected > 0 {
170        logger.info(&format!("Detected {} change(s)", summary.changes_detected));
171    }
172    if summary.isolation_mode {
173        logger.info("Running in isolation mode");
174    }
175
176    // Log warnings for unresolved issues if present
177    if let Some(ref review) = summary.review_summary {
178        if review.unresolved_count > 0 {
179            logger.warn(&format!(
180                "{} unresolved issue(s) remaining",
181                review.unresolved_count
182            ));
183        }
184        if review.blocking_count > 0 {
185            logger.error(&format!(
186                "{} blocking issue(s) unresolved",
187                review.blocking_count
188            ));
189        }
190    }
191
192    // Log connectivity interruptions if any occurred
193    if let Some(interruptions) = summary.connectivity_interruptions {
194        if interruptions > 0 {
195            logger.info(&format!(
196                "Connectivity interruptions: {} (workflow paused {} times due to offline detection; \
197                 no continuation or retry budget was consumed during offline windows)",
198                interruptions, interruptions
199            ));
200        }
201    }
202
203    Ok(())
204}
205
206fn write_banner_to<W: std::io::Write>(mut output: W, content: &str) -> std::io::Result<()> {
207    output.write_all(content.as_bytes())
208}
209
210fn build_final_summary_content(colors: Colors, summary: &PipelineSummary) -> String {
211    let base_lines: Vec<String> = vec![
212        "".to_string(),
213        format!(
214            "{}{}📊 Summary{}",
215            colors.bold(),
216            colors.white(),
217            colors.reset()
218        ),
219        format!(
220            "{}──────────────────────────────────{}",
221            colors.dim(),
222            colors.reset()
223        ),
224        format!(
225            "  {}⏱{}  Total time:      {}{}{}",
226            colors.cyan(),
227            colors.reset(),
228            colors.bold(),
229            summary.total_time,
230            colors.reset()
231        ),
232        format!(
233            "  {}🔄{}  Dev runs:        {}{}{}/{}",
234            colors.blue(),
235            colors.reset(),
236            colors.bold(),
237            summary.dev_runs_completed,
238            colors.reset(),
239            summary.dev_runs_total
240        ),
241        format!(
242            "  {}🔍{}  Review passes:   {}{}{}/{}",
243            colors.magenta(),
244            colors.reset(),
245            colors.bold(),
246            summary.review_passes_completed,
247            colors.reset(),
248            summary.review_passes_total
249        ),
250        format!(
251            "  {}📝{}  Changes detected: {}{}{}",
252            colors.green(),
253            colors.reset(),
254            colors.bold(),
255            summary.changes_detected,
256            colors.reset()
257        ),
258    ];
259
260    let verbose_line: Vec<String> = if summary.verbose {
261        vec![format!(
262            "  {}  {}  (Total runs:     {}{}{}){}",
263            colors.dim(),
264            colors.magenta(),
265            colors.bold(),
266            summary.review_runs,
267            colors.reset(),
268            colors.reset()
269        )]
270    } else {
271        Vec::new()
272    };
273
274    let review_lines: Vec<String> = summary
275        .review_summary
276        .as_ref()
277        .map_or(Vec::new(), |review| {
278            build_review_summary_content(colors, summary.verbose, review)
279                .lines()
280                .map(String::from)
281                .collect()
282        });
283
284    let output_files_lines: Vec<String> =
285        build_output_files_content(colors, summary.isolation_mode)
286            .lines()
287            .map(String::from)
288            .collect();
289
290    base_lines
291        .into_iter()
292        .chain(verbose_line)
293        .chain(std::iter::once("".to_string()))
294        .chain(review_lines)
295        .chain(std::iter::once("".to_string()))
296        .chain(output_files_lines)
297        .collect::<Vec<_>>()
298        .join("\n")
299}
300
301fn build_review_summary_content(colors: Colors, verbose: bool, review: &ReviewSummary) -> String {
302    if review.unresolved_count == 0 && review.blocking_count == 0 {
303        return [format!(
304            "  {}✓{}   Review result:   {}{}{}",
305            colors.green(),
306            colors.reset(),
307            colors.bold(),
308            review.summary,
309            colors.reset()
310        )]
311        .join("\n");
312    }
313
314    let base_line: Vec<String> = vec![format!(
315        "  {}🔎{}  Review summary:  {}{}{}",
316        colors.yellow(),
317        colors.reset(),
318        colors.bold(),
319        review.summary,
320        colors.reset()
321    )];
322
323    let unresolved_line: Vec<String> = if review.unresolved_count > 0 {
324        vec![format!(
325            "  {}⚠{}   Unresolved:      {}{}{} issues remaining",
326            colors.red(),
327            colors.reset(),
328            colors.bold(),
329            review.unresolved_count,
330            colors.reset()
331        )]
332    } else {
333        Vec::new()
334    };
335
336    let verbose_lines: Vec<String> = if verbose {
337        let breakdown_lines: Vec<String> =
338            review
339                .detailed_breakdown
340                .as_ref()
341                .map_or_else(Vec::<String>::new, |breakdown| {
342                    let line_strs: Vec<&str> = breakdown.lines().collect();
343                    let dimmed: Vec<String> = line_strs
344                        .iter()
345                        .map(|line| {
346                            format!("      {}{}{}", colors.dim(), line.trim(), colors.reset())
347                        })
348                        .collect();
349                    std::iter::once(format!(
350                        "  {}📊{}  Breakdown:",
351                        colors.dim(),
352                        colors.reset()
353                    ))
354                    .chain(dimmed)
355                    .collect()
356                });
357
358        let sample_lines: Vec<String> = if !review.samples.is_empty() {
359            std::iter::once(format!(
360                "  {}🧾{}  Unresolved samples:",
361                colors.dim(),
362                colors.reset()
363            ))
364            .chain(
365                review
366                    .samples
367                    .iter()
368                    .map(|s| format!("      {}- {}{}", colors.dim(), s, colors.reset())),
369            )
370            .collect()
371        } else {
372            Vec::new()
373        };
374
375        breakdown_lines.into_iter().chain(sample_lines).collect()
376    } else {
377        Vec::new()
378    };
379
380    let blocking_line: Vec<String> = if review.blocking_count > 0 {
381        vec![format!(
382            "  {}🚨{}  BLOCKING:        {}{}{} critical/high issues unresolved",
383            colors.red(),
384            colors.reset(),
385            colors.bold(),
386            review.blocking_count,
387            colors.reset()
388        )]
389    } else {
390        Vec::new()
391    };
392
393    base_line
394        .into_iter()
395        .chain(unresolved_line)
396        .chain(verbose_lines)
397        .chain(blocking_line)
398        .collect::<Vec<_>>()
399        .join("\n")
400}
401
402fn build_output_files_content(colors: Colors, isolation_mode: bool) -> String {
403    let base_lines: Vec<String> = vec![
404        format!(
405            "{}{}📁 Output Files{}",
406            colors.bold(),
407            colors.white(),
408            colors.reset()
409        ),
410        format!(
411            "{}──────────────────────────────────{}",
412            colors.dim(),
413            colors.reset()
414        ),
415        format!(
416            "  → {}PROMPT.md{}           Goal definition",
417            colors.cyan(),
418            colors.reset()
419        ),
420        format!(
421            "  → {}.agent/STATUS.md{}    Current status",
422            colors.cyan(),
423            colors.reset()
424        ),
425    ];
426
427    let isolation_lines: Vec<String> = if !isolation_mode {
428        vec![
429            format!(
430                "  → {}.agent/ISSUES.md{}    Review findings",
431                colors.cyan(),
432                colors.reset()
433            ),
434            format!(
435                "  → {}.agent/NOTES.md{}     Progress notes",
436                colors.cyan(),
437                colors.reset()
438            ),
439        ]
440    } else {
441        Vec::new()
442    };
443
444    base_lines
445        .into_iter()
446        .chain(isolation_lines)
447        .chain(std::iter::once(format!(
448            "  → {}.agent/logs/{}        Detailed logs",
449            colors.cyan(),
450            colors.reset()
451        )))
452        .collect::<Vec<_>>()
453        .join("\n")
454}