pub mod catalogue;
pub mod install;
use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackSource {
Configured,
System,
Managed,
}
impl fmt::Display for PackSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Configured => "configured",
Self::System => "system",
Self::Managed => "managed",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedPack {
pub language: String,
pub stem: String,
pub aff: PathBuf,
pub dic: PathBuf,
pub source: PackSource,
}
#[derive(Debug)]
pub enum PackError {
NotFound {
language: String,
searched: Vec<PathBuf>,
},
Incomplete { language: String, missing: PathBuf },
Unreadable { path: PathBuf, detail: String },
Malformed { path: PathBuf, detail: String },
}
impl fmt::Display for PackError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotFound { language, searched } => {
write!(f, "no Hunspell dictionary for \"{language}\"")?;
if !searched.is_empty() {
write!(f, "; looked in ")?;
let shown: Vec<String> =
searched.iter().map(|p| p.display().to_string()).collect();
write!(f, "{}", shown.join(", "))?;
}
Ok(())
}
Self::Incomplete { language, missing } => write!(
f,
"the Hunspell dictionary for \"{language}\" is missing {}; an .aff and a .dic are both needed",
missing.display()
),
Self::Unreadable { path, detail } => {
write!(f, "cannot read {}: {detail}", path.display())
}
Self::Malformed { path, detail } => write!(
f,
"{} is not a dictionary this checker can read: {detail}",
path.display()
),
}
}
}
impl std::error::Error for PackError {}
impl PackError {
#[must_use]
pub const fn is_installable(&self) -> bool {
matches!(self, Self::NotFound { .. })
}
}
#[derive(Debug, Clone, Default)]
pub struct PackRegistry {
overrides: Vec<(String, PathBuf)>,
search_paths: Vec<PathBuf>,
}
#[must_use]
pub fn managed_dir() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join("language-check").join("dictionaries"))
}
#[must_use]
pub fn system_dirs() -> Vec<PathBuf> {
let mut dirs_out: Vec<PathBuf> = Vec::new();
#[cfg(target_os = "macos")]
{
if let Some(home) = dirs::home_dir() {
dirs_out.push(home.join("Library/Spelling"));
}
dirs_out.push(PathBuf::from("/Library/Spelling"));
dirs_out.push(PathBuf::from("/System/Library/Spelling"));
}
#[cfg(target_os = "windows")]
{
if let Some(data) = dirs::data_dir() {
dirs_out.push(data.join("hunspell"));
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
dirs_out.push(PathBuf::from("/usr/share/hunspell"));
dirs_out.push(PathBuf::from("/usr/share/myspell"));
dirs_out.push(PathBuf::from("/usr/share/myspell/dicts"));
dirs_out.push(PathBuf::from("/usr/local/share/hunspell"));
if let Some(home) = dirs::home_dir() {
dirs_out.push(home.join(".local/share/hunspell"));
}
}
dirs_out
}
impl PackRegistry {
#[must_use]
pub fn new() -> Self {
let mut search_paths = Vec::new();
search_paths.extend(managed_dir());
search_paths.extend(system_dirs());
Self {
overrides: Vec::new(),
search_paths,
}
}
#[must_use]
pub fn with_override(mut self, language: &str, path: impl Into<PathBuf>) -> Self {
self.overrides.push((normalise_tag(language), path.into()));
self
}
#[must_use]
pub fn with_search_path(mut self, path: impl Into<PathBuf>) -> Self {
self.search_paths.insert(0, path.into());
self
}
#[must_use]
pub fn with_only_search_paths(mut self, paths: Vec<PathBuf>) -> Self {
self.search_paths = paths;
self
}
#[must_use]
pub fn search_paths(&self) -> &[PathBuf] {
&self.search_paths
}
#[must_use]
pub fn for_hunspell(config: &crate::config::HunspellConfig) -> Self {
let mut registry = Self::new();
for dir in &config.search_paths {
registry = registry.with_search_path(dir);
}
for (language, path) in &config.dictionary_paths {
registry = registry.with_override(language, path);
}
registry
}
#[must_use]
pub fn fingerprint(&self, languages: &[String]) -> u64 {
let mut parts: Vec<String> = Vec::new();
let describe = |path: &Path| -> String {
std::fs::metadata(path).map_or_else(
|_| "missing".to_string(),
|meta| {
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| d.as_secs());
format!("{}:{modified}", meta.len())
},
)
};
if languages.is_empty() {
if let Some(dir) = managed_dir()
&& let Ok(entries) = std::fs::read_dir(&dir)
{
let mut listed: Vec<String> = entries
.flatten()
.map(|entry| {
format!(
"{}={}",
entry.file_name().to_string_lossy(),
describe(&entry.path())
)
})
.collect();
listed.sort_unstable();
parts.extend(listed);
}
} else {
for language in languages {
match self.resolve(language) {
Ok(pack) => parts.push(format!(
"{language}={}|{}|{}",
pack.stem,
describe(&pack.aff),
describe(&pack.dic),
)),
Err(_) => parts.push(format!("{language}=none")),
}
}
}
crate::hashing::stable_hash(&parts.join("\x1e"))
}
pub fn resolve(&self, language: &str) -> Result<ResolvedPack, PackError> {
let tag = normalise_tag(language);
for (over_lang, path) in &self.overrides {
if over_lang != &tag {
continue;
}
return resolve_override(language, path);
}
let mut searched = Vec::new();
for dir in &self.search_paths {
if !dir.is_dir() {
continue;
}
searched.push(dir.clone());
if let Some(stem) = find_stem(dir, &tag) {
let source = if managed_dir().is_some_and(|m| dir.starts_with(&m)) {
PackSource::Managed
} else {
PackSource::System
};
return complete_pair(language, dir, &stem, source);
}
}
Err(PackError::NotFound {
language: language.to_string(),
searched,
})
}
#[must_use]
pub fn installed(&self) -> Vec<ResolvedPack> {
let mut found: Vec<ResolvedPack> = Vec::new();
for dir in &self.search_paths {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "aff")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
{
let source = if managed_dir().is_some_and(|m| dir.starts_with(&m)) {
PackSource::Managed
} else {
PackSource::System
};
if let Ok(pack) = complete_pair(stem, dir, stem, source)
&& !found.iter().any(|p| p.stem == pack.stem)
{
found.push(pack);
}
}
}
}
found.sort_by(|a, b| a.stem.cmp(&b.stem));
found
}
}
fn normalise_tag(language: &str) -> String {
language.replace('-', "_").to_ascii_lowercase()
}
fn resolve_override(language: &str, path: &Path) -> Result<ResolvedPack, PackError> {
let tag = normalise_tag(language);
if path.is_dir() {
return find_stem(path, &tag).map_or_else(
|| {
Err(PackError::NotFound {
language: language.to_string(),
searched: vec![path.to_path_buf()],
})
},
|stem| complete_pair(language, path, &stem, PackSource::Configured),
);
}
let stem_path = if matches!(
path.extension().and_then(|e| e.to_str()),
Some("aff" | "dic")
) {
path.with_extension("")
} else {
path.to_path_buf()
};
let dir = stem_path.parent().unwrap_or_else(|| Path::new("."));
let stem = stem_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
complete_pair(language, dir, &stem, PackSource::Configured)
}
fn find_stem(dir: &Path, tag: &str) -> Option<String> {
let mut stems: Vec<String> = std::fs::read_dir(dir)
.ok()?
.flatten()
.filter_map(|entry| {
let path = entry.path();
(path.extension()? == "aff")
.then(|| path.file_stem()?.to_str().map(str::to_string))
.flatten()
})
.collect();
stems.sort();
if let Some(hit) = stems.iter().find(|s| normalise_tag(s) == tag) {
return Some(hit.clone());
}
let primary = tag.split('_').next().unwrap_or(tag);
if let Some(hit) = stems.iter().find(|s| normalise_tag(s) == primary) {
return Some(hit.clone());
}
stems
.iter()
.find(|s| {
normalise_tag(s)
.split('_')
.next()
.is_some_and(|p| p == primary)
})
.cloned()
}
fn complete_pair(
language: &str,
dir: &Path,
stem: &str,
source: PackSource,
) -> Result<ResolvedPack, PackError> {
let aff = dir.join(format!("{stem}.aff"));
let dic = dir.join(format!("{stem}.dic"));
for path in [&aff, &dic] {
if !path.is_file() {
return Err(PackError::Incomplete {
language: language.to_string(),
missing: path.clone(),
});
}
}
Ok(ResolvedPack {
language: language.to_string(),
stem: stem.to_string(),
aff,
dic,
source,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackWarning {
pub path: PathBuf,
pub detail: String,
}
impl fmt::Display for PackWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.path.display(), self.detail)
}
}
#[derive(Debug, Clone)]
pub struct PackReport {
pub pack: ResolvedPack,
pub entries: usize,
pub warnings: Vec<PackWarning>,
}
const COUNT_DRIFT_PERCENT: usize = 5;
const SELFTEST_SAMPLE: usize = 64;
pub fn validate(pack: &ResolvedPack) -> Result<PackReport, PackError> {
let mut warnings = Vec::new();
let aff = read_pack_file(&pack.aff)?;
let dic = read_pack_file(&pack.dic)?;
let mut lines = dic.lines();
let header = lines
.next()
.unwrap_or_default()
.trim_start_matches('\u{feff}');
let declared: usize = header
.split_whitespace()
.next()
.unwrap_or("")
.parse()
.map_err(|_| PackError::Malformed {
path: pack.dic.clone(),
detail: format!(
"the first line should be the entry count, and reads {:?}",
header.chars().take(40).collect::<String>()
),
})?;
let entries: Vec<&str> = lines.filter(|l| !l.trim().is_empty()).collect();
let counted = entries.len();
if declared > 0 {
let gap = counted.abs_diff(declared);
if gap * 100 > declared * COUNT_DRIFT_PERCENT {
warnings.push(PackWarning {
path: pack.dic.clone(),
detail: format!(
"declares {declared} entries and carries {counted}; \
the file may be truncated"
),
});
}
}
if !aff.lines().any(|l| l.trim_start().starts_with("SET ")) {
warnings.push(PackWarning {
path: pack.aff.clone(),
detail: "no SET line, so the encoding is assumed rather than declared".to_string(),
});
}
let dictionary = spellbook::Dictionary::new(&aff, &dic).map_err(|e| PackError::Malformed {
path: pack.aff.clone(),
detail: e.to_string(),
})?;
let step = (counted / SELFTEST_SAMPLE).max(1);
let mut checked = 0usize;
let mut rejected = Vec::new();
for entry in entries.iter().step_by(step).take(SELFTEST_SAMPLE) {
let word = entry.split(['/', '\t']).next().unwrap_or_default().trim();
if word.is_empty() || word.starts_with('#') {
continue;
}
checked += 1;
if !dictionary.check(word) {
rejected.push(word.to_string());
}
}
if checked > 0 && rejected.len() * 2 > checked {
return Err(PackError::Malformed {
path: pack.dic.clone(),
detail: format!(
"the dictionary rejects its own entries ({} of {checked} sampled, \
including {:?}); the affix rules do not match the word list",
rejected.len(),
rejected.iter().take(3).collect::<Vec<_>>()
),
});
}
Ok(PackReport {
pack: pack.clone(),
entries: counted,
warnings,
})
}
fn read_pack_file(path: &Path) -> Result<String, PackError> {
let metadata = std::fs::metadata(path).map_err(|e| PackError::Unreadable {
path: path.to_path_buf(),
detail: e.to_string(),
})?;
if !metadata.is_file() {
return Err(PackError::Unreadable {
path: path.to_path_buf(),
detail: "not a regular file".to_string(),
});
}
if metadata.len() == 0 {
return Err(PackError::Unreadable {
path: path.to_path_buf(),
detail: "the file is empty".to_string(),
});
}
std::fs::read_to_string(path).map_err(|e| PackError::Unreadable {
path: path.to_path_buf(),
detail: if e.kind() == std::io::ErrorKind::InvalidData {
"not valid UTF-8; the pack may use a legacy encoding this build cannot read".to_string()
} else {
e.to_string()
},
})
}
#[cfg(test)]
mod tests {
#[test]
fn the_fingerprint_changes_when_a_pack_appears() {
let dir = std::env::temp_dir().join(format!("lc_packfp_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let registry = PackRegistry::new().with_only_search_paths(vec![dir.clone()]);
let asked = vec!["he".to_string()];
let before = registry.fingerprint(&asked);
std::fs::write(dir.join("he_IL.aff"), "SET UTF-8\n").unwrap();
std::fs::write(dir.join("he_IL.dic"), "1\nword\n").unwrap();
let after = registry.fingerprint(&asked);
assert_ne!(before, after, "a newly installed pack went unnoticed");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_fingerprint_is_stable_while_nothing_changes() {
let dir = std::env::temp_dir().join(format!("lc_packfp_stable_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("he_IL.aff"), "SET UTF-8\n").unwrap();
std::fs::write(dir.join("he_IL.dic"), "1\nword\n").unwrap();
let registry = PackRegistry::new().with_only_search_paths(vec![dir.clone()]);
let asked = vec!["he".to_string()];
assert_eq!(registry.fingerprint(&asked), registry.fingerprint(&asked));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn replacing_a_pack_in_place_counts_as_a_change() {
let dir = std::env::temp_dir().join(format!("lc_packfp_replace_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("he_IL.aff"), "SET UTF-8\n").unwrap();
std::fs::write(dir.join("he_IL.dic"), "1\nword\n").unwrap();
let registry = PackRegistry::new().with_only_search_paths(vec![dir.clone()]);
let asked = vec!["he".to_string()];
let before = registry.fingerprint(&asked);
std::fs::write(dir.join("he_IL.dic"), "2\nword\nanother\n").unwrap();
assert_ne!(before, registry.fingerprint(&asked));
std::fs::remove_dir_all(&dir).ok();
}
use super::*;
fn pack_dir(stems: &[&str], lone: &[&str]) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("temp dir");
for stem in stems {
std::fs::write(dir.path().join(format!("{stem}.aff")), "SET UTF-8\n").unwrap();
std::fs::write(dir.path().join(format!("{stem}.dic")), "1\nword\n").unwrap();
}
for name in lone {
std::fs::write(dir.path().join(name), "").unwrap();
}
dir
}
fn registry(dir: &tempfile::TempDir) -> PackRegistry {
PackRegistry::new().with_only_search_paths(vec![dir.path().to_path_buf()])
}
#[test]
fn an_exact_tag_wins() {
let dir = pack_dir(&["en_GB", "en_US"], &[]);
assert_eq!(registry(&dir).resolve("en-GB").unwrap().stem, "en_GB");
}
#[test]
fn a_bare_tag_finds_the_regional_pack_it_is_shipped_as() {
let dir = pack_dir(&["he_IL"], &[]);
let pack = registry(&dir).resolve("he").unwrap();
assert_eq!(pack.stem, "he_IL");
assert_eq!(pack.language, "he");
}
#[test]
fn a_bare_stem_is_found_for_a_bare_tag() {
let dir = pack_dir(&["la"], &[]);
assert_eq!(registry(&dir).resolve("la").unwrap().stem, "la");
}
#[test]
fn a_bare_stem_beats_a_regional_one_for_a_bare_tag() {
let dir = pack_dir(&["la", "la_LA"], &[]);
assert_eq!(registry(&dir).resolve("la").unwrap().stem, "la");
}
#[test]
fn the_choice_among_regional_packs_is_the_same_every_run() {
let dir = pack_dir(&["en_ZA", "en_AU", "en_CA"], &[]);
for _ in 0..8 {
assert_eq!(registry(&dir).resolve("en").unwrap().stem, "en_AU");
}
}
#[test]
fn a_language_with_no_pack_says_where_it_looked() {
let dir = pack_dir(&["en_GB"], &[]);
let err = registry(&dir).resolve("he").unwrap_err();
assert!(err.is_installable(), "a missing pack is installable");
let message = err.to_string();
assert!(message.contains("\"he\""), "{message}");
assert!(
message.contains(&dir.path().display().to_string()),
"{message}"
);
}
#[test]
fn half_a_pack_is_not_a_missing_one() {
let dir = pack_dir(&[], &["he_IL.aff"]);
let err = registry(&dir).resolve("he").unwrap_err();
assert!(matches!(err, PackError::Incomplete { .. }), "{err}");
assert!(!err.is_installable());
assert!(err.to_string().contains("he_IL.dic"), "{err}");
}
#[test]
fn an_override_beats_every_search_path() {
let installed = pack_dir(&["he_IL"], &[]);
let preferred = pack_dir(&["he_IL"], &[]);
let pack = registry(&installed)
.with_override("he", preferred.path())
.resolve("he")
.unwrap();
assert_eq!(pack.source, PackSource::Configured);
assert!(pack.aff.starts_with(preferred.path()), "{:?}", pack.aff);
}
#[test]
fn an_override_may_name_a_directory_a_stem_or_either_file() {
let dir = pack_dir(&["he_IL"], &[]);
let stem = dir.path().join("he_IL");
for form in [
dir.path().to_path_buf(),
stem.clone(),
stem.with_extension("aff"),
stem.with_extension("dic"),
] {
let pack = PackRegistry::new()
.with_only_search_paths(Vec::new())
.with_override("he", &form)
.resolve("he")
.unwrap_or_else(|e| panic!("override {form:?} did not resolve: {e}"));
assert_eq!(pack.stem, "he_IL");
assert_eq!(pack.source, PackSource::Configured);
}
}
#[test]
fn an_override_pointing_nowhere_reports_the_path_it_was_given() {
let missing = PathBuf::from("/nonexistent/dictionaries/he_IL");
let err = PackRegistry::new()
.with_only_search_paths(Vec::new())
.with_override("he", &missing)
.resolve("he")
.unwrap_err();
assert!(matches!(err, PackError::Incomplete { .. }), "{err}");
assert!(err.to_string().contains("he_IL"), "{err}");
}
#[test]
fn an_earlier_search_path_wins() {
let first = pack_dir(&["he_IL"], &[]);
let second = pack_dir(&["he_IL"], &[]);
let pack = PackRegistry::new()
.with_only_search_paths(vec![
first.path().to_path_buf(),
second.path().to_path_buf(),
])
.resolve("he")
.unwrap();
assert!(pack.aff.starts_with(first.path()));
}
#[test]
fn listing_installed_packs_reports_each_stem_once() {
let first = pack_dir(&["he_IL", "la"], &[]);
let second = pack_dir(&["he_IL", "en_GB"], &[]);
let installed = PackRegistry::new()
.with_only_search_paths(vec![
first.path().to_path_buf(),
second.path().to_path_buf(),
])
.installed();
let stems: Vec<&str> = installed.iter().map(|p| p.stem.as_str()).collect();
assert_eq!(stems, vec!["en_GB", "he_IL", "la"]);
}
#[test]
fn a_missing_directory_is_skipped_rather_than_fatal() {
let dir = pack_dir(&["he_IL"], &[]);
let pack = PackRegistry::new()
.with_only_search_paths(vec![
PathBuf::from("/nonexistent/one"),
dir.path().to_path_buf(),
])
.resolve("he")
.unwrap();
assert_eq!(pack.stem, "he_IL");
}
#[test]
fn tags_compare_without_case_or_separator() {
let dir = pack_dir(&["en_GB"], &[]);
for tag in ["en-GB", "en_gb", "EN-gb", "en_GB"] {
assert_eq!(registry(&dir).resolve(tag).unwrap().stem, "en_GB", "{tag}");
}
}
fn raw_pack(aff: &str, dic: &str) -> (tempfile::TempDir, ResolvedPack) {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("xx.aff"), aff).unwrap();
std::fs::write(dir.path().join("xx.dic"), dic).unwrap();
let pack = ResolvedPack {
language: "xx".to_string(),
stem: "xx".to_string(),
aff: dir.path().join("xx.aff"),
dic: dir.path().join("xx.dic"),
source: PackSource::Managed,
};
(dir, pack)
}
const GOOD_AFF: &str = "SET UTF-8\n";
const GOOD_DIC: &str = "3\nalpha\nbeta\ngamma\n";
#[test]
fn a_sound_pack_validates_without_warnings() {
let (_dir, pack) = raw_pack(GOOD_AFF, GOOD_DIC);
let report = validate(&pack).expect("should validate");
assert_eq!(report.entries, 3);
assert_eq!(
report.warnings,
Vec::new(),
"a sound pack has nothing to report"
);
}
#[test]
fn a_dic_without_its_entry_count_is_malformed() {
let (_dir, pack) = raw_pack(GOOD_AFF, "alpha\nbeta\n");
let err = validate(&pack).unwrap_err();
assert!(matches!(err, PackError::Malformed { .. }), "{err}");
assert!(err.to_string().contains("entry count"), "{err}");
}
#[test]
fn an_affix_file_the_parser_rejects_names_the_file() {
let (_dir, pack) = raw_pack("SET UTF-8\nSFX k Y 129\nSFK k idis idos idis\n", GOOD_DIC);
let err = validate(&pack).unwrap_err();
assert!(matches!(err, PackError::Malformed { .. }), "{err}");
assert!(err.to_string().contains("xx.aff"), "{err}");
}
#[test]
fn a_truncated_dic_is_flagged_without_being_rejected() {
let mut dic = String::from("300\n");
for i in 0..100 {
use std::fmt::Write as _;
let _ = writeln!(dic, "word{i}a");
}
let (_dir, pack) = raw_pack(GOOD_AFF, &dic);
let report = validate(&pack).expect("a short file is still a usable one");
assert_eq!(report.entries, 100);
assert_eq!(report.warnings.len(), 1, "{:?}", report.warnings);
assert!(
report.warnings[0].detail.contains("truncated"),
"{:?}",
report.warnings
);
}
#[test]
fn a_small_count_disagreement_is_not_worth_mentioning() {
let mut dic = String::from("100\n");
for i in 0..99 {
use std::fmt::Write as _;
let _ = writeln!(dic, "word{i}a");
}
let (_dir, pack) = raw_pack(GOOD_AFF, &dic);
assert_eq!(validate(&pack).unwrap().warnings, Vec::new());
}
#[test]
fn an_affix_file_with_no_declared_encoding_is_flagged() {
let (_dir, pack) = raw_pack("# no SET line here\n", GOOD_DIC);
let report = validate(&pack).expect("still usable");
assert!(
report
.warnings
.iter()
.any(|w| w.detail.contains("encoding")),
"{:?}",
report.warnings
);
}
#[test]
fn an_empty_file_is_reported_as_such() {
let (_dir, pack) = raw_pack("", GOOD_DIC);
let err = validate(&pack).unwrap_err();
assert!(matches!(err, PackError::Unreadable { .. }), "{err}");
assert!(err.to_string().contains("empty"), "{err}");
}
#[test]
fn a_directory_where_a_file_belongs_is_reported_as_such() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("xx.aff")).unwrap();
std::fs::write(dir.path().join("xx.dic"), GOOD_DIC).unwrap();
let pack = ResolvedPack {
language: "xx".to_string(),
stem: "xx".to_string(),
aff: dir.path().join("xx.aff"),
dic: dir.path().join("xx.dic"),
source: PackSource::Managed,
};
let err = validate(&pack).unwrap_err();
assert!(err.to_string().contains("not a regular file"), "{err}");
}
#[test]
fn a_missing_file_is_reported_with_its_path() {
let dir = tempfile::tempdir().unwrap();
let pack = ResolvedPack {
language: "xx".to_string(),
stem: "xx".to_string(),
aff: dir.path().join("gone.aff"),
dic: dir.path().join("gone.dic"),
source: PackSource::Managed,
};
let err = validate(&pack).unwrap_err();
assert!(matches!(err, PackError::Unreadable { .. }), "{err}");
assert!(err.to_string().contains("gone.aff"), "{err}");
}
#[test]
fn a_non_utf8_file_says_so_rather_than_failing_obscurely() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("xx.aff"),
[0x53, 0x45, 0x54, 0x20, 0xff, 0xfe],
)
.unwrap();
std::fs::write(dir.path().join("xx.dic"), GOOD_DIC).unwrap();
let pack = ResolvedPack {
language: "xx".to_string(),
stem: "xx".to_string(),
aff: dir.path().join("xx.aff"),
dic: dir.path().join("xx.dic"),
source: PackSource::Managed,
};
let err = validate(&pack).unwrap_err();
assert!(err.to_string().contains("UTF-8"), "{err}");
}
#[test]
fn a_dictionary_that_rejects_its_own_entries_is_malformed() {
let aff = "SET UTF-8\nFORBIDDENWORD X\n";
let dic = "3\nalpha/X\nbeta/X\ngamma/X\n";
let (_dir, pack) = raw_pack(aff, dic);
let err = validate(&pack).unwrap_err();
assert!(matches!(err, PackError::Malformed { .. }), "{err}");
assert!(err.to_string().contains("its own entries"), "{err}");
}
#[test]
fn a_byte_order_mark_does_not_hide_the_entry_count() {
let (_dir, pack) = raw_pack(GOOD_AFF, "\u{feff}3\nalpha\nbeta\ngamma\n");
assert_eq!(validate(&pack).expect("BOM is not corruption").entries, 3);
}
}