Skip to main content

msime_engine/user_dictionary/
generation.rs

1//! 代次准备(core-session.md §12、data-formats.md §3、`runtime_paths.cpp:116-182`):`user_data/dictionaries/<content id>` 里是经 backup API 复制的 `msime-pinyin.db` 与 `msime-english.db`(资源单独发布的 `msime-wubi.db` 五笔码表并回前者),回放过用户日志,旁边还有 n-gram 表。不读 `msime-pinyin.db` 的方案集合(见 `SchemeSet::reads_main_dictionary`)准备的代次只有 `msime-english.db`。
2
3use std::fs::{self, OpenOptions};
4use std::io::{self, ErrorKind};
5use std::path::{Component, Path, PathBuf};
6use std::time::{Duration, SystemTime};
7
8use rusqlite::backup::{Backup, StepResult};
9use rusqlite::{Connection, 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::journal::{open_database, quarantine_corrupt_journal};
17use crate::user_dictionary::replay::{replay, replay_english};
18use crate::user_dictionary::reset::restore_interrupted_reset;
19use crate::wubi::provider::ensure_reverse_indexes;
20
21pub const MAX_CONTENT_ID_LENGTH: usize = 128;
22
23/// Tables the lattice reads through `RuntimePaths::dictionary`. They are built from one generation's vocabulary, so they live beside it, and a resource set without them simply leaves the decoder without them (RP:56-59).
24const GENERATION_COPIES: [&str; 2] = [assets::BIGRAM_TABLE, assets::TRIGRAM_TABLE];
25
26/// 暂存目录名的后缀,见 [`prepare_runtime_paths_for`]。
27const STAGE_SUFFIX: &str = ".incoming";
28
29/// 清理时先把被丢下的暂存目录改名成这个后缀认领,再删。
30const CLAIMED_STAGE_SUFFIX: &str = ".removing";
31
32/// 暂存目录多久没有任何写入才算被丢下。真在准备的进程一直在写:复制约 200 MB 的词库、回放日志、建索引,最慢的设备上两次写入之间也只隔几十秒,一次完整准备在几分钟以内;一小时留出几十倍余量。代价是被杀留下的半截目录要等下一次在一小时后的准备才被删,期间只占空间,不再挡住准备。
33const ABANDONED_STAGE_AGE: Duration = Duration::from_secs(60 * 60);
34
35/// `msime-wubi.db` 里单独发布、准备代次时并回工作主词库的五笔码表。
36const SPLIT_WUBI_TABLES: [&str; 2] = ["wubi86", "wubi98"];
37
38/// 校验代次 id(1 到 128 个 `[0-9A-Za-z_-]`)和三个互不重叠的绝对路径根,建好 `user_data` 和 `cache`,清掉被丢下的暂存目录,然后要么对已就绪的代次重新回放日志(补上它缺的 n-gram 表),要么在这次尝试独有的 `<id>.<随机串>.incoming` 里暂存、回放、写 `.ready` 再改名就位。暂存失败只删自己的目录;同一个代次被别的进程抢先发布时用那一份。
39pub fn prepare_runtime_paths(
40    resources: &Path,
41    user_data: &Path,
42    cache: &Path,
43    content_id: &str,
44) -> Result<RuntimePaths> {
45    prepare_runtime_paths_for(resources, user_data, cache, content_id, SchemeSet::ALL)
46}
47
48/// 按会话允许的方案准备代次。`schemes` 读 `msime-pinyin.db`([`SchemeSet::reads_main_dictionary`])时与 [`prepare_runtime_paths`] 完全相同:`msime-pinyin.db` 和 `msime-english.db` 都必须在资源目录里,都复制进代次。不读它时(只有日文、越南文、藏文这类方案的版本)资源目录里本来就没有它:代次只复制并要求 `msime-english.db`,日志只回放英文行(`replay_english`)。
49pub fn prepare_runtime_paths_for(
50    resources: &Path,
51    user_data: &Path,
52    cache: &Path,
53    content_id: &str,
54    schemes: SchemeSet,
55) -> Result<RuntimePaths> {
56    let main_dictionary = schemes.reads_main_dictionary();
57    if !valid_content_id(content_id) {
58        return Err(EngineError::invalid(
59            diagnostics::INVALID_RUNTIME_CONTENT_ID,
60        ));
61    }
62    let result = RuntimePaths {
63        resources: resources.to_owned(),
64        user_data: user_data.to_owned(),
65        cache: cache.to_owned(),
66        dictionaries: user_data.join("dictionaries").join(content_id),
67    };
68    result.validate()?;
69    // Each root has a different lifetime, so validate before creating anything: clearing the cache must not delete durable data, and preparation must not write inside immutable resources (RP:126-128).
70    let resource_root = weakly_canonical(resources)?;
71    let user_root = weakly_canonical(user_data)?;
72    let cache_root = weakly_canonical(cache)?;
73    if roots_overlap(&resource_root, &user_root)
74        || roots_overlap(&resource_root, &cache_root)
75        || roots_overlap(&user_root, &cache_root)
76    {
77        return Err(EngineError::invalid(diagnostics::RUNTIME_ROOTS_OVERLAP));
78    }
79    reject_redirected_directory(user_data)?;
80    reject_redirected_directory(cache)?;
81    fs::create_dir_all(user_data)?;
82    fs::create_dir_all(cache)?;
83    reject_redirected_directory(user_data)?;
84    reject_redirected_directory(cache)?;
85    // 重置途中被杀时日志可能还挪在备份里,先放回来,否则下面会当作没有日志、从空日志开始。
86    restore_interrupted_reset(&result.user(assets::USER_JOURNAL));
87    // 不是数据库的日志会在下面的回放中每次启动都失败,让宿主永远无法启动;先把它挪开,从空日志开始。宿主 API 会在进入这里之前上报挪动结果;这里覆盖其余调用方。
88    if let Some(moved) = quarantine_corrupt_journal(&result.user(assets::USER_JOURNAL))? {
89        crate::logging::warn(format_args!(
90            "user dictionary journal is not a database, moved aside to {}",
91            moved.display()
92        ));
93    }
94
95    let parent = user_data.join("dictionaries");
96    sweep_abandoned_stages(&parent, SystemTime::now());
97
98    let marker = result.dictionaries.join(assets::GENERATION_READY);
99    if generation_ready(&marker)? {
100        return open_ready_generation(result, resources, main_dictionary);
101    }
102
103    fs::create_dir_all(&parent)?;
104    // 每次尝试在 `<id>.<随机串>.incoming` 里暂存,目录由 `tempdir_in` 独占新建(同名已存在就换一个名字),两次准备绝不共用一个暂存目录,这是 RP:155-157 独占新建要保证的;名字每次不同,进程在中途被杀留下的暂存目录也就挡不住下一次准备,由 [`sweep_abandoned_stages`] 收拾。出错时 `TempDir` 被丢弃,只删这次自己的目录(RP:174-179)。
105    let stage = tempfile::Builder::new()
106        .prefix(&format!("{content_id}."))
107        .suffix(STAGE_SUFFIX)
108        .tempdir_in(&parent)?;
109    let staged = (|| -> Result<()> {
110        if main_dictionary {
111            check_dictionary_format(
112                &resources.join(assets::MAIN_DICTIONARY),
113                assets::MAIN_DICTIONARY,
114            )?;
115        }
116        for name in generation_dictionaries(main_dictionary) {
117            copy_database(&resources.join(name), &stage.path().join(name))?;
118        }
119        if main_dictionary {
120            merge_split_wubi(resources, &stage.path().join(assets::MAIN_DICTIONARY))?;
121        }
122        stage_generation_copies(resources, stage.path(), true)?;
123        replay_into(&result, stage.path(), main_dictionary)?;
124        index_reverse_lookup(stage.path(), main_dictionary);
125        write_private_file(
126            &stage.path().join(assets::GENERATION_READY),
127            format!("{content_id}\n").as_bytes(),
128        )
129        .map_err(|_| EngineError::failed(diagnostics::RUNTIME_FINALIZE_FAILED))?;
130        // 只把整套做完的目录改名成代次,读的一方要么看不到代次,要么看到带 `.ready` 的完整代次。代次已经存在且非空时改名一定失败(Unix 报 `ENOTEMPTY`/`EEXIST`,Windows 报 `ERROR_DIR_NOT_EMPTY` 或拒绝访问),不会顶掉别人发布的代次。
131        fs::rename(stage.path(), &result.dictionaries)?;
132        Ok(())
133    })();
134    match staged {
135        Ok(()) => {
136            // 目录已经改名成代次,不能再让 `TempDir` 按原路径去删。
137            let _ = stage.keep();
138            Ok(result)
139        }
140        Err(error) => {
141            drop(stage);
142            // 另一个进程同时在准备同一个代次并抢先发布了它(这次的改名因此失败),或者这次停顿太久、暂存目录被当作丢下的清掉了:用已经就绪的那一份,和一开始就看到它一样再回放一遍日志。
143            if generation_ready(&marker).unwrap_or(false) {
144                return open_ready_generation(result, resources, main_dictionary);
145            }
146            Err(error)
147        }
148    }
149}
150
151/// 代次目录里是不是一个普通文件的 `.ready`。目录、设备或符号链接不算完成的代次,也不跟随、不去替换它所在的代次。
152fn generation_ready(marker: &Path) -> Result<bool> {
153    match fs::symlink_metadata(marker) {
154        Ok(metadata) if metadata.file_type().is_file() => Ok(true),
155        Ok(_) => Err(EngineError::failed(
156            diagnostics::INCOMPLETE_RUNTIME_GENERATION,
157        )),
158        Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
159        Err(error) => Err(error.into()),
160    }
161}
162
163/// 打开已经就绪的代次:放回重置途中被杀时挪开的词库,检查词库副本都在,补上五笔表、反查索引和后来才随资源发布的 n-gram 表,再把当前日志回放一遍。
164fn open_ready_generation(
165    result: RuntimePaths,
166    resources: &Path,
167    main_dictionary: bool,
168) -> Result<RuntimePaths> {
169    for name in generation_dictionaries(main_dictionary) {
170        // 重置途中被杀时词库可能还挪在备份里,见 `restore_interrupted_reset`。
171        restore_interrupted_reset(&result.dictionary(name));
172        if !is_real_file(&result.dictionary(name)) {
173            return Err(EngineError::failed(
174                diagnostics::INCOMPLETE_RUNTIME_GENERATION,
175            ));
176        }
177    }
178    // 拆分五笔码表之后准备的代次也要有五笔表;表已在时这一步只读不写。没有工作主词库的代次没有地方放五笔表。
179    if main_dictionary {
180        // 代次里的主库副本带着来源文件的 `metadata`。就绪的代次可能是更新的引擎用更新格式的词库准备的(宿主降级后仍选着那份下载的词库更新),这里不查,会话就会照旧格式去读它。
181        check_dictionary_format(
182            &result.dictionary(assets::MAIN_DICTIONARY),
183            assets::MAIN_DICTIONARY,
184        )?;
185        merge_split_wubi(resources, &result.dictionary(assets::MAIN_DICTIONARY))?;
186    }
187    // A host can switch back to a previously prepared generation. Replay the current journal again so changes learned on a newer generation survive that switch (RP:143-144).
188    replay_into(&result, &result.dictionaries, main_dictionary)?;
189    // 旧版本准备的代次没有反查索引,重新打开时补上;已有索引时只读一次 schema。
190    index_reverse_lookup(&result.dictionaries, main_dictionary);
191    stage_generation_copies(resources, &result.dictionaries, false)?;
192    Ok(result)
193}
194
195/// 清掉 `dictionaries` 下被丢下的暂存目录:准备途中被杀的进程留下的 `<id>.<随机串>.incoming`、旧版本固定名字的 `<id>.incoming`,以及上一次清理没删完的 `.removing`。每次准备都先做一遍,出什么错都不影响准备本身。
196///
197/// 暂存目录本身和里面每一项最近一次修改都早于 [`ABANDONED_STAGE_AGE`] 才算被丢下。不是自己的暂存目录无从判断主人是否还活着:引擎不能用文件锁(只有 client-base 知道 Android 上怎么锁,引擎不依赖它),进程号在 Linux 的不同 PID 命名空间之间、iOS 沙盒的 App 与扩展之间都不可靠,只剩“多久没写”这一条。被丢下的目录先改名成 `.removing` 认领再删:万一主人只是停顿太久(iOS 把 App 挂起在准备中途),它之后的写入和改名找不到原路径,只会让那次准备失败,不会把删到一半的目录发布成代次。
198fn sweep_abandoned_stages(parent: &Path, now: SystemTime) {
199    let Ok(entries) = fs::read_dir(parent) else {
200        return;
201    };
202    for entry in entries.flatten() {
203        let name = entry.file_name();
204        let Some(name) = name.to_str() else {
205            continue;
206        };
207        let path = entry.path();
208        if is_stage_name(name, CLAIMED_STAGE_SUFFIX) {
209            // 已经认领的目录不会再被谁发布,不必等它变旧。
210            if is_real_directory(&path) {
211                let _ = fs::remove_dir_all(&path);
212            }
213            continue;
214        }
215        if !is_stage_name(name, STAGE_SUFFIX) || !abandoned(&path, now) {
216            continue;
217        }
218        let mut claimed = name[..name.len() - STAGE_SUFFIX.len()].to_owned();
219        claimed.push_str(CLAIMED_STAGE_SUFFIX);
220        let claimed = parent.join(claimed);
221        // Windows 上目录里还有打开的文件时改名失败,跳过即可。
222        if fs::rename(&path, &claimed).is_ok() {
223            let _ = fs::remove_dir_all(&claimed);
224        }
225    }
226}
227
228/// `<id><suffix>` 或 `<id>.<字母数字><suffix>`,`<id>` 是合法的代次 id。
229fn is_stage_name(name: &str, suffix: &str) -> bool {
230    let Some(stem) = name.strip_suffix(suffix) else {
231        return false;
232    };
233    let (content_id, attempt) = match stem.split_once('.') {
234        Some((content_id, attempt)) => (content_id, Some(attempt)),
235        None => (stem, None),
236    };
237    valid_content_id(content_id)
238        && attempt.is_none_or(|attempt| {
239            !attempt.is_empty() && attempt.bytes().all(|byte| byte.is_ascii_alphanumeric())
240        })
241}
242
243/// 暂存目录(不是符号链接)和里面每一项的修改时间都早于 `now - ABANDONED_STAGE_AGE`。读不到时间、时间在将来、或者读的同时有项目消失,都当作还有人在写。
244fn abandoned(stage: &Path, now: SystemTime) -> bool {
245    let Some(cutoff) = now.checked_sub(ABANDONED_STAGE_AGE) else {
246        return false;
247    };
248    let untouched = |path: &Path| {
249        fs::symlink_metadata(path)
250            .and_then(|metadata| metadata.modified())
251            .is_ok_and(|modified| modified < cutoff)
252    };
253    if !is_real_directory(stage) || !untouched(stage) {
254        return false;
255    }
256    let Ok(entries) = fs::read_dir(stage) else {
257        return false;
258    };
259    entries
260        .into_iter()
261        .all(|entry| entry.is_ok_and(|entry| untouched(&entry.path())))
262}
263
264fn is_real_directory(path: &Path) -> bool {
265    fs::symlink_metadata(path)
266        .map(|metadata| metadata.file_type().is_dir())
267        .unwrap_or(false)
268}
269
270fn valid_content_id(content_id: &str) -> bool {
271    !content_id.is_empty()
272        && content_id.len() <= MAX_CONTENT_ID_LENGTH
273        && content_id
274            .bytes()
275            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
276}
277
278/// 代次里的词库工作副本:`main_dictionary` 为假时没有 `msime-pinyin.db`,只有 `msime-english.db`。
279fn generation_dictionaries(main_dictionary: bool) -> &'static [&'static str] {
280    if main_dictionary {
281        &[assets::MAIN_DICTIONARY, assets::ENGLISH_DICTIONARY]
282    } else {
283        &[assets::ENGLISH_DICTIONARY]
284    }
285}
286
287/// Replay the journal of `paths` into the dictionaries in `generation`; any failed row or error refuses the generation. 没有 `msime-pinyin.db` 的代次只回放英文行。
288fn replay_into(paths: &RuntimePaths, generation: &Path, main_dictionary: bool) -> Result<()> {
289    let journal = paths.user(assets::USER_JOURNAL);
290    let english = generation.join(assets::ENGLISH_DICTIONARY);
291    let replayed = if main_dictionary {
292        replay(
293            &journal,
294            &generation.join(assets::MAIN_DICTIONARY),
295            &english,
296        )
297    } else {
298        replay_english(&journal, &english)
299    };
300    if replayed.failed != 0 || !replayed.error.is_empty() {
301        return Err(EngineError::failed(format!(
302            "{}{}",
303            diagnostics::RUNTIME_REPLAY_FAILED,
304            replayed.error
305        )));
306    }
307    Ok(())
308}
309
310/// 给代次里每个词库副本(`main_dictionary` 为假时只有英文词库)补上五笔反查索引(见 `wubi::provider::ensure_reverse_indexes`)。代次是用户可写的副本,资源目录保持只读、原样;索引不进日志,也不影响词库状态的摘要。
311///
312/// 只是提速,从不失败:建索引要写盘(约 1.3 MB 加回滚日志),磁盘满、文件只读或被别的进程长时间锁住时记一条日志、照常用没有索引的副本,反查仍然正确,只是慢。这里报错会让宿主起不来。新词库在 dict-builder 里就带着同名索引,这里只为旧词库和旧代次补建。
313fn index_reverse_lookup(generation: &Path, main_dictionary: bool) {
314    for name in generation_dictionaries(main_dictionary) {
315        let database = generation.join(name);
316        let indexed = open_database(&database, OpenFlags::SQLITE_OPEN_READ_WRITE)
317            .and_then(|connection| ensure_reverse_indexes(&connection).map_err(Into::into));
318        if let Err(error) = indexed {
319            crate::logging::warn(format_args!(
320                "wubi reverse lookup index unavailable in {}, lookups stay unindexed: {error}",
321                database.display()
322            ));
323        }
324    }
325}
326
327/// Copy through the SQLite backup API, which includes committed WAL content that a plain file copy of a live database would lose (RP:38-55).
328fn copy_database(source: &Path, target: &Path) -> Result<()> {
329    if !is_real_file(source) {
330        return Err(EngineError::failed(format!(
331            "{}{}",
332            diagnostics::RUNTIME_COPY_FAILED,
333            source.display()
334        )));
335    }
336    let copied = (|| -> rusqlite::Result<StepResult> {
337        let source = crate::paths::sqlite_read_only_path_no_follow(source)
338            .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
339        let target = crate::paths::sqlite_path_no_follow(target)
340            .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
341        let input = Connection::open_with_flags(
342            &source,
343            OpenFlags::SQLITE_OPEN_READ_ONLY
344                | OpenFlags::SQLITE_OPEN_NOFOLLOW
345                | OpenFlags::SQLITE_OPEN_NO_MUTEX,
346        )?;
347        let mut output = Connection::open_with_flags(
348            &target,
349            OpenFlags::SQLITE_OPEN_READ_WRITE
350                | OpenFlags::SQLITE_OPEN_CREATE
351                | OpenFlags::SQLITE_OPEN_NOFOLLOW
352                | OpenFlags::SQLITE_OPEN_NO_MUTEX,
353        )?;
354        // runtime_paths.cpp:43-44 set no busy timeout on either side; rusqlite would otherwise add a 5 s wait the reference never had.
355        input.busy_timeout(std::time::Duration::ZERO)?;
356        output.busy_timeout(std::time::Duration::ZERO)?;
357        let backup = Backup::new(&input, &mut output)?;
358        backup.step(-1)
359    })();
360    if !matches!(copied, Ok(StepResult::Done)) {
361        return Err(EngineError::failed(format!(
362            "{}{}",
363            diagnostics::RUNTIME_COPY_FAILED,
364            source.display()
365        )));
366    }
367    Ok(())
368}
369
370pub(super) fn copy_private_file(source: &Path, target: &Path) -> io::Result<u64> {
371    let mut input = crate::paths::open_file_no_follow(source)?;
372    let mut output = create_private_file(target)?;
373    let copied = io::copy(&mut input, &mut output)?;
374    output.sync_all()?;
375    Ok(copied)
376}
377
378fn create_private_file(path: &Path) -> io::Result<fs::File> {
379    let mut options = OpenOptions::new();
380    options.write(true).create_new(true);
381    #[cfg(unix)]
382    {
383        use std::os::unix::fs::OpenOptionsExt;
384        options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
385    }
386    #[cfg(windows)]
387    {
388        use std::os::windows::fs::OpenOptionsExt;
389        const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
390        options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
391    }
392    options.open(path)
393}
394
395fn write_private_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
396    let mut output = create_private_file(path)?;
397    io::Write::write_all(&mut output, bytes)?;
398    output.sync_all()?;
399    Ok(())
400}
401
402/// 资源目录不带 `msime-wubi.db`、五笔码表来自宿主另行下载的那份 `wubi`(`EngineOptions::wubi_dictionary`,macOS 的 `wubi` 资源包)时,把它并进代次的工作主词库,再回放一遍日志:准备代次时码表还不在,日志里的五笔行被跳过(`replay`),并进来之后才有地方写。返回是否并入了码表。
403///
404/// 工作主词库已有五笔表、代次就是资源目录(只读布局)、没有工作主词库(不读拼音库的版本),或者 `wubi` 不是普通文件 `msime-wubi.db` 时什么也不做。已有五笔表时只读一次 schema,所以每次建会话都调用也不贵。
405pub(crate) fn merge_downloaded_wubi(paths: &RuntimePaths, wubi: &Path) -> Result<bool> {
406    let main_db = paths.dictionary(assets::MAIN_DICTIONARY);
407    if paths.dictionaries == paths.resources
408        || !is_real_file(&main_db)
409        || wubi.file_name() != Some(std::ffi::OsStr::new(assets::WUBI_DICTIONARY))
410        || !is_real_file(wubi)
411    {
412        return Ok(false);
413    }
414    if has_split_wubi_tables(&main_db)? {
415        return Ok(false);
416    }
417    let Some(directory) = wubi.parent() else {
418        return Ok(false);
419    };
420    merge_split_wubi(directory, &main_db)?;
421    replay_into(paths, &paths.dictionaries, true)?;
422    index_reverse_lookup(&paths.dictionaries, true);
423    Ok(true)
424}
425
426/// 工作主词库里是否已有 `wubi86` 或 `wubi98`。
427pub(crate) fn has_split_wubi_tables(main_db: &Path) -> Result<bool> {
428    let present = (|| -> rusqlite::Result<bool> {
429        let path = crate::paths::sqlite_read_only_path_no_follow(main_db)
430            .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
431        let connection = Connection::open_with_flags(
432            &path,
433            OpenFlags::SQLITE_OPEN_READ_ONLY
434                | OpenFlags::SQLITE_OPEN_NOFOLLOW
435                | OpenFlags::SQLITE_OPEN_NO_MUTEX,
436        )?;
437        connection.query_row(
438            "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name IN ('wubi86','wubi98'))",
439            [],
440            |row| row.get(0),
441        )
442    })();
443    present.map_err(|error| {
444        EngineError::failed(format!("cannot read {}: {error}", assets::MAIN_DICTIONARY))
445    })
446}
447
448/// 词库发布把五笔码表单独放在只读的 `msime-wubi.db` 里,`msime-pinyin.db` 不再含 `wubi86`/`wubi98`。五笔的学习调序、删词、个人词典编辑与日志回放都写代次里的工作主词库,五笔 provider 也从它读,所以准备代次(以及重置学习数据)时把这两张表连同索引并回工作副本:读写落在同一个文件上,学到的权重立即可见。资源目录没有 `msime-wubi.db`(旧的合并发布)或工作副本里已有同名表时不动。
449pub(crate) fn merge_split_wubi(resources: &Path, main_db: &Path) -> Result<()> {
450    let source = resources.join(assets::WUBI_DICTIONARY);
451    if !is_real_file(&source) {
452        return Ok(());
453    }
454    check_dictionary_format(&source, assets::WUBI_DICTIONARY)?;
455    let merged = (|| -> rusqlite::Result<()> {
456        let source = crate::paths::sqlite_read_only_path_no_follow(&source)
457            .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
458        let main_db = crate::paths::sqlite_path_no_follow(main_db)
459            .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
460        let input = Connection::open_with_flags(
461            &source,
462            OpenFlags::SQLITE_OPEN_READ_ONLY
463                | OpenFlags::SQLITE_OPEN_NOFOLLOW
464                | OpenFlags::SQLITE_OPEN_NO_MUTEX,
465        )?;
466        let mut output = Connection::open_with_flags(
467            &main_db,
468            OpenFlags::SQLITE_OPEN_READ_WRITE
469                | OpenFlags::SQLITE_OPEN_NOFOLLOW
470                | OpenFlags::SQLITE_OPEN_NO_MUTEX,
471        )?;
472        input.busy_timeout(std::time::Duration::ZERO)?;
473        output.busy_timeout(std::time::Duration::ZERO)?;
474        let transaction = output.transaction()?;
475        for table in SPLIT_WUBI_TABLES {
476            let present: bool = transaction.query_row(
477                "SELECT EXISTS(SELECT 1 FROM main.sqlite_master WHERE type='table' AND name=?1)",
478                [table],
479                |row| row.get(0),
480            )?;
481            if present {
482                continue;
483            }
484            // 先建表(`UNIQUE` 约束的自动索引随表建立),灌完数据再建显式索引。
485            let schema = input
486                .prepare("SELECT sql FROM sqlite_master WHERE tbl_name=?1 AND sql IS NOT NULL ORDER BY type='index', rowid")?
487                .query_map([table], |row| row.get::<_, String>(0))?
488                .collect::<rusqlite::Result<Vec<_>>>()?;
489            let Some((create, indexes)) = schema.split_first() else {
490                continue;
491            };
492            transaction.execute_batch(create)?;
493            let mut select = input.prepare(&format!("SELECT rowid,* FROM \"{table}\""))?;
494            let columns: Vec<String> = select
495                .column_names()
496                .iter()
497                .map(|name| format!("\"{name}\""))
498                .collect();
499            let placeholders = vec!["?"; columns.len()].join(",");
500            // 保留 rowid:反查五笔编码时用 rowid 作最后的排序键。
501            let mut insert = transaction.prepare(&format!(
502                "INSERT INTO \"{table}\"({}) VALUES({placeholders})",
503                columns.join(",")
504            ))?;
505            let mut rows = select.query([])?;
506            while let Some(row) = rows.next()? {
507                let values = (0..columns.len())
508                    .map(|index| row.get::<_, rusqlite::types::Value>(index))
509                    .collect::<rusqlite::Result<Vec<_>>>()?;
510                insert.execute(rusqlite::params_from_iter(values))?;
511            }
512            for index in indexes {
513                transaction.execute_batch(index)?;
514            }
515            transaction.execute_batch(&format!("ANALYZE \"{table}\""))?;
516        }
517        transaction.commit()
518    })();
519    merged.map_err(|_| {
520        EngineError::failed(format!(
521            "{}{}",
522            diagnostics::RUNTIME_COPY_FAILED,
523            source.display()
524        ))
525    })
526}
527
528/// 资源目录里的主库或五笔库在复制、合并进代次之前先查文件内版本([`crate::format::check_format_version`]):比这个引擎新的格式拒绝,旧格式和没有 `metadata` 表的旧发布照常使用。`name` 只用于错误信息。文件不在、打不开或不是数据库时这里不报错,复制与合并照原来的方式报告它。
529pub(crate) fn check_dictionary_format(path: &Path, name: &str) -> Result<()> {
530    let Ok(resolved) = crate::paths::sqlite_read_only_path_no_follow(path) else {
531        return Ok(());
532    };
533    let Ok(connection) = Connection::open_with_flags(
534        &resolved,
535        OpenFlags::SQLITE_OPEN_READ_ONLY
536            | OpenFlags::SQLITE_OPEN_NOFOLLOW
537            | OpenFlags::SQLITE_OPEN_NO_MUTEX,
538    ) else {
539        return Ok(());
540    };
541    // 与 `copy_database` 一样不等锁:资源文件没有写者,读不了就交给后面的步骤报告。
542    if connection.busy_timeout(Duration::ZERO).is_err() {
543        return Ok(());
544    }
545    match crate::format::check_format_version(&connection, name) {
546        Ok(_) | Err(EngineError::Sqlite(_)) => Ok(()),
547        Err(error) => Err(error),
548    }
549}
550
551/// 只读布局(代次目录就是资源目录,网页引擎、后端和部分测试这样用)没有准备代次这一步,建会话时在这里查主库和五笔库的文件内版本,规则与准备代次时相同。其他布局什么也不做:它们的文件在准备代次时已经查过。
552pub(crate) fn check_read_only_layout(paths: &RuntimePaths) -> Result<()> {
553    if paths.dictionaries != paths.resources {
554        return Ok(());
555    }
556    for name in [assets::MAIN_DICTIONARY, assets::WUBI_DICTIONARY] {
557        check_dictionary_format(&paths.dictionary(name), name)?;
558    }
559    Ok(())
560}
561
562/// 把资源带的可选 n-gram 表复制进代次。已就绪的代次也会以 `replace_existing` 为假调用它:后来的版本才开始发布的表不用换代次就能补上,会话可能已经映射着的那张保持不动(RP:76-90)。
563///
564/// 每张表先写进这次尝试独有的 `<表名>.<随机串>.incoming`(独占新建,重名就换一个),落盘后再改名就位:会话可能映射着旧文件,映射下面的表写到一半就是崩溃而不是查不到(RP:62-63)。以前用固定的 `<表名>.incoming`,两个进程同时补同一张表时,一方会删掉另一方正在写的文件,再把对方写到一半的副本改名就位。几方同时补同一张表时,后改名的一方在 Unix 上以同样的字节替换前一方的文件(映射着前一份的会话不受影响);Windows 上目标被映射时改名失败,这时目标已经是完整的表,丢掉自己的副本即可。
565pub(super) fn stage_generation_copies(
566    resources: &Path,
567    generation: &Path,
568    replace_existing: bool,
569) -> Result<()> {
570    sweep_abandoned_copies(generation, SystemTime::now());
571    for name in GENERATION_COPIES {
572        let source = resources.join(name);
573        if !is_real_file(&source) {
574            continue;
575        }
576        let target = generation.join(name);
577        if !replace_existing && is_real_file(&target) {
578            continue;
579        }
580        let mut input = crate::paths::open_file_no_follow(&source)?;
581        // 出错时丢弃 `TempPath` 会删掉这次自己的临时文件。写入句柄在块结束时关闭,Windows 不能改名打开着的文件。
582        let incoming = {
583            let (mut output, incoming) = tempfile::Builder::new()
584                .prefix(&format!("{name}."))
585                .suffix(STAGE_SUFFIX)
586                .make_in(generation, create_private_file)?
587                .into_parts();
588            io::copy(&mut input, &mut output)?;
589            output.sync_all()?;
590            incoming
591        };
592        match fs::rename(&incoming, &target) {
593            Ok(()) => {
594                // 文件已经改名就位,不能再让 `TempPath` 按原路径去删。
595                let _ = incoming.keep();
596            }
597            Err(_) if !replace_existing && is_real_file(&target) => {}
598            Err(error) => return Err(error.into()),
599        }
600    }
601    Ok(())
602}
603
604/// 删掉代次目录里一小时没被写过的 n-gram 临时副本:准备途中被杀的进程留下的 `<表名>.<随机串>.incoming`,以及旧版本固定名字的 `<表名>.incoming`。判据与 [`sweep_abandoned_stages`] 相同;文件按路径删除,主人若其实还活着,之后的改名找不到原路径,只会让那次准备失败,已就位的表不受影响。出什么错都不影响准备本身。
605fn sweep_abandoned_copies(generation: &Path, now: SystemTime) {
606    let Some(cutoff) = now.checked_sub(ABANDONED_STAGE_AGE) else {
607        return;
608    };
609    let Ok(entries) = fs::read_dir(generation) else {
610        return;
611    };
612    for entry in entries.flatten() {
613        let name = entry.file_name();
614        if !name.to_str().is_some_and(is_copy_name) {
615            continue;
616        }
617        let path = entry.path();
618        let stale = fs::symlink_metadata(&path).is_ok_and(|metadata| {
619            metadata.file_type().is_file()
620                && metadata.modified().is_ok_and(|modified| modified < cutoff)
621        });
622        if stale {
623            let _ = fs::remove_file(&path);
624        }
625    }
626}
627
628/// `<表名>.incoming` 或 `<表名>.<字母数字>.incoming`,表名是 [`GENERATION_COPIES`] 之一。
629fn is_copy_name(name: &str) -> bool {
630    let Some(stem) = name.strip_suffix(STAGE_SUFFIX) else {
631        return false;
632    };
633    GENERATION_COPIES.iter().any(|copy| {
634        stem == *copy
635            || stem
636                .strip_prefix(copy)
637                .and_then(|rest| rest.strip_prefix('.'))
638                .is_some_and(|attempt| {
639                    !attempt.is_empty() && attempt.bytes().all(|byte| byte.is_ascii_alphanumeric())
640                })
641    })
642}
643
644fn is_real_file(path: &Path) -> bool {
645    fs::symlink_metadata(path)
646        .map(|metadata| metadata.file_type().is_file())
647        .unwrap_or(false)
648}
649
650/// `std::filesystem::weakly_canonical`: the longest existing prefix resolved through symlinks, the rest appended with `.` and `..` folded lexically, so roots that do not exist yet compare by what they will resolve to. No crate resolves symlinks for a partly missing path, which the overlap check needs (a symlinked resource alias must still be caught).
651pub(super) fn weakly_canonical(path: &Path) -> Result<PathBuf> {
652    let components: Vec<Component<'_>> = path.components().collect();
653    for split in (1..=components.len()).rev() {
654        let head: PathBuf = components[..split].iter().collect();
655        match head.canonicalize() {
656            Ok(mut resolved) => {
657                append_lexically(&mut resolved, &components[split..]);
658                return Ok(resolved);
659            }
660            Err(error) if error.kind() == ErrorKind::NotFound => {}
661            Err(error) => return Err(error.into()),
662        }
663    }
664    let mut resolved = PathBuf::new();
665    append_lexically(&mut resolved, &components);
666    Ok(resolved)
667}
668
669fn append_lexically(base: &mut PathBuf, rest: &[Component<'_>]) {
670    for component in rest {
671        match component {
672            Component::CurDir => {}
673            Component::ParentDir => {
674                base.pop();
675            }
676            other => base.push(other),
677        }
678    }
679}
680
681fn reject_redirected_directory(path: &Path) -> std::io::Result<()> {
682    for ancestor in path.ancestors() {
683        match fs::symlink_metadata(ancestor) {
684            Ok(metadata) if metadata.file_type().is_symlink() => {
685                if crate::paths::is_trusted_system_alias(ancestor) {
686                    continue;
687                }
688                return Err(std::io::Error::new(
689                    std::io::ErrorKind::InvalidInput,
690                    "runtime directory has a symbolic-link ancestor",
691                ));
692            }
693            Ok(metadata) if !metadata.is_dir() => {
694                return Err(std::io::Error::new(
695                    std::io::ErrorKind::NotADirectory,
696                    "runtime directory parent is not a directory",
697                ));
698            }
699            Ok(_) => {}
700            Err(error) if error.kind() == ErrorKind::NotFound => {}
701            Err(error) => return Err(error),
702        }
703    }
704    Ok(())
705}
706
707/// One root contains the other, compared by whole components (RP:34-37).
708pub(super) fn roots_overlap(first: &Path, second: &Path) -> bool {
709    first.starts_with(second) || second.starts_with(first)
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use crate::user_dictionary::replay::tests::{sql, weight, OPERATIONS_DDL};
716
717    fn resources(root: &Path) -> PathBuf {
718        let resources = root.join("resources");
719        fs::create_dir_all(&resources).unwrap();
720        sql(
721            &resources.join(assets::MAIN_DICTIONARY),
722            "CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
723             INSERT INTO tbl_1_n VALUES('ni','n','你',100);",
724        );
725        crate::dictionary::english::ensure_english_schema(
726            &resources.join(assets::ENGLISH_DICTIONARY),
727        )
728        .unwrap();
729        resources
730    }
731
732    /// `user/dictionaries` 下还剩的暂存目录。
733    fn stages(user: &Path) -> Vec<String> {
734        fs::read_dir(user.join("dictionaries"))
735            .map(|entries| {
736                entries
737                    .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
738                    .filter(|name| name.ends_with(STAGE_SUFFIX))
739                    .collect()
740            })
741            .unwrap_or_default()
742    }
743
744    /// 目录下名字以 `.incoming` 结尾的项。
745    fn incoming_entries(directory: &Path) -> Vec<String> {
746        let mut names: Vec<String> = fs::read_dir(directory)
747            .unwrap()
748            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
749            .filter(|name| name.ends_with(STAGE_SUFFIX))
750            .collect();
751        names.sort();
752        names
753    }
754
755    fn learn(journal: &Path, word: &str, updated_at: i64) {
756        sql(journal, OPERATIONS_DDL);
757        sql(
758            journal,
759            &format!("INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted,updated_at) VALUES('pinyin','ni','{word}','upsert',50,1,{updated_at})"),
760        );
761    }
762
763    #[test]
764    fn a_journal_that_is_not_a_database_does_not_stop_preparation() {
765        let root = tempfile::tempdir().unwrap();
766        let resources = resources(root.path());
767        let user = root.path().join("user");
768        fs::create_dir_all(&user).unwrap();
769        let journal = user.join(assets::USER_JOURNAL);
770        fs::write(&journal, vec![0u8; 8 * 4096]).unwrap();
771
772        let prepared =
773            prepare_runtime_paths(&resources, &user, &root.path().join("cache"), "v1").unwrap();
774        assert!(prepared
775            .dictionaries
776            .join(assets::GENERATION_READY)
777            .exists());
778        assert!(!journal.exists());
779        let moved: Vec<_> = fs::read_dir(&user)
780            .unwrap()
781            .filter_map(|entry| entry.ok())
782            .map(|entry| entry.file_name().to_string_lossy().into_owned())
783            .filter(|name| name.starts_with("msime_user.db.corrupt-"))
784            .collect();
785        assert_eq!(moved.len(), 1, "{moved:?}");
786        // 之后写入的日志仍会按原流程回放到代次中。
787        learn(&journal, "你", 1);
788        prepare_runtime_paths(&resources, &user, &root.path().join("cache"), "v1").unwrap();
789    }
790
791    #[test]
792    fn content_ids_are_validated_before_anything_is_created() {
793        let root = tempfile::tempdir().unwrap();
794        let resources = resources(root.path());
795        let user = root.path().join("user");
796        for id in ["", "../escape", "a b", "é", &"x".repeat(129)] {
797            let error = prepare_runtime_paths(&resources, &user, &root.path().join("cache"), id)
798                .unwrap_err();
799            assert_eq!(error.to_string(), diagnostics::INVALID_RUNTIME_CONTENT_ID);
800        }
801        assert!(!user.exists());
802        let relative = prepare_runtime_paths(
803            Path::new("resources"),
804            &user,
805            &root.path().join("cache"),
806            "v1",
807        )
808        .unwrap_err();
809        assert_eq!(
810            relative.to_string(),
811            diagnostics::RUNTIME_DIRECTORIES_MUST_BE_ABSOLUTE
812        );
813        assert!(prepare_runtime_paths(
814            &resources,
815            &user,
816            &root.path().join("cache"),
817            &"x".repeat(128)
818        )
819        .is_ok());
820    }
821
822    #[cfg(unix)]
823    #[test]
824    fn resource_dictionary_symlinks_are_rejected_before_staging() {
825        use std::os::unix::fs::symlink;
826
827        let root = tempfile::tempdir().unwrap();
828        let resources = resources(root.path());
829        let external = root.path().join("external-msime-pinyin.db");
830        fs::rename(resources.join(assets::MAIN_DICTIONARY), &external).unwrap();
831        symlink(&external, resources.join(assets::MAIN_DICTIONARY)).unwrap();
832
833        let error = prepare_runtime_paths(
834            &resources,
835            &root.path().join("user"),
836            &root.path().join("cache"),
837            "v1",
838        )
839        .unwrap_err();
840        assert!(error
841            .to_string()
842            .starts_with(diagnostics::RUNTIME_COPY_FAILED));
843        assert!(!root.path().join("user/dictionaries/v1").exists());
844    }
845
846    #[cfg(unix)]
847    #[test]
848    fn user_and_cache_roots_reject_symlinked_directories() {
849        use std::os::unix::fs::symlink;
850
851        for linked_name in ["user", "cache"] {
852            let root = tempfile::tempdir().unwrap();
853            let resources = resources(root.path());
854            let outside = tempfile::tempdir().unwrap();
855            let user = root.path().join("user");
856            let cache = root.path().join("cache");
857            symlink(outside.path(), root.path().join(linked_name)).unwrap();
858
859            assert!(prepare_runtime_paths(&resources, &user, &cache, "v1").is_err());
860            assert!(!outside.path().join("dictionaries").exists());
861        }
862    }
863
864    // test_runtime_isolation.cpp:140-168.
865    #[test]
866    fn overlapping_roots_are_rejected_without_creating_directories() {
867        let temporary = tempfile::tempdir().unwrap();
868        let root = temporary.path();
869        let resource_a = resources(root);
870        let reject = |resources: &Path, user: &Path, cache: &Path| {
871            let error = prepare_runtime_paths(resources, user, cache, "overlap").unwrap_err();
872            assert!(matches!(error, EngineError::InvalidArgument(_)), "{error}");
873            assert_eq!(error.to_string(), diagnostics::RUNTIME_ROOTS_OVERLAP);
874            assert!(!user.join("dictionaries/overlap").exists());
875        };
876        reject(
877            &resource_a,
878            &root.join("same-user-cache"),
879            &root.join("same-user-cache"),
880        );
881        reject(
882            &resource_a,
883            &root.join("cache-parent/user"),
884            &root.join("cache-parent"),
885        );
886        reject(
887            &resource_a,
888            &root.join("user-parent"),
889            &root.join("user-parent/cache"),
890        );
891        reject(
892            &resource_a,
893            &resource_a.join("user"),
894            &root.join("separate-cache"),
895        );
896        reject(
897            &resource_a,
898            &root.join("separate-user"),
899            &resource_a.join("cache"),
900        );
901        reject(&resource_a, root, &root.join("separate-cache"));
902        reject(
903            &resource_a,
904            &root.join("dotted/../resources/user"),
905            &root.join("separate-cache"),
906        );
907        for created in [
908            root.join("same-user-cache"),
909            root.join("cache-parent"),
910            resource_a.join("user"),
911            resource_a.join("cache"),
912        ] {
913            assert!(!created.exists(), "{}", created.display());
914        }
915        #[cfg(unix)]
916        {
917            let alias = root.join("resource-alias");
918            std::os::unix::fs::symlink(&resource_a, &alias).unwrap();
919            reject(&resource_a, &root.join("alias-user"), &alias.join("cache"));
920        }
921        // Siblings that share a name prefix do not overlap.
922        assert!(prepare_runtime_paths(
923            &resource_a,
924            &root.join("resources-user"),
925            &root.join("resources-cache"),
926            "v1"
927        )
928        .is_ok());
929    }
930
931    #[test]
932    fn a_new_generation_is_a_replayed_backup_copy_marked_ready() {
933        let root = tempfile::tempdir().unwrap();
934        let resources = resources(root.path());
935        let original = fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap();
936        let user = root.path().join("user");
937        let cache = root.path().join("cache");
938        fs::create_dir_all(&user).unwrap();
939        learn(&user.join(assets::USER_JOURNAL), "伱", 1);
940
941        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
942        assert_eq!(paths.dictionaries, user.join("dictionaries/v1"));
943        assert!(cache.is_dir());
944        assert_eq!(
945            fs::read_to_string(paths.dictionary(assets::GENERATION_READY)).unwrap(),
946            "v1\n"
947        );
948        assert!(stages(&user).is_empty());
949        assert_eq!(
950            weight(
951                &paths.dictionary(assets::MAIN_DICTIONARY),
952                "SELECT weight FROM tbl_1_n WHERE value='伱'"
953            ),
954            Some(50)
955        );
956        assert!(paths.dictionary(assets::ENGLISH_DICTIONARY).is_file());
957        assert_eq!(
958            fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap(),
959            original
960        );
961
962        // Clearing the cache keeps the journal and the working copies.
963        fs::remove_dir_all(&cache).unwrap();
964        assert!(user.join(assets::USER_JOURNAL).is_file());
965        let again = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
966        assert_eq!(again, paths);
967    }
968
969    // test_runtime_isolation.cpp:648-723: learning on a newer generation survives switching back, and a replay failure publishes nothing.
970    #[test]
971    fn switching_back_replays_newer_learning_and_a_failed_replay_publishes_nothing() {
972        let root = tempfile::tempdir().unwrap();
973        let resources = resources(root.path());
974        let user = root.path().join("user");
975        let cache = root.path().join("cache");
976        let journal = user.join(assets::USER_JOURNAL);
977        let v1 = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
978        learn(&journal, "伱", 1);
979        let v2 = prepare_runtime_paths(&resources, &user, &cache, "v2").unwrap();
980        assert_eq!(
981            weight(
982                &v2.dictionary(assets::MAIN_DICTIONARY),
983                "SELECT weight FROM tbl_1_n WHERE value='伱'"
984            ),
985            Some(50)
986        );
987        learn(&journal, "倪", 2);
988        let restored = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
989        assert_eq!(restored, v1);
990        assert_eq!(
991            weight(
992                &v1.dictionary(assets::MAIN_DICTIONARY),
993                "SELECT weight FROM tbl_1_n WHERE value='倪'"
994            ),
995            Some(50)
996        );
997
998        let before_failure = fs::read(v2.dictionary(assets::MAIN_DICTIONARY)).unwrap();
999        sql(
1000            &journal,
1001            "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight) VALUES('pinyin','@','无效词','upsert',10)",
1002        );
1003        let error = prepare_runtime_paths(&resources, &user, &cache, "v3").unwrap_err();
1004        assert!(matches!(error, EngineError::Failed(_)));
1005        assert_eq!(
1006            error.to_string(),
1007            format!(
1008                "{}one or more operations failed; changes were rolled back",
1009                diagnostics::RUNTIME_REPLAY_FAILED
1010            )
1011        );
1012        assert_eq!(
1013            fs::read(v2.dictionary(assets::MAIN_DICTIONARY)).unwrap(),
1014            before_failure
1015        );
1016        assert!(!user.join("dictionaries/v3").exists());
1017        assert!(stages(&user).is_empty());
1018        // A ready generation refuses the same journal as well.
1019        assert!(prepare_runtime_paths(&resources, &user, &cache, "v2").is_err());
1020    }
1021
1022    #[test]
1023    fn staging_failures_clean_up_and_incomplete_generations_are_refused() {
1024        let root = tempfile::tempdir().unwrap();
1025        let resources = resources(root.path());
1026        let user = root.path().join("user");
1027        let cache = root.path().join("cache");
1028        let missing = root.path().join("missing-resources");
1029        fs::create_dir_all(&missing).unwrap();
1030        let error = prepare_runtime_paths(&missing, &user, &cache, "v1").unwrap_err();
1031        assert_eq!(
1032            error.to_string(),
1033            format!(
1034                "{}{}",
1035                diagnostics::RUNTIME_COPY_FAILED,
1036                missing.join(assets::MAIN_DICTIONARY).display()
1037            )
1038        );
1039        assert_eq!(fs::read_dir(user.join("dictionaries")).unwrap().count(), 0);
1040        assert!(!missing.join(assets::MAIN_DICTIONARY).exists());
1041
1042        let incomplete = user.join("dictionaries/v2");
1043        fs::create_dir_all(&incomplete).unwrap();
1044        fs::write(incomplete.join(assets::GENERATION_READY), "v2\n").unwrap();
1045        let error = prepare_runtime_paths(&resources, &user, &cache, "v2").unwrap_err();
1046        assert_eq!(
1047            error.to_string(),
1048            diagnostics::INCOMPLETE_RUNTIME_GENERATION
1049        );
1050    }
1051
1052    /// 进程在准备途中被杀(iOS 键盘扩展超出内存上限、Android 与 HarmonyOS 退到后台被系统回收)会留下复制到一半的暂存目录。下一次准备照常成功,不再每次都报暂存目录已存在;刚留下的暂存目录分不清是不是另一个进程正在写的,原样留着。
1053    #[test]
1054    fn a_stage_left_by_a_killed_preparation_does_not_block_the_next_one() {
1055        let root = tempfile::tempdir().unwrap();
1056        let resources = resources(root.path());
1057        let user = root.path().join("user");
1058        let cache = root.path().join("cache");
1059        let parent = user.join("dictionaries");
1060        // 旧版本用的固定暂存名,和现在每次尝试各自的暂存名。
1061        let leftovers = ["v1.incoming", "v1.k3Xq9z.incoming"];
1062        for leftover in leftovers {
1063            fs::create_dir_all(parent.join(leftover)).unwrap();
1064            fs::write(
1065                parent.join(leftover).join(assets::MAIN_DICTIONARY),
1066                b"SQLite format 3\0partial",
1067            )
1068            .unwrap();
1069        }
1070        learn(&user.join(assets::USER_JOURNAL), "伱", 1);
1071
1072        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1073        assert_eq!(paths.dictionaries, parent.join("v1"));
1074        assert_eq!(
1075            fs::read_to_string(paths.dictionary(assets::GENERATION_READY)).unwrap(),
1076            "v1\n"
1077        );
1078        assert_eq!(
1079            weight(
1080                &paths.dictionary(assets::MAIN_DICTIONARY),
1081                "SELECT weight FROM tbl_1_n WHERE value='伱'"
1082            ),
1083            Some(50)
1084        );
1085        for leftover in leftovers {
1086            assert_eq!(
1087                fs::read(parent.join(leftover).join(assets::MAIN_DICTIONARY)).unwrap(),
1088                b"SQLite format 3\0partial",
1089                "{leftover}"
1090            );
1091        }
1092        assert_eq!(
1093            prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap(),
1094            paths
1095        );
1096    }
1097
1098    /// 重置学习数据在把工作主词库挪成 `.msime-pinyin.db.backup.<时间戳>` 之后、新副本改名就位之前被杀:下一次准备把时间戳最新的那份备份放回去、删掉同一时间戳没发布的新副本,学到的词仍在;更旧的备份、以及词库在时出现的备份都不动。
1099    #[test]
1100    fn a_dictionary_left_aside_by_a_killed_reset_is_put_back() {
1101        let root = tempfile::tempdir().unwrap();
1102        let resources = resources(root.path());
1103        let user = root.path().join("user");
1104        let cache = root.path().join("cache");
1105        fs::create_dir_all(&user).unwrap();
1106        learn(&user.join(assets::USER_JOURNAL), "伱", 1);
1107        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1108        let main = paths.dictionary(assets::MAIN_DICTIONARY);
1109        let sibling = |name: &str| paths.dictionaries.join(name);
1110        fs::rename(
1111            &main,
1112            sibling(".msime-pinyin.db.backup.1700000000000000000"),
1113        )
1114        .unwrap();
1115        fs::write(
1116            sibling(".msime-pinyin.db.reset.1700000000000000000"),
1117            b"fresh copy",
1118        )
1119        .unwrap();
1120        fs::write(sibling(".msime-pinyin.db.backup.999"), b"older").unwrap();
1121
1122        assert_eq!(
1123            prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap(),
1124            paths
1125        );
1126        assert_eq!(
1127            weight(&main, "SELECT weight FROM tbl_1_n WHERE value='伱'"),
1128            Some(50)
1129        );
1130        assert!(!sibling(".msime-pinyin.db.backup.1700000000000000000").exists());
1131        assert!(!sibling(".msime-pinyin.db.reset.1700000000000000000").exists());
1132        assert_eq!(
1133            fs::read(sibling(".msime-pinyin.db.backup.999")).unwrap(),
1134            b"older"
1135        );
1136
1137        let restored = fs::read(&main).unwrap();
1138        fs::write(
1139            sibling(".msime-pinyin.db.backup.1800000000000000000"),
1140            b"newer",
1141        )
1142        .unwrap();
1143        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1144        assert_eq!(fs::read(&main).unwrap(), restored);
1145        assert_eq!(
1146            fs::read(sibling(".msime-pinyin.db.backup.1800000000000000000")).unwrap(),
1147            b"newer"
1148        );
1149    }
1150
1151    fn assert_a_legacy_dictionary_left_aside_is_put_back(name: &str, legacy: &str) {
1152        let root = tempfile::tempdir().unwrap();
1153        let resources = resources(root.path());
1154        let user = root.path().join("user");
1155        let cache = root.path().join("cache");
1156        fs::create_dir_all(&user).unwrap();
1157        let journal = user.join(assets::USER_JOURNAL);
1158        learn(&journal, "伱", 1);
1159        sql(
1160            &journal,
1161            "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted,updated_at) VALUES('english','zzfixture','Zzfixture','upsert',11,'Zzfixture',1,2)",
1162        );
1163        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1164        let current = paths.dictionaries.join(name);
1165        let old = paths.dictionaries.join(legacy);
1166        fs::rename(&current, &old).unwrap();
1167        assert_eq!(paths.dictionary(name), old);
1168        let backup = paths.dictionaries.join(format!(".{legacy}.backup.200"));
1169        let staged = paths.dictionaries.join(format!(".{legacy}.reset.200"));
1170        fs::rename(&old, &backup).unwrap();
1171        fs::write(&staged, b"unpublished synthetic dictionary").unwrap();
1172
1173        assert_eq!(
1174            prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap(),
1175            paths
1176        );
1177        assert_eq!(paths.dictionary(name), current);
1178        assert!(!old.exists());
1179        assert!(!backup.exists());
1180        assert!(!staged.exists());
1181        assert_eq!(
1182            weight(
1183                &paths.dictionary(assets::MAIN_DICTIONARY),
1184                "SELECT weight FROM tbl_1_n WHERE value='伱'"
1185            ),
1186            Some(50)
1187        );
1188        assert_eq!(
1189            weight(
1190                &paths.dictionary(assets::ENGLISH_DICTIONARY),
1191                "SELECT weight FROM english_words WHERE word='zzfixture' AND display='Zzfixture'"
1192            ),
1193            Some(11)
1194        );
1195    }
1196
1197    /// 旧主词库被挪成备份后,路径解析已看不到旧名,准备仍应找到它。
1198    #[test]
1199    fn a_legacy_main_dictionary_left_aside_by_a_reset_is_put_back() {
1200        assert_a_legacy_dictionary_left_aside_is_put_back(assets::MAIN_DICTIONARY, "msime.db");
1201    }
1202
1203    /// 英文词库也从旧名备份恢复到现名,继续使用同一套回放路径。
1204    #[test]
1205    fn a_legacy_english_dictionary_left_aside_by_a_reset_is_put_back() {
1206        assert_a_legacy_dictionary_left_aside_is_put_back(assets::ENGLISH_DICTIONARY, "english.db");
1207    }
1208
1209    /// 重置在把日志挪成 `.msime_user.db.backup.<时间戳>` 之后被杀:日志放回去,学到的词不会因为下一次准备新建了空日志而丢掉。
1210    #[test]
1211    fn a_journal_left_aside_by_a_killed_reset_is_put_back() {
1212        let root = tempfile::tempdir().unwrap();
1213        let resources = resources(root.path());
1214        let user = root.path().join("user");
1215        let cache = root.path().join("cache");
1216        fs::create_dir_all(&user).unwrap();
1217        let journal = user.join(assets::USER_JOURNAL);
1218        learn(&journal, "伱", 1);
1219        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1220        fs::rename(
1221            &journal,
1222            user.join(".msime_user.db.backup.1700000000000000000"),
1223        )
1224        .unwrap();
1225        fs::write(
1226            user.join(".msime_user.db.reset.1700000000000000000"),
1227            b"empty journal",
1228        )
1229        .unwrap();
1230
1231        let paths = prepare_runtime_paths(&resources, &user, &cache, "v2").unwrap();
1232        assert_eq!(
1233            weight(&journal, "SELECT count(*) FROM user_dictionary_operations"),
1234            Some(1)
1235        );
1236        assert_eq!(
1237            weight(
1238                &paths.dictionary(assets::MAIN_DICTIONARY),
1239                "SELECT weight FROM tbl_1_n WHERE value='伱'"
1240            ),
1241            Some(50)
1242        );
1243        assert!(!user
1244            .join(".msime_user.db.backup.1700000000000000000")
1245            .exists());
1246        assert!(!user
1247            .join(".msime_user.db.reset.1700000000000000000")
1248            .exists());
1249    }
1250
1251    /// 同一个代次被几方同时准备(iOS 的 App 与键盘扩展、Windows 的 TIP 与 Server、IBus 与 Fcitx5):每一方都拿到同一个可用的代次,谁也不报错,输掉的一方不留下暂存目录,也不动赢的一方发布的代次。
1252    #[test]
1253    fn concurrent_preparations_of_one_generation_all_succeed() {
1254        const PREPARERS: usize = 8;
1255        let root = tempfile::tempdir().unwrap();
1256        let resources = resources(root.path());
1257        fs::write(resources.join(assets::BIGRAM_TABLE), "bigram").unwrap();
1258        let user = root.path().join("user");
1259        let cache = root.path().join("cache");
1260        fs::create_dir_all(&user).unwrap();
1261        learn(&user.join(assets::USER_JOURNAL), "伱", 1);
1262
1263        let barrier = std::sync::Barrier::new(PREPARERS);
1264        let results: Vec<_> = std::thread::scope(|scope| {
1265            let preparers: Vec<_> = (0..PREPARERS)
1266                .map(|_| {
1267                    scope.spawn(|| {
1268                        barrier.wait();
1269                        prepare_runtime_paths(&resources, &user, &cache, "v1")
1270                            .map_err(|error| error.to_string())
1271                    })
1272                })
1273                .collect();
1274            preparers
1275                .into_iter()
1276                .map(|preparer| preparer.join().unwrap())
1277                .collect()
1278        });
1279        let generation = user.join("dictionaries/v1");
1280        for result in &results {
1281            assert_eq!(result.as_ref().unwrap().dictionaries, generation);
1282        }
1283        assert_eq!(
1284            fs::read_to_string(generation.join(assets::GENERATION_READY)).unwrap(),
1285            "v1\n"
1286        );
1287        assert_eq!(
1288            weight(
1289                &generation.join(assets::MAIN_DICTIONARY),
1290                "SELECT weight FROM tbl_1_n WHERE value='伱'"
1291            ),
1292            Some(50)
1293        );
1294        assert_eq!(
1295            fs::read(generation.join(assets::BIGRAM_TABLE)).unwrap(),
1296            b"bigram"
1297        );
1298        let entries: Vec<_> = fs::read_dir(user.join("dictionaries"))
1299            .unwrap()
1300            .map(|entry| entry.unwrap().file_name())
1301            .collect();
1302        assert_eq!(entries, ["v1"]);
1303    }
1304
1305    /// 清理只删一小时没被写过的暂存目录和已经认领的 `.removing`;代次、名字对不上的目录、普通文件和符号链接都不动。
1306    #[test]
1307    fn only_stages_untouched_for_an_hour_are_swept() {
1308        let root = tempfile::tempdir().unwrap();
1309        let parent = root.path().join("dictionaries");
1310        let directories = [
1311            "v1.incoming",
1312            "v1.k3Xq9z.incoming",
1313            "v2.Ab12cd.removing",
1314            "v1",
1315            "a b.incoming",
1316            "v1.k3.x.incoming",
1317            "v1..incoming",
1318            "v1.incoming.bak",
1319        ];
1320        for name in directories {
1321            fs::create_dir_all(parent.join(name)).unwrap();
1322            fs::write(parent.join(name).join(assets::MAIN_DICTIONARY), b"partial").unwrap();
1323        }
1324        fs::write(parent.join("v3.incoming"), b"not a directory").unwrap();
1325        let outside = tempfile::tempdir().unwrap();
1326        fs::write(outside.path().join("keep"), b"keep").unwrap();
1327        #[cfg(unix)]
1328        std::os::unix::fs::symlink(outside.path(), parent.join("v4.incoming")).unwrap();
1329        let names = || {
1330            let mut names: Vec<_> = fs::read_dir(&parent)
1331                .unwrap()
1332                .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1333                .collect();
1334            names.sort();
1335            names
1336        };
1337        let mut untouched: Vec<String> = [
1338            "a b.incoming",
1339            "v1",
1340            "v1..incoming",
1341            "v1.incoming.bak",
1342            "v1.k3.x.incoming",
1343            "v3.incoming",
1344        ]
1345        .map(String::from)
1346        .to_vec();
1347        if cfg!(unix) {
1348            untouched.push("v4.incoming".into());
1349        }
1350        untouched.sort();
1351
1352        // 刚写过的暂存目录可能还有人在写,留着;认领过的不会再被发布,直接删。
1353        sweep_abandoned_stages(&parent, SystemTime::now());
1354        let mut fresh = untouched.clone();
1355        fresh.extend(["v1.incoming".into(), "v1.k3Xq9z.incoming".into()]);
1356        fresh.sort();
1357        assert_eq!(names(), fresh);
1358
1359        sweep_abandoned_stages(
1360            &parent,
1361            SystemTime::now() + ABANDONED_STAGE_AGE + Duration::from_secs(60),
1362        );
1363        assert_eq!(names(), untouched);
1364        assert_eq!(fs::read(outside.path().join("keep")).unwrap(), b"keep");
1365    }
1366
1367    /// 一小时前被丢下的暂存目录和 n-gram 临时副本在下一次准备时删掉,哪怕那次准备走的是已就绪代次的路径。
1368    #[cfg(unix)]
1369    #[test]
1370    fn preparation_sweeps_stages_and_table_copies_abandoned_an_hour_ago() {
1371        let root = tempfile::tempdir().unwrap();
1372        let resources = resources(root.path());
1373        let user = root.path().join("user");
1374        let cache = root.path().join("cache");
1375        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1376        let parent = user.join("dictionaries");
1377        let abandoned = parent.join("v1.k3Xq9z.incoming");
1378        fs::create_dir_all(&abandoned).unwrap();
1379        let partial = abandoned.join(assets::MAIN_DICTIONARY);
1380        fs::write(&partial, b"partial").unwrap();
1381        let long_ago = SystemTime::now() - ABANDONED_STAGE_AGE - Duration::from_secs(60);
1382        let backdate = |path: &Path| {
1383            fs::File::open(path)
1384                .unwrap()
1385                .set_modified(long_ago)
1386                .unwrap()
1387        };
1388        // 目录本身一小时没变、里面的文件却刚写过(正在复制的大词库就是这样),仍算有人在写。
1389        backdate(&abandoned);
1390        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1391        assert_eq!(fs::read(&partial).unwrap(), b"partial");
1392
1393        // 代次里补 n-gram 表留下的临时副本同理:刚写过的留着,一小时没写过的删掉。
1394        let copy = parent.join("v1").join("msime-trigram.bin.k3Xq9z.incoming");
1395        fs::write(&copy, b"partial").unwrap();
1396        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1397        assert!(copy.is_file());
1398
1399        backdate(&partial);
1400        backdate(&abandoned);
1401        backdate(&copy);
1402        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1403        assert!(!abandoned.exists());
1404        assert!(!copy.exists());
1405        assert_eq!(fs::read_dir(&parent).unwrap().count(), 1);
1406    }
1407
1408    #[cfg(unix)]
1409    #[test]
1410    fn a_symlinked_generation_marker_is_not_treated_as_ready() {
1411        use std::os::unix::fs::symlink;
1412
1413        let root = tempfile::tempdir().unwrap();
1414        let resources = resources(root.path());
1415        let user = root.path().join("user");
1416        let cache = root.path().join("cache");
1417        let generation = user.join("dictionaries/v1");
1418        fs::create_dir_all(&generation).unwrap();
1419        for name in generation_dictionaries(true) {
1420            fs::copy(resources.join(name), generation.join(name)).unwrap();
1421        }
1422        let outside = tempfile::tempdir().unwrap();
1423        let marker_target = outside.path().join("marker");
1424        fs::write(&marker_target, b"v1\n").unwrap();
1425        symlink(&marker_target, generation.join(assets::GENERATION_READY)).unwrap();
1426
1427        assert_eq!(
1428            prepare_runtime_paths(&resources, &user, &cache, "v1")
1429                .unwrap_err()
1430                .to_string(),
1431            diagnostics::INCOMPLETE_RUNTIME_GENERATION
1432        );
1433    }
1434
1435    #[test]
1436    fn the_backup_copy_includes_committed_wal_content() {
1437        let root = tempfile::tempdir().unwrap();
1438        let resources = resources(root.path());
1439        let live = Connection::open(resources.join(assets::MAIN_DICTIONARY)).unwrap();
1440        live.query_row("PRAGMA journal_mode=WAL", [], |_| Ok(()))
1441            .unwrap();
1442        live.execute_batch(
1443            "PRAGMA wal_autocheckpoint=0; INSERT INTO tbl_1_n VALUES('ni','n','妮',70);",
1444        )
1445        .unwrap();
1446        let paths = prepare_runtime_paths(
1447            &resources,
1448            &root.path().join("user"),
1449            &root.path().join("cache"),
1450            "v1",
1451        )
1452        .unwrap();
1453        assert_eq!(
1454            weight(
1455                &paths.dictionary(assets::MAIN_DICTIONARY),
1456                "SELECT weight FROM tbl_1_n WHERE value='妮'"
1457            ),
1458            Some(70)
1459        );
1460    }
1461
1462    fn reverse_indexes(database: &Path) -> Vec<String> {
1463        let connection = Connection::open(database).unwrap();
1464        let mut statement = connection
1465            .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_wubi%_value' ORDER BY name")
1466            .unwrap();
1467        statement
1468            .query_map([], |row| row.get(0))
1469            .unwrap()
1470            .map(Result::unwrap)
1471            .collect()
1472    }
1473
1474    #[test]
1475    fn generations_get_the_wubi_reverse_index_and_resources_stay_untouched() {
1476        let root = tempfile::tempdir().unwrap();
1477        let resources = resources(root.path());
1478        // 只有 wubi86:没有的 wubi98 不会凭空建表或建索引。
1479        sql(
1480            &resources.join(assets::MAIN_DICTIONARY),
1481            "CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER,UNIQUE(key,value));
1482             INSERT INTO wubi86 VALUES('wqvb','你好',300);",
1483        );
1484        let original = fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap();
1485        let user = root.path().join("user");
1486        let cache = root.path().join("cache");
1487
1488        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1489        let main = paths.dictionary(assets::MAIN_DICTIONARY);
1490        assert_eq!(reverse_indexes(&main), ["idx_wubi86_value"]);
1491        assert!(reverse_indexes(&paths.dictionary(assets::ENGLISH_DICTIONARY)).is_empty());
1492        assert_eq!(
1493            fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap(),
1494            original
1495        );
1496
1497        sql(&main, "DROP INDEX idx_wubi86_value;");
1498        // 补建索引只是提速:副本写不进去(磁盘满、只读、被锁)时照常打开代次,只是没有索引。
1499        #[cfg(unix)]
1500        {
1501            use std::os::unix::fs::PermissionsExt;
1502            fs::set_permissions(&main, fs::Permissions::from_mode(0o444)).unwrap();
1503            let reopened = prepare_runtime_paths(&resources, &user, &cache, "v1");
1504            fs::set_permissions(&main, fs::Permissions::from_mode(0o644)).unwrap();
1505            reopened.unwrap();
1506            assert!(reverse_indexes(&main).is_empty());
1507        }
1508        // 旧版本准备的代次没有这个索引:再次打开同一代次时补上。
1509        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1510        assert_eq!(reverse_indexes(&main), ["idx_wubi86_value"]);
1511        // 已有索引时什么也不改。
1512        let indexed = fs::read(&main).unwrap();
1513        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1514        assert_eq!(reverse_indexes(&main), ["idx_wubi86_value"]);
1515        assert_eq!(fs::read(&main).unwrap(), indexed);
1516        assert_eq!(
1517            weight(&main, "SELECT weight FROM wubi86 WHERE value='你好'"),
1518            Some(300)
1519        );
1520    }
1521
1522    // test_runtime_isolation.cpp:754-782.
1523    #[test]
1524    fn generation_tables_are_staged_and_later_ones_added_without_replacing() {
1525        let root = tempfile::tempdir().unwrap();
1526        let resources = resources(root.path());
1527        fs::write(resources.join(assets::BIGRAM_TABLE), "first").unwrap();
1528        let user = root.path().join("user");
1529        let cache = root.path().join("cache");
1530        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1531        assert_eq!(
1532            fs::read(paths.dictionary(assets::BIGRAM_TABLE)).unwrap(),
1533            b"first"
1534        );
1535        assert!(!paths.dictionary(assets::TRIGRAM_TABLE).exists());
1536
1537        fs::write(resources.join(assets::TRIGRAM_TABLE), "third").unwrap();
1538        fs::write(resources.join(assets::BIGRAM_TABLE), "second").unwrap();
1539        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1540        assert_eq!(
1541            fs::read(paths.dictionary(assets::TRIGRAM_TABLE)).unwrap(),
1542            b"third"
1543        );
1544        assert_eq!(
1545            fs::read(paths.dictionary(assets::BIGRAM_TABLE)).unwrap(),
1546            b"first"
1547        );
1548        assert!(incoming_entries(&paths.dictionaries).is_empty());
1549        let fresh = prepare_runtime_paths(&resources, &user, &cache, "v2").unwrap();
1550        assert_eq!(
1551            fs::read(fresh.dictionary(assets::BIGRAM_TABLE)).unwrap(),
1552            b"second"
1553        );
1554    }
1555
1556    /// 补 n-gram 表时被杀留下的临时副本(旧版本的固定名字和现在的名字)不挡住下一次补表,刚写过的原样留着;一小时没被写过的在之后的清理里删掉,代次里别的文件不动。
1557    #[test]
1558    fn table_copies_left_by_a_killed_preparation_do_not_block_and_are_swept_later() {
1559        let root = tempfile::tempdir().unwrap();
1560        let resources = resources(root.path());
1561        let user = root.path().join("user");
1562        let cache = root.path().join("cache");
1563        let generation = prepare_runtime_paths(&resources, &user, &cache, "v1")
1564            .unwrap()
1565            .dictionaries;
1566        let leftovers = [
1567            "msime-trigram.bin.incoming",
1568            "msime-trigram.bin.k3Xq9z.incoming",
1569        ];
1570        for leftover in leftovers {
1571            fs::write(generation.join(leftover), b"partial").unwrap();
1572        }
1573        let unrelated = [
1574            "other.incoming",
1575            "msime-trigram.bin.k3.x.incoming",
1576            "msime-trigram.bin..incoming",
1577            "msime-trigram.bin.incoming.bak",
1578        ];
1579        for name in unrelated {
1580            fs::write(generation.join(name), b"keep").unwrap();
1581        }
1582        fs::create_dir(generation.join("msime-bigram.bin.Ab12cd.incoming")).unwrap();
1583        fs::write(resources.join(assets::TRIGRAM_TABLE), "third").unwrap();
1584
1585        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1586        assert_eq!(
1587            fs::read(generation.join(assets::TRIGRAM_TABLE)).unwrap(),
1588            b"third"
1589        );
1590        for leftover in leftovers {
1591            assert_eq!(fs::read(generation.join(leftover)).unwrap(), b"partial");
1592        }
1593
1594        sweep_abandoned_copies(
1595            &generation,
1596            SystemTime::now() + ABANDONED_STAGE_AGE + Duration::from_secs(60),
1597        );
1598        for leftover in leftovers {
1599            assert!(!generation.join(leftover).exists(), "{leftover}");
1600        }
1601        for name in unrelated {
1602            assert_eq!(fs::read(generation.join(name)).unwrap(), b"keep", "{name}");
1603        }
1604        assert!(generation.join("msime-bigram.bin.Ab12cd.incoming").is_dir());
1605        for name in [
1606            assets::MAIN_DICTIONARY,
1607            assets::ENGLISH_DICTIONARY,
1608            assets::GENERATION_READY,
1609            assets::TRIGRAM_TABLE,
1610        ] {
1611            assert!(generation.join(name).is_file(), "{name}");
1612        }
1613    }
1614
1615    /// 后来的版本开始随资源发布一张 n-gram 表,几个进程同时打开同一个已就绪的代次、同时补这张表:每一方都成功,就位的表与资源逐字节一致,谁也不会把别人写到一半的副本改名就位,也不留下临时文件。
1616    #[test]
1617    fn concurrent_preparations_add_a_newly_shipped_table_once() {
1618        const PREPARERS: usize = 8;
1619        let root = tempfile::tempdir().unwrap();
1620        let resources = resources(root.path());
1621        let user = root.path().join("user");
1622        let cache = root.path().join("cache");
1623        let generation = prepare_runtime_paths(&resources, &user, &cache, "v1")
1624            .unwrap()
1625            .dictionaries;
1626        // 大到复制要花一点时间,几方的复制才会互相重叠。
1627        let table: Vec<u8> = (0..8 * 1024 * 1024u32).map(|index| index as u8).collect();
1628        fs::write(resources.join(assets::TRIGRAM_TABLE), &table).unwrap();
1629
1630        let barrier = std::sync::Barrier::new(PREPARERS);
1631        let results: Vec<_> = std::thread::scope(|scope| {
1632            let preparers: Vec<_> = (0..PREPARERS)
1633                .map(|_| {
1634                    scope.spawn(|| {
1635                        barrier.wait();
1636                        prepare_runtime_paths(&resources, &user, &cache, "v1")
1637                            .map_err(|error| error.to_string())
1638                    })
1639                })
1640                .collect();
1641            preparers
1642                .into_iter()
1643                .map(|preparer| preparer.join().unwrap())
1644                .collect()
1645        });
1646        for result in &results {
1647            assert_eq!(result.as_ref().unwrap().dictionaries, generation);
1648        }
1649        assert!(fs::read(generation.join(assets::TRIGRAM_TABLE)).unwrap() == table);
1650        assert!(incoming_entries(&generation).is_empty());
1651    }
1652
1653    /// 发布把五笔码表拆进 `msime-wubi.db` 后,准备代次要把它并回工作主词库:日志里的五笔行照常回放,不会让整个代次因为缺表被拒;索引与 rowid 原样保留;再次准备已就绪的代次时不重复导入。
1654    #[test]
1655    fn split_wubi_tables_are_merged_into_the_generation_before_replay() {
1656        let root = tempfile::tempdir().unwrap();
1657        let resources = resources(root.path());
1658        sql(
1659            &resources.join(assets::WUBI_DICTIONARY),
1660            "CREATE TABLE wubi86(\"key\" TEXT NOT NULL,\"value\" TEXT NOT NULL,\"weight\" INTEGER NOT NULL DEFAULT 0,UNIQUE(\"key\",\"value\"));
1661             CREATE INDEX idx_wubi86_key_weight ON wubi86(\"key\",\"weight\" DESC);
1662             CREATE TABLE wubi98(\"key\" TEXT NOT NULL,\"value\" TEXT NOT NULL,\"weight\" INTEGER NOT NULL DEFAULT 0,UNIQUE(\"key\",\"value\"));
1663             INSERT INTO wubi86(rowid,\"key\",\"value\",weight) VALUES(7,'wqvb','你好',100),(3,'aaaa','工',90);
1664             INSERT INTO wubi98 VALUES('wqvb','你好',80);",
1665        );
1666        let user = root.path().join("user");
1667        let journal = user.join(assets::USER_JOURNAL);
1668        fs::create_dir_all(&user).unwrap();
1669        sql(&journal, OPERATIONS_DDL);
1670        sql(
1671            &journal,
1672            "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted,updated_at) VALUES
1673               ('wubi','wqvb','合成五笔','upsert',9,1,1),
1674               ('wubi98','wqvb','你好','upsert',99,1,2)",
1675        );
1676        let cache = root.path().join("cache");
1677        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1678        let main = paths.dictionary(assets::MAIN_DICTIONARY);
1679        assert_eq!(
1680            weight(&main, "SELECT weight FROM wubi86 WHERE value='合成五笔'"),
1681            Some(9)
1682        );
1683        assert_eq!(
1684            weight(&main, "SELECT weight FROM wubi98 WHERE value='你好'"),
1685            Some(99)
1686        );
1687        assert_eq!(
1688            weight(&main, "SELECT rowid FROM wubi86 WHERE value='你好'"),
1689            Some(7)
1690        );
1691        assert_eq!(
1692            weight(
1693                &main,
1694                "SELECT count(*) FROM sqlite_master WHERE name='idx_wubi86_key_weight'"
1695            ),
1696            Some(1)
1697        );
1698        // 只读资源不变。
1699        assert_eq!(
1700            weight(
1701                &resources.join(assets::WUBI_DICTIONARY),
1702                "SELECT count(*) FROM wubi86"
1703            ),
1704            Some(2)
1705        );
1706        assert!(!paths.dictionary(assets::WUBI_DICTIONARY).exists());
1707
1708        let again = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1709        assert_eq!(
1710            weight(
1711                &again.dictionary(assets::MAIN_DICTIONARY),
1712                "SELECT count(*) FROM wubi86"
1713            ),
1714            Some(3)
1715        );
1716    }
1717
1718    /// 资源目录不带 `msime-wubi.db`(macOS 发布包改为按需下载):准备代次时五笔行跳过、代次照常就绪;宿主交来下载的码表后,建会话时并进工作主词库并补上日志里的五笔行,再来一次什么也不做。
1719    #[test]
1720    fn a_downloaded_wubi_dictionary_is_merged_into_a_generation_prepared_without_one() {
1721        let root = tempfile::tempdir().unwrap();
1722        let resources = resources(root.path());
1723        assert!(!resources.join(assets::WUBI_DICTIONARY).exists());
1724        let user = root.path().join("user");
1725        fs::create_dir_all(&user).unwrap();
1726        let journal = user.join(assets::USER_JOURNAL);
1727        sql(&journal, OPERATIONS_DDL);
1728        sql(
1729            &journal,
1730            "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted,updated_at) VALUES
1731               ('wubi','wqvb','合成五笔','upsert',9,1,1),
1732               ('wubi98','wqvb','你好','upsert',99,1,2)",
1733        );
1734        let cache = root.path().join("cache");
1735        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1736        let main = paths.dictionary(assets::MAIN_DICTIONARY);
1737        assert_eq!(
1738            weight(
1739                &main,
1740                "SELECT count(*) FROM sqlite_master WHERE name IN ('wubi86','wubi98')"
1741            ),
1742            Some(0)
1743        );
1744
1745        let pack = root.path().join("resource-packs").join("wubi");
1746        fs::create_dir_all(&pack).unwrap();
1747        let downloaded = pack.join(assets::WUBI_DICTIONARY);
1748        sql(
1749            &downloaded,
1750            "CREATE TABLE wubi86(\"key\" TEXT NOT NULL,\"value\" TEXT NOT NULL,\"weight\" INTEGER NOT NULL DEFAULT 0,UNIQUE(\"key\",\"value\"));
1751             CREATE TABLE wubi98(\"key\" TEXT NOT NULL,\"value\" TEXT NOT NULL,\"weight\" INTEGER NOT NULL DEFAULT 0,UNIQUE(\"key\",\"value\"));
1752             INSERT INTO wubi86 VALUES('aaaa','工',90);
1753             INSERT INTO wubi98 VALUES('wqvb','你好',80);",
1754        );
1755        // 不是 `msime-wubi.db` 的文件不认。
1756        let renamed = pack.join("other.db");
1757        fs::copy(&downloaded, &renamed).unwrap();
1758        assert!(!merge_downloaded_wubi(&paths, &renamed).unwrap());
1759        assert!(!merge_downloaded_wubi(
1760            &paths,
1761            &pack.join("missing").join(assets::WUBI_DICTIONARY)
1762        )
1763        .unwrap());
1764
1765        assert!(merge_downloaded_wubi(&paths, &downloaded).unwrap());
1766        assert_eq!(
1767            weight(&main, "SELECT weight FROM wubi86 WHERE value='合成五笔'"),
1768            Some(9)
1769        );
1770        assert_eq!(
1771            weight(&main, "SELECT weight FROM wubi98 WHERE value='你好'"),
1772            Some(99)
1773        );
1774        assert_eq!(
1775            weight(&main, "SELECT weight FROM wubi86 WHERE value='工'"),
1776            Some(90)
1777        );
1778        // 下载的码表不变;已有五笔表时不再导入。
1779        assert_eq!(weight(&downloaded, "SELECT count(*) FROM wubi86"), Some(1));
1780        assert!(!merge_downloaded_wubi(&paths, &downloaded).unwrap());
1781        assert_eq!(weight(&main, "SELECT count(*) FROM wubi86"), Some(2));
1782        // 代次就是资源目录(只读布局)时不写。
1783        let read_only = RuntimePaths {
1784            dictionaries: paths.resources.clone(),
1785            ..paths.clone()
1786        };
1787        assert!(!merge_downloaded_wubi(&read_only, &downloaded).unwrap());
1788    }
1789
1790    #[test]
1791    fn weakly_canonical_resolves_the_existing_prefix() {
1792        let root = tempfile::tempdir().unwrap();
1793        let base = root.path().canonicalize().unwrap();
1794        assert_eq!(
1795            weakly_canonical(&root.path().join("a/./b/../c")).unwrap(),
1796            base.join("a/c")
1797        );
1798        assert_eq!(weakly_canonical(root.path()).unwrap(), base);
1799        assert!(roots_overlap(Path::new("/a/b"), Path::new("/a")));
1800        assert!(!roots_overlap(Path::new("/a/bc"), Path::new("/a/b")));
1801    }
1802
1803    /// 发布的整套词库能完整准备:两个数据库复制进来(五笔码表并回主词库),两张 n-gram 表逐字节一致,每种日志行都回放到真实的表上。
1804    #[test]
1805    fn the_real_dictionary_set_stages_and_replays() {
1806        let Some(resources) = std::env::var_os("MSIME_EVAL_RESOURCES") else {
1807            eprintln!(
1808                "skipped: MSIME_EVAL_RESOURCES is not set to the dict-v2.0.5 resource directory"
1809            );
1810            return;
1811        };
1812        let resources = PathBuf::from(resources);
1813        let root = tempfile::tempdir().unwrap();
1814        let user = root.path().join("user");
1815        fs::create_dir_all(&user).unwrap();
1816        let journal = user.join(assets::USER_JOURNAL);
1817        sql(&journal, OPERATIONS_DDL);
1818        sql(
1819            &journal,
1820            "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted) VALUES
1821               ('pinyin','ni''hao','拟好蒿','upsert',12345,'',1),
1822               ('pinyin','ni''hao''ni''hao''ni''hao''ni''hao','你好你好你好你好','upsert',7,'',1),
1823               ('wubi','wqvb','合成五笔','upsert',9,'',1),
1824               ('quick','zzfixture','合成短语','upsert',10,'',1),
1825               ('english','zzfixture','Zzfixture','upsert',11,'Zzfixture',1)",
1826        );
1827        let paths =
1828            prepare_runtime_paths(&resources, &user, &root.path().join("cache"), "real").unwrap();
1829        for name in [assets::BIGRAM_TABLE, assets::TRIGRAM_TABLE] {
1830            assert_eq!(
1831                fs::read(paths.dictionary(name)).unwrap(),
1832                fs::read(resources.join(name)).unwrap(),
1833                "{name}"
1834            );
1835        }
1836        let main = paths.dictionary(assets::MAIN_DICTIONARY);
1837        assert_eq!(
1838            weight(
1839                &main,
1840                "SELECT weight FROM tbl_2_n WHERE key='ni''hao' AND value='拟好蒿'"
1841            ),
1842            Some(12345)
1843        );
1844        assert_eq!(
1845            weight(
1846                &main,
1847                "SELECT weight FROM tbl_others_n WHERE value='你好你好你好你好'"
1848            ),
1849            Some(7)
1850        );
1851        assert_eq!(
1852            weight(&main, "SELECT weight FROM wubi86 WHERE value='合成五笔'"),
1853            Some(9)
1854        );
1855        assert_eq!(
1856            weight(
1857                &main,
1858                "SELECT weight FROM quick_parases WHERE value='合成短语'"
1859            ),
1860            Some(10)
1861        );
1862        assert_eq!(
1863            weight(
1864                &paths.dictionary(assets::ENGLISH_DICTIONARY),
1865                "SELECT weight FROM english_words WHERE word='zzfixture' AND display='Zzfixture'"
1866            ),
1867            Some(11)
1868        );
1869        let shipped =
1870            |path: &Path| weight(path, "SELECT count(*) FROM tbl_2_n WHERE key='ni''hao'").unwrap();
1871        assert_eq!(
1872            shipped(&main),
1873            shipped(&resources.join(assets::MAIN_DICTIONARY)) + 1
1874        );
1875    }
1876
1877    fn japanese_only() -> SchemeSet {
1878        SchemeSet::of(&[crate::types::SchemeType::JapaneseRomaji])
1879    }
1880
1881    /// 只有日文(或越南文、藏文)的版本不带 `msime-pinyin.db`:代次只复制 `msime-english.db`,日志里的英文词照样回放,拼音行跳过而不拒绝这个代次;再次准备同一个代次也不要求 `msime-pinyin.db`。读 `msime-pinyin.db` 的集合缺了它仍然失败,与以前相同。
1882    #[test]
1883    fn a_generation_without_the_main_dictionary_holds_only_english() {
1884        let root = tempfile::tempdir().unwrap();
1885        let resources = resources(root.path());
1886        fs::remove_file(resources.join(assets::MAIN_DICTIONARY)).unwrap();
1887        let user = root.path().join("user");
1888        let cache = root.path().join("cache");
1889        let journal = user.join(assets::USER_JOURNAL);
1890        fs::create_dir_all(&user).unwrap();
1891        learn(&journal, "你", 1);
1892        sql(
1893            &journal,
1894            "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted,updated_at) VALUES('english','zzfixture','Zzfixture','upsert',11,'Zzfixture',1,2)",
1895        );
1896
1897        let paths =
1898            prepare_runtime_paths_for(&resources, &user, &cache, "v1", japanese_only()).unwrap();
1899        assert!(paths.dictionaries.join(assets::GENERATION_READY).is_file());
1900        assert!(!paths.dictionary(assets::MAIN_DICTIONARY).exists());
1901        assert!(!resources.join(assets::MAIN_DICTIONARY).exists());
1902        assert_eq!(
1903            weight(
1904                &paths.dictionary(assets::ENGLISH_DICTIONARY),
1905                "SELECT weight FROM english_words WHERE word='zzfixture' AND display='Zzfixture'"
1906            ),
1907            Some(11)
1908        );
1909
1910        // 已经准备好的代次再准备一次:不要求 msime-pinyin.db,日志再回放一次。
1911        let again =
1912            prepare_runtime_paths_for(&resources, &user, &cache, "v1", japanese_only()).unwrap();
1913        assert_eq!(again, paths);
1914        assert!(!again.dictionary(assets::MAIN_DICTIONARY).exists());
1915
1916        // 同一个资源目录给读 msime-pinyin.db 的集合准备,照旧因为缺 msime-pinyin.db 失败。
1917        let error = prepare_runtime_paths(&resources, &user, &cache, "v2").unwrap_err();
1918        assert_eq!(
1919            error.to_string(),
1920            format!(
1921                "{}{}",
1922                diagnostics::RUNTIME_COPY_FAILED,
1923                resources.join(assets::MAIN_DICTIONARY).display()
1924            )
1925        );
1926        assert!(!user.join("dictionaries/v2").exists());
1927        // 读 msime-pinyin.db 的集合也不接受一个没有 msime-pinyin.db 的现成代次。
1928        let error = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap_err();
1929        assert_eq!(
1930            error.to_string(),
1931            diagnostics::INCOMPLETE_RUNTIME_GENERATION
1932        );
1933    }
1934
1935    /// 不读 msime-pinyin.db 的代次仍然要求 `msime-english.db`:资源目录缺了它就失败,不留下暂存目录。
1936    #[test]
1937    fn a_generation_without_the_main_dictionary_still_needs_english() {
1938        let root = tempfile::tempdir().unwrap();
1939        let missing = root.path().join("missing-resources");
1940        fs::create_dir_all(&missing).unwrap();
1941        let user = root.path().join("user");
1942        let error = prepare_runtime_paths_for(
1943            &missing,
1944            &user,
1945            &root.path().join("cache"),
1946            "v1",
1947            japanese_only(),
1948        )
1949        .unwrap_err();
1950        assert_eq!(
1951            error.to_string(),
1952            format!(
1953                "{}{}",
1954                diagnostics::RUNTIME_COPY_FAILED,
1955                missing.join(assets::ENGLISH_DICTIONARY).display()
1956            )
1957        );
1958        assert!(stages(&user).is_empty());
1959        assert!(!user.join("dictionaries/v1").exists());
1960    }
1961
1962    #[cfg(unix)]
1963    #[test]
1964    fn generation_copy_rejects_a_symlinked_source() {
1965        use std::os::unix::fs::symlink;
1966
1967        let directory = tempfile::tempdir().unwrap();
1968        let outside = tempfile::tempdir().unwrap();
1969        let source = outside.path().join("source.bin");
1970        let target = directory.path().join("target.bin");
1971        std::fs::write(&source, b"outside").unwrap();
1972        symlink(&source, directory.path().join("linked.bin")).unwrap();
1973
1974        assert!(copy_private_file(&directory.path().join("linked.bin"), &target).is_err());
1975        assert!(!target.exists());
1976    }
1977
1978    #[cfg(unix)]
1979    #[test]
1980    fn generation_marker_writing_rejects_a_symlink() {
1981        use std::os::unix::fs::symlink;
1982
1983        let directory = tempfile::tempdir().unwrap();
1984        let outside = tempfile::tempdir().unwrap();
1985        let target = outside.path().join("outside.txt");
1986        std::fs::write(&target, b"keep").unwrap();
1987        let marker = directory.path().join(".ready");
1988        symlink(&target, &marker).unwrap();
1989
1990        assert!(write_private_file(&marker, b"ready\n").is_err());
1991        assert_eq!(std::fs::read(&target).unwrap(), b"keep");
1992    }
1993
1994    fn stamp(path: &Path, version: i32) {
1995        sql(
1996            path,
1997            &format!(
1998                "{} INSERT OR REPLACE INTO metadata VALUES('format_version', '{version}');",
1999                crate::format::METADATA_SCHEMA
2000            ),
2001        );
2002    }
2003
2004    fn assert_unsupported(error: &EngineError, file: &str) {
2005        let text = error.to_string();
2006        assert!(
2007            text.starts_with(diagnostics::DICTIONARY_FORMAT_UNSUPPORTED),
2008            "{text}"
2009        );
2010        assert!(text.contains(file), "{text}");
2011    }
2012
2013    /// 带文件内版本的主库照常准备,`metadata` 随备份一起进代次;比引擎新的主库拒绝准备,不留下代次也不留下暂存目录。没有 `metadata` 的旧发布由其余测试覆盖。
2014    #[test]
2015    fn preparation_checks_the_main_dictionary_format_version() {
2016        let root = tempfile::tempdir().unwrap();
2017        let resources = resources(root.path());
2018        let user = root.path().join("user");
2019        let cache = root.path().join("cache");
2020        let main = resources.join(assets::MAIN_DICTIONARY);
2021
2022        stamp(&main, crate::format::FORMAT_VERSION);
2023        let prepared = prepare_runtime_paths(&resources, &user, &cache, "current").unwrap();
2024        let copy = Connection::open(prepared.dictionary(assets::MAIN_DICTIONARY)).unwrap();
2025        assert_eq!(
2026            crate::format::check_format_version(&copy, assets::MAIN_DICTIONARY).unwrap(),
2027            crate::format::FORMAT_VERSION
2028        );
2029
2030        stamp(&main, crate::format::FORMAT_VERSION + 1);
2031        let error = prepare_runtime_paths(&resources, &user, &cache, "newer").unwrap_err();
2032        assert_unsupported(&error, assets::MAIN_DICTIONARY);
2033        assert!(!user.join("dictionaries").join("newer").exists());
2034        assert!(stages(&user).is_empty());
2035    }
2036
2037    /// 已经就绪的代次再次打开时也查主库副本:更新的引擎用更新格式准备好的代次,降级后的引擎不能照旧格式去读。
2038    #[test]
2039    fn reopening_a_ready_generation_checks_the_main_dictionary_format_version() {
2040        let root = tempfile::tempdir().unwrap();
2041        let resources = resources(root.path());
2042        let user = root.path().join("user");
2043        let cache = root.path().join("cache");
2044        let prepared = prepare_runtime_paths(&resources, &user, &cache, "current").unwrap();
2045        let copy = prepared.dictionary(assets::MAIN_DICTIONARY);
2046        assert!(prepared
2047            .dictionaries
2048            .join(assets::GENERATION_READY)
2049            .is_file());
2050
2051        stamp(&copy, crate::format::FORMAT_VERSION);
2052        prepare_runtime_paths(&resources, &user, &cache, "current").unwrap();
2053
2054        stamp(&copy, crate::format::FORMAT_VERSION + 1);
2055        let error = prepare_runtime_paths(&resources, &user, &cache, "current").unwrap_err();
2056        assert_unsupported(&error, assets::MAIN_DICTIONARY);
2057    }
2058
2059    /// 单独发布的五笔库同样检查:比引擎新的 `msime-wubi.db` 不会并进代次。
2060    #[test]
2061    fn preparation_checks_the_wubi_dictionary_format_version() {
2062        let root = tempfile::tempdir().unwrap();
2063        let resources = resources(root.path());
2064        let user = root.path().join("user");
2065        let cache = root.path().join("cache");
2066        let wubi = resources.join(assets::WUBI_DICTIONARY);
2067        sql(
2068            &wubi,
2069            "CREATE TABLE wubi86(key TEXT NOT NULL,value TEXT NOT NULL,weight INTEGER NOT NULL DEFAULT 0,UNIQUE(key,value));
2070             INSERT INTO wubi86 VALUES('wq','你',10);",
2071        );
2072        stamp(&wubi, crate::format::FORMAT_VERSION + 1);
2073        let error = prepare_runtime_paths(&resources, &user, &cache, "newer").unwrap_err();
2074        assert_unsupported(&error, assets::WUBI_DICTIONARY);
2075        assert!(!user.join("dictionaries").join("newer").exists());
2076
2077        stamp(&wubi, crate::format::FORMAT_VERSION);
2078        let prepared = prepare_runtime_paths(&resources, &user, &cache, "current").unwrap();
2079        assert_eq!(
2080            weight(
2081                &prepared.dictionary(assets::MAIN_DICTIONARY),
2082                "SELECT weight FROM wubi86 WHERE value='你'"
2083            ),
2084            Some(10)
2085        );
2086    }
2087
2088    /// 只读布局在建会话时检查;其他布局不在这里查。
2089    #[test]
2090    fn the_read_only_layout_checks_the_format_version() {
2091        let root = tempfile::tempdir().unwrap();
2092        let resources = resources(root.path());
2093        let paths = RuntimePaths {
2094            resources: resources.clone(),
2095            user_data: root.path().join("user"),
2096            cache: root.path().join("cache"),
2097            dictionaries: resources.clone(),
2098        };
2099        check_read_only_layout(&paths).unwrap();
2100        stamp(
2101            &resources.join(assets::MAIN_DICTIONARY),
2102            crate::format::FORMAT_VERSION + 1,
2103        );
2104        let error = check_read_only_layout(&paths).unwrap_err();
2105        assert_unsupported(&error, assets::MAIN_DICTIONARY);
2106        let error = crate::Session::new(crate::SessionOptions::new(paths.clone()))
2107            .err()
2108            .expect("a newer dictionary format refuses the session");
2109        assert_unsupported(&error, assets::MAIN_DICTIONARY);
2110        let generation = RuntimePaths {
2111            dictionaries: root.path().join("user/dictionaries/v1"),
2112            ..paths
2113        };
2114        check_read_only_layout(&generation).unwrap();
2115    }
2116}