1use crate::country::SourceCountry;
2use crate::ghs_codes;
3use crate::schema::{HazardIdentificationClassification, SdsRoot};
4
5fn looks_like_date(s: &str) -> bool {
9 let s = s.trim();
10 if s.split(|c: char| !c.is_ascii_digit())
12 .any(|tok| {
13 (tok.len() == 4 || tok.len() == 8)
14 && tok.starts_with(|c: char| c == '1' || c == '2')
15 })
16 {
17 return true;
18 }
19 if s.contains('年') && s.chars().any(|c| c.is_ascii_digit()) {
21 return true;
22 }
23 false
24}
25
26const PLACEHOLDER_NAMES: &[&str] = &[
28 "n/a", "na", "unknown", "不明", "未定", "化学物質", "chemical substance",
29 "substance", "物質名", "product", "製品名",
30];
31
32fn is_placeholder(s: &str) -> bool {
33 let lower = s.trim().to_lowercase();
34 PLACEHOLDER_NAMES.contains(&lower.as_str()) || lower.is_empty()
35}
36
37const NOT_CLASSIFIED_PATTERNS: &[&str] = &[
39 "not classified", "not applicable", "n/a", "na",
40 "分類できない", "分類対象外", "区分外", "該当なし", "データなし",
41 "不适用", "不分类", "无资料",
42 "分類不明", "分类不明",
44];
45
46fn is_not_classified(s: &str) -> bool {
47 let lower = s.trim().to_lowercase();
48 NOT_CLASSIFIED_PATTERNS.iter().any(|&p| lower.contains(p)) || lower.is_empty()
49}
50
51fn classification_has_real_hazard(c: &HazardIdentificationClassification) -> bool {
54 let Ok(v) = serde_json::to_value(c) else { return false };
55 let mut strings: Vec<String> = Vec::new();
56 collect_strings(&v, &mut strings);
57 strings.iter().any(|s| !is_not_classified(s))
58}
59
60fn collect_strings(v: &serde_json::Value, out: &mut Vec<String>) {
62 match v {
63 serde_json::Value::String(s) => out.push(s.clone()),
64 serde_json::Value::Object(map) => map.values().for_each(|v| collect_strings(v, out)),
65 serde_json::Value::Array(arr) => arr.iter().for_each(|v| collect_strings(v, out)),
66 _ => {}
67 }
68}
69
70pub fn validate(sds: &SdsRoot) -> Vec<String> {
73 let mut w: Vec<String> = Vec::new();
74
75 macro_rules! missing {
76 ($section:literal) => {
77 w.push(format!(
78 "{}: section not extracted — check source document.",
79 $section
80 ))
81 };
82 }
83
84 match &sds.identification {
86 None => missing!("Section 1 (Identification)"),
87 Some(id) => {
88 let has_name = id
89 .trade_product_identity
90 .as_ref()
91 .map(|t| t.trade_name_jp.is_some() || t.trade_name_en.is_some())
92 .unwrap_or(false);
93 if !has_name {
94 w.push("Section 1 (Identification): no product name (TradeNameJP/TradeNameEN).".into());
95 }
96 if id.supplier_information.is_none() {
97 w.push("Section 1 (Identification): SupplierInformation is missing.".into());
98 }
99 }
100 }
101
102 match &sds.hazard_identification {
104 None => missing!("Section 2 (HazardIdentification)"),
105 Some(hz) => {
106 if hz.classification.is_none() && hz.hazard_labelling.is_none() {
107 w.push("Section 2 (HazardIdentification): neither Classification nor HazardLabelling extracted.".into());
108 }
109 if let Some(hl) = &hz.hazard_labelling {
110 if let Some(stmts) = &hl.hazard_statement {
112 for s in stmts {
113 if let Some(code) = &s.hazard_statement_code {
114 let upper = code.to_uppercase();
115 if !ghs_codes::is_valid_h_code(&upper) {
116 w.push(format!(
117 "Section 2 (HazardStatement): unknown H-code '{code}'"
118 ));
119 }
120 }
121 }
122 }
123 if let Some(ps) = &hl.precautionary_statements {
125 let all_codes: Vec<Option<&String>> = ps
126 .prevention
127 .iter()
128 .flatten()
129 .map(|s| s.precautionary_statement_code.as_ref())
130 .chain(
131 ps.response
132 .iter()
133 .flatten()
134 .map(|s| s.precautionary_statement_code.as_ref()),
135 )
136 .chain(
137 ps.storage
138 .iter()
139 .flatten()
140 .map(|s| s.precautionary_statement_code.as_ref()),
141 )
142 .chain(
143 ps.disposal
144 .iter()
145 .flatten()
146 .map(|s| s.precautionary_statement_code.as_ref()),
147 )
148 .collect();
149 for code in all_codes.into_iter().flatten() {
150 let upper = code.to_uppercase();
151 if !ghs_codes::is_valid_p_code(&upper) {
152 w.push(format!(
153 "Section 2 (PrecautionaryStatement): unknown P-code '{code}'"
154 ));
155 }
156 }
157 }
158 }
159 }
160 }
161
162 match &sds.composition {
164 None => missing!("Section 3 (Composition)"),
165 Some(comp) => {
166 let items = comp.composition_and_concentration.as_deref().unwrap_or(&[]);
167 if items.is_empty() {
168 w.push("Section 3 (Composition): CompositionAndConcentration is empty.".into());
169 }
170 for (i, item) in items.iter().enumerate() {
172 if let Some(ids) = &item.substance_identifiers {
173 if let Some(identity) = &ids.substance_identity {
174 if let Some(cas_node) = &identity.ca_sno {
175 for cas in cas_node.full_text.iter().flatten() {
176 match validate_cas(cas) {
177 CasValidation::Ok => {}
178 CasValidation::InvalidFormat => {
179 w.push(format!(
180 "Section 3 (Composition[{i}]): invalid CAS format '{cas}' \
181 (expected \\d{{2,7}}-\\d{{2}}-\\d)"
182 ));
183 }
184 CasValidation::InvalidCheckDigit { expected } => {
185 w.push(format!(
186 "Section 3 (Composition[{i}]): CAS check digit mismatch \
187 '{cas}' (expected check digit {expected}, source has {})",
188 cas.chars().last().unwrap_or('?')
189 ));
190 }
191 }
192 }
193 }
194 }
195 }
196 }
197 }
198 }
199
200 if sds.first_aid_measures.is_none() {
202 missing!("Section 4 (FirstAidMeasures)");
203 }
204
205 if sds.fire_fighting_measures.is_none() {
207 missing!("Section 5 (FireFightingMeasures)");
208 }
209
210 if sds.accidental_release_measures.is_none() {
212 missing!("Section 6 (AccidentalReleaseMeasures)");
213 }
214
215 if sds.handling_and_storage.is_none() {
217 missing!("Section 7 (HandlingAndStorage)");
218 }
219
220 if sds.exposure_control_personal_protection.is_none() {
222 missing!("Section 8 (ExposureControlPersonalProtection)");
223 }
224
225 if sds.physical_chemical_properties.is_none() {
227 missing!("Section 9 (PhysicalChemicalProperties)");
228 }
229
230 match &sds.stability_reactivity {
232 None => missing!("Section 10 (StabilityReactivity)"),
233 Some(sr) => {
234 if sr.stability_description.is_none() && sr.reactivity_description.is_none() {
235 w.push("Section 10 (StabilityReactivity): neither StabilityDescription nor ReactivityDescription extracted.".into());
236 }
237 }
238 }
239
240 match &sds.toxicological_information {
242 None => missing!("Section 11 (ToxicologicalInformation)"),
243 Some(list) if list.is_empty() => {
244 w.push("Section 11 (ToxicologicalInformation): array is present but empty.".into());
245 }
246 _ => {}
247 }
248
249 match &sds.ecological_information {
251 None => missing!("Section 12 (EcologicalInformation)"),
252 Some(list) if list.is_empty() => {
253 w.push("Section 12 (EcologicalInformation): array is present but empty.".into());
254 }
255 _ => {}
256 }
257
258 if sds.disposal_considerations.is_none() {
260 missing!("Section 13 (DisposalConsiderations)");
261 }
262
263 if sds.transport_information.is_none() {
265 missing!("Section 14 (TransportInformation)");
266 }
267
268 if sds.regulatory_information.is_none() {
270 missing!("Section 15 (RegulatoryInformation)");
271 }
272
273 if sds.other_information.is_none() {
275 missing!("Section 16 (OtherInformation)");
276 }
277
278 if let Some(ds) = &sds.datasheet {
282 if let Some(d) = &ds.issue_date {
283 if !looks_like_date(d) {
284 w.push(format!("Datasheet.IssueDate '{d}' does not look like a valid date."));
285 }
286 }
287 for d in ds.revision_date.iter().flatten() {
288 if !looks_like_date(d) {
289 w.push(format!("Datasheet.RevisionDate '{d}' does not look like a valid date."));
290 }
291 }
292 }
293
294 if let Some(id) = &sds.identification {
296 if let Some(tpi) = &id.trade_product_identity {
297 for (field, val) in [
298 ("TradeNameJP", tpi.trade_name_jp.as_deref()),
299 ("TradeNameEN", tpi.trade_name_en.as_deref()),
300 ] {
301 if let Some(name) = val {
302 if is_placeholder(name) {
303 w.push(format!(
304 "Section 1 (Identification.{field}): value '{name}' looks like a placeholder."
305 ));
306 }
307 }
308 }
309 }
310 }
311
312 if let Some(comp) = &sds.composition {
314 for (i, item) in comp
315 .composition_and_concentration
316 .iter()
317 .flatten()
318 .enumerate()
319 {
320 if let Some(conc) = &item.concentration {
321 if let Some(nr) = &conc.numeric_range_with_unit_and_qualifier {
322 let is_percent = nr
323 .unit
324 .as_deref()
325 .map(|u| u.contains('%'))
326 .unwrap_or(false);
327 if is_percent {
328 let values: Vec<f64> = [
329 nr.exact_value.as_ref().and_then(|v| v.value),
330 nr.lower_value.as_ref().and_then(|v| v.value),
331 nr.upper_value.as_ref().and_then(|v| v.value),
332 ]
333 .into_iter()
334 .flatten()
335 .collect();
336 for v in values {
337 if !(0.0..=100.0).contains(&v) {
338 w.push(format!(
339 "Section 3 (Composition[{i}]): concentration {v}% is outside the \
340 valid range 0-100."
341 ));
342 }
343 }
344 }
345 }
346 }
347 }
348 }
349
350 if let Some(hz) = &sds.hazard_identification {
353 let has_real_classification = hz
354 .classification
355 .as_ref()
356 .map(classification_has_real_hazard)
357 .unwrap_or(false);
358 let has_hazard_statement = hz
359 .hazard_labelling
360 .as_ref()
361 .and_then(|hl| hl.hazard_statement.as_ref())
362 .map(|s| !s.is_empty())
363 .unwrap_or(false);
364 if has_real_classification && !has_hazard_statement {
365 w.push(
366 "Section 2 (HazardIdentification): Classification is present but \
367 HazardStatement is absent — verify labelling completeness."
368 .into(),
369 );
370 }
371 }
372
373 w
374}
375
376#[derive(Debug, Clone, PartialEq)]
382pub enum PCodeCategory {
383 Prevention,
384 Response,
385 Storage,
386 Disposal,
387}
388
389impl std::fmt::Display for PCodeCategory {
390 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391 match self {
392 Self::Prevention => write!(f, "Prevention"),
393 Self::Response => write!(f, "Response"),
394 Self::Storage => write!(f, "Storage"),
395 Self::Disposal => write!(f, "Disposal"),
396 }
397 }
398}
399
400#[derive(Debug, Clone)]
406pub enum ValidationFinding {
407 UnknownHCode {
409 code: String,
411 full_text: Option<String>,
413 statement_index: usize,
415 },
416 UnknownPCode {
418 code: String,
420 full_text: Option<String>,
422 category: PCodeCategory,
424 statement_index: usize,
426 },
427 CasCheckDigit {
429 cas: String,
431 composition_index: usize,
433 expected_digit: u32,
435 },
436}
437
438pub fn collect_findings(sds: &SdsRoot) -> Vec<ValidationFinding> {
448 let mut findings: Vec<ValidationFinding> = Vec::new();
449
450 if let Some(hz) = &sds.hazard_identification {
452 if let Some(hl) = &hz.hazard_labelling {
453 if let Some(stmts) = &hl.hazard_statement {
455 for (i, s) in stmts.iter().enumerate() {
456 if let Some(code) = &s.hazard_statement_code {
457 if !ghs_codes::is_valid_h_code(&code.to_uppercase()) {
458 findings.push(ValidationFinding::UnknownHCode {
459 code: code.clone(),
460 full_text: s.full_text.clone(),
461 statement_index: i,
462 });
463 }
464 }
465 }
466 }
467 if let Some(ps) = &hl.precautionary_statements {
470 macro_rules! collect_p {
471 ($cat:expr, $list:expr) => {
472 if let Some(list) = $list {
473 for (i, s) in list.iter().enumerate() {
474 if let Some(code) = &s.precautionary_statement_code {
475 if !ghs_codes::is_valid_p_code(&code.to_uppercase()) {
476 findings.push(ValidationFinding::UnknownPCode {
477 code: code.clone(),
478 full_text: s.full_text.clone(),
479 category: $cat,
480 statement_index: i,
481 });
482 }
483 }
484 }
485 }
486 };
487 }
488 collect_p!(PCodeCategory::Prevention, ps.prevention.as_deref());
489 collect_p!(PCodeCategory::Response, ps.response.as_deref());
490 collect_p!(PCodeCategory::Storage, ps.storage.as_deref());
491 collect_p!(PCodeCategory::Disposal, ps.disposal.as_deref());
492 }
493 }
494 }
495
496 if let Some(comp) = &sds.composition {
498 let items = comp.composition_and_concentration.as_deref().unwrap_or(&[]);
499 for (i, item) in items.iter().enumerate() {
500 if let Some(ids) = &item.substance_identifiers {
501 if let Some(identity) = &ids.substance_identity {
502 if let Some(cas_node) = &identity.ca_sno {
503 for cas in cas_node.full_text.iter().flatten() {
504 if let CasValidation::InvalidCheckDigit { expected } = validate_cas(cas) {
505 findings.push(ValidationFinding::CasCheckDigit {
506 cas: cas.clone(),
507 composition_index: i,
508 expected_digit: expected,
509 });
510 }
511 }
512 }
513 }
514 }
515 }
516 }
517
518 findings
519}
520
521#[derive(Debug, PartialEq)]
523pub(crate) enum CasValidation {
524 Ok,
526 InvalidFormat,
528 InvalidCheckDigit {
530 expected: u32,
532 },
533}
534
535pub(crate) fn validate_cas(cas: &str) -> CasValidation {
541 let parts: Vec<&str> = cas.split('-').collect();
542 if parts.len() != 3 {
543 return CasValidation::InvalidFormat;
544 }
545 let (a, b, c) = (parts[0], parts[1], parts[2]);
546 if a.len() < 2
548 || a.len() > 7
549 || b.len() != 2
550 || c.len() != 1
551 || !a.chars().all(|ch| ch.is_ascii_digit())
552 || !b.chars().all(|ch| ch.is_ascii_digit())
553 || !c.chars().all(|ch| ch.is_ascii_digit())
554 {
555 return CasValidation::InvalidFormat;
556 }
557 let check_digit: u32 = c
559 .chars()
560 .next()
561 .expect("c has length 1 after format check")
562 .to_digit(10)
563 .expect("c is an ASCII digit after format check");
564 let digits: Vec<u32> = a
565 .chars()
566 .chain(b.chars())
567 .filter_map(|ch| ch.to_digit(10))
568 .collect();
569 let sum: u32 = digits
570 .iter()
571 .rev()
572 .enumerate()
573 .map(|(i, &d)| d * (i as u32 + 1))
574 .sum();
575 let expected = sum % 10;
576 if expected == check_digit {
577 CasValidation::Ok
578 } else {
579 CasValidation::InvalidCheckDigit { expected }
580 }
581}
582
583pub(crate) fn validate_cas_format(cas: &str) -> bool {
587 validate_cas(cas) == CasValidation::Ok
588}
589
590pub fn validate_country(sds: &SdsRoot, country: SourceCountry) -> Vec<String> {
608 let mut w: Vec<String> = Vec::new();
609 match country {
610 SourceCountry::China => {
611 if !has_emergency_contact(sds) {
612 w.push(
613 "WARN: [China] Section 1: 24-hour emergency contact (紧急电话) is \
614 mandatory under GB/T 16483 but was not found."
615 .into(),
616 );
617 }
618 if !regulatory_mentions_keyword(sds, &["危险化学品", "GB 13690", "GB13690", "GB 30000"]) {
619 w.push(
620 "WARN: [China] Section 15: GB/T 16483 requires reference to \
621 危险化学品目录 / GB 13690 in RegulatoryInformation, but none found."
622 .into(),
623 );
624 }
625 }
626 SourceCountry::Korea => {
627 if !has_emergency_contact(sds) {
628 w.push(
629 "WARN: [Korea] Section 1: emergency contact (e.g. 1588-9119) is \
630 required by K-GHS but was not found."
631 .into(),
632 );
633 }
634 }
635 SourceCountry::Taiwan => {
636 if !has_emergency_contact(sds) {
637 w.push(
638 "WARN: [Taiwan] Section 1: emergency contact information is \
639 required by CNS 15030 but was not found."
640 .into(),
641 );
642 }
643 }
644 SourceCountry::Japan => {}
645 }
646 w
647}
648
649fn has_emergency_contact(sds: &SdsRoot) -> bool {
651 let id = match sds.identification.as_ref() { Some(v) => v, None => return false };
652 if let Some(dmi) = &id.domestic_manufacturer_information {
654 if let Some(contacts) = &dmi.emergency_contact {
655 if contacts.iter().any(|c| c.phone.as_ref().map_or(false, |p| !p.is_empty())) {
656 return true;
657 }
658 }
659 }
660 if let Some(si) = &id.supplier_information {
662 if let Some(contacts) = &si.emergency_contact {
663 if contacts.iter().any(|c| c.phone.as_ref().map_or(false, |p| !p.is_empty())) {
664 return true;
665 }
666 }
667 }
668 false
669}
670
671fn regulatory_mentions_keyword(sds: &SdsRoot, keywords: &[&str]) -> bool {
673 let ri = match sds.regulatory_information.as_ref() { Some(v) => v, None => return false };
674 let Ok(v) = serde_json::to_string(ri) else { return false };
676 keywords.iter().any(|kw| v.contains(kw))
677}
678
679pub fn verify_against_source(sds: &SdsRoot, source_text: &str, skip_cas: &[String]) -> Vec<String> {
680 let mut w: Vec<String> = Vec::new();
681
682 let source_has_kana = has_kana(source_text);
684 if let Some(id) = &sds.identification {
685 if let Some(tpi) = &id.trade_product_identity {
686 for (field, val) in [
687 ("TradeNameJP", tpi.trade_name_jp.as_deref()),
688 ("TradeNameEN", tpi.trade_name_en.as_deref()),
689 ] {
690 if let Some(name) = val {
691 if !is_placeholder(name) && !source_contains_name(source_text, name) {
692 let suffix = if field == "TradeNameJP" && !source_has_kana {
696 " (source has no Japanese — likely a language hallucination)"
697 } else {
698 " — possible hallucination"
699 };
700 w.push(format!(
701 "[SourceVerify] Section 1 ({field}): '{}' not found verbatim in \
702 source text{suffix}.",
703 truncate(name, 60)
704 ));
705 }
706 }
707 }
708 }
709 }
710
711 if let Some(comp) = &sds.composition {
713 for (i, item) in comp
714 .composition_and_concentration
715 .iter()
716 .flatten()
717 .enumerate()
718 {
719 if let Some(ids) = &item.substance_identifiers {
720 if let Some(identity) = &ids.substance_identity {
721 if let Some(cas_node) = &identity.ca_sno {
722 for cas in cas_node.full_text.iter().flatten() {
723 if validate_cas(cas) == CasValidation::Ok
729 && !skip_cas.contains(cas)
730 && !source_contains_cas(source_text, cas)
731 {
732 w.push(format!(
733 "[SourceVerify] Section 3 (Composition[{i}] CAS): \
734 '{cas}' not found in source text — possible hallucination."
735 ));
736 }
737 }
738 }
739 }
740 }
741 }
742 }
743
744 if let Some(comp) = &sds.composition {
746 for (i, item) in comp
747 .composition_and_concentration
748 .iter()
749 .flatten()
750 .enumerate()
751 {
752 if let Some(ids) = &item.substance_identifiers {
753 if let Some(names) = &ids.substance_names {
754 for (field, val) in [
755 ("IupacName", names.iupac_name.as_deref()),
756 ("GenericName", names.generic_name.as_deref()),
757 ] {
758 if let Some(name) = val {
759 if name.trim().len() >= 3
760 && !is_placeholder(name)
761 && !source_contains_name(source_text, name)
762 {
763 w.push(format!(
764 "[SourceVerify] Section 3 (Composition[{i}].{field}): \
765 '{}' not found verbatim in source text.",
766 truncate(name, 60)
767 ));
768 }
769 }
770 }
771 }
772 }
773 }
774 }
775
776 if !source_has_kana {
781 if let Some(id) = &sds.identification {
783 if let Some(tpi) = &id.trade_product_identity {
784 if let Some(name) = &tpi.trade_name_jp {
785 if has_kana(name) {
786 w.push(format!(
787 "[SourceVerify] Section 1 (TradeNameJP): '{}' contains Japanese \
788 kana but none appears in the source document — likely a \
789 transliteration hallucination.",
790 truncate(name, 60)
791 ));
792 }
793 }
794 }
795 }
796 if let Some(comp) = &sds.composition {
798 for (i, item) in comp
799 .composition_and_concentration
800 .iter()
801 .flatten()
802 .enumerate()
803 {
804 if let Some(ids) = &item.substance_identifiers {
805 if let Some(names) = &ids.substance_names {
806 for (field, val) in [
807 ("IupacName", names.iupac_name.as_deref()),
808 ("GenericName", names.generic_name.as_deref()),
809 ] {
810 if let Some(name) = val {
811 if has_kana(name) {
812 w.push(format!(
813 "[SourceVerify] Section 3 (Composition[{i}].{field}): \
814 '{}' contains Japanese kana but source has none — \
815 possible language hallucination.",
816 truncate(name, 60)
817 ));
818 }
819 }
820 }
821 }
822 }
823 }
824 }
825 if let Some(id) = &sds.identification {
827 if let Some(si) = &id.supplier_information {
828 if let Some(name) = &si.company_name {
829 if has_kana(name) {
830 w.push(format!(
831 "[SourceVerify] Section 1 (SupplierInformation.CompanyName): \
832 '{}' contains Japanese kana but source has none — \
833 possible language hallucination.",
834 truncate(name, 60)
835 ));
836 }
837 }
838 }
839 }
840 }
841
842 w
843}
844
845fn source_contains_name(source: &str, name: &str) -> bool {
856 if source_contains(source, name) {
857 return true;
858 }
859 let chars: Vec<char> = name.chars().collect();
860 if chars.len() > 40 {
861 let delimiters = ['(', ',', ';', '('];
866 let cut = chars[..chars.len().min(35)]
867 .iter()
868 .position(|c| delimiters.contains(c))
869 .unwrap_or(25)
870 .max(8); let prefix: String = chars[..cut].iter().collect();
872 if source_contains(source, prefix.trim()) {
873 return true;
874 }
875 }
876 let name_norm = normalize_for_search(name);
880 let src_norm = normalize_for_search(source);
881 let core = name_norm.trim_start_matches(|c: char| c.is_ascii_digit() || c == ',' || c == '-');
882 if core.len() >= 8 && src_norm.contains(core) {
883 return true;
884 }
885 let stripped_stereo = name_norm
889 .replace("(-)", "")
890 .replace("(+)", "")
891 .replace("(±)", "")
892 .replace("(R)", "")
893 .replace("(S)", "")
894 .replace("(d)", "")
895 .replace("(l)", "");
896 if stripped_stereo.len() >= 8 && src_norm.contains(stripped_stereo.as_str()) {
897 return true;
898 }
899 false
900}
901
902fn source_contains(source: &str, value: &str) -> bool {
908 let v = value.trim();
909 if v.len() < 3 {
910 return true; }
912 if source.contains(v) {
913 return true;
914 }
915 let v_norm = normalize_for_search(v);
918 let src_norm = normalize_for_search(source);
919 if v_norm.len() >= 3 {
920 src_norm.contains(&v_norm)
921 } else {
922 false
923 }
924}
925
926fn normalize_for_search(s: &str) -> String {
932 s.chars()
933 .filter(|c| !c.is_whitespace())
934 .map(|c| match c {
935 ',' => ',',
937 '、' => ',', '.' => '.',
939 '。' => '.',
940 '-' => '-',
941 '(' => '(',
942 ')' => ')',
943 '異' => '异', '環' => '环', '鹼' => '碱', '鹽' => '盐', '鋰' => '锂', '鈉' => '钠', '鉀' => '钾', '鐵' => '铁', '銅' => '铜', '鋁' => '铝', '銀' => '银', '鋅' => '锌', '氫' => '氢', '氯' => '氯', '無' => '无', '劑' => '剂', '酸' => '酸', '烴' => '烃', _ => c,
963 })
964 .collect()
965}
966
967fn has_kana(s: &str) -> bool {
969 s.chars().any(|c| matches!(c, '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}'))
970}
971
972fn source_contains_cas(source: &str, cas: &str) -> bool {
976 if source.contains(cas) {
977 return true;
978 }
979 let fullwidth = cas.replace('-', "-");
981 if source.contains(&fullwidth) {
982 return true;
983 }
984 let cas_norm = normalize_for_search(cas);
986 let src_norm = normalize_for_search(source);
987 src_norm.contains(&cas_norm)
988}
989
990fn truncate(s: &str, max: usize) -> &str {
992 let mut idx = max;
993 while !s.is_char_boundary(idx) && idx > 0 {
994 idx -= 1;
995 }
996 if idx < s.len() { &s[..idx] } else { s }
997}
998
999#[cfg(test)]
1004mod tests {
1005 use super::*;
1006
1007 #[test]
1008 fn test_looks_like_date_valid() {
1009 assert!(looks_like_date("2024-03-15"));
1010 assert!(looks_like_date("2024/03/15"));
1011 assert!(looks_like_date("令和6年3月15日"));
1012 assert!(looks_like_date("20240315"));
1013 }
1014
1015 #[test]
1016 fn test_looks_like_date_invalid() {
1017 assert!(!looks_like_date("不明"));
1018 assert!(!looks_like_date("N/A"));
1019 assert!(!looks_like_date("改訂"));
1020 }
1021
1022 #[test]
1023 fn test_source_contains_cas_ascii_hyphen() {
1024 let source = "成分: エタノール CAS No. 64-17-5 含有量 99%";
1025 assert!(source_contains_cas(source, "64-17-5"));
1026 }
1027
1028 #[test]
1029 fn test_source_contains_cas_fullwidth_hyphen() {
1030 let source = "CAS No. 64-17-5";
1031 assert!(source_contains_cas(source, "64-17-5"));
1032 }
1033
1034 #[test]
1035 fn test_source_contains_cas_not_present() {
1036 let source = "CAS No. 7732-18-5";
1037 assert!(!source_contains_cas(source, "64-17-5"));
1038 }
1039
1040 #[test]
1041 fn test_source_contains_short_value_always_passes() {
1042 let source = "no matches here";
1043 assert!(source_contains(source, "AB")); }
1045
1046 #[test]
1047 fn test_truncate_ascii() {
1048 assert_eq!(truncate("hello world", 5), "hello");
1049 }
1050
1051 #[test]
1052 fn test_truncate_multibyte() {
1053 let s = "エタノール"; let t = truncate(s, 6); assert!(s.starts_with(t));
1056 }
1057}