Skip to main content

sdsforge_core/converter/
validator.rs

1use crate::country::SourceCountry;
2use crate::ghs_codes;
3use crate::schema::{HazardIdentificationClassification, SdsRoot};
4
5// ---------------------------------------------------------------------------
6// Typed finding
7// ---------------------------------------------------------------------------
8
9/// A structured validation finding.
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
11pub struct Finding {
12    pub level:   String,   // "CRIT" | "HIGH" | "MED" | "LOW" | "WARN"
13    pub rule:    String,   // e.g. "S2-GHS-INCOMPLETE", "STRUCTURAL", "SourceVerify"
14    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
29/// Parse a single warning string into a typed [`Finding`].
30fn parse_finding(w: &str) -> Finding {
31    // [LEVEL][RULE] message
32    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            // [SourceVerify] / [China] / [Korea] / [Taiwan]
46            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    // plain structural warning
54    Finding {
55        level:   "WARN".to_string(),
56        rule:    "STRUCTURAL".to_string(),
57        message: w.to_string(),
58    }
59}
60
61/// Typed variant of [`validate`]. Returns structured [`Finding`]s instead of raw strings.
62pub fn validate_typed(sds: &SdsRoot) -> Vec<Finding> {
63    validate(sds).iter().map(|s| parse_finding(s)).collect()
64}
65
66// ── Phase 1 helpers ──────────────────────────────────────────────────────────
67
68/// Returns true if `s` looks like a date (contains a 4-digit year or a Japanese era year).
69fn looks_like_date(s: &str) -> bool {
70    let s = s.trim();
71    // Western year: 4-digit (YYYY) or 8-digit (YYYYMMDD) sequence starting with 1 or 2.
72    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    // Japanese era date: contains "年" (year kanji) and at least one digit.
81    if s.contains('年') && s.chars().any(|c| c.is_ascii_digit()) {
82        return true;
83    }
84    false
85}
86
87/// Placeholder / generic values that should not appear as product names.
88const 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
98/// "非危険物" を意味する分類値。これらだけが存在する場合は実質的な危険分類なし。
99const NOT_CLASSIFIED_PATTERNS: &[&str] = &[
100    "not classified", "not applicable", "n/a", "na",
101    "分類できない", "分類対象外", "区分外", "該当なし", "データなし",
102    "不适用", "不分类", "无资料",
103    // 「分類不明 / 分类不明」= data unavailable, treat same as not-classified
104    "分類不明", "分类不明",
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
112/// Returns true if Classification contains at least one real hazard category
113/// (i.e., at least one field that is not empty / "not classified" / "not applicable").
114fn 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
121/// Collect every string leaf in a serde_json::Value tree into `out`.
122fn 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
131/// Performs post-deserialization structural checks on all 16 SDS sections.
132/// Does not hard-fail so partial results are still usable.
133pub 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    // ── Section 1: Identification ─────────────────────────────────────────────
146    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    // ── Section 2: HazardIdentification ──────────────────────────────────────
164    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                // GHS H-code validation
172                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                // GHS P-code validation
185                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    // ── Section 3: Composition ────────────────────────────────────────────────
224    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            // CAS number format and check-digit validation
232            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    // ── Section 4: FirstAidMeasures ───────────────────────────────────────────
262    if sds.first_aid_measures.is_none() {
263        missing!("Section 4 (FirstAidMeasures)");
264    }
265
266    // ── Section 5: FireFightingMeasures ───────────────────────────────────────
267    if sds.fire_fighting_measures.is_none() {
268        missing!("Section 5 (FireFightingMeasures)");
269    }
270
271    // ── Section 6: AccidentalReleaseMeasures ─────────────────────────────────
272    if sds.accidental_release_measures.is_none() {
273        missing!("Section 6 (AccidentalReleaseMeasures)");
274    }
275
276    // ── Section 7: HandlingAndStorage ────────────────────────────────────────
277    if sds.handling_and_storage.is_none() {
278        missing!("Section 7 (HandlingAndStorage)");
279    }
280
281    // ── Section 8: ExposureControlPersonalProtection ─────────────────────────
282    if sds.exposure_control_personal_protection.is_none() {
283        missing!("Section 8 (ExposureControlPersonalProtection)");
284    }
285
286    // ── Section 9: PhysicalChemicalProperties ────────────────────────────────
287    if sds.physical_chemical_properties.is_none() {
288        missing!("Section 9 (PhysicalChemicalProperties)");
289    }
290
291    // ── Section 10: StabilityReactivity ──────────────────────────────────────
292    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    // ── Section 11: ToxicologicalInformation ─────────────────────────────────
302    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    // ── Section 12: EcologicalInformation ────────────────────────────────────
311    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    // ── Section 13: DisposalConsiderations ───────────────────────────────────
320    if sds.disposal_considerations.is_none() {
321        missing!("Section 13 (DisposalConsiderations)");
322    }
323
324    // ── Section 14: TransportInformation ─────────────────────────────────────
325    if sds.transport_information.is_none() {
326        missing!("Section 14 (TransportInformation)");
327    }
328
329    // ── Section 15: RegulatoryInformation ────────────────────────────────────
330    if sds.regulatory_information.is_none() {
331        missing!("Section 15 (RegulatoryInformation)");
332    }
333
334    // ── Section 16: OtherInformation ─────────────────────────────────────────
335    if sds.other_information.is_none() {
336        missing!("Section 16 (OtherInformation)");
337    }
338
339    // ── Phase 1: cross-section consistency & value sanity ────────────────────
340
341    // Date format: IssueDate / RevisionDate
342    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    // ProductName quality
356    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    // Concentration range: numeric % values should be 0-100
374    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    // Cross-consistency: real hazard Classification present but HazardStatement absent.
412    // Skip non-hazardous substances (all categories "not classified" / "not applicable").
413    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    // ── MHLW §3 compliance rules ─────────────────────────────────────────────
435
436    // [HIGH] S2-GHS-INCOMPLETE: H-codes present but GHS labelling elements missing.
437    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    // [HIGH] S3-CAS-WITHOUT-NAME / S3-NAME-WITHOUT-CAS.
471    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    // [HIGH] S14-UN-INCOMPLETE: UN number present but required transport fields missing.
516    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) = &reg_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    // [MED] S2-DUPLICATE-HCODE / S2-DUPLICATE-PCODE.
540    if let Some(hz) = &sds.hazard_identification {
541        if let Some(hl) = &hz.hazard_labelling {
542            // H-codes
543            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            // P-codes — each sub-list has a distinct concrete type; collect codes separately.
559            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    // [MED] S9-VALUE-WITHOUT-UNIT: numeric physical property value without a unit.
586    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
594/// Walk a JSON value tree and warn for every `NumericRangeWithUnitAndQualifier`-shaped
595/// object that has a numeric value but no `Unit` field.
596fn 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// ---------------------------------------------------------------------------
629// Typed findings for the correction pass
630// ---------------------------------------------------------------------------
631
632/// Which sub-list a P-code belongs to within PrecautionaryStatements.
633#[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/// An actionable validation finding with enough context to apply a correction.
653///
654/// Unlike the string-based [`validate()`] warnings, these carry the structured
655/// field path and the pre-computed correct value so [`crate::converter::corrector`]
656/// can apply fixes without re-parsing warning messages.
657#[derive(Debug, Clone)]
658pub enum ValidationFinding {
659    /// An H-code in `HazardLabelling.HazardStatement` is not in the GHS H-code list.
660    UnknownHCode {
661        /// The invalid code as extracted (e.g. `"H999"`).
662        code: String,
663        /// Full text of the hazard statement (for LLM context).
664        full_text: Option<String>,
665        /// Zero-based index into `HazardStatement[]`.
666        statement_index: usize,
667    },
668    /// A P-code in `PrecautionaryStatements` is not in the GHS P-code list.
669    UnknownPCode {
670        /// The invalid code as extracted (e.g. `"P286"`).
671        code: String,
672        /// Full text of the precautionary statement (for LLM context).
673        full_text: Option<String>,
674        /// Which sub-list the entry belongs to.
675        category: PCodeCategory,
676        /// Zero-based index within that sub-list.
677        statement_index: usize,
678    },
679    /// A CAS number whose check digit does not match; `expected_digit` is already computed.
680    CasCheckDigit {
681        /// The CAS string with the wrong check digit (e.g. `"238016-30-4"`).
682        cas: String,
683        /// Zero-based index into `CompositionAndConcentration[]`.
684        composition_index: usize,
685        /// The digit the weighted-sum algorithm requires.
686        expected_digit: u32,
687    },
688}
689
690/// Collect actionable [`ValidationFinding`]s for Sections 2 (GHS codes) and 3 (CAS).
691///
692/// These are the findings that [`crate::converter::corrector::apply_correction_pass`]
693/// can act on.  Structural presence-check warnings (missing sections, missing supplier
694/// info, etc.) are **not** included here because they cannot be corrected without
695/// regenerating the entire section.
696///
697/// This function is independent of [`validate()`] and can be called before or after
698/// it without affecting the existing warning pipeline.
699pub fn collect_findings(sds: &SdsRoot) -> Vec<ValidationFinding> {
700    let mut findings: Vec<ValidationFinding> = Vec::new();
701
702    // ── Section 2: H-codes ────────────────────────────────────────────────────
703    if let Some(hz) = &sds.hazard_identification {
704        if let Some(hl) = &hz.hazard_labelling {
705            // H-codes
706            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            // P-codes — each sub-list has a distinct concrete type, so we use a
720            // macro to avoid repeating identical logic four times.
721            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    // ── Section 3: CAS check-digit mismatches ─────────────────────────────────
749    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/// Detailed result of CAS Registry Number validation.
774#[derive(Debug, PartialEq)]
775pub(crate) enum CasValidation {
776    /// The CAS number passes both format and check-digit validation.
777    Ok,
778    /// The string does not match the expected format (`\d{2,7}-\d{2}-\d`).
779    InvalidFormat,
780    /// The format is correct but the check digit is wrong.
781    InvalidCheckDigit {
782        /// The check digit value that the weighted-sum algorithm requires.
783        expected: u32,
784    },
785}
786
787/// Validate a CAS Registry Number, returning a detailed [`CasValidation`] result.
788///
789/// Format: `^\d{2,7}-\d{2}-\d$`
790/// Check digit: weighted sum of all non-check digits (right-to-left, weight starts at 1)
791/// modulo 10 must equal the supplied check digit.
792pub(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    // Format check
799    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    // Both unwraps are guaranteed by the format check above.
810    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
835/// Convenience wrapper — returns `true` only when both format and check digit are valid.
836///
837/// Kept for use by [`crate::enrichment`] which only needs a boolean result.
838pub(crate) fn validate_cas_format(cas: &str) -> bool {
839    validate_cas(cas) == CasValidation::Ok
840}
841
842// ---------------------------------------------------------------------------
843// Phase 2: source-text verification (zero LLM calls)
844// ---------------------------------------------------------------------------
845
846/// Check whether key extracted values can be found verbatim in the source text.
847///
848/// This is a pure, deterministic pass — no API calls.  Values that are too
849/// short (< 3 chars), match a placeholder pattern, or are otherwise
850/// un-verifiable are silently skipped.
851///
852/// Returns human-readable warnings for each value that could not be located in
853/// `source_text`.  These warnings should be appended to the conversion report
854/// alongside the `validate()` warnings.
855/// Country-specific validation checks run after the universal `validate()` pass.
856///
857/// Returns a list of warning strings prefixed with `[China]`, `[Korea]`, etc.
858/// so they are distinguishable in the conversion report.
859pub 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
901/// Returns true if any EmergencyContact entry with a non-empty Phone field is present.
902fn has_emergency_contact(sds: &SdsRoot) -> bool {
903    let id = match sds.identification.as_ref() { Some(v) => v, None => return false };
904    // Check DomesticManufacturerInformation.EmergencyContact
905    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    // Check SupplierInformation.EmergencyContact
913    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
923/// Returns true if RegulatoryInformation contains any of the given keywords.
924fn regulatory_mentions_keyword(sds: &SdsRoot, keywords: &[&str]) -> bool {
925    let ri = match sds.regulatory_information.as_ref() { Some(v) => v, None => return false };
926    // Serialize to JSON and do a string search — simpler than traversing the deeply nested schema.
927    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    // ── ProductName ──────────────────────────────────────────────────────────
935    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                        // Extra hint when TradeNameJP is absent from a kana-free source:
945                        // the LLM likely invented a Japanese name (kana or kanji) that
946                        // does not appear anywhere in the source document.
947                        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    // ── CAS numbers ─────────────────────────────────────────────────────────
964    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                            // Only check format-valid CAS numbers; malformed ones
976                            // (e.g. "无资料") are already reported by validate().
977                            // Skip CAS values that were deterministically corrected by the
978                            // corrector — the source PDF has the wrong digit, so a match
979                            // against the corrected value would always fail.
980                            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    // ── Substance names ──────────────────────────────────────────────────────
997    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    // ── Language-mismatch: Japanese kana in non-Japanese source ──────────────
1029    // If any name field contains hiragana/katakana but the source document has
1030    // none, the LLM has fabricated a Japanese transliteration that isn't in the
1031    // source — a hallucination pattern distinct from "not found verbatim".
1032    if !source_has_kana {
1033        // TradeNameJP with kana
1034        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        // IupacName / GenericName with kana
1049        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        // SupplierInformation.CompanyName with kana
1078        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
1097// ---------------------------------------------------------------------------
1098// Source-text search helpers
1099// ---------------------------------------------------------------------------
1100
1101/// Product-name-specific source check.
1102///
1103/// For short names (≤ 40 chars) the full value must appear. For long names — e.g.
1104/// UN transport names that span multiple PDF lines with intervening labels — only
1105/// the first 30 characters need to be found. This avoids false positives where a
1106/// PDF inserts a section label ("英文名称:") in the middle of a long name.
1107fn 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        // For long names (UN names, IUPAC names that span PDF lines), verify only
1114        // the "base" segment — the text up to the first delimiter that commonly
1115        // introduces sub-clauses in long names: '(', ',', ';', '('.
1116        // Falls back to the first 25 chars if no delimiter appears early enough.
1117        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); // always verify at least 8 chars
1123        let prefix: String = chars[..cut].iter().collect();
1124        if source_contains(source, prefix.trim()) {
1125            return true;
1126        }
1127    }
1128    // Strip leading positional-number prefix (e.g. "1,1,2,3,4,4-" in IUPAC names like
1129    // "1,1,2,3,4,4-六氯-1,3-丁二烯") and verify the core name in the source.
1130    // Sources sometimes print only the short form without position descriptors.
1131    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    // Strip optical-rotation / stereodescriptor markers that CID-font PDFs often
1138    // split across lines, causing the marker to disappear from extracted text.
1139    // e.g. "イソプロピルβD(-)チオガラクトピラノシド" → "イソプロピルβDチオガラクトピラノシド"
1140    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
1154/// Check if `value` appears as a substring of `source` (after whitespace normalization).
1155///
1156/// Skips values shorter than 3 characters to avoid trivial false-positives.
1157/// Falls back to a space-collapsed comparison to handle CID-font PDFs that insert
1158/// stray spaces inside words (e.g. "エ チル" vs "エチル").
1159fn source_contains(source: &str, value: &str) -> bool {
1160    let v = value.trim();
1161    if v.len() < 3 {
1162        return true; // too short to verify reliably
1163    }
1164    if source.contains(v) {
1165        return true;
1166    }
1167    // Collapse whitespace + normalize fullwidth punctuation, then retry.
1168    // Handles CID-font artifacts (stray spaces, fullwidth commas/hyphens).
1169    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
1178/// Normalize a string for fuzzy source-text matching:
1179/// - Remove all whitespace
1180/// - Convert fullwidth ASCII punctuation (,、。.-) to their halfwidth equivalents
1181/// - Map common traditional Chinese characters to their simplified equivalents so that
1182///   LLM output using trad. chars still matches simplified-Chinese source PDFs (and vice versa).
1183fn normalize_for_search(s: &str) -> String {
1184    s.chars()
1185        .filter(|c| !c.is_whitespace())
1186        .map(|c| match c {
1187            // Fullwidth / Japanese punctuation → ASCII
1188            ',' => ',',
1189            '、' => ',',  // Japanese ideographic comma (U+3001)
1190            '.' => '.',
1191            '。' => '.',
1192            '-' => '-',
1193            '(' => '(',
1194            ')' => ')',
1195            // Traditional → Simplified (common in chemical/SDS names)
1196            '異' => '异',  // iso- prefix (异丙基, 异庚烷, …)
1197            '環' => '环',  // ring/cycle (环氧, 环己烷, …)
1198            '鹼' => '碱',  // alkali
1199            '鹽' => '盐',  // salt
1200            '鋰' => '锂',  // Li
1201            '鈉' => '钠',  // Na
1202            '鉀' => '钾',  // K
1203            '鐵' => '铁',  // Fe
1204            '銅' => '铜',  // Cu
1205            '鋁' => '铝',  // Al
1206            '銀' => '银',  // Ag
1207            '鋅' => '锌',  // Zn
1208            '氫' => '氢',  // hydrogen
1209            '氯' => '氯',  // chlorine (same)
1210            '無' => '无',  // none/not
1211            '劑' => '剂',  // agent/reagent suffix
1212            '酸' => '酸',  // acid (same)
1213            '烴' => '烃',  // hydrocarbon
1214            _ => c,
1215        })
1216        .collect()
1217}
1218
1219/// Returns true if `s` contains any hiragana (U+3040–U+309F) or katakana (U+30A0–U+30FF).
1220fn has_kana(s: &str) -> bool {
1221    s.chars().any(|c| matches!(c, '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}'))
1222}
1223
1224/// CAS-aware source search: also tries full-width hyphen variants and space-collapsed
1225/// forms because Japanese PDFs sometimes render hyphens as U+FF0D (-) or insert
1226/// stray spaces inside CAS numbers.
1227fn source_contains_cas(source: &str, cas: &str) -> bool {
1228    if source.contains(cas) {
1229        return true;
1230    }
1231    // Full-width hyphens (U+FF0D).
1232    let fullwidth = cas.replace('-', "-");
1233    if source.contains(&fullwidth) {
1234        return true;
1235    }
1236    // Normalized fallback: collapse spaces + fullwidth punctuation.
1237    let cas_norm = normalize_for_search(cas);
1238    let src_norm = normalize_for_search(source);
1239    src_norm.contains(&cas_norm)
1240}
1241
1242/// Truncate a string to `max` chars for warning messages.
1243fn 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// ---------------------------------------------------------------------------
1252// Unit tests
1253// ---------------------------------------------------------------------------
1254
1255#[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")); // len < 3 → skip
1296    }
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 = "エタノール"; // 5 chars, 15 bytes
1306        let t = truncate(s, 6); // 6 bytes → "エタ" (each is 3 bytes)
1307        assert!(s.starts_with(t));
1308    }
1309}