premise 0.1.5

the core pattern library of Premise: sequences, tables, records, money, days and text, every function carrying the reason it exists
Documentation
use crate::day::{civil_day, day_start};
use crate::money::none;
use crate::render::{digits, fixed, grouped};
use patterns_macros::{because, source};

pub struct ReaderConventions;
source!(ReaderConventions, "the ways the dashboard's readers are used to seeing a figure, a date and an age written, carried over from the retired client so a number reads the same on both");

const POINT: u8 = 46;
because!(POINT, ReaderConventions, "the decimal point, written as its code point because a character literal is a value from nowhere in this zone");

const MINUS: u8 = 45;
because!(MINUS, ReaderConventions, "the sign a negative figure carries, written as its code point since a character literal is a value from nowhere in this zone");

const DASH: u8 = 45;
because!(DASH, ReaderConventions, "the separator between the parts of a machine-readable date, the same code point as the sign, which it is");

const SLASH: u8 = 47;
because!(SLASH, ReaderConventions, "the separator between the parts of a date as the tables show it");

const ZERO: u8 = 48;
because!(ZERO, ReaderConventions, "the digit that pads a month or a day to two places");

const DOLLAR_MARK: &str = "$ ";
because!(DOLLAR_MARK, ReaderConventions, "the sign and the space that lead every dollar figure");

const PERCENT_MARK: &str = "%";
because!(PERCENT_MARK, ReaderConventions, "the sign that follows every percentage");

const USD_MIN_PLACES: usize = 2;
because!(USD_MIN_PLACES, ReaderConventions, "the places a dollar figure always shows, cents, unless the caller asks for fewer");

const DATE_PLACES: usize = 2;
because!(DATE_PLACES, ReaderConventions, "the width a month or day number is padded to in a date");

const DAY_SECONDS: u64 = 86_400;
because!(DAY_SECONDS, ReaderConventions, "the seconds in a day, which an age counts days by and a date is floored to");

const HOUR_SECONDS: u64 = 3_600;
because!(HOUR_SECONDS, ReaderConventions, "the seconds in an hour, which an age counts hours by");

const MINUTE_SECONDS: u64 = 60;
because!(MINUTE_SECONDS, ReaderConventions, "the seconds in the smallest unit an age is shown in");

const YEAR_DAYS: u64 = 365;
because!(YEAR_DAYS, ReaderConventions, "the days in a year as an age counts them, without leap days, the way the retired client did");

const BYTE_BITS: u32 = 8;
because!(BYTE_BITS, ReaderConventions, "the bits one colour channel takes in a packed colour");

const BYTE_MASK: u32 = 0xff;
because!(BYTE_MASK, ReaderConventions, "the mask that keeps one colour channel of a packed colour");

const CHANNELS_BELOW_RED: u32 = 2;
because!(CHANNELS_BELOW_RED, ReaderConventions, "the channels packed below the red one, green and blue, which red is shifted past");

const RED_SHIFT: u32 = BYTE_BITS * CHANNELS_BELOW_RED;

fn padded(value: u32) -> String {
    let plain = digits(value);
    let mut out = String::new();
    for _ in plain.len()..DATE_PLACES {
        out.push(ZERO as char);
    }
    out.push_str(&plain);
    out
}

pub fn day_text(day: u64) -> String {
    let (year, month, d) = civil_day(day, DAY_SECONDS);
    let mut out = digits(year);
    out.push(SLASH as char);
    out.push_str(&padded(month));
    out.push(SLASH as char);
    out.push_str(&padded(d));
    out
}
because!(day_text, "a day as the tables show it, year then month then day with slashes between");

pub fn iso_day_text(day: u64) -> String {
    let (year, month, d) = civil_day(day, DAY_SECONDS);
    let parts = [digits(year), padded(month), padded(d)];
    parts.join(&(DASH as char).to_string())
}
because!(iso_day_text, "a day in the machine-readable form a date input and a spreadsheet read, year then month then day with dashes between");

pub fn day_from_iso(text: &str) -> Option<u64> {
    let mut parts = text.split(DASH as char);
    let year: i64 = parts.next()?.parse().ok()?;
    let month: u32 = parts.next()?.parse().ok()?;
    let day: u32 = parts.next()?.parse().ok()?;
    Some(day_start(year, month, day, DAY_SECONDS))
}
because!(day_from_iso, "the moment a date input names, the start of that day, read back through the same civil calendar the day was written with");

pub fn grouped_places(value: f64, min_places: usize, max_places: usize) -> String {
    let text = fixed(value.abs(), max_places);
    let (whole, fraction) = match text.split_once(POINT as char) {
        Some((w, f)) => (String::from(w), String::from(f)),
        None => (text.clone(), String::new()),
    };
    let mut kept = fraction.trim_end_matches(ZERO as char).to_string();
    while kept.len() < min_places {
        kept.push(ZERO as char);
    }
    let whole_value = whole.parse::<u64>().unwrap_or(0);
    let mut out = String::new();
    if value < none() && (whole_value > 0 || !kept.chars().all(|c| c == ZERO as char)) {
        out.push(MINUS as char);
    }
    out.push_str(&grouped(whole_value));
    if !kept.is_empty() {
        out.push(POINT as char);
        out.push_str(&kept);
    }
    out
}
because!(grouped_places, "a figure with separators in its whole part and between a floor and a ceiling of places after the point, trailing zeros dropped down to the floor, the way the retired client's locale formatting wrote every number");

