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#[derive(Debug, Clone)]
28pub struct SectionDraftResult {
29 pub sds: SdsRoot,
30 pub findings: Vec<Finding>,
31}
32
33pub 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 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 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 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
194fn 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
239pub 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 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 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 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 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 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 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 let input_findings = validate_product_input(&input);
599 assert!(input_findings.iter().any(|f| f.rule == "GEN-CAS-DUPLICATE"));
600
601 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 let input_findings = validate_product_input(&input);
626 assert!(input_findings
627 .iter()
628 .any(|f| f.rule == "GEN-CONC-RANGE-INVALID"));
629
630 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 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 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}