use owo_colors::OwoColorize;
use crate::api::models::{Pipeline, Stage};
use crate::render::theme;
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 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))
}
pub fn format_millis(millis: i64) -> String {
if millis < 0 {
return "—".to_owned();
}
if millis < 1_000 {
return format!("{millis}ms");
}
if millis < 60_000 {
return format!("{:.1}s", millis as f64 / 1_000.0);
}
let minutes = millis / 60_000;
let seconds = (millis % 60_000) / 1_000;
format!("{minutes}m {seconds}s")
}
pub fn millis_between(from: &str, to: &str) -> Option<i64> {
Some(epoch_millis(to)? - epoch_millis(from)?)
}
pub fn epoch_millis(iso: &str) -> Option<i64> {
let (date, rest) = iso.split_once('T')?;
let mut date_parts = date.split('-');
let year: i64 = date_parts.next()?.parse().ok()?;
let month: i64 = date_parts.next()?.parse().ok()?;
let day: i64 = date_parts.next()?.parse().ok()?;
let time = rest.trim_end_matches('Z');
let (clock, fraction) = match time.split_once('.') {
Some((clock, fraction)) => (clock, fraction),
None => (time, "0"),
};
let mut clock_parts = clock.split(':');
let hour: i64 = clock_parts.next()?.parse().ok()?;
let minute: i64 = clock_parts.next()?.parse().ok()?;
let second: i64 = clock_parts.next()?.parse().ok()?;
let millis: i64 = fraction
.chars()
.take(3)
.collect::<String>()
.parse()
.unwrap_or(0);
Some(
days_from_civil(year, month, day) * 86_400_000
+ hour * 3_600_000
+ minute * 60_000
+ second * 1_000
+ millis,
)
}
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
let year = if month <= 2 { year - 1 } else { year };
let era = if year >= 0 { year } else { year - 399 } / 400;
let year_of_era = year - era * 400;
let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146_097 + day_of_era - 719_468
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_should_read_the_epoch_itself_as_zero() {
assert_eq!(epoch_millis("1970-01-01T00:00:00.000Z"), Some(0));
}
#[test]
fn it_should_read_one_second_after_the_epoch() {
assert_eq!(epoch_millis("1970-01-01T00:00:01.000Z"), Some(1_000));
}
#[test]
fn it_should_read_a_date_without_a_fractional_part() {
assert_eq!(epoch_millis("1970-01-02T00:00:00Z"), Some(86_400_000));
}
#[test]
fn it_should_measure_the_gap_between_two_instants() {
let gap = millis_between("2026-07-30T10:00:00.000Z", "2026-07-30T10:00:12.500Z");
assert_eq!(gap, Some(12_500));
}
#[test]
fn it_should_refuse_something_that_is_not_an_instant() {
assert_eq!(epoch_millis("yesterday"), None);
}
#[test]
fn it_should_print_a_short_gap_in_milliseconds() {
assert_eq!(format_millis(420), "420ms");
}
#[test]
fn it_should_print_a_medium_gap_in_seconds() {
assert_eq!(format_millis(12_500), "12.5s");
}
#[test]
fn it_should_print_a_long_gap_in_minutes_and_seconds() {
assert_eq!(format_millis(185_000), "3m 5s");
}
#[test]
fn it_should_give_no_duration_for_a_stage_that_never_finished() {
let stage = Stage {
id: "build".into(),
label: "Construyendo".into(),
state: "running".into(),
started_at: Some("2026-07-30T10:00:00.000Z".into()),
finished_at: None,
failure_message: None,
};
assert_eq!(duration_of(&stage), None);
}
#[test]
fn it_should_give_a_duration_for_a_stage_with_both_ends() {
let stage = Stage {
id: "build".into(),
label: "Construyendo".into(),
state: "done".into(),
started_at: Some("2026-07-30T10:00:00.000Z".into()),
finished_at: Some("2026-07-30T10:00:30.000Z".into()),
failure_message: None,
};
assert_eq!(duration_of(&stage), Some("30.0s".to_owned()));
}
}