Skip to main content

sdsforge_core/schema/
mod.rs

1mod generated;
2
3pub use generated::*;
4
5/// Flexible serde helpers for fields whose JSON type varies between LLM responses.
6pub mod serde_flex {
7    use serde::de::{self, SeqAccess, Visitor};
8    use serde::Deserializer;
9    use std::fmt;
10
11    /// Deserialise `Option<String>` from a JSON string, array-of-strings, or null.
12    ///
13    /// Many free-text fields in the MHLW schema are typed as plain `String`, but LLMs
14    /// sometimes return them as arrays (e.g. `["CO2", "NH3"]` for Substance, or
15    /// `["text line 1", "text line 2"]` for FullText).  This helper accepts both forms:
16    /// a bare string is used as-is, while an array is joined with "\n".
17    pub fn flex_string_opt<'de, D>(d: D) -> Result<Option<String>, D::Error>
18    where
19        D: Deserializer<'de>,
20    {
21        struct FlexStringVisitor;
22
23        impl<'de> Visitor<'de> for FlexStringVisitor {
24            type Value = Option<String>;
25
26            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
27                write!(f, "a string, an array of strings, or null")
28            }
29
30            fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
31                if v.is_empty() { Ok(None) } else { Ok(Some(v.to_string())) }
32            }
33
34            fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
35                if v.is_empty() { Ok(None) } else { Ok(Some(v)) }
36            }
37
38            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
39                let mut parts = Vec::new();
40                while let Some(s) = seq.next_element::<String>()? {
41                    if !s.is_empty() {
42                        parts.push(s);
43                    }
44                }
45                if parts.is_empty() { Ok(None) } else { Ok(Some(parts.join("\n"))) }
46            }
47
48            fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> { Ok(None) }
49            fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> { Ok(None) }
50        }
51
52        d.deserialize_any(FlexStringVisitor)
53    }
54
55    /// Deserialise `Option<Vec<String>>` from a JSON string, array-of-strings, or null.
56    ///
57    /// The MHLW schema defines `AdditionalInfo.FullText` as `Vec<String>`, but LLMs
58    /// sometimes emit a bare string.  This helper wraps a bare string in a one-element vec.
59    pub fn flex_vec_string_opt<'de, D>(d: D) -> Result<Option<Vec<String>>, D::Error>
60    where
61        D: Deserializer<'de>,
62    {
63        struct FlexVecVisitor;
64
65        impl<'de> Visitor<'de> for FlexVecVisitor {
66            type Value = Option<Vec<String>>;
67
68            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
69                write!(f, "a string, an array of strings, or null")
70            }
71
72            fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
73                if v.is_empty() {
74                    Ok(None)
75                } else {
76                    Ok(Some(vec![v.to_string()]))
77                }
78            }
79
80            fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
81                if v.is_empty() {
82                    Ok(None)
83                } else {
84                    Ok(Some(vec![v]))
85                }
86            }
87
88            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
89                let mut items = Vec::new();
90                while let Some(s) = seq.next_element::<String>()? {
91                    if !s.is_empty() {
92                        items.push(s);
93                    }
94                }
95                if items.is_empty() {
96                    Ok(None)
97                } else {
98                    Ok(Some(items))
99                }
100            }
101
102            fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
103                Ok(None)
104            }
105
106            fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
107                Ok(None)
108            }
109        }
110
111        d.deserialize_any(FlexVecVisitor)
112    }
113
114    /// Deserialise `Option<Vec<String>>` where elements may be JSON objects.
115    ///
116    /// Extends [`flex_vec_string_opt`] to also handle arrays of objects — e.g.
117    /// `[{"ItemName": "引火性", "AdditionalInfo": {"FullText": ["引火性液体"]}}]`.
118    /// Objects are converted to strings by trying (in order):
119    /// 1. `AdditionalInfo.FullText` joined with " / "
120    /// 2. `ItemName`
121    /// 3. JSON serialisation of the whole object as a fallback.
122    pub fn flex_vec_string_or_obj_opt<'de, D>(d: D) -> Result<Option<Vec<String>>, D::Error>
123    where
124        D: Deserializer<'de>,
125    {
126        use serde::Deserialize;
127
128        let v = Option::<serde_json::Value>::deserialize(d)
129            .map_err(de::Error::custom)?;
130
131        fn obj_to_string(obj: serde_json::Value) -> String {
132            if let serde_json::Value::Object(ref map) = obj {
133                // Try AdditionalInfo.FullText
134                if let Some(ai) = map.get("AdditionalInfo") {
135                    if let Some(ft) = ai.get("FullText") {
136                        let parts: Vec<String> = match ft {
137                            serde_json::Value::Array(arr) => arr
138                                .iter()
139                                .filter_map(|v| v.as_str().map(str::to_string))
140                                .collect(),
141                            serde_json::Value::String(s) => vec![s.clone()],
142                            _ => vec![],
143                        };
144                        if !parts.is_empty() {
145                            return parts.join(" / ");
146                        }
147                    }
148                }
149                // Try ItemName
150                if let Some(serde_json::Value::String(name)) = map.get("ItemName") {
151                    if !name.is_empty() {
152                        return name.clone();
153                    }
154                }
155            }
156            // Fallback: compact JSON
157            serde_json::to_string(&obj).unwrap_or_default()
158        }
159
160        match v {
161            None => Ok(None),
162            Some(serde_json::Value::String(s)) => {
163                if s.is_empty() { Ok(None) } else { Ok(Some(vec![s])) }
164            }
165            Some(serde_json::Value::Array(arr)) => {
166                let items: Vec<String> = arr
167                    .into_iter()
168                    .filter_map(|elem| match elem {
169                        serde_json::Value::String(s) if !s.is_empty() => Some(s),
170                        serde_json::Value::Object(_) => {
171                            let s = obj_to_string(elem);
172                            if s.is_empty() { None } else { Some(s) }
173                        }
174                        _ => None,
175                    })
176                    .collect();
177                if items.is_empty() { Ok(None) } else { Ok(Some(items)) }
178            }
179            _ => Ok(None),
180        }
181    }
182
183    /// Generate a `flex_single_or_vec_opt` deserialiser for a concrete type `$T`.
184    ///
185    /// LLMs sometimes emit a single JSON object where the schema expects an array.
186    /// The generated function accepts either form and always returns `Option<Vec<$T>>`.
187    macro_rules! flex_single_or_vec_opt {
188        ($fn_name:ident, $T:ty) => {
189            pub fn $fn_name<'de, D>(d: D) -> Result<Option<Vec<$T>>, D::Error>
190            where
191                D: serde::Deserializer<'de>,
192            {
193                use serde::Deserialize;
194                let v = Option::<serde_json::Value>::deserialize(d)
195                    .map_err(serde::de::Error::custom)?;
196                match v {
197                    None => Ok(None),
198                    Some(serde_json::Value::Array(arr)) => {
199                        let items: Result<Vec<$T>, _> = arr
200                            .into_iter()
201                            .map(|val| {
202                                serde_json::from_value(val).map_err(serde::de::Error::custom)
203                            })
204                            .collect();
205                        Ok(Some(items?))
206                    }
207                    Some(obj) => {
208                        let item = serde_json::from_value::<$T>(obj)
209                            .map_err(serde::de::Error::custom)?;
210                        Ok(Some(vec![item]))
211                    }
212                }
213            }
214        };
215    }
216
217    use crate::schema::{
218        HandlingAndStorageStorageConditionsForSafeStorage,
219        HazardIdentificationClassificationHealthEffectSpecificTargetOrganRE,
220        HazardIdentificationClassificationHealthEffectSpecificTargetOrganSE,
221    };
222
223    flex_single_or_vec_opt!(
224        flex_stoase_opt,
225        HazardIdentificationClassificationHealthEffectSpecificTargetOrganSE
226    );
227    flex_single_or_vec_opt!(
228        flex_stoare_opt,
229        HazardIdentificationClassificationHealthEffectSpecificTargetOrganRE
230    );
231
232    /// Deserialise `Option<HandlingAndStorageStorageConditionsForSafeStorage>` from
233    /// either a struct or a plain string.
234    ///
235    /// LLMs sometimes emit the storage precautions text as a bare string instead of
236    /// nesting it in the struct.  When a string is received it is placed in
237    /// `TechnicalMeasuresAndStorageConditions`.
238    pub fn flex_storage_conditions_opt<'de, D>(
239        d: D,
240    ) -> Result<Option<HandlingAndStorageStorageConditionsForSafeStorage>, D::Error>
241    where
242        D: serde::Deserializer<'de>,
243    {
244        use serde::Deserialize;
245        let v = Option::<serde_json::Value>::deserialize(d)
246            .map_err(serde::de::Error::custom)?;
247        match v {
248            None => Ok(None),
249            Some(serde_json::Value::String(s)) if s.is_empty() => Ok(None),
250            Some(serde_json::Value::String(s)) => {
251                Ok(Some(HandlingAndStorageStorageConditionsForSafeStorage {
252                    technical_measures_and_storage_conditions: Some(s),
253                    ..Default::default()
254                }))
255            }
256            Some(obj) => serde_json::from_value(obj)
257                .map_err(serde::de::Error::custom),
258        }
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    /// FireFightingMeasures.FireAndExplosionHazards.FullText returned as array
267    /// previously caused "invalid type: sequence, expected a string" and
268    /// skipped the entire FireFightingMeasures section.
269    #[test]
270    fn fire_fighting_full_text_accepts_array() {
271        let json = r#"{
272            "FireFightingMeasures": {
273                "FireAndExplosionHazards": {
274                    "FullText": ["有害燃焼副産物:ケイ素酸化物", "炭素酸化物"]
275                },
276                "FireFightingProcedures": {
277                    "FullText": "現場の状況に応じて適切な消火手段を用いる。"
278                }
279            }
280        }"#;
281        let sds: SdsRoot = serde_json::from_str(json)
282            .expect("FireFightingMeasures with array FullText should deserialize");
283        let ffm = sds.fire_fighting_measures.as_ref().expect("FireFightingMeasures must be Some");
284        let hazards = ffm.fire_and_explosion_hazards.as_ref().expect("FireAndExplosionHazards must be Some");
285        let full_text = hazards.full_text.as_deref().expect("FullText must be Some");
286        // array elements joined with \n
287        assert!(full_text.contains("有害燃焼副産物"), "FullText content lost: {full_text}");
288        assert!(full_text.contains("炭素酸化物"), "second element lost: {full_text}");
289    }
290
291    /// StabilityReactivity.HazardousDecompositionProducts.Substance returned as array
292    /// previously caused "invalid type: sequence, expected a string" and
293    /// skipped the entire StabilityReactivity section.
294    #[test]
295    fn stability_reactivity_substance_accepts_array() {
296        let json = r#"{
297            "StabilityReactivity": {
298                "HazardousDecompositionProducts": {
299                    "Substance": ["二酸化炭素(CO2)", "アンモニア", "窒素酸化物(NOx)"]
300                }
301            }
302        }"#;
303        let sds: SdsRoot = serde_json::from_str(json)
304            .expect("StabilityReactivity with array Substance should deserialize");
305        let sr = sds.stability_reactivity.as_ref().expect("StabilityReactivity must be Some");
306        let hdp = sr.hazardous_decomposition_products.as_ref().expect("HazardousDecompositionProducts must be Some");
307        let substance = hdp.substance.as_deref().expect("Substance must be Some");
308        assert!(substance.contains("二酸化炭素"), "first element lost: {substance}");
309        assert!(substance.contains("アンモニア"), "second element lost: {substance}");
310        assert!(substance.contains("窒素酸化物"), "third element lost: {substance}");
311    }
312
313    #[test]
314    fn sds_root_round_trip_empty() {
315        let sds = SdsRoot::default();
316        let json = serde_json::to_string(&sds).unwrap();
317        assert_eq!(json, "{}");
318        let sds2: SdsRoot = serde_json::from_str("{}").unwrap();
319        let json2 = serde_json::to_string(&sds2).unwrap();
320        assert_eq!(json2, "{}");
321    }
322
323    #[test]
324    fn sds_root_round_trip_partial() {
325        let json = r#"{"Datasheet":{"IssueDate":"2024-03-31","SDS-SchemaVersionNo":"1.0"}}"#;
326        let sds: SdsRoot = serde_json::from_str(json).unwrap();
327        assert_eq!(sds.datasheet.as_ref().unwrap().issue_date.as_deref(), Some("2024-03-31"));
328        let out = serde_json::to_string(&sds).unwrap();
329        let v1: serde_json::Value = serde_json::from_str(json).unwrap();
330        let v2: serde_json::Value = serde_json::from_str(&out).unwrap();
331        assert_eq!(v1, v2);
332    }
333}