Functional Weave
Code in Python

education.cpd-hours@1.0.0

impl/rust.rs

4,239 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, days_between, parse_iso_date};  ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_add_months::add_months;  ← from dates.add-months ^1.0.0 · built alongside by fune

/// CPD minutes logged in the period that applies on on_date, against the
/// requirement. Fixed period boundaries are anchor + k x period_months, each
/// worked out from the anchor so month-end clamping never accumulates.
///
/// # Panics
/// Panics on negative minutes or requirement, a period under one month, a
/// rolling period with an anchor or a fixed one without, or a malformed date.
pub fn cpd_hours(entries: &[CpdEntry], requirement: &CpdRequirement, on_date: &str) -> CpdProgress {
    let on = parse_iso_date(on_date);
    let required = requirement.required_minutes;
    let months = requirement.period_months;
    if required < 0 {
        panic!("requiredMinutes must be a whole number of 0 or more, received {}", required);
    }
    if months < 1 {
        panic!("periodMonths must be a whole number of 1 or more, received {}", months);
    }
    let (period_start, period_end) = if requirement.rolling {
        if requirement.anchor.is_some() {
            panic!("a rolling period takes no anchor");
        }
        (add_days(&add_months(on_date, -months), 1), on_date.to_string())
    } else {
        let anchor = match &requirement.anchor {
            Some(a) => a.as_str(),
            None => panic!("a fixed period needs an anchor date"),
        };
        let a = parse_iso_date(anchor);
        let months_apart = (on.year - a.year) * 12 + (on.month - a.month);
        let mut k = months_apart.div_euclid(months);
        while add_months(anchor, k * months).as_str() > on_date {
            k -= 1;
        }
        while add_months(anchor, (k + 1) * months).as_str() <= on_date {
            k += 1;
        }
        (add_months(anchor, k * months), add_days(&add_months(anchor, (k + 1) * months), -1))
    };

    let mut logged: i64 = 0;
    let mut counted: i64 = 0;
    for e in entries {
        parse_iso_date(&e.date);
        if e.minutes < 0 {
            panic!("minutes must be a whole number of 0 or more, received {} on {}", e.minutes, e.date);
        }
        if e.date >= period_start && e.date <= period_end && e.date.as_str() <= on_date {
            logged += e.minutes;
            counted += 1;
        }
    }
    let shortfall = (required - logged).max(0);
    CpdProgress {
        days_remaining: days_between(on_date, &period_end),
        period_start,
        period_end,
        logged_minutes: logged,
        entries_counted: counted,
        required_minutes: required,
        shortfall_minutes: shortfall,
        met: shortfall == 0,
    }
}

fn whole(value: &Value, message: &str) -> i64 {
    match value {
        Value::Float(f) if f.fract() != 0.0 => panic!("{}, received {}", message, f),
        _ => value.as_i64(),
    }
}

pub fn cpd_progress_to_value(p: &CpdProgress) -> Value {
    Value::obj(vec![
        ("periodStart", Value::str(&p.period_start)),
        ("periodEnd", Value::str(&p.period_end)),
        ("loggedMinutes", Value::Int(p.logged_minutes)),
        ("entriesCounted", Value::Int(p.entries_counted)),
        ("requiredMinutes", Value::Int(p.required_minutes)),
        ("shortfallMinutes", Value::Int(p.shortfall_minutes)),
        ("met", Value::Bool(p.met)),
        ("daysRemaining", Value::Int(p.days_remaining)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let entries: Vec<CpdEntry> = args[0]
        .as_arr()
        .iter()
        .map(|v| CpdEntry {
            date: v.get("date").as_str().to_string(),
            minutes: whole(v.get("minutes"), "minutes must be a whole number of 0 or more"),
        })
        .collect();
    let r = &args[1];
    let anchor = r.get("anchor");
    let requirement = CpdRequirement {
        required_minutes: whole(r.get("requiredMinutes"), "requiredMinutes must be a whole number of 0 or more"),
        period_months: whole(r.get("periodMonths"), "periodMonths must be a whole number of 1 or more"),
        rolling: r.get("rolling").as_bool(),
        anchor: if anchor.is_null() { None } else { Some(anchor.as_str().to_string()) },
    };
    cpd_progress_to_value(&cpd_hours(&entries, &requirement, args[2].as_str()))
}