Skip to main content

msime_engine/user_dictionary/
reset.rs

1//! Resetting learned data (user-dictionary.md §13, bridge.cpp:588-691): fresh copies of the packaged dictionaries and an empty journal, swapped in with backups and rolled back on any failure. Sibling names are built by concatenation so non-ASCII profile directories work.
2
3use std::ffi::OsString;
4use std::fs;
5use std::io::ErrorKind;
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use rusqlite::OpenFlags;
10
11use crate::assets;
12use crate::diagnostics;
13use crate::error::{EngineError, Result};
14use crate::paths::RuntimePaths;
15use crate::user_dictionary::generation::{copy_private_file, merge_split_wubi, weakly_canonical};
16use crate::user_dictionary::journal::{
17    checkpoint_journal, close_cached_journals, ensure_schema, open_database,
18};
19
20/// SQLite files that sit beside the journal. A leftover `-wal` would bring the learned data the user just erased back on the next open.
21const JOURNAL_SIDECARS: [&str; 3] = ["-wal", "-shm", "-journal"];
22
23/// One file being swapped: the fresh copy is published over `target` while the original waits at `backup` until every file is in place.
24struct Replacement {
25    target: PathBuf,
26    temporary: PathBuf,
27    backup: PathBuf,
28    had_original: bool,
29    published: bool,
30}
31
32/// The caller must have quiesced every session on these paths.
33///
34/// 自己取独占的维护租约(`DictionaryAccess::try_maintenance`),在建好两个目录之后、动任何文件之前:还有会话或别的维护持着租约时以 `Busy`(`DICTIONARY_MAINTENANCE_BUSY`)失败,什么也不换。
35pub fn reset_learned_data(paths: &RuntimePaths) -> Result<()> {
36    reset(paths, true, true)
37}
38
39/// [`reset_learned_data`],`main_dictionary` 为假(代次里没有 `msime-pinyin.db`,见 `SchemeSet::reads_main_dictionary`)时只换回 `msime-english.db` 和清空日志,不要求也不复制 `msime-pinyin.db`。不取维护租约,调用方(host 门面背后的 host-api)已经持有。
40pub fn reset_learned_data_with(paths: &RuntimePaths, main_dictionary: bool) -> Result<()> {
41    reset(paths, main_dictionary, false)
42}
43
44/// `take_lease` 为真时自己取维护租约,持到换完为止。
45fn reset(paths: &RuntimePaths, main_dictionary: bool, take_lease: bool) -> Result<()> {
46    paths.validate()?;
47    if weakly_canonical(&paths.resources)? == weakly_canonical(&paths.dictionaries)? {
48        return Err(EngineError::invalid(diagnostics::RESET_IN_PLACE));
49    }
50    let names: &[&str] = if main_dictionary {
51        &[assets::MAIN_DICTIONARY, assets::ENGLISH_DICTIONARY]
52    } else {
53        &[assets::ENGLISH_DICTIONARY]
54    };
55    let sources: Vec<PathBuf> = names.iter().map(|name| paths.resource(name)).collect();
56    if !sources.iter().all(|source| is_real_file(source)) {
57        return Err(EngineError::failed(
58            diagnostics::PACKAGED_DICTIONARY_UNAVAILABLE,
59        ));
60    }
61    reject_redirected_directory(&paths.user_data)?;
62    reject_redirected_directory(&paths.dictionaries)?;
63    fs::create_dir_all(&paths.user_data)?;
64    fs::create_dir_all(&paths.dictionaries)?;
65    reject_redirected_directory(&paths.user_data)?;
66    reject_redirected_directory(&paths.dictionaries)?;
67    let _access = if take_lease {
68        Some(crate::access::maintenance(paths)?)
69    } else {
70        None
71    };
72    // Every cached handle on the files about to be replaced goes first; on Windows an open handle blocks the rename.
73    close_cached_journals();
74
75    // Unique per call, so a reset interrupted earlier never collides with this one's files. A clock before 1970 would only make the names less unique, not wrong.
76    let stamp = SystemTime::now()
77        .duration_since(UNIX_EPOCH)
78        .map_or(0, |elapsed| elapsed.as_nanos())
79        .to_string();
80    let journal = paths.user(assets::USER_JOURNAL);
81    // 日志是 WAL 时,最近的学习可能还在 `-wal` 里、没并回主文件。先并回并清空 `-wal`:失败回滚或被杀后放回的备份本身就是完整的日志,新日志发布后、旁路文件删掉之前,原名旁边也只剩一个空 `-wal`,套不到新日志上。
82    checkpoint_journal(&journal);
83    // 词库在前、日志在最后:`swap` 按顺序把 `sources` 配给前面的词库。
84    let mut replacements: Vec<Replacement> = names
85        .iter()
86        .map(|name| paths.dictionary(name))
87        .chain([journal.clone()])
88        .map(|target| Replacement {
89            temporary: affixed(&target, ".reset.", &stamp),
90            backup: affixed(&target, ".backup.", &stamp),
91            target,
92            had_original: false,
93            published: false,
94        })
95        .collect();
96    let journal_temporary = replacements[names.len()].temporary.clone();
97
98    // The empty journal gets its own connection, closed before it is published: a cached one would keep the renamed file open.
99    let prepared = (|| -> Result<()> {
100        let connection = open_database(
101            &journal_temporary,
102            OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
103        )?;
104        ensure_schema(&connection)
105    })();
106    if prepared.is_err() {
107        remove_ignoring_errors(&journal_temporary);
108        return Err(EngineError::failed(diagnostics::RESET_JOURNAL_FAILED));
109    }
110
111    let swapped = swap(
112        &mut replacements,
113        &paths.resources,
114        main_dictionary,
115        &sources,
116        &journal,
117    );
118    match swapped {
119        Ok(()) => {
120            // Every replacement is published; the originals are no longer needed.
121            for replacement in &replacements {
122                remove_ignoring_errors(&replacement.backup);
123            }
124            Ok(())
125        }
126        Err(error) => {
127            restore(&replacements);
128            Err(error)
129        }
130    }
131}
132
133fn swap(
134    replacements: &mut [Replacement],
135    resources: &Path,
136    main_dictionary: bool,
137    sources: &[PathBuf],
138    journal: &Path,
139) -> Result<()> {
140    // A plain copy, as the reference made: the packaged bundle is immutable, so it has no WAL to lose.
141    for (source, replacement) in sources.iter().zip(replacements.iter()) {
142        copy_private_file(source, &replacement.temporary)
143            .map_err(|_| EngineError::failed(diagnostics::RESET_STAGE_DICTIONARIES_FAILED))?;
144    }
145    // 与准备代次相同:单独发布的五笔码表并回新的工作主词库,否则重置之后五笔学习与删词都找不到表。没有工作主词库时 `replacements[0]` 是英文词库,不并。
146    if main_dictionary {
147        merge_split_wubi(resources, &replacements[0].temporary)
148            .map_err(|_| EngineError::failed(diagnostics::RESET_STAGE_DICTIONARIES_FAILED))?;
149    }
150    for replacement in replacements.iter_mut() {
151        replacement.had_original = replacement.target.exists();
152        if replacement.had_original {
153            if replacement.backup.exists() {
154                remove_ignoring_errors(&replacement.backup);
155            }
156            // The original is held aside until every replacement is published.
157            fs::rename(&replacement.target, &replacement.backup)
158                .map_err(|_| EngineError::failed(diagnostics::RESET_STAGE_FAILED))?;
159        }
160        fs::rename(&replacement.temporary, &replacement.target)
161            .map_err(|_| EngineError::failed(diagnostics::RESET_PUBLISH_FAILED))?;
162        replacement.published = true;
163    }
164    for suffix in JOURNAL_SIDECARS {
165        let mut sidecar = journal.as_os_str().to_owned();
166        sidecar.push(suffix);
167        match fs::remove_file(PathBuf::from(sidecar)) {
168            Ok(()) => {}
169            Err(error) if error.kind() == ErrorKind::NotFound => {}
170            Err(error) => return Err(error.into()),
171        }
172    }
173    Ok(())
174}
175
176/// Undo in reverse order: drop what was published, put each original back, remove the fresh copies. Best effort, since the error being returned is the one that matters (bridge.cpp:648-655).
177fn restore(replacements: &[Replacement]) {
178    for replacement in replacements.iter().rev() {
179        if replacement.published {
180            remove_ignoring_errors(&replacement.target);
181        }
182        if replacement.had_original && replacement.backup.exists() {
183            let _ = fs::rename(&replacement.backup, &replacement.target);
184        }
185        remove_ignoring_errors(&replacement.temporary);
186    }
187}
188
189/// 重置在两次改名之间被杀时,从时间戳最新的普通文件备份恢复缺失的 `target`,再删除源文件名下同一时间戳没发布的 `.reset.` 副本。词库现名没有备份时也查找合法旧名备份,发布到请求的现名,供代次回放使用;现有文件与备份都按现名优先。准备代次时在打开词库和日志之前调用。
190///
191/// `target` 在(包括符号链接)时不动它,最终发布也不得覆盖检查之后竞争者放回的文件;失败保留备份。恢复后原重置进程若还活着,它缺少已删除的 `.reset.` 副本,后续发布失败会走原有回滚;这里不提供跨文件事务原子性。
192pub(crate) fn restore_interrupted_reset(target: &Path) {
193    match fs::symlink_metadata(target) {
194        Err(error) if error.kind() == ErrorKind::NotFound => {}
195        _ => return,
196    }
197    // 旧文件挪开后 `RuntimePaths::dictionary` 已看不到它,只会给出现名;备份仍使用旧文件名。
198    let backup = newest_reset_backup(target).or_else(|| {
199        let name = target.file_name()?.to_str()?;
200        let (_, legacy) = assets::LEGACY_NAMES
201            .iter()
202            .find(|(current, _)| *current == name)?;
203        let legacy = target.with_file_name(legacy);
204        match fs::symlink_metadata(&legacy) {
205            Err(error) if error.kind() == ErrorKind::NotFound => newest_reset_backup(&legacy),
206            _ => None,
207        }
208    });
209    if let Some(backup) = backup {
210        backup.publish(target);
211    }
212}
213
214struct ResetBackup {
215    path: PathBuf,
216    unpublished: PathBuf,
217}
218
219/// 选择与发布分开:目录枚举结束后,其他准备者仍可能先把目标放回来。
220fn newest_reset_backup(target: &Path) -> Option<ResetBackup> {
221    let directory = target.parent()?;
222    let name = target.file_name()?.to_str()?;
223    let prefix = format!(".{name}.backup.");
224    fs::read_dir(directory)
225        .ok()?
226        .flatten()
227        .filter_map(|entry| {
228            let stamp = entry
229                .file_name()
230                .to_str()?
231                .strip_prefix(&prefix)?
232                .to_owned();
233            let order = stamp
234                .parse::<u128>()
235                .ok()
236                .filter(|_| stamp.bytes().all(|byte| byte.is_ascii_digit()))?;
237            is_real_file(&entry.path()).then_some((order, stamp, entry.path()))
238        })
239        .max_by_key(|(order, _, _)| *order)
240        .map(|(_, stamp, path)| ResetBackup {
241            path,
242            unpublished: affixed(target, ".reset.", &stamp),
243        })
244}
245
246impl ResetBackup {
247    fn publish(self, target: &Path) {
248        let Ok(mut backup) = tempfile::TempPath::try_from_path(self.path) else {
249            return;
250        };
251        // 这是唯一的原件,失败或输掉发布竞争都不能让 `TempPath::drop` 删除它。
252        backup.disable_cleanup(true);
253        if backup.persist_noclobber(target).is_ok() {
254            remove_ignoring_errors(&self.unpublished);
255        }
256    }
257}
258
259/// `.<name><infix><stamp>` beside `target`, built on the native string so a profile directory such as `C:\Users\陆傲天` survives unchanged; everything added is ASCII.
260fn affixed(target: &Path, infix: &str, stamp: &str) -> PathBuf {
261    let mut name = OsString::from(".");
262    name.push(target.file_name().unwrap_or_default());
263    name.push(infix);
264    name.push(stamp);
265    target.with_file_name(name)
266}
267
268fn remove_ignoring_errors(path: &Path) {
269    if path.is_dir() {
270        let _ = fs::remove_dir_all(path);
271    } else {
272        let _ = fs::remove_file(path);
273    }
274}
275
276fn is_real_file(path: &Path) -> bool {
277    fs::symlink_metadata(path)
278        .map(|metadata| metadata.file_type().is_file())
279        .unwrap_or(false)
280}
281
282fn reject_redirected_directory(path: &Path) -> std::io::Result<()> {
283    for ancestor in path.ancestors() {
284        match fs::symlink_metadata(ancestor) {
285            Ok(metadata) if metadata.file_type().is_symlink() => {
286                if crate::paths::is_trusted_system_alias(ancestor) {
287                    continue;
288                }
289                return Err(std::io::Error::new(
290                    std::io::ErrorKind::InvalidInput,
291                    "reset directory has a symbolic-link ancestor",
292                ));
293            }
294            Ok(metadata) if !metadata.is_dir() => {
295                return Err(std::io::Error::new(
296                    std::io::ErrorKind::NotADirectory,
297                    "reset directory parent is not a directory",
298                ));
299            }
300            Ok(_) => {}
301            Err(error) if error.kind() == ErrorKind::NotFound => {}
302            Err(error) => return Err(error),
303        }
304    }
305    Ok(())
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::user_dictionary::replay::tests::{sql, weight};
312
313    /// 两个准备者先看到缺失目标;最新备份先发布后,迟到的旧备份不能覆盖它,也不能被失败路径清理。
314    #[test]
315    fn a_late_reset_recovery_preserves_the_winners_file_and_its_own_backup() {
316        let root = tempfile::tempdir().unwrap();
317        let target = root.path().join(assets::USER_JOURNAL);
318        let older = affixed(&target, ".backup.", "100");
319        let newest = affixed(&target, ".backup.", "200");
320        let older_stage = affixed(&target, ".reset.", "100");
321        fs::write(&older, b"older synthetic journal").unwrap();
322        fs::write(&newest, b"newest synthetic journal").unwrap();
323        fs::write(&older_stage, b"unpublished synthetic journal").unwrap();
324        assert_eq!(
325            fs::symlink_metadata(&target).unwrap_err().kind(),
326            ErrorKind::NotFound
327        );
328
329        newest_reset_backup(&target).unwrap().publish(&target);
330        // 迟到者此前同样看到了缺失;枚举时最新备份已被赢家取走,只剩旧备份。
331        newest_reset_backup(&target).unwrap().publish(&target);
332
333        assert_eq!(fs::read(&target).unwrap(), b"newest synthetic journal");
334        assert_eq!(fs::read(&older).unwrap(), b"older synthetic journal");
335        assert_eq!(
336            fs::read(&older_stage).unwrap(),
337            b"unpublished synthetic journal"
338        );
339    }
340
341    /// 现有词库沿用路径解析的优先级;两种名称都只有备份时优先恢复现名备份。
342    #[test]
343    fn reset_recovery_follows_current_and_legacy_name_priority() {
344        for (name, legacy) in [
345            (assets::MAIN_DICTIONARY, "msime.db"),
346            (assets::ENGLISH_DICTIONARY, "english.db"),
347        ] {
348            let root = tempfile::tempdir().unwrap();
349            let paths = RuntimePaths {
350                dictionaries: root.path().to_owned(),
351                ..RuntimePaths::default()
352            };
353            let current = root.path().join(name);
354            let old = root.path().join(legacy);
355            let current_backup = affixed(&current, ".backup.", "100");
356            let old_backup = affixed(&old, ".backup.", "200");
357            fs::write(&current, b"existing synthetic dictionary").unwrap();
358            fs::write(&old_backup, b"legacy synthetic backup").unwrap();
359            restore_interrupted_reset(&paths.dictionary(name));
360            assert_eq!(
361                fs::read(&current).unwrap(),
362                b"existing synthetic dictionary"
363            );
364            assert!(old_backup.exists());
365
366            fs::rename(&current, &old).unwrap();
367            fs::write(&current_backup, b"current synthetic backup").unwrap();
368            restore_interrupted_reset(&paths.dictionary(name));
369            assert_eq!(fs::read(&old).unwrap(), b"existing synthetic dictionary");
370            assert!(current_backup.exists());
371            assert!(!current.exists());
372
373            fs::remove_file(&old).unwrap();
374            restore_interrupted_reset(&paths.dictionary(name));
375            assert_eq!(fs::read(&current).unwrap(), b"current synthetic backup");
376            assert!(!current_backup.exists());
377            assert_eq!(fs::read(&old_backup).unwrap(), b"legacy synthetic backup");
378        }
379    }
380
381    fn paths(root: &Path) -> RuntimePaths {
382        let path = |name: &str| {
383            let path = root.join(name);
384            fs::create_dir_all(&path).unwrap();
385            path
386        };
387        RuntimePaths {
388            resources: path("resources"),
389            user_data: path("user"),
390            cache: path("cache"),
391            dictionaries: path("dictionaries"),
392        }
393    }
394
395    /// 引擎层的重置自己取维护租约:还有会话持共享租约时报 `Busy`,词库和日志原样不动;会话结束后照常重置。
396    #[test]
397    fn reset_takes_the_maintenance_lease() {
398        let temporary = tempfile::tempdir().unwrap();
399        let paths = paths(temporary.path());
400        sql(
401            &paths.resource(assets::MAIN_DICTIONARY),
402            "CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);",
403        );
404        sql(
405            &paths.resource(assets::ENGLISH_DICTIONARY),
406            "CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);",
407        );
408        fs::create_dir_all(&paths.user_data).unwrap();
409        fs::create_dir_all(&paths.dictionaries).unwrap();
410        let journal = paths.user(assets::USER_JOURNAL);
411        sql(&journal, "CREATE TABLE marker(value INTEGER);");
412        let session = crate::DictionaryAccess::try_session(&paths.user_data, &paths.dictionaries)
413            .unwrap()
414            .unwrap();
415
416        let error = reset_learned_data(&paths).unwrap_err();
417        assert_eq!(error.code(), crate::ErrorCode::Busy);
418        let marker = "SELECT count(*) FROM sqlite_master WHERE name='marker'";
419        assert_eq!(weight(&journal, marker), Some(1));
420        assert!(!paths.dictionary(assets::MAIN_DICTIONARY).exists());
421
422        drop(session);
423        reset_learned_data(&paths).unwrap();
424        assert!(paths.dictionary(assets::MAIN_DICTIONARY).exists());
425        assert_eq!(weight(&journal, marker), Some(0));
426    }
427
428    /// 拆分发布布局:`msime-pinyin.db` 没有五笔表,重置后的工作主词库要把 `msime-wubi.db` 的码表并回来,五笔学习与删词才有表可写。
429    #[test]
430    fn reset_merges_the_split_wubi_tables_into_the_fresh_working_copy() {
431        let temporary = tempfile::tempdir().unwrap();
432        let paths = paths(temporary.path());
433        sql(
434            &paths.resource(assets::MAIN_DICTIONARY),
435            "CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);",
436        );
437        sql(
438            &paths.resource(assets::WUBI_DICTIONARY),
439            "CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);
440             INSERT INTO wubi86 VALUES('aaaa','工',200);
441             CREATE TABLE wubi98(key TEXT,value TEXT,weight INTEGER);",
442        );
443        sql(
444            &paths.resource(assets::ENGLISH_DICTIONARY),
445            "CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);",
446        );
447
448        reset_learned_data(&paths).unwrap();
449
450        assert_eq!(
451            weight(
452                &paths.dictionary(assets::MAIN_DICTIONARY),
453                "SELECT weight FROM wubi86 WHERE key='aaaa'"
454            ),
455            Some(200)
456        );
457        assert_eq!(
458            weight(
459                &paths.dictionary(assets::MAIN_DICTIONARY),
460                "SELECT count(*) FROM wubi98"
461            ),
462            Some(0)
463        );
464        assert!(!paths.dictionary(assets::WUBI_DICTIONARY).exists());
465    }
466
467    // engine-bridge tests.rs `reset_learned_data_restores_packaged_dictionaries_and_clears_journal`, for an ASCII root and one carrying Chinese characters.
468    #[test]
469    fn reset_restores_packaged_dictionaries_and_clears_the_journal() {
470        for component in ["ascii", "陆傲天"] {
471            let temporary = tempfile::tempdir().unwrap();
472            let paths = paths(&temporary.path().join(component));
473            sql(
474                &paths.resource(assets::MAIN_DICTIONARY),
475                "CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
476                 INSERT INTO tbl_2_n VALUES('ni''hao','nh','你好',100);
477                 CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);
478                 CREATE TABLE quick_parases(key TEXT,value TEXT,weight INTEGER);",
479            );
480            sql(
481                &paths.resource(assets::ENGLISH_DICTIONARY),
482                "CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);
483                 CREATE TABLE en_zh_glosses(english TEXT PRIMARY KEY,chinese_gloss TEXT);
484                 CREATE TABLE zh_en_glosses(chinese TEXT PRIMARY KEY,english TEXT);
485                 INSERT INTO english_words VALUES('word','word',100);",
486            );
487            for name in [assets::MAIN_DICTIONARY, assets::ENGLISH_DICTIONARY] {
488                fs::copy(paths.resource(name), paths.dictionary(name)).unwrap();
489            }
490            let journal = paths.user(assets::USER_JOURNAL);
491            // The legacy journal shape the bridge test used: no primary key, no `updated_at`.
492            sql(
493                &journal,
494                "CREATE TABLE user_dictionary_operations(dictionary TEXT,key TEXT,value TEXT,operation TEXT,weight INTEGER,display TEXT,user_inserted INTEGER);
495                 CREATE TABLE personal_dictionary_receipts(request_id TEXT PRIMARY KEY,payload TEXT);
496                 CREATE TABLE candidate_selection_state(context_key TEXT,entry_key TEXT,value TEXT,selection_count INTEGER);
497                 CREATE TABLE fixed_candidate_positions(context_key TEXT,entry_key TEXT,value TEXT,position INTEGER);
498                 INSERT INTO user_dictionary_operations VALUES('pinyin','ni''hao','你好','upsert',1,'',1);
499                 INSERT INTO candidate_selection_state VALUES('ni''hao','ni''hao','你好',7);",
500            );
501            sql(
502                &paths.dictionary(assets::MAIN_DICTIONARY),
503                "UPDATE tbl_2_n SET weight=1",
504            );
505            let mut wal = journal.as_os_str().to_owned();
506            wal.push("-wal");
507            fs::write(PathBuf::from(&wal), b"stale learned data").unwrap();
508
509            reset_learned_data(&paths).unwrap();
510
511            assert_eq!(
512                weight(
513                    &paths.dictionary(assets::MAIN_DICTIONARY),
514                    "SELECT weight FROM tbl_2_n WHERE key='ni''hao'"
515                ),
516                Some(100)
517            );
518            assert_eq!(
519                weight(&journal, "SELECT count(*) FROM user_dictionary_operations"),
520                Some(0)
521            );
522            assert_eq!(
523                weight(&journal, "SELECT count(*) FROM candidate_selection_state"),
524                Some(0)
525            );
526            // The fresh journal has the full v4 schema.
527            assert_eq!(
528                weight(&journal, "SELECT count(*) FROM pinned_candidates"),
529                Some(0)
530            );
531            assert!(!PathBuf::from(&wal).exists());
532            let leftovers: Vec<_> = [&paths.user_data, &paths.dictionaries]
533                .iter()
534                .flat_map(|directory| fs::read_dir(directory).unwrap())
535                .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
536                // 维护租约的锁文件是稳定的协调对象,留着是对的(`access::LOCK_FILE`)。
537                .filter(|name| name.starts_with('.') && name != crate::access::LOCK_FILE)
538                .collect();
539            assert!(leftovers.is_empty(), "{leftovers:?}");
540        }
541    }
542
543    /// 重置学习数据连双拼按两键码学的纠错(#10018)一起清掉:新日志里两张表是空的,共用的纠错档案下次刷新就读不到旧记录。
544    #[test]
545    fn reset_clears_shuangpin_typo_learning() {
546        use crate::types::ShuangpinProfileKind;
547        use crate::user_dictionary::typo_profile::{PersonalTypoProfile, ShuangpinTypoOutcome};
548        let temporary = tempfile::tempdir().unwrap();
549        let paths = paths(temporary.path());
550        sql(
551            &paths.resource(assets::MAIN_DICTIONARY),
552            "CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);",
553        );
554        sql(
555            &paths.resource(assets::ENGLISH_DICTIONARY),
556            "CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);",
557        );
558        let journal = paths.user(assets::USER_JOURNAL);
559        let profile = PersonalTypoProfile::shared(&journal);
560        profile
561            .record_shuangpin(
562                ShuangpinProfileKind::Xiaohe,
563                &[(*b"gf", *b"gr")],
564                ShuangpinTypoOutcome::Accepted,
565            )
566            .unwrap();
567        profile
568            .record_shuangpin_suppression(ShuangpinProfileKind::Xiaohe, "mwgfxi")
569            .unwrap();
570        assert!(profile.has_shuangpin_counts());
571
572        reset_learned_data(&paths).unwrap();
573
574        for table in [
575            "shuangpin_typo_counts",
576            "shuangpin_autocorrect_suppressions",
577        ] {
578            assert_eq!(
579                weight(&journal, &format!("SELECT count(*) FROM {table}")),
580                Some(0),
581                "{table}"
582            );
583        }
584        profile.refresh_if_changed();
585        assert!(!profile.has_shuangpin_counts());
586        assert!(!profile.shuangpin_suppressed(ShuangpinProfileKind::Xiaohe, "mwgfxi"));
587    }
588
589    #[test]
590    fn reset_refuses_the_bundle_in_place_and_a_missing_package() {
591        let root = tempfile::tempdir().unwrap();
592        let mut paths = paths(root.path());
593        let error = reset_learned_data(&paths).unwrap_err();
594        assert_eq!(
595            error.to_string(),
596            diagnostics::PACKAGED_DICTIONARY_UNAVAILABLE
597        );
598        paths.dictionaries = paths.resources.join(".");
599        let error = reset_learned_data(&paths).unwrap_err();
600        assert!(matches!(error, EngineError::InvalidArgument(_)));
601        assert_eq!(error.to_string(), diagnostics::RESET_IN_PLACE);
602    }
603
604    /// 没有 `msime-pinyin.db` 的代次:重置只换回 `msime-english.db`、清空日志,不要求资源目录里有 `msime-pinyin.db`,也不在代次里留下它;同一个资源目录按有 `msime-pinyin.db` 重置仍然被拒。
605    #[test]
606    fn reset_without_the_main_dictionary_restores_only_english() {
607        let root = tempfile::tempdir().unwrap();
608        let paths = paths(root.path());
609        sql(
610            &paths.resource(assets::ENGLISH_DICTIONARY),
611            "CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);
612             INSERT INTO english_words VALUES('word','word',100);",
613        );
614        fs::copy(
615            paths.resource(assets::ENGLISH_DICTIONARY),
616            paths.dictionary(assets::ENGLISH_DICTIONARY),
617        )
618        .unwrap();
619        sql(
620            &paths.dictionary(assets::ENGLISH_DICTIONARY),
621            "UPDATE english_words SET weight=1",
622        );
623        let journal = paths.user(assets::USER_JOURNAL);
624        sql(
625            &journal,
626            "CREATE TABLE user_dictionary_operations(dictionary TEXT,key TEXT,value TEXT,operation TEXT,weight INTEGER,display TEXT,user_inserted INTEGER);
627             INSERT INTO user_dictionary_operations VALUES('english','word','word','upsert',1,'word',0);",
628        );
629
630        assert_eq!(
631            reset_learned_data(&paths).unwrap_err().to_string(),
632            diagnostics::PACKAGED_DICTIONARY_UNAVAILABLE
633        );
634        reset_learned_data_with(&paths, false).unwrap();
635
636        assert_eq!(
637            weight(
638                &paths.dictionary(assets::ENGLISH_DICTIONARY),
639                "SELECT weight FROM english_words WHERE word='word'"
640            ),
641            Some(100)
642        );
643        assert_eq!(
644            weight(&journal, "SELECT count(*) FROM user_dictionary_operations"),
645            Some(0)
646        );
647        assert!(!paths.dictionary(assets::MAIN_DICTIONARY).exists());
648        assert!(!paths.resource(assets::MAIN_DICTIONARY).exists());
649        let leftovers: Vec<_> = [&paths.user_data, &paths.dictionaries]
650            .iter()
651            .flat_map(|directory| fs::read_dir(directory).unwrap())
652            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
653            // 维护租约的锁文件是稳定的协调对象,留着是对的(`access::LOCK_FILE`)。
654            .filter(|name| name.starts_with('.') && name != crate::access::LOCK_FILE)
655            .collect();
656        assert!(leftovers.is_empty(), "{leftovers:?}");
657    }
658
659    #[cfg(unix)]
660    #[test]
661    fn reset_rejects_a_symlinked_packaged_dictionary() {
662        use std::os::unix::fs::symlink;
663
664        let root = tempfile::tempdir().unwrap();
665        let paths = paths(root.path());
666        sql(
667            &paths.resource(assets::ENGLISH_DICTIONARY),
668            "CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);",
669        );
670        let external = root.path().join("external-msime-pinyin.db");
671        sql(
672            &external,
673            "CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);",
674        );
675        symlink(&external, paths.resource(assets::MAIN_DICTIONARY)).unwrap();
676
677        assert_eq!(
678            reset_learned_data(&paths).unwrap_err().to_string(),
679            diagnostics::PACKAGED_DICTIONARY_UNAVAILABLE
680        );
681        assert!(!paths.dictionary(assets::MAIN_DICTIONARY).exists());
682    }
683
684    #[cfg(unix)]
685    #[test]
686    fn reset_rejects_a_symlinked_dictionary_directory() {
687        use std::os::unix::fs::symlink;
688
689        let root = tempfile::tempdir().unwrap();
690        let outside = tempfile::tempdir().unwrap();
691        let paths = paths(root.path());
692        for name in [assets::MAIN_DICTIONARY, assets::ENGLISH_DICTIONARY] {
693            sql(&paths.resource(name), "CREATE TABLE packaged(x);");
694        }
695        fs::remove_dir(&paths.dictionaries).unwrap();
696        symlink(outside.path(), &paths.dictionaries).unwrap();
697
698        assert!(reset_learned_data(&paths).is_err());
699        assert!(fs::read_dir(outside.path()).unwrap().next().is_none());
700    }
701
702    #[test]
703    fn a_failed_publish_puts_every_original_back() {
704        let root = tempfile::tempdir().unwrap();
705        let paths = paths(root.path());
706        sql(
707            &paths.resource(assets::MAIN_DICTIONARY),
708            "CREATE TABLE fresh(x);",
709        );
710        sql(
711            &paths.resource(assets::ENGLISH_DICTIONARY),
712            "CREATE TABLE fresh(x);",
713        );
714        sql(
715            &paths.dictionary(assets::MAIN_DICTIONARY),
716            "CREATE TABLE learned(x);",
717        );
718        sql(
719            &paths.dictionary(assets::ENGLISH_DICTIONARY),
720            "CREATE TABLE learned(x);",
721        );
722        let journal = paths.user(assets::USER_JOURNAL);
723        sql(&journal, "CREATE TABLE learned(x);");
724        // A `-wal` sidecar that is a directory cannot be removed as a file, so the reset fails after all three files were published.
725        let mut wal = journal.as_os_str().to_owned();
726        wal.push("-wal");
727        let wal = PathBuf::from(wal);
728        fs::create_dir_all(wal.join("blocker")).unwrap();
729        let before: Vec<Vec<u8>> = [
730            paths.dictionary(assets::MAIN_DICTIONARY),
731            paths.dictionary(assets::ENGLISH_DICTIONARY),
732            journal.clone(),
733        ]
734        .iter()
735        .map(|path| fs::read(path).unwrap())
736        .collect();
737
738        assert!(reset_learned_data(&paths).is_err());
739
740        let after: Vec<Vec<u8>> = [
741            paths.dictionary(assets::MAIN_DICTIONARY),
742            paths.dictionary(assets::ENGLISH_DICTIONARY),
743            journal.clone(),
744        ]
745        .iter()
746        .map(|path| fs::read(path).unwrap())
747        .collect();
748        assert_eq!(after, before);
749        assert!(wal.join("blocker").is_dir());
750        let leftovers: Vec<_> = [&paths.user_data, &paths.dictionaries]
751            .iter()
752            .flat_map(|directory| fs::read_dir(directory).unwrap())
753            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
754            // 维护租约的锁文件是稳定的协调对象,留着是对的(`access::LOCK_FILE`)。
755            .filter(|name| name.starts_with('.') && name != crate::access::LOCK_FILE)
756            .collect();
757        assert!(leftovers.is_empty(), "{leftovers:?}");
758    }
759}