dpp-domain 0.21.0

EU Digital Product Passport domain types, port traits, and per-field disclosure policy
Documentation
//! Textile (EU Textile DPP, expected ~Q2 2027 adoption, compliance ~2028–2029).

use serde::{Deserialize, Serialize};

use crate::identifier::ProductIdentifier;

use super::common::SvhcSubstance;

/// A single fibre entry in a textile product's composition list.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct FibreEntry {
    /// Fibre name, e.g. `"cotton"`, `"polyester"`, `"recycled_polyester"`.
    pub fibre: String,
    /// Percentage by weight (0.0–100.0).
    ///
    /// All entries in the composition list must sum to approximately 100.0
    /// (± 2.0 percentage point tolerance to accommodate rounding).
    pub pct: f64,
    /// ISO 3166-1 alpha-2 country code where this specific fibre was sourced.
    ///
    /// Per-fibre origin traceability as anticipated by the textile delegated act.
    /// When present, allows downstream actors to verify sourcing per fibre rather
    /// than relying on a single `country_of_raw_material_origin` for the whole product.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub country_of_origin: Option<String>,
}

/// Textile-specific fields for EU textile DPP compliance.
///
/// Based on the anticipated EU Textile DPP delegated act (~Q2 2027 adoption).
/// Fields marked as `Option` are optional under v1.0.0 but may become mandatory
/// once the delegated act is finalised.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TextileData {
    // ── Mandatory fields (v1.0.0) ──────────────────────────────────────────
    /// The unique product identifier for the textile product, in whichever EN 18219
    /// clause 5 scheme issued it — a 14-digit GTIN under scheme 1.
    pub product_identifier: ProductIdentifier,
    /// List of fibres and their percentage composition. Must sum to ~100%.
    pub fibre_composition: Vec<FibreEntry>,
    /// ISO 3166-1 alpha-2 country code where the textile was manufactured.
    pub country_of_origin: String,
    /// ISO 3758 care symbols or free text care instructions.
    pub care_instructions: String,
    /// Chemical compliance standard, e.g. `"OEKO-TEX 100"`, `"REACH"`, `"GOTS"`.
    pub chemical_compliance_standard: String,

    // ── Environmental metrics ──────────────────────────────────────────────
    /// Total recycled content as a percentage of total weight (0.0–100.0).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recycled_content_pct: Option<f64>,
    /// Carbon footprint in kg CO₂e per unit.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub carbon_footprint_kg_co2e: Option<f64>,
    /// Water consumption in litres per unit produced.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub water_use_litres: Option<f64>,
    /// Microplastic fibre shedding in milligrams per wash cycle.
    ///
    /// Measured per ISO/DIS 4484 or equivalent. Relevant for synthetic textiles
    /// where microplastic release is a growing regulatory concern.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub microplastic_shedding_mg_per_wash: Option<f64>,

    // ── Durability & repairability ─────────────────────────────────────────
    /// Repairability score (0.0–10.0) per EU methodology.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub repair_score: Option<f64>,
    /// Durability score (0.0–10.0) measuring resistance to pilling,
    /// colour fastness, dimensional stability, and seam strength.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub durability_score: Option<f64>,
    /// Expected number of wash cycles before significant quality degradation.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expected_wash_cycles: Option<u32>,

    // ── Traceability ───────────────────────────────────────────────────────
    /// ISO 3166-1 alpha-2 country of raw material origin (product-level fallback).
    /// Per-fibre origin on `FibreEntry.country_of_origin` takes precedence when present.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub country_of_raw_material_origin: Option<String>,

    // ── SCIP / SVHC substance disclosure ───────────────────────────────────
    /// List of SVHC substances present above the 0.1% w/w threshold.
    ///
    /// Required by REACH Article 33 and linked to the ECHA SCIP database.
    /// An empty vec means the manufacturer has checked and found no SVHCs.
    /// `None` means the check has not yet been performed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub svhc_substances: Option<Vec<SvhcSubstance>>,

    // ── Substances of concern (beyond SVHC) ──────────────────────────────
    /// Substances present in the textile that are restricted by **REACH Annex
    /// XVII entry 72** — the substances listed in its Appendix 12, in clothing,
    /// skin-contact textiles and footwear, at or above the concentration given
    /// for each, measured in homogeneous material.
    ///
    /// Appendix 12 is predominantly a **CMR and PAH** list: cadmium, arsenic,
    /// lead, benzene and seven polycyclic aromatic hydrocarbons, plus chromium VI
    /// and C.I. Disperse Blue 1. Only the last two are skin sensitisers, so the
    /// field name is narrower than what it carries — kept because it is the
    /// published wire name and renaming it would break stored documents.
    ///
    /// ⚠️ **Nickel does not belong here.** Nickel release from articles in
    /// prolonged skin contact is **Annex XVII entry 27**, a separate restriction
    /// with its own migration-limit regime. An earlier version of this comment
    /// gave "nickel in accessories" as an entry 72 example; it never was one.
    /// `v1.1.0` and `v1.2.0` are published and carry that error in their field
    /// description — see the errata note in `crate::schemas`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub allergens: Option<Vec<String>>,

    /// Restricted substances present that are not classified as SVHC but are
    /// regulated under product group-specific rules (e.g. PFASs, flame retardants,
    /// biocides).  Each entry is a free-text substance identifier.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub substances_of_concern: Option<Vec<String>>,

    // ── Circularity & end-of-life ─────────────────────────────────────────
    /// Recyclability classification, e.g. `"mono-material"`, `"multi-material"`,
    /// `"not-recyclable"`.  Based on design-for-recycling assessment.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recyclability_class: Option<String>,

    /// End-of-life instructions: how to dispose of or recycle the product.
    /// Free text or URL pointing to take-back/collection point information.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub end_of_life_instructions: Option<String>,

    /// Whether the product has been refurbished or reused, and if so its
    /// condition grade (e.g. `"like-new"`, `"good"`, `"fair"`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reuse_condition: Option<String>,

    /// Number of prior ownership cycles (0 for new products).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prior_use_cycles: Option<u32>,

    // ── Restricted data (gated by access control) ──────────────────────────
    /// Free-text or structured disassembly / deconstruction instructions.
    /// Classified `Disclosure::Restricted` (Verifiable Credential required).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub disassembly_instructions: Option<String>,
    /// Whether spare parts or replacement components are available from the manufacturer.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub spare_parts_available: Option<bool>,
    /// Product net weight in grams (used for per-unit environmental calculations).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub product_weight_grams: Option<f64>,

    // ── Repair & maintenance history ──────────────────────────────────────
    /// URL or structured log of repair events performed on this specific item.
    /// Relevant for products sold with a repair history (e.g. certified
    /// pre-owned programmes).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub repair_history_url: Option<String>,

    /// Number of completed professional repairs on this item.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub repair_count: Option<u32>,

    /// Environmental impact score per garment lifecycle (PEF/OEF methodology).
    /// Dimensionless single score aggregating multiple impact categories.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pef_score: Option<f64>,
}

impl crate::product_group::payload::ProductGroupPayload for TextileData {
    fn product_identifier(&self) -> Option<&crate::identifier::ProductIdentifier> {
        Some(&self.product_identifier)
    }

    /// This act defines no model identifier.
    fn model_identifier(&self) -> Option<&str> {
        None
    }

    /// This group's schema carries a REACH candidate-list declaration.
    fn svhc_substances(&self) -> Option<&[crate::product_group::SvhcSubstance]> {
        self.svhc_substances.as_deref()
    }

    /// This group models no product category.
    ///
    /// The catalog declared `apparel`, `footwear` and `home_textile` until
    /// they were removed, and no field here ever carried any of them — the
    /// textile schema has no category-shaped property at all, at any version.
    /// All three were therefore unreachable: nothing could set one and nothing
    /// could validate against one. (`home_textile` was also the only snake_case
    /// value in the whole catalog, which is the kind of thing an unread list
    /// accumulates.)
    ///
    /// The delegated act is unadopted, so what a textile category *is* is not
    /// yet readable from a text. Inventing the field ahead of it would put a
    /// guess where the act's own axis has to go.
    fn product_category(&self) -> Option<&str> {
        None
    }
}