1use std::collections::HashMap;
4use std::fs;
5use std::io::ErrorKind;
6use std::path::{Path, PathBuf};
7use std::sync::{Mutex, OnceLock, PoisonError};
8use std::time::{Duration, SystemTime};
9
10use rusqlite::{params, OpenFlags, TransactionBehavior};
11use sha2::{Digest, Sha256};
12
13use crate::assets;
14use crate::diagnostics;
15use crate::error::{EngineError, Result};
16use crate::paths::RuntimePaths;
17use crate::types::{PersonalDictionaryKind, SchemeSet};
18use crate::user_dictionary::generation::{
19 prepare_runtime_paths_for, roots_overlap, weakly_canonical,
20};
21use crate::user_dictionary::journal::{ensure_schema, open_database};
22
23pub const DEFAULT_MAXIMUM_RECORDS: usize = 500_000;
24
25const MAX_KEY_BYTES: usize = 512;
26const MAX_VALUE_BYTES: usize = 4_096;
27const MAX_STAGED_WEIGHT: i64 = 100_000_000;
28const MAX_STAGED_SELECTION_COUNT: i64 = 10;
29
30const REVISION_DOMAIN: &str = "msime-local-dictionary-state-v1";
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum DictionaryStateRecord {
36 Entry {
37 kind: PersonalDictionaryKind,
38 key: String,
39 value: String,
40 weight: i64,
41 display: String,
42 deleted: bool,
43 user_inserted: bool,
44 },
45 Position {
46 context: String,
47 key: String,
48 value: String,
49 position: i64,
50 },
51 Selection {
52 context: String,
53 key: String,
54 value: String,
55 count: i64,
56 },
57}
58
59fn invalid_state() -> EngineError {
61 EngineError::failed(diagnostics::INVALID_DICTIONARY_STATE)
62}
63
64fn require(condition: bool) -> Result<()> {
65 if condition {
66 Ok(())
67 } else {
68 Err(invalid_state())
69 }
70}
71
72pub fn stream_dictionary_state(
76 paths: &RuntimePaths,
77 emit: &mut dyn FnMut(&DictionaryStateRecord) -> bool,
78) -> Result<()> {
79 paths.validate()?;
80 let file = paths.user(assets::USER_JOURNAL);
81 if !file.exists() {
82 return Ok(());
83 }
84 read_state(&file, emit).map_err(|error| match error {
85 EngineError::Sqlite(_) => invalid_state(),
86 other => other,
87 })
88}
89
90fn read_state(file: &Path, emit: &mut dyn FnMut(&DictionaryStateRecord) -> bool) -> Result<()> {
91 let mut connection = open_database(file, OpenFlags::SQLITE_OPEN_READ_WRITE)?;
93 connection.execute_batch("PRAGMA query_only=ON")?;
94 let transaction = connection.transaction_with_behavior(TransactionBehavior::Deferred)?;
95 let mut records = 0usize;
96 {
97 let mut rows = transaction.prepare(
98 "SELECT dictionary,key,value,weight,display,operation,user_inserted,
99 length(CAST(dictionary AS BLOB)),length(CAST(key AS BLOB)),
100 length(CAST(value AS BLOB)),length(CAST(display AS BLOB)),
101 length(CAST(operation AS BLOB))
102 FROM user_dictionary_operations ORDER BY dictionary,key,value",
103 )?;
104 let mut cursor = rows.query([])?;
105 while let Some(row) = cursor.next()? {
106 require(records < DEFAULT_MAXIMUM_RECORDS)?;
107 require(
108 row.get::<_, Option<i64>>(7)?
109 .is_some_and(|length| (0..=32).contains(&length))
110 && row
111 .get::<_, Option<i64>>(8)?
112 .is_some_and(|length| (0..=MAX_KEY_BYTES as i64).contains(&length))
113 && row
114 .get::<_, Option<i64>>(9)?
115 .is_some_and(|length| (0..=MAX_VALUE_BYTES as i64).contains(&length))
116 && row
117 .get::<_, Option<i64>>(10)?
118 .is_some_and(|length| (0..=MAX_VALUE_BYTES as i64).contains(&length))
119 && row
120 .get::<_, Option<i64>>(11)?
121 .is_some_and(|length| (0..=16).contains(&length)),
122 )?;
123 let kind = PersonalDictionaryKind::from_journal_name(&row.get::<_, String>(0)?)
124 .ok_or_else(invalid_state)?;
125 let record = DictionaryStateRecord::Entry {
127 kind,
128 key: row.get(1)?,
129 value: row.get(2)?,
130 weight: row.get::<_, Option<i64>>(3)?.unwrap_or(0),
131 display: row.get(4)?,
132 deleted: row.get::<_, String>(5)? == "delete",
133 user_inserted: row.get::<_, Option<i64>>(6)?.unwrap_or(0) != 0,
134 };
135 require(emit(&record))?;
136 records += 1;
137 }
138 }
139 for fixed in [true, false] {
140 let mut rows = transaction.prepare(if fixed {
141 "SELECT context_key,entry_key,value,position,
142 length(CAST(context_key AS BLOB)),length(CAST(entry_key AS BLOB)),
143 length(CAST(value AS BLOB))
144 FROM fixed_candidate_positions ORDER BY context_key,entry_key,value"
145 } else {
146 "SELECT context_key,entry_key,value,selection_count,
147 length(CAST(context_key AS BLOB)),length(CAST(entry_key AS BLOB)),
148 length(CAST(value AS BLOB))
149 FROM candidate_selection_state ORDER BY context_key,entry_key,value"
150 })?;
151 let mut cursor = rows.query([])?;
152 while let Some(row) = cursor.next()? {
153 require(records < DEFAULT_MAXIMUM_RECORDS)?;
154 require(
155 row.get::<_, Option<i64>>(4)?
156 .is_some_and(|length| (0..=MAX_KEY_BYTES as i64).contains(&length))
157 && row
158 .get::<_, Option<i64>>(5)?
159 .is_some_and(|length| (0..=MAX_KEY_BYTES as i64).contains(&length))
160 && row
161 .get::<_, Option<i64>>(6)?
162 .is_some_and(|length| (0..=MAX_VALUE_BYTES as i64).contains(&length)),
163 )?;
164 let context: String = row.get(0)?;
165 let key: String = row.get(1)?;
166 let value: String = row.get(2)?;
167 let number = row.get::<_, Option<i64>>(3)?.unwrap_or(0);
168 let record = if fixed {
169 DictionaryStateRecord::Position {
170 context,
171 key,
172 value,
173 position: number,
174 }
175 } else {
176 DictionaryStateRecord::Selection {
177 context,
178 key,
179 value,
180 count: number,
181 }
182 };
183 require(emit(&record))?;
184 records += 1;
185 }
186 }
187 transaction.commit()?;
188 Ok(())
189}
190
191pub fn dictionary_state_revision(paths: &RuntimePaths) -> Result<String> {
195 state_revision(paths, false, SystemTime::now)
196}
197
198pub fn cached_dictionary_state_revision(paths: &RuntimePaths) -> Result<String> {
204 state_revision(paths, true, SystemTime::now)
205}
206
207fn state_revision(
209 paths: &RuntimePaths,
210 use_cache: bool,
211 now: impl FnOnce() -> SystemTime,
212) -> Result<String> {
213 paths.validate()?;
214 let journal = paths.user(assets::USER_JOURNAL);
215 let stamp = JournalStamp::read(&journal);
217 if use_cache {
218 if let Some(stamp) = &stamp {
219 let cache = revision_cache()
220 .lock()
221 .unwrap_or_else(PoisonError::into_inner);
222 if let Some((cached, revision)) = cache.get(&journal) {
223 if cached == stamp {
224 return Ok(revision.clone());
225 }
226 }
227 }
228 }
229 let mut revision = Revision::new();
230 stream_dictionary_state(paths, &mut |record| {
231 revision.record(record);
232 true
233 })?;
234 let revision = revision.finish();
235 if let Some(stamp) = stamp.filter(|stamp| stamp.settled(now())) {
236 let mut cache = revision_cache()
237 .lock()
238 .unwrap_or_else(PoisonError::into_inner);
239 if cache.len() >= REVISION_CACHE_LIMIT && !cache.contains_key(&journal) {
240 cache.clear();
241 }
242 cache.insert(journal, (stamp, revision.clone()));
243 }
244 Ok(revision)
245}
246
247pub(crate) const REVISION_SETTLE: Duration = Duration::from_secs(2);
249
250const REVISION_CACHE_LIMIT: usize = 16;
252
253fn revision_cache() -> &'static Mutex<HashMap<PathBuf, (JournalStamp, String)>> {
255 static CACHE: OnceLock<Mutex<HashMap<PathBuf, (JournalStamp, String)>>> = OnceLock::new();
256 CACHE.get_or_init(Default::default)
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
261struct JournalStamp([Option<FileStamp>; 3]);
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264struct FileStamp {
265 length: u64,
266 modified: SystemTime,
267 #[cfg(unix)]
268 device: u64,
269 #[cfg(unix)]
270 inode: u64,
271 #[cfg(unix)]
272 changed: (i64, i64),
273}
274
275impl JournalStamp {
276 fn read(journal: &Path) -> Option<Self> {
278 let side = |suffix: &str| {
279 let mut name = journal.as_os_str().to_owned();
280 name.push(suffix);
281 PathBuf::from(name)
282 };
283 Some(Self([
284 FileStamp::read(journal, false)?,
285 FileStamp::read(&side("-wal"), true)?,
286 FileStamp::read(&side("-journal"), true)?,
287 ]))
288 }
289
290 fn settled(&self, now: SystemTime) -> bool {
291 let Some(horizon) = now.checked_sub(REVISION_SETTLE) else {
292 return false;
293 };
294 self.0
295 .iter()
296 .flatten()
297 .all(|file| file.last_change().is_some_and(|time| time < horizon))
298 }
299}
300
301impl FileStamp {
302 fn read(path: &Path, empty_is_absent: bool) -> Option<Option<Self>> {
303 let metadata = match fs::symlink_metadata(path) {
304 Ok(metadata) => metadata,
305 Err(error) if error.kind() == ErrorKind::NotFound => return Some(None),
306 Err(_) => return None,
307 };
308 if empty_is_absent && metadata.len() == 0 {
309 return Some(None);
310 }
311 #[cfg(unix)]
312 use std::os::unix::fs::MetadataExt;
313 Some(Some(Self {
314 length: metadata.len(),
315 modified: metadata.modified().ok()?,
316 #[cfg(unix)]
317 device: metadata.dev(),
318 #[cfg(unix)]
319 inode: metadata.ino(),
320 #[cfg(unix)]
321 changed: (metadata.ctime(), metadata.ctime_nsec()),
322 }))
323 }
324
325 fn last_change(&self) -> Option<SystemTime> {
327 #[cfg(unix)]
328 {
329 let seconds = u64::try_from(self.changed.0).ok()?;
330 let nanos = u32::try_from(self.changed.1).ok()?;
331 let changed = SystemTime::UNIX_EPOCH.checked_add(Duration::new(seconds, nanos))?;
332 Some(self.modified.max(changed))
333 }
334 #[cfg(not(unix))]
335 Some(self.modified)
336 }
337}
338
339struct Revision(Sha256);
341
342impl Revision {
343 fn new() -> Self {
344 let mut revision = Self(Sha256::new());
345 revision.text(REVISION_DOMAIN);
346 revision
347 }
348
349 fn integer(&mut self, value: u64) {
350 self.0.update(value.to_be_bytes());
351 }
352
353 fn text(&mut self, value: &str) {
354 self.integer(value.len() as u64);
355 self.0.update(value.as_bytes());
356 }
357
358 fn record(&mut self, record: &DictionaryStateRecord) {
360 match record {
361 DictionaryStateRecord::Entry {
362 kind,
363 key,
364 value,
365 weight,
366 display,
367 deleted,
368 user_inserted,
369 } => {
370 self.text("entry");
371 self.text(kind.journal_name());
372 self.text(key);
373 self.text(value);
374 self.integer(*weight as u64);
375 self.text(display);
376 self.integer(u64::from(*deleted));
377 self.integer(u64::from(*user_inserted));
378 }
379 DictionaryStateRecord::Position {
380 context,
381 key,
382 value,
383 position,
384 } => {
385 self.text("position");
386 self.text(context);
387 self.text(key);
388 self.text(value);
389 self.integer(*position as u64);
390 }
391 DictionaryStateRecord::Selection {
392 context,
393 key,
394 value,
395 count,
396 } => {
397 self.text("selection");
398 self.text(context);
399 self.text(key);
400 self.text(value);
401 self.integer(*count as u64);
402 }
403 }
404 }
405
406 fn finish(self) -> String {
407 hex::encode(self.0.finalize())
408 }
409}
410
411pub fn stage_dictionary_state(
415 resources: &Path,
416 generation: &Path,
417 content_id: &str,
418 records: &mut dyn Iterator<Item = Result<DictionaryStateRecord>>,
419 maximum_records: usize,
420) -> Result<RuntimePaths> {
421 stage_dictionary_state_for(
422 resources,
423 generation,
424 content_id,
425 records,
426 maximum_records,
427 SchemeSet::ALL,
428 )
429}
430
431pub fn stage_dictionary_state_for(
433 resources: &Path,
434 generation: &Path,
435 content_id: &str,
436 records: &mut dyn Iterator<Item = Result<DictionaryStateRecord>>,
437 maximum_records: usize,
438 schemes: SchemeSet,
439) -> Result<RuntimePaths> {
440 require(resources.is_absolute() && generation.is_absolute())?;
441 require(!roots_overlap(
442 &weakly_canonical(resources)?,
443 &weakly_canonical(generation)?,
444 ))?;
445 match fs::create_dir(generation) {
447 Ok(()) => {}
448 Err(error) if error.kind() == ErrorKind::AlreadyExists => return Err(invalid_state()),
449 Err(error) => return Err(error.into()),
450 }
451 let staged = build_generation(
452 resources,
453 generation,
454 content_id,
455 records,
456 maximum_records,
457 schemes,
458 );
459 if staged.is_err() {
460 let _ = fs::remove_dir_all(generation);
462 }
463 staged
464}
465
466fn build_generation(
467 resources: &Path,
468 generation: &Path,
469 content_id: &str,
470 records: &mut dyn Iterator<Item = Result<DictionaryStateRecord>>,
471 maximum_records: usize,
472 schemes: SchemeSet,
473) -> Result<RuntimePaths> {
474 #[cfg(unix)]
475 {
476 use std::os::unix::fs::PermissionsExt;
477 fs::set_permissions(generation, fs::Permissions::from_mode(0o700))?;
478 }
479 let user = generation.join("user");
480 fs::create_dir(&user)?;
481 let journal = user.join(assets::USER_JOURNAL);
482 let mut connection = open_database(
484 &journal,
485 OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
486 )?;
487 ensure_schema(&connection).map_err(|_| invalid_state())?;
488 let transaction = connection
489 .transaction_with_behavior(TransactionBehavior::Immediate)
490 .map_err(|_| invalid_state())?;
491 {
492 let mut entry = transaction
494 .prepare("INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted) VALUES(?1,?2,?3,?4,?5,?6,?7)")
495 .map_err(|_| invalid_state())?;
496 let mut position = transaction
497 .prepare("INSERT INTO fixed_candidate_positions(context_key,entry_key,value,position) VALUES(?1,?2,?3,?4)")
498 .map_err(|_| invalid_state())?;
499 let mut selection = transaction
500 .prepare("INSERT INTO candidate_selection_state(context_key,entry_key,value,selection_count) VALUES(?1,?2,?3,?4)")
501 .map_err(|_| invalid_state())?;
502 let mut count = 0usize;
503 for record in records {
504 let record = record?;
505 count += 1;
506 require(count <= maximum_records)?;
507 let inserted = match &record {
508 DictionaryStateRecord::Entry {
509 kind,
510 key,
511 value,
512 weight,
513 display,
514 deleted,
515 user_inserted,
516 } => {
517 require(
518 bounded(key, MAX_KEY_BYTES, false)
519 && bounded(value, MAX_VALUE_BYTES, false),
520 )?;
521 require(bounded(display, MAX_VALUE_BYTES, true))?;
522 require((0..=MAX_STAGED_WEIGHT).contains(weight))?;
523 entry.execute(params![
524 kind.journal_name(),
525 key,
526 value,
527 if *deleted { "delete" } else { "upsert" },
528 weight,
529 display,
530 i64::from(*user_inserted),
531 ])
532 }
533 DictionaryStateRecord::Position {
534 context,
535 key,
536 value,
537 position: slot,
538 } => {
539 require(
540 bounded(key, MAX_KEY_BYTES, false)
541 && bounded(value, MAX_VALUE_BYTES, false),
542 )?;
543 require(bounded(context, MAX_KEY_BYTES, false))?;
544 require((1..=5).contains(slot))?;
545 position.execute(params![context, key, value, slot])
546 }
547 DictionaryStateRecord::Selection {
548 context,
549 key,
550 value,
551 count: selections,
552 } => {
553 require(
554 bounded(key, MAX_KEY_BYTES, false)
555 && bounded(value, MAX_VALUE_BYTES, false),
556 )?;
557 require(bounded(context, MAX_KEY_BYTES, false))?;
558 require((0..=MAX_STAGED_SELECTION_COUNT).contains(selections))?;
559 selection.execute(params![context, key, value, selections])
560 }
561 };
562 inserted.map_err(|_| invalid_state())?;
563 }
564 }
565 transaction.commit().map_err(|_| invalid_state())?;
566 drop(connection);
567 prepare_runtime_paths_for(
568 resources,
569 &user,
570 &generation.join("cache"),
571 content_id,
572 schemes,
573 )
574}
575
576#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
578pub struct DictionaryStateMerge {
579 pub entries: usize,
581 pub positions: usize,
583 pub selections: usize,
585 pub kept: usize,
587 pub words: usize,
589 pub skipped: usize,
591}
592
593pub fn merge_dictionary_state(
599 paths: &RuntimePaths,
600 main_dictionary: bool,
601 records: &mut dyn Iterator<Item = Result<DictionaryStateRecord>>,
602 maximum_records: usize,
603) -> Result<DictionaryStateMerge> {
604 use super::personal::open_edit_connection;
605 use super::replay::{apply_english, apply_pinyin, apply_simple};
606 use rusqlite::OptionalExtension;
607
608 paths.validate()?;
609 let mut connection = open_edit_connection(paths, main_dictionary)?;
610 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
611 let mut merge = DictionaryStateMerge::default();
612 {
613 let mut existing = transaction.prepare(
614 "SELECT 1 FROM personal_journal.user_dictionary_operations WHERE dictionary=?1 AND key=?2 AND value=?3",
615 )?;
616 let mut journal = transaction.prepare(
617 "INSERT INTO personal_journal.user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted) VALUES(?1,?2,?3,?4,?5,?6,?7)",
618 )?;
619 let mut position = transaction.prepare(
621 "INSERT OR IGNORE INTO personal_journal.fixed_candidate_positions(context_key,entry_key,value,position) VALUES(?1,?2,?3,?4)",
622 )?;
623 let mut selection = transaction.prepare(
624 "INSERT INTO personal_journal.candidate_selection_state(context_key,entry_key,value,selection_count) VALUES(?1,?2,?3,?4) ON CONFLICT(context_key,entry_key,value) DO UPDATE SET selection_count=excluded.selection_count WHERE excluded.selection_count>selection_count",
625 )?;
626 let mut count = 0usize;
627 for record in records {
628 let record = record?;
629 count += 1;
630 require(count <= maximum_records)?;
631 match &record {
632 DictionaryStateRecord::Entry {
633 kind,
634 key,
635 value,
636 weight,
637 display,
638 deleted,
639 user_inserted,
640 } => {
641 require(
642 bounded(key, MAX_KEY_BYTES, false)
643 && bounded(value, MAX_VALUE_BYTES, false)
644 && bounded(display, MAX_VALUE_BYTES, true)
645 && (0..=MAX_STAGED_WEIGHT).contains(weight),
646 )?;
647 if *user_inserted && !*deleted {
648 merge.words += 1;
649 continue;
650 }
651 if (!main_dictionary && *kind != PersonalDictionaryKind::English)
652 || (!*deleted && *kind == PersonalDictionaryKind::Pinyin && *weight < 1)
653 {
654 merge.skipped += 1;
655 continue;
656 }
657 let name = kind.journal_name();
658 if existing
659 .query_row(params![name, key, value], |_| Ok(()))
660 .optional()?
661 .is_some()
662 {
663 merge.kept += 1;
664 continue;
665 }
666 let (key_bytes, value_bytes) = (key.as_bytes(), value.as_bytes());
667 let applied = match kind {
669 PersonalDictionaryKind::Pinyin => {
670 apply_pinyin(&transaction, key_bytes, value_bytes, *deleted, *weight)
671 }
672 PersonalDictionaryKind::Wubi | PersonalDictionaryKind::Wubi98 => {
673 apply_simple(
674 &transaction,
675 kind.wubi_table().unwrap_or_default(),
676 key_bytes,
677 value_bytes,
678 *deleted,
679 *weight,
680 )
681 }
682 PersonalDictionaryKind::QuickPhrase => apply_simple(
683 &transaction,
684 "quick_parases",
685 key_bytes,
686 value_bytes,
687 *deleted,
688 *weight,
689 ),
690 PersonalDictionaryKind::English => apply_english(
691 &transaction,
692 key_bytes,
693 value_bytes,
694 *deleted,
695 *weight,
696 display.as_bytes(),
697 ),
698 };
699 let applied = match applied {
700 Ok(applied) => applied,
701 Err(error) if missing_table(&error) => false,
702 Err(error) => return Err(error.into()),
703 };
704 if !applied {
705 merge.skipped += 1;
706 continue;
707 }
708 journal.execute(params![
709 name,
710 key,
711 value,
712 if *deleted { "delete" } else { "upsert" },
713 weight,
714 display,
715 i64::from(*user_inserted),
716 ])?;
717 merge.entries += 1;
718 }
719 DictionaryStateRecord::Position {
720 context,
721 key,
722 value,
723 position: slot,
724 } => {
725 require(
726 bounded(context, MAX_KEY_BYTES, false)
727 && bounded(key, MAX_KEY_BYTES, false)
728 && bounded(value, MAX_VALUE_BYTES, false)
729 && (1..=5).contains(slot),
730 )?;
731 if position.execute(params![context, key, value, slot])? == 1 {
732 merge.positions += 1;
733 } else {
734 merge.kept += 1;
735 }
736 }
737 DictionaryStateRecord::Selection {
738 context,
739 key,
740 value,
741 count: selections,
742 } => {
743 require(
744 bounded(context, MAX_KEY_BYTES, false)
745 && bounded(key, MAX_KEY_BYTES, false)
746 && bounded(value, MAX_VALUE_BYTES, false)
747 && (0..=MAX_STAGED_SELECTION_COUNT).contains(selections),
748 )?;
749 if selection.execute(params![context, key, value, selections])? == 1 {
750 merge.selections += 1;
751 } else {
752 merge.kept += 1;
753 }
754 }
755 }
756 }
757 }
758 transaction.commit()?;
759 Ok(merge)
760}
761
762fn missing_table(error: &rusqlite::Error) -> bool {
764 matches!(
765 error,
766 rusqlite::Error::SqliteFailure(failure, Some(message))
767 if failure.code == rusqlite::ErrorCode::Unknown && message.starts_with("no such table")
768 )
769}
770
771fn bounded(text: &str, maximum: usize, allow_empty: bool) -> bool {
773 (allow_empty || !text.is_empty()) && text.len() <= maximum && !text.contains('\0')
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779 use crate::user_dictionary::replay::tests::{sql, weight};
780 use rusqlite::Connection;
781 use std::cell::Cell;
782 use std::path::PathBuf;
783 use std::rc::Rc;
784
785 fn paths(root: &Path) -> RuntimePaths {
786 let path = |name: &str| {
787 let path = root.join(name);
788 fs::create_dir_all(&path).unwrap();
789 path
790 };
791 RuntimePaths {
792 resources: path("resources"),
793 user_data: path("user"),
794 cache: path("cache"),
795 dictionaries: path("dictionaries"),
796 }
797 }
798
799 #[test]
801 fn framing_is_length_prefixed_and_big_endian() {
802 let mut actual = Revision::new();
803 actual.text("synthetic");
804 actual.integer(258);
805 let mut expected = Sha256::new();
806 let domain = b"msime-local-dictionary-state-v1";
807 expected.update((domain.len() as u64).to_be_bytes());
808 expected.update(domain);
809 expected.update(9_u64.to_be_bytes());
810 expected.update(b"synthetic");
811 expected.update([0, 0, 0, 0, 0, 0, 1, 2]);
812 assert_eq!(actual.finish(), hex::encode(expected.finalize()));
813 }
814
815 #[test]
816 fn text_boundaries_cannot_collide() {
817 let mut first = Revision::new();
818 first.text("ab");
819 first.text("c");
820 let mut second = Revision::new();
821 second.text("a");
822 second.text("bc");
823 assert_ne!(first.finish(), second.finish());
824 }
825
826 #[test]
828 fn real_journal_covers_every_record_and_field_without_mutation() {
829 let root = tempfile::tempdir().unwrap();
830 let paths = paths(root.path());
831 let journal = paths.user(assets::USER_JOURNAL);
832 sql(
833 &journal,
834 "CREATE TABLE user_dictionary_operations (
835 dictionary TEXT, key TEXT, value TEXT, weight INTEGER,
836 display TEXT, operation TEXT, user_inserted INTEGER);
837 CREATE TABLE fixed_candidate_positions (
838 context_key TEXT, entry_key TEXT, value TEXT, position INTEGER);
839 CREATE TABLE candidate_selection_state (
840 context_key TEXT, entry_key TEXT, value TEXT, selection_count INTEGER);
841 INSERT INTO user_dictionary_operations VALUES
842 ('wubi', 'synthetic-key', '测试', 258, 'fixture', 'upsert', 1),
843 ('quick', 'synthetic-key', '测试', 258, 'fixture', 'upsert', 1),
844 ('pinyin', 'synthetic-key', '测试', 258, 'fixture', 'upsert', 1),
845 ('english', 'synthetic-key', '测试', -2, 'fixture', 'delete', 0);
846 INSERT INTO fixed_candidate_positions VALUES ('ctx', 'key', '测试', 3);
847 INSERT INTO candidate_selection_state VALUES ('ctx', 'key', '测试', 7);",
848 );
849 let mut expected = Revision::new();
850 for kind in ["english", "pinyin", "quick", "wubi"] {
851 expected.text("entry");
852 expected.text(kind);
853 expected.text("synthetic-key");
854 expected.text("测试");
855 expected.integer(if kind == "english" {
856 (-2_i64) as u64
857 } else {
858 258
859 });
860 expected.text("fixture");
861 expected.integer(u64::from(kind == "english"));
862 expected.integer(u64::from(kind != "english"));
863 }
864 for (kind, number) in [("position", 3), ("selection", 7)] {
865 expected.text(kind);
866 expected.text("ctx");
867 expected.text("key");
868 expected.text("测试");
869 expected.integer(number);
870 }
871 let original = fs::read(&journal).unwrap();
872 let revision = dictionary_state_revision(&paths).unwrap();
873 assert_eq!(revision, expected.finish());
874 assert_eq!(fs::read(&journal).unwrap(), original);
875 for update in [
876 "UPDATE user_dictionary_operations SET dictionary='quick' WHERE dictionary='english'",
877 "UPDATE user_dictionary_operations SET key='changed'",
878 "UPDATE user_dictionary_operations SET value='changed'",
879 "UPDATE user_dictionary_operations SET weight=259",
880 "UPDATE user_dictionary_operations SET display='changed'",
881 "UPDATE user_dictionary_operations SET operation='delete'",
882 "UPDATE user_dictionary_operations SET user_inserted=0",
883 "UPDATE fixed_candidate_positions SET context_key='changed'",
884 "UPDATE fixed_candidate_positions SET entry_key='changed'",
885 "UPDATE fixed_candidate_positions SET value='changed'",
886 "UPDATE fixed_candidate_positions SET position=4",
887 "UPDATE candidate_selection_state SET context_key='changed'",
888 "UPDATE candidate_selection_state SET entry_key='changed'",
889 "UPDATE candidate_selection_state SET value='changed'",
890 "UPDATE candidate_selection_state SET selection_count=8",
891 ] {
892 sql(&journal, update);
893 assert_ne!(
894 dictionary_state_revision(&paths).unwrap(),
895 revision,
896 "{update}"
897 );
898 fs::write(&journal, &original).unwrap();
899 assert_eq!(dictionary_state_revision(&paths).unwrap(), revision);
900 }
901 }
902
903 #[test]
905 fn the_cached_revision_is_reused_only_while_the_journal_is_untouched() {
906 let root = tempfile::tempdir().unwrap();
907 let paths = paths(root.path());
908 let journal = paths.user(assets::USER_JOURNAL);
909 let later = || SystemTime::now() + REVISION_SETTLE * 30;
910 let cached = |paths: &RuntimePaths| state_revision(paths, true, later).unwrap();
911 let entry = |journal: &Path| {
912 revision_cache()
913 .lock()
914 .unwrap_or_else(PoisonError::into_inner)
915 .get(journal)
916 .cloned()
917 };
918
919 let empty = dictionary_state_revision(&paths).unwrap();
920 assert_eq!(cached(&paths), empty);
921 assert_eq!(
922 entry(&journal).map(|(_, revision)| revision),
923 Some(empty.clone())
924 );
925
926 sql(
927 &journal,
928 "CREATE TABLE user_dictionary_operations (
929 dictionary TEXT, key TEXT, value TEXT, weight INTEGER,
930 display TEXT, operation TEXT, user_inserted INTEGER);
931 CREATE TABLE fixed_candidate_positions (
932 context_key TEXT, entry_key TEXT, value TEXT, position INTEGER);
933 CREATE TABLE candidate_selection_state (
934 context_key TEXT, entry_key TEXT, value TEXT, selection_count INTEGER);
935 INSERT INTO user_dictionary_operations VALUES
936 ('pinyin', 'synthetic-key', '测试', 1, '', 'upsert', 1);",
937 );
938 let first = dictionary_state_revision(&paths).unwrap();
939 assert_ne!(first, empty);
940 assert_eq!(cached_dictionary_state_revision(&paths).unwrap(), first);
942 let stamp = JournalStamp::read(&journal).unwrap();
943 assert!(entry(&journal).is_none_or(|(cached, _)| cached != stamp));
944
945 assert_eq!(cached(&paths), first);
946 revision_cache()
948 .lock()
949 .unwrap_or_else(PoisonError::into_inner)
950 .get_mut(&journal)
951 .unwrap()
952 .1 = "synthetic".to_owned();
953 assert_eq!(cached(&paths), "synthetic");
954 assert_eq!(state_revision(&paths, false, later).unwrap(), first);
956 assert_eq!(cached(&paths), first);
957
958 let long = "x".repeat(4_000);
960 sql(
961 &journal,
962 &format!(
963 "INSERT INTO user_dictionary_operations VALUES
964 ('pinyin', 'long', '{long}', 1, '{long}', 'upsert', 1);"
965 ),
966 );
967 let second = cached(&paths);
968 assert_ne!(second, first);
969 assert_eq!(second, dictionary_state_revision(&paths).unwrap());
970
971 let writer = Connection::open(&journal).unwrap();
973 writer
974 .query_row("PRAGMA journal_mode=WAL", [], |_| Ok(()))
975 .unwrap();
976 assert_eq!(cached(&paths), second);
977 writer
978 .execute_batch("UPDATE user_dictionary_operations SET weight=7 WHERE key='long'")
979 .unwrap();
980 let mut wal = journal.as_os_str().to_owned();
981 wal.push("-wal");
982 assert!(fs::metadata(&wal).unwrap().len() > 0);
983 let third = cached(&paths);
984 assert_ne!(third, second);
985 assert_eq!(third, dictionary_state_revision(&paths).unwrap());
986 }
987
988 #[test]
990 fn a_missing_journal_is_not_created_and_a_corrupt_one_is_not_rewritten() {
991 let root = tempfile::tempdir().unwrap();
992 let paths = paths(root.path());
993 let before = dictionary_state_revision(&paths).unwrap();
994 assert_eq!(before.len(), 64);
995 assert_eq!(before, dictionary_state_revision(&paths).unwrap());
996 let journal = paths.user(assets::USER_JOURNAL);
997 assert!(!journal.exists());
998 fs::write(&journal, b"synthetic invalid database").unwrap();
999 let error = dictionary_state_revision(&paths).unwrap_err();
1000 assert_eq!(error.to_string(), diagnostics::INVALID_DICTIONARY_STATE);
1001 assert_eq!(fs::read(&journal).unwrap(), b"synthetic invalid database");
1002 }
1003
1004 #[test]
1005 fn unknown_kinds_text_that_is_not_utf8_and_a_stopped_export_are_invalid() {
1006 let root = tempfile::tempdir().unwrap();
1007 let paths = paths(root.path());
1008 let journal = paths.user(assets::USER_JOURNAL);
1009 sql(
1010 &journal,
1011 "CREATE TABLE user_dictionary_operations(dictionary TEXT,key TEXT,value TEXT,weight INTEGER,display TEXT,operation TEXT,user_inserted INTEGER);
1012 CREATE TABLE fixed_candidate_positions(context_key TEXT,entry_key TEXT,value TEXT,position INTEGER);
1013 CREATE TABLE candidate_selection_state(context_key TEXT,entry_key TEXT,value TEXT,selection_count INTEGER);
1014 INSERT INTO user_dictionary_operations VALUES('pinyin','ni','你',1,'','upsert',1);",
1015 );
1016 let mut seen = 0;
1017 let stopped = stream_dictionary_state(&paths, &mut |_| {
1018 seen += 1;
1019 false
1020 });
1021 assert_eq!(
1022 stopped.unwrap_err().to_string(),
1023 diagnostics::INVALID_DICTIONARY_STATE
1024 );
1025 assert_eq!(seen, 1);
1026 for corruption in [
1027 "INSERT INTO user_dictionary_operations VALUES('japanese','ni','你',1,'','upsert',1)",
1028 "INSERT INTO user_dictionary_operations VALUES('wubi','ni',CAST(x'ff' AS TEXT),1,'','upsert',1)",
1029 "INSERT INTO user_dictionary_operations VALUES('wubi','ni',NULL,1,'','upsert',1)",
1030 ] {
1031 let original = fs::read(&journal).unwrap();
1032 sql(&journal, corruption);
1033 let error = dictionary_state_revision(&paths).unwrap_err();
1034 assert_eq!(error.to_string(), diagnostics::INVALID_DICTIONARY_STATE, "{corruption}");
1035 fs::write(&journal, &original).unwrap();
1036 }
1037 assert!(dictionary_state_revision(&paths).is_ok());
1038 }
1039
1040 #[test]
1041 fn oversized_state_text_is_rejected_before_export() {
1042 let root = tempfile::tempdir().unwrap();
1043 let paths = paths(root.path());
1044 let journal = paths.user(assets::USER_JOURNAL);
1045 sql(
1046 &journal,
1047 "CREATE TABLE user_dictionary_operations(dictionary TEXT,key TEXT,value TEXT,weight INTEGER,display TEXT,operation TEXT,user_inserted INTEGER);
1048 CREATE TABLE fixed_candidate_positions(context_key TEXT,entry_key TEXT,value TEXT,position INTEGER);
1049 CREATE TABLE candidate_selection_state(context_key TEXT,entry_key TEXT,value TEXT,selection_count INTEGER);",
1050 );
1051 let huge = "x".repeat(MAX_KEY_BYTES + 1);
1052 Connection::open(&journal)
1053 .unwrap()
1054 .execute(
1055 "INSERT INTO user_dictionary_operations VALUES('pinyin',?1,'你',1,'','upsert',1)",
1056 [&huge],
1057 )
1058 .unwrap();
1059
1060 assert!(dictionary_state_revision(&paths).is_err());
1061 }
1062
1063 fn resources(root: &Path) -> PathBuf {
1064 let resources = root.join("resources");
1065 fs::create_dir_all(&resources).unwrap();
1066 sql(
1068 &resources.join(assets::MAIN_DICTIONARY),
1069 "CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
1070 INSERT INTO tbl_2_n VALUES('ni''hao','nh','你好',100),('ni''hao','nh','拟好',80);
1071 CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);
1072 CREATE TABLE quick_parases(key TEXT,value TEXT,weight INTEGER);
1073 CREATE INDEX idx_quick_parases_key_weight ON quick_parases(key,weight DESC);",
1074 );
1075 sql(
1076 &resources.join(assets::ENGLISH_DICTIONARY),
1077 "CREATE TABLE english_words(word TEXT COLLATE BINARY NOT NULL,display TEXT NOT NULL,
1078 weight INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(word,display)) WITHOUT ROWID;
1079 CREATE TABLE en_zh_glosses(english TEXT COLLATE BINARY PRIMARY KEY,chinese_gloss TEXT NOT NULL) WITHOUT ROWID;
1080 CREATE TABLE zh_en_glosses(chinese TEXT COLLATE BINARY PRIMARY KEY,english_gloss TEXT NOT NULL) WITHOUT ROWID;
1081 PRAGMA user_version=3;",
1082 );
1083 resources
1084 }
1085
1086 fn entry(
1087 kind: PersonalDictionaryKind,
1088 key: &str,
1089 value: &str,
1090 weight: i64,
1091 display: &str,
1092 deleted: bool,
1093 user_inserted: bool,
1094 ) -> DictionaryStateRecord {
1095 DictionaryStateRecord::Entry {
1096 kind,
1097 key: key.into(),
1098 value: value.into(),
1099 weight,
1100 display: display.into(),
1101 deleted,
1102 user_inserted,
1103 }
1104 }
1105
1106 fn records() -> Vec<DictionaryStateRecord> {
1108 use PersonalDictionaryKind::*;
1109 vec![
1110 entry(Pinyin, "ni'hao", "你好", 0, "", true, true),
1111 entry(Pinyin, "ni'hao", "拟好", 200, "", false, false),
1112 entry(Pinyin, "ni'hao", "拟蒿", 150, "", false, true),
1113 entry(Wubi, "fixture", "合成五笔", 100, "", false, true),
1114 entry(QuickPhrase, "fixture", "合成短语", 100, "", false, true),
1115 entry(
1116 English,
1117 "cloudfixture",
1118 "Cloudfixture",
1119 100,
1120 "Cloudfixture",
1121 false,
1122 true,
1123 ),
1124 DictionaryStateRecord::Position {
1125 context: "ni'hao".into(),
1126 key: "ni'hao".into(),
1127 value: "拟蒿".into(),
1128 position: 1,
1129 },
1130 DictionaryStateRecord::Selection {
1131 context: "ni'hao".into(),
1132 key: "ni'hao".into(),
1133 value: "拟好".into(),
1134 count: 7,
1135 },
1136 ]
1137 }
1138
1139 fn stage(
1140 resources: &Path,
1141 generation: &Path,
1142 records: Vec<DictionaryStateRecord>,
1143 ) -> Result<RuntimePaths> {
1144 stage_dictionary_state(
1145 resources,
1146 generation,
1147 "fixture",
1148 &mut records.into_iter().map(Ok),
1149 100,
1150 )
1151 }
1152
1153 fn streamed(paths: &RuntimePaths) -> Vec<DictionaryStateRecord> {
1154 let mut records = Vec::new();
1155 stream_dictionary_state(paths, &mut |record| {
1156 records.push(record.clone());
1157 true
1158 })
1159 .unwrap();
1160 records
1161 }
1162
1163 #[test]
1165 fn a_complete_state_rebuild_replays_into_fresh_dictionaries_without_touching_resources() {
1166 let root = tempfile::tempdir().unwrap();
1167 let resources = resources(root.path());
1168 let original = fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap();
1169 let restored = stage(&resources, &root.path().join("restored"), records()).unwrap();
1170 assert_eq!(restored.resources, resources);
1171 assert_eq!(restored.user_data, root.path().join("restored/user"));
1172 assert_eq!(restored.cache, root.path().join("restored/cache"));
1173 assert_eq!(
1174 restored.dictionaries,
1175 root.path().join("restored/user/dictionaries/fixture")
1176 );
1177 let journal = restored.user(assets::USER_JOURNAL);
1178 assert_eq!(
1179 weight(&journal, "SELECT count(*) FROM user_dictionary_operations"),
1180 Some(6)
1181 );
1182 let deletion: (String, i64) = Connection::open(&journal)
1183 .unwrap()
1184 .query_row(
1185 "SELECT operation,user_inserted FROM user_dictionary_operations WHERE value='你好'",
1186 [],
1187 |row| Ok((row.get(0)?, row.get(1)?)),
1188 )
1189 .unwrap();
1190 assert_eq!(deletion, ("delete".into(), 1));
1191 assert_eq!(
1192 weight(
1193 &journal,
1194 "SELECT selection_count FROM candidate_selection_state"
1195 ),
1196 Some(7)
1197 );
1198 assert_eq!(
1199 weight(
1200 &journal,
1201 "SELECT position FROM fixed_candidate_positions WHERE value='拟蒿'"
1202 ),
1203 Some(1)
1204 );
1205 let main = restored.dictionary(assets::MAIN_DICTIONARY);
1206 assert_eq!(
1207 weight(&main, "SELECT count(*) FROM tbl_2_n WHERE value='你好'"),
1208 Some(0)
1209 );
1210 assert_eq!(
1211 weight(&main, "SELECT weight FROM tbl_2_n WHERE value='拟好'"),
1212 Some(200)
1213 );
1214 assert_eq!(
1215 weight(&main, "SELECT weight FROM tbl_2_n WHERE value='拟蒿'"),
1216 Some(150)
1217 );
1218 assert_eq!(
1219 weight(&main, "SELECT weight FROM wubi86 WHERE value='合成五笔'"),
1220 Some(100)
1221 );
1222 assert_eq!(
1223 weight(
1224 &main,
1225 "SELECT weight FROM quick_parases WHERE value='合成短语'"
1226 ),
1227 Some(100)
1228 );
1229 assert_eq!(
1230 weight(&restored.dictionary(assets::ENGLISH_DICTIONARY), "SELECT weight FROM english_words WHERE word='cloudfixture' AND display='Cloudfixture'"),
1231 Some(100)
1232 );
1233 assert_eq!(
1234 fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap(),
1235 original
1236 );
1237
1238 let exported = streamed(&restored);
1240 assert_eq!(exported.len(), records().len());
1241 let second = stage(&resources, &root.path().join("second"), exported.clone()).unwrap();
1242 assert_eq!(streamed(&second), exported);
1243 assert_eq!(
1244 dictionary_state_revision(&second).unwrap(),
1245 dictionary_state_revision(&restored).unwrap()
1246 );
1247
1248 let empty = stage(&resources, &root.path().join("empty"), vec![]).unwrap();
1250 assert_eq!(
1251 weight(
1252 &empty.dictionary(assets::MAIN_DICTIONARY),
1253 "SELECT weight FROM tbl_2_n WHERE value='你好'"
1254 ),
1255 Some(100)
1256 );
1257 assert!(streamed(&empty).is_empty());
1258 }
1259
1260 #[test]
1262 fn a_session_on_a_staged_generation_offers_its_candidates() {
1263 use crate::session::{Session, SessionOptions};
1264 let words = |paths: RuntimePaths| {
1265 let mut options = SessionOptions::new(paths);
1266 options.learning = false;
1267 let mut session = Session::new(options).unwrap();
1268 for letter in "nihao".bytes() {
1269 session.character(letter, false);
1270 }
1271 session
1272 .snapshot()
1273 .candidates
1274 .into_iter()
1275 .map(|item| item.word.into_string())
1276 .collect::<Vec<_>>()
1277 };
1278 let root = tempfile::tempdir().unwrap();
1279 let resources = resources(root.path());
1280 let restored = words(stage(&resources, &root.path().join("restored"), records()).unwrap());
1281 assert_eq!(
1282 restored.first().map(String::as_str),
1283 Some("拟蒿"),
1284 "{restored:?}"
1285 );
1286 assert!(!restored.iter().any(|word| word == "你好"), "{restored:?}");
1287 let empty = words(stage(&resources, &root.path().join("empty"), vec![]).unwrap());
1288 assert_eq!(empty.first().map(String::as_str), Some("你好"), "{empty:?}");
1289 }
1290
1291 #[test]
1292 fn an_export_is_one_consistent_read_despite_a_concurrent_writer() {
1293 let root = tempfile::tempdir().unwrap();
1294 let resources = resources(root.path());
1295 let first = stage(&resources, &root.path().join("first"), records()).unwrap();
1296 let journal = first.user(assets::USER_JOURNAL);
1297 Connection::open(&journal)
1298 .unwrap()
1299 .query_row("PRAGMA journal_mode=WAL", [], |_| Ok(()))
1300 .unwrap();
1301 let mut wrote = false;
1302 let mut count = 0;
1303 stream_dictionary_state(&first, &mut |_| {
1304 count += 1;
1305 if !wrote {
1306 wrote = true;
1307 sql(
1308 &journal,
1309 "INSERT INTO fixed_candidate_positions VALUES('ni''hao','ni''hao','拟好',2)",
1310 );
1311 }
1312 true
1313 })
1314 .unwrap();
1315 assert_eq!(count, records().len());
1316 assert_eq!(streamed(&first).len(), records().len() + 1);
1317 }
1318
1319 #[test]
1321 fn rejected_records_and_transport_failures_remove_only_the_new_generation() {
1322 let root = tempfile::tempdir().unwrap();
1323 let resources = resources(root.path());
1324 let target = root.path().join("rejected");
1325 let mut duplicate = records();
1326 duplicate.push(duplicate[0].clone());
1327 let mut conflicting_slot = records();
1328 conflicting_slot.push(DictionaryStateRecord::Position {
1329 context: "ni'hao".into(),
1330 key: "ni'hao".into(),
1331 value: "拟好".into(),
1332 position: 1,
1333 });
1334 let position = |slot| DictionaryStateRecord::Position {
1335 context: "ctx".into(),
1336 key: "key".into(),
1337 value: "value".into(),
1338 position: slot,
1339 };
1340 let selection = |context: &str, count| DictionaryStateRecord::Selection {
1341 context: context.into(),
1342 key: "key".into(),
1343 value: "value".into(),
1344 count,
1345 };
1346 for invalid in [
1347 duplicate,
1348 conflicting_slot,
1349 vec![position(i64::MAX)],
1350 vec![position(0)],
1351 vec![position(6)],
1352 vec![selection("ctx", -1)],
1353 vec![selection("ctx", 11)],
1354 vec![selection("ctx\0bad", 1)],
1355 vec![selection("", 1)],
1356 vec![entry(
1357 PersonalDictionaryKind::Pinyin,
1358 "ni",
1359 "你",
1360 -1,
1361 "",
1362 false,
1363 true,
1364 )],
1365 vec![entry(
1366 PersonalDictionaryKind::Pinyin,
1367 "ni",
1368 "你",
1369 100_000_001,
1370 "",
1371 false,
1372 true,
1373 )],
1374 vec![entry(
1375 PersonalDictionaryKind::Pinyin,
1376 "",
1377 "你",
1378 1,
1379 "",
1380 false,
1381 true,
1382 )],
1383 vec![entry(
1384 PersonalDictionaryKind::Pinyin,
1385 "ni",
1386 "",
1387 1,
1388 "",
1389 false,
1390 true,
1391 )],
1392 vec![entry(
1393 PersonalDictionaryKind::Pinyin,
1394 &"n".repeat(513),
1395 "你",
1396 1,
1397 "",
1398 false,
1399 true,
1400 )],
1401 vec![entry(
1402 PersonalDictionaryKind::Pinyin,
1403 "ni",
1404 &"你".repeat(1366),
1405 1,
1406 "",
1407 false,
1408 true,
1409 )],
1410 vec![entry(
1411 PersonalDictionaryKind::Pinyin,
1412 "ni",
1413 "你",
1414 1,
1415 &"x".repeat(4097),
1416 false,
1417 true,
1418 )],
1419 ] {
1420 let error = stage(&resources, &target, invalid).unwrap_err();
1421 assert_eq!(error.to_string(), diagnostics::INVALID_DICTIONARY_STATE);
1422 assert!(!target.exists());
1423 }
1424 for maximum in [0, 1] {
1425 assert!(stage_dictionary_state(
1426 &resources,
1427 &target,
1428 "fixture",
1429 &mut records().into_iter().map(Ok),
1430 maximum
1431 )
1432 .is_err());
1433 assert!(!target.exists());
1434 }
1435 let mut stream =
1437 records()
1438 .into_iter()
1439 .map(Ok)
1440 .chain(std::iter::once(Err(EngineError::failed(
1441 diagnostics::SNAPSHOT_STREAM_FAILED,
1442 ))));
1443 let error =
1444 stage_dictionary_state(&resources, &target, "fixture", &mut stream, 8).unwrap_err();
1445 assert_eq!(error.to_string(), diagnostics::SNAPSHOT_STREAM_FAILED);
1446 assert!(!target.exists());
1447 let error = stage_dictionary_state(
1448 &resources,
1449 &target,
1450 "../escape",
1451 &mut records().into_iter().map(Ok),
1452 8,
1453 )
1454 .unwrap_err();
1455 assert_eq!(error.to_string(), diagnostics::INVALID_RUNTIME_CONTENT_ID);
1456 assert!(!target.exists());
1457
1458 let existing = stage(&resources, &target, records()).unwrap();
1459 let revision = dictionary_state_revision(&existing).unwrap();
1460 assert!(stage(&resources, &target, vec![]).is_err());
1461 assert_eq!(dictionary_state_revision(&existing).unwrap(), revision);
1462 let inside_resources = resources.join("forbidden");
1463 assert!(stage(&resources, &inside_resources, records()).is_err());
1464 assert!(!inside_resources.exists());
1465 assert!(stage(
1466 Path::new("resources"),
1467 &root.path().join("relative"),
1468 records()
1469 )
1470 .is_err());
1471 assert!(!root.path().join("relative").exists());
1472 }
1473
1474 #[test]
1476 fn the_stream_is_consumed_lazily_and_a_rebuild_failure_cleans_up() {
1477 let root = tempfile::tempdir().unwrap();
1478 let resources = resources(root.path());
1479 let target = root.path().join("limited");
1480 let consumed = Rc::new(Cell::new(0));
1481 let counter = consumed.clone();
1482 let mut stream = (0..100_000).map(move |index| {
1483 counter.set(counter.get() + 1);
1484 Ok(entry(
1485 PersonalDictionaryKind::QuickPhrase,
1486 &format!("fixture{index}"),
1487 "合成短语",
1488 100,
1489 "",
1490 false,
1491 true,
1492 ))
1493 });
1494 assert!(stage_dictionary_state(&resources, &target, "fixture", &mut stream, 2).is_err());
1495 assert_eq!(consumed.get(), 3);
1496 assert!(!target.exists());
1497
1498 let missing = root.path().join("missing-resources");
1500 fs::create_dir(&missing).unwrap();
1501 assert!(stage(&missing, &target, records()).is_err());
1502 assert!(!target.exists());
1503 assert!(missing.is_dir());
1504 }
1505
1506 fn position(context: &str, key: &str, value: &str, slot: i64) -> DictionaryStateRecord {
1507 DictionaryStateRecord::Position {
1508 context: context.into(),
1509 key: key.into(),
1510 value: value.into(),
1511 position: slot,
1512 }
1513 }
1514
1515 fn selection(context: &str, key: &str, value: &str, count: i64) -> DictionaryStateRecord {
1516 DictionaryStateRecord::Selection {
1517 context: context.into(),
1518 key: key.into(),
1519 value: value.into(),
1520 count,
1521 }
1522 }
1523
1524 fn merge(
1525 paths: &RuntimePaths,
1526 records: Vec<DictionaryStateRecord>,
1527 ) -> Result<DictionaryStateMerge> {
1528 merge_dictionary_state(paths, true, &mut records.into_iter().map(Ok), 100)
1529 }
1530
1531 fn local_state(resources: &Path, generation: &Path) -> RuntimePaths {
1533 use PersonalDictionaryKind::*;
1534 stage(
1535 resources,
1536 generation,
1537 vec![
1538 entry(Pinyin, "ni'hao", "拟好", 200, "", false, false),
1539 entry(Pinyin, "ni'hao", "拟蒿", 150, "", false, true),
1540 position("ni'hao", "ni'hao", "拟蒿", 1),
1541 selection("ni'hao", "ni'hao", "拟好", 7),
1542 selection("ni'hao", "ni'hao", "你好", 2),
1543 ],
1544 )
1545 .unwrap()
1546 }
1547
1548 fn backup_records() -> Vec<DictionaryStateRecord> {
1549 use PersonalDictionaryKind::*;
1550 vec![
1551 entry(Pinyin, "ni'hao", "拟好", 999, "", false, false),
1553 entry(Pinyin, "ni'hao", "你好", 0, "", true, true),
1555 entry(Pinyin, "ni'hao", "泥好", 120, "", false, false),
1557 entry(Pinyin, "ni'hao", "拟蒿", 150, "", false, true),
1559 entry(Pinyin, "ni''hao", "坏键", 120, "", false, false),
1561 position("ni'hao", "ni'hao", "拟蒿", 3),
1563 position("ni'hao", "ni'hao", "泥好", 1),
1565 position("ni'hao", "ni'hao", "泥好", 2),
1566 selection("ni'hao", "ni'hao", "拟好", 3),
1568 selection("ni'hao", "ni'hao", "你好", 5),
1570 selection("ni", "ni", "你", 4),
1571 ]
1572 }
1573
1574 #[test]
1575 fn a_merge_keeps_local_rows_takes_the_larger_count_and_writes_the_rest() {
1576 let root = tempfile::tempdir().unwrap();
1577 let resources = resources(root.path());
1578 let local = local_state(&resources, &root.path().join("local"));
1579 let merged = merge(&local, backup_records()).unwrap();
1580 assert_eq!(
1581 merged,
1582 DictionaryStateMerge {
1583 entries: 2,
1584 positions: 1,
1585 selections: 2,
1586 kept: 4,
1587 words: 1,
1588 skipped: 1,
1589 }
1590 );
1591 let journal = local.user(assets::USER_JOURNAL);
1592 let main = local.dictionary(assets::MAIN_DICTIONARY);
1593 assert_eq!(
1594 weight(&main, "SELECT weight FROM tbl_2_n WHERE value='拟好'"),
1595 Some(200)
1596 );
1597 assert_eq!(
1598 weight(&main, "SELECT count(*) FROM tbl_2_n WHERE value='你好'"),
1599 Some(0)
1600 );
1601 assert_eq!(
1602 weight(&main, "SELECT weight FROM tbl_2_n WHERE value='泥好'"),
1603 Some(120)
1604 );
1605 assert_eq!(
1606 weight(
1607 &journal,
1608 "SELECT weight FROM user_dictionary_operations WHERE value='拟好'"
1609 ),
1610 Some(200)
1611 );
1612 let rows: Vec<(String, String, i64)> = Connection::open(&journal)
1613 .unwrap()
1614 .prepare("SELECT value,operation,user_inserted FROM user_dictionary_operations WHERE value IN ('你好','泥好') ORDER BY value")
1615 .unwrap()
1616 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1617 .unwrap()
1618 .collect::<rusqlite::Result<_>>()
1619 .unwrap();
1620 assert_eq!(
1621 rows,
1622 vec![
1623 ("你好".into(), "delete".into(), 1),
1624 ("泥好".into(), "upsert".into(), 0),
1625 ]
1626 );
1627 assert_eq!(
1628 weight(
1629 &journal,
1630 "SELECT count(*) FROM user_dictionary_operations WHERE key='ni''''hao'"
1631 ),
1632 Some(0)
1633 );
1634 assert_eq!(
1635 weight(
1636 &journal,
1637 "SELECT position FROM fixed_candidate_positions WHERE value='拟蒿'"
1638 ),
1639 Some(1)
1640 );
1641 assert_eq!(
1642 weight(
1643 &journal,
1644 "SELECT position FROM fixed_candidate_positions WHERE value='泥好'"
1645 ),
1646 Some(2)
1647 );
1648 for (value, count) in [("拟好", 7), ("你好", 5), ("你", 4)] {
1649 assert_eq!(
1650 weight(
1651 &journal,
1652 &format!(
1653 "SELECT selection_count FROM candidate_selection_state WHERE value='{value}'"
1654 )
1655 ),
1656 Some(count),
1657 "{value}"
1658 );
1659 }
1660
1661 let revision = dictionary_state_revision(&local).unwrap();
1663 let again = merge(&local, backup_records()).unwrap();
1664 assert_eq!(
1665 again,
1666 DictionaryStateMerge {
1667 entries: 0,
1668 positions: 0,
1669 selections: 0,
1670 kept: 9,
1671 words: 1,
1672 skipped: 1,
1673 }
1674 );
1675 assert_eq!(dictionary_state_revision(&local).unwrap(), revision);
1676 }
1677
1678 #[test]
1679 fn a_failed_merge_changes_nothing() {
1680 let root = tempfile::tempdir().unwrap();
1681 let resources = resources(root.path());
1682 let local = local_state(&resources, &root.path().join("local"));
1683 let main = local.dictionary(assets::MAIN_DICTIONARY);
1684 let revision = dictionary_state_revision(&local).unwrap();
1685 let dictionary = fs::read(&main).unwrap();
1686 let mut invalid = backup_records();
1687 invalid.push(position("ni", "ni", "你", 6));
1688 assert_eq!(
1689 merge(&local, invalid).unwrap_err().to_string(),
1690 diagnostics::INVALID_DICTIONARY_STATE
1691 );
1692 let mut stream = backup_records()
1693 .into_iter()
1694 .map(Ok)
1695 .chain(std::iter::once(Err(EngineError::failed(
1696 diagnostics::SNAPSHOT_STREAM_FAILED,
1697 ))));
1698 assert_eq!(
1699 merge_dictionary_state(&local, true, &mut stream, 100)
1700 .unwrap_err()
1701 .to_string(),
1702 diagnostics::SNAPSHOT_STREAM_FAILED
1703 );
1704 assert!(
1705 merge_dictionary_state(&local, true, &mut backup_records().into_iter().map(Ok), 2)
1706 .is_err()
1707 );
1708 assert_eq!(dictionary_state_revision(&local).unwrap(), revision);
1709 assert_eq!(fs::read(&main).unwrap(), dictionary);
1710 }
1711
1712 #[test]
1714 fn a_missing_table_is_skipped_but_a_write_failure_rolls_back() {
1715 use PersonalDictionaryKind::*;
1716 let root = tempfile::tempdir().unwrap();
1717 let resources = resources(root.path());
1718 let local = local_state(&resources, &root.path().join("local"));
1719 let merged = merge(
1721 &local,
1722 vec![
1723 entry(Wubi98, "wqvb", "你好", 120, "", false, false),
1724 selection("ni", "ni", "你", 4),
1725 ],
1726 )
1727 .unwrap();
1728 assert_eq!(
1729 (merged.entries, merged.selections, merged.skipped),
1730 (0, 1, 1)
1731 );
1732
1733 sql(
1735 &local.dictionary(assets::MAIN_DICTIONARY),
1736 "CREATE TRIGGER synthetic_failure BEFORE INSERT ON tbl_2_n BEGIN SELECT RAISE(ABORT,'synthetic write failure'); END;",
1737 );
1738 let revision = dictionary_state_revision(&local).unwrap();
1739 assert!(merge(
1740 &local,
1741 vec![
1742 selection("ni", "ni", "你", 9),
1743 entry(Pinyin, "ni'hao", "泥好", 120, "", false, false),
1744 ],
1745 )
1746 .is_err());
1747 assert_eq!(dictionary_state_revision(&local).unwrap(), revision);
1748 assert_eq!(
1749 weight(
1750 &local.user(assets::USER_JOURNAL),
1751 "SELECT selection_count FROM candidate_selection_state WHERE value='你'"
1752 ),
1753 Some(4)
1754 );
1755 }
1756
1757 #[test]
1759 fn a_generation_without_the_main_dictionary_merges_english_rows_only() {
1760 use PersonalDictionaryKind::*;
1761 let root = tempfile::tempdir().unwrap();
1762 let resources = resources(root.path());
1763 let local = stage(&resources, &root.path().join("local"), vec![]).unwrap();
1764 let merged = merge_dictionary_state(
1765 &local,
1766 false,
1767 &mut vec![
1768 entry(Pinyin, "ni'hao", "泥好", 120, "", false, false),
1769 entry(
1770 English,
1771 "cloudfixture",
1772 "Cloudfixture",
1773 300,
1774 "Cloudfixture",
1775 false,
1776 false,
1777 ),
1778 ]
1779 .into_iter()
1780 .map(Ok),
1781 100,
1782 )
1783 .unwrap();
1784 assert_eq!((merged.entries, merged.skipped), (1, 1));
1785 assert_eq!(
1786 weight(&local.dictionary(assets::ENGLISH_DICTIONARY), "SELECT weight FROM english_words WHERE word='cloudfixture' AND display='Cloudfixture'"),
1787 Some(300)
1788 );
1789 assert_eq!(
1790 weight(
1791 &local.user(assets::USER_JOURNAL),
1792 "SELECT count(*) FROM user_dictionary_operations"
1793 ),
1794 Some(1)
1795 );
1796 }
1797
1798 #[test]
1800 fn a_large_state_stages_and_streams_back_whole() {
1801 let root = tempfile::tempdir().unwrap();
1802 let resources = resources(root.path());
1803 let total = 20_000;
1804 let mut stream = (0..total).map(|index| {
1805 Ok(entry(
1806 PersonalDictionaryKind::QuickPhrase,
1807 &format!("bulk{index}"),
1808 &format!("合成短语{index}"),
1809 100,
1810 "",
1811 false,
1812 true,
1813 ))
1814 });
1815 let staged = stage_dictionary_state(
1816 &resources,
1817 &root.path().join("capacity"),
1818 "fixture",
1819 &mut stream,
1820 DEFAULT_MAXIMUM_RECORDS,
1821 )
1822 .unwrap();
1823 let mut received = 0;
1824 stream_dictionary_state(&staged, &mut |record| {
1825 assert!(matches!(
1826 record,
1827 DictionaryStateRecord::Entry {
1828 user_inserted: true,
1829 weight: 100,
1830 ..
1831 }
1832 ));
1833 received += 1;
1834 true
1835 })
1836 .unwrap();
1837 assert_eq!(received, total);
1838 assert_eq!(
1839 weight(
1840 &staged.dictionary(assets::MAIN_DICTIONARY),
1841 "SELECT count(*) FROM quick_parases"
1842 ),
1843 Some(total as i64)
1844 );
1845 }
1846}