fundaia 0.55.0

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use std::time::{SystemTime, UNIX_EPOCH};

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 elapsed(started: &str, finished: Option<&str>) -> String {
    finished
        .and_then(|finished| millis_between(started, finished))
        .map_or_else(|| "—".to_owned(), format_millis)
}

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
}

pub fn relative_time(iso: &str) -> Option<String> {
    Some(relative_since(epoch_millis(iso)?))
}

pub fn when(iso: &str) -> String {
    relative_time(iso).unwrap_or_else(|| "—".to_owned())
}

pub fn relative_since(then: i64) -> String {
    let minutes = (now_millis() - then) / 60_000;

    if minutes < 1 {
        return "ahora mismo".to_owned();
    }
    if minutes < 60 {
        return format!("hace {minutes} min");
    }
    if minutes < 60 * 24 {
        return format!("hace {} h", minutes / 60);
    }

    let days = minutes / (60 * 24);
    if days == 1 {
        "hace 1 día".to_owned()
    } else {
        format!("hace {days} días")
    }
}

pub fn until(iso: &str) -> String {
    epoch_millis(iso).map_or_else(
        || "—".to_owned(),
        |then| words_until((then - now_millis()) / 60_000),
    )
}

fn words_until(minutes: i64) -> String {
    if minutes < 1 {
        return "en menos de un minuto".to_owned();
    }
    if minutes < 60 {
        return format!("en {minutes} min");
    }
    if minutes < 60 * 24 {
        return format!("en {} h", minutes / 60);
    }
    let days = minutes / (60 * 24);
    if days == 1 {
        "mañana".to_owned()
    } else {
        format!("en {days} días")
    }
}

fn now_millis() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|since| since.as_millis() as i64)
        .unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_should_say_a_run_a_few_minutes_ahead() {
        assert_eq!(words_until(5), "en 5 min");
    }

    #[test]
    fn it_should_say_a_run_a_day_ahead_as_tomorrow() {
        assert_eq!(words_until(60 * 24 + 30), "mañana");
    }

    #[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_refuse_to_describe_something_that_is_not_an_instant() {
        assert_eq!(relative_time("yesterday"), None);
    }

    #[test]
    fn it_should_put_an_instant_in_a_table_as_how_long_ago_it_was() {
        assert!(when("2026-09-20T10:00:00.000Z").starts_with("hace "));
    }

    #[test]
    fn it_should_put_a_dash_where_the_instant_cannot_be_read() {
        assert_eq!(when("yesterday"), "—");
    }

    #[test]
    fn it_should_describe_the_epoch_as_a_long_time_ago() {
        let described = relative_time("1970-01-01T00:00:00.000Z").unwrap_or_default();
        assert!(described.starts_with("hace "));
    }
}