use alloc::{format, vec::Vec};
use super::super::{LintFinding, LintSeverity};
use crate::unsold_goods::disclosure;
#[derive(Debug, Clone, Copy)]
pub struct DisclosureLineInput<'a> {
pub cn_category: &'a str,
pub reason_point: char,
pub units: u64,
pub weight_kg: u64,
pub preparing_for_reuse_pct: u8,
pub recycling_pct: u8,
pub other_recovery_pct: u8,
pub disposal_pct: u8,
pub unknown_pct: u8,
}
#[derive(Debug, Clone, Copy)]
pub struct UnsoldGoodsLintInput<'a> {
pub lines: &'a [DisclosureLineInput<'a>],
pub consolidated_undertaking_count: Option<usize>,
pub measures_taken_len: usize,
pub measures_planned_len: usize,
}
const MEANINGFUL_NARRATIVE_CHARS: usize = 20;
fn split_does_not_total_100(line: &DisclosureLineInput<'_>, index: usize) -> Option<LintFinding> {
if disclosure::treatment_split_is_complete(
line.preparing_for_reuse_pct,
line.recycling_pct,
line.other_recovery_pct,
line.disposal_pct,
line.unknown_pct,
) {
return None;
}
let total = u16::from(line.preparing_for_reuse_pct)
+ u16::from(line.recycling_pct)
+ u16::from(line.other_recovery_pct)
+ u16::from(line.disposal_pct)
+ u16::from(line.unknown_pct);
Some(LintFinding {
code: "unsold_goods.treatment_split_does_not_total_100",
field: "/lines",
severity: LintSeverity::Warning,
message: format!(
"line {index}: waste treatment shares total {total}%, not 100% — Annex I note (i) \
provides `unknown` for the share whose treatment could not be established, so no \
share is left unaccounted for"
),
})
}
fn cn_depth_too_shallow(line: &DisclosureLineInput<'_>, index: usize) -> Option<LintFinding> {
if disclosure::cn_depth_is_correct(line.cn_category) {
return None;
}
if line.cn_category.len() != 2 {
return Some(LintFinding {
code: "unsold_goods.cn_category_malformed",
field: "/lines",
severity: LintSeverity::Warning,
message: format!(
"line {index}: '{}' is not a CN chapter (2 digits) or heading (4 digits)",
line.cn_category
),
});
}
let headings = disclosure::annex_ii_headings_in_chapter(line.cn_category);
Some(LintFinding {
code: "unsold_goods.cn_category_needs_four_digits",
field: "/lines",
severity: LintSeverity::Warning,
message: format!(
"line {index}: chapter '{}' contains Annex II headings ({}), which Art. 3 requires be \
disclosed at four digits — a chapter-level line hides which of them the goods were",
line.cn_category,
headings.join(", ")
),
})
}
fn weight_without_units(line: &DisclosureLineInput<'_>, index: usize) -> Option<LintFinding> {
if line.weight_kg > 0 && line.units == 0 {
return Some(LintFinding {
code: "unsold_goods.weight_without_units",
field: "/lines",
severity: LintSeverity::Notice,
message: format!(
"line {index}: {} kg discarded but zero units — note (f) allows the count to be \
estimated from the weight, so a figure is expected here",
line.weight_kg
),
});
}
None
}
fn donation_claimed_alongside_stronger_reason(
lines: &[DisclosureLineInput<'_>],
) -> Option<LintFinding> {
let mut offending: Vec<&str> = Vec::new();
for line in lines {
if line.reason_point != 'h' {
continue;
}
let same_category: Vec<char> = lines
.iter()
.filter(|l| l.cn_category == line.cn_category)
.map(|l| l.reason_point)
.collect();
if !disclosure::donation_reason_is_admissible(&same_category)
&& !offending.contains(&line.cn_category)
{
offending.push(line.cn_category);
}
}
if offending.is_empty() {
return None;
}
Some(LintFinding {
code: "unsold_goods.donation_reason_alongside_stronger_reason",
field: "/lines",
severity: LintSeverity::Warning,
message: format!(
"category/ies {} claim Art. 2 point (h) — offered for donation and not accepted — \
alongside a point (a)-(g) reason. Point (h) is available only where none of (a) to \
(g) applies",
offending.join(", ")
),
})
}
fn consolidated_without_undertakings(input: &UnsoldGoodsLintInput<'_>) -> Option<LintFinding> {
if input.consolidated_undertaking_count == Some(0) {
return Some(LintFinding {
code: "unsold_goods.consolidated_disclosure_lists_no_undertakings",
field: "/entity/scope",
severity: LintSeverity::Warning,
message:
"a consolidated disclosure must list the subsidiaries or member undertakings it \
covers (Annex I note (c)); with none listed a reader cannot tell whose figures \
these are"
.into(),
});
}
None
}
fn narrative_too_thin(len: usize, field: &'static str, note: char) -> Option<LintFinding> {
if len >= MEANINGFUL_NARRATIVE_CHARS {
return None;
}
Some(LintFinding {
code: "unsold_goods.prevention_measures_not_described",
field,
severity: LintSeverity::Notice,
message: format!(
"Annex I note ({note}) asks for the measures themselves, not a placeholder — {len} \
characters is unlikely to describe one"
),
})
}
#[must_use]
pub fn lint_unsold_goods(input: &UnsoldGoodsLintInput<'_>) -> Vec<LintFinding> {
let mut findings = Vec::new();
for (index, line) in input.lines.iter().enumerate() {
findings.extend(split_does_not_total_100(line, index));
findings.extend(cn_depth_too_shallow(line, index));
findings.extend(weight_without_units(line, index));
}
findings.extend(donation_claimed_alongside_stronger_reason(input.lines));
findings.extend(consolidated_without_undertakings(input));
findings.extend(narrative_too_thin(
input.measures_taken_len,
"/measuresTaken",
'i',
));
findings.extend(narrative_too_thin(
input.measures_planned_len,
"/measuresPlanned",
'j',
));
findings
}