Skip to main content

moine_ja/
unidic.rs

1use std::borrow::Cow;
2use std::collections::{btree_map::Entry, BTreeSet, HashMap};
3use std::error::Error;
4use std::fmt;
5use std::fmt::Write as _;
6use std::fs::File;
7use std::io::{Read, Write};
8use std::path::Path;
9use std::string::FromUtf8Error;
10use std::sync::Arc;
11
12use fst::{Map, MapBuilder, Streamer};
13use memmap2::Mmap;
14use moine_core::Lattice;
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18use crate::romaji::{
19    can_build_direct_romaji_path, romaji_paths_from_segmented_readings,
20    romaji_symbol_paths_from_segmented_readings, JaLatticeError, RomajiSegmentMode,
21};
22
23const SURFACE_COLUMN: usize = 0;
24const POS1_COLUMN: usize = 4;
25const LFORM_COLUMN: usize = 10;
26const PRON_COLUMN: usize = 13;
27const ARTIFACT_PAYLOAD_SCHEMA_VERSION: u32 = 1;
28const ARTIFACT_PAYLOAD_TYPE: &str = "moine.unidic.reading-index.surface-readings";
29const BINARY_ARTIFACT_MAGIC: &[u8; 8] = b"MOINEU01";
30const BINARY_ARTIFACT_VERSION: u32 = 1;
31const INDEXED_ARTIFACT_MAGIC: &[u8; 8] = b"MOINEI01";
32const INDEXED_ARTIFACT_VERSION: u32 = 1;
33const INDEXED_ARTIFACT_HEADER_LEN: usize = 40;
34const MAX_ARTIFACT_PAYLOAD_BYTES: u64 = 512 * 1024 * 1024;
35const MAX_ARTIFACT_ENTRIES: usize = 3_000_000;
36const MAX_ARTIFACT_READINGS_PER_ENTRY: usize = 256;
37const MAX_ARTIFACT_STRING_BYTES: usize = 16 * 1024;
38const DEFAULT_QUERY_SPAN_CHARS: usize = 8;
39const DEFAULT_QUERY_PATHS: usize = 1024;
40// Query expansion is clone-heavy; keep hard caps as bounded multiples of the
41// defaults so malformed metadata cannot silently request unbounded work.
42const MAX_QUERY_SPAN_CHARS: usize = DEFAULT_QUERY_SPAN_CHARS * 32;
43const MAX_QUERY_PATHS: usize = DEFAULT_QUERY_PATHS * 16;
44const MAX_QUERY_READINGS_PER_SEGMENT: usize = MAX_ARTIFACT_READINGS_PER_ENTRY;
45/// Current canonical checksum algorithm for normalized UniDic payload content.
46pub const ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM: &str = "sha256-canonical-v1";
47/// Legacy canonical checksum algorithm accepted for older UniDic artifacts.
48pub const LEGACY_ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM: &str = "fnv1a64-canonical-v1";
49/// File digest algorithm used to verify payload bytes before loading.
50pub const ARTIFACT_PAYLOAD_FILE_DIGEST_ALGORITHM: &str = "sha256-file-v1";
51
52/// UniDic-derived surface-to-reading index.
53#[derive(Clone, Debug)]
54pub struct UnidicReadingIndex {
55    storage: UnidicReadingStorage,
56}
57
58#[derive(Clone, Debug)]
59enum UnidicReadingStorage {
60    Eager(HashMap<String, Vec<String>>),
61    Indexed(IndexedUnidicPayload),
62}
63
64impl Default for UnidicReadingIndex {
65    fn default() -> Self {
66        Self {
67            storage: UnidicReadingStorage::Eager(HashMap::new()),
68        }
69    }
70}
71
72impl PartialEq for UnidicReadingIndex {
73    fn eq(&self, other: &Self) -> bool {
74        self.artifact_payload() == other.artifact_payload()
75    }
76}
77
78impl Eq for UnidicReadingIndex {}
79
80#[derive(Clone, Debug)]
81struct IndexedUnidicPayload {
82    mmap: Arc<Mmap>,
83    map: Map<Vec<u8>>,
84    readings_start: usize,
85    entries: usize,
86}
87
88/// Header for indexed FST UniDic payloads.
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub struct UnidicIndexedArtifactPayloadHeader {
91    /// Indexed payload format version.
92    pub version: u32,
93    /// Number of entries in the payload.
94    pub entries: usize,
95    /// Length of the embedded FST section in bytes.
96    pub fst_len: usize,
97    /// Length of the reading blob section in bytes.
98    pub readings_len: usize,
99}
100
101/// Header for legacy binary UniDic payloads.
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103pub struct UnidicBinaryArtifactPayloadHeader {
104    /// Binary payload format version.
105    pub version: u32,
106    /// Number of entries in the payload.
107    pub entries: usize,
108}
109
110/// Controls dictionary reading-path expansion.
111#[derive(Clone, Copy, Debug, Eq, PartialEq)]
112pub struct DictionaryReadingOptions {
113    /// Maximum surface span length considered for one dictionary segment.
114    pub max_span_chars: usize,
115    /// Maximum complete reading paths to keep.
116    pub max_paths: usize,
117    /// Prefer the longest dictionary span when multiple spans start together.
118    pub longest_match_only: bool,
119    /// Optional cap on readings used per dictionary segment.
120    pub max_readings_per_segment: Option<usize>,
121}
122
123/// One surface segment and its selected UniDic reading.
124#[derive(Clone, Debug, Eq, PartialEq)]
125pub struct DictionaryReadingSegment {
126    /// Surface text covered by the segment.
127    pub surface: String,
128    /// Reading selected for the segment.
129    pub reading: String,
130    /// Whether the segment came from the dictionary or direct surface fallback.
131    pub source: DictionaryReadingSegmentSource,
132}
133
134/// Source of one Japanese reading-path segment.
135#[derive(Clone, Copy, Debug, Eq, PartialEq)]
136pub enum DictionaryReadingSegmentSource {
137    /// Segment was backed by a dictionary entry.
138    Dictionary,
139    /// Segment was copied from direct kana/romaji surface fallback.
140    Direct,
141}
142
143/// One complete segmentation and joined reading for an input string.
144#[derive(Clone, Debug, Eq, PartialEq)]
145pub struct DictionaryReadingPath {
146    /// Ordered dictionary/direct segments in the path.
147    pub segments: Vec<DictionaryReadingSegment>,
148    /// Segment readings concatenated into one reading string.
149    pub joined_reading: String,
150}
151
152/// Reading-path expansion result plus pruning statistics.
153#[derive(Clone, Debug, Default, Eq, PartialEq)]
154pub struct DictionaryReadingExpansion {
155    /// Expanded reading paths.
156    pub paths: Vec<DictionaryReadingPath>,
157    /// Statistics gathered during expansion.
158    pub stats: DictionaryReadingStats,
159}
160
161/// Counters describing dictionary reading-path expansion.
162#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
163pub struct DictionaryReadingStats {
164    /// Dictionary spans matched during expansion.
165    pub matched_spans: usize,
166    /// Direct fallback spans used when no dictionary span matched.
167    pub direct_fallback_spans: usize,
168    /// Candidate spans pruned by longest-match mode.
169    pub longest_match_pruned_spans: usize,
170    /// Raw readings seen before per-segment pruning.
171    pub raw_segment_readings: usize,
172    /// Readings retained after per-segment pruning.
173    pub used_segment_readings: usize,
174    /// Readings removed by per-segment pruning.
175    pub pruned_segment_readings: usize,
176    /// Candidate path combinations considered.
177    pub candidate_combinations: usize,
178    /// Unique complete reading paths retained.
179    pub unique_paths: usize,
180    /// Duplicate joined readings removed.
181    pub duplicate_joined_readings: usize,
182    /// Number of times the `max_paths` cap was hit.
183    pub max_paths_hit_count: usize,
184}
185
186/// Builds a compact romaji lattice from dictionary reading paths.
187pub fn romaji_lattice_from_reading_paths(
188    paths: &[DictionaryReadingPath],
189) -> Result<Lattice, JaLatticeError> {
190    if paths.is_empty() {
191        return Err(JaLatticeError::EmptyReadings);
192    }
193
194    let paths = romaji_symbol_paths_from_segmented_readings(
195        paths
196            .iter()
197            .map(|path| path.segments.iter().map(romaji_segment_input)),
198    )?;
199    Ok(Lattice::from_symbol_paths_compact(paths))
200}
201
202/// Expands dictionary reading paths into explicit romaji strings.
203pub fn romaji_paths_from_reading_paths(
204    paths: &[DictionaryReadingPath],
205) -> Result<Vec<String>, JaLatticeError> {
206    if paths.is_empty() {
207        return Err(JaLatticeError::EmptyReadings);
208    }
209
210    romaji_paths_from_segmented_readings(
211        paths
212            .iter()
213            .map(|path| path.segments.iter().map(romaji_segment_input)),
214    )
215}
216
217fn romaji_segment_input(segment: &DictionaryReadingSegment) -> (&str, RomajiSegmentMode) {
218    let mode = match segment.source {
219        DictionaryReadingSegmentSource::Dictionary => RomajiSegmentMode::Reading,
220        DictionaryReadingSegmentSource::Direct => RomajiSegmentMode::Surface,
221    };
222    (segment.reading.as_str(), mode)
223}
224
225/// UniDic CSV field used as the source reading.
226#[derive(Clone, Copy, Debug, Eq, PartialEq)]
227pub enum UnidicReadingField {
228    /// Lemma-form reading column.
229    LForm,
230    /// Pronunciation column.
231    Pron,
232}
233
234/// Metadata stored in a UniDic dictionary bundle.
235#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
236pub struct UnidicArtifactMetadata {
237    /// Metadata schema version.
238    pub schema_version: u32,
239    /// Artifact type identifier.
240    pub artifact_type: String,
241    /// Human-readable artifact name.
242    pub artifact_name: String,
243    /// Tool or command that generated the artifact.
244    pub generator: String,
245    /// Payload file metadata.
246    pub payload: UnidicArtifactPayload,
247    /// Source dictionary metadata.
248    pub source: UnidicArtifactSource,
249    /// Build-time options and counts.
250    pub build: UnidicArtifactBuild,
251    /// Default query options for this artifact.
252    pub query_defaults: UnidicArtifactQueryDefaults,
253    /// License metadata and references.
254    pub license: UnidicArtifactLicense,
255}
256
257/// Payload file metadata for a UniDic dictionary bundle.
258#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
259pub struct UnidicArtifactPayload {
260    /// Bundle-relative payload file path.
261    pub path: String,
262    /// Payload serialization format.
263    pub format: String,
264    /// Optional digest algorithm for the raw payload file.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub file_digest_algorithm: Option<String>,
267    /// Optional digest of the raw payload file.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub file_digest: Option<String>,
270    /// Canonical payload checksum algorithm.
271    pub checksum_algorithm: String,
272    /// Canonical payload checksum.
273    pub checksum: String,
274}
275
276/// Source dictionary metadata for a UniDic artifact.
277#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
278pub struct UnidicArtifactSource {
279    /// Source dictionary name.
280    pub name: String,
281    /// Source dictionary version.
282    pub version: String,
283    /// Source `lex.csv` path used to build the artifact.
284    pub lex_csv: String,
285}
286
287/// Build settings and counts recorded in UniDic artifact metadata.
288#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
289pub struct UnidicArtifactBuild {
290    /// UniDic reading field used for entries.
291    pub reading_field: String,
292    /// Optional cap applied to readings stored per surface.
293    pub max_readings_per_surface: Option<usize>,
294    /// Whether ASCII-only surfaces were excluded.
295    pub exclude_ascii_surfaces: bool,
296    /// Whether symbol part-of-speech entries were excluded.
297    pub exclude_symbol_pos: bool,
298    /// Whether source normalized-form aliases were added as lookup surfaces.
299    #[serde(default, skip_serializing_if = "is_false")]
300    pub include_normalized_surfaces: bool,
301    /// Whether readings unsupported by the romaji converter were excluded.
302    #[serde(default, skip_serializing_if = "is_false")]
303    pub exclude_unsupported_readings: bool,
304    /// Number of entries in the generated payload.
305    pub entries: usize,
306}
307
308/// Default reading-path query settings stored in an artifact.
309#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
310pub struct UnidicArtifactQueryDefaults {
311    /// Maximum surface span length considered for one segment.
312    pub max_span_chars: usize,
313    /// Maximum complete reading paths to keep.
314    pub max_paths: usize,
315    /// Whether longest-match-only expansion should be used by default.
316    pub longest_match_only: bool,
317    /// Optional cap on readings used per segment.
318    pub max_readings_per_segment: Option<usize>,
319}
320
321/// License metadata for a UniDic-derived artifact.
322#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
323pub struct UnidicArtifactLicense {
324    /// Selected license label for the artifact.
325    pub selected_license: String,
326    /// Bundle-relative license or notice files.
327    pub references: Vec<UnidicArtifactLicenseReference>,
328}
329
330/// One license or notice file referenced by artifact metadata.
331#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
332pub struct UnidicArtifactLicenseReference {
333    /// Human-readable reference label.
334    pub label: String,
335    /// Bundle-relative file path.
336    pub path: String,
337}
338
339/// Portable YAML representation of a UniDic reading index.
340#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
341pub struct UnidicReadingIndexPayload {
342    /// Payload schema version.
343    pub schema_version: u32,
344    /// Payload type identifier.
345    pub payload_type: String,
346    /// Surface entries and readings.
347    pub entries: Vec<UnidicReadingIndexPayloadEntry>,
348}
349
350/// One surface entry in a UniDic reading-index payload.
351#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
352pub struct UnidicReadingIndexPayloadEntry {
353    /// Surface form.
354    pub surface: String,
355    /// Readings associated with the surface form.
356    pub readings: Vec<String>,
357}
358
359/// Inputs used to generate artifact metadata for an index.
360#[derive(Clone, Debug, Eq, PartialEq)]
361pub struct UnidicArtifactMetadataOptions {
362    /// Human-readable artifact name.
363    pub artifact_name: String,
364    /// Tool or command that generated the artifact.
365    pub generator: String,
366    /// Bundle-relative payload file name.
367    pub payload_file_name: String,
368    /// Payload serialization format.
369    pub payload_format: String,
370    /// Source dictionary name.
371    pub source_name: String,
372    /// Source dictionary version.
373    pub source_version: String,
374    /// Source `lex.csv` path.
375    pub source_lex_csv: String,
376    /// Index build settings.
377    pub index_options: UnidicIndexOptions,
378    /// Default query settings.
379    pub query_defaults: DictionaryReadingOptions,
380    /// License metadata and references.
381    pub license: UnidicArtifactLicense,
382}
383
384/// Options used while building a UniDic reading index.
385#[derive(Clone, Copy, Debug, Eq, PartialEq)]
386pub struct UnidicIndexOptions {
387    /// UniDic CSV field used as the source reading.
388    pub reading_field: UnidicReadingField,
389    /// Optional cap on readings stored for each surface form.
390    pub max_readings_per_surface: Option<usize>,
391    /// Exclude ASCII-only dictionary surfaces.
392    pub exclude_ascii_surfaces: bool,
393    /// Exclude entries whose coarse part of speech is a symbol.
394    pub exclude_symbol_pos: bool,
395}
396
397impl UnidicReadingField {
398    fn column(self) -> usize {
399        match self {
400            Self::LForm => LFORM_COLUMN,
401            Self::Pron => PRON_COLUMN,
402        }
403    }
404
405    /// Returns the stable artifact string for this reading field.
406    pub fn as_str(self) -> &'static str {
407        match self {
408            Self::LForm => "lform",
409            Self::Pron => "pron",
410        }
411    }
412}
413
414impl Default for UnidicIndexOptions {
415    fn default() -> Self {
416        Self {
417            reading_field: UnidicReadingField::Pron,
418            max_readings_per_surface: None,
419            exclude_ascii_surfaces: true,
420            exclude_symbol_pos: true,
421        }
422    }
423}
424
425impl Default for DictionaryReadingOptions {
426    fn default() -> Self {
427        Self {
428            max_span_chars: DEFAULT_QUERY_SPAN_CHARS,
429            max_paths: DEFAULT_QUERY_PATHS,
430            longest_match_only: false,
431            max_readings_per_segment: None,
432        }
433    }
434}
435
436impl DictionaryReadingOptions {
437    /// Validates expansion options before they are used at public or metadata
438    /// trust boundaries.
439    pub fn validate(self) -> Result<Self, UnidicArtifactPayloadError> {
440        check_limit("max_span_chars", self.max_span_chars, MAX_QUERY_SPAN_CHARS)?;
441        check_limit("max_paths", self.max_paths, MAX_QUERY_PATHS)?;
442        if let Some(max_readings) = self.max_readings_per_segment {
443            check_limit(
444                "max_readings_per_segment",
445                max_readings,
446                MAX_QUERY_READINGS_PER_SEGMENT,
447            )?;
448        }
449        Ok(self)
450    }
451}
452
453impl Default for UnidicArtifactLicense {
454    fn default() -> Self {
455        Self {
456            selected_license: "BSD-3-Clause".to_string(),
457            references: vec![
458                UnidicArtifactLicenseReference {
459                    label: "BSD".to_string(),
460                    path: "license/BSD".to_string(),
461                },
462                UnidicArtifactLicenseReference {
463                    label: "COPYING".to_string(),
464                    path: "license/COPYING".to_string(),
465                },
466            ],
467        }
468    }
469}
470
471/// Errors returned while reading UniDic CSV resources.
472#[derive(Debug)]
473pub enum UnidicCsvError {
474    /// CSV parser error.
475    Csv(csv::Error),
476    /// Filesystem or reader error.
477    Io(std::io::Error),
478    /// A required CSV column was missing.
479    MissingColumn {
480        /// Zero-based record index.
481        record_index: u64,
482        /// Required column index.
483        column: usize,
484        /// Number of columns in the record.
485        len: usize,
486    },
487}
488
489/// Errors returned while reading or validating UniDic artifact payloads.
490#[derive(Debug)]
491pub enum UnidicArtifactPayloadError {
492    /// Filesystem or reader error.
493    Io(std::io::Error),
494    /// YAML parser error.
495    Yaml(serde_yaml::Error),
496    /// Binary payload magic did not match the expected value.
497    InvalidBinaryMagic {
498        /// Magic bytes read from the payload.
499        magic: [u8; 8],
500    },
501    /// Binary payload version is not supported.
502    UnsupportedBinaryVersion {
503        /// Version read from the payload.
504        version: u32,
505    },
506    /// Reserved binary header field was non-zero.
507    NonZeroBinaryReserved {
508        /// Reserved value read from the payload.
509        value: u32,
510    },
511    /// Binary payload ended before a field could be read.
512    TruncatedBinary {
513        /// Field being read.
514        field: &'static str,
515    },
516    /// Binary payload contained invalid UTF-8.
517    InvalidBinaryUtf8 {
518        /// Field being decoded.
519        field: &'static str,
520        /// UTF-8 conversion error.
521        source: FromUtf8Error,
522    },
523    /// Binary field length exceeded supported bounds.
524    BinaryValueTooLarge {
525        /// Field being read.
526        field: &'static str,
527        /// Field length.
528        len: usize,
529    },
530    /// Binary payload entry count exceeded supported bounds.
531    BinaryEntryCountTooLarge {
532        /// Entry count read from the payload.
533        entries: u64,
534    },
535    /// Artifact payload exceeded a configured safety limit.
536    ArtifactLimitExceeded {
537        /// Field whose length or count exceeded the limit.
538        field: &'static str,
539        /// Observed length or count.
540        len: u64,
541        /// Maximum allowed length or count.
542        max: u64,
543    },
544    /// Indexed payload magic did not match the expected value.
545    InvalidIndexedMagic {
546        /// Magic bytes read from the payload.
547        magic: [u8; 8],
548    },
549    /// Indexed payload version is not supported.
550    UnsupportedIndexedVersion {
551        /// Version read from the payload.
552        version: u32,
553    },
554    /// Reserved indexed header field was non-zero.
555    NonZeroIndexedReserved {
556        /// Reserved value read from the payload.
557        value: u32,
558    },
559    /// Indexed payload ended before a section could be read.
560    TruncatedIndexed {
561        /// Field or section being read.
562        field: &'static str,
563    },
564    /// Indexed payload contained an invalid FST section.
565    InvalidIndexedFst {
566        /// FST error message.
567        message: String,
568    },
569    /// Indexed payload section length exceeded supported bounds.
570    IndexedSectionTooLarge {
571        /// Section name.
572        field: &'static str,
573        /// Section length.
574        len: u64,
575    },
576    /// Indexed payload referenced an invalid readings offset.
577    InvalidIndexedOffset {
578        /// Offset read from the FST value.
579        offset: u64,
580    },
581    /// Indexed payload contained invalid UTF-8.
582    InvalidIndexedUtf8 {
583        /// Field being decoded.
584        field: &'static str,
585        /// UTF-8 conversion error.
586        source: std::str::Utf8Error,
587    },
588    /// Indexed header entry count disagreed with the FST entry count.
589    IndexedEntryCountMismatch {
590        /// Entry count recorded in the header.
591        header_entries: usize,
592        /// Entry count decoded from the FST.
593        fst_entries: usize,
594    },
595    /// YAML payload schema version is not supported.
596    UnsupportedSchemaVersion {
597        /// Version read from the payload.
598        version: u32,
599    },
600    /// YAML payload type is not a UniDic reading index.
601    UnsupportedPayloadType {
602        /// Payload type read from the payload.
603        payload_type: String,
604    },
605    /// Payload entry had an empty surface form.
606    EmptySurface {
607        /// Zero-based entry index.
608        entry_index: usize,
609    },
610    /// Surface form appeared more than once.
611    DuplicateSurface {
612        /// Duplicated surface form.
613        surface: String,
614    },
615    /// Payload entry had no readings.
616    EmptyReadings {
617        /// Surface form for the invalid entry.
618        surface: String,
619    },
620    /// Payload entry contained an empty reading.
621    EmptyReading {
622        /// Surface form for the invalid entry.
623        surface: String,
624        /// Zero-based reading index.
625        reading_index: usize,
626    },
627    /// Payload entry contained the same reading more than once.
628    DuplicateReading {
629        /// Surface form for the invalid entry.
630        surface: String,
631        /// Duplicated reading.
632        reading: String,
633    },
634}
635
636impl fmt::Display for UnidicCsvError {
637    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
638        match self {
639            Self::Csv(err) => write!(f, "invalid UniDic CSV: {err}"),
640            Self::Io(err) => write!(f, "failed to read UniDic CSV: {err}"),
641            Self::MissingColumn {
642                record_index,
643                column,
644                len,
645            } => write!(
646                f,
647                "UniDic CSV record {record_index} has no column {column}; record has {len} columns"
648            ),
649        }
650    }
651}
652
653impl Error for UnidicCsvError {}
654
655impl fmt::Display for UnidicArtifactPayloadError {
656    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
657        match self {
658            Self::Io(err) => write!(f, "failed to read UniDic artifact payload: {err}"),
659            Self::Yaml(err) => write!(f, "invalid UniDic artifact payload YAML: {err}"),
660            Self::InvalidBinaryMagic { magic } => {
661                write!(f, "invalid UniDic binary artifact magic {magic:?}")
662            }
663            Self::UnsupportedBinaryVersion { version } => {
664                write!(f, "unsupported UniDic binary artifact version {version}")
665            }
666            Self::NonZeroBinaryReserved { value } => {
667                write!(f, "UniDic binary artifact reserved header field is {value}")
668            }
669            Self::TruncatedBinary { field } => {
670                write!(f, "truncated UniDic binary artifact while reading {field}")
671            }
672            Self::InvalidBinaryUtf8 { field, source } => {
673                write!(f, "invalid UTF-8 in UniDic binary artifact {field}: {source}")
674            }
675            Self::BinaryValueTooLarge { field, len } => write!(
676                f,
677                "UniDic binary artifact {field} length {len} exceeds u32::MAX"
678            ),
679            Self::BinaryEntryCountTooLarge { entries } => write!(
680                f,
681                "UniDic binary artifact entry count {entries} exceeds usize::MAX"
682            ),
683            Self::ArtifactLimitExceeded { field, len, max } => write!(
684                f,
685                "UniDic artifact {field} length/count {len} exceeds limit {max}"
686            ),
687            Self::InvalidIndexedMagic { magic } => {
688                write!(f, "invalid UniDic indexed artifact magic {magic:?}")
689            }
690            Self::UnsupportedIndexedVersion { version } => {
691                write!(f, "unsupported UniDic indexed artifact version {version}")
692            }
693            Self::NonZeroIndexedReserved { value } => {
694                write!(f, "UniDic indexed artifact reserved header field is {value}")
695            }
696            Self::TruncatedIndexed { field } => {
697                write!(f, "truncated UniDic indexed artifact while reading {field}")
698            }
699            Self::InvalidIndexedFst { message } => {
700                write!(f, "invalid UniDic indexed artifact FST: {message}")
701            }
702            Self::IndexedSectionTooLarge { field, len } => write!(
703                f,
704                "UniDic indexed artifact {field} length {len} exceeds usize::MAX"
705            ),
706            Self::InvalidIndexedOffset { offset } => {
707                write!(f, "invalid UniDic indexed artifact readings offset {offset}")
708            }
709            Self::InvalidIndexedUtf8 { field, source } => {
710                write!(f, "invalid UTF-8 in UniDic indexed artifact {field}: {source}")
711            }
712            Self::IndexedEntryCountMismatch {
713                header_entries,
714                fst_entries,
715            } => write!(
716                f,
717                "UniDic indexed artifact header entry count {header_entries} does not match FST entry count {fst_entries}"
718            ),
719            Self::UnsupportedSchemaVersion { version } => write!(
720                f,
721                "unsupported UniDic artifact payload schema version {version}"
722            ),
723            Self::UnsupportedPayloadType { payload_type } => {
724                write!(f, "unsupported UniDic artifact payload type {payload_type:?}")
725            }
726            Self::EmptySurface { entry_index } => write!(
727                f,
728                "UniDic artifact payload entry {entry_index} has an empty surface"
729            ),
730            Self::DuplicateSurface { surface } => {
731                write!(f, "UniDic artifact payload has duplicate surface {surface:?}")
732            }
733            Self::EmptyReadings { surface } => write!(
734                f,
735                "UniDic artifact payload surface {surface:?} has no readings"
736            ),
737            Self::EmptyReading {
738                surface,
739                reading_index,
740            } => write!(
741                f,
742                "UniDic artifact payload surface {surface:?} has an empty reading at index {reading_index}"
743            ),
744            Self::DuplicateReading { surface, reading } => write!(
745                f,
746                "UniDic artifact payload surface {surface:?} has duplicate reading {reading:?}"
747            ),
748        }
749    }
750}
751
752impl Error for UnidicArtifactPayloadError {
753    fn source(&self) -> Option<&(dyn Error + 'static)> {
754        match self {
755            Self::Io(err) => Some(err),
756            Self::Yaml(err) => Some(err),
757            Self::InvalidBinaryUtf8 { source, .. } => Some(source),
758            Self::InvalidIndexedUtf8 { source, .. } => Some(source),
759            _ => None,
760        }
761    }
762}
763
764impl From<csv::Error> for UnidicCsvError {
765    fn from(err: csv::Error) -> Self {
766        Self::Csv(err)
767    }
768}
769
770impl From<std::io::Error> for UnidicCsvError {
771    fn from(err: std::io::Error) -> Self {
772        Self::Io(err)
773    }
774}
775
776impl From<std::io::Error> for UnidicArtifactPayloadError {
777    fn from(err: std::io::Error) -> Self {
778        Self::Io(err)
779    }
780}
781
782impl From<serde_yaml::Error> for UnidicArtifactPayloadError {
783    fn from(err: serde_yaml::Error) -> Self {
784        Self::Yaml(err)
785    }
786}
787
788impl UnidicReadingIndex {
789    /// Builds an index from a UniDic `lex.csv` file.
790    pub fn from_lex_csv_path(path: impl AsRef<Path>) -> Result<Self, UnidicCsvError> {
791        Self::from_lex_csv_path_with_options(path, UnidicIndexOptions::default())
792    }
793
794    /// Builds an index from a UniDic `lex.csv` file using a specific reading field.
795    pub fn from_lex_csv_path_with_field(
796        path: impl AsRef<Path>,
797        field: UnidicReadingField,
798    ) -> Result<Self, UnidicCsvError> {
799        Self::from_lex_csv_path_with_options(
800            path,
801            UnidicIndexOptions {
802                reading_field: field,
803                ..UnidicIndexOptions::default()
804            },
805        )
806    }
807
808    /// Builds an index from a UniDic `lex.csv` file with custom options.
809    pub fn from_lex_csv_path_with_options(
810        path: impl AsRef<Path>,
811        options: UnidicIndexOptions,
812    ) -> Result<Self, UnidicCsvError> {
813        let file = File::open(path)?;
814        Self::from_lex_csv_reader_with_options(file, options)
815    }
816
817    /// Builds an index from a reader containing UniDic `lex.csv` data.
818    pub fn from_lex_csv_reader(reader: impl Read) -> Result<Self, UnidicCsvError> {
819        Self::from_lex_csv_reader_with_options(reader, UnidicIndexOptions::default())
820    }
821
822    /// Builds an index from a UniDic `lex.csv` reader using a specific reading field.
823    pub fn from_lex_csv_reader_with_field(
824        reader: impl Read,
825        reading_field: UnidicReadingField,
826    ) -> Result<Self, UnidicCsvError> {
827        Self::from_lex_csv_reader_with_options(
828            reader,
829            UnidicIndexOptions {
830                reading_field,
831                ..UnidicIndexOptions::default()
832            },
833        )
834    }
835
836    /// Builds an index from a UniDic `lex.csv` reader with custom options.
837    pub fn from_lex_csv_reader_with_options(
838        reader: impl Read,
839        options: UnidicIndexOptions,
840    ) -> Result<Self, UnidicCsvError> {
841        let mut by_surface = HashMap::<String, BTreeSet<String>>::new();
842        for record in lex_csv_reader(reader).records() {
843            let record = record?;
844            let surface = field(&record, SURFACE_COLUMN)?;
845            let reading = field(&record, options.reading_field.column())?;
846
847            if surface == "*" || reading == "*" {
848                continue;
849            }
850            if options.exclude_ascii_surfaces && surface.is_ascii() {
851                continue;
852            }
853            if options.exclude_symbol_pos && is_symbol_pos(field(&record, POS1_COLUMN)?) {
854                continue;
855            }
856
857            insert_surface_reading(&mut by_surface, surface, reading);
858            if let Some(normalized_surface) = normalize_ascii_width(surface) {
859                insert_surface_reading(&mut by_surface, &normalized_surface, reading);
860            }
861        }
862
863        let readings_by_surface = by_surface
864            .into_iter()
865            .map(|(surface, readings)| {
866                let mut readings = readings.into_iter().collect::<Vec<_>>();
867                if let Some(max_readings) = options.max_readings_per_surface {
868                    readings.truncate(max_readings);
869                }
870                (surface, readings)
871            })
872            .filter(|(_, readings)| !readings.is_empty())
873            .collect();
874
875        Ok(Self::from_readings_by_surface(readings_by_surface))
876    }
877
878    /// Loads a YAML artifact payload from a file path.
879    pub fn from_artifact_payload_path(
880        path: impl AsRef<Path>,
881    ) -> Result<Self, UnidicArtifactPayloadError> {
882        let path = path.as_ref();
883        check_payload_file_size(path)?;
884        let file = File::open(path)?;
885        Self::from_artifact_payload_reader(file)
886    }
887
888    /// Loads a YAML artifact payload from a reader.
889    pub fn from_artifact_payload_reader(
890        reader: impl Read,
891    ) -> Result<Self, UnidicArtifactPayloadError> {
892        let payload = serde_yaml::from_reader(reader)?;
893        Self::from_artifact_payload(payload)
894    }
895
896    /// Builds an index from a deserialized artifact payload.
897    pub fn from_artifact_payload(
898        payload: UnidicReadingIndexPayload,
899    ) -> Result<Self, UnidicArtifactPayloadError> {
900        validate_artifact_payload_header(&payload)?;
901        check_limit("entry_count", payload.entries.len(), MAX_ARTIFACT_ENTRIES)?;
902
903        let mut readings_by_surface = HashMap::new();
904        for (entry_index, entry) in payload.entries.into_iter().enumerate() {
905            check_limit(
906                "surface_bytes",
907                entry.surface.len(),
908                MAX_ARTIFACT_STRING_BYTES,
909            )?;
910            check_limit(
911                "reading_count",
912                entry.readings.len(),
913                MAX_ARTIFACT_READINGS_PER_ENTRY,
914            )?;
915            if entry.surface.is_empty() {
916                return Err(UnidicArtifactPayloadError::EmptySurface { entry_index });
917            }
918            if entry.readings.is_empty() {
919                return Err(UnidicArtifactPayloadError::EmptyReadings {
920                    surface: entry.surface,
921                });
922            }
923
924            let mut seen_readings = BTreeSet::new();
925            for (reading_index, reading) in entry.readings.iter().enumerate() {
926                check_limit("reading_bytes", reading.len(), MAX_ARTIFACT_STRING_BYTES)?;
927                if reading.is_empty() {
928                    return Err(UnidicArtifactPayloadError::EmptyReading {
929                        surface: entry.surface,
930                        reading_index,
931                    });
932                }
933                if !seen_readings.insert(reading) {
934                    return Err(UnidicArtifactPayloadError::DuplicateReading {
935                        surface: entry.surface,
936                        reading: reading.clone(),
937                    });
938                }
939            }
940
941            if readings_by_surface
942                .insert(entry.surface.clone(), entry.readings)
943                .is_some()
944            {
945                return Err(UnidicArtifactPayloadError::DuplicateSurface {
946                    surface: entry.surface,
947                });
948            }
949        }
950
951        Ok(Self::from_readings_by_surface(readings_by_surface))
952    }
953
954    /// Loads a binary artifact payload from a file path.
955    pub fn from_binary_artifact_payload_path(
956        path: impl AsRef<Path>,
957    ) -> Result<Self, UnidicArtifactPayloadError> {
958        let path = path.as_ref();
959        check_payload_file_size(path)?;
960        let file = File::open(path)?;
961        Self::from_binary_artifact_payload_reader(file)
962    }
963
964    /// Loads a binary artifact payload from a reader.
965    pub fn from_binary_artifact_payload_reader(
966        mut reader: impl Read,
967    ) -> Result<Self, UnidicArtifactPayloadError> {
968        let header = read_binary_artifact_payload_header(&mut reader)?;
969        check_limit("entry_count", header.entries, MAX_ARTIFACT_ENTRIES)?;
970        let mut entries = Vec::with_capacity(header.entries);
971        for _ in 0..header.entries {
972            let surface = read_binary_string(&mut reader, "surface")?;
973            let reading_count = read_u32_le(&mut reader, "reading_count")?;
974            let reading_count = usize::try_from(reading_count).expect("u32 fits usize");
975            check_limit(
976                "reading_count",
977                reading_count,
978                MAX_ARTIFACT_READINGS_PER_ENTRY,
979            )?;
980            let mut readings = Vec::with_capacity(reading_count);
981            for _ in 0..reading_count {
982                readings.push(read_binary_string(&mut reader, "reading")?);
983            }
984            entries.push(UnidicReadingIndexPayloadEntry { surface, readings });
985        }
986
987        Self::from_artifact_payload(UnidicReadingIndexPayload {
988            schema_version: ARTIFACT_PAYLOAD_SCHEMA_VERSION,
989            payload_type: ARTIFACT_PAYLOAD_TYPE.to_string(),
990            entries,
991        })
992    }
993
994    /// Loads an indexed FST artifact payload from a file path.
995    pub fn from_indexed_artifact_payload_path(
996        path: impl AsRef<Path>,
997    ) -> Result<Self, UnidicArtifactPayloadError> {
998        let path = path.as_ref();
999        check_payload_file_size(path)?;
1000        let file = File::open(path)?;
1001        // SAFETY: the mmap is kept alive by IndexedUnidicPayload for as long as
1002        // any offsets or slices derived from it can be used.
1003        let mmap = unsafe { Mmap::map(&file)? };
1004        Self::from_indexed_mmap(mmap)
1005    }
1006
1007    /// Loads an indexed artifact payload from bytes.
1008    ///
1009    /// This eagerly materializes the indexed payload and is intended for
1010    /// environments such as WebAssembly where mmap-backed loading is not
1011    /// available.
1012    ///
1013    /// # Errors
1014    ///
1015    /// Returns an error when the payload is too large, malformed, truncated,
1016    /// has an invalid FST section, or fails canonical artifact validation.
1017    pub fn from_indexed_artifact_payload_bytes(
1018        bytes: &[u8],
1019    ) -> Result<Self, UnidicArtifactPayloadError> {
1020        if bytes.len() as u64 > MAX_ARTIFACT_PAYLOAD_BYTES {
1021            return Err(UnidicArtifactPayloadError::ArtifactLimitExceeded {
1022                field: "payload_bytes",
1023                len: bytes.len() as u64,
1024                max: MAX_ARTIFACT_PAYLOAD_BYTES,
1025            });
1026        }
1027        let header = read_indexed_artifact_payload_header_bytes(bytes)?;
1028        let fst_start = INDEXED_ARTIFACT_HEADER_LEN;
1029        let fst_end = fst_start.checked_add(header.fst_len).ok_or(
1030            UnidicArtifactPayloadError::TruncatedIndexed {
1031                field: "fst_section",
1032            },
1033        )?;
1034        let readings_end = fst_end.checked_add(header.readings_len).ok_or(
1035            UnidicArtifactPayloadError::TruncatedIndexed {
1036                field: "readings_section",
1037            },
1038        )?;
1039        if bytes.len() < readings_end {
1040            return Err(UnidicArtifactPayloadError::TruncatedIndexed {
1041                field: "indexed_payload",
1042            });
1043        }
1044
1045        let map = Map::new(bytes[fst_start..fst_end].to_vec()).map_err(|err| {
1046            UnidicArtifactPayloadError::InvalidIndexedFst {
1047                message: err.to_string(),
1048            }
1049        })?;
1050        let fst_entries = map.len();
1051        if fst_entries != header.entries {
1052            return Err(UnidicArtifactPayloadError::IndexedEntryCountMismatch {
1053                header_entries: header.entries,
1054                fst_entries,
1055            });
1056        }
1057
1058        let mut entries = Vec::with_capacity(header.entries);
1059        let mut stream = map.stream();
1060        while let Some((surface, offset)) = stream.next() {
1061            let surface = std::str::from_utf8(surface)
1062                .map_err(|source| UnidicArtifactPayloadError::InvalidIndexedUtf8 {
1063                    field: "surface",
1064                    source,
1065                })?
1066                .to_string();
1067            let readings = read_indexed_readings_at_bytes(bytes, fst_end, offset)?;
1068            entries.push(UnidicReadingIndexPayloadEntry { surface, readings });
1069        }
1070
1071        Self::from_artifact_payload(UnidicReadingIndexPayload {
1072            schema_version: ARTIFACT_PAYLOAD_SCHEMA_VERSION,
1073            payload_type: ARTIFACT_PAYLOAD_TYPE.to_string(),
1074            entries,
1075        })
1076    }
1077
1078    fn from_indexed_mmap(mmap: Mmap) -> Result<Self, UnidicArtifactPayloadError> {
1079        if mmap.len() as u64 > MAX_ARTIFACT_PAYLOAD_BYTES {
1080            return Err(UnidicArtifactPayloadError::ArtifactLimitExceeded {
1081                field: "payload_bytes",
1082                len: mmap.len() as u64,
1083                max: MAX_ARTIFACT_PAYLOAD_BYTES,
1084            });
1085        }
1086        let header = read_indexed_artifact_payload_header_bytes(&mmap)?;
1087        let fst_start = INDEXED_ARTIFACT_HEADER_LEN;
1088        let fst_end = fst_start.checked_add(header.fst_len).ok_or(
1089            UnidicArtifactPayloadError::TruncatedIndexed {
1090                field: "fst_section",
1091            },
1092        )?;
1093        let readings_end = fst_end.checked_add(header.readings_len).ok_or(
1094            UnidicArtifactPayloadError::TruncatedIndexed {
1095                field: "readings_section",
1096            },
1097        )?;
1098        if mmap.len() < readings_end {
1099            return Err(UnidicArtifactPayloadError::TruncatedIndexed {
1100                field: "indexed_payload",
1101            });
1102        }
1103
1104        let map = Map::new(mmap[fst_start..fst_end].to_vec()).map_err(|err| {
1105            UnidicArtifactPayloadError::InvalidIndexedFst {
1106                message: err.to_string(),
1107            }
1108        })?;
1109        let fst_entries = map.len();
1110        if fst_entries != header.entries {
1111            return Err(UnidicArtifactPayloadError::IndexedEntryCountMismatch {
1112                header_entries: header.entries,
1113                fst_entries,
1114            });
1115        }
1116
1117        let indexed = IndexedUnidicPayload {
1118            mmap: Arc::new(mmap),
1119            map,
1120            readings_start: fst_end,
1121            entries: header.entries,
1122        };
1123        indexed.validate()?;
1124        Ok(Self {
1125            storage: UnidicReadingStorage::Indexed(indexed),
1126        })
1127    }
1128
1129    /// Reads only the header from a binary artifact payload file.
1130    pub fn binary_artifact_payload_header_path(
1131        path: impl AsRef<Path>,
1132    ) -> Result<UnidicBinaryArtifactPayloadHeader, UnidicArtifactPayloadError> {
1133        let file = File::open(path)?;
1134        Self::binary_artifact_payload_header_reader(file)
1135    }
1136
1137    /// Reads only the header from a binary artifact payload reader.
1138    pub fn binary_artifact_payload_header_reader(
1139        mut reader: impl Read,
1140    ) -> Result<UnidicBinaryArtifactPayloadHeader, UnidicArtifactPayloadError> {
1141        read_binary_artifact_payload_header(&mut reader)
1142    }
1143
1144    pub(crate) fn from_readings_by_surface(
1145        readings_by_surface: HashMap<String, Vec<String>>,
1146    ) -> Self {
1147        Self {
1148            storage: UnidicReadingStorage::Eager(readings_by_surface),
1149        }
1150    }
1151
1152    /// Returns readings for `surface`, if present.
1153    ///
1154    /// For indexed artifacts, decode errors are treated the same as a missing
1155    /// surface for backward compatibility. Use [`Self::try_readings`] at trust
1156    /// boundaries when artifact corruption must be reported distinctly.
1157    pub fn readings(&self, surface: &str) -> Option<Cow<'_, [String]>> {
1158        self.try_readings(surface).ok().flatten()
1159    }
1160
1161    /// Returns readings for `surface` and preserves indexed artifact decode
1162    /// errors.
1163    pub fn try_readings(
1164        &self,
1165        surface: &str,
1166    ) -> Result<Option<Cow<'_, [String]>>, UnidicArtifactPayloadError> {
1167        if let Some(readings) = self.try_readings_exact(surface)? {
1168            return Ok(Some(readings));
1169        }
1170
1171        let Some(normalized_surface) = normalize_ascii_width(surface) else {
1172            return Ok(None);
1173        };
1174        if normalized_surface == surface {
1175            return Ok(None);
1176        }
1177
1178        self.try_readings_exact(&normalized_surface)
1179    }
1180
1181    fn try_readings_exact(
1182        &self,
1183        surface: &str,
1184    ) -> Result<Option<Cow<'_, [String]>>, UnidicArtifactPayloadError> {
1185        match &self.storage {
1186            UnidicReadingStorage::Eager(readings_by_surface) => Ok(readings_by_surface
1187                .get(surface)
1188                .map(|readings| Cow::Borrowed(readings.as_slice()))),
1189            UnidicReadingStorage::Indexed(indexed) => indexed
1190                .readings(surface)
1191                .map(|readings| readings.map(Cow::Owned)),
1192        }
1193    }
1194
1195    /// Returns the number of indexed surface forms.
1196    pub fn len(&self) -> usize {
1197        match &self.storage {
1198            UnidicReadingStorage::Eager(readings_by_surface) => readings_by_surface.len(),
1199            UnidicReadingStorage::Indexed(indexed) => indexed.entries,
1200        }
1201    }
1202
1203    /// Returns `true` when the index contains no surface forms.
1204    pub fn is_empty(&self) -> bool {
1205        self.len() == 0
1206    }
1207
1208    /// Builds bundle metadata for the current index and caller-provided
1209    /// provenance.
1210    ///
1211    /// The returned metadata includes a canonical payload checksum computed
1212    /// from the normalized payload view.
1213    pub fn artifact_metadata(
1214        &self,
1215        options: UnidicArtifactMetadataOptions,
1216    ) -> UnidicArtifactMetadata {
1217        let build = UnidicArtifactBuild {
1218            reading_field: options.index_options.reading_field.as_str().to_string(),
1219            max_readings_per_surface: options.index_options.max_readings_per_surface,
1220            exclude_ascii_surfaces: options.index_options.exclude_ascii_surfaces,
1221            exclude_symbol_pos: options.index_options.exclude_symbol_pos,
1222            include_normalized_surfaces: false,
1223            exclude_unsupported_readings: false,
1224            entries: self.len(),
1225        };
1226        self.artifact_metadata_with_build(options, build)
1227    }
1228
1229    /// Builds bundle metadata with caller-provided build provenance.
1230    ///
1231    /// This is used by Japanese dictionary sources that reuse the same reading
1232    /// payload format but do not share UniDic's CSV field layout.
1233    pub fn artifact_metadata_with_build(
1234        &self,
1235        options: UnidicArtifactMetadataOptions,
1236        mut build: UnidicArtifactBuild,
1237    ) -> UnidicArtifactMetadata {
1238        build.entries = self.len();
1239        UnidicArtifactMetadata {
1240            schema_version: 1,
1241            artifact_type: "moine.unidic.reading-index".to_string(),
1242            artifact_name: options.artifact_name,
1243            generator: options.generator,
1244            payload: UnidicArtifactPayload {
1245                path: options.payload_file_name,
1246                format: options.payload_format,
1247                file_digest_algorithm: None,
1248                file_digest: None,
1249                checksum_algorithm: ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM.to_string(),
1250                checksum: self.artifact_payload_checksum(),
1251            },
1252            source: UnidicArtifactSource {
1253                name: options.source_name,
1254                version: options.source_version,
1255                lex_csv: options.source_lex_csv,
1256            },
1257            build,
1258            query_defaults: UnidicArtifactQueryDefaults {
1259                max_span_chars: options.query_defaults.max_span_chars,
1260                max_paths: options.query_defaults.max_paths,
1261                longest_match_only: options.query_defaults.longest_match_only,
1262                max_readings_per_segment: options.query_defaults.max_readings_per_segment,
1263            },
1264            license: options.license,
1265        }
1266    }
1267
1268    /// Returns the normalized YAML-compatible payload view for this index.
1269    ///
1270    /// Entries are sorted by surface form so serialization and checksums are
1271    /// deterministic regardless of the index storage backend.
1272    pub fn artifact_payload(&self) -> UnidicReadingIndexPayload {
1273        let entries = match &self.storage {
1274            UnidicReadingStorage::Eager(readings_by_surface) => {
1275                let mut entries = readings_by_surface
1276                    .iter()
1277                    .map(|(surface, readings)| UnidicReadingIndexPayloadEntry {
1278                        surface: surface.clone(),
1279                        readings: readings.clone(),
1280                    })
1281                    .collect::<Vec<_>>();
1282                entries.sort_by(|left, right| left.surface.cmp(&right.surface));
1283                entries
1284            }
1285            UnidicReadingStorage::Indexed(indexed) => indexed
1286                .entries()
1287                .expect("validated indexed artifact should decode"),
1288        };
1289
1290        UnidicReadingIndexPayload {
1291            schema_version: ARTIFACT_PAYLOAD_SCHEMA_VERSION,
1292            payload_type: ARTIFACT_PAYLOAD_TYPE.to_string(),
1293            entries,
1294        }
1295    }
1296
1297    /// Returns the canonical checksum for the normalized payload.
1298    pub fn artifact_payload_checksum(&self) -> String {
1299        self.artifact_payload_checksum_for_algorithm(ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM)
1300            .expect("default artifact checksum algorithm should be supported")
1301    }
1302
1303    /// Returns a canonical payload checksum for `algorithm`.
1304    ///
1305    /// Supported values are [`ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM`] and
1306    /// [`LEGACY_ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM`]. Unknown algorithms return
1307    /// `None`.
1308    pub fn artifact_payload_checksum_for_algorithm(&self, algorithm: &str) -> Option<String> {
1309        let payload = self.artifact_payload();
1310        let bytes = canonical_payload_bytes(&payload);
1311        match algorithm {
1312            ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM => Some(sha256_hex(&bytes)),
1313            LEGACY_ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM => Some(format!("{:016x}", fnv1a64(&bytes))),
1314            _ => None,
1315        }
1316    }
1317
1318    /// Writes the legacy binary artifact payload format.
1319    ///
1320    /// Prefer [`Self::write_indexed_artifact_payload`] for newly generated
1321    /// bundles; this format is kept for compatibility with older artifacts.
1322    pub fn write_artifact_binary_payload(
1323        &self,
1324        mut writer: impl Write,
1325    ) -> Result<(), UnidicArtifactPayloadError> {
1326        let payload = self.artifact_payload();
1327        writer.write_all(BINARY_ARTIFACT_MAGIC)?;
1328        writer.write_all(&BINARY_ARTIFACT_VERSION.to_le_bytes())?;
1329        writer.write_all(&0_u32.to_le_bytes())?;
1330        writer.write_all(&(payload.entries.len() as u64).to_le_bytes())?;
1331
1332        for entry in &payload.entries {
1333            write_binary_string(&mut writer, "surface", &entry.surface)?;
1334            write_u32_len(&mut writer, "reading_count", entry.readings.len())?;
1335            for reading in &entry.readings {
1336                write_binary_string(&mut writer, "reading", reading)?;
1337            }
1338        }
1339
1340        Ok(())
1341    }
1342
1343    /// Writes the indexed FST-backed artifact payload format.
1344    ///
1345    /// The payload stores a finite-state transducer from surface form to an
1346    /// offset in a compact reading blob and can be loaded with
1347    /// [`Self::from_indexed_artifact_payload_path`].
1348    pub fn write_indexed_artifact_payload(
1349        &self,
1350        mut writer: impl Write,
1351    ) -> Result<(), UnidicArtifactPayloadError> {
1352        let payload = self.artifact_payload();
1353        let mut fst_bytes = Vec::new();
1354        let mut readings_bytes = Vec::new();
1355        {
1356            let mut builder = MapBuilder::new(&mut fst_bytes).map_err(|err| {
1357                UnidicArtifactPayloadError::InvalidIndexedFst {
1358                    message: err.to_string(),
1359                }
1360            })?;
1361            for entry in &payload.entries {
1362                let offset = readings_bytes.len() as u64;
1363                builder.insert(&entry.surface, offset).map_err(|err| {
1364                    UnidicArtifactPayloadError::InvalidIndexedFst {
1365                        message: err.to_string(),
1366                    }
1367                })?;
1368                write_indexed_reading_block(&mut readings_bytes, &entry.readings)?;
1369            }
1370            builder
1371                .finish()
1372                .map_err(|err| UnidicArtifactPayloadError::InvalidIndexedFst {
1373                    message: err.to_string(),
1374                })?;
1375        }
1376
1377        writer.write_all(INDEXED_ARTIFACT_MAGIC)?;
1378        writer.write_all(&INDEXED_ARTIFACT_VERSION.to_le_bytes())?;
1379        writer.write_all(&0_u32.to_le_bytes())?;
1380        writer.write_all(&(payload.entries.len() as u64).to_le_bytes())?;
1381        writer.write_all(&(fst_bytes.len() as u64).to_le_bytes())?;
1382        writer.write_all(&(readings_bytes.len() as u64).to_le_bytes())?;
1383        writer.write_all(&fst_bytes)?;
1384        writer.write_all(&readings_bytes)?;
1385        Ok(())
1386    }
1387
1388    /// Expands `text` into joined kana reading strings.
1389    ///
1390    /// This is a compatibility helper over [`Self::reading_paths`]. It drops
1391    /// segment boundaries and treats indexed artifact decode errors or invalid
1392    /// expansion options as an empty expansion.
1393    pub fn reading_sequences(&self, text: &str, options: DictionaryReadingOptions) -> Vec<String> {
1394        self.reading_sequences_with_stats_inner(text, options, false)
1395            .unwrap_or_default()
1396            .paths
1397    }
1398
1399    /// Expands `text` into dictionary-only reading paths.
1400    ///
1401    /// Every returned path contains surface/reading segment boundaries plus the
1402    /// joined kana reading. Use [`Self::try_reading_paths_with_stats`] when
1403    /// indexed artifact corruption or invalid expansion options must be
1404    /// reported.
1405    pub fn reading_paths(
1406        &self,
1407        text: &str,
1408        options: DictionaryReadingOptions,
1409    ) -> Vec<DictionaryReadingPath> {
1410        self.reading_paths_with_stats(text, options).paths
1411    }
1412
1413    /// Expands dictionary reading paths and treats artifact decode errors or
1414    /// invalid expansion options as an empty expansion for backward
1415    /// compatibility.
1416    ///
1417    /// Use [`Self::try_reading_paths_with_stats`] when loading indexed
1418    /// artifacts from outside the process trust boundary.
1419    pub fn reading_paths_with_stats(
1420        &self,
1421        text: &str,
1422        options: DictionaryReadingOptions,
1423    ) -> DictionaryReadingExpansion {
1424        self.try_reading_paths_with_stats(text, options)
1425            .unwrap_or_default()
1426    }
1427
1428    /// Expands dictionary reading paths and preserves indexed artifact decode
1429    /// and option validation errors.
1430    pub fn try_reading_paths_with_stats(
1431        &self,
1432        text: &str,
1433        options: DictionaryReadingOptions,
1434    ) -> Result<DictionaryReadingExpansion, UnidicArtifactPayloadError> {
1435        self.reading_paths_with_stats_inner(text, options, false)
1436    }
1437
1438    /// Expands `text` into reading paths with direct fallback segments.
1439    ///
1440    /// Dictionary matches are preferred, but kana and ASCII spans can pass
1441    /// through directly so mixed dictionary/direct input can still form a full
1442    /// path.
1443    pub fn hybrid_reading_paths(
1444        &self,
1445        text: &str,
1446        options: DictionaryReadingOptions,
1447    ) -> Vec<DictionaryReadingPath> {
1448        self.hybrid_reading_paths_with_stats(text, options).paths
1449    }
1450
1451    /// Expands hybrid dictionary/direct reading paths and treats artifact
1452    /// decode errors or invalid expansion options as an empty expansion for
1453    /// backward compatibility.
1454    ///
1455    /// Use [`Self::try_hybrid_reading_paths_with_stats`] when loading indexed
1456    /// artifacts from outside the process trust boundary.
1457    pub fn hybrid_reading_paths_with_stats(
1458        &self,
1459        text: &str,
1460        options: DictionaryReadingOptions,
1461    ) -> DictionaryReadingExpansion {
1462        self.try_hybrid_reading_paths_with_stats(text, options)
1463            .unwrap_or_default()
1464    }
1465
1466    /// Expands hybrid dictionary/direct reading paths and preserves indexed
1467    /// artifact decode and option validation errors.
1468    pub fn try_hybrid_reading_paths_with_stats(
1469        &self,
1470        text: &str,
1471        options: DictionaryReadingOptions,
1472    ) -> Result<DictionaryReadingExpansion, UnidicArtifactPayloadError> {
1473        self.reading_paths_with_stats_inner(text, options, true)
1474    }
1475
1476    fn reading_paths_with_stats_inner(
1477        &self,
1478        text: &str,
1479        options: DictionaryReadingOptions,
1480        allow_direct_fallback: bool,
1481    ) -> Result<DictionaryReadingExpansion, UnidicArtifactPayloadError> {
1482        let options = options.validate()?;
1483        if text.is_empty() || options.max_span_chars == 0 || options.max_paths == 0 {
1484            return Ok(DictionaryReadingExpansion::default());
1485        }
1486
1487        let mut stats = DictionaryReadingStats::default();
1488        let boundaries = char_boundaries(text);
1489        let char_len = boundaries.len() - 1;
1490        let mut suffix_paths = vec![Vec::<DictionaryReadingPath>::new(); char_len + 1];
1491        suffix_paths[char_len].push(DictionaryReadingPath {
1492            segments: Vec::new(),
1493            joined_reading: String::new(),
1494        });
1495
1496        for start in (0..char_len).rev() {
1497            let mut paths_by_reading = std::collections::BTreeMap::new();
1498            let end_limit = char_len.min(start.saturating_add(options.max_span_chars));
1499            let mut matching_ends = Vec::new();
1500
1501            for end in start + 1..=end_limit {
1502                let surface = &text[boundaries[start]..boundaries[end]];
1503                if !suffix_paths[end].is_empty() {
1504                    if let Some(surface_readings) = self.try_readings(surface)? {
1505                        matching_ends.push((end, surface_readings));
1506                    }
1507                }
1508            }
1509            stats.matched_spans += matching_ends.len();
1510
1511            if options.longest_match_only && !allow_direct_fallback {
1512                let pruned_spans = matching_ends.len().saturating_sub(1);
1513                if let Some(longest_match) = matching_ends.pop() {
1514                    stats.longest_match_pruned_spans += pruned_spans;
1515                    matching_ends.clear();
1516                    matching_ends.push(longest_match);
1517                }
1518            }
1519
1520            for (end, surface_readings) in matching_ends {
1521                let surface = &text[boundaries[start]..boundaries[end]];
1522
1523                stats.raw_segment_readings += surface_readings.len();
1524                let raw_surface_reading_count = surface_readings.len();
1525                let surface_readings = limited_surface_readings(surface_readings.as_ref(), options);
1526                stats.used_segment_readings += surface_readings.len();
1527                stats.pruned_segment_readings += raw_surface_reading_count - surface_readings.len();
1528                for surface_reading in surface_readings {
1529                    for suffix in &suffix_paths[end] {
1530                        stats.candidate_combinations += 1;
1531                        let mut reading = String::with_capacity(
1532                            surface_reading.len() + suffix.joined_reading.len(),
1533                        );
1534                        reading.push_str(surface_reading);
1535                        reading.push_str(&suffix.joined_reading);
1536
1537                        let mut segments = Vec::with_capacity(suffix.segments.len() + 1);
1538                        segments.push(DictionaryReadingSegment {
1539                            surface: surface.to_string(),
1540                            reading: surface_reading.to_string(),
1541                            source: DictionaryReadingSegmentSource::Dictionary,
1542                        });
1543                        segments.extend(suffix.segments.iter().cloned());
1544
1545                        match paths_by_reading.entry(reading.clone()) {
1546                            Entry::Vacant(entry) => {
1547                                entry.insert(DictionaryReadingPath {
1548                                    segments,
1549                                    joined_reading: reading,
1550                                });
1551                                stats.unique_paths += 1;
1552                            }
1553                            Entry::Occupied(_) => {
1554                                stats.duplicate_joined_readings += 1;
1555                            }
1556                        }
1557
1558                        if paths_by_reading.len() >= options.max_paths {
1559                            stats.max_paths_hit_count += 1;
1560                            break;
1561                        }
1562                    }
1563
1564                    if paths_by_reading.len() >= options.max_paths {
1565                        break;
1566                    }
1567                }
1568
1569                if paths_by_reading.len() >= options.max_paths {
1570                    break;
1571                }
1572            }
1573
1574            if allow_direct_fallback && paths_by_reading.len() < options.max_paths {
1575                if let Some(end) = direct_fallback_end(text, &boundaries, start, char_len) {
1576                    if !suffix_paths[end].is_empty() {
1577                        stats.direct_fallback_spans += 1;
1578                        let surface = &text[boundaries[start]..boundaries[end]];
1579                        for suffix in &suffix_paths[end] {
1580                            stats.candidate_combinations += 1;
1581                            let mut reading =
1582                                String::with_capacity(surface.len() + suffix.joined_reading.len());
1583                            reading.push_str(surface);
1584                            reading.push_str(&suffix.joined_reading);
1585
1586                            let mut segments = Vec::with_capacity(suffix.segments.len() + 1);
1587                            segments.push(DictionaryReadingSegment {
1588                                surface: surface.to_string(),
1589                                reading: surface.to_string(),
1590                                source: DictionaryReadingSegmentSource::Direct,
1591                            });
1592                            segments.extend(suffix.segments.iter().cloned());
1593
1594                            match paths_by_reading.entry(reading.clone()) {
1595                                Entry::Vacant(entry) => {
1596                                    entry.insert(DictionaryReadingPath {
1597                                        segments,
1598                                        joined_reading: reading,
1599                                    });
1600                                    stats.unique_paths += 1;
1601                                }
1602                                Entry::Occupied(_) => {
1603                                    stats.duplicate_joined_readings += 1;
1604                                }
1605                            }
1606
1607                            if paths_by_reading.len() >= options.max_paths {
1608                                stats.max_paths_hit_count += 1;
1609                                break;
1610                            }
1611                        }
1612                    }
1613                }
1614            }
1615
1616            suffix_paths[start] = paths_by_reading.into_values().collect();
1617        }
1618
1619        Ok(DictionaryReadingExpansion {
1620            paths: suffix_paths.remove(0),
1621            stats,
1622        })
1623    }
1624
1625    fn reading_sequences_with_stats_inner(
1626        &self,
1627        text: &str,
1628        options: DictionaryReadingOptions,
1629        allow_direct_fallback: bool,
1630    ) -> Result<DictionaryReadingSequenceExpansion, UnidicArtifactPayloadError> {
1631        let options = options.validate()?;
1632        if text.is_empty() || options.max_span_chars == 0 || options.max_paths == 0 {
1633            return Ok(DictionaryReadingSequenceExpansion::default());
1634        }
1635
1636        let mut stats = DictionaryReadingStats::default();
1637        let boundaries = char_boundaries(text);
1638        let char_len = boundaries.len() - 1;
1639        let mut suffix_paths = vec![Vec::<String>::new(); char_len + 1];
1640        suffix_paths[char_len].push(String::new());
1641
1642        for start in (0..char_len).rev() {
1643            let mut paths_by_reading = BTreeSet::new();
1644            let end_limit = char_len.min(start.saturating_add(options.max_span_chars));
1645            let mut matching_ends = Vec::new();
1646
1647            for end in start + 1..=end_limit {
1648                let surface = &text[boundaries[start]..boundaries[end]];
1649                if !suffix_paths[end].is_empty() {
1650                    if let Some(surface_readings) = self.try_readings(surface)? {
1651                        matching_ends.push((end, surface_readings));
1652                    }
1653                }
1654            }
1655            stats.matched_spans += matching_ends.len();
1656
1657            if options.longest_match_only && !allow_direct_fallback {
1658                let pruned_spans = matching_ends.len().saturating_sub(1);
1659                if let Some(longest_match) = matching_ends.pop() {
1660                    stats.longest_match_pruned_spans += pruned_spans;
1661                    matching_ends.clear();
1662                    matching_ends.push(longest_match);
1663                }
1664            }
1665
1666            for (end, surface_readings) in matching_ends {
1667                stats.raw_segment_readings += surface_readings.len();
1668                let raw_surface_reading_count = surface_readings.len();
1669                let surface_readings = limited_surface_readings(surface_readings.as_ref(), options);
1670                stats.used_segment_readings += surface_readings.len();
1671                stats.pruned_segment_readings += raw_surface_reading_count - surface_readings.len();
1672                for surface_reading in surface_readings {
1673                    for suffix in &suffix_paths[end] {
1674                        stats.candidate_combinations += 1;
1675                        let mut reading =
1676                            String::with_capacity(surface_reading.len() + suffix.len());
1677                        reading.push_str(surface_reading);
1678                        reading.push_str(suffix);
1679
1680                        if paths_by_reading.insert(reading) {
1681                            stats.unique_paths += 1;
1682                        } else {
1683                            stats.duplicate_joined_readings += 1;
1684                        }
1685
1686                        if paths_by_reading.len() >= options.max_paths {
1687                            stats.max_paths_hit_count += 1;
1688                            break;
1689                        }
1690                    }
1691
1692                    if paths_by_reading.len() >= options.max_paths {
1693                        break;
1694                    }
1695                }
1696
1697                if paths_by_reading.len() >= options.max_paths {
1698                    break;
1699                }
1700            }
1701
1702            if allow_direct_fallback && paths_by_reading.len() < options.max_paths {
1703                if let Some(end) = direct_fallback_end(text, &boundaries, start, char_len) {
1704                    if !suffix_paths[end].is_empty() {
1705                        stats.direct_fallback_spans += 1;
1706                        let surface = &text[boundaries[start]..boundaries[end]];
1707                        for suffix in &suffix_paths[end] {
1708                            stats.candidate_combinations += 1;
1709                            let mut reading = String::with_capacity(surface.len() + suffix.len());
1710                            reading.push_str(surface);
1711                            reading.push_str(suffix);
1712
1713                            if paths_by_reading.insert(reading) {
1714                                stats.unique_paths += 1;
1715                            } else {
1716                                stats.duplicate_joined_readings += 1;
1717                            }
1718
1719                            if paths_by_reading.len() >= options.max_paths {
1720                                stats.max_paths_hit_count += 1;
1721                                break;
1722                            }
1723                        }
1724                    }
1725                }
1726            }
1727
1728            suffix_paths[start] = paths_by_reading.into_iter().collect();
1729        }
1730
1731        Ok(DictionaryReadingSequenceExpansion {
1732            paths: suffix_paths.remove(0),
1733            stats,
1734        })
1735    }
1736
1737    /// Builds a romaji lattice from dictionary-only readings of `text`.
1738    ///
1739    /// Returns `Ok(None)` when the dictionary cannot cover the entire input.
1740    /// Indexed artifact decode errors are reported as
1741    /// [`JaLatticeError::ArtifactPayload`].
1742    pub fn romaji_lattice(
1743        &self,
1744        text: &str,
1745        options: DictionaryReadingOptions,
1746    ) -> Result<Option<Lattice>, JaLatticeError> {
1747        let readings = if options.longest_match_only {
1748            self.reading_sequences_longest_only(text, options)
1749                .map_err(|err| JaLatticeError::ArtifactPayload(err.to_string()))?
1750        } else {
1751            self.reading_sequences_with_stats_inner(text, options, false)
1752                .map_err(|err| JaLatticeError::ArtifactPayload(err.to_string()))?
1753                .paths
1754        };
1755        if readings.is_empty() {
1756            return Ok(None);
1757        }
1758
1759        crate::romaji::romaji_lattice_from_supported_readings(readings)
1760    }
1761
1762    /// Builds a romaji lattice with dictionary readings and direct fallback.
1763    ///
1764    /// This is the preferred lattice builder for mixed Japanese text where
1765    /// kana or ASCII spans may appear beside dictionary-backed surfaces.
1766    pub fn hybrid_romaji_lattice(
1767        &self,
1768        text: &str,
1769        options: DictionaryReadingOptions,
1770    ) -> Result<Option<Lattice>, JaLatticeError> {
1771        let readings = self
1772            .reading_sequences_with_stats_inner(text, options, true)
1773            .map_err(|err| JaLatticeError::ArtifactPayload(err.to_string()))?;
1774        if readings.paths.is_empty() {
1775            return Ok(None);
1776        }
1777
1778        crate::romaji::romaji_lattice_from_supported_readings(readings.paths)
1779    }
1780
1781    fn reading_sequences_longest_only(
1782        &self,
1783        text: &str,
1784        options: DictionaryReadingOptions,
1785    ) -> Result<Vec<String>, UnidicArtifactPayloadError> {
1786        let options = options.validate()?;
1787        if text.is_empty() || options.max_span_chars == 0 || options.max_paths == 0 {
1788            return Ok(Vec::new());
1789        }
1790
1791        let boundaries = char_boundaries(text);
1792        let char_len = boundaries.len() - 1;
1793        let mut suffix_paths = vec![Vec::<String>::new(); char_len + 1];
1794        suffix_paths[char_len].push(String::new());
1795
1796        for start in (0..char_len).rev() {
1797            let mut paths_by_reading = BTreeSet::new();
1798            let end_limit = char_len.min(start.saturating_add(options.max_span_chars));
1799
1800            for end in (start + 1..=end_limit).rev() {
1801                if suffix_paths[end].is_empty() {
1802                    continue;
1803                }
1804                let surface = &text[boundaries[start]..boundaries[end]];
1805                let Some(surface_readings) = self.try_readings(surface)? else {
1806                    continue;
1807                };
1808                let surface_readings = limited_surface_readings(surface_readings.as_ref(), options);
1809                for surface_reading in surface_readings {
1810                    for suffix in &suffix_paths[end] {
1811                        let mut reading =
1812                            String::with_capacity(surface_reading.len() + suffix.len());
1813                        reading.push_str(surface_reading);
1814                        reading.push_str(suffix);
1815                        paths_by_reading.insert(reading);
1816                        if paths_by_reading.len() >= options.max_paths {
1817                            break;
1818                        }
1819                    }
1820
1821                    if paths_by_reading.len() >= options.max_paths {
1822                        break;
1823                    }
1824                }
1825                break;
1826            }
1827
1828            suffix_paths[start] = paths_by_reading.into_iter().collect();
1829        }
1830
1831        Ok(suffix_paths.remove(0))
1832    }
1833}
1834
1835#[derive(Clone, Debug, Default, Eq, PartialEq)]
1836struct DictionaryReadingSequenceExpansion {
1837    paths: Vec<String>,
1838    stats: DictionaryReadingStats,
1839}
1840
1841fn char_boundaries(text: &str) -> Vec<usize> {
1842    text.char_indices()
1843        .map(|(index, _)| index)
1844        .chain(std::iter::once(text.len()))
1845        .collect()
1846}
1847
1848pub(crate) fn insert_surface_reading(
1849    by_surface: &mut HashMap<String, BTreeSet<String>>,
1850    surface: &str,
1851    reading: &str,
1852) {
1853    by_surface
1854        .entry(surface.to_string())
1855        .or_default()
1856        .insert(reading.to_string());
1857}
1858
1859pub(crate) fn normalize_ascii_width(input: &str) -> Option<String> {
1860    let mut normalized = String::with_capacity(input.len());
1861    let mut changed = false;
1862
1863    for ch in input.chars() {
1864        let normalized_ch = normalize_ascii_width_char(ch);
1865        changed |= normalized_ch != ch;
1866        normalized.push(normalized_ch);
1867    }
1868
1869    changed.then_some(normalized)
1870}
1871
1872fn normalize_ascii_width_char(ch: char) -> char {
1873    match ch {
1874        '\u{3000}' => ' ',
1875        '\u{ff01}'..='\u{ff5e}' => {
1876            char::from_u32(ch as u32 - 0xfee0).expect("fullwidth ASCII maps to ASCII")
1877        }
1878        _ => ch,
1879    }
1880}
1881
1882pub(crate) fn lex_csv_reader(reader: impl Read) -> csv::Reader<impl Read> {
1883    csv::ReaderBuilder::new()
1884        .has_headers(false)
1885        .flexible(true)
1886        .from_reader(reader)
1887}
1888
1889pub(crate) fn field(record: &csv::StringRecord, column: usize) -> Result<&str, UnidicCsvError> {
1890    record
1891        .get(column)
1892        .ok_or_else(|| UnidicCsvError::MissingColumn {
1893            record_index: record
1894                .position()
1895                .map(|position| position.record())
1896                .unwrap_or(0),
1897            column,
1898            len: record.len(),
1899        })
1900}
1901
1902pub(crate) fn is_symbol_pos(pos1: &str) -> bool {
1903    pos1.contains("記号")
1904}
1905
1906fn is_false(value: &bool) -> bool {
1907    !*value
1908}
1909
1910fn limited_surface_readings(readings: &[String], options: DictionaryReadingOptions) -> &[String] {
1911    if let Some(max_readings) = options.max_readings_per_segment {
1912        &readings[..readings.len().min(max_readings)]
1913    } else {
1914        readings
1915    }
1916}
1917
1918fn direct_fallback_end(
1919    text: &str,
1920    boundaries: &[usize],
1921    start: usize,
1922    char_len: usize,
1923) -> Option<usize> {
1924    let mut end = start;
1925    while end < char_len {
1926        let surface = &text[boundaries[start]..boundaries[end + 1]];
1927        if !can_build_direct_romaji_path(surface) {
1928            break;
1929        }
1930        end += 1;
1931    }
1932
1933    (end > start).then_some(end)
1934}
1935
1936fn write_binary_string(
1937    writer: &mut impl Write,
1938    field: &'static str,
1939    value: &str,
1940) -> Result<(), UnidicArtifactPayloadError> {
1941    write_u32_len(writer, field, value.len())?;
1942    writer.write_all(value.as_bytes())?;
1943    Ok(())
1944}
1945
1946fn write_u32_len(
1947    writer: &mut impl Write,
1948    field: &'static str,
1949    len: usize,
1950) -> Result<(), UnidicArtifactPayloadError> {
1951    let len = u32::try_from(len)
1952        .map_err(|_| UnidicArtifactPayloadError::BinaryValueTooLarge { field, len })?;
1953    writer.write_all(&len.to_le_bytes())?;
1954    Ok(())
1955}
1956
1957fn read_binary_string(
1958    reader: &mut impl Read,
1959    field: &'static str,
1960) -> Result<String, UnidicArtifactPayloadError> {
1961    let len = read_u32_le(reader, field)? as usize;
1962    check_limit(field, len, MAX_ARTIFACT_STRING_BYTES)?;
1963    let mut bytes = vec![0_u8; len];
1964    read_exact_binary(reader, &mut bytes, field)?;
1965    String::from_utf8(bytes)
1966        .map_err(|source| UnidicArtifactPayloadError::InvalidBinaryUtf8 { field, source })
1967}
1968
1969fn read_u32_le(
1970    reader: &mut impl Read,
1971    field: &'static str,
1972) -> Result<u32, UnidicArtifactPayloadError> {
1973    let mut bytes = [0_u8; 4];
1974    read_exact_binary(reader, &mut bytes, field)?;
1975    Ok(u32::from_le_bytes(bytes))
1976}
1977
1978fn read_u64_le(
1979    reader: &mut impl Read,
1980    field: &'static str,
1981) -> Result<u64, UnidicArtifactPayloadError> {
1982    let mut bytes = [0_u8; 8];
1983    read_exact_binary(reader, &mut bytes, field)?;
1984    Ok(u64::from_le_bytes(bytes))
1985}
1986
1987fn read_exact_binary(
1988    reader: &mut impl Read,
1989    bytes: &mut [u8],
1990    field: &'static str,
1991) -> Result<(), UnidicArtifactPayloadError> {
1992    match reader.read_exact(bytes) {
1993        Ok(()) => Ok(()),
1994        Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => {
1995            Err(UnidicArtifactPayloadError::TruncatedBinary { field })
1996        }
1997        Err(err) => Err(UnidicArtifactPayloadError::Io(err)),
1998    }
1999}
2000
2001fn read_binary_artifact_payload_header(
2002    reader: &mut impl Read,
2003) -> Result<UnidicBinaryArtifactPayloadHeader, UnidicArtifactPayloadError> {
2004    let mut magic = [0_u8; 8];
2005    read_exact_binary(reader, &mut magic, "magic")?;
2006    if &magic != BINARY_ARTIFACT_MAGIC {
2007        return Err(UnidicArtifactPayloadError::InvalidBinaryMagic { magic });
2008    }
2009
2010    let version = read_u32_le(reader, "version")?;
2011    if version != BINARY_ARTIFACT_VERSION {
2012        return Err(UnidicArtifactPayloadError::UnsupportedBinaryVersion { version });
2013    }
2014
2015    let reserved = read_u32_le(reader, "reserved")?;
2016    if reserved != 0 {
2017        return Err(UnidicArtifactPayloadError::NonZeroBinaryReserved { value: reserved });
2018    }
2019
2020    let entry_count = read_u64_le(reader, "entry_count")?;
2021    let entries = usize::try_from(entry_count).map_err(|_| {
2022        UnidicArtifactPayloadError::BinaryEntryCountTooLarge {
2023            entries: entry_count,
2024        }
2025    })?;
2026    check_limit("entry_count", entries, MAX_ARTIFACT_ENTRIES)?;
2027
2028    Ok(UnidicBinaryArtifactPayloadHeader { version, entries })
2029}
2030
2031fn read_indexed_artifact_payload_header_bytes(
2032    bytes: &[u8],
2033) -> Result<UnidicIndexedArtifactPayloadHeader, UnidicArtifactPayloadError> {
2034    if bytes.len() < INDEXED_ARTIFACT_HEADER_LEN {
2035        return Err(UnidicArtifactPayloadError::TruncatedIndexed { field: "header" });
2036    }
2037    let mut magic = [0_u8; 8];
2038    magic.copy_from_slice(&bytes[..8]);
2039    if &magic != INDEXED_ARTIFACT_MAGIC {
2040        return Err(UnidicArtifactPayloadError::InvalidIndexedMagic { magic });
2041    }
2042
2043    let version = read_u32_le_bytes(bytes, 8, "version")?;
2044    if version != INDEXED_ARTIFACT_VERSION {
2045        return Err(UnidicArtifactPayloadError::UnsupportedIndexedVersion { version });
2046    }
2047    let reserved = read_u32_le_bytes(bytes, 12, "reserved")?;
2048    if reserved != 0 {
2049        return Err(UnidicArtifactPayloadError::NonZeroIndexedReserved { value: reserved });
2050    }
2051    let entry_count = read_u64_le_bytes(bytes, 16, "entry_count")?;
2052    let fst_len = read_u64_le_bytes(bytes, 24, "fst_len")?;
2053    let readings_len = read_u64_le_bytes(bytes, 32, "readings_len")?;
2054    let entries = checked_indexed_usize("entry_count", entry_count)?;
2055    check_limit("entry_count", entries, MAX_ARTIFACT_ENTRIES)?;
2056    Ok(UnidicIndexedArtifactPayloadHeader {
2057        version,
2058        entries,
2059        fst_len: checked_indexed_usize("fst_len", fst_len)?,
2060        readings_len: checked_indexed_usize("readings_len", readings_len)?,
2061    })
2062}
2063
2064fn read_u32_le_bytes(
2065    bytes: &[u8],
2066    offset: usize,
2067    field: &'static str,
2068) -> Result<u32, UnidicArtifactPayloadError> {
2069    let end = offset
2070        .checked_add(4)
2071        .ok_or(UnidicArtifactPayloadError::TruncatedIndexed { field })?;
2072    let chunk = bytes
2073        .get(offset..end)
2074        .ok_or(UnidicArtifactPayloadError::TruncatedIndexed { field })?;
2075    Ok(u32::from_le_bytes(
2076        chunk.try_into().expect("slice length is 4"),
2077    ))
2078}
2079
2080fn read_u64_le_bytes(
2081    bytes: &[u8],
2082    offset: usize,
2083    field: &'static str,
2084) -> Result<u64, UnidicArtifactPayloadError> {
2085    let end = offset
2086        .checked_add(8)
2087        .ok_or(UnidicArtifactPayloadError::TruncatedIndexed { field })?;
2088    let chunk = bytes
2089        .get(offset..end)
2090        .ok_or(UnidicArtifactPayloadError::TruncatedIndexed { field })?;
2091    Ok(u64::from_le_bytes(
2092        chunk.try_into().expect("slice length is 8"),
2093    ))
2094}
2095
2096fn checked_indexed_usize(
2097    field: &'static str,
2098    len: u64,
2099) -> Result<usize, UnidicArtifactPayloadError> {
2100    usize::try_from(len)
2101        .map_err(|_| UnidicArtifactPayloadError::IndexedSectionTooLarge { field, len })
2102}
2103
2104fn check_payload_file_size(path: &Path) -> Result<(), UnidicArtifactPayloadError> {
2105    let len = std::fs::metadata(path)?.len();
2106    if len > MAX_ARTIFACT_PAYLOAD_BYTES {
2107        return Err(UnidicArtifactPayloadError::ArtifactLimitExceeded {
2108            field: "payload_bytes",
2109            len,
2110            max: MAX_ARTIFACT_PAYLOAD_BYTES,
2111        });
2112    }
2113    Ok(())
2114}
2115
2116fn check_limit(
2117    field: &'static str,
2118    len: usize,
2119    max: usize,
2120) -> Result<(), UnidicArtifactPayloadError> {
2121    if len > max {
2122        return Err(UnidicArtifactPayloadError::ArtifactLimitExceeded {
2123            field,
2124            len: len as u64,
2125            max: max as u64,
2126        });
2127    }
2128    Ok(())
2129}
2130
2131fn write_indexed_reading_block(
2132    writer: &mut Vec<u8>,
2133    readings: &[String],
2134) -> Result<(), UnidicArtifactPayloadError> {
2135    write_u32_len(writer, "reading_count", readings.len())?;
2136    for reading in readings {
2137        write_binary_string(writer, "reading", reading)?;
2138    }
2139    Ok(())
2140}
2141
2142impl IndexedUnidicPayload {
2143    fn validate(&self) -> Result<(), UnidicArtifactPayloadError> {
2144        let mut stream = self.map.stream();
2145        while let Some((surface, offset)) = stream.next() {
2146            let surface = std::str::from_utf8(surface).map_err(|source| {
2147                UnidicArtifactPayloadError::InvalidIndexedUtf8 {
2148                    field: "surface",
2149                    source,
2150                }
2151            })?;
2152            if surface.is_empty() {
2153                return Err(UnidicArtifactPayloadError::EmptySurface { entry_index: 0 });
2154            }
2155            let readings = self.readings_at(offset)?;
2156            if readings.is_empty() {
2157                return Err(UnidicArtifactPayloadError::EmptyReadings {
2158                    surface: surface.to_string(),
2159                });
2160            }
2161            let mut seen = BTreeSet::new();
2162            for (reading_index, reading) in readings.iter().enumerate() {
2163                if reading.is_empty() {
2164                    return Err(UnidicArtifactPayloadError::EmptyReading {
2165                        surface: surface.to_string(),
2166                        reading_index,
2167                    });
2168                }
2169                if !seen.insert(reading) {
2170                    return Err(UnidicArtifactPayloadError::DuplicateReading {
2171                        surface: surface.to_string(),
2172                        reading: reading.clone(),
2173                    });
2174                }
2175            }
2176        }
2177        Ok(())
2178    }
2179
2180    fn readings(&self, surface: &str) -> Result<Option<Vec<String>>, UnidicArtifactPayloadError> {
2181        self.map
2182            .get(surface)
2183            .map(|offset| self.readings_at(offset))
2184            .transpose()
2185    }
2186
2187    fn entries(&self) -> Result<Vec<UnidicReadingIndexPayloadEntry>, UnidicArtifactPayloadError> {
2188        let mut entries = Vec::with_capacity(self.entries);
2189        let mut stream = self.map.stream();
2190        while let Some((surface, offset)) = stream.next() {
2191            let surface = std::str::from_utf8(surface)
2192                .map_err(|source| UnidicArtifactPayloadError::InvalidIndexedUtf8 {
2193                    field: "surface",
2194                    source,
2195                })?
2196                .to_string();
2197            let readings = self.readings_at(offset)?;
2198            entries.push(UnidicReadingIndexPayloadEntry { surface, readings });
2199        }
2200        Ok(entries)
2201    }
2202
2203    fn readings_at(&self, offset: u64) -> Result<Vec<String>, UnidicArtifactPayloadError> {
2204        read_indexed_readings_at_bytes(&self.mmap, self.readings_start, offset)
2205    }
2206}
2207
2208fn read_indexed_readings_at_bytes(
2209    bytes: &[u8],
2210    readings_start: usize,
2211    offset: u64,
2212) -> Result<Vec<String>, UnidicArtifactPayloadError> {
2213    let offset = usize::try_from(offset)
2214        .map_err(|_| UnidicArtifactPayloadError::InvalidIndexedOffset { offset })?;
2215    let start = readings_start.checked_add(offset).ok_or(
2216        UnidicArtifactPayloadError::InvalidIndexedOffset {
2217            offset: offset as u64,
2218        },
2219    )?;
2220    if start >= bytes.len() {
2221        return Err(UnidicArtifactPayloadError::InvalidIndexedOffset {
2222            offset: offset as u64,
2223        });
2224    }
2225    let mut cursor = start;
2226    let reading_count = read_u32_le_bytes(bytes, cursor, "reading_count")? as usize;
2227    check_limit(
2228        "reading_count",
2229        reading_count,
2230        MAX_ARTIFACT_READINGS_PER_ENTRY,
2231    )?;
2232    cursor += 4;
2233    let mut readings = Vec::with_capacity(reading_count);
2234    for _ in 0..reading_count {
2235        let len = read_u32_le_bytes(bytes, cursor, "reading_len")? as usize;
2236        check_limit("reading_bytes", len, MAX_ARTIFACT_STRING_BYTES)?;
2237        cursor += 4;
2238        let end = cursor
2239            .checked_add(len)
2240            .ok_or(UnidicArtifactPayloadError::TruncatedIndexed { field: "reading" })?;
2241        let reading_bytes = bytes
2242            .get(cursor..end)
2243            .ok_or(UnidicArtifactPayloadError::TruncatedIndexed { field: "reading" })?;
2244        let reading = std::str::from_utf8(reading_bytes)
2245            .map_err(|source| UnidicArtifactPayloadError::InvalidIndexedUtf8 {
2246                field: "reading",
2247                source,
2248            })?
2249            .to_string();
2250        readings.push(reading);
2251        cursor = end;
2252    }
2253    Ok(readings)
2254}
2255
2256/// Computes the SHA-256 file digest string for a UniDic artifact payload file.
2257pub fn artifact_file_digest_path(path: impl AsRef<Path>) -> Result<String, std::io::Error> {
2258    let file = File::open(path)?;
2259    artifact_file_digest_reader(file)
2260}
2261
2262/// Computes the SHA-256 file digest string from a reader.
2263pub fn artifact_file_digest_reader(mut reader: impl Read) -> Result<String, std::io::Error> {
2264    let mut hasher = Sha256::new();
2265    let mut buffer = [0_u8; 64 * 1024];
2266    loop {
2267        let read = reader.read(&mut buffer)?;
2268        if read == 0 {
2269            break;
2270        }
2271        hasher.update(&buffer[..read]);
2272    }
2273    Ok(sha256_digest_hex(hasher.finalize()))
2274}
2275
2276fn validate_artifact_payload_header(
2277    payload: &UnidicReadingIndexPayload,
2278) -> Result<(), UnidicArtifactPayloadError> {
2279    if payload.schema_version != ARTIFACT_PAYLOAD_SCHEMA_VERSION {
2280        return Err(UnidicArtifactPayloadError::UnsupportedSchemaVersion {
2281            version: payload.schema_version,
2282        });
2283    }
2284    if payload.payload_type != ARTIFACT_PAYLOAD_TYPE {
2285        return Err(UnidicArtifactPayloadError::UnsupportedPayloadType {
2286            payload_type: payload.payload_type.clone(),
2287        });
2288    }
2289    Ok(())
2290}
2291
2292fn canonical_payload_bytes(payload: &UnidicReadingIndexPayload) -> Vec<u8> {
2293    let mut bytes = Vec::new();
2294    bytes.extend_from_slice(b"moine.unidic.reading-index.surface-readings/v1\n");
2295    for entry in &payload.entries {
2296        push_len_prefixed(&mut bytes, b"S", &entry.surface);
2297        bytes.extend_from_slice(format!("R{}\n", entry.readings.len()).as_bytes());
2298        for reading in &entry.readings {
2299            push_len_prefixed(&mut bytes, b"r", reading);
2300        }
2301    }
2302    bytes
2303}
2304
2305fn push_len_prefixed(bytes: &mut Vec<u8>, tag: &[u8], value: &str) {
2306    bytes.extend_from_slice(tag);
2307    bytes.extend_from_slice(value.len().to_string().as_bytes());
2308    bytes.push(b'\n');
2309    bytes.extend_from_slice(value.as_bytes());
2310    bytes.push(b'\n');
2311}
2312
2313fn fnv1a64(bytes: &[u8]) -> u64 {
2314    let mut hash = 0xcbf29ce484222325_u64;
2315    for byte in bytes {
2316        hash ^= u64::from(*byte);
2317        hash = hash.wrapping_mul(0x100000001b3);
2318    }
2319    hash
2320}
2321
2322fn sha256_hex(bytes: &[u8]) -> String {
2323    sha256_digest_hex(Sha256::digest(bytes))
2324}
2325
2326fn sha256_digest_hex(digest: impl IntoIterator<Item = u8>) -> String {
2327    let mut output = String::with_capacity(64);
2328    for byte in digest {
2329        write!(&mut output, "{byte:02x}").expect("writing to String should not fail");
2330    }
2331    output
2332}
2333
2334#[cfg(test)]
2335mod tests {
2336    use super::*;
2337
2338    #[test]
2339    fn builds_surface_to_readings_index() {
2340        let csv = "\
2341印刷,18331,19434,9138,名詞,普通名詞,サ変可能,*,*,*,インサツ,印刷,印刷,インサツ,印刷,インサツ,漢,*,*,*,*,*,*,体,インサツ,インサツ,インサツ,インサツ,0,C2,*,752349454934528,2737
2342刃,18521,20041,11551,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和,ハ濁,基本形,*,*,*,*,体,ハ,ハ,ハ,ハ,1,C3,*,8060803244761600,29325
2343刃,18419,19578,12664,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和,*,*,*,*,*,*,体,ヤイバ,ヤイバ,ヤイバ,ヤイバ,\"1,0\",C1,*,18677687522566656,67949
2344";
2345        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2346
2347        assert_eq!(
2348            index.readings("印刷").as_deref(),
2349            Some(&["インサツ".to_string()][..])
2350        );
2351        assert_eq!(
2352            index.readings("刃").as_deref(),
2353            Some(&["ハ".to_string(), "ヤイバ".to_string()][..])
2354        );
2355    }
2356
2357    #[test]
2358    fn skips_star_readings() {
2359        let csv = "記号,1,2,3,補助記号,一般,*,*,*,*,*,記号,記号,*,記号,*,記号\n";
2360        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2361
2362        assert!(index.is_empty());
2363    }
2364
2365    #[test]
2366    fn excludes_ascii_and_symbol_surfaces_by_default() {
2367        let csv = "\
2368a,1,2,3,記号,文字,*,*,*,*,エー,a,a,エー,a,エー,外
2369!,1,2,3,補助記号,一般,*,*,*,*,!,!,!,!,!,!,記号
2370印刷,1,2,3,名詞,普通名詞,サ変可能,*,*,*,インサツ,印刷,印刷,インサツ,印刷,インサツ,漢
2371";
2372        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2373
2374        assert_eq!(index.readings("a"), None);
2375        assert_eq!(index.readings("!"), None);
2376        assert_eq!(
2377            index.readings("印刷").as_deref(),
2378            Some(&["インサツ".to_string()][..])
2379        );
2380    }
2381
2382    #[test]
2383    fn can_keep_ascii_surfaces_when_requested() {
2384        let csv = "a,1,2,3,名詞,普通名詞,一般,*,*,*,エー,a,a,エー,a,エー,外\n";
2385        let index = UnidicReadingIndex::from_lex_csv_reader_with_options(
2386            csv.as_bytes(),
2387            UnidicIndexOptions {
2388                exclude_ascii_surfaces: false,
2389                ..UnidicIndexOptions::default()
2390            },
2391        )
2392        .unwrap();
2393
2394        assert_eq!(
2395            index.readings("a").as_deref(),
2396            Some(&["エー".to_string()][..])
2397        );
2398    }
2399
2400    #[test]
2401    fn limits_readings_per_surface_when_requested() {
2402        let csv = "\
2403刃,1,2,3,名詞,普通名詞,一般,*,*,*,ジン,刃,刃,ジン,刃,ジン,漢
2404刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
2405刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
2406";
2407        let index = UnidicReadingIndex::from_lex_csv_reader_with_options(
2408            csv.as_bytes(),
2409            UnidicIndexOptions {
2410                max_readings_per_surface: Some(2),
2411                ..UnidicIndexOptions::default()
2412            },
2413        )
2414        .unwrap();
2415
2416        assert_eq!(
2417            index.readings("刃").as_deref(),
2418            Some(&["ジン".to_string(), "ハ".to_string()][..])
2419        );
2420    }
2421
2422    #[test]
2423    fn can_limit_readings_per_segment_at_query_time() {
2424        let csv = "\
2425刃,1,2,3,名詞,普通名詞,一般,*,*,*,ジン,刃,刃,ジン,刃,ジン,漢
2426刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
2427刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
2428";
2429        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2430        let readings = index.reading_sequences(
2431            "刃",
2432            DictionaryReadingOptions {
2433                max_readings_per_segment: Some(2),
2434                ..DictionaryReadingOptions::default()
2435            },
2436        );
2437
2438        assert_eq!(readings, vec!["ジン".to_string(), "ハ".to_string()]);
2439    }
2440
2441    #[test]
2442    fn builds_artifact_metadata_from_index_and_options() {
2443        let csv = "\
2444刃,1,2,3,名詞,普通名詞,一般,*,*,*,ジン,刃,刃,ジン,刃,ジン,漢
2445刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
2446";
2447        let index_options = UnidicIndexOptions {
2448            reading_field: UnidicReadingField::Pron,
2449            max_readings_per_surface: Some(1),
2450            exclude_ascii_surfaces: true,
2451            exclude_symbol_pos: true,
2452        };
2453        let index =
2454            UnidicReadingIndex::from_lex_csv_reader_with_options(csv.as_bytes(), index_options)
2455                .unwrap();
2456
2457        let metadata = index.artifact_metadata(UnidicArtifactMetadataOptions {
2458            artifact_name: "moine-unidic-cwj-202512".to_string(),
2459            generator: "moine-cli".to_string(),
2460            payload_file_name: "moine-unidic-cwj-202512.readings.yaml".to_string(),
2461            payload_format: "yaml.surface-readings.v1".to_string(),
2462            source_name: "UniDic-CWJ".to_string(),
2463            source_version: "2025.12".to_string(),
2464            source_lex_csv: "unidic-cwj-202512_full/lex.csv".to_string(),
2465            index_options,
2466            query_defaults: DictionaryReadingOptions {
2467                longest_match_only: true,
2468                max_readings_per_segment: Some(16),
2469                ..DictionaryReadingOptions::default()
2470            },
2471            license: UnidicArtifactLicense::default(),
2472        });
2473
2474        assert_eq!(metadata.schema_version, 1);
2475        assert_eq!(metadata.artifact_type, "moine.unidic.reading-index");
2476        assert_eq!(
2477            metadata.payload.path,
2478            "moine-unidic-cwj-202512.readings.yaml"
2479        );
2480        assert_eq!(metadata.payload.format, "yaml.surface-readings.v1");
2481        assert_eq!(
2482            metadata.payload.checksum_algorithm,
2483            ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM
2484        );
2485        assert_eq!(metadata.payload.checksum.len(), 64);
2486        assert_eq!(metadata.source.version, "2025.12");
2487        assert_eq!(metadata.build.reading_field, "pron");
2488        assert_eq!(metadata.build.entries, 1);
2489        assert_eq!(metadata.build.max_readings_per_surface, Some(1));
2490        assert!(metadata.query_defaults.longest_match_only);
2491        assert_eq!(metadata.query_defaults.max_readings_per_segment, Some(16));
2492        assert_eq!(metadata.license.selected_license, "BSD-3-Clause");
2493    }
2494
2495    #[test]
2496    fn builds_deterministic_payload_entries() {
2497        let csv = "\
2498刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
2499印刷,1,2,3,名詞,普通名詞,サ変可能,*,*,*,インサツ,印刷,印刷,インサツ,印刷,インサツ,漢
2500刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
2501";
2502        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2503        let payload = index.artifact_payload();
2504
2505        assert_eq!(payload.schema_version, 1);
2506        assert_eq!(
2507            payload.payload_type,
2508            "moine.unidic.reading-index.surface-readings"
2509        );
2510        assert_eq!(
2511            payload.entries,
2512            vec![
2513                UnidicReadingIndexPayloadEntry {
2514                    surface: "刃".to_string(),
2515                    readings: vec!["ハ".to_string(), "ヤイバ".to_string()],
2516                },
2517                UnidicReadingIndexPayloadEntry {
2518                    surface: "印刷".to_string(),
2519                    readings: vec!["インサツ".to_string()],
2520                },
2521            ]
2522        );
2523    }
2524
2525    #[test]
2526    fn payload_checksum_changes_with_payload_content() {
2527        let first = UnidicReadingIndex::from_lex_csv_reader(
2528            "刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和\n".as_bytes(),
2529        )
2530        .unwrap();
2531        let second = UnidicReadingIndex::from_lex_csv_reader(
2532            "刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和\n".as_bytes(),
2533        )
2534        .unwrap();
2535
2536        assert_eq!(first.artifact_payload_checksum().len(), 64);
2537        assert_eq!(
2538            first.artifact_payload_checksum(),
2539            first
2540                .artifact_payload_checksum_for_algorithm(ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM)
2541                .unwrap()
2542        );
2543        assert_eq!(
2544            first
2545                .artifact_payload_checksum_for_algorithm(LEGACY_ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM)
2546                .unwrap()
2547                .len(),
2548            16
2549        );
2550        assert_ne!(
2551            first.artifact_payload_checksum(),
2552            second.artifact_payload_checksum()
2553        );
2554    }
2555
2556    #[test]
2557    fn loads_artifact_payload_back_into_index() {
2558        let payload = UnidicReadingIndexPayload {
2559            schema_version: 1,
2560            payload_type: "moine.unidic.reading-index.surface-readings".to_string(),
2561            entries: vec![UnidicReadingIndexPayloadEntry {
2562                surface: "印刷".to_string(),
2563                readings: vec!["インサツ".to_string()],
2564            }],
2565        };
2566
2567        let index = UnidicReadingIndex::from_artifact_payload(payload).unwrap();
2568
2569        assert_eq!(index.len(), 1);
2570        assert_eq!(
2571            index.readings("印刷").as_deref(),
2572            Some(&["インサツ".to_string()][..])
2573        );
2574    }
2575
2576    #[test]
2577    fn loads_artifact_payload_reader() {
2578        let yaml = "\
2579schema_version: 1
2580payload_type: moine.unidic.reading-index.surface-readings
2581entries:
2582- surface: 刃
2583  readings:
2584  - ハ
2585  - ヤイバ
2586";
2587
2588        let index = UnidicReadingIndex::from_artifact_payload_reader(yaml.as_bytes()).unwrap();
2589
2590        assert_eq!(
2591            index.readings("刃").as_deref(),
2592            Some(&["ハ".to_string(), "ヤイバ".to_string()][..])
2593        );
2594    }
2595
2596    #[test]
2597    fn binary_artifact_payload_round_trips_to_equivalent_index() {
2598        let csv = "\
2599刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
2600刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
2601印刷,1,2,3,名詞,普通名詞,サ変可能,*,*,*,インサツ,印刷,印刷,インサツ,印刷,インサツ,漢
2602";
2603        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2604        let mut bytes = Vec::new();
2605
2606        index.write_artifact_binary_payload(&mut bytes).unwrap();
2607        let loaded = UnidicReadingIndex::from_binary_artifact_payload_reader(bytes.as_slice())
2608            .expect("binary payload should load");
2609        let header = UnidicReadingIndex::binary_artifact_payload_header_reader(bytes.as_slice())
2610            .expect("binary payload header should load");
2611
2612        assert_eq!(
2613            header,
2614            UnidicBinaryArtifactPayloadHeader {
2615                version: 1,
2616                entries: 2,
2617            }
2618        );
2619        assert_eq!(loaded.artifact_payload(), index.artifact_payload());
2620        assert_eq!(
2621            loaded.artifact_payload_checksum(),
2622            index.artifact_payload_checksum()
2623        );
2624    }
2625
2626    #[test]
2627    fn indexed_artifact_payload_round_trips_and_supports_lookup() {
2628        let csv = "\
2629刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
2630刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
2631印刷,1,2,3,名詞,普通名詞,サ変可能,*,*,*,インサツ,印刷,印刷,インサツ,印刷,インサツ,漢
2632";
2633        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2634        let mut bytes = Vec::new();
2635        index.write_indexed_artifact_payload(&mut bytes).unwrap();
2636
2637        let unique = std::time::SystemTime::now()
2638            .duration_since(std::time::UNIX_EPOCH)
2639            .unwrap()
2640            .as_nanos();
2641        let path = std::env::temp_dir().join(format!(
2642            "moine-indexed-test-{}-{}.moineidx",
2643            std::process::id(),
2644            unique
2645        ));
2646        std::fs::write(&path, &bytes).unwrap();
2647        let loaded = UnidicReadingIndex::from_indexed_artifact_payload_path(&path)
2648            .expect("indexed payload should load");
2649        let _ = std::fs::remove_file(&path);
2650        let loaded_from_bytes = UnidicReadingIndex::from_indexed_artifact_payload_bytes(&bytes)
2651            .expect("indexed payload bytes should load");
2652
2653        assert_eq!(loaded.len(), 2);
2654        assert_eq!(
2655            loaded.readings("刃").as_deref(),
2656            Some(&["ハ".to_string(), "ヤイバ".to_string()][..])
2657        );
2658        assert_eq!(
2659            loaded_from_bytes.artifact_payload(),
2660            index.artifact_payload()
2661        );
2662        assert_eq!(loaded.artifact_payload(), index.artifact_payload());
2663        assert_eq!(
2664            loaded.artifact_payload_checksum(),
2665            index.artifact_payload_checksum()
2666        );
2667        assert_eq!(
2668            loaded.reading_sequences("印刷", DictionaryReadingOptions::default()),
2669            vec!["インサツ".to_string()]
2670        );
2671    }
2672
2673    #[test]
2674    fn binary_artifact_payload_uses_stable_little_endian_layout() {
2675        let csv = "\
2676刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
2677刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
2678印刷,1,2,3,名詞,普通名詞,サ変可能,*,*,*,インサツ,印刷,印刷,インサツ,印刷,インサツ,漢
2679";
2680        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2681        let mut bytes = Vec::new();
2682
2683        index.write_artifact_binary_payload(&mut bytes).unwrap();
2684
2685        #[rustfmt::skip]
2686        let expected = vec![
2687            b'M', b'O', b'I', b'N', b'E', b'U', b'0', b'1',
2688            1, 0, 0, 0,
2689            0, 0, 0, 0,
2690            2, 0, 0, 0, 0, 0, 0, 0,
2691            3, 0, 0, 0, 0xe5, 0x88, 0x83,
2692            2, 0, 0, 0,
2693            3, 0, 0, 0, 0xe3, 0x83, 0x8f,
2694            9, 0, 0, 0, 0xe3, 0x83, 0xa4, 0xe3, 0x82, 0xa4, 0xe3, 0x83, 0x90,
2695            6, 0, 0, 0, 0xe5, 0x8d, 0xb0, 0xe5, 0x88, 0xb7,
2696            1, 0, 0, 0,
2697            12, 0, 0, 0, 0xe3, 0x82, 0xa4, 0xe3, 0x83, 0xb3, 0xe3, 0x82, 0xb5, 0xe3, 0x83, 0x84,
2698        ];
2699        assert_eq!(bytes, expected);
2700    }
2701
2702    #[test]
2703    fn rejects_binary_artifact_bad_magic() {
2704        let bytes = *b"NOTMOINE";
2705        let err =
2706            UnidicReadingIndex::from_binary_artifact_payload_reader(bytes.as_slice()).unwrap_err();
2707
2708        assert!(matches!(
2709            err,
2710            UnidicArtifactPayloadError::InvalidBinaryMagic { .. }
2711        ));
2712    }
2713
2714    #[test]
2715    fn rejects_binary_artifact_unsupported_version() {
2716        let mut bytes = Vec::new();
2717        bytes.extend_from_slice(b"MOINEU01");
2718        bytes.extend_from_slice(&2_u32.to_le_bytes());
2719        bytes.extend_from_slice(&0_u32.to_le_bytes());
2720        bytes.extend_from_slice(&0_u64.to_le_bytes());
2721
2722        let err =
2723            UnidicReadingIndex::from_binary_artifact_payload_reader(bytes.as_slice()).unwrap_err();
2724
2725        assert!(matches!(
2726            err,
2727            UnidicArtifactPayloadError::UnsupportedBinaryVersion { version: 2 }
2728        ));
2729    }
2730
2731    #[test]
2732    fn rejects_binary_artifact_truncated_string() {
2733        let mut bytes = Vec::new();
2734        bytes.extend_from_slice(b"MOINEU01");
2735        bytes.extend_from_slice(&1_u32.to_le_bytes());
2736        bytes.extend_from_slice(&0_u32.to_le_bytes());
2737        bytes.extend_from_slice(&1_u64.to_le_bytes());
2738        bytes.extend_from_slice(&4_u32.to_le_bytes());
2739        bytes.extend_from_slice("刃".as_bytes());
2740
2741        let err =
2742            UnidicReadingIndex::from_binary_artifact_payload_reader(bytes.as_slice()).unwrap_err();
2743
2744        assert!(matches!(
2745            err,
2746            UnidicArtifactPayloadError::TruncatedBinary { field: "surface" }
2747        ));
2748    }
2749
2750    #[test]
2751    fn rejects_binary_artifact_invalid_utf8() {
2752        let mut bytes = Vec::new();
2753        bytes.extend_from_slice(b"MOINEU01");
2754        bytes.extend_from_slice(&1_u32.to_le_bytes());
2755        bytes.extend_from_slice(&0_u32.to_le_bytes());
2756        bytes.extend_from_slice(&1_u64.to_le_bytes());
2757        bytes.extend_from_slice(&1_u32.to_le_bytes());
2758        bytes.push(0xff);
2759        bytes.extend_from_slice(&0_u32.to_le_bytes());
2760
2761        let err =
2762            UnidicReadingIndex::from_binary_artifact_payload_reader(bytes.as_slice()).unwrap_err();
2763
2764        assert!(matches!(
2765            err,
2766            UnidicArtifactPayloadError::InvalidBinaryUtf8 {
2767                field: "surface",
2768                ..
2769            }
2770        ));
2771    }
2772
2773    #[test]
2774    fn rejects_binary_artifact_excessive_entry_count() {
2775        let mut bytes = Vec::new();
2776        bytes.extend_from_slice(b"MOINEU01");
2777        bytes.extend_from_slice(&1_u32.to_le_bytes());
2778        bytes.extend_from_slice(&0_u32.to_le_bytes());
2779        bytes.extend_from_slice(&((MAX_ARTIFACT_ENTRIES as u64) + 1).to_le_bytes());
2780
2781        let err =
2782            UnidicReadingIndex::from_binary_artifact_payload_reader(bytes.as_slice()).unwrap_err();
2783
2784        assert!(matches!(
2785            err,
2786            UnidicArtifactPayloadError::ArtifactLimitExceeded {
2787                field: "entry_count",
2788                ..
2789            }
2790        ));
2791    }
2792
2793    #[test]
2794    fn rejects_excessive_dictionary_reading_options() {
2795        let index = UnidicReadingIndex::default();
2796        let err = index
2797            .try_reading_paths_with_stats(
2798                "印刷",
2799                DictionaryReadingOptions {
2800                    max_span_chars: MAX_QUERY_SPAN_CHARS + 1,
2801                    ..DictionaryReadingOptions::default()
2802                },
2803            )
2804            .unwrap_err();
2805
2806        assert!(matches!(
2807            err,
2808            UnidicArtifactPayloadError::ArtifactLimitExceeded {
2809                field: "max_span_chars",
2810                ..
2811            }
2812        ));
2813
2814        let err = index
2815            .try_reading_paths_with_stats(
2816                "印刷",
2817                DictionaryReadingOptions {
2818                    max_paths: MAX_QUERY_PATHS + 1,
2819                    ..DictionaryReadingOptions::default()
2820                },
2821            )
2822            .unwrap_err();
2823
2824        assert!(matches!(
2825            err,
2826            UnidicArtifactPayloadError::ArtifactLimitExceeded {
2827                field: "max_paths",
2828                ..
2829            }
2830        ));
2831
2832        let err = index
2833            .try_reading_paths_with_stats(
2834                "印刷",
2835                DictionaryReadingOptions {
2836                    max_readings_per_segment: Some(MAX_QUERY_READINGS_PER_SEGMENT + 1),
2837                    ..DictionaryReadingOptions::default()
2838                },
2839            )
2840            .unwrap_err();
2841
2842        assert!(matches!(
2843            err,
2844            UnidicArtifactPayloadError::ArtifactLimitExceeded {
2845                field: "max_readings_per_segment",
2846                ..
2847            }
2848        ));
2849    }
2850
2851    #[test]
2852    fn rejects_artifact_payload_duplicate_surfaces() {
2853        let payload = UnidicReadingIndexPayload {
2854            schema_version: 1,
2855            payload_type: "moine.unidic.reading-index.surface-readings".to_string(),
2856            entries: vec![
2857                UnidicReadingIndexPayloadEntry {
2858                    surface: "刃".to_string(),
2859                    readings: vec!["ハ".to_string()],
2860                },
2861                UnidicReadingIndexPayloadEntry {
2862                    surface: "刃".to_string(),
2863                    readings: vec!["ヤイバ".to_string()],
2864                },
2865            ],
2866        };
2867
2868        let err = UnidicReadingIndex::from_artifact_payload(payload).unwrap_err();
2869
2870        assert!(matches!(
2871            err,
2872            UnidicArtifactPayloadError::DuplicateSurface { surface } if surface == "刃"
2873        ));
2874    }
2875
2876    #[test]
2877    fn rejects_artifact_payload_duplicate_readings() {
2878        let payload = UnidicReadingIndexPayload {
2879            schema_version: 1,
2880            payload_type: "moine.unidic.reading-index.surface-readings".to_string(),
2881            entries: vec![UnidicReadingIndexPayloadEntry {
2882                surface: "刃".to_string(),
2883                readings: vec!["ハ".to_string(), "ハ".to_string()],
2884            }],
2885        };
2886
2887        let err = UnidicReadingIndex::from_artifact_payload(payload).unwrap_err();
2888
2889        assert!(matches!(
2890            err,
2891            UnidicArtifactPayloadError::DuplicateReading { surface, reading }
2892                if surface == "刃" && reading == "ハ"
2893        ));
2894    }
2895
2896    #[test]
2897    fn rejects_artifact_payload_excessive_reading_count() {
2898        let payload = UnidicReadingIndexPayload {
2899            schema_version: 1,
2900            payload_type: "moine.unidic.reading-index.surface-readings".to_string(),
2901            entries: vec![UnidicReadingIndexPayloadEntry {
2902                surface: "刃".to_string(),
2903                readings: vec!["ハ".to_string(); MAX_ARTIFACT_READINGS_PER_ENTRY + 1],
2904            }],
2905        };
2906
2907        let err = UnidicReadingIndex::from_artifact_payload(payload).unwrap_err();
2908
2909        assert!(matches!(
2910            err,
2911            UnidicArtifactPayloadError::ArtifactLimitExceeded {
2912                field: "reading_count",
2913                ..
2914            }
2915        ));
2916    }
2917
2918    #[test]
2919    fn rejects_artifact_payload_schema_mismatch() {
2920        let payload = UnidicReadingIndexPayload {
2921            schema_version: 2,
2922            payload_type: "moine.unidic.reading-index.surface-readings".to_string(),
2923            entries: Vec::new(),
2924        };
2925
2926        let err = UnidicReadingIndex::from_artifact_payload(payload).unwrap_err();
2927
2928        assert!(matches!(
2929            err,
2930            UnidicArtifactPayloadError::UnsupportedSchemaVersion { version: 2 }
2931        ));
2932    }
2933
2934    #[test]
2935    fn reports_reading_expansion_stats() {
2936        let csv = "\
2937刃,1,2,3,名詞,普通名詞,一般,*,*,*,ジン,刃,刃,ジン,刃,ジン,漢
2938刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
2939刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
2940";
2941        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2942        let expansion = index.reading_paths_with_stats(
2943            "刃",
2944            DictionaryReadingOptions {
2945                max_readings_per_segment: Some(2),
2946                ..DictionaryReadingOptions::default()
2947            },
2948        );
2949
2950        assert_eq!(expansion.paths.len(), 2);
2951        assert_eq!(
2952            expansion.stats,
2953            DictionaryReadingStats {
2954                matched_spans: 1,
2955                direct_fallback_spans: 0,
2956                longest_match_pruned_spans: 0,
2957                raw_segment_readings: 3,
2958                used_segment_readings: 2,
2959                pruned_segment_readings: 1,
2960                candidate_combinations: 2,
2961                unique_paths: 2,
2962                duplicate_joined_readings: 0,
2963                max_paths_hit_count: 0,
2964            }
2965        );
2966    }
2967
2968    #[test]
2969    fn reports_longest_match_and_path_limit_stats() {
2970        let csv = "\
2971茶,1,2,3,名詞,普通名詞,一般,*,*,*,チャ,茶,茶,チャ,茶,チャ,和
2972道,1,2,3,名詞,普通名詞,一般,*,*,*,ミチ,道,道,ミチ,道,ミチ,和
2973道具,1,2,3,名詞,普通名詞,一般,*,*,*,ドウグ,道具,道具,ドーグ,道具,ドーグ,和
2974具,1,2,3,名詞,普通名詞,一般,*,*,*,グ,具,具,グ,具,グ,和
2975";
2976        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2977        let expansion = index.reading_paths_with_stats(
2978            "茶道具",
2979            DictionaryReadingOptions {
2980                longest_match_only: true,
2981                max_paths: 1,
2982                ..DictionaryReadingOptions::default()
2983            },
2984        );
2985
2986        assert_eq!(expansion.paths.len(), 1);
2987        assert!(expansion.stats.longest_match_pruned_spans > 0);
2988        assert!(expansion.stats.max_paths_hit_count > 0);
2989    }
2990
2991    #[test]
2992    fn hybrid_reading_paths_use_direct_fallback_for_kana_ascii_spans() {
2993        let csv = "\
2994印,1,2,3,名詞,普通名詞,一般,*,*,*,イン,印,印,イン,印,イン,漢
2995";
2996        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2997        let expansion =
2998            index.hybrid_reading_paths_with_stats("印さt", DictionaryReadingOptions::default());
2999
3000        assert_eq!(
3001            expansion.paths,
3002            vec![DictionaryReadingPath {
3003                joined_reading: "インさt".to_string(),
3004                segments: vec![
3005                    DictionaryReadingSegment {
3006                        surface: "印".to_string(),
3007                        reading: "イン".to_string(),
3008                        source: DictionaryReadingSegmentSource::Dictionary,
3009                    },
3010                    DictionaryReadingSegment {
3011                        surface: "さt".to_string(),
3012                        reading: "さt".to_string(),
3013                        source: DictionaryReadingSegmentSource::Direct,
3014                    },
3015                ],
3016            }]
3017        );
3018        assert_eq!(expansion.stats.direct_fallback_spans, 2);
3019    }
3020
3021    #[test]
3022    fn hybrid_reading_paths_keep_shorter_dictionary_spans_for_direct_tail() {
3023        let csv = "\
3024印,1,2,3,名詞,普通名詞,一般,*,*,*,イン,印,印,イン,印,イン,漢
3025印さ,1,2,3,動詞,一般,*,*,*,*,シルス,印す,印す,シルス,印す,シルス,和
3026";
3027        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
3028        let expansion = index.hybrid_reading_paths_with_stats(
3029            "印さt",
3030            DictionaryReadingOptions {
3031                longest_match_only: true,
3032                ..DictionaryReadingOptions::default()
3033            },
3034        );
3035
3036        assert!(expansion
3037            .paths
3038            .iter()
3039            .any(|path| path.joined_reading == "インさt"));
3040        assert_eq!(expansion.stats.longest_match_pruned_spans, 0);
3041    }
3042
3043    #[test]
3044    fn hybrid_reading_paths_still_reject_uncovered_kanji() {
3045        let index = UnidicReadingIndex::default();
3046        let expansion =
3047            index.hybrid_reading_paths_with_stats("未知z", DictionaryReadingOptions::default());
3048
3049        assert!(expansion.paths.is_empty());
3050        assert_eq!(expansion.stats.direct_fallback_spans, 1);
3051    }
3052
3053    #[test]
3054    fn can_use_pron_instead_of_lform() {
3055        let csv = "\
3056刃,18521,20041,11551,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和,ハ濁,基本形,*,*,*,*,体,ハ,ハ,ハ,ハ,1,C3,*,8060803244761600,29325
3057刃,18521,20055,14836,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,バ,刃,バ,和,ハ濁,濁音形,*,*,*,*,体,バ,バ,バ,ハ,1,C3,*,8060803244769792,29325
3058";
3059        let index = UnidicReadingIndex::from_lex_csv_reader_with_field(
3060            csv.as_bytes(),
3061            UnidicReadingField::Pron,
3062        )
3063        .unwrap();
3064
3065        assert_eq!(
3066            index.readings("刃").as_deref(),
3067            Some(&["ハ".to_string(), "バ".to_string()][..])
3068        );
3069    }
3070
3071    #[test]
3072    fn fullwidth_ascii_surfaces_are_indexed_under_halfwidth_aliases() {
3073        let csv = "\
3074WHISKY,1,2,3,名詞,普通名詞,一般,*,*,*,ウイスキー,WHISKY,WHISKY,ウイスキー,WHISKY,ウイスキー,外
3075WHISKEY,1,2,3,名詞,普通名詞,一般,*,*,*,ウイスキー,WHISKEY,WHISKEY,ウイスキー,WHISKEY,ウイスキー,外
3076MALT,1,2,3,名詞,普通名詞,一般,*,*,*,モルト,MALT,MALT,モルト,MALT,モルト,外
3077abc,1,2,3,名詞,固有名詞,一般,*,*,*,エービーシー,abc,abc,エービーシー,abc,エービーシー,外
3078";
3079        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
3080
3081        assert_eq!(
3082            index.readings("WHISKY").as_deref(),
3083            Some(&["ウイスキー".to_string()][..])
3084        );
3085        assert_eq!(
3086            index.readings("WHISKY").as_deref(),
3087            Some(&["ウイスキー".to_string()][..])
3088        );
3089        assert_eq!(
3090            index.readings("WHISKEY").as_deref(),
3091            Some(&["ウイスキー".to_string()][..])
3092        );
3093        assert_eq!(
3094            index.readings("WHISKEY").as_deref(),
3095            Some(&["ウイスキー".to_string()][..])
3096        );
3097        assert_eq!(
3098            index.readings("MALT").as_deref(),
3099            Some(&["モルト".to_string()][..])
3100        );
3101        assert_eq!(index.readings("abc"), None);
3102    }
3103
3104    #[test]
3105    fn builds_reading_sequences_from_dictionary_segments() {
3106        let csv = "\
3107鬼滅,1,2,3,名詞,普通名詞,一般,*,*,*,キメツ,鬼滅,鬼滅,キメツ,鬼滅,キメツ,固
3108の,1,2,3,助詞,格助詞,*,*,*,*,ノ,の,の,ノ,の,ノ,和
3109刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
3110刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
3111";
3112        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
3113        let readings = index.reading_sequences("鬼滅の刃", DictionaryReadingOptions::default());
3114
3115        assert_eq!(
3116            readings,
3117            vec!["キメツノハ".to_string(), "キメツノヤイバ".to_string()]
3118        );
3119    }
3120
3121    #[test]
3122    fn reading_paths_keep_segmentation_and_segment_readings() {
3123        let csv = "\
3124茶,1,2,3,名詞,普通名詞,一般,*,*,*,チャ,茶,茶,チャ,茶,チャ,和
3125道具,1,2,3,名詞,普通名詞,一般,*,*,*,ドウグ,道具,道具,ドーグ,道具,ドーグ,漢
3126";
3127        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
3128        let paths = index.reading_paths(
3129            "茶道具",
3130            DictionaryReadingOptions {
3131                longest_match_only: true,
3132                ..DictionaryReadingOptions::default()
3133            },
3134        );
3135
3136        assert_eq!(
3137            paths,
3138            vec![DictionaryReadingPath {
3139                joined_reading: "チャドーグ".to_string(),
3140                segments: vec![
3141                    DictionaryReadingSegment {
3142                        surface: "茶".to_string(),
3143                        reading: "チャ".to_string(),
3144                        source: DictionaryReadingSegmentSource::Dictionary,
3145                    },
3146                    DictionaryReadingSegment {
3147                        surface: "道具".to_string(),
3148                        reading: "ドーグ".to_string(),
3149                        source: DictionaryReadingSegmentSource::Dictionary,
3150                    },
3151                ],
3152            }]
3153        );
3154    }
3155
3156    #[test]
3157    fn builds_romaji_lattice_from_dictionary_segments() {
3158        let csv = "\
3159茶,1,2,3,名詞,普通名詞,一般,*,*,*,チャ,茶,茶,チャ,茶,チャ,和
3160道具,1,2,3,名詞,普通名詞,一般,*,*,*,ドウグ,道具,道具,ドーグ,道具,ドーグ,和
3161";
3162        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
3163        let lattice = index
3164            .romaji_lattice("茶道具", DictionaryReadingOptions::default())
3165            .unwrap()
3166            .unwrap();
3167
3168        assert_eq!(
3169            moine_core::distance(&lattice, &Lattice::from_paths(["chadougu"])),
3170            0
3171        );
3172    }
3173
3174    #[test]
3175    fn builds_romaji_lattice_directly_from_reading_paths() {
3176        let paths = vec![
3177            DictionaryReadingPath {
3178                joined_reading: "チャドウグ".to_string(),
3179                segments: vec![
3180                    DictionaryReadingSegment {
3181                        surface: "茶".to_string(),
3182                        reading: "チャ".to_string(),
3183                        source: DictionaryReadingSegmentSource::Dictionary,
3184                    },
3185                    DictionaryReadingSegment {
3186                        surface: "道具".to_string(),
3187                        reading: "ドウグ".to_string(),
3188                        source: DictionaryReadingSegmentSource::Dictionary,
3189                    },
3190                ],
3191            },
3192            DictionaryReadingPath {
3193                joined_reading: "チャドーグ".to_string(),
3194                segments: vec![
3195                    DictionaryReadingSegment {
3196                        surface: "茶".to_string(),
3197                        reading: "チャ".to_string(),
3198                        source: DictionaryReadingSegmentSource::Dictionary,
3199                    },
3200                    DictionaryReadingSegment {
3201                        surface: "道具".to_string(),
3202                        reading: "ドーグ".to_string(),
3203                        source: DictionaryReadingSegmentSource::Dictionary,
3204                    },
3205                ],
3206            },
3207        ];
3208        let lattice = romaji_lattice_from_reading_paths(&paths).unwrap();
3209
3210        assert_eq!(
3211            moine_core::distance(&lattice, &Lattice::from_paths(["chadougu"])),
3212            0
3213        );
3214        assert_eq!(
3215            moine_core::distance(&lattice, &Lattice::from_paths(["chadoogu"])),
3216            0
3217        );
3218    }
3219
3220    #[test]
3221    fn structured_reading_paths_keep_cross_segment_context() {
3222        let paths = vec![DictionaryReadingPath {
3223            joined_reading: "マッチャ".to_string(),
3224            segments: vec![
3225                DictionaryReadingSegment {
3226                    surface: "抹".to_string(),
3227                    reading: "マッ".to_string(),
3228                    source: DictionaryReadingSegmentSource::Dictionary,
3229                },
3230                DictionaryReadingSegment {
3231                    surface: "茶".to_string(),
3232                    reading: "チャ".to_string(),
3233                    source: DictionaryReadingSegmentSource::Dictionary,
3234                },
3235            ],
3236        }];
3237        let lattice = romaji_lattice_from_reading_paths(&paths).unwrap();
3238
3239        assert_eq!(
3240            moine_core::distance(&lattice, &Lattice::from_paths(["maccha"])),
3241            0
3242        );
3243        assert_eq!(
3244            moine_core::distance(&lattice, &Lattice::from_paths(["mattya"])),
3245            0
3246        );
3247    }
3248
3249    #[test]
3250    fn can_restrict_reading_sequences_to_longest_matches() {
3251        let csv = "\
3252茶,1,2,3,名詞,普通名詞,一般,*,*,*,チャ,茶,茶,チャ,茶,チャ,和
3253道,1,2,3,名詞,普通名詞,一般,*,*,*,ミチ,道,道,ミチ,道,ミチ,和
3254道具,1,2,3,名詞,普通名詞,一般,*,*,*,ドウグ,道具,道具,ドーグ,道具,ドーグ,和
3255具,1,2,3,名詞,普通名詞,一般,*,*,*,グ,具,具,グ,具,グ,和
3256";
3257        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
3258        let readings = index.reading_sequences(
3259            "茶道具",
3260            DictionaryReadingOptions {
3261                longest_match_only: true,
3262                ..DictionaryReadingOptions::default()
3263            },
3264        );
3265
3266        assert_eq!(readings, vec!["チャドーグ".to_string()]);
3267    }
3268}