fundaia 0.7.0

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use owo_colors::OwoColorize;

use crate::api::models::{Pipeline, Stage};
use crate::render::theme;

/// The deployment pipeline, drawn in the terminal.
///
/// The same six stages the web draws, from the same endpoint. That is the whole
/// point of deriving them on the server: "where is it stuck" has one answer, and
/// nobody has to learn two vocabularies for one deployment.
///
/// Redrawn in place while it runs. The rendering is a pure function from the
/// pipeline to a block of text, and the only stateful part — how many lines to
/// walk back up — is one number held here, which is what keeps a resize or a
/// stray print from corrupting the display permanently.
pub struct PipelineView {
    printed_lines: usize,
    /// Off when the output is not a terminal, where cursor moves are noise.
    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 {
            // Up one line per line printed, clearing each: `\r` alone leaves the
            // tail of a longer previous line behind, which is how a stage that
            // shrinks from "en curso…" to nothing leaves a ghost.
            for _ in 0..self.printed_lines {
                print!("\u{1b}[1A\u{1b}[2K");
            }
        }

        print!("{block}");
        self.printed_lines = if self.interactive { lines } else { 0 };
    }

    /// Prints the final state and stops redrawing over it.
    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
}

/// How long a stage took, or nothing when only one end of it is known.
///
/// Half a duration is worse than none: a start with no end reads as a duration
/// and is off by however long the stage actually ran.
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")
}

/// Difference between two ISO-8601 instants, in milliseconds.
///
/// Parsed by hand rather than through a date crate's format machinery: both
/// values come from `Date.toISOString()`, so the shape is fixed, and the only
/// thing needed is the difference — never the calendar.
pub fn millis_between(from: &str, to: &str) -> Option<i64> {
    Some(epoch_millis(to)? - epoch_millis(from)?)
}

/// `2026-07-30T10:04:31.512Z` as milliseconds since the epoch.
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,
    )
}

/// Howard Hinnant's `days_from_civil`, which is the standard answer.
///
/// Reproduced rather than pulled in: it is eight lines, it is exact for every
/// proleptic Gregorian date, and the alternative is a date crate whose whole
/// calendar comes along for a subtraction.
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()));
    }
}