fundaia 0.10.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;
use crate::render::time::{format_millis, millis_between};

/// 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())));
    }
    /*
     * What the step printed, so `--step` is discoverable from the drawing.
     *
     * Only once it has stopped: a running step is still producing, and a number
     * taken a second ago is wrong by the time the next frame redraws over it.
     */
    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
}

/// 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))
}

#[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"));
    }

    /// A running step is still producing: the number would be stale the moment
    /// the next frame redrew over it.
    #[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"));
    }
}