use std::path::{Path, PathBuf};
use lindera::LinderaResult;
use lindera::error::LinderaErrorKind;
pub(crate) const DOWNLOADABLE_DICTIONARIES: [&str; 6] = [
"ipadic",
"ipadic-neologd",
"unidic",
"ko-dic",
"cc-cedict",
"jieba",
];
pub(crate) const REQUIRED_DICTIONARY_FILES: [&str; 9] = [
"metadata.json",
"char_def.bin",
"matrix.mtx",
"dict.trie",
"dict.valsidx",
"dict.vals",
"dict.wordsidx",
"dict.words",
"unk.bin",
];
pub(crate) const DATA_DIR_ENV: &str = "LINDERA_DATA_DIR";
pub(crate) fn is_downloadable_dictionary(name: &str) -> bool {
DOWNLOADABLE_DICTIONARIES.contains(&name)
}
pub(crate) fn download_url(name: &str, version: &str) -> String {
format!(
"https://github.com/lindera/lindera/releases/download/v{version}/lindera-{name}-{version}.zip"
)
}
pub(crate) fn base_data_dir(env_override: Option<PathBuf>) -> LinderaResult<PathBuf> {
if let Some(dir) = env_override {
return Ok(dir);
}
dirs::data_local_dir()
.map(|dir| dir.join("lindera"))
.ok_or_else(|| {
LinderaErrorKind::Io.with_error(anyhow::anyhow!(
"could not determine the application data directory; set {DATA_DIR_ENV} to override it"
))
})
}
pub(crate) fn dictionary_dir(base: &Path, version: &str, name: &str) -> PathBuf {
base.join("dictionaries")
.join(version)
.join(format!("lindera-{name}"))
}
pub(crate) fn missing_dictionary_files(dir: &Path) -> Vec<&'static str> {
REQUIRED_DICTIONARY_FILES
.iter()
.copied()
.filter(|file| !dir.join(file).is_file())
.collect()
}
pub(crate) fn is_complete_dictionary_dir(dir: &Path) -> bool {
missing_dictionary_files(dir).is_empty()
}
pub(crate) fn resolve_dictionary_arg(
dict: &str,
env_override: Option<PathBuf>,
version: &str,
) -> LinderaResult<String> {
if dict.contains("://") || Path::new(dict).exists() || !is_downloadable_dictionary(dict) {
return Ok(dict.to_string());
}
let dir = dictionary_dir(&base_data_dir(env_override)?, version, dict);
let missing = missing_dictionary_files(&dir);
if missing.is_empty() {
return dir.into_os_string().into_string().map_err(|path| {
LinderaErrorKind::Io.with_error(anyhow::anyhow!(
"dictionary path is not valid UTF-8: {}",
PathBuf::from(path).display()
))
});
}
if dir.is_dir() {
Err(LinderaErrorKind::Content.with_error(anyhow::anyhow!(
"dictionary '{dict}' at {} is incomplete (missing: {}). Run 'lindera download {dict} --force' to re-download it.",
dir.display(),
missing.join(", ")
)))
} else {
Err(LinderaErrorKind::NotFound.with_error(anyhow::anyhow!(
"dictionary '{dict}' is not downloaded (looked in {}). Run 'lindera download {dict}' to fetch it.",
dir.display()
)))
}
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
fn create_files(dir: &Path, files: &[&str]) {
fs::create_dir_all(dir).unwrap();
for file in files {
fs::write(dir.join(file), b"test").unwrap();
}
}
#[test]
fn download_url_builds_expected_release_url() {
assert_eq!(
download_url("ipadic", "5.1.0"),
"https://github.com/lindera/lindera/releases/download/v5.1.0/lindera-ipadic-5.1.0.zip"
);
}
#[test]
fn downloadable_dictionary_names_are_accepted() {
for name in DOWNLOADABLE_DICTIONARIES {
assert!(
is_downloadable_dictionary(name),
"{name} should be accepted"
);
}
}
#[test]
fn invalid_dictionary_names_are_rejected() {
for name in ["IPADIC", "", "ipadic2", "embedded://ipadic"] {
assert!(
!is_downloadable_dictionary(name),
"{name} should be rejected"
);
}
}
#[test]
fn base_data_dir_prefers_env_override() {
let dir = base_data_dir(Some(PathBuf::from("/custom/data"))).unwrap();
assert_eq!(dir, PathBuf::from("/custom/data"));
}
#[test]
fn base_data_dir_defaults_to_local_data_dir() {
if let Some(expected) = dirs::data_local_dir() {
assert_eq!(base_data_dir(None).unwrap(), expected.join("lindera"));
}
}
#[test]
fn dictionary_dir_uses_versioned_layout() {
assert_eq!(
dictionary_dir(Path::new("/base"), "5.1.0", "ipadic"),
PathBuf::from("/base/dictionaries/5.1.0/lindera-ipadic")
);
}
#[test]
fn missing_dictionary_files_reports_all_when_dir_is_absent() {
let tmp = tempfile::tempdir().unwrap();
let missing = missing_dictionary_files(&tmp.path().join("nonexistent"));
assert_eq!(missing, REQUIRED_DICTIONARY_FILES.to_vec());
}
#[test]
fn missing_dictionary_files_reports_only_absent_files() {
let tmp = tempfile::tempdir().unwrap();
create_files(tmp.path(), &["metadata.json", "char_def.bin"]);
let missing = missing_dictionary_files(tmp.path());
assert!(!missing.contains(&"metadata.json"));
assert!(!missing.contains(&"char_def.bin"));
assert!(missing.contains(&"matrix.mtx"));
assert!(missing.contains(&"unk.bin"));
}
#[test]
fn complete_dictionary_dir_is_detected() {
let tmp = tempfile::tempdir().unwrap();
create_files(tmp.path(), &REQUIRED_DICTIONARY_FILES);
assert!(is_complete_dictionary_dir(tmp.path()));
assert!(missing_dictionary_files(tmp.path()).is_empty());
}
#[test]
fn resolve_passes_uri_through_unchanged() {
let resolved = resolve_dictionary_arg("embedded://ipadic", None, "5.1.0").unwrap();
assert_eq!(resolved, "embedded://ipadic");
}
#[test]
fn resolve_prefers_existing_filesystem_path() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("ipadic");
fs::create_dir_all(&path).unwrap();
let arg = path.to_str().unwrap();
let resolved = resolve_dictionary_arg(arg, None, "5.1.0").unwrap();
assert_eq!(resolved, arg);
}
#[test]
fn resolve_returns_downloaded_dictionary_path() {
let tmp = tempfile::tempdir().unwrap();
let dir = dictionary_dir(tmp.path(), "5.1.0", "ipadic");
create_files(&dir, &REQUIRED_DICTIONARY_FILES);
let resolved =
resolve_dictionary_arg("ipadic", Some(tmp.path().to_path_buf()), "5.1.0").unwrap();
assert_eq!(resolved, dir.to_str().unwrap());
}
#[test]
fn resolve_suggests_download_when_not_downloaded() {
let tmp = tempfile::tempdir().unwrap();
let err =
resolve_dictionary_arg("ipadic", Some(tmp.path().to_path_buf()), "5.1.0").unwrap_err();
let message = format!("{err}");
assert!(message.contains("lindera download ipadic"), "{message}");
}
#[test]
fn resolve_suggests_force_when_incomplete() {
let tmp = tempfile::tempdir().unwrap();
let dir = dictionary_dir(tmp.path(), "5.1.0", "ipadic");
create_files(&dir, &["metadata.json"]);
let err =
resolve_dictionary_arg("ipadic", Some(tmp.path().to_path_buf()), "5.1.0").unwrap_err();
let message = format!("{err}");
assert!(
message.contains("lindera download ipadic --force"),
"{message}"
);
assert!(message.contains("unk.bin"), "{message}");
}
#[test]
fn resolve_passes_unknown_name_through_unchanged() {
let resolved = resolve_dictionary_arg("no-such-dictionary", None, "5.1.0").unwrap();
assert_eq!(resolved, "no-such-dictionary");
}
}