Skip to main content

dpp_domain/lint/
finding.rs

1//! [`LintFinding`] — one plausibility finding, and how findings are produced.
2//!
3//! A lint never gates publish. The severity is tone, not a gate.
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use crate::product_group::{self, DisclosureScope, ProductGroupData};
9
10/// How strongly a lint finding should be read. Neither variant blocks
11/// publish — the distinction is tone, not gating. Mirrors
12/// [`dpp_rules::lint::LintSeverity`] in an owned, serialisable form.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
15pub enum LintSeverity {
16    Warning,
17    Notice,
18}
19
20/// A single plausibility finding. Mirrors [`dpp_rules::lint::LintFinding`] in
21/// an owned, serialisable form.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct LintFinding {
25    pub code: String,
26    pub field: String,
27    pub severity: LintSeverity,
28    pub message: String,
29}
30
31fn convert(f: dpp_rules::lint::LintFinding) -> LintFinding {
32    LintFinding {
33        code: f.code.to_owned(),
34        field: f.field.to_owned(),
35        severity: match f.severity {
36            dpp_rules::lint::LintSeverity::Warning => LintSeverity::Warning,
37            dpp_rules::lint::LintSeverity::Notice => LintSeverity::Notice,
38        },
39        message: f.message,
40    }
41}
42
43/// Every lint that applies to this payload: its product group's own pack, plus
44/// the cross-group checks that apply wherever the relevant field exists.
45#[must_use]
46pub fn lint_product_group_data(data: &ProductGroupData, as_of: DateTime<Utc>) -> Vec<LintFinding> {
47    let mut findings = product_group_lints(data, as_of);
48    findings.extend(svhc_lints(data));
49    findings
50}
51
52/// Substance-of-very-high-concern lints, for any product group whose schema
53/// carries a declaration.
54///
55/// Outside the product-group match on purpose: REACH Art. 33 reaches every
56/// article, so this runs on all five groups that declare substances rather than
57/// being repeated in five arms — four of which have no pack of their own and
58/// would otherwise have to grow one to get this check.
59///
60/// The list is the compiled-in baseline. Nothing here yet supplies another one;
61/// [`dpp_rules::check_svhc_declarations`] takes it as a parameter so that a
62/// caller can, and the findings state which list ran so a reader can tell.
63fn svhc_lints(data: &ProductGroupData) -> Vec<LintFinding> {
64    let Some(substances) = data.svhc_substances() else {
65        return Vec::new();
66    };
67    let inputs: Vec<dpp_rules::SvhcInput<'_>> = substances
68        .iter()
69        .map(|s| dpp_rules::SvhcInput {
70            cas_number: &s.cas_number,
71            substance_name: &s.substance_name,
72            concentration_pct: s.concentration_pct,
73        })
74        .collect();
75    dpp_rules::lint::svhc::lint_svhc_declarations(&inputs, &dpp_rules::CandidateList::embedded())
76        .into_iter()
77        .map(convert)
78        .collect()
79}
80
81/// Dispatch to the product group-specific lint pack. ProductGroups with no lint pack yet
82/// (everything but battery/textile/unsold-goods in the first ruleset)
83/// produce no findings.
84fn product_group_lints(data: &ProductGroupData, as_of: DateTime<Utc>) -> Vec<LintFinding> {
85    match data {
86        ProductGroupData::Battery(b) => {
87            let cathode: Vec<f64> = b
88                .cathode_material
89                .as_deref()
90                .unwrap_or(&[])
91                .iter()
92                .map(|m| m.weight_pct)
93                .collect();
94            let anode: Vec<f64> = b
95                .anode_material
96                .as_deref()
97                .unwrap_or(&[])
98                .iter()
99                .map(|m| m.weight_pct)
100                .collect();
101            let electrolyte: Vec<f64> = b
102                .electrolyte_material
103                .as_deref()
104                .unwrap_or(&[])
105                .iter()
106                .map(|m| m.weight_pct)
107                .collect();
108            let input = dpp_rules::lint::battery::BatteryLintInput {
109                nominal_voltage_v: b.nominal_voltage_v,
110                nominal_capacity_ah: b.nominal_capacity_ah,
111                rated_energy_wh: b.rated_energy_wh,
112                rated_capacity_kwh: b.rated_capacity_kwh,
113                operating_temp_min_c: b.operating_temp_min_c,
114                operating_temp_max_c: b.operating_temp_max_c,
115                manufacturing_date_unix: b.manufacturing_date.map(|d| d.timestamp()),
116                as_of_unix: as_of.timestamp(),
117                cathode_material_pct: &cathode,
118                anode_material_pct: &anode,
119                electrolyte_material_pct: &electrolyte,
120            };
121            dpp_rules::lint::battery::lint_battery(&input)
122                .into_iter()
123                .map(convert)
124                .collect()
125        }
126        ProductGroupData::Textile(t) => {
127            let fibres: Vec<&str> = t
128                .fibre_composition
129                .iter()
130                .map(|f| f.fibre.as_str())
131                .collect();
132            let input = dpp_rules::lint::textile::TextileLintInput {
133                durability_score: t.durability_score,
134                expected_wash_cycles: t.expected_wash_cycles,
135                repair_count: t.repair_count,
136                repair_history_url: t.repair_history_url.as_deref(),
137                prior_use_cycles: t.prior_use_cycles,
138                reuse_condition: t.reuse_condition.as_deref(),
139                repair_score: t.repair_score,
140                disassembly_instructions: t.disassembly_instructions.as_deref(),
141                spare_parts_available: t.spare_parts_available,
142                microplastic_shedding_mg_per_wash: t.microplastic_shedding_mg_per_wash,
143                fibres: &fibres,
144            };
145            dpp_rules::lint::textile::lint_textile(&input)
146                .into_iter()
147                .map(convert)
148                .collect()
149        }
150        ProductGroupData::UnsoldGoods(u) => {
151            let lines: Vec<dpp_rules::lint::unsold_goods::DisclosureLineInput<'_>> = u
152                .lines
153                .iter()
154                .map(|l| dpp_rules::lint::unsold_goods::DisclosureLineInput {
155                    // A line may carry several CN codes (Annex I note (f)); the
156                    // depth rule is about the first, which is the one the line
157                    // is filed under.
158                    cn_category: l
159                        .cn_categories
160                        .first()
161                        .map_or("", product_group::CnCategory::as_str),
162                    reason_point: l.reason.article_2_point(),
163                    units: l.units_discarded.value,
164                    weight_kg: l.weight_kg.value,
165                    preparing_for_reuse_pct: l.treatment.preparing_for_reuse_pct,
166                    recycling_pct: l.treatment.recycling_pct,
167                    other_recovery_pct: l.treatment.other_recovery_pct,
168                    disposal_pct: l.treatment.disposal_pct,
169                    unknown_pct: l.treatment.unknown_pct,
170                })
171                .collect();
172            let input = dpp_rules::lint::unsold_goods::UnsoldGoodsLintInput {
173                lines: &lines,
174                consolidated_undertaking_count: match &u.entity.scope {
175                    DisclosureScope::Consolidated { undertakings } => Some(undertakings.len()),
176                    DisclosureScope::Standalone => None,
177                },
178                measures_taken_len: u.measures_taken.trim().chars().count(),
179                measures_planned_len: u.measures_planned.trim().chars().count(),
180            };
181            dpp_rules::lint::unsold_goods::lint_unsold_goods(&input)
182                .into_iter()
183                .map(convert)
184                .collect()
185        }
186        _ => Vec::new(),
187    }
188}