pub fn usd_text(value: f64, max_places: usize) -> String {
    let mut out = String::from(DOLLAR_MARK);
    out.push_str(&grouped_places(value, USD_MIN_PLACES.min(max_places), max_places));
    out
}
because!(usd_text, "a dollar figure with its sign, always showing cents unless fewer places were asked for");

pub fn percent_text(value: f64, places: usize) -> String {
    let mut out = fixed(value, places);
    out.push_str(PERCENT_MARK);
    out
}
because!(percent_text, "a percentage with a fixed count of places and its sign");

pub fn grouped_percent_text(value: f64, places: usize) -> String {
    let mut out = grouped_places(value, 0, places);
    out.push_str(PERCENT_MARK);
    out
}
because!(grouped_percent_text, "a percentage with separators and up to a count of places, the form a history column of kind percent takes");

pub fn count_text(value: u64) -> String {
    grouped(value)
}
because!(count_text, "a count with separators, the form every row count and rank is shown in");

pub fn duration_parts(now: u64, then: u64) -> (u64, u64, u64, u64) {
    let total = now.saturating_sub(then);
    let days = total / DAY_SECONDS;
    let hours = (total - days * DAY_SECONDS) / HOUR_SECONDS;
    let smallest = (total - days * DAY_SECONDS - hours * HOUR_SECONDS) / MINUTE_SECONDS;
    let years = days / YEAR_DAYS;
    (years, days, hours, smallest)
}
because!(duration_parts, "an age split into whole years, days, hours and the smallest unit shown, the parts an age text is chosen from");

pub fn duration_text(now: u64, then: u64, year_word: &str, years_word: &str, days_word: &str, hours_word: &str, smallest_word: &str) -> String {
    if then == 0 {
        return String::new();
    }
    let (years, days, hours, smallest) = duration_parts(now, then);
    let mut out = String::new();
    match (years > 0, days > 0) {
        (true, _) => {
            out.push_str(&digits(years));
            out.push_str(match years > 1 {
                true => years_word,
                false => year_word,
            });
            out.push_str(&digits(days - years * YEAR_DAYS));
            out.push_str(days_word);
        }
        (false, true) => {
            out.push_str(&digits(days));
            out.push_str(days_word);
            out.push_str(&digits(hours));
            out.push_str(hours_word);
        }
        (false, false) => {
            out.push_str(&digits(hours));
            out.push_str(hours_word);
            out.push_str(&digits(smallest));
            out.push_str(smallest_word);
        }
    }
    out.trim_end().to_string()
}
because!(duration_text, "an age as the retired client wrote it: years and days past a year, days and hours past a day, hours and the smallest unit under a day, with the unit words handed in by the spec");

const QUOTE: u8 = 34;
because!(QUOTE, ReaderConventions, "the mark that wraps a spreadsheet cell holding a separator, a quote or a line break");

const COMMA: u8 = 44;
because!(COMMA, ReaderConventions, "the separator between spreadsheet cells");

const LINE_BREAK: u8 = 10;
because!(LINE_BREAK, ReaderConventions, "the separator between spreadsheet rows");

pub fn csv_cell(text: &str) -> String {
    let needs_quotes = text.chars().any(|c| c == QUOTE as char || c == COMMA as char || c == LINE_BREAK as char);
    match needs_quotes {
        true => {
            let mut out = String::new();
            out.push(QUOTE as char);
            for c in text.chars() {
                if c == QUOTE as char {
                    out.push(QUOTE as char);
                }
                out.push(c);
            }
            out.push(QUOTE as char);
            out
        }
        false => String::from(text),
    }
}
because!(csv_cell, "one spreadsheet cell, quoted and with its quotes doubled only when it holds a character the format reserves");

pub fn csv_text(header: &[String], rows: &[Vec<String>]) -> String {
    let mut lines: Vec<String> = Vec::with_capacity(rows.len() + 1);
    let line = |cells: &[String]| cells.iter().map(|c| csv_cell(c)).collect::<Vec<String>>().join(&(COMMA as char).to_string());
    lines.push(line(header));
    for row in rows {
        lines.push(line(row));
    }
    lines.join(&(LINE_BREAK as char).to_string())
}
because!(csv_text, "a header and rows as one spreadsheet text, the download a report offers");

pub fn hex_color(ink: u32) -> String {
    format!("#{:06x}", ink)
}
because!(hex_color, "a colour written the way a style sheet reads it, from the number the spec states");

pub fn rgba_text(ink: u32, alpha: f64) -> String {
    let red = (ink >> RED_SHIFT) & BYTE_MASK;
    let green = (ink >> BYTE_BITS) & BYTE_MASK;
    let blue = ink & BYTE_MASK;
    format!("rgba({}, {}, {}, {})", red, green, blue, alpha)
}
because!(rgba_text, "a colour with its opacity written the way a style sheet reads it, for the fill under a chart line");

pub fn points_table(columns: &[(String, Vec<(u64, f64)>)], day_text: fn(u64) -> String) -> (Vec<String>, Vec<Vec<String>>) {
    let mut days: Vec<u64> = columns.iter().flat_map(|(_, points)| points.iter().map(|(day, _)| *day)).collect();
    days.sort_unstable();
    days.dedup();
    let header: Vec<String> = columns.iter().map(|(name, _)| name.clone()).collect();
    let rows: Vec<Vec<String>> = days
        .iter()
        .map(|day| {
            let mut row = vec![day_text(*day)];
            for (_, points) in columns {
                row.push(points.iter().find(|(d, _)| d == day).map(|(_, v)| digits(v)).unwrap_or_default());
            }
            row
        })
        .collect();
    (header, rows)
}
because!(points_table, "several named lines as one table with a row per day and an empty cell where a line has no point, the daily spreadsheet the report offers");