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