use alloc::{format, vec::Vec};
use super::{LintFinding, LintSeverity};
#[derive(Debug, Clone, Copy)]
pub struct UnsoldGoodsLintInput<'a> {
pub reporting_period: &'a str,
pub volume_kg: f64,
pub destination: &'a str,
pub operator_name: Option<&'a str>,
pub destruction_justification: Option<&'a str>,
pub as_of_year: u32,
pub as_of_month: u32,
}
struct ParsedPeriod {
year: u32,
end_month: Option<u32>,
}
fn parse_reporting_period(s: &str) -> Option<ParsedPeriod> {
let s = s.trim();
let is_digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit());
if s.len() == 4 && is_digits(s) {
return Some(ParsedPeriod {
year: s.parse().ok()?,
end_month: None,
});
}
if let Some((y, q)) = s.split_once("-Q").or_else(|| s.split_once("-q")) {
if y.len() == 4 && is_digits(y) && q.len() == 1 && is_digits(q) {
let quarter: u32 = q.parse().ok()?;
if (1..=4).contains(&quarter) {
return Some(ParsedPeriod {
year: y.parse().ok()?,
end_month: Some(quarter * 3),
});
}
}
return None;
}
if let Some((y, m)) = s.split_once('-') {
if y.len() == 4 && is_digits(y) && m.len() == 2 && is_digits(m) {
let month: u32 = m.parse().ok()?;
if (1..=12).contains(&month) {
return Some(ParsedPeriod {
year: y.parse().ok()?,
end_month: Some(month),
});
}
}
return None;
}
None
}
#[must_use]
pub fn reporting_period_format_implausible(
input: &UnsoldGoodsLintInput<'_>,
) -> Option<LintFinding> {
if parse_reporting_period(input.reporting_period).is_some() {
return None;
}
Some(LintFinding {
code: "unsold_goods.reporting_period_format_implausible",
field: "reportingPeriod",
severity: LintSeverity::Notice,
message: format!(
"reportingPeriod '{}' does not match a recognised YYYY, YYYY-QN, or YYYY-MM format — intended?",
input.reporting_period
),
})
}
#[must_use]
pub fn reporting_period_in_future(input: &UnsoldGoodsLintInput<'_>) -> Option<LintFinding> {
let period = parse_reporting_period(input.reporting_period)?;
let is_future = match period.end_month {
Some(month) => {
period.year > input.as_of_year
|| (period.year == input.as_of_year && month > input.as_of_month)
}
None => period.year > input.as_of_year,
};
if !is_future {
return None;
}
Some(LintFinding {
code: "unsold_goods.reporting_period_in_future",
field: "reportingPeriod",
severity: LintSeverity::Warning,
message: format!(
"reportingPeriod '{}' is in the future — intended?",
input.reporting_period
),
})
}
const VOLUME_KG_IMPLAUSIBLE_THRESHOLD: f64 = 1_000_000.0;
#[must_use]
pub fn volume_kg_implausibly_large(input: &UnsoldGoodsLintInput<'_>) -> Option<LintFinding> {
if !input.volume_kg.is_finite() || input.volume_kg <= VOLUME_KG_IMPLAUSIBLE_THRESHOLD {
return None;
}
Some(LintFinding {
code: "unsold_goods.volume_kg_implausibly_large",
field: "volumeKg",
severity: LintSeverity::Notice,
message: format!(
"volumeKg ({}) exceeds 1,000,000 kg for a single report — intended, or a unit slip \
(e.g. grams entered as kilograms)?",
input.volume_kg
),
})
}
const THIRD_PARTY_DESTINATIONS: &[&str] = &[
"donation",
"recycling",
"supplier_return",
"exempt_destruction",
];
#[must_use]
pub fn operator_name_missing_for_third_party_destination(
input: &UnsoldGoodsLintInput<'_>,
) -> Option<LintFinding> {
let is_third_party = THIRD_PARTY_DESTINATIONS
.iter()
.any(|d| input.destination.eq_ignore_ascii_case(d));
if !is_third_party || input.operator_name.is_some() {
return None;
}
Some(LintFinding {
code: "unsold_goods.operator_name_missing_for_third_party_destination",
field: "operatorName",
severity: LintSeverity::Notice,
message: format!(
"destination '{}' involves a third party but operatorName is absent — intended?",
input.destination
),
})
}
#[must_use]
pub fn destruction_justification_without_exempt_destination(
input: &UnsoldGoodsLintInput<'_>,
) -> Option<LintFinding> {
if input.destruction_justification.is_none()
|| input.destination.eq_ignore_ascii_case("exempt_destruction")
{
return None;
}
Some(LintFinding {
code: "unsold_goods.destruction_justification_without_exempt_destination",
field: "destructionJustification",
severity: LintSeverity::Notice,
message: format!(
"destructionJustification is populated but destination is '{}', not exempt_destruction — intended?",
input.destination
),
})
}
#[must_use]
pub fn lint_unsold_goods(input: &UnsoldGoodsLintInput<'_>) -> Vec<LintFinding> {
let mut out = Vec::new();
out.extend(reporting_period_format_implausible(input));
out.extend(reporting_period_in_future(input));
out.extend(volume_kg_implausibly_large(input));
out.extend(operator_name_missing_for_third_party_destination(input));
out.extend(destruction_justification_without_exempt_destination(input));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn base_input() -> UnsoldGoodsLintInput<'static> {
UnsoldGoodsLintInput {
reporting_period: "2026-Q2",
volume_kg: 500.0,
destination: "donation",
operator_name: Some("Caritas Skopje"),
destruction_justification: None,
as_of_year: 2026,
as_of_month: 7,
}
}
#[test]
fn recognised_formats_all_parse() {
for s in ["2026", "2026-Q2", "2026-07"] {
let mut input = base_input();
input.reporting_period = s;
assert!(reporting_period_format_implausible(&input).is_none(), "{s}");
}
}
#[test]
fn garbage_format_triggers() {
let mut input = base_input();
input.reporting_period = "asdf";
let finding = reporting_period_format_implausible(&input).unwrap();
assert_eq!(
finding.code,
"unsold_goods.reporting_period_format_implausible"
);
}
#[test]
fn out_of_range_quarter_triggers_format_lint() {
let mut input = base_input();
input.reporting_period = "2026-Q9";
assert!(reporting_period_format_implausible(&input).is_some());
}
#[test]
fn past_period_passes() {
assert!(reporting_period_in_future(&base_input()).is_none());
}
#[test]
fn future_quarter_triggers() {
let mut input = base_input();
input.reporting_period = "2027-Q1";
let finding = reporting_period_in_future(&input).unwrap();
assert_eq!(finding.code, "unsold_goods.reporting_period_in_future");
}
#[test]
fn same_year_later_month_triggers() {
let mut input = base_input();
input.reporting_period = "2026-12";
input.as_of_month = 7;
assert!(reporting_period_in_future(&input).is_some());
}
#[test]
fn unparsable_period_does_not_trigger_future_lint() {
let mut input = base_input();
input.reporting_period = "asdf";
assert!(reporting_period_in_future(&input).is_none());
}
#[test]
fn ordinary_volume_passes() {
assert!(volume_kg_implausibly_large(&base_input()).is_none());
}
#[test]
fn huge_volume_triggers() {
let mut input = base_input();
input.volume_kg = 5_000_000.0;
let finding = volume_kg_implausibly_large(&input).unwrap();
assert_eq!(finding.code, "unsold_goods.volume_kg_implausibly_large");
}
#[test]
fn third_party_with_operator_name_passes() {
assert!(operator_name_missing_for_third_party_destination(&base_input()).is_none());
}
#[test]
fn third_party_without_operator_name_triggers() {
let mut input = base_input();
input.operator_name = None;
let finding = operator_name_missing_for_third_party_destination(&input).unwrap();
assert_eq!(
finding.code,
"unsold_goods.operator_name_missing_for_third_party_destination"
);
}
#[test]
fn repurposing_destination_never_triggers() {
let mut input = base_input();
input.destination = "repurposing";
input.operator_name = None;
assert!(operator_name_missing_for_third_party_destination(&input).is_none());
}
#[test]
fn no_justification_passes() {
assert!(destruction_justification_without_exempt_destination(&base_input()).is_none());
}
#[test]
fn justification_on_exempt_destination_passes() {
let mut input = base_input();
input.destination = "exempt_destruction";
input.destruction_justification =
Some("Contaminated batch, health authority order 2026-119");
assert!(destruction_justification_without_exempt_destination(&input).is_none());
}
#[test]
fn justification_on_other_destination_triggers() {
let mut input = base_input();
input.destruction_justification = Some("stray text");
let finding = destruction_justification_without_exempt_destination(&input).unwrap();
assert_eq!(
finding.code,
"unsold_goods.destruction_justification_without_exempt_destination"
);
}
#[test]
fn clean_input_produces_no_findings() {
assert!(lint_unsold_goods(&base_input()).is_empty());
}
}