use chrono::{DateTime, Datelike, Utc};
use serde::{Deserialize, Serialize};
use super::sector::{SectorData, UnsoldGoodsDestination};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum LintSeverity {
Warning,
Notice,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LintFinding {
pub code: String,
pub field: String,
pub severity: LintSeverity,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LintResult {
pub pack_version: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub findings: Vec<LintFinding>,
pub assessed_at: DateTime<Utc>,
}
impl LintResult {
#[must_use]
pub fn compute(data: &SectorData) -> Self {
let now = Utc::now();
Self {
pack_version: dpp_rules::lint::LINT_PACK_VERSION.to_owned(),
findings: lint_sector_data(data, now),
assessed_at: now,
}
}
}
fn convert(f: dpp_rules::lint::LintFinding) -> LintFinding {
LintFinding {
code: f.code.to_owned(),
field: f.field.to_owned(),
severity: match f.severity {
dpp_rules::lint::LintSeverity::Warning => LintSeverity::Warning,
dpp_rules::lint::LintSeverity::Notice => LintSeverity::Notice,
},
message: f.message,
}
}
fn unsold_goods_destination_code(d: &UnsoldGoodsDestination) -> &'static str {
match d {
UnsoldGoodsDestination::Donation => "donation",
UnsoldGoodsDestination::Recycling => "recycling",
UnsoldGoodsDestination::Repurposing => "repurposing",
UnsoldGoodsDestination::SupplierReturn => "supplier_return",
UnsoldGoodsDestination::ExemptDestruction => "exempt_destruction",
}
}
#[must_use]
pub fn lint_sector_data(data: &SectorData, as_of: DateTime<Utc>) -> Vec<LintFinding> {
match data {
SectorData::Battery(b) => {
let cathode: Vec<f64> = b
.cathode_material
.as_deref()
.unwrap_or(&[])
.iter()
.map(|m| m.weight_pct)
.collect();
let anode: Vec<f64> = b
.anode_material
.as_deref()
.unwrap_or(&[])
.iter()
.map(|m| m.weight_pct)
.collect();
let electrolyte: Vec<f64> = b
.electrolyte_material
.as_deref()
.unwrap_or(&[])
.iter()
.map(|m| m.weight_pct)
.collect();
let input = dpp_rules::lint::battery::BatteryLintInput {
nominal_voltage_v: b.nominal_voltage_v,
nominal_capacity_ah: b.nominal_capacity_ah,
rated_energy_wh: b.rated_energy_wh,
rated_capacity_kwh: b.rated_capacity_kwh,
operating_temp_min_c: b.operating_temp_min_c,
operating_temp_max_c: b.operating_temp_max_c,
manufacturing_date_unix: b.manufacturing_date.map(|d| d.timestamp()),
as_of_unix: as_of.timestamp(),
cathode_material_pct: &cathode,
anode_material_pct: &anode,
electrolyte_material_pct: &electrolyte,
};
dpp_rules::lint::battery::lint_battery(&input)
.into_iter()
.map(convert)
.collect()
}
SectorData::Textile(t) => {
let fibres: Vec<&str> = t
.fibre_composition
.iter()
.map(|f| f.fibre.as_str())
.collect();
let input = dpp_rules::lint::textile::TextileLintInput {
durability_score: t.durability_score,
expected_wash_cycles: t.expected_wash_cycles,
repair_count: t.repair_count,
repair_history_url: t.repair_history_url.as_deref(),
prior_use_cycles: t.prior_use_cycles,
reuse_condition: t.reuse_condition.as_deref(),
repair_score: t.repair_score,
disassembly_instructions: t.disassembly_instructions.as_deref(),
spare_parts_available: t.spare_parts_available,
microplastic_shedding_mg_per_wash: t.microplastic_shedding_mg_per_wash,
fibres: &fibres,
};
dpp_rules::lint::textile::lint_textile(&input)
.into_iter()
.map(convert)
.collect()
}
SectorData::UnsoldGoods(u) => {
let input = dpp_rules::lint::unsold_goods::UnsoldGoodsLintInput {
reporting_period: &u.reporting_period,
volume_kg: u.volume_kg,
destination: unsold_goods_destination_code(&u.destination),
operator_name: u.operator_name.as_deref(),
destruction_justification: u.destruction_justification.as_deref(),
as_of_year: as_of.year().max(0) as u32,
as_of_month: as_of.month(),
};
dpp_rules::lint::unsold_goods::lint_unsold_goods(&input)
.into_iter()
.map(convert)
.collect()
}
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::sector::{BatteryData, UnsoldGoodsReason, UnsoldGoodsReport};
fn battery() -> BatteryData {
BatteryData {
nominal_voltage_v: 3.7,
nominal_capacity_ah: 10.0,
expected_lifetime_cycles: 500,
co2e_per_unit_kg: 5.0,
rated_energy_wh: Some(37.0),
..crate::test_support::sample_battery_data()
}
}
#[test]
fn clean_battery_produces_no_findings() {
let data = SectorData::Battery(battery());
assert!(lint_sector_data(&data, Utc::now()).is_empty());
}
#[test]
fn battery_energy_mismatch_surfaces_as_domain_finding() {
let mut b = battery();
b.rated_energy_wh = Some(500.0); let data = SectorData::Battery(b);
let findings = lint_sector_data(&data, Utc::now());
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].code, "battery.energy_capacity_mismatch");
assert_eq!(findings[0].severity, LintSeverity::Notice);
}
#[test]
fn unsold_goods_third_party_without_operator_name_triggers() {
let report = UnsoldGoodsReport {
reporting_period: "2026-Q2".into(),
volume_kg: 500.0,
product_category: "apparel".into(),
reason: UnsoldGoodsReason::EndOfSeason,
destination: UnsoldGoodsDestination::Donation,
destruction_justification: None,
country_of_disposal: "MK".into(),
operator_name: None,
};
let data = SectorData::UnsoldGoods(report);
let findings = lint_sector_data(&data, Utc::now());
assert!(
findings
.iter()
.any(|f| f.code == "unsold_goods.operator_name_missing_for_third_party_destination")
);
}
#[test]
fn other_sector_produces_no_findings() {
let data = SectorData::other(serde_json::json!({"sector": "packaging"}))
.expect("packaging has no typed variant");
assert!(lint_sector_data(&data, Utc::now()).is_empty());
}
#[test]
fn lint_result_compute_stamps_pack_version_and_timestamp() {
let data = SectorData::Battery(battery());
let result = LintResult::compute(&data);
assert_eq!(result.pack_version, dpp_rules::lint::LINT_PACK_VERSION);
assert!(result.findings.is_empty());
}
#[test]
fn lint_result_serde_round_trip() {
let data = SectorData::Battery(battery());
let result = LintResult::compute(&data);
let json = serde_json::to_value(&result).unwrap();
let back: LintResult = serde_json::from_value(json).unwrap();
assert_eq!(back, result);
}
}