Functional Weave
Code in Python

fleet.drivers-hours@1.0.1

impl/rust.rs

12,435 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::add_days;  ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_day_of_week::day_of_week;  ← from dates.day-of-week ^1.0.0 · built alongside by fune
use super::fleet_drivers_hours_data::{DRIVING_LIMITS, DRIVING_LIMITS_HISTORY};  ← this capability’s own data, compiled from data/limits.json into the same file by fune build

const KINDS: [&str; 4] = ["driving", "work", "availability", "rest"];
const WEEK: i64 = 10080;

fn is_iso_date(value: &str) -> bool {
    let bytes = value.as_bytes();
    bytes.len() == 10
        && bytes[4] == b'-'
        && bytes[7] == b'-'
        && bytes
            .iter()
            .enumerate()
            .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit())
}

fn parse_hh_mm(value: &str) -> Option<i64> {
    let bytes = value.as_bytes();
    if bytes.len() != 5 || bytes[2] != b':' {
        return None;
    }
    if !(bytes[0].is_ascii_digit() && bytes[1].is_ascii_digit() && bytes[3].is_ascii_digit() && bytes[4].is_ascii_digit()) {
        return None;
    }
    let hours = ((bytes[0] - b'0') * 10 + (bytes[1] - b'0')) as i64;
    let minutes = ((bytes[3] - b'0') * 10 + (bytes[4] - b'0')) as i64;
    if hours > 23 || minutes > 59 {
        return None;
    }
    Some(hours * 60 + minutes)
}

