Skip to main content

sdsforge_core/generation/
input.rs

1use serde::{Deserialize, Serialize};
2
3use super::evidence::{EvidenceSource, MeasuredPropertiesInput};
4
5/// A single component's concentration in a mixture.
6///
7/// Either `exact` or a `lower`/`upper` pair should be set, not both — see
8/// [`super::validate_product_input`] for the ambiguity check. `unit` is
9/// required — there is no unambiguous default concentration unit.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub struct ConcentrationRange {
13    pub exact: Option<f64>,
14    pub lower: Option<f64>,
15    pub upper: Option<f64>,
16    pub unit: String,
17}
18
19/// One ingredient of a product formulation.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct ComponentInput {
23    pub cas_number: Option<String>,
24    pub name: Option<String>,
25    pub concentration: ConcentrationRange,
26}
27
28/// Supplier contact details for Section 1 (Identification).
29///
30/// Derives `Default` (empty/`None` fields) purely so `ProductInput`'s new
31/// evidence-related fields (added alongside `MeasuredPropertiesInput`) can
32/// be defaulted in existing test fixtures via `..Default::default()`
33/// without hand-editing every construction site — not because a
34/// default-valued supplier is ever meaningful on its own. `company_name`
35/// stays required on deserialization regardless: an *omitted* `supplier`
36/// key is a parse error (`ProductInput::supplier` has no `#[serde(default)]`),
37/// and a *present* `supplier` object still requires `company_name`.
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct SupplierInput {
41    pub company_name: String,
42    pub address: Option<String>,
43    pub phone: Option<String>,
44    pub email: Option<String>,
45}
46
47/// Raw input to the `generate` feature: a product's identity, supplier,
48/// formulation, and any measured-property evidence. This is the domain
49/// model — deliberately separate from the MHLW-schema `SdsRoot` in
50/// [`crate::schema`], which is the *output* shape.
51///
52/// Derives `Default` for the same reason as [`SupplierInput`] — test
53/// ergonomics for the new fields, not a meaningful default product.
54/// Deliberately NOT `#[serde(default)]` on the struct itself: `trade_name`,
55/// `supplier`, and `components` must remain required keys in the input
56/// file. Only the three fields below whose omission has an unambiguous
57/// meaning ("nothing was supplied") get `#[serde(default)]`.
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct ProductInput {
61    pub trade_name: String,
62    #[serde(default)]
63    pub other_names: Vec<String>,
64    pub supplier: SupplierInput,
65    pub components: Vec<ComponentInput>,
66    /// Measured-property evidence for the seven safety-sensitive
67    /// properties Section 1/3 generation alone can never resolve — see
68    /// `docs/sdsforge-architecture.md`'s "Properties that require
69    /// product-level evidence" table and
70    /// `super::unresolved::PRODUCT_LEVEL_POLICIES`. Omitting this key
71    /// means no measured-property evidence was supplied at all — the same
72    /// as `measured_properties: {}` — never a parse error.
73    #[serde(default)]
74    pub measured_properties: MeasuredPropertiesInput,
75    /// The evidence sources referenced by `measured_properties` entries'
76    /// `evidence_id` fields. An entry whose `evidence_id` doesn't resolve
77    /// here can never become `Confirmed` — see the resolution logic in
78    /// `super::result`. Omitting this key means no evidence was supplied.
79    #[serde(default)]
80    pub evidence: Vec<EvidenceSource>,
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    const MINIMAL_JSON: &str = r#"{
88        "trade_name": "Minimal Product",
89        "supplier": {"company_name": "Acme"},
90        "components": [
91            {"concentration": {"exact": 100.0, "unit": "%"}}
92        ]
93    }"#;
94
95    const VERBOSE_EQUIVALENT_JSON: &str = r#"{
96        "trade_name": "Minimal Product",
97        "other_names": [],
98        "supplier": {"company_name": "Acme", "address": null, "phone": null, "email": null},
99        "components": [
100            {"cas_number": null, "name": null,
101             "concentration": {"exact": 100.0, "lower": null, "upper": null, "unit": "%"}}
102        ],
103        "measured_properties": {"flash_point": [], "boiling_point": [], "vapor_pressure": [],
104            "explosive_limits": [], "self_reactivity": [], "oxidizing_properties": [],
105            "metal_corrosivity": []},
106        "evidence": []
107    }"#;
108
109    #[test]
110    fn minimal_json_omits_other_names_measured_properties_and_evidence() {
111        let input: ProductInput = serde_json::from_str(MINIMAL_JSON).unwrap();
112        assert_eq!(input.trade_name, "Minimal Product");
113        assert!(input.other_names.is_empty());
114        assert!(input.evidence.is_empty());
115        assert!(input.measured_properties.flash_point.is_empty());
116        assert!(input.measured_properties.boiling_point.is_empty());
117        assert!(input.components[0].cas_number.is_none());
118        assert!(input.components[0].name.is_none());
119    }
120
121    #[test]
122    fn verbose_and_concise_product_input_deserialize_to_equivalent_values() {
123        let concise: ProductInput = serde_json::from_str(MINIMAL_JSON).unwrap();
124        let verbose: ProductInput = serde_json::from_str(VERBOSE_EQUIVALENT_JSON).unwrap();
125        assert_eq!(
126            serde_json::to_string(&concise).unwrap(),
127            serde_json::to_string(&verbose).unwrap()
128        );
129    }
130
131    #[test]
132    fn missing_trade_name_fails() {
133        let json = r#"{"supplier":{"company_name":"Acme"},"components":[]}"#;
134        assert!(serde_json::from_str::<ProductInput>(json).is_err());
135    }
136
137    #[test]
138    fn missing_supplier_fails() {
139        let json = r#"{"trade_name":"X","components":[]}"#;
140        assert!(serde_json::from_str::<ProductInput>(json).is_err());
141    }
142
143    #[test]
144    fn missing_supplier_company_name_fails() {
145        let json = r#"{"trade_name":"X","supplier":{},"components":[]}"#;
146        assert!(serde_json::from_str::<ProductInput>(json).is_err());
147    }
148
149    #[test]
150    fn missing_components_fails() {
151        let json = r#"{"trade_name":"X","supplier":{"company_name":"Acme"}}"#;
152        assert!(serde_json::from_str::<ProductInput>(json).is_err());
153    }
154
155    #[test]
156    fn missing_component_concentration_fails() {
157        let json = r#"{"cas_number":"7732-18-5","name":"Water"}"#;
158        assert!(serde_json::from_str::<ComponentInput>(json).is_err());
159    }
160
161    #[test]
162    fn missing_concentration_unit_fails() {
163        let json = r#"{"exact": 100.0}"#;
164        assert!(serde_json::from_str::<ConcentrationRange>(json).is_err());
165    }
166
167    #[test]
168    fn unknown_top_level_field_fails() {
169        let json = r#"{"trade_name":"X","supplier":{"company_name":"Acme"},
170            "components":[],"typo_field": 1}"#;
171        let err = serde_json::from_str::<ProductInput>(json).unwrap_err();
172        assert!(err.to_string().contains("typo_field"));
173    }
174
175    #[test]
176    fn unknown_component_field_fails() {
177        let json = r#"{"concentration":{"exact":1.0,"unit":"%"},"bogus":true}"#;
178        let err = serde_json::from_str::<ComponentInput>(json).unwrap_err();
179        assert!(err.to_string().contains("bogus"));
180    }
181
182    #[test]
183    fn misspelled_concentration_field_fails_instead_of_being_ignored() {
184        // "concentation" (missing r) is silently dropped by deny_unknown_fields
185        // as an unknown key, which then surfaces the real problem: the
186        // required `concentration` field is missing.
187        let json =
188            r#"{"cas_number":"7732-18-5","name":"Water","concentation":{"exact":1.0,"unit":"%"}}"#;
189        assert!(serde_json::from_str::<ComponentInput>(json).is_err());
190    }
191
192    #[test]
193    fn explicit_empty_trade_name_is_not_replaced_by_default() {
194        let json = r#"{"trade_name":"","supplier":{"company_name":"Acme"},"components":[]}"#;
195        let input: ProductInput = serde_json::from_str(json).unwrap();
196        assert_eq!(input.trade_name, "");
197    }
198
199    #[test]
200    fn explicit_empty_components_parses_distinctly_from_a_missing_components_key() {
201        // `components` has no `#[serde(default)]` -- an explicit `[]` is a
202        // different code path from an omitted key (see
203        // `missing_components_fails`): the parser accepts an explicit empty
204        // list, whether or not a downstream formulation check later flags
205        // it, rather than treating "empty" and "absent" as interchangeable.
206        let json = r#"{"trade_name":"X","supplier":{"company_name":"Acme"},"components":[]}"#;
207        let input: ProductInput = serde_json::from_str(json).unwrap();
208        assert!(input.components.is_empty());
209    }
210}