1use crate::country::SourceCountry;
2use crate::ghs_codes;
3use crate::schema::{HazardIdentificationClassification, SdsRoot};
4
5#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
11pub struct Finding {
12 pub level: String, pub rule: String, pub message: String,
15}
16
17impl std::fmt::Display for Finding {
18 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19 match self.rule.as_str() {
20 "STRUCTURAL" => write!(f, "{}", self.message),
21 rule if self.level == "WARN" && !rule.starts_with('S') => {
22 write!(f, "[{}] {}", rule, self.message)
23 }
24 _ => write!(f, "[{}][{}] {}", self.level, self.rule, self.message),
25 }
26 }
27}
28
29fn parse_finding(w: &str) -> Finding {
31 if let Some(rest) = w.strip_prefix('[') {
33 if let Some(level_end) = rest.find(']') {
34 let level = &rest[..level_end];
35 let after = &rest[level_end + 1..];
36 if let Some(rule_part) = after.strip_prefix('[') {
37 if let Some(rule_end) = rule_part.find(']') {
38 return Finding {
39 level: level.to_string(),
40 rule: rule_part[..rule_end].to_string(),
41 message: rule_part[rule_end + 2..].trim().to_string(),
42 };
43 }
44 }
45 return Finding {
47 level: "WARN".to_string(),
48 rule: level.to_string(),
49 message: after.trim_start_matches(']').trim().to_string(),
50 };
51 }
52 }
53 Finding {
55 level: "WARN".to_string(),
56 rule: "STRUCTURAL".to_string(),
57 message: w.to_string(),
58 }
59}
60
61pub fn validate_typed(sds: &SdsRoot) -> Vec<Finding> {
63 validate(sds).iter().map(|s| parse_finding(s)).collect()
64}
65
66fn looks_like_date(s: &str) -> bool {
70 let s = s.trim();
71 if s.split(|c: char| !c.is_ascii_digit())
73 .any(|tok| {
74 (tok.len() == 4 || tok.len() == 8)
75 && tok.starts_with(|c: char| c == '1' || c == '2')
76 })
77 {
78 return true;
79 }
80 if s.contains('年') && s.chars().any(|c| c.is_ascii_digit()) {
82 return true;
83 }
84 false
85}
86
87const PLACEHOLDER_NAMES: &[&str] = &[
89 "n/a", "na", "unknown", "不明", "未定", "化学物質", "chemical substance",
90 "substance", "物質名", "product", "製品名",
91];
92
93fn is_placeholder(s: &str) -> bool {
94 let lower = s.trim().to_lowercase();
95 PLACEHOLDER_NAMES.contains(&lower.as_str()) || lower.is_empty()
96}
97
98const NOT_CLASSIFIED_PATTERNS: &[&str] = &[
100 "not classified", "not applicable", "n/a", "na",
101 "分類できない", "分類対象外", "区分外", "該当なし", "データなし",
102 "不适用", "不分类", "无资料",
103 "分類不明", "分类不明",
105];
106
107fn is_not_classified(s: &str) -> bool {
108 let lower = s.trim().to_lowercase();
109 NOT_CLASSIFIED_PATTERNS.iter().any(|&p| lower.contains(p)) || lower.is_empty()
110}
111
112fn classification_has_real_hazard(c: &HazardIdentificationClassification) -> bool {
115 let Ok(v) = serde_json::to_value(c) else { return false };
116 let mut strings: Vec<String> = Vec::new();
117 collect_strings(&v, &mut strings);
118 strings.iter().any(|s| !is_not_classified(s))
119}
120
121fn collect_strings(v: &serde_json::Value, out: &mut Vec<String>) {
123 match v {
124 serde_json::Value::String(s) => out.push(s.clone()),
125 serde_json::Value::Object(map) => map.values().for_each(|v| collect_strings(v, out)),
126 serde_json::Value::Array(arr) => arr.iter().for_each(|v| collect_strings(v, out)),
127 _ => {}
128 }
129}
130
131pub fn validate(sds: &SdsRoot) -> Vec<String> {
134 let mut w: Vec<String> = Vec::new();
135
136 macro_rules! missing {
137 ($section:literal) => {
138 w.push(format!(
139 "{}: section not extracted — check source document.",
140 $section
141 ))
142 };
143 }
144
145 match &sds.identification {
147 None => missing!("Section 1 (Identification)"),
148 Some(id) => {
149 let has_name = id
150 .trade_product_identity
151 .as_ref()
152 .map(|t| t.trade_name_jp.is_some() || t.trade_name_en.is_some())
153 .unwrap_or(false);
154 if !has_name {
155 w.push("Section 1 (Identification): no product name (TradeNameJP/TradeNameEN).".into());
156 }
157 if id.supplier_information.is_none() {
158 w.push("Section 1 (Identification): SupplierInformation is missing.".into());
159 }
160 }
161 }
162
163 match &sds.hazard_identification {
165 None => missing!("Section 2 (HazardIdentification)"),
166 Some(hz) => {
167 if hz.classification.is_none() && hz.hazard_labelling.is_none() {
168 w.push("Section 2 (HazardIdentification): neither Classification nor HazardLabelling extracted.".into());
169 }
170 if let Some(hl) = &hz.hazard_labelling {
171 if let Some(stmts) = &hl.hazard_statement {
173 for s in stmts {
174 if let Some(code) = &s.hazard_statement_code {
175 let upper = code.to_uppercase();
176 if !ghs_codes::is_valid_h_code(&upper) {
177 w.push(format!(
178 "Section 2 (HazardStatement): unknown H-code '{code}'"
179 ));
180 }
181 }
182 }
183 }
184 if let Some(ps) = &hl.precautionary_statements {
186 let all_codes: Vec<Option<&String>> = ps
187 .prevention
188 .iter()
189 .flatten()
190 .map(|s| s.precautionary_statement_code.as_ref())
191 .chain(
192 ps.response
193 .iter()
194 .flatten()
195 .map(|s| s.precautionary_statement_code.as_ref()),
196 )
197 .chain(
198 ps.storage
199 .iter()
200 .flatten()
201 .map(|s| s.precautionary_statement_code.as_ref()),
202 )
203 .chain(
204 ps.disposal
205 .iter()
206 .flatten()
207 .map(|s| s.precautionary_statement_code.as_ref()),
208 )
209 .collect();
210 for code in all_codes.into_iter().flatten() {
211 let upper = code.to_uppercase();
212 if !ghs_codes::is_valid_p_code(&upper) {
213 w.push(format!(
214 "Section 2 (PrecautionaryStatement): unknown P-code '{code}'"
215 ));
216 }
217 }
218 }
219 }
220 }
221 }
222
223 match &sds.composition {
225 None => missing!("Section 3 (Composition)"),
226 Some(comp) => {
227 let items = comp.composition_and_concentration.as_deref().unwrap_or(&[]);
228 if items.is_empty() {
229 w.push("Section 3 (Composition): CompositionAndConcentration is empty.".into());
230 }
231 for (i, item) in items.iter().enumerate() {
233 if let Some(ids) = &item.substance_identifiers {
234 if let Some(identity) = &ids.substance_identity {
235 if let Some(cas_node) = &identity.ca_sno {
236 for cas in cas_node.full_text.iter().flatten() {
237 match validate_cas(cas) {
238 CasValidation::Ok => {}
239 CasValidation::InvalidFormat => {
240 w.push(format!(
241 "Section 3 (Composition[{i}]): invalid CAS format '{cas}' \
242 (expected \\d{{2,7}}-\\d{{2}}-\\d)"
243 ));
244 }
245 CasValidation::InvalidCheckDigit { expected } => {
246 w.push(format!(
247 "Section 3 (Composition[{i}]): CAS check digit mismatch \
248 '{cas}' (expected check digit {expected}, source has {})",
249 cas.chars().last().unwrap_or('?')
250 ));
251 }
252 }
253 }
254 }
255 }
256 }
257 }
258 }
259 }
260
261 if sds.first_aid_measures.is_none() {
263 missing!("Section 4 (FirstAidMeasures)");
264 }
265
266 if sds.fire_fighting_measures.is_none() {
268 missing!("Section 5 (FireFightingMeasures)");
269 }
270
271 if sds.accidental_release_measures.is_none() {
273 missing!("Section 6 (AccidentalReleaseMeasures)");
274 }
275
276 if sds.handling_and_storage.is_none() {
278 missing!("Section 7 (HandlingAndStorage)");
279 }
280
281 if sds.exposure_control_personal_protection.is_none() {
283 missing!("Section 8 (ExposureControlPersonalProtection)");
284 }
285
286 if sds.physical_chemical_properties.is_none() {
288 missing!("Section 9 (PhysicalChemicalProperties)");
289 }
290
291 match &sds.stability_reactivity {
293 None => missing!("Section 10 (StabilityReactivity)"),
294 Some(sr) => {
295 if sr.stability_description.is_none() && sr.reactivity_description.is_none() {
296 w.push("Section 10 (StabilityReactivity): neither StabilityDescription nor ReactivityDescription extracted.".into());
297 }
298 }
299 }
300
301 match &sds.toxicological_information {
303 None => missing!("Section 11 (ToxicologicalInformation)"),
304 Some(list) if list.is_empty() => {
305 w.push("Section 11 (ToxicologicalInformation): array is present but empty.".into());
306 }
307 _ => {}
308 }
309
310 match &sds.ecological_information {
312 None => missing!("Section 12 (EcologicalInformation)"),
313 Some(list) if list.is_empty() => {
314 w.push("Section 12 (EcologicalInformation): array is present but empty.".into());
315 }
316 _ => {}
317 }
318
319 if sds.disposal_considerations.is_none() {
321 missing!("Section 13 (DisposalConsiderations)");
322 }
323
324 if sds.transport_information.is_none() {
326 missing!("Section 14 (TransportInformation)");
327 }
328
329 if sds.regulatory_information.is_none() {
331 missing!("Section 15 (RegulatoryInformation)");
332 }
333
334 if sds.other_information.is_none() {
336 missing!("Section 16 (OtherInformation)");
337 }
338
339 if let Some(ds) = &sds.datasheet {
343 if let Some(d) = &ds.issue_date {
344 if !looks_like_date(d) {
345 w.push(format!("Datasheet.IssueDate '{d}' does not look like a valid date."));
346 }
347 }
348 for d in ds.revision_date.iter().flatten() {
349 if !looks_like_date(d) {
350 w.push(format!("Datasheet.RevisionDate '{d}' does not look like a valid date."));
351 }
352 }
353 }
354
355 if let Some(id) = &sds.identification {
357 if let Some(tpi) = &id.trade_product_identity {
358 for (field, val) in [
359 ("TradeNameJP", tpi.trade_name_jp.as_deref()),
360 ("TradeNameEN", tpi.trade_name_en.as_deref()),
361 ] {
362 if let Some(name) = val {
363 if is_placeholder(name) {
364 w.push(format!(
365 "Section 1 (Identification.{field}): value '{name}' looks like a placeholder."
366 ));
367 }
368 }
369 }
370 }
371 }
372
373 if let Some(comp) = &sds.composition {
375 for (i, item) in comp
376 .composition_and_concentration
377 .iter()
378 .flatten()
379 .enumerate()
380 {
381 if let Some(conc) = &item.concentration {
382 if let Some(nr) = &conc.numeric_range_with_unit_and_qualifier {
383 let is_percent = nr
384 .unit
385 .as_deref()
386 .map(|u| u.contains('%'))
387 .unwrap_or(false);
388 if is_percent {
389 let values: Vec<f64> = [
390 nr.exact_value.as_ref().and_then(|v| v.value),
391 nr.lower_value.as_ref().and_then(|v| v.value),
392 nr.upper_value.as_ref().and_then(|v| v.value),
393 ]
394 .into_iter()
395 .flatten()
396 .collect();
397 for v in values {
398 if !(0.0..=100.0).contains(&v) {
399 w.push(format!(
400 "Section 3 (Composition[{i}]): concentration {v}% is outside the \
401 valid range 0-100."
402 ));
403 }
404 }
405 }
406 }
407 }
408 }
409 }
410
411 if let Some(hz) = &sds.hazard_identification {
414 let has_real_classification = hz
415 .classification
416 .as_ref()
417 .map(classification_has_real_hazard)
418 .unwrap_or(false);
419 let has_hazard_statement = hz
420 .hazard_labelling
421 .as_ref()
422 .and_then(|hl| hl.hazard_statement.as_ref())
423 .map(|s| !s.is_empty())
424 .unwrap_or(false);
425 if has_real_classification && !has_hazard_statement {
426 w.push(
427 "Section 2 (HazardIdentification): Classification is present but \
428 HazardStatement is absent — verify labelling completeness."
429 .into(),
430 );
431 }
432 }
433
434 if let Some(hz) = &sds.hazard_identification {
438 if let Some(hl) = &hz.hazard_labelling {
439 let has_h_codes = hl
440 .hazard_statement
441 .as_deref()
442 .map(|s| !s.is_empty())
443 .unwrap_or(false);
444 if has_h_codes {
445 if hl.hazard_pictogram.as_deref().map(|p| p.is_empty()).unwrap_or(true) {
446 w.push(
447 "[HIGH][S2-GHS-INCOMPLETE] Section 2: HazardStatement present but \
448 HazardPictogram is empty — GHS labelling incomplete."
449 .into(),
450 );
451 }
452 if hl.signal_word.as_deref().map(|s| s.trim().is_empty()).unwrap_or(true) {
453 w.push(
454 "[HIGH][S2-GHS-INCOMPLETE] Section 2: HazardStatement present but \
455 SignalWord is missing — GHS labelling incomplete."
456 .into(),
457 );
458 }
459 if hz.classification.is_none() {
460 w.push(
461 "[HIGH][S2-GHS-INCOMPLETE] Section 2: HazardStatement present but \
462 Classification is missing — MHLW format requires both."
463 .into(),
464 );
465 }
466 }
467 }
468 }
469
470 if let Some(comp) = &sds.composition {
472 for (i, item) in comp
473 .composition_and_concentration
474 .iter()
475 .flatten()
476 .enumerate()
477 {
478 if let Some(ids) = &item.substance_identifiers {
479 let has_cas = ids
480 .substance_identity
481 .as_ref()
482 .and_then(|si| si.ca_sno.as_ref())
483 .and_then(|c| c.full_text.as_ref())
484 .map(|ft| ft.iter().any(|s| !s.trim().is_empty()))
485 .unwrap_or(false);
486 let has_name = ids
487 .substance_names
488 .as_ref()
489 .map(|n| {
490 n.iupac_name.as_deref().is_some_and(|s| !s.trim().is_empty())
491 || n.generic_name.as_deref().is_some_and(|s| !s.trim().is_empty())
492 })
493 .unwrap_or(false)
494 || ids
495 .common_name
496 .as_ref()
497 .and_then(|cn| cn.other_name.as_ref())
498 .map(|names| names.iter().any(|s| !s.trim().is_empty()))
499 .unwrap_or(false);
500 if has_cas && !has_name {
501 w.push(format!(
502 "[HIGH][S3-CAS-WITHOUT-NAME] Section 3 (Composition[{i}]): \
503 CAS number present but substance name is missing."
504 ));
505 } else if has_name && !has_cas {
506 w.push(format!(
507 "[HIGH][S3-NAME-WITHOUT-CAS] Section 3 (Composition[{i}]): \
508 substance name present but CAS number is missing."
509 ));
510 }
511 }
512 }
513 }
514
515 if let Some(ti) = &sds.transport_information {
517 for regs in ti.international_regulations.iter().flatten() {
518 for reg_name in regs.regulation_name.iter().flatten() {
519 if let Some(un) = ®_name.un_no {
520 if !un.trim().is_empty() {
521 if reg_name.proper_shipping_name.as_deref().map(|s| s.trim().is_empty()).unwrap_or(true) {
522 w.push(format!(
523 "[HIGH][S14-UN-INCOMPLETE] Section 14: UN No. '{un}' present but \
524 ProperShippingName is missing."
525 ));
526 }
527 if reg_name.packing_group.as_deref().map(|s| s.trim().is_empty()).unwrap_or(true) {
528 w.push(format!(
529 "[HIGH][S14-UN-INCOMPLETE] Section 14: UN No. '{un}' present but \
530 PackingGroup is missing."
531 ));
532 }
533 }
534 }
535 }
536 }
537 }
538
539 if let Some(hz) = &sds.hazard_identification {
541 if let Some(hl) = &hz.hazard_labelling {
542 let h_codes: Vec<String> = hl
544 .hazard_statement
545 .iter()
546 .flatten()
547 .filter_map(|s| s.hazard_statement_code.clone())
548 .map(|c| c.to_uppercase())
549 .collect();
550 let mut seen_h = std::collections::HashSet::new();
551 for code in &h_codes {
552 if !seen_h.insert(code.as_str()) {
553 w.push(format!(
554 "[MED][S2-DUPLICATE-HCODE] Section 2: H-code '{code}' appears more than once."
555 ));
556 }
557 }
558 if let Some(ps) = &hl.precautionary_statements {
560 let mut p_codes: Vec<String> = Vec::new();
561 for s in ps.prevention.iter().flatten() {
562 if let Some(c) = &s.precautionary_statement_code { p_codes.push(c.to_uppercase()); }
563 }
564 for s in ps.response.iter().flatten() {
565 if let Some(c) = &s.precautionary_statement_code { p_codes.push(c.to_uppercase()); }
566 }
567 for s in ps.storage.iter().flatten() {
568 if let Some(c) = &s.precautionary_statement_code { p_codes.push(c.to_uppercase()); }
569 }
570 for s in ps.disposal.iter().flatten() {
571 if let Some(c) = &s.precautionary_statement_code { p_codes.push(c.to_uppercase()); }
572 }
573 let mut seen_p = std::collections::HashSet::new();
574 for code in &p_codes {
575 if !seen_p.insert(code.as_str()) {
576 w.push(format!(
577 "[MED][S2-DUPLICATE-PCODE] Section 2: P-code '{code}' appears more than once."
578 ));
579 }
580 }
581 }
582 }
583 }
584
585 if let Some(pcp) = &sds.physical_chemical_properties {
587 let Ok(v) = serde_json::to_value(pcp) else { return w };
588 check_numeric_without_unit(&v, &mut w);
589 }
590
591 w
592}
593
594fn check_numeric_without_unit(v: &serde_json::Value, w: &mut Vec<String>) {
597 match v {
598 serde_json::Value::Object(map) => {
599 let has_value = map.contains_key("ExactValue")
600 || map.contains_key("UpperValue")
601 || map.contains_key("LowerValue");
602 let has_unit = map
603 .get("Unit")
604 .and_then(|u| u.as_str())
605 .map(|s| !s.trim().is_empty())
606 .unwrap_or(false);
607 let has_additional_info = map.contains_key("AdditionalInfo");
608 if has_value && !has_unit && !has_additional_info {
609 w.push(
610 "[MED][S9-VALUE-WITHOUT-UNIT] Section 9: numeric physical property \
611 value present without a Unit — add unit (e.g. \"°C\", \"kPa\", \"g/cm³\")."
612 .into(),
613 );
614 }
615 for child in map.values() {
616 check_numeric_without_unit(child, w);
617 }
618 }
619 serde_json::Value::Array(arr) => {
620 for child in arr {
621 check_numeric_without_unit(child, w);
622 }
623 }
624 _ => {}
625 }
626}
627
628#[derive(Debug, Clone, PartialEq)]
634pub enum PCodeCategory {
635 Prevention,
636 Response,
637 Storage,
638 Disposal,
639}
640
641impl std::fmt::Display for PCodeCategory {
642 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
643 match self {
644 Self::Prevention => write!(f, "Prevention"),
645 Self::Response => write!(f, "Response"),
646 Self::Storage => write!(f, "Storage"),
647 Self::Disposal => write!(f, "Disposal"),
648 }
649 }
650}
651
652#[derive(Debug, Clone)]
658pub enum ValidationFinding {
659 UnknownHCode {
661 code: String,
663 full_text: Option<String>,
665 statement_index: usize,
667 },
668 UnknownPCode {
670 code: String,
672 full_text: Option<String>,
674 category: PCodeCategory,
676 statement_index: usize,
678 },
679 CasCheckDigit {
681 cas: String,
683 composition_index: usize,
685 expected_digit: u32,
687 },
688}
689
690pub fn collect_findings(sds: &SdsRoot) -> Vec<ValidationFinding> {
700 let mut findings: Vec<ValidationFinding> = Vec::new();
701
702 if let Some(hz) = &sds.hazard_identification {
704 if let Some(hl) = &hz.hazard_labelling {
705 if let Some(stmts) = &hl.hazard_statement {
707 for (i, s) in stmts.iter().enumerate() {
708 if let Some(code) = &s.hazard_statement_code {
709 if !ghs_codes::is_valid_h_code(&code.to_uppercase()) {
710 findings.push(ValidationFinding::UnknownHCode {
711 code: code.clone(),
712 full_text: s.full_text.clone(),
713 statement_index: i,
714 });
715 }
716 }
717 }
718 }
719 if let Some(ps) = &hl.precautionary_statements {
722 macro_rules! collect_p {
723 ($cat:expr, $list:expr) => {
724 if let Some(list) = $list {
725 for (i, s) in list.iter().enumerate() {
726 if let Some(code) = &s.precautionary_statement_code {
727 if !ghs_codes::is_valid_p_code(&code.to_uppercase()) {
728 findings.push(ValidationFinding::UnknownPCode {
729 code: code.clone(),
730 full_text: s.full_text.clone(),
731 category: $cat,
732 statement_index: i,
733 });
734 }
735 }
736 }
737 }
738 };
739 }
740 collect_p!(PCodeCategory::Prevention, ps.prevention.as_deref());
741 collect_p!(PCodeCategory::Response, ps.response.as_deref());
742 collect_p!(PCodeCategory::Storage, ps.storage.as_deref());
743 collect_p!(PCodeCategory::Disposal, ps.disposal.as_deref());
744 }
745 }
746 }
747
748 if let Some(comp) = &sds.composition {
750 let items = comp.composition_and_concentration.as_deref().unwrap_or(&[]);
751 for (i, item) in items.iter().enumerate() {
752 if let Some(ids) = &item.substance_identifiers {
753 if let Some(identity) = &ids.substance_identity {
754 if let Some(cas_node) = &identity.ca_sno {
755 for cas in cas_node.full_text.iter().flatten() {
756 if let CasValidation::InvalidCheckDigit { expected } = validate_cas(cas) {
757 findings.push(ValidationFinding::CasCheckDigit {
758 cas: cas.clone(),
759 composition_index: i,
760 expected_digit: expected,
761 });
762 }
763 }
764 }
765 }
766 }
767 }
768 }
769
770 findings
771}
772
773#[derive(Debug, PartialEq)]
775pub(crate) enum CasValidation {
776 Ok,
778 InvalidFormat,
780 InvalidCheckDigit {
782 expected: u32,
784 },
785}
786
787pub(crate) fn validate_cas(cas: &str) -> CasValidation {
793 let parts: Vec<&str> = cas.split('-').collect();
794 if parts.len() != 3 {
795 return CasValidation::InvalidFormat;
796 }
797 let (a, b, c) = (parts[0], parts[1], parts[2]);
798 if a.len() < 2
800 || a.len() > 7
801 || b.len() != 2
802 || c.len() != 1
803 || !a.chars().all(|ch| ch.is_ascii_digit())
804 || !b.chars().all(|ch| ch.is_ascii_digit())
805 || !c.chars().all(|ch| ch.is_ascii_digit())
806 {
807 return CasValidation::InvalidFormat;
808 }
809 let check_digit: u32 = c
811 .chars()
812 .next()
813 .expect("c has length 1 after format check")
814 .to_digit(10)
815 .expect("c is an ASCII digit after format check");
816 let digits: Vec<u32> = a
817 .chars()
818 .chain(b.chars())
819 .filter_map(|ch| ch.to_digit(10))
820 .collect();
821 let sum: u32 = digits
822 .iter()
823 .rev()
824 .enumerate()
825 .map(|(i, &d)| d * (i as u32 + 1))
826 .sum();
827 let expected = sum % 10;
828 if expected == check_digit {
829 CasValidation::Ok
830 } else {
831 CasValidation::InvalidCheckDigit { expected }
832 }
833}
834
835pub(crate) fn validate_cas_format(cas: &str) -> bool {
839 validate_cas(cas) == CasValidation::Ok
840}
841
842pub fn validate_country(sds: &SdsRoot, country: SourceCountry) -> Vec<String> {
860 let mut w: Vec<String> = Vec::new();
861 match country {
862 SourceCountry::China => {
863 if !has_emergency_contact(sds) {
864 w.push(
865 "WARN: [China] Section 1: 24-hour emergency contact (紧急电话) is \
866 mandatory under GB/T 16483 but was not found."
867 .into(),
868 );
869 }
870 if !regulatory_mentions_keyword(sds, &["危险化学品", "GB 13690", "GB13690", "GB 30000"]) {
871 w.push(
872 "WARN: [China] Section 15: GB/T 16483 requires reference to \
873 危险化学品目录 / GB 13690 in RegulatoryInformation, but none found."
874 .into(),
875 );
876 }
877 }
878 SourceCountry::Korea => {
879 if !has_emergency_contact(sds) {
880 w.push(
881 "WARN: [Korea] Section 1: emergency contact (e.g. 1588-9119) is \
882 required by K-GHS but was not found."
883 .into(),
884 );
885 }
886 }
887 SourceCountry::Taiwan => {
888 if !has_emergency_contact(sds) {
889 w.push(
890 "WARN: [Taiwan] Section 1: emergency contact information is \
891 required by CNS 15030 but was not found."
892 .into(),
893 );
894 }
895 }
896 SourceCountry::Japan => {}
897 }
898 w
899}
900
901fn has_emergency_contact(sds: &SdsRoot) -> bool {
903 let id = match sds.identification.as_ref() { Some(v) => v, None => return false };
904 if let Some(dmi) = &id.domestic_manufacturer_information {
906 if let Some(contacts) = &dmi.emergency_contact {
907 if contacts.iter().any(|c| c.phone.as_ref().map_or(false, |p| !p.is_empty())) {
908 return true;
909 }
910 }
911 }
912 if let Some(si) = &id.supplier_information {
914 if let Some(contacts) = &si.emergency_contact {
915 if contacts.iter().any(|c| c.phone.as_ref().map_or(false, |p| !p.is_empty())) {
916 return true;
917 }
918 }
919 }
920 false
921}
922
923fn regulatory_mentions_keyword(sds: &SdsRoot, keywords: &[&str]) -> bool {
925 let ri = match sds.regulatory_information.as_ref() { Some(v) => v, None => return false };
926 let Ok(v) = serde_json::to_string(ri) else { return false };
928 keywords.iter().any(|kw| v.contains(kw))
929}
930
931pub fn verify_against_source(sds: &SdsRoot, source_text: &str, skip_cas: &[String]) -> Vec<String> {
932 let mut w: Vec<String> = Vec::new();
933
934 let source_has_kana = has_kana(source_text);
936 if let Some(id) = &sds.identification {
937 if let Some(tpi) = &id.trade_product_identity {
938 for (field, val) in [
939 ("TradeNameJP", tpi.trade_name_jp.as_deref()),
940 ("TradeNameEN", tpi.trade_name_en.as_deref()),
941 ] {
942 if let Some(name) = val {
943 if !is_placeholder(name) && !source_contains_name(source_text, name) {
944 let suffix = if field == "TradeNameJP" && !source_has_kana {
948 " (source has no Japanese — likely a language hallucination)"
949 } else {
950 " — possible hallucination"
951 };
952 w.push(format!(
953 "[SourceVerify] Section 1 ({field}): '{}' not found verbatim in \
954 source text{suffix}.",
955 truncate(name, 60)
956 ));
957 }
958 }
959 }
960 }
961 }
962
963 if let Some(comp) = &sds.composition {
965 for (i, item) in comp
966 .composition_and_concentration
967 .iter()
968 .flatten()
969 .enumerate()
970 {
971 if let Some(ids) = &item.substance_identifiers {
972 if let Some(identity) = &ids.substance_identity {
973 if let Some(cas_node) = &identity.ca_sno {
974 for cas in cas_node.full_text.iter().flatten() {
975 if validate_cas(cas) == CasValidation::Ok
981 && !skip_cas.contains(cas)
982 && !source_contains_cas(source_text, cas)
983 {
984 w.push(format!(
985 "[SourceVerify] Section 3 (Composition[{i}] CAS): \
986 '{cas}' not found in source text — possible hallucination."
987 ));
988 }
989 }
990 }
991 }
992 }
993 }
994 }
995
996 if let Some(comp) = &sds.composition {
998 for (i, item) in comp
999 .composition_and_concentration
1000 .iter()
1001 .flatten()
1002 .enumerate()
1003 {
1004 if let Some(ids) = &item.substance_identifiers {
1005 if let Some(names) = &ids.substance_names {
1006 for (field, val) in [
1007 ("IupacName", names.iupac_name.as_deref()),
1008 ("GenericName", names.generic_name.as_deref()),
1009 ] {
1010 if let Some(name) = val {
1011 if name.trim().len() >= 3
1012 && !is_placeholder(name)
1013 && !source_contains_name(source_text, name)
1014 {
1015 w.push(format!(
1016 "[SourceVerify] Section 3 (Composition[{i}].{field}): \
1017 '{}' not found verbatim in source text.",
1018 truncate(name, 60)
1019 ));
1020 }
1021 }
1022 }
1023 }
1024 }
1025 }
1026 }
1027
1028 if !source_has_kana {
1033 if let Some(id) = &sds.identification {
1035 if let Some(tpi) = &id.trade_product_identity {
1036 if let Some(name) = &tpi.trade_name_jp {
1037 if has_kana(name) {
1038 w.push(format!(
1039 "[SourceVerify] Section 1 (TradeNameJP): '{}' contains Japanese \
1040 kana but none appears in the source document — likely a \
1041 transliteration hallucination.",
1042 truncate(name, 60)
1043 ));
1044 }
1045 }
1046 }
1047 }
1048 if let Some(comp) = &sds.composition {
1050 for (i, item) in comp
1051 .composition_and_concentration
1052 .iter()
1053 .flatten()
1054 .enumerate()
1055 {
1056 if let Some(ids) = &item.substance_identifiers {
1057 if let Some(names) = &ids.substance_names {
1058 for (field, val) in [
1059 ("IupacName", names.iupac_name.as_deref()),
1060 ("GenericName", names.generic_name.as_deref()),
1061 ] {
1062 if let Some(name) = val {
1063 if has_kana(name) {
1064 w.push(format!(
1065 "[SourceVerify] Section 3 (Composition[{i}].{field}): \
1066 '{}' contains Japanese kana but source has none — \
1067 possible language hallucination.",
1068 truncate(name, 60)
1069 ));
1070 }
1071 }
1072 }
1073 }
1074 }
1075 }
1076 }
1077 if let Some(id) = &sds.identification {
1079 if let Some(si) = &id.supplier_information {
1080 if let Some(name) = &si.company_name {
1081 if has_kana(name) {
1082 w.push(format!(
1083 "[SourceVerify] Section 1 (SupplierInformation.CompanyName): \
1084 '{}' contains Japanese kana but source has none — \
1085 possible language hallucination.",
1086 truncate(name, 60)
1087 ));
1088 }
1089 }
1090 }
1091 }
1092 }
1093
1094 w
1095}
1096
1097fn source_contains_name(source: &str, name: &str) -> bool {
1108 if source_contains(source, name) {
1109 return true;
1110 }
1111 let chars: Vec<char> = name.chars().collect();
1112 if chars.len() > 40 {
1113 let delimiters = ['(', ',', ';', '('];
1118 let cut = chars[..chars.len().min(35)]
1119 .iter()
1120 .position(|c| delimiters.contains(c))
1121 .unwrap_or(25)
1122 .max(8); let prefix: String = chars[..cut].iter().collect();
1124 if source_contains(source, prefix.trim()) {
1125 return true;
1126 }
1127 }
1128 let name_norm = normalize_for_search(name);
1132 let src_norm = normalize_for_search(source);
1133 let core = name_norm.trim_start_matches(|c: char| c.is_ascii_digit() || c == ',' || c == '-');
1134 if core.len() >= 8 && src_norm.contains(core) {
1135 return true;
1136 }
1137 let stripped_stereo = name_norm
1141 .replace("(-)", "")
1142 .replace("(+)", "")
1143 .replace("(±)", "")
1144 .replace("(R)", "")
1145 .replace("(S)", "")
1146 .replace("(d)", "")
1147 .replace("(l)", "");
1148 if stripped_stereo.len() >= 8 && src_norm.contains(stripped_stereo.as_str()) {
1149 return true;
1150 }
1151 false
1152}
1153
1154fn source_contains(source: &str, value: &str) -> bool {
1160 let v = value.trim();
1161 if v.len() < 3 {
1162 return true; }
1164 if source.contains(v) {
1165 return true;
1166 }
1167 let v_norm = normalize_for_search(v);
1170 let src_norm = normalize_for_search(source);
1171 if v_norm.len() >= 3 {
1172 src_norm.contains(&v_norm)
1173 } else {
1174 false
1175 }
1176}
1177
1178fn normalize_for_search(s: &str) -> String {
1184 s.chars()
1185 .filter(|c| !c.is_whitespace())
1186 .map(|c| match c {
1187 ',' => ',',
1189 '、' => ',', '.' => '.',
1191 '。' => '.',
1192 '-' => '-',
1193 '(' => '(',
1194 ')' => ')',
1195 '異' => '异', '環' => '环', '鹼' => '碱', '鹽' => '盐', '鋰' => '锂', '鈉' => '钠', '鉀' => '钾', '鐵' => '铁', '銅' => '铜', '鋁' => '铝', '銀' => '银', '鋅' => '锌', '氫' => '氢', '氯' => '氯', '無' => '无', '劑' => '剂', '酸' => '酸', '烴' => '烃', _ => c,
1215 })
1216 .collect()
1217}
1218
1219fn has_kana(s: &str) -> bool {
1221 s.chars().any(|c| matches!(c, '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}'))
1222}
1223
1224fn source_contains_cas(source: &str, cas: &str) -> bool {
1228 if source.contains(cas) {
1229 return true;
1230 }
1231 let fullwidth = cas.replace('-', "-");
1233 if source.contains(&fullwidth) {
1234 return true;
1235 }
1236 let cas_norm = normalize_for_search(cas);
1238 let src_norm = normalize_for_search(source);
1239 src_norm.contains(&cas_norm)
1240}
1241
1242fn truncate(s: &str, max: usize) -> &str {
1244 let mut idx = max;
1245 while !s.is_char_boundary(idx) && idx > 0 {
1246 idx -= 1;
1247 }
1248 if idx < s.len() { &s[..idx] } else { s }
1249}
1250
1251#[cfg(test)]
1256mod tests {
1257 use super::*;
1258
1259 #[test]
1260 fn test_looks_like_date_valid() {
1261 assert!(looks_like_date("2024-03-15"));
1262 assert!(looks_like_date("2024/03/15"));
1263 assert!(looks_like_date("令和6年3月15日"));
1264 assert!(looks_like_date("20240315"));
1265 }
1266
1267 #[test]
1268 fn test_looks_like_date_invalid() {
1269 assert!(!looks_like_date("不明"));
1270 assert!(!looks_like_date("N/A"));
1271 assert!(!looks_like_date("改訂"));
1272 }
1273
1274 #[test]
1275 fn test_source_contains_cas_ascii_hyphen() {
1276 let source = "成分: エタノール CAS No. 64-17-5 含有量 99%";
1277 assert!(source_contains_cas(source, "64-17-5"));
1278 }
1279
1280 #[test]
1281 fn test_source_contains_cas_fullwidth_hyphen() {
1282 let source = "CAS No. 64-17-5";
1283 assert!(source_contains_cas(source, "64-17-5"));
1284 }
1285
1286 #[test]
1287 fn test_source_contains_cas_not_present() {
1288 let source = "CAS No. 7732-18-5";
1289 assert!(!source_contains_cas(source, "64-17-5"));
1290 }
1291
1292 #[test]
1293 fn test_source_contains_short_value_always_passes() {
1294 let source = "no matches here";
1295 assert!(source_contains(source, "AB")); }
1297
1298 #[test]
1299 fn test_truncate_ascii() {
1300 assert_eq!(truncate("hello world", 5), "hello");
1301 }
1302
1303 #[test]
1304 fn test_truncate_multibyte() {
1305 let s = "エタノール"; let t = truncate(s, 6); assert!(s.starts_with(t));
1308 }
1309}