Skip to main content

sdsforge_core/generation/
draft.rs

1use std::collections::HashMap;
2
3use crate::converter::validator::{validate_cas_format, Finding};
4use crate::enrichment::{lookup_cas, CasInfo};
5use crate::schema::{
6    Composition, CompositionCompositionAndConcentration,
7    CompositionCompositionAndConcentrationConcentration, Identification,
8    IdentificationSupplierInformation, IdentificationTradeProductIdentity,
9    NumericRangeWithUnitAndQualifier, NumericRangeWithUnitAndQualifierExactValue,
10    NumericRangeWithUnitAndQualifierLowerValue, NumericRangeWithUnitAndQualifierUpperValue,
11    SdsRoot, SubstanceIdentifiers, SubstanceIdentifiersSubstanceIdentity,
12    SubstanceIdentifiersSubstanceIdentityCASno, SubstanceIdentifiersSubstanceNames,
13};
14
15use super::input::{ConcentrationRange, ProductInput};
16
17/// A partial SDS draft covering **only** Section 1 (Identification) and
18/// Section 3 (Composition) — the sections derivable from supplied product/
19/// supplier/CAS/concentration data without product testing. Every other
20/// `SdsRoot` field is left `None`.
21///
22/// Interim type: roadmap commit #10 introduces `GenerationResult`, which
23/// carries additional fields (`unresolved`, `provenance`, `evidence_summary`,
24/// `release_status`) that don't exist yet. `SectionDraftResult` is expected
25/// to be wrapped or replaced by `GenerationResult` at that point, not
26/// extended in place — keep it minimal.
27#[derive(Debug, Clone)]
28pub struct SectionDraftResult {
29    pub sds: SdsRoot,
30    pub findings: Vec<Finding>,
31}
32
33/// Pure, deterministic mapping from [`ProductInput`] to a Section 1/3 draft.
34///
35/// Performs no I/O. `resolved` supplies CAS lookup results the caller has
36/// already fetched (e.g. via [`generate_section_1_and_3`]) — a component
37/// whose CAS is absent from this map is treated as "enrichment unavailable"
38/// for that component, not as an error; its supplied data (name, CAS,
39/// concentration, unit) is still mapped into the draft. This split exists
40/// so mapping logic can be tested without PubChem availability: pass an
41/// empty or partial map to exercise the lookup-failure path deterministically.
42///
43/// Does not re-validate CAS format/check-digit or concentration sanity —
44/// that's roadmap commit #8's `validate_product_input`, already run
45/// separately. Its findings remain authoritative; this function does not
46/// silently repair invalid input, it maps whatever was supplied.
47pub fn draft_sections_from_resolved_input(
48    input: &ProductInput,
49    resolved: &HashMap<String, CasInfo>,
50) -> SectionDraftResult {
51    let mut findings = Vec::new();
52
53    let identification = Identification {
54        trade_product_identity: Some(IdentificationTradeProductIdentity {
55            // The schema splits trade names by language (TradeNameJP/EN);
56            // ProductInput carries a single untagged `trade_name`. Mapping
57            // it to the JP slot (this schema's primary/first name field,
58            // matching the project's Japan-first default elsewhere) rather
59            // than guessing a language or duplicating it into both fields —
60            // duplicating into TradeNameEN would assert an English name we
61            // were never actually given.
62            trade_name_jp: Some(input.trade_name.clone()),
63            trade_name_en: None,
64            other_name: non_empty(input.other_names.clone()),
65            product_no_user: None,
66            additional_info: None,
67        }),
68        specification_no: None,
69        supplier_information: Some(IdentificationSupplierInformation {
70            company_name: Some(input.supplier.company_name.clone()),
71            department: None,
72            name: None,
73            post_code: None,
74            address: input.supplier.address.clone(),
75            phone: input.supplier.phone.clone(),
76            working_hours: None,
77            fax: None,
78            email: input.supplier.email.clone(),
79            company_url: None,
80            // SupplierInput has one general `phone`, not a distinct
81            // emergency number — populating EmergencyContact from it would
82            // be inferring an emergency contact from an unrelated field.
83            emergency_contact: None,
84            additional_info: None,
85        }),
86        use_and_use_advised_against: None,
87        domestic_manufacturer_information: None,
88        additional_info: None,
89    };
90
91    let rows: Vec<CompositionCompositionAndConcentration> = input
92        .components
93        .iter()
94        .map(|component| {
95            let cas_info = component
96                .cas_number
97                .as_ref()
98                .and_then(|cas| resolved.get(cas));
99
100            if let Some(cas) = &component.cas_number {
101                if cas_info.is_none() && validate_cas_format(cas) {
102                    findings.push(Finding {
103                        level: "LOW".into(),
104                        rule: "GEN-CAS-ENRICHMENT-MISSING".into(),
105                        message: format!(
106                            "CAS '{cas}': no enrichment data available (not found, or lookup not performed). Supplied data was kept as-is."
107                        ),
108                    });
109                }
110            }
111
112            component_to_row(component, cas_info)
113        })
114        .collect();
115
116    let composition = Composition {
117        composition_type: None,
118        substance_identifiers: None,
119        r#use: None,
120        composition_and_concentration: non_empty(rows),
121        impurities_and_stabilizing_additives: None,
122        additional_info: None,
123    };
124
125    let sds = SdsRoot {
126        identification: Some(identification),
127        composition: Some(composition),
128        ..Default::default()
129    };
130
131    SectionDraftResult { sds, findings }
132}
133
134fn component_to_row(
135    component: &super::input::ComponentInput,
136    cas_info: Option<&CasInfo>,
137) -> CompositionCompositionAndConcentration {
138    let cas_identity =
139        component
140            .cas_number
141            .as_ref()
142            .map(|cas| SubstanceIdentifiersSubstanceIdentity {
143                ca_sno: Some(SubstanceIdentifiersSubstanceIdentityCASno {
144                    full_text: Some(vec![cas.clone()]),
145                    additional_info: None,
146                }),
147                other_no: None,
148            });
149
150    let substance_identifiers =
151        if component.name.is_some() || cas_identity.is_some() || cas_info.is_some() {
152            Some(SubstanceIdentifiers {
153                substance_names: Some(SubstanceIdentifiersSubstanceNames {
154                    // GenericName carries the name the caller supplied.
155                    // IupacName carries the name the existing CAS resolver
156                    // returned — a deterministic 1:1 lookup by CAS number, not
157                    // a choice among ambiguous candidates, so populating it
158                    // here isn't the kind of identity-guessing this feature
159                    // avoids elsewhere.
160                    iupac_name: cas_info.and_then(|info| info.iupac_name.clone()),
161                    cas_inventory_name: None,
162                    generic_name: component.name.clone(),
163                }),
164                common_name: None,
165                substance_identity: cas_identity,
166                cbi: None,
167                additional_info: None,
168            })
169        } else {
170            None
171        };
172
173    CompositionCompositionAndConcentration {
174        substance_identifiers,
175        molecular_formula: cas_info.and_then(|info| info.molecular_formula.clone()),
176        structural_formula: None,
177        structural_formula_path_and_file_name: None,
178        smiles: None,
179        in_ch_i: None,
180        in_ch_i_key: None,
181        molecular_weight: None,
182        concentration: Some(CompositionCompositionAndConcentrationConcentration {
183            numeric_range_with_unit_and_qualifier: Some(concentration_range_to_schema(
184                &component.concentration,
185            )),
186        }),
187        gazette_no: None,
188        r#use: None,
189        gh_sinfo: None,
190        additional_info: None,
191    }
192}
193
194/// Maps [`ConcentrationRange`] preserving exact-vs-range meaning — an exact
195/// value stays exact, a lower/upper pair stays a range. Never converted to a
196/// midpoint, and an exact value is never serialized as an artificial
197/// lower==upper range (the schema doesn't require that representation).
198fn concentration_range_to_schema(range: &ConcentrationRange) -> NumericRangeWithUnitAndQualifier {
199    NumericRangeWithUnitAndQualifier {
200        exact_value: range
201            .exact
202            .map(|v| NumericRangeWithUnitAndQualifierExactValue {
203                value_symbol: None,
204                value: Some(v),
205            }),
206        lower_value: range
207            .lower
208            .map(|v| NumericRangeWithUnitAndQualifierLowerValue {
209                value_symbol: None,
210                value: Some(v),
211            }),
212        upper_value: range
213            .upper
214            .map(|v| NumericRangeWithUnitAndQualifierUpperValue {
215                value_symbol: None,
216                value: Some(v),
217            }),
218        unit: non_empty_string(range.unit.clone()),
219        additional_info: None,
220    }
221}
222
223fn non_empty<T>(v: Vec<T>) -> Option<Vec<T>> {
224    if v.is_empty() {
225        None
226    } else {
227        Some(v)
228    }
229}
230
231fn non_empty_string(s: String) -> Option<String> {
232    if s.trim().is_empty() {
233        None
234    } else {
235        Some(s)
236    }
237}
238
239/// Orchestration: performs CAS enrichment lookups (network I/O against
240/// PubChem via [`crate::enrichment::lookup_cas`] — no second PubChem client
241/// or CAS resolver is implemented here), then delegates all mapping and
242/// finding logic to [`draft_sections_from_resolved_input`].
243///
244/// A lookup failure for one component never discards that component's
245/// supplied data — it's still mapped, with a `Finding` noting enrichment
246/// was unavailable. Guessing a chemical identity from a failed lookup would
247/// produce a more dangerous draft than an incomplete-but-truthful one.
248pub async fn generate_section_1_and_3(
249    input: &ProductInput,
250    client: &reqwest::Client,
251) -> SectionDraftResult {
252    let mut resolved = HashMap::new();
253    for component in &input.components {
254        if let Some(cas) = &component.cas_number {
255            if let Ok(Some(info)) = lookup_cas(cas, client).await {
256                resolved.insert(cas.clone(), info);
257            }
258        }
259    }
260    draft_sections_from_resolved_input(input, &resolved)
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::generation::input::{ComponentInput, SupplierInput};
267    use crate::generation::validate_product_input;
268
269    fn supplier() -> SupplierInput {
270        SupplierInput {
271            company_name: "Example Chemical Co.".into(),
272            address: Some("1-1 Example, Tokyo".into()),
273            phone: Some("03-1234-5678".into()),
274            email: Some("safety@example.com".into()),
275        }
276    }
277
278    fn exact_component(cas: &str, name: &str, value: f64) -> ComponentInput {
279        ComponentInput {
280            cas_number: Some(cas.into()),
281            name: Some(name.into()),
282            concentration: ConcentrationRange {
283                exact: Some(value),
284                lower: None,
285                upper: None,
286                unit: "%".into(),
287            },
288        }
289    }
290
291    fn range_component(cas: &str, name: &str, lower: f64, upper: f64) -> ComponentInput {
292        ComponentInput {
293            cas_number: Some(cas.into()),
294            name: Some(name.into()),
295            concentration: ConcentrationRange {
296                exact: None,
297                lower: Some(lower),
298                upper: Some(upper),
299                unit: "%".into(),
300            },
301        }
302    }
303
304    #[test]
305    fn single_component_maps_section_1_and_3() {
306        let input = ProductInput {
307            trade_name: "Test Solvent".into(),
308            other_names: vec![],
309            supplier: supplier(),
310            components: vec![exact_component("7732-18-5", "Water", 100.0)],
311            measured_properties: Default::default(),
312            evidence: vec![],
313        };
314        let result = draft_sections_from_resolved_input(&input, &HashMap::new());
315
316        let ident = result.sds.identification.as_ref().unwrap();
317        let identity = ident.trade_product_identity.as_ref().unwrap();
318        assert_eq!(identity.trade_name_jp.as_deref(), Some("Test Solvent"));
319
320        let supplier_info = ident.supplier_information.as_ref().unwrap();
321        assert_eq!(
322            supplier_info.company_name.as_deref(),
323            Some("Example Chemical Co.")
324        );
325        assert_eq!(supplier_info.phone.as_deref(), Some("03-1234-5678"));
326
327        let rows = result
328            .sds
329            .composition
330            .as_ref()
331            .unwrap()
332            .composition_and_concentration
333            .as_ref()
334            .unwrap();
335        assert_eq!(rows.len(), 1);
336        let cas = rows[0]
337            .substance_identifiers
338            .as_ref()
339            .unwrap()
340            .substance_identity
341            .as_ref()
342            .unwrap()
343            .ca_sno
344            .as_ref()
345            .unwrap()
346            .full_text
347            .as_ref()
348            .unwrap();
349        assert_eq!(cas, &vec!["7732-18-5".to_string()]);
350
351        let exact = rows[0]
352            .concentration
353            .as_ref()
354            .unwrap()
355            .numeric_range_with_unit_and_qualifier
356            .as_ref()
357            .unwrap()
358            .exact_value
359            .as_ref()
360            .unwrap()
361            .value;
362        assert_eq!(exact, Some(100.0));
363    }
364
365    #[test]
366    fn multi_component_preserves_order_and_range_shape() {
367        let input = ProductInput {
368            trade_name: "Test Mixture".into(),
369            other_names: vec![],
370            supplier: supplier(),
371            components: vec![
372                exact_component("7732-18-5", "Water", 60.0),
373                range_component("64-17-5", "Ethanol", 30.0, 40.0),
374            ],
375            measured_properties: Default::default(),
376            evidence: vec![],
377        };
378        let result = draft_sections_from_resolved_input(&input, &HashMap::new());
379        let rows = result
380            .sds
381            .composition
382            .as_ref()
383            .unwrap()
384            .composition_and_concentration
385            .as_ref()
386            .unwrap();
387        assert_eq!(rows.len(), 2);
388
389        // Order preserved: row 0 is Water, row 1 is Ethanol.
390        let name0 = rows[0]
391            .substance_identifiers
392            .as_ref()
393            .unwrap()
394            .substance_names
395            .as_ref()
396            .unwrap()
397            .generic_name
398            .as_deref();
399        assert_eq!(name0, Some("Water"));
400        let name1 = rows[1]
401            .substance_identifiers
402            .as_ref()
403            .unwrap()
404            .substance_names
405            .as_ref()
406            .unwrap()
407            .generic_name
408            .as_deref();
409        assert_eq!(name1, Some("Ethanol"));
410
411        // Row 1's concentration is still a range, not collapsed to a midpoint.
412        let conc1 = rows[1]
413            .concentration
414            .as_ref()
415            .unwrap()
416            .numeric_range_with_unit_and_qualifier
417            .as_ref()
418            .unwrap();
419        assert!(conc1.exact_value.is_none());
420        assert_eq!(conc1.lower_value.as_ref().unwrap().value, Some(30.0));
421        assert_eq!(conc1.upper_value.as_ref().unwrap().value, Some(40.0));
422    }
423
424    #[test]
425    fn product_identity_is_not_replaced_by_component_name() {
426        let input = ProductInput {
427            trade_name: "Brand X Cleaner".into(),
428            other_names: vec![],
429            supplier: supplier(),
430            components: vec![exact_component("64-17-5", "Ethanol", 100.0)],
431            measured_properties: Default::default(),
432            evidence: vec![],
433        };
434        let result = draft_sections_from_resolved_input(&input, &HashMap::new());
435        let trade_name = result
436            .sds
437            .identification
438            .as_ref()
439            .unwrap()
440            .trade_product_identity
441            .as_ref()
442            .unwrap()
443            .trade_name_jp
444            .as_deref();
445        assert_eq!(trade_name, Some("Brand X Cleaner"));
446        assert_ne!(trade_name, Some("Ethanol"));
447    }
448
449    #[test]
450    fn missing_optional_section1_data_is_not_fabricated() {
451        let input = ProductInput {
452            trade_name: "Minimal Product".into(),
453            other_names: vec![],
454            supplier: SupplierInput {
455                company_name: "Minimal Co.".into(),
456                address: None,
457                phone: None,
458                email: None,
459            },
460            components: vec![exact_component("7732-18-5", "Water", 100.0)],
461            measured_properties: Default::default(),
462            evidence: vec![],
463        };
464        let result = draft_sections_from_resolved_input(&input, &HashMap::new());
465        let supplier_info = result
466            .sds
467            .identification
468            .as_ref()
469            .unwrap()
470            .supplier_information
471            .as_ref()
472            .unwrap();
473        assert!(supplier_info.address.is_none());
474        assert!(supplier_info.phone.is_none());
475        assert!(supplier_info.email.is_none());
476        assert!(supplier_info.emergency_contact.is_none());
477        assert!(result
478            .sds
479            .identification
480            .as_ref()
481            .unwrap()
482            .domestic_manufacturer_information
483            .is_none());
484    }
485
486    #[test]
487    fn resolved_enrichment_is_mapped() {
488        let input = ProductInput {
489            trade_name: "Test".into(),
490            other_names: vec![],
491            supplier: supplier(),
492            components: vec![exact_component("7732-18-5", "Water", 100.0)],
493            measured_properties: Default::default(),
494            evidence: vec![],
495        };
496        let mut resolved = HashMap::new();
497        resolved.insert(
498            "7732-18-5".to_string(),
499            CasInfo {
500                cas: "7732-18-5".into(),
501                iupac_name: Some("oxidane".into()),
502                molecular_formula: Some("H2O".into()),
503                pubchem_cid: Some(962),
504            },
505        );
506        let result = draft_sections_from_resolved_input(&input, &resolved);
507        let row = &result
508            .sds
509            .composition
510            .as_ref()
511            .unwrap()
512            .composition_and_concentration
513            .as_ref()
514            .unwrap()[0];
515        assert_eq!(row.molecular_formula.as_deref(), Some("H2O"));
516        assert_eq!(
517            row.substance_identifiers
518                .as_ref()
519                .unwrap()
520                .substance_names
521                .as_ref()
522                .unwrap()
523                .iupac_name
524                .as_deref(),
525            Some("oxidane")
526        );
527        // No lookup-failure finding when enrichment succeeded.
528        assert!(!result
529            .findings
530            .iter()
531            .any(|f| f.rule == "GEN-CAS-ENRICHMENT-MISSING"));
532    }
533
534    #[test]
535    fn lookup_failure_keeps_supplied_data_and_returns_finding() {
536        let input = ProductInput {
537            trade_name: "Test".into(),
538            other_names: vec![],
539            supplier: supplier(),
540            components: vec![exact_component("7732-18-5", "Water", 100.0)],
541            measured_properties: Default::default(),
542            evidence: vec![],
543        };
544        // Empty resolved map == lookup failed/unavailable for this CAS.
545        let result = draft_sections_from_resolved_input(&input, &HashMap::new());
546
547        let row = &result
548            .sds
549            .composition
550            .as_ref()
551            .unwrap()
552            .composition_and_concentration
553            .as_ref()
554            .unwrap()[0];
555        // Supplied data is preserved even though enrichment failed.
556        assert_eq!(
557            row.substance_identifiers
558                .as_ref()
559                .unwrap()
560                .substance_names
561                .as_ref()
562                .unwrap()
563                .generic_name
564                .as_deref(),
565            Some("Water")
566        );
567        assert!(row.molecular_formula.is_none());
568        // No guessed IUPAC name inserted.
569        assert!(row
570            .substance_identifiers
571            .as_ref()
572            .unwrap()
573            .substance_names
574            .as_ref()
575            .unwrap()
576            .iupac_name
577            .is_none());
578        assert!(result
579            .findings
580            .iter()
581            .any(|f| f.rule == "GEN-CAS-ENRICHMENT-MISSING"));
582    }
583
584    #[test]
585    fn duplicate_cas_is_still_validated_and_not_merged() {
586        let input = ProductInput {
587            trade_name: "Test".into(),
588            other_names: vec![],
589            supplier: supplier(),
590            components: vec![
591                exact_component("7732-18-5", "Water", 50.0),
592                exact_component("7732-18-5", "Water again", 50.0),
593            ],
594            measured_properties: Default::default(),
595            evidence: vec![],
596        };
597        // Commit #8's validator still flags the duplicate independently.
598        let input_findings = validate_product_input(&input);
599        assert!(input_findings.iter().any(|f| f.rule == "GEN-CAS-DUPLICATE"));
600
601        // The generator does not silently merge the two components.
602        let result = draft_sections_from_resolved_input(&input, &HashMap::new());
603        let rows = result
604            .sds
605            .composition
606            .as_ref()
607            .unwrap()
608            .composition_and_concentration
609            .as_ref()
610            .unwrap();
611        assert_eq!(rows.len(), 2);
612    }
613
614    #[test]
615    fn invalid_concentration_range_is_mapped_as_supplied_not_repaired() {
616        let input = ProductInput {
617            trade_name: "Test".into(),
618            other_names: vec![],
619            supplier: supplier(),
620            components: vec![range_component("7732-18-5", "Water", 90.0, 10.0)],
621            measured_properties: Default::default(),
622            evidence: vec![],
623        };
624        // Commit #8 flags this as invalid...
625        let input_findings = validate_product_input(&input);
626        assert!(input_findings
627            .iter()
628            .any(|f| f.rule == "GEN-CONC-RANGE-INVALID"));
629
630        // ...but the generator maps it exactly as supplied, without
631        // swapping bounds or otherwise "fixing" it.
632        let result = draft_sections_from_resolved_input(&input, &HashMap::new());
633        let conc = result
634            .sds
635            .composition
636            .as_ref()
637            .unwrap()
638            .composition_and_concentration
639            .as_ref()
640            .unwrap()[0]
641            .concentration
642            .as_ref()
643            .unwrap()
644            .numeric_range_with_unit_and_qualifier
645            .as_ref()
646            .unwrap();
647        assert_eq!(conc.lower_value.as_ref().unwrap().value, Some(90.0));
648        assert_eq!(conc.upper_value.as_ref().unwrap().value, Some(10.0));
649    }
650
651    #[test]
652    fn concentration_below_100_percent_gets_no_automatic_balance_component() {
653        let input = ProductInput {
654            trade_name: "Test".into(),
655            other_names: vec![],
656            supplier: supplier(),
657            components: vec![exact_component("7732-18-5", "Water", 60.0)],
658            measured_properties: Default::default(),
659            evidence: vec![],
660        };
661        let result = draft_sections_from_resolved_input(&input, &HashMap::new());
662        let rows = result
663            .sds
664            .composition
665            .as_ref()
666            .unwrap()
667            .composition_and_concentration
668            .as_ref()
669            .unwrap();
670        // Exactly one row — no synthetic "balance" component was added to
671        // reach 100%.
672        assert_eq!(rows.len(), 1);
673    }
674
675    #[test]
676    fn concentration_above_threshold_finding_is_preserved() {
677        let input = ProductInput {
678            trade_name: "Test".into(),
679            other_names: vec![],
680            supplier: supplier(),
681            components: vec![
682                exact_component("7732-18-5", "Water", 60.0),
683                exact_component("64-17-5", "Ethanol", 50.0),
684            ],
685            measured_properties: Default::default(),
686            evidence: vec![],
687        };
688        let input_findings = validate_product_input(&input);
689        assert!(input_findings
690            .iter()
691            .any(|f| f.rule == "GEN-CONC-SUM-EXCEEDS-100"));
692    }
693
694    #[test]
695    fn serialized_draft_has_no_empty_artifacts_and_no_diagnostic_keys() {
696        let input = ProductInput {
697            trade_name: "Test Solvent".into(),
698            other_names: vec![],
699            supplier: SupplierInput {
700                company_name: "Example Chemical Co.".into(),
701                address: None,
702                phone: None,
703                email: None,
704            },
705            components: vec![exact_component("7732-18-5", "Water", 100.0)],
706            measured_properties: Default::default(),
707            evidence: vec![],
708        };
709        let result = draft_sections_from_resolved_input(&input, &HashMap::new());
710
711        let value = serde_json::to_value(&result.sds).unwrap();
712        let pruned = crate::converter::prune_empty_fields(value.clone());
713        assert_eq!(
714            value, pruned,
715            "draft already matches pruned output — no empty placeholders were emitted"
716        );
717
718        // No generation-only diagnostics (findings, provenance, etc.) leak
719        // into the official SDS JSON — `findings` lives on
720        // `SectionDraftResult`, never inside `sds`.
721        let as_object = value.as_object().unwrap();
722        for key in ["findings", "provenance", "unresolved", "release_status"] {
723            assert!(
724                !as_object.contains_key(key),
725                "official SDS JSON must not contain diagnostic key '{key}'"
726            );
727        }
728    }
729}