haki-dl 0.3.0

A Rust downloader library and CLI foundation.
Documentation
use time::{OffsetDateTime, UtcOffset};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct ManifestTimestamp {
    pub(crate) unix_seconds: i64,
    pub(crate) unix_millis: i64,
}

pub(crate) fn parse_manifest_timestamp(value: &str) -> Option<ManifestTimestamp> {
    let trimmed = value.trim();
    let bytes = trimmed.as_bytes();
    if bytes.len() < 19
        || bytes.get(4) != Some(&b'-')
        || bytes.get(7) != Some(&b'-')
        || !matches!(bytes.get(10), Some(b'T' | b't' | b' '))
        || bytes.get(13) != Some(&b':')
        || bytes.get(16) != Some(&b':')
    {
        return None;
    }

    let year = parse_digits(bytes.get(0..4)?)?;
    let month = parse_digits(bytes.get(5..7)?)?;
    let day = parse_digits(bytes.get(8..10)?)?;
    let hour = parse_digits(bytes.get(11..13)?)?;
    let minute = parse_digits(bytes.get(14..16)?)?;
    let second = parse_digits(bytes.get(17..19)?)?;
    if hour > 23 || minute > 59 || second > 59 {
        return None;
    }

    let mut cursor = 19;
    let mut millis = 0_i64;
    if bytes.get(cursor) == Some(&b'.') {
        cursor += 1;
        let start = cursor;
        let mut digits = 0_u8;
        while let Some(byte) = bytes.get(cursor)
            && byte.is_ascii_digit()
        {
            if digits < 3 {
                millis = millis * 10 + i64::from(byte - b'0');
                digits += 1;
            }
            cursor += 1;
        }
        if cursor == start {
            return None;
        }
        while digits < 3 {
            millis *= 10;
            digits += 1;
        }
    }

    let offset_seconds = parse_offset_seconds(bytes.get(cursor..).unwrap_or_default())?;
    let days = days_from_civil(year, month, day)?;
    let local_seconds = days
        .checked_mul(86_400)?
        .checked_add(hour.checked_mul(3_600)?)?
        .checked_add(minute.checked_mul(60)?)?
        .checked_add(second)?;
    let unix_seconds = local_seconds.checked_sub(i64::from(offset_seconds))?;
    let unix_millis = unix_seconds.checked_mul(1_000)?.checked_add(millis)?;
    Some(ManifestTimestamp {
        unix_seconds,
        unix_millis,
    })
}

pub(crate) fn parse_manifest_timestamp_seconds(value: &str) -> Option<i64> {
    parse_manifest_timestamp(value).map(|timestamp| timestamp.unix_seconds)
}

pub(crate) fn parse_manifest_timestamp_millis(value: &str) -> Option<i64> {
    parse_manifest_timestamp(value).map(|timestamp| timestamp.unix_millis)
}

pub(crate) fn current_local_iso_timestamp() -> String {
    let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
    iso_timestamp(now)
}

fn iso_timestamp(value: OffsetDateTime) -> String {
    let offset_seconds = value.offset().whole_seconds();
    let sign = if offset_seconds < 0 { '-' } else { '+' };
    let absolute_offset = offset_seconds.unsigned_abs();
    let offset_hours = absolute_offset / 3_600;
    let offset_minutes = (absolute_offset % 3_600) / 60;
    let fractional_ticks = value.nanosecond() / 100;
    format!(
        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:07}{sign}{:02}:{:02}",
        value.year(),
        u8::from(value.month()),
        value.day(),
        value.hour(),
        value.minute(),
        value.second(),
        fractional_ticks,
        offset_hours,
        offset_minutes
    )
}

fn parse_offset_seconds(bytes: &[u8]) -> Option<i32> {
    let text = std::str::from_utf8(bytes).ok()?.trim();
    if text.is_empty() {
        return Some(local_offset_seconds());
    }
    if matches!(text, "Z" | "z") {
        return Some(0);
    }
    let mut chars = text.chars();
    let sign = match chars.next()? {
        '+' => 1,
        '-' => -1,
        _ => return None,
    };
    let rest = chars.as_str();
    let (hour, minute) = if let Some((hour, minute)) = rest.split_once(':') {
        (parse_ascii_i32(hour)?, parse_ascii_i32(minute)?)
    } else if rest.len() == 2 {
        (parse_ascii_i32(rest)?, 0)
    } else if rest.len() == 4 {
        (
            parse_ascii_i32(rest.get(0..2)?)?,
            parse_ascii_i32(rest.get(2..4)?)?,
        )
    } else {
        return None;
    };
    if hour > 23 || minute > 59 {
        return None;
    }
    Some(sign * (hour * 3_600 + minute * 60))
}

fn parse_ascii_i32(value: &str) -> Option<i32> {
    if value.is_empty() || !value.as_bytes().iter().all(u8::is_ascii_digit) {
        return None;
    }
    value.parse().ok()
}

fn local_offset_seconds() -> i32 {
    UtcOffset::current_local_offset()
        .map(|offset| offset.whole_seconds())
        .unwrap_or(0)
}

fn parse_digits(bytes: &[u8]) -> Option<i64> {
    let mut value = 0_i64;
    for byte in bytes {
        if !byte.is_ascii_digit() {
            return None;
        }
        value = value.checked_mul(10)?.checked_add(i64::from(byte - b'0'))?;
    }
    Some(value)
}

fn days_from_civil(year: i64, month: i64, day: i64) -> Option<i64> {
    if !(1..=9999).contains(&year) || !(1..=12).contains(&month) {
        return None;
    }
    let max_day = days_in_month(year, month)?;
    if day < 1 || day > max_day {
        return None;
    }
    let adjusted_year = year - i64::from(month <= 2);
    let era = if adjusted_year >= 0 {
        adjusted_year
    } else {
        adjusted_year - 399
    } / 400;
    let year_of_era = adjusted_year - era * 400;
    let month_prime = month + if month > 2 { -3 } else { 9 };
    let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
    Some(era * 146_097 + day_of_era - 719_468)
}

fn days_in_month(year: i64, month: i64) -> Option<i64> {
    match month {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => Some(31),
        4 | 6 | 9 | 11 => Some(30),
        2 => Some(if is_leap_year(year) { 29 } else { 28 }),
        _ => None,
    }
}

fn is_leap_year(year: i64) -> bool {
    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}