use std::ffi::OsString;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::OpenFlags;
use crate::assets;
use crate::diagnostics;
use crate::error::{EngineError, Result};
use crate::paths::RuntimePaths;
use crate::types::SchemeSet;
use crate::user_dictionary::generation::{copy_private_file, merge_split_wubi, weakly_canonical};
use crate::user_dictionary::journal::{
checkpoint_journal, close_cached_journals, ensure_schema, open_database,
};
const JOURNAL_SIDECARS: [&str; 3] = ["-wal", "-shm", "-journal"];
struct Replacement {
target: PathBuf,
temporary: PathBuf,
backup: PathBuf,
had_original: bool,
published: bool,
}
pub fn reset_learned_data(paths: &RuntimePaths) -> Result<()> {
reset(paths, true, true, true)
}
pub fn reset_learned_data_for(paths: &RuntimePaths, schemes: SchemeSet) -> Result<()> {
let main_dictionary = schemes.reads_main_dictionary();
reset(
paths,
main_dictionary,
main_dictionary || schemes.requires_english_dictionary(),
true,
)
}
pub fn reset_learned_data_with(paths: &RuntimePaths, main_dictionary: bool) -> Result<()> {
reset(paths, main_dictionary, main_dictionary, false)
}
fn reset(
paths: &RuntimePaths,
main_dictionary: bool,
english_required: bool,
take_lease: bool,
) -> Result<()> {
paths.validate()?;
if weakly_canonical(&paths.resources)? == weakly_canonical(&paths.dictionaries)? {
return Err(EngineError::invalid(diagnostics::RESET_IN_PLACE));
}
let english = english_required
|| fs::symlink_metadata(paths.resource(assets::ENGLISH_DICTIONARY)).is_ok();
let names: Vec<&str> = [
(main_dictionary, assets::MAIN_DICTIONARY),
(english, assets::ENGLISH_DICTIONARY),
]
.into_iter()
.filter_map(|(present, name)| present.then_some(name))
.collect();
let sources: Vec<PathBuf> = names.iter().map(|name| paths.resource(name)).collect();
if !sources.iter().all(|source| is_real_file(source)) {
return Err(EngineError::failed(
diagnostics::PACKAGED_DICTIONARY_UNAVAILABLE,
));
}
reject_redirected_directory(&paths.user_data)?;
reject_redirected_directory(&paths.dictionaries)?;
fs::create_dir_all(&paths.user_data)?;
fs::create_dir_all(&paths.dictionaries)?;
reject_redirected_directory(&paths.user_data)?;
reject_redirected_directory(&paths.dictionaries)?;
let _access = if take_lease {
Some(crate::access::maintenance(paths)?)
} else {
None
};
close_cached_journals();
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos())
.to_string();
let journal = paths.user(assets::USER_JOURNAL);
checkpoint_journal(&journal);
let mut replacements: Vec<Replacement> = names
.iter()
.map(|name| paths.dictionary(name))
.chain([journal.clone()])
.map(|target| Replacement {
temporary: affixed(&target, ".reset.", &stamp),
backup: affixed(&target, ".backup.", &stamp),
target,
had_original: false,
published: false,
})
.collect();
let journal_temporary = replacements[names.len()].temporary.clone();
let prepared = (|| -> Result<()> {
let connection = open_database(
&journal_temporary,
OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
)?;
ensure_schema(&connection)
})();
if prepared.is_err() {
remove_ignoring_errors(&journal_temporary);
return Err(EngineError::failed(diagnostics::RESET_JOURNAL_FAILED));
}
let swapped = swap(
&mut replacements,
&paths.resources,
main_dictionary,
&sources,
&journal,
);
match swapped {
Ok(()) => {
for replacement in &replacements {
remove_ignoring_errors(&replacement.backup);
}
Ok(())
}
Err(error) => {
restore(&replacements);
Err(error)
}
}
}
fn swap(
replacements: &mut [Replacement],
resources: &Path,
main_dictionary: bool,
sources: &[PathBuf],
journal: &Path,
) -> Result<()> {
for (source, replacement) in sources.iter().zip(replacements.iter()) {
copy_private_file(source, &replacement.temporary)
.map_err(|_| EngineError::failed(diagnostics::RESET_STAGE_DICTIONARIES_FAILED))?;
}
if main_dictionary {
merge_split_wubi(resources, &replacements[0].temporary)
.map_err(|_| EngineError::failed(diagnostics::RESET_STAGE_DICTIONARIES_FAILED))?;
}
for replacement in replacements.iter_mut() {
replacement.had_original = replacement.target.exists();
if replacement.had_original {
if replacement.backup.exists() {
remove_ignoring_errors(&replacement.backup);
}
fs::rename(&replacement.target, &replacement.backup)
.map_err(|_| EngineError::failed(diagnostics::RESET_STAGE_FAILED))?;
}
fs::rename(&replacement.temporary, &replacement.target)
.map_err(|_| EngineError::failed(diagnostics::RESET_PUBLISH_FAILED))?;
replacement.published = true;
}
for suffix in JOURNAL_SIDECARS {
let mut sidecar = journal.as_os_str().to_owned();
sidecar.push(suffix);
match fs::remove_file(PathBuf::from(sidecar)) {
Ok(()) => {}
Err(error) if error.kind() == ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
Ok(())
}
fn restore(replacements: &[Replacement]) {
for replacement in replacements.iter().rev() {
if replacement.published {
remove_ignoring_errors(&replacement.target);
}
if replacement.had_original && replacement.backup.exists() {
let _ = fs::rename(&replacement.backup, &replacement.target);
}
remove_ignoring_errors(&replacement.temporary);
}
}
pub(crate) fn restore_interrupted_reset(target: &Path) {
match fs::symlink_metadata(target) {
Err(error) if error.kind() == ErrorKind::NotFound => {}
_ => return,
}
let backup = newest_reset_backup(target).or_else(|| {
let name = target.file_name()?.to_str()?;
let (_, legacy) = assets::LEGACY_NAMES
.iter()
.find(|(current, _)| *current == name)?;
let legacy = target.with_file_name(legacy);
match fs::symlink_metadata(&legacy) {
Err(error) if error.kind() == ErrorKind::NotFound => newest_reset_backup(&legacy),
_ => None,
}
});
if let Some(backup) = backup {
backup.publish(target);
}
}
struct ResetBackup {
path: PathBuf,
unpublished: PathBuf,
}
fn newest_reset_backup(target: &Path) -> Option<ResetBackup> {
let directory = target.parent()?;
let name = target.file_name()?.to_str()?;
let prefix = format!(".{name}.backup.");
fs::read_dir(directory)
.ok()?
.flatten()
.filter_map(|entry| {
let stamp = entry
.file_name()
.to_str()?
.strip_prefix(&prefix)?
.to_owned();
let order = stamp
.parse::<u128>()
.ok()
.filter(|_| stamp.bytes().all(|byte| byte.is_ascii_digit()))?;
is_real_file(&entry.path()).then_some((order, stamp, entry.path()))
})
.max_by_key(|(order, _, _)| *order)
.map(|(_, stamp, path)| ResetBackup {
path,
unpublished: affixed(target, ".reset.", &stamp),
})
}
impl ResetBackup {
fn publish(self, target: &Path) {
let Ok(mut backup) = tempfile::TempPath::try_from_path(self.path) else {
return;
};
backup.disable_cleanup(true);
if backup.persist_noclobber(target).is_ok() {
remove_ignoring_errors(&self.unpublished);
}
}
}
fn affixed(target: &Path, infix: &str, stamp: &str) -> PathBuf {
let mut name = OsString::from(".");
name.push(target.file_name().unwrap_or_default());
name.push(infix);
name.push(stamp);
target.with_file_name(name)
}
fn remove_ignoring_errors(path: &Path) {
if path.is_dir() {
let _ = fs::remove_dir_all(path);
} else {
let _ = fs::remove_file(path);
}
}
fn is_real_file(path: &Path) -> bool {
fs::symlink_metadata(path)
.map(|metadata| metadata.file_type().is_file())
.unwrap_or(false)
}
fn reject_redirected_directory(path: &Path) -> std::io::Result<()> {
for ancestor in path.ancestors() {
match fs::symlink_metadata(ancestor) {
Ok(metadata) if metadata.file_type().is_symlink() => {
if crate::paths::is_trusted_system_alias(ancestor) {
continue;
}
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"reset directory has a symbolic-link ancestor",
));
}
Ok(metadata) if !metadata.is_dir() => {
return Err(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
"reset directory parent is not a directory",
));
}
Ok(_) => {}
Err(error) if error.kind() == ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::user_dictionary::replay::tests::{sql, weight};
#[test]
fn a_late_reset_recovery_preserves_the_winners_file_and_its_own_backup() {
let root = tempfile::tempdir().unwrap();
let target = root.path().join(assets::USER_JOURNAL);
let older = affixed(&target, ".backup.", "100");
let newest = affixed(&target, ".backup.", "200");
let older_stage = affixed(&target, ".reset.", "100");
fs::write(&older, b"older synthetic journal").unwrap();
fs::write(&newest, b"newest synthetic journal").unwrap();
fs::write(&older_stage, b"unpublished synthetic journal").unwrap();
assert_eq!(
fs::symlink_metadata(&target).unwrap_err().kind(),
ErrorKind::NotFound
);
newest_reset_backup(&target).unwrap().publish(&target);
newest_reset_backup(&target).unwrap().publish(&target);
assert_eq!(fs::read(&target).unwrap(), b"newest synthetic journal");
assert_eq!(fs::read(&older).unwrap(), b"older synthetic journal");
assert_eq!(
fs::read(&older_stage).unwrap(),
b"unpublished synthetic journal"
);
}
#[test]
fn reset_recovery_follows_current_and_legacy_name_priority() {
for (name, legacy) in [
(assets::MAIN_DICTIONARY, "msime.db"),
(assets::ENGLISH_DICTIONARY, "english.db"),
] {
let root = tempfile::tempdir().unwrap();
let paths = RuntimePaths {
dictionaries: root.path().to_owned(),
..RuntimePaths::default()
};
let current = root.path().join(name);
let old = root.path().join(legacy);
let current_backup = affixed(¤t, ".backup.", "100");
let old_backup = affixed(&old, ".backup.", "200");
fs::write(¤t, b"existing synthetic dictionary").unwrap();
fs::write(&old_backup, b"legacy synthetic backup").unwrap();
restore_interrupted_reset(&paths.dictionary(name));
assert_eq!(
fs::read(¤t).unwrap(),
b"existing synthetic dictionary"
);
assert!(old_backup.exists());
fs::rename(¤t, &old).unwrap();
fs::write(¤t_backup, b"current synthetic backup").unwrap();
restore_interrupted_reset(&paths.dictionary(name));
assert_eq!(fs::read(&old).unwrap(), b"existing synthetic dictionary");
assert!(current_backup.exists());
assert!(!current.exists());
fs::remove_file(&old).unwrap();
restore_interrupted_reset(&paths.dictionary(name));
assert_eq!(fs::read(¤t).unwrap(), b"current synthetic backup");
assert!(!current_backup.exists());
assert_eq!(fs::read(&old_backup).unwrap(), b"legacy synthetic backup");
}
}
fn paths(root: &Path) -> RuntimePaths {
let path = |name: &str| {
let path = root.join(name);
fs::create_dir_all(&path).unwrap();
path
};
RuntimePaths {
resources: path("resources"),
user_data: path("user"),
cache: path("cache"),
dictionaries: path("dictionaries"),
}
}
#[test]
fn reset_takes_the_maintenance_lease() {
let temporary = tempfile::tempdir().unwrap();
let paths = paths(temporary.path());
sql(
&paths.resource(assets::MAIN_DICTIONARY),
"CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);",
);
sql(
&paths.resource(assets::ENGLISH_DICTIONARY),
"CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);",
);
fs::create_dir_all(&paths.user_data).unwrap();
fs::create_dir_all(&paths.dictionaries).unwrap();
let journal = paths.user(assets::USER_JOURNAL);
sql(&journal, "CREATE TABLE marker(value INTEGER);");
let session = crate::DictionaryAccess::try_session(&paths.user_data, &paths.dictionaries)
.unwrap()
.unwrap();
let error = reset_learned_data(&paths).unwrap_err();
assert_eq!(error.code(), crate::ErrorCode::Busy);
let marker = "SELECT count(*) FROM sqlite_master WHERE name='marker'";
assert_eq!(weight(&journal, marker), Some(1));
assert!(!paths.dictionary(assets::MAIN_DICTIONARY).exists());
drop(session);
reset_learned_data(&paths).unwrap();
assert!(paths.dictionary(assets::MAIN_DICTIONARY).exists());
assert_eq!(weight(&journal, marker), Some(0));
}
#[test]
fn reset_merges_the_split_wubi_tables_into_the_fresh_working_copy() {
let temporary = tempfile::tempdir().unwrap();
let paths = paths(temporary.path());
sql(
&paths.resource(assets::MAIN_DICTIONARY),
"CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);",
);
sql(
&paths.resource(assets::WUBI_DICTIONARY),
"CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);
INSERT INTO wubi86 VALUES('aaaa','工',200);
CREATE TABLE wubi98(key TEXT,value TEXT,weight INTEGER);",
);
sql(
&paths.resource(assets::ENGLISH_DICTIONARY),
"CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);",
);
reset_learned_data(&paths).unwrap();
assert_eq!(
weight(
&paths.dictionary(assets::MAIN_DICTIONARY),
"SELECT weight FROM wubi86 WHERE key='aaaa'"
),
Some(200)
);
assert_eq!(
weight(
&paths.dictionary(assets::MAIN_DICTIONARY),
"SELECT count(*) FROM wubi98"
),
Some(0)
);
assert!(!paths.dictionary(assets::WUBI_DICTIONARY).exists());
}
#[test]
fn reset_restores_packaged_dictionaries_and_clears_the_journal() {
for component in ["ascii", "陆傲天"] {
let temporary = tempfile::tempdir().unwrap();
let paths = paths(&temporary.path().join(component));
sql(
&paths.resource(assets::MAIN_DICTIONARY),
"CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
INSERT INTO tbl_2_n VALUES('ni''hao','nh','你好',100);
CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);
CREATE TABLE quick_parases(key TEXT,value TEXT,weight INTEGER);",
);
sql(
&paths.resource(assets::ENGLISH_DICTIONARY),
"CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);
CREATE TABLE en_zh_glosses(english TEXT PRIMARY KEY,chinese_gloss TEXT);
CREATE TABLE zh_en_glosses(chinese TEXT PRIMARY KEY,english TEXT);
INSERT INTO english_words VALUES('word','word',100);",
);
for name in [assets::MAIN_DICTIONARY, assets::ENGLISH_DICTIONARY] {
fs::copy(paths.resource(name), paths.dictionary(name)).unwrap();
}
let journal = paths.user(assets::USER_JOURNAL);
sql(
&journal,
"CREATE TABLE user_dictionary_operations(dictionary TEXT,key TEXT,value TEXT,operation TEXT,weight INTEGER,display TEXT,user_inserted INTEGER);
CREATE TABLE personal_dictionary_receipts(request_id TEXT PRIMARY KEY,payload TEXT);
CREATE TABLE candidate_selection_state(context_key TEXT,entry_key TEXT,value TEXT,selection_count INTEGER);
CREATE TABLE fixed_candidate_positions(context_key TEXT,entry_key TEXT,value TEXT,position INTEGER);
INSERT INTO user_dictionary_operations VALUES('pinyin','ni''hao','你好','upsert',1,'',1);
INSERT INTO candidate_selection_state VALUES('ni''hao','ni''hao','你好',7);",
);
sql(
&paths.dictionary(assets::MAIN_DICTIONARY),
"UPDATE tbl_2_n SET weight=1",
);
let mut wal = journal.as_os_str().to_owned();
wal.push("-wal");
fs::write(PathBuf::from(&wal), b"stale learned data").unwrap();
reset_learned_data(&paths).unwrap();
assert_eq!(
weight(
&paths.dictionary(assets::MAIN_DICTIONARY),
"SELECT weight FROM tbl_2_n WHERE key='ni''hao'"
),
Some(100)
);
assert_eq!(
weight(&journal, "SELECT count(*) FROM user_dictionary_operations"),
Some(0)
);
assert_eq!(
weight(&journal, "SELECT count(*) FROM candidate_selection_state"),
Some(0)
);
assert_eq!(
weight(&journal, "SELECT count(*) FROM pinned_candidates"),
Some(0)
);
assert!(!PathBuf::from(&wal).exists());
let leftovers: Vec<_> = [&paths.user_data, &paths.dictionaries]
.iter()
.flat_map(|directory| fs::read_dir(directory).unwrap())
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name.starts_with('.') && name != crate::access::LOCK_FILE)
.collect();
assert!(leftovers.is_empty(), "{leftovers:?}");
}
}
#[test]
fn reset_clears_shuangpin_typo_learning() {
use crate::types::ShuangpinProfileKind;
use crate::user_dictionary::typo_profile::{PersonalTypoProfile, ShuangpinTypoOutcome};
let temporary = tempfile::tempdir().unwrap();
let paths = paths(temporary.path());
sql(
&paths.resource(assets::MAIN_DICTIONARY),
"CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);",
);
sql(
&paths.resource(assets::ENGLISH_DICTIONARY),
"CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);",
);
let journal = paths.user(assets::USER_JOURNAL);
let profile = PersonalTypoProfile::shared(&journal);
profile
.record_shuangpin(
ShuangpinProfileKind::Xiaohe,
&[(*b"gf", *b"gr")],
ShuangpinTypoOutcome::Accepted,
)
.unwrap();
profile
.record_shuangpin_suppression(ShuangpinProfileKind::Xiaohe, "mwgfxi")
.unwrap();
assert!(profile.has_shuangpin_counts());
reset_learned_data(&paths).unwrap();
for table in [
"shuangpin_typo_counts",
"shuangpin_autocorrect_suppressions",
] {
assert_eq!(
weight(&journal, &format!("SELECT count(*) FROM {table}")),
Some(0),
"{table}"
);
}
profile.refresh_if_changed();
assert!(!profile.has_shuangpin_counts());
assert!(!profile.shuangpin_suppressed(ShuangpinProfileKind::Xiaohe, "mwgfxi"));
}
#[test]
fn reset_refuses_the_bundle_in_place_and_a_missing_package() {
let root = tempfile::tempdir().unwrap();
let mut paths = paths(root.path());
let error = reset_learned_data(&paths).unwrap_err();
assert_eq!(
error.to_string(),
diagnostics::PACKAGED_DICTIONARY_UNAVAILABLE
);
paths.dictionaries = paths.resources.join(".");
let error = reset_learned_data(&paths).unwrap_err();
assert!(matches!(error, EngineError::InvalidArgument(_)));
assert_eq!(error.to_string(), diagnostics::RESET_IN_PLACE);
}
#[test]
fn reset_without_the_main_dictionary_restores_only_english() {
let root = tempfile::tempdir().unwrap();
let paths = paths(root.path());
sql(
&paths.resource(assets::ENGLISH_DICTIONARY),
"CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);
INSERT INTO english_words VALUES('word','word',100);",
);
fs::copy(
paths.resource(assets::ENGLISH_DICTIONARY),
paths.dictionary(assets::ENGLISH_DICTIONARY),
)
.unwrap();
sql(
&paths.dictionary(assets::ENGLISH_DICTIONARY),
"UPDATE english_words SET weight=1",
);
let journal = paths.user(assets::USER_JOURNAL);
sql(
&journal,
"CREATE TABLE user_dictionary_operations(dictionary TEXT,key TEXT,value TEXT,operation TEXT,weight INTEGER,display TEXT,user_inserted INTEGER);
INSERT INTO user_dictionary_operations VALUES('english','word','word','upsert',1,'word',0);",
);
assert_eq!(
reset_learned_data(&paths).unwrap_err().to_string(),
diagnostics::PACKAGED_DICTIONARY_UNAVAILABLE
);
reset_learned_data_with(&paths, false).unwrap();
assert_eq!(
weight(
&paths.dictionary(assets::ENGLISH_DICTIONARY),
"SELECT weight FROM english_words WHERE word='word'"
),
Some(100)
);
assert_eq!(
weight(&journal, "SELECT count(*) FROM user_dictionary_operations"),
Some(0)
);
assert!(!paths.dictionary(assets::MAIN_DICTIONARY).exists());
assert!(!paths.resource(assets::MAIN_DICTIONARY).exists());
let leftovers: Vec<_> = [&paths.user_data, &paths.dictionaries]
.iter()
.flat_map(|directory| fs::read_dir(directory).unwrap())
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name.starts_with('.') && name != crate::access::LOCK_FILE)
.collect();
assert!(leftovers.is_empty(), "{leftovers:?}");
}
#[cfg(unix)]
#[test]
fn reset_rejects_a_symlinked_packaged_dictionary() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let paths = paths(root.path());
sql(
&paths.resource(assets::ENGLISH_DICTIONARY),
"CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);",
);
let external = root.path().join("external-msime-pinyin.db");
sql(
&external,
"CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);",
);
symlink(&external, paths.resource(assets::MAIN_DICTIONARY)).unwrap();
assert_eq!(
reset_learned_data(&paths).unwrap_err().to_string(),
diagnostics::PACKAGED_DICTIONARY_UNAVAILABLE
);
assert!(!paths.dictionary(assets::MAIN_DICTIONARY).exists());
}
#[cfg(unix)]
#[test]
fn reset_rejects_a_symlinked_dictionary_directory() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let paths = paths(root.path());
for name in [assets::MAIN_DICTIONARY, assets::ENGLISH_DICTIONARY] {
sql(&paths.resource(name), "CREATE TABLE packaged(x);");
}
fs::remove_dir(&paths.dictionaries).unwrap();
symlink(outside.path(), &paths.dictionaries).unwrap();
assert!(reset_learned_data(&paths).is_err());
assert!(fs::read_dir(outside.path()).unwrap().next().is_none());
}
#[test]
fn a_failed_publish_puts_every_original_back() {
let root = tempfile::tempdir().unwrap();
let paths = paths(root.path());
sql(
&paths.resource(assets::MAIN_DICTIONARY),
"CREATE TABLE fresh(x);",
);
sql(
&paths.resource(assets::ENGLISH_DICTIONARY),
"CREATE TABLE fresh(x);",
);
sql(
&paths.dictionary(assets::MAIN_DICTIONARY),
"CREATE TABLE learned(x);",
);
sql(
&paths.dictionary(assets::ENGLISH_DICTIONARY),
"CREATE TABLE learned(x);",
);
let journal = paths.user(assets::USER_JOURNAL);
sql(&journal, "CREATE TABLE learned(x);");
let mut wal = journal.as_os_str().to_owned();
wal.push("-wal");
let wal = PathBuf::from(wal);
fs::create_dir_all(wal.join("blocker")).unwrap();
let before: Vec<Vec<u8>> = [
paths.dictionary(assets::MAIN_DICTIONARY),
paths.dictionary(assets::ENGLISH_DICTIONARY),
journal.clone(),
]
.iter()
.map(|path| fs::read(path).unwrap())
.collect();
assert!(reset_learned_data(&paths).is_err());
let after: Vec<Vec<u8>> = [
paths.dictionary(assets::MAIN_DICTIONARY),
paths.dictionary(assets::ENGLISH_DICTIONARY),
journal.clone(),
]
.iter()
.map(|path| fs::read(path).unwrap())
.collect();
assert_eq!(after, before);
assert!(wal.join("blocker").is_dir());
let leftovers: Vec<_> = [&paths.user_data, &paths.dictionaries]
.iter()
.flat_map(|directory| fs::read_dir(directory).unwrap())
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name.starts_with('.') && name != crate::access::LOCK_FILE)
.collect();
assert!(leftovers.is_empty(), "{leftovers:?}");
}
}