fn limit_on(rule: &str, on_date: &str) -> i64 {
    let mut best: Option<(i64, &'static str)> = None;
    let mut earliest: Option<&'static str> = None;
    for row in DRIVING_LIMITS {
        if row.rule != rule {
            continue;
        }
        if earliest.map_or(true, |e| row.valid_from < e) {
            earliest = Some(row.valid_from);
        }
        if on_date < row.valid_from {
            continue;
        }
        if let Some(valid_to) = row.valid_to {
            if on_date > valid_to {
                continue;
            }
        }
        if best.map_or(true, |(_, from)| row.valid_from > from) {
            best = Some((row.value, row.valid_from));
        }
    }
    if let Some((value, _)) = best {
        return value;
    }
    if DRIVING_LIMITS_HISTORY != "full" {
        if let Some(earliest) = earliest {
            if on_date < earliest {
                panic!(
                    "no drivers' hours limit \"{}\" on {}: this build was installed with history={}, so it only carries limits from {}. Reinstall with history=full for older records.",
                    rule, on_date, DRIVING_LIMITS_HISTORY, earliest
                );
            }
        }
    }
    panic!("no drivers' hours limit \"{}\" on {}", rule, on_date)
}

fn local_time(start_date: &str, minute: i64) -> String {
    let day = minute.div_euclid(1440);
    let in_day = minute - day * 1440;
    format!("{}T{:02}:{:02}", add_days(start_date, day), in_day / 60, in_day % 60)
}

struct Pending {
    rule: &'static str,
    at_minute: i64,
    actual: i64,
    limit: i64,
}

/// Check a run of tachograph activities against the GB/EU drivers' hours
/// rules on driving time and breaks (Regulation (EC) 561/2006, Articles 6
/// and 7).
///
/// Activities are back to back from start_date/start_time, in local time. A
/// rest of 45 minutes, or 15 then 30, ends a driving period; a rest of 9 hours
/// or more is a daily rest and ends the daily driving time. Weeks are fixed
/// weeks, Monday 00:00 to Sunday 24:00, so driving across Sunday midnight is
/// split.
///
/// # Panics
/// Panics on a malformed date or time, an unknown kind, a length below one
/// minute, or a date before the rules applied.
pub fn check_drivers_hours(start_date: &str, start_time: &str, activities: &[DriverActivity]) -> DriversHoursCheck {
    if !is_iso_date(start_date) {
        panic!("startDate must be an ISO date (YYYY-MM-DD), received \"{}\"", start_date);
    }
    let start_minute = match parse_hh_mm(start_time) {
        Some(m) => m,
        None => panic!("startTime must be HH:MM, 24-hour, received \"{}\"", start_time),
    };
    let driving_period = limit_on("driving-period", start_date);
    let full_break = limit_on("break", start_date);
    let split_first = limit_on("split-break-first", start_date);
    let split_second = limit_on("split-break-second", start_date);
    let daily = limit_on("daily-driving", start_date);
    let daily_extended = limit_on("daily-driving-extended", start_date);
    let extensions_allowed = limit_on("extended-days-per-week", start_date);
    let weekly_limit = limit_on("weekly-driving", start_date);
    let fortnight_limit = limit_on("fortnightly-driving", start_date);
    let daily_rest = limit_on("daily-rest-minimum", start_date);

    // Merge neighbours of the same kind: two rests back to back are one
    // uninterrupted rest, whatever the recording split them into.
    let mut segments: Vec<(String, i64)> = Vec::new();
    for activity in activities {
        if !KINDS.contains(&activity.kind.as_str()) {
            panic!("unknown activity kind \"{}\"", activity.kind);
        }
        if activity.minutes < 1 {
            panic!("minutes must be a whole number of at least 1, received {}", activity.minutes);
        }
        match segments.last_mut() {
            Some(last) if last.0 == activity.kind => last.1 += activity.minutes,
            _ => segments.push((activity.kind.clone(), activity.minutes)),
        }
    }

    let week_base = (day_of_week(start_date) - 1) * 1440 + start_minute;

    let mut breaches: Vec<Pending> = Vec::new();
    let mut accumulated: i64 = 0;
    let mut first_part = false;
    let mut break_breach: Option<usize> = None;
    let mut day_driving: i64 = 0;
    let mut day_passed_daily: Option<i64> = None;
    let mut day_week: Option<i64> = None;
    let mut day_breach: Option<usize> = None;
    let mut extensions: Vec<(i64, i64)> = Vec::new();
    let mut weekly: Vec<(i64, i64)> = Vec::new();
    let mut weekly_breaches: Vec<(i64, usize)> = Vec::new();
    let mut fortnight_breaches: Vec<(i64, usize)> = Vec::new();

    fn get(map: &[(i64, i64)], key: i64) -> i64 {
        map.iter().find(|(k, _)| *k == key).map_or(0, |(_, v)| *v)
    }
    fn set(map: &mut Vec<(i64, i64)>, key: i64, value: i64) {
        match map.iter_mut().find(|(k, _)| *k == key) {
            Some(entry) => entry.1 = value,
            None => map.push((key, value)),
        }
    }

    let mut total_driving: i64 = 0;
    let mut offset: i64 = 0;
    let segment_count = segments.len();
    for index in 0..=segment_count {
        // One pass past the end closes the open driving period and day.
        let (kind, minutes) = if index < segment_count {
            (segments[index].0.as_str(), segments[index].1)
        } else {
            ("end", 0)
        };
        if kind == "driving" {
            total_driving += minutes;
            let mut start = offset;
            let end = offset + minutes;
            while start < end {
                let week = (week_base + start).div_euclid(WEEK);
                let chunk_end = end.min((week + 1) * WEEK - week_base);
                let m = chunk_end - start;
                if break_breach.is_none() && accumulated + m > driving_period {
                    breaches.push(Pending { rule: "break", at_minute: start + (driving_period - accumulated), actual: 0, limit: driving_period });
                    break_breach = Some(breaches.len() - 1);
                }
                accumulated += m;
                if day_week.is_none() {
                    day_week = Some(week);
                }
                if day_passed_daily.is_none() && day_driving + m > daily {
                    day_passed_daily = Some(start + (daily - day_driving));
                }
                if day_breach.is_none() && day_driving + m > daily_extended {
                    breaches.push(Pending { rule: "daily-driving", at_minute: start + (daily_extended - day_driving), actual: 0, limit: daily_extended });
                    day_breach = Some(breaches.len() - 1);
                }
                day_driving += m;
                let before = get(&weekly, week);
                if !weekly_breaches.iter().any(|(w, _)| *w == week) && before + m > weekly_limit {
                    breaches.push(Pending { rule: "weekly-driving", at_minute: start + (weekly_limit - before), actual: 0, limit: weekly_limit });
                    weekly_breaches.push((week, breaches.len() - 1));
                }
                let pair_before = get(&weekly, week - 1) + before;
                if !fortnight_breaches.iter().any(|(w, _)| *w == week) && pair_before + m > fortnight_limit {
                    breaches.push(Pending { rule: "fortnightly-driving", at_minute: start + (fortnight_limit - pair_before), actual: 0, limit: fortnight_limit });
                    fortnight_breaches.push((week, breaches.len() - 1));
                }
                set(&mut weekly, week, before + m);
                start = chunk_end;
            }
        } else if kind == "rest" || kind == "end" {
            let is_end = kind == "end";
            if is_end || minutes >= full_break || (first_part && minutes >= split_second) {
                if let Some(i) = break_breach {
                    breaches[i].actual = accumulated;
                }
                accumulated = 0;
                first_part = false;
                break_breach = None;
            } else if minutes >= split_first && accumulated > 0 {
                first_part = true;
            }
            if is_end || minutes >= daily_rest {
                if day_driving > daily {
                    if let Some(week) = day_week {
                        let used = get(&extensions, week) + 1;
                        set(&mut extensions, week, used);
                        if used > extensions_allowed && day_driving <= daily_extended {
                            if let Some(at) = day_passed_daily {
                                breaches.push(Pending { rule: "daily-driving", at_minute: at, actual: day_driving, limit: daily });
                            }
                        }
                    }
                }
                if let Some(i) = day_breach {
                    breaches[i].actual = day_driving;
                }
                day_driving = 0;
                day_passed_daily = None;
                day_week = None;
                day_breach = None;
            }
        }
        offset += minutes;
    }
    for (week, i) in &weekly_breaches {
        breaches[*i].actual = get(&weekly, *week);
    }
    for (week, i) in &fortnight_breaches {
        breaches[*i].actual = get(&weekly, *week - 1) + get(&weekly, *week);
    }

    // sort_by is stable, so breaches at the same minute keep the order found.
    breaches.sort_by(|a, b| a.at_minute.cmp(&b.at_minute));
    let ordered: Vec<HoursBreach> = breaches
        .iter()
        .map(|b| HoursBreach {
            rule: b.rule.to_string(),
            at: local_time(start_date, start_minute + b.at_minute),
            actual: b.actual,
            limit: b.limit,
        })
        .collect();
    DriversHoursCheck { compliant: ordered.is_empty(), driving_minutes: total_driving, breaches: ordered }
}

/// Read one activity from vector JSON, refusing a length Rust's i64 cannot hold.
pub fn driver_activity_from_value(value: &Value) -> DriverActivity {
    let minutes = value.get("minutes");
    let whole = match minutes {
        Value::Int(i) => *i,
        Value::Float(f) => panic!("minutes must be a whole number of at least 1, received {}", f),
        _ => panic!("minutes must be a whole number of at least 1, received a non-number"),
    };
    let kind = match value.get("kind") {
        Value::Str(s) => s.clone(),
        _ => panic!("unknown activity kind \"\""),
    };
    DriverActivity { kind, minutes: whole }
}

pub fn hours_breach_to_value(breach: &HoursBreach) -> Value {
    Value::obj(vec![
        ("rule", Value::str(&breach.rule)),
        ("at", Value::str(&breach.at)),
        ("actual", Value::Int(breach.actual)),
        ("limit", Value::Int(breach.limit)),
    ])
}

pub fn drivers_hours_check_to_value(check: &DriversHoursCheck) -> Value {
    Value::obj(vec![
        ("compliant", Value::Bool(check.compliant)),
        ("drivingMinutes", Value::Int(check.driving_minutes)),
        ("breaches", Value::Arr(check.breaches.iter().map(hours_breach_to_value).collect())),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let activities: Vec<DriverActivity> = args[2].as_arr().iter().map(driver_activity_from_value).collect();
    drivers_hours_check_to_value(&check_drivers_hours(args[0].as_str(), args[1].as_str(), &activities))
}