use owo_colors::OwoColorize;
use crate::api::models::{Pipeline, Stage};
use crate::render::theme;
use crate::render::time::{format_millis, millis_between};
pub struct PipelineView {
printed_lines: usize,
interactive: bool,
}
impl PipelineView {
pub fn new(interactive: bool) -> Self {
Self {
printed_lines: 0,
interactive,
}
}
pub fn draw(&mut self, pipeline: &Pipeline) {
let block = render(pipeline);
let lines = block.lines().count();
if self.interactive && self.printed_lines > 0 {
for _ in 0..self.printed_lines {
print!("\u{1b}[1A\u{1b}[2K");
}
}
print!("{block}");
self.printed_lines = if self.interactive { lines } else { 0 };
}
pub fn finish(&mut self, pipeline: &Pipeline) {
self.draw(pipeline);
self.printed_lines = 0;
}
}
fn render(pipeline: &Pipeline) -> String {
let mut out = String::new();
for stage in &pipeline.stages {
out.push_str(&render_stage(stage));
out.push('\n');
}
out
}
fn render_stage(stage: &Stage) -> String {
let mark = match stage.state.as_str() {
"done" => format!("{}", theme::CHECK.style(theme::success())),
"failed" => format!("{}", theme::CROSS.style(theme::danger())),
"skipped" => format!("{}", theme::SKIP.style(theme::muted())),
"running" => format!("{}", "▸".style(theme::accent())),
_ => format!("{}", theme::PENDING.style(theme::muted())),
};
let label = match stage.state.as_str() {
"running" => format!("{}", stage.label.style(theme::strong())),
"pending" | "skipped" => format!("{}", stage.label.style(theme::muted())),
"failed" => format!("{}", stage.label.style(theme::danger())),
_ => stage.label.clone(),
};
let mut line = format!(" {mark} {label}");
if let Some(duration) = duration_of(stage) {
line.push_str(&format!(" {}", duration.style(theme::muted())));
}
if stage.state == "running" {
line.push_str(&format!(" {}", "…".style(theme::accent())));
}
if stage.log_line_count > 0 && stage.state != "running" {
line.push_str(&format!(
" {}",
format!("· {} líneas", stage.log_line_count).style(theme::muted())
));
}
if let Some(message) = &stage.failure_message {
line.push_str(&format!("\n {}", message.style(theme::danger())));
}
line
}
fn duration_of(stage: &Stage) -> Option<String> {
let started = stage.started_at.as_ref()?;
let finished = stage.finished_at.as_ref()?;
let millis = millis_between(started, finished)?;
Some(format_millis(millis))
}
#[cfg(test)]
mod tests {
use super::*;
fn stage_with(state: &str, log_line_count: u32) -> Stage {
Stage {
id: "build".into(),
label: "Construyendo".into(),
state: state.into(),
started_at: None,
finished_at: None,
failure_message: None,
log_line_count,
}
}
#[test]
fn it_should_give_no_duration_for_a_stage_that_never_finished() {
let mut stage = stage_with("running", 0);
stage.started_at = Some("2026-07-30T10:00:00.000Z".into());
assert_eq!(duration_of(&stage), None);
}
#[test]
fn it_should_give_a_duration_for_a_stage_with_both_ends() {
let mut stage = stage_with("done", 0);
stage.started_at = Some("2026-07-30T10:00:00.000Z".into());
stage.finished_at = Some("2026-07-30T10:00:30.000Z".into());
assert_eq!(duration_of(&stage), Some("30.0s".to_owned()));
}
#[test]
fn it_should_say_how_much_a_finished_step_printed() {
assert!(render_stage(&stage_with("done", 412)).contains("412 líneas"));
}
#[test]
fn it_should_not_count_a_step_that_is_still_printing() {
assert!(!render_stage(&stage_with("running", 12)).contains("líneas"));
}
#[test]
fn it_should_say_nothing_about_a_step_that_printed_nothing() {
assert!(!render_stage(&stage_with("done", 0)).contains("líneas"));
}
}