sefy 0.19.0

An inconspicuous encrypted store for notes, credentials and files
//! Rendering moments and days.
//!
//! sefy stores times as Unix seconds and prints them in a handful of lines:
//! `status`, `audit`, a record's date in `show`. A date crate for those would
//! be a dependency carried into every build and every audit of what this
//! program links against — a poor trade for arithmetic that is a few lines and
//! has not changed since 1582.
//!
//! Everything here is UTC. A local time would need the platform's zone
//! database, which is the dependency this avoids, and would also make the
//! output depend on where the machine thinks it is — unhelpful for a file that
//! travels between machines.

use sefy_core::time::civil_from_days;

/// A moment, as a date and as a distance from now.
///
/// Both, because either alone is the wrong one half the time: "3 minutes ago"
/// says nothing useful about last spring, and "2026-03-04" makes the reader do
/// the subtraction for something that happened this morning.
pub fn moment(at: i64, now: i64) -> String {
    format!("{} ({})", date_time(at), ago(now - at))
}

/// A stretch of time as a person says it: `45s`, `14m`, `1h 5m`, `12h`.
///
/// To the minute once it is that long: "locks in 14m" is what anyone wants to
/// know, and the seconds would be out of date before they were read.
pub fn span(time: std::time::Duration) -> String {
    let seconds = time.as_secs();
    let (hours, minutes) = (seconds / 3600, seconds % 3600 / 60);
    match (hours, minutes) {
        (0, 0) => format!("{seconds}s"),
        (0, minutes) => format!("{minutes}m"),
        (hours, 0) => format!("{hours}h"),
        (hours, minutes) => format!("{hours}h {minutes}m"),
    }
}

/// `YYYY-MM-DD HH:MM UTC` for a Unix timestamp.
pub fn date_time(at: i64) -> String {
    // Floor division, so times before 1970 land on the right day rather than
    // one too late. They should not occur, but a wrong date is worse than an
    // impossible one being handled.
    let days = at.div_euclid(86_400);
    let seconds = at.rem_euclid(86_400);
    let (year, month, day) = civil_from_days(days);
    format!(
        "{year:04}-{month:02}-{day:02} {:02}:{:02} UTC",
        seconds / 3600,
        (seconds % 3600) / 60
    )
}

/// `YYYY-MM-DD` for a Unix timestamp: the day, when the hour is beside the
/// point.
pub fn date(at: i64) -> String {
    let (year, month, day) = civil_from_days(at.div_euclid(86_400));
    format!("{year:04}-{month:02}-{day:02}")
}

/// A number of whole days from today, either way, as it is said.
pub fn days_away(days: i64) -> String {
    match days {
        0 => "today".to_owned(),
        1 => "tomorrow".to_owned(),
        -1 => "yesterday".to_owned(),
        days if days > 1 => format!("in {days} days"),
        days => format!("{} days ago", -days),
    }
}

/// Where a record stands against the last day it works, `days` from today:
/// "expires in 5 days", "expires today", "expired yesterday".
pub fn due(days: i64) -> String {
    if days < 0 {
        format!("expired {}", days_away(days))
    } else {
        format!("expires {}", days_away(days))
    }
}

/// A rough distance in the past, in the largest unit that still reads.
fn ago(seconds: i64) -> String {
    // A stamp from the future is not an error worth refusing: clocks disagree,
    // and a vault synced by a machine a minute ahead should not make `status`
    // complain about it.
    if seconds < 0 {
        return "in the future".to_owned();
    }

    const MINUTE: i64 = 60;
    const HOUR: i64 = 60 * MINUTE;
    const DAY: i64 = 24 * HOUR;

    if seconds < MINUTE {
        "just now".to_owned()
    } else if seconds < HOUR {
        plural(seconds / MINUTE, "minute")
    } else if seconds < DAY {
        plural(seconds / HOUR, "hour")
    } else {
        plural(seconds / DAY, "day")
    }
}

fn plural(n: i64, noun: &str) -> String {
    if n == 1 {
        format!("1 {noun} ago")
    } else {
        format!("{n} {noun}s ago")
    }
}

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

    #[test]
    fn the_epoch_and_some_dates_after_it() {
        assert_eq!(date_time(0), "1970-01-01 00:00 UTC");
        // A leap day, which is where a wrong month-length table shows up.
        assert_eq!(date_time(1_709_164_800), "2024-02-29 00:00 UTC");
        // The day after it, and the turn of a century that is not a leap year.
        assert_eq!(date_time(1_709_251_200), "2024-03-01 00:00 UTC");
        assert_eq!(date_time(951_782_400), "2000-02-29 00:00 UTC");
    }

    #[test]
    fn the_time_of_day_comes_out_too() {
        assert_eq!(date_time(3600 * 13 + 60 * 45), "1970-01-01 13:45 UTC");
        assert_eq!(date_time(86_399), "1970-01-01 23:59 UTC");
    }

    #[test]
    fn a_date_before_the_epoch_lands_on_the_right_day() {
        // Truncating division would put this on 1970-01-01, a day late: the
        // reason the arithmetic above is explicitly euclidean.
        assert_eq!(date_time(-1), "1969-12-31 23:59 UTC");
    }

    #[test]
    fn distances_pick_the_unit_that_reads() {
        assert_eq!(ago(0), "just now");
        assert_eq!(ago(59), "just now");
        assert_eq!(ago(60), "1 minute ago");
        assert_eq!(ago(119), "1 minute ago");
        assert_eq!(ago(3600), "1 hour ago");
        assert_eq!(ago(86_400), "1 day ago");
        assert_eq!(ago(86_400 * 3), "3 days ago");
    }

    #[test]
    fn a_stamp_from_the_future_is_said_plainly() {
        // Two machines syncing with clocks a little apart is ordinary; it must
        // not turn into "-1 days ago" or a panic.
        assert_eq!(ago(-5), "in the future");
        assert!(moment(100, 50).ends_with("(in the future)"));
    }

    #[test]
    fn a_date_is_the_day_alone() {
        assert_eq!(date(1_709_164_800 + 13 * 3600), "2024-02-29");
        assert_eq!(date(-1), "1969-12-31");
    }

    #[test]
    fn days_either_way_are_said_the_way_people_say_them() {
        assert_eq!(days_away(0), "today");
        assert_eq!(days_away(1), "tomorrow");
        assert_eq!(days_away(-1), "yesterday");
        assert_eq!(days_away(12), "in 12 days");
        assert_eq!(days_away(-3), "3 days ago");
        assert_eq!(due(5), "expires in 5 days");
        assert_eq!(due(0), "expires today");
        assert_eq!(due(-1), "expired yesterday");
        assert_eq!(due(-40), "expired 40 days ago");
    }

    #[test]
    fn a_moment_carries_both_readings() {
        let rendered = moment(1_709_164_800, 1_709_164_800 + 7200);
        assert_eq!(rendered, "2024-02-29 00:00 UTC (2 hours ago)");
    }
}