use serde::{Deserialize, Serialize};
use crate::{
error::{LogoxideError, Result},
manifest::{Direction, PackageKind},
};
pub const EBIBLE_CATALOG: &str = "https://ebible.org/Scriptures/translations.csv";
pub const FETCH_BIBLE_CATALOG: &str = "https://v1.fetch.bible/manifest.json";
pub const CCEL_CATALOG: &str = "https://ccel.org/ccel/bible/";
pub const CCEL_COMMENTARIES_CATALOG: &str = "https://ccel.org/index/genre/gen-COM";
pub const SWORD_CATALOG: &str = "https://crosswire.org/ftpmirror/pub/sword/raw/mods.d.tar.gz";
pub const DEFAULT_CATALOGS: [&str; 5] = [
EBIBLE_CATALOG,
FETCH_BIBLE_CATALOG,
CCEL_CATALOG,
CCEL_COMMENTARIES_CATALOG,
SWORD_CATALOG,
];
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CatalogSource {
Git { url: String },
Registry { catalog: String },
External {
import: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
upstream: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
tracks_versions: bool,
downloads: Vec<Download>,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Download {
pub name: String,
pub urls: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CatalogRelease {
pub version: String,
pub url: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mirrors: Vec<String>,
pub sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub yanked: bool,
}
impl CatalogSource {
pub fn prefix(&self) -> Option<&str> {
Some(self.import_name()?.split_once(':')?.0)
}
pub fn import_id(&self) -> Option<&str> {
Some(self.import_name()?.split_once(':')?.1)
}
pub fn versions_track_updates(&self) -> bool {
matches!(
self,
CatalogSource::External {
tracks_versions: true,
..
}
)
}
pub fn import_name(&self) -> Option<&str> {
match self {
CatalogSource::External { import, .. } => Some(import),
CatalogSource::Git { .. } | CatalogSource::Registry { .. } => None,
}
}
pub fn upstream_url(&self) -> Option<&str> {
match self {
CatalogSource::External { upstream, .. } => upstream.as_deref(),
CatalogSource::Git { .. } | CatalogSource::Registry { .. } => None,
}
}
pub fn downloads(&self) -> &[Download] {
match self {
CatalogSource::External { downloads, .. } => downloads,
CatalogSource::Git { .. } | CatalogSource::Registry { .. } => &[],
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CatalogEntry {
pub id: String,
pub name: String,
pub abbreviation: Option<String>,
pub aliases: Vec<String>,
pub language: String,
pub language_name: Option<String>,
pub script: Option<String>,
pub description: Option<String>,
pub license: Option<String>,
pub rights: crate::license::Rights,
pub copyright: Option<String>,
pub version: Option<String>,
pub kind: PackageKind,
pub direction: Direction,
pub source: CatalogSource,
pub path: Option<String>,
pub releases: Vec<CatalogRelease>,
pub keywords: Vec<String>,
pub categories: Vec<String>,
pub about_books: Vec<String>,
pub work: Option<String>,
pub edition: Option<String>,
pub edition_year: Option<i32>,
pub keys: Option<crate::manifest::Keys>,
}
impl CatalogEntry {
pub fn text_rights(&self) -> crate::license::Rights {
(self.rights).or(crate::license::derived_rights(self.license.as_deref()))
}
pub fn in_language(&self, wanted: &str) -> bool {
let wanted = fold(wanted.trim());
crate::language::matches_range(&wanted, &self.language)
|| (self.language_name.as_deref())
.into_iter()
.chain(crate::language::language_name(&self.language))
.any(|name| fold(name) == wanted)
}
pub fn short_name(&self) -> &str {
self.abbreviation.as_deref().unwrap_or(&self.id)
}
pub fn language_shown(&self) -> &str {
(self.language_name.as_deref())
.or_else(|| crate::language::language_name(&self.language))
.unwrap_or(&self.language)
}
pub fn is_about(&self, book: &str) -> bool {
self.about_books.iter().any(|b| b == book)
}
pub fn release(&self, req: Option<&semver::VersionReq>) -> Option<&CatalogRelease> {
(self.releases.iter())
.filter(|r| !r.yanked)
.filter_map(|r| Some((r.version.parse::<semver::Version>().ok()?, r)))
.filter(|(v, _)| match req {
Some(req) => req.matches(v),
None => v.pre.is_empty(),
})
.max_by(|a, b| a.0.cmp(&b.0))
.map(|(_, r)| r)
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct TomlEntry {
id: String,
name: String,
#[serde(default)]
abbreviation: Option<String>,
#[serde(default)]
aliases: Vec<String>,
#[serde(default)]
language: Option<String>,
#[serde(default)]
language_name: Option<String>,
#[serde(default)]
script: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
license: Option<String>,
#[serde(default)]
rights: crate::license::Rights,
#[serde(default)]
kind: PackageKind,
#[serde(default)]
direction: Option<Direction>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
git: Option<String>,
#[serde(default, rename = "release")]
releases: Vec<CatalogRelease>,
#[serde(default)]
keywords: Vec<String>,
#[serde(default)]
categories: Vec<String>,
#[serde(default)]
about_books: Vec<String>,
#[serde(default)]
work: Option<String>,
#[serde(default)]
edition: Option<String>,
#[serde(default)]
edition_year: Option<i32>,
#[serde(default)]
keys: Option<crate::manifest::Keys>,
#[serde(default)]
path: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
struct TomlCatalog {
#[serde(default)]
format: Option<u32>,
#[serde(default)]
name: Option<String>,
#[serde(default, rename = "package")]
packages: Vec<TomlEntry>,
#[serde(default)]
works: std::collections::BTreeMap<String, crate::works::Work>,
}
pub const CATALOG_FORMAT: u32 = 1;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Catalog {
pub name: String,
pub entries: Vec<CatalogEntry>,
pub works: Vec<crate::works::DeclaredWork>,
pub warnings: Vec<String>,
}
impl Catalog {
pub fn parse(location: &str, text: &str) -> Result<Catalog> {
Catalog::parse_toml_at(Some(location), text)
}
pub fn parse_toml(text: &str) -> Result<Catalog> {
Catalog::parse_toml_at(None, text)
}
pub fn parse_toml_at(location: Option<&str>, text: &str) -> Result<Catalog> {
let catalog: TomlCatalog =
toml::from_str(text).map_err(|e| LogoxideError::Catalog(e.to_string()))?;
let format = catalog.format.unwrap_or(1);
if format > CATALOG_FORMAT {
return Err(LogoxideError::Catalog(format!(
"{}is format {format}, newer than this lx reads ({CATALOG_FORMAT}): upgrade lx",
location.map(|l| format!("{l} ")).unwrap_or_default()
)));
}
let remote = location.is_some_and(|l| !crate::git::is_local(l));
let check = |id: &str, url: &str| match remote && crate::git::is_local(url) {
true => Err(LogoxideError::Catalog(format!(
"{id}: `{url}` is on this machine, but the catalog isn't"
))),
false => Ok(()),
};
let (mut entries, mut warnings) = (vec![], vec![]);
for e in catalog.packages {
let at = || location.map(|l| format!("{l}: ")).unwrap_or_default();
if let Err(LogoxideError::Manifest(why)) = crate::manifest::check_id("package", &e.id) {
warnings.push(format!("{}passed over {why}", at()));
continue;
}
if e.abbreviation.is_none() && e.kind.needs_abbreviation() {
warnings.push(format!(
"{}passed over {}: a {} needs an abbreviation",
at(),
e.id,
e.kind.name()
));
continue;
}
let language = match e.language {
Some(language) => language,
None if !e.kind.needs_language() => String::from(crate::language::UNDETERMINED),
None => {
warnings.push(format!(
"{}passed over {}: a {} needs a language",
at(),
e.id,
e.kind.name()
));
continue;
}
};
if let Some(git) = &e.git {
check(&e.id, git)?;
}
let source = match &e.git {
Some(git) => CatalogSource::Git {
url: crate::git::expand_url(git),
},
None if e.releases.is_empty() => {
return Err(LogoxideError::Catalog(format!(
"{}: no `git` and no releases",
e.id
)));
}
None => CatalogSource::Registry {
catalog: location.unwrap_or_default().to_string(),
},
};
let releases = (e.releases.into_iter())
.map(|r| {
let whole = |url: &String| match location {
Some(base) => relative_url(base, url),
None => url.clone(),
};
CatalogRelease {
url: whole(&r.url),
mirrors: r.mirrors.iter().map(whole).collect(),
..r
}
})
.collect::<Vec<CatalogRelease>>();
for release in &releases {
for url in std::iter::once(&release.url).chain(&release.mirrors) {
check(&e.id, url)?;
}
}
let script = (e.script.clone()).or_else(|| crate::language::script_of_tag(&language));
entries.push(CatalogEntry {
id: e.id,
name: e.name,
abbreviation: e.abbreviation,
aliases: e.aliases,
language,
language_name: e.language_name,
direction: (e.direction)
.unwrap_or_else(|| crate::language::script_direction(script.as_deref())),
script,
description: e.description,
license: e.license,
rights: e.rights,
copyright: None,
version: e.version,
kind: e.kind,
source,
path: e.path,
releases,
keywords: e.keywords,
categories: e.categories,
about_books: e.about_books,
work: e.work,
edition: e.edition,
edition_year: e.edition_year,
keys: e.keys,
});
}
let name = catalog.name.unwrap_or_default();
let named_by = match (name.as_str(), location) {
("", Some(location)) => location,
(name, _) => name,
};
let (works, passed_over) = crate::works::parse_declared(named_by, location, catalog.works);
warnings.extend(passed_over);
Ok(Catalog {
name,
entries,
works,
warnings,
})
}
pub fn search_grouped(&self, query: &str) -> Vec<WorkGroup<'_>> {
self.group_by_work(self.search(query))
}
pub fn rename(&mut self, name: &str) {
self.name = name.to_string();
for declared in &mut self.works {
declared.catalog = name.to_string();
}
}
pub fn work(&self, id: &str) -> Option<&crate::works::Work> {
crate::works::get(id).or_else(|| (self.works.iter()).map(|d| &d.work).find(|w| w.id == id))
}
pub fn group_by_work<'a>(
&self,
entries: impl IntoIterator<Item = &'a CatalogEntry>,
) -> Vec<WorkGroup<'a>> {
let mut groups: Vec<WorkGroup<'a>> = vec![];
for entry in entries {
match (entry.work.as_ref())
.and_then(|work| groups.iter_mut().find(|g| g.work.as_ref() == Some(work)))
{
Some(group) => group.entries.push(entry),
None => groups.push(WorkGroup {
work: entry.work.clone(),
name: (entry.work.as_deref())
.map(|id| self.work(id).map_or(id, |w| w.name.as_str()).to_string()),
entries: vec![entry],
}),
}
}
groups
}
pub fn group_by_language<'a>(
entries: impl IntoIterator<Item = &'a CatalogEntry>,
) -> Vec<LanguageGroup<'a>> {
let mut groups: Vec<LanguageGroup<'a>> = vec![];
for entry in entries {
let language = crate::language::primary_language(&entry.language);
let named = (language == "und")
.then(|| entry.language_name.as_deref().map(fold))
.flatten();
match (groups.iter_mut()).find(|g| g.language == language && g.key == named) {
Some(group) => {
if group.name.is_none() {
group.name = entry.language_name.clone();
}
group.entries.push(entry);
}
None => groups.push(LanguageGroup {
name: (crate::language::language_name(&language).map(String::from))
.or_else(|| entry.language_name.clone()),
language,
key: named,
entries: vec![entry],
}),
}
}
groups
}
pub fn works_listing(&self, query: &str) -> Vec<WorkListing<'_>> {
let words: Vec<String> = query.split_whitespace().map(fold).collect();
let claiming = |id: &str| -> Vec<&CatalogEntry> {
(self.entries.iter())
.filter(|e| e.work.as_deref() == Some(id))
.collect()
};
let mut listed: Vec<WorkListing<'_>> = (crate::works::works().iter())
.map(|w| (w, WorkFrom::Logoxide))
.chain((self.works.iter()).map(|d| (&d.work, WorkFrom::Catalog(d.catalog.clone()))))
.map(|(work, from)| WorkListing {
id: work.id.clone(),
name: Some(work.name.clone()),
aliases: work.aliases.clone(),
wikidata: work.wikidata.clone(),
from,
entries: claiming(&work.id),
})
.collect();
for entry in &self.entries {
if let Some(id) = &entry.work
&& !listed.iter().any(|l| &l.id == id)
{
listed.push(WorkListing {
id: id.clone(),
name: None,
aliases: vec![],
wikidata: None,
from: WorkFrom::Unnamed,
entries: claiming(id),
});
}
}
listed.retain(|l| {
let names: Vec<String> = std::iter::once(&l.id)
.chain(&l.name)
.chain(&l.aliases)
.map(|n| fold(n))
.collect();
(words.iter()).all(|w| names.iter().any(|n| n.contains(w.as_str())))
});
listed
}
pub fn add_works(&mut self, works: Vec<crate::works::DeclaredWork>) {
for declared in works {
match (self.works.iter()).find(|d| d.work.id == declared.work.id) {
Some(kept) if kept.work != declared.work => self.warnings.push(format!(
"work `{}` is named by {} and {}: {}'s is used",
declared.work.id, kept.catalog, declared.catalog, kept.catalog
)),
Some(_) => {}
None => self.works.push(declared),
}
}
}
pub fn search(&self, query: &str) -> Vec<&CatalogEntry> {
let words: Vec<(String, Option<String>)> = (query.split_whitespace())
.map(|word| (fold(word), crate::manifest::book_code(word)))
.collect();
let mut found: Vec<(u32, &CatalogEntry)> = (self.entries.iter())
.filter_map(|entry| {
let work = (entry.work.as_deref()).and_then(|id| self.work(id));
Some((score(entry, work, &words)?, entry))
})
.collect();
found.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name)));
found.into_iter().map(|(_, e)| e).collect()
}
pub fn find_import(&self, name: &str) -> Option<&CatalogEntry> {
(self.entries.iter()).find(|e| e.source.import_name() == Some(name))
}
pub fn find_upstream(&self, upstream: &crate::manifest::Upstream) -> Option<&CatalogEntry> {
(upstream.import.as_deref())
.and_then(|name| self.find_import(name.trim()))
.or_else(|| {
let url = upstream.url.as_deref()?.trim();
(self.entries.iter()).find(|e| e.source.upstream_url() == Some(url))
})
}
pub fn from_packages(
name: &str,
base_url: &str,
packages: Vec<(String, crate::manifest::Manifest)>,
) -> Catalog {
let base = base_url.trim_end_matches('/');
let mut entries: Vec<CatalogEntry> = (packages.into_iter())
.filter_map(|(path, manifest)| {
let info = manifest.package?;
Some(CatalogEntry {
script: info.text_script(),
direction: info.text_direction(),
id: info.id,
name: info.name,
abbreviation: info.abbreviation,
aliases: info.aliases,
language: info.language,
language_name: info.language_name,
description: info.description,
license: info.license,
rights: info.rights.unwrap_or_default(),
copyright: None,
version: Some(info.version.to_string()),
kind: info.kind,
source: CatalogSource::Git {
url: format!("{base}/{path}"),
},
path: Some(path),
releases: vec![],
keywords: info.keywords,
categories: info.categories,
about_books: info.about_books,
work: info.work,
edition: info.edition,
edition_year: info.edition_year,
keys: info.keys,
})
})
.collect();
entries.sort_by(|a, b| a.path.cmp(&b.path));
Catalog {
name: name.to_string(),
entries,
works: vec![],
warnings: vec![],
}
}
pub fn find(&self, name: &str) -> Result<Option<&CatalogEntry>> {
let name = name.trim_matches('/');
if let Some(entry) = (self.entries.iter()).find(|e| e.path.as_deref() == Some(name)) {
return Ok(Some(entry));
}
let found: Vec<&CatalogEntry> = (self.entries.iter()).filter(|e| e.id == name).collect();
match found.as_slice() {
[] => Ok(None),
[entry] => Ok(Some(entry)),
many => {
let paths: Vec<&str> = (many.iter())
.map(|e| e.path.as_deref().unwrap_or("?"))
.collect();
Err(LogoxideError::Catalog(format!(
"{} packages are `{name}` in {}: name one by its path ({})",
many.len(),
self.name,
paths.join(", ")
)))
}
}
}
pub fn shadowed(catalogs: &[Catalog]) -> Vec<(String, Vec<String>)> {
let mut listed: indexmap::IndexMap<&str, Vec<String>> = indexmap::IndexMap::new();
for catalog in catalogs {
let mut seen = std::collections::BTreeSet::new();
for entry in &catalog.entries {
if seen.insert(entry.id.as_str()) {
listed
.entry(entry.id.as_str())
.or_default()
.push(catalog.name.clone());
}
}
}
(listed.into_iter())
.filter(|(_, names)| names.len() > 1)
.map(|(id, names)| (id.to_string(), names))
.collect()
}
pub fn merge(mut self, other: Catalog) -> Catalog {
for entry in other.entries {
if !self.entries.iter().any(|e| e.id == entry.id) {
self.entries.push(entry);
}
}
self.warnings.extend(other.warnings);
self.add_works(other.works);
self
}
pub fn toml_entry(info: &crate::manifest::Package, git: &str) -> String {
Catalog::toml_document(None, vec![Catalog::entry_table(info, git)])
}
pub fn toml_document(name: Option<&str>, tables: Vec<toml_edit::Table>) -> String {
let mut doc = toml_edit::DocumentMut::new();
doc.insert("format", toml_edit::value(i64::from(CATALOG_FORMAT)));
if let Some(name) = name {
doc.insert("name", toml_edit::value(name));
}
let mut packages = toml_edit::ArrayOfTables::new();
for table in tables {
packages.push(table);
}
doc.insert("package", toml_edit::Item::ArrayOfTables(packages));
doc.to_string()
}
pub fn entry_table(info: &crate::manifest::Package, git: &str) -> toml_edit::Table {
let mut table = toml_edit::Table::new();
table.insert("id", toml_edit::value(&info.id));
table.insert("name", toml_edit::value(&info.name));
if let Some(abbreviation) = &info.abbreviation {
table.insert("abbreviation", toml_edit::value(abbreviation));
}
if !info.aliases.is_empty() {
let aliases: toml_edit::Array = info.aliases.iter().map(String::as_str).collect();
table.insert("aliases", toml_edit::value(aliases));
}
table.insert("language", toml_edit::value(&info.language));
if let Some(name) = &info.language_name {
table.insert("language-name", toml_edit::value(name));
}
if let Some(script) = &info.text_script() {
table.insert("script", toml_edit::value(script));
}
if let Some(description) = &info.description {
table.insert("description", toml_edit::value(description));
}
table.insert("version", toml_edit::value(info.version.to_string()));
if let Some(license) = &info.license {
table.insert("license", toml_edit::value(license));
}
if let Some(rights) = info.rights.filter(|r| !r.is_empty())
&& let Ok(toml::Value::Table(said)) = toml::Value::try_from(rights)
{
let mut inline = toml_edit::InlineTable::new();
for (key, value) in said {
let value = match value {
toml::Value::Boolean(b) => toml_edit::Value::from(b),
other => toml_edit::Value::from(other.as_str().unwrap_or_default()),
};
inline.insert(&key, value);
}
table.insert("rights", toml_edit::value(inline));
}
if info.kind != PackageKind::Bible {
let kind = toml::Value::try_from(info.kind)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default();
table.insert("kind", toml_edit::value(kind));
}
if info.text_direction() == Direction::Rtl {
table.insert("direction", toml_edit::value("rtl"));
}
for (key, value) in [("work", &info.work), ("edition", &info.edition)] {
if let Some(value) = value {
table.insert(key, toml_edit::value(value));
}
}
if let Some(year) = info.edition_year {
table.insert("edition-year", toml_edit::value(i64::from(year)));
}
if let Some(keys) = info.keys {
table.insert("keys", toml_edit::value(keys.name()));
}
for (key, list) in [
("keywords", &info.keywords),
("categories", &info.categories),
("about-books", &info.about_books),
] {
if !list.is_empty() {
let list: toml_edit::Array = list.iter().map(String::as_str).collect();
table.insert(key, toml_edit::value(list));
}
}
table.insert("git", toml_edit::value(git));
table
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkGroup<'a> {
pub work: Option<String>,
pub name: Option<String>,
pub entries: Vec<&'a CatalogEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LanguageGroup<'a> {
pub language: String,
pub name: Option<String>,
key: Option<String>,
pub entries: Vec<&'a CatalogEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WorkFrom {
Logoxide,
Catalog(String),
Unnamed,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkListing<'a> {
pub id: String,
pub name: Option<String>,
pub aliases: Vec<String>,
pub wikidata: Option<String>,
pub from: WorkFrom,
pub entries: Vec<&'a CatalogEntry>,
}
pub fn known_works(entries: &mut [CatalogEntry]) {
for entry in entries {
if entry.work.is_none() {
entry.work = crate::works::of_source(&entry.source);
}
}
}
pub fn relative_url(base: &str, url: &str) -> String {
if url.contains("://") || url.starts_with('/') {
return url.to_string();
}
match base.rfind('/') {
Some(slash) => format!("{}/{url}", &base[..slash]),
None => url.to_string(),
}
}
pub const RESERVED_REGISTRY_NAMES: [&str; 15] = [
"github", "gitlab", "codeberg", "ebible", "fetch", "ccel", "sword", "door43", "burrito", "dbl",
"http", "https", "file", "ssh", "git",
];
pub fn check_registry_name(name: &str) -> Result<()> {
let valid = !name.is_empty()
&& (name.chars()).all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "-_".contains(c));
if !valid {
return Err(LogoxideError::Catalog(format!(
"registry name `{name}`: use lowercase letters, digits, `-`, and `_`"
)));
}
if RESERVED_REGISTRY_NAMES.contains(&name) {
return Err(LogoxideError::Catalog(format!(
"registry name `{name}` is taken (it starts URLs or other names)"
)));
}
Ok(())
}
pub fn split_registry_name<'a>(
text: &'a str,
registries: &std::collections::BTreeMap<String, String>,
) -> Option<(&'a str, &'a str)> {
let (registry, name) = text.split_once(':')?;
(registries.contains_key(registry) && !name.is_empty()).then_some((registry, name))
}
fn fold(text: &str) -> String {
use unicode_normalization::{UnicodeNormalization, char::is_combining_mark};
text.to_lowercase()
.nfd()
.filter(|c| !is_combining_mark(*c))
.map(|c| if c == 'ς' { 'σ' } else { c })
.nfc()
.collect()
}
fn score(
entry: &CatalogEntry,
work: Option<&crate::works::Work>,
words: &[(String, Option<String>)],
) -> Option<u32> {
if words.is_empty() {
return Some(0);
}
let exact = [fold(entry.short_name()), fold(&entry.id)];
let names: Vec<String> = [&entry.name]
.into_iter()
.chain(&entry.aliases)
.chain(&entry.language_name)
.chain(&entry.keywords)
.chain(work.map(|w| &w.name))
.chain(work.into_iter().flat_map(|w| &w.aliases))
.map(|s| fold(s))
.chain(crate::language::search_codes(&entry.language))
.chain(entry.categories.iter().map(|c| fold(&c.replace('-', " "))))
.collect();
let description = entry.description.as_deref().map(fold).unwrap_or_default();
let mut total = 0;
for (word, book) in words {
let about = book.as_ref().is_some_and(|b| entry.about_books.contains(b));
total += if exact.iter().any(|e| e == word) {
100
} else if exact.iter().any(|e| e.contains(word.as_str())) {
40
} else if about
|| names
.iter()
.any(|n| n.split_whitespace().any(|w| w == word))
{
30
} else if names.iter().any(|n| n.contains(word.as_str())) {
20
} else if description.contains(word.as_str()) {
5
} else {
return None;
};
}
Some(total)
}
#[cfg(test)]
mod tests {
use super::*;
fn ebible() -> Catalog {
let entry = |id: &str, name: &str, abbreviation: &str, language: &str, name_of: &str| {
let import = format!("ebible:{id}");
CatalogEntry {
id: format!("ebible-{id}"),
name: name.into(),
abbreviation: Some(abbreviation.into()),
aliases: vec![],
language: language.into(),
language_name: Some(name_of.into()),
script: None,
description: None,
license: Some("LicenseRef-eBible".into()),
rights: Default::default(),
copyright: None,
version: Some("2026-10-08".into()),
kind: PackageKind::Bible,
direction: Direction::Ltr,
source: CatalogSource::External {
upstream: Some(format!("https://ebible.org/Scriptures/{id}_usfm.zip")),
import,
tracks_versions: true,
downloads: vec![],
},
path: None,
releases: vec![],
keywords: vec![],
categories: vec![],
about_books: vec![],
work: None,
edition: None,
edition_year: None,
keys: None,
}
};
let mut entries = vec![
entry(
"eng-asv",
"American Standard Version (1901)",
"ASV",
"en",
"English",
),
entry(
"hebwlc",
"Westminster Leningrad Codex",
"WLC",
"he",
"Hebrew",
),
];
known_works(&mut entries);
Catalog {
name: String::from("eBible.org"),
entries,
works: vec![],
warnings: vec![],
}
}
#[test]
fn entries_are_found_and_grouped_by_language() {
let mut catalog = ebible();
let mut us = catalog.entries[0].clone();
us.id = String::from("us");
us.language = String::from("en-US");
us.language_name = None;
let mut odd = catalog.entries[0].clone();
odd.id = String::from("odd");
odd.language = String::from("und");
odd.language_name = Some(String::from("Ancient Ones"));
let mut korean = catalog.entries[0].clone();
korean.id = String::from("korean");
korean.language = String::from("ko");
korean.language_name = None;
catalog.entries.extend([us, odd, korean]);
let ids = |wanted: &str| -> Vec<&str> {
(catalog.entries.iter())
.filter(|e| e.in_language(wanted))
.map(|e| e.id.as_str())
.collect()
};
assert_eq!(ids("en"), ["ebible-eng-asv", "us"]);
assert_eq!(ids("eng"), ["ebible-eng-asv", "us"]);
assert_eq!(ids("en-US"), ["us"]);
assert_eq!(ids("hebrew"), ["ebible-hebwlc"]);
assert_eq!(ids("ancient ones"), ["odd"]);
assert_eq!(ids("korean"), ["korean"]);
assert_eq!(catalog.entries[4].language_shown(), "Korean");
assert_eq!(catalog.entries[3].language_shown(), "Ancient Ones");
let groups = Catalog::group_by_language(&catalog.entries);
let summary: Vec<(&str, Option<&str>, usize)> = (groups.iter())
.map(|g| (g.language.as_str(), g.name.as_deref(), g.entries.len()))
.collect();
assert_eq!(
summary,
[
("en", Some("English"), 2),
("he", Some("Hebrew"), 1),
("und", Some("Ancient Ones"), 1),
("ko", Some("Korean"), 1)
]
);
}
#[test]
fn catalogs_name_works_of_their_own() {
let mine = Catalog::parse_toml_at(
Some("https://example.org/catalog.toml"),
r#"
name = "mine"
[works.gill]
name = "John Gill's Exposition of the Bible"
aliases = ["Gill's Exposition"]
wikidata = "Q42"
[works.kjv]
name = "Something else"
[[package]]
id = "gill-1810"
name = "Gill, 1810"
abbreviation = "GILL"
language = "en"
kind = "commentary"
work = "gill"
git = "https://example.org/gill"
[[package]]
id = "gill-modern"
name = "Gill, modernized"
abbreviation = "GILLM"
language = "en"
kind = "commentary"
work = "gill"
git = "https://example.org/gill-modern"
[[package]]
id = "mystery"
name = "A mystery"
abbreviation = "MYS"
language = "en"
work = "mystery-text"
git = "https://example.org/mystery"
"#,
)
.unwrap();
assert_eq!(mine.works.len(), 1);
assert_eq!(mine.warnings.len(), 1, "{:?}", mine.warnings);
assert!(mine.warnings[0].contains("logoxide's table names it"));
let groups = mine.search_grouped("exposition");
assert_eq!(groups.len(), 1, "{groups:?}");
assert_eq!(
groups[0].name.as_deref(),
Some("John Gill's Exposition of the Bible")
);
assert_eq!(groups[0].entries.len(), 2);
let other = Catalog::parse_toml(
"name = \"other\"\n[works.gill]\nname = \"Gill\"\n[works.henry]\nname = \"Matthew Henry\"\n",
)
.unwrap();
let all = mine.merge(other);
assert_eq!(all.work("gill").unwrap().aliases, ["Gill's Exposition"]);
assert_eq!(all.work("henry").unwrap().name, "Matthew Henry");
assert_eq!(all.work("kjv").unwrap().name, "King James Version");
assert!(
all.warnings
.iter()
.any(|w| w.contains("named by mine and other"))
);
let listing = all.works_listing("");
assert!(listing.len() > 7);
let gill = listing.iter().find(|l| l.id == "gill").unwrap();
assert_eq!(gill.from, WorkFrom::Catalog("mine".into()));
assert_eq!(gill.entries.len(), 2);
assert_eq!(gill.wikidata.as_deref(), Some("Q42"));
let last = listing.last().unwrap();
assert_eq!(
(last.id.as_str(), &last.from),
("mystery-text", &WorkFrom::Unnamed)
);
assert_eq!(last.name, None);
let kjv = listing.iter().find(|l| l.id == "kjv").unwrap();
assert_eq!(kjv.from, WorkFrom::Logoxide);
assert!(kjv.entries.is_empty());
let ids: Vec<String> = (all.works_listing("authorized").iter())
.map(|l| l.id.clone())
.collect();
assert_eq!(ids, ["kjv"]);
assert_eq!(all.works_listing("mystery").len(), 1);
assert_eq!(all.works_listing("gill exposition").len(), 1);
}
#[test]
fn editions_are_grouped_by_work() {
let mine = Catalog::parse_toml(
r#"
[[package]]
id = "asv-1901"
name = "ASV, the 1901 printing"
abbreviation = "ASV1901"
language = "en"
work = "asv"
edition = "1901-nelson"
edition-year = 1901
git = "https://example.org/asv"
[[package]]
id = "bsb"
name = "Berean Standard Bible"
abbreviation = "BSB"
language = "en"
git = "https://example.org/bsb"
"#,
)
.unwrap();
let all = mine.merge(ebible());
let asv = all.find_import("ebible:eng-asv").unwrap();
assert_eq!(asv.work.as_deref(), Some("asv"));
assert_eq!(all.entries[0].edition_year, Some(1901));
let groups = all.search_grouped("american standard");
assert_eq!(groups.len(), 1, "{groups:?}");
assert_eq!(groups[0].work.as_deref(), Some("asv"));
assert_eq!(groups[0].name.as_deref(), Some("American Standard Version"));
let ids: Vec<&str> = groups[0].entries.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, ["asv-1901", "ebible-eng-asv"]);
let groups = all.search_grouped("");
assert_eq!(
(groups.iter())
.map(|g| (g.work.as_deref(), g.entries.len()))
.collect::<Vec<_>>(),
[(Some("asv"), 2), (None, 1), (None, 1)]
);
let info = crate::Manifest::parse(
"[package]\nid = \"kjv\"\nname = \"KJV\"\nabbreviation = \"KJV\"\nversion = \"1.0.0\"\n\
language = \"en\"\nwork = \"kjv\"\nedition = \"1769-oxford\"\nedition-year = 1769\n\
[formats]\nusx = { path = \"usx/\" }\n",
)
.unwrap()
.package
.unwrap();
let text = Catalog::toml_entry(&info, "https://example.org/kjv");
let entry = &Catalog::parse_toml(&text).unwrap().entries[0];
assert_eq!(
(
entry.work.as_deref(),
entry.edition.as_deref(),
entry.edition_year
),
(Some("kjv"), Some("1769-oxford"), Some(1769))
);
}
#[test]
fn a_dictionarys_keys_are_listed() {
let info = crate::Manifest::parse(
"[package]\nid = \"strongs\"\nname = \"Strong's\"\nabbreviation = \"SG\"\n\
version = \"1.0.0\"\nlanguage = \"en\"\nkind = \"lexicon\"\n\
keys = \"strongs-greek\"\n[formats]\ndictionary = { path = \"entries/\" }\n",
)
.unwrap()
.package
.unwrap();
let text = Catalog::toml_entry(&info, "https://example.org/strongs");
let entry = &Catalog::parse_toml(&text).unwrap().entries[0];
assert_eq!(entry.keys, Some(crate::manifest::Keys::StrongsGreek));
let manifest = crate::Manifest {
package: Some(info),
..crate::Manifest::default()
};
let folder = Catalog::from_packages("local", "/r", vec![("strongs".into(), manifest)]);
assert_eq!(
folder.entries[0].keys,
Some(crate::manifest::Keys::StrongsGreek)
);
let bible = Catalog::parse_toml(
"[[package]]\nid = \"b\"\nname = \"B\"\nabbreviation = \"B\"\nlanguage = \"en\"\n\
git = \"https://example.org/b\"\n",
)
.unwrap();
assert_eq!(bible.entries[0].keys, None);
}
#[test]
fn finds_imports_by_prefix() {
let catalog = ebible();
let asv = catalog.find_import("ebible:eng-asv").unwrap();
assert_eq!(asv.source.import_name(), Some("ebible:eng-asv"));
assert_eq!(asv.source.prefix(), Some("ebible"));
assert_eq!(asv.source.import_id(), Some("eng-asv"));
assert!(asv.source.versions_track_updates());
assert!(catalog.find_import("fetch:eng-asv").is_none());
assert!(catalog.find_import("eng-asv").is_none());
let git = CatalogSource::Git {
url: "https://example.org/bsb".into(),
};
assert_eq!(git.import_name(), None);
}
#[test]
fn lists_short_names_by_url() {
let catalog = Catalog::parse_toml(
"[[package]]\nid = \"kjv\"\nname = \"KJV\"\nabbreviation = \"KJV\"\nlanguage = \"en\"\ngit = \"github:someone/kjv\"\n",
)
.unwrap();
assert_eq!(
catalog.entries[0].source,
CatalogSource::Git {
url: "https://github.com/someone/kjv".into()
}
);
}
#[test]
fn folds_accents_points_and_final_sigma() {
assert_eq!(fold("Ἰησοῦς"), fold("ιησους"));
assert_eq!(fold("ΙΗΣΟΥΣ"), "ιησουσ");
assert_eq!(fold("בְּרֵאשִׁית"), fold("בראשית"));
assert_eq!(fold("José"), "jose");
assert_eq!(fold("성경"), "성경");
let catalog = Catalog::parse_toml(
r#"
[[package]]
id = "tr"
name = "Ἡ Καινὴ Διαθήκη"
abbreviation = "TR"
language = "grc"
keywords = ["Ἰησοῦς"]
git = "https://example.org/tr"
[[package]]
id = "wlc"
name = "תּוֹרָה נְבִיאִים וּכְתוּבִים"
abbreviation = "WLC"
language = "hbo"
keywords = ["בְּרֵאשִׁית"]
git = "https://example.org/wlc"
[[package]]
id = "rv"
name = "Reina-Valera"
abbreviation = "RV"
language = "es"
description = "Traducción de Casiodoro de Reina, revisada por Cipriano de Valera; José"
git = "https://example.org/rv"
[[package]]
id = "luther"
name = "Lutherbibel"
abbreviation = "LUT"
language = "deu"
git = "https://example.org/luther"
"#,
)
.unwrap();
let ids = |q: &str| (catalog.search(q).iter().map(|e| e.id.clone())).collect::<Vec<_>>();
assert_eq!(ids("ιησους"), ["tr"]);
assert_eq!(ids("καινη διαθηκη"), ["tr"]);
assert_eq!(ids("בראשית"), ["wlc"]);
assert_eq!(ids("תורה"), ["wlc"]);
assert_eq!(ids("jose"), ["rv"]);
assert_eq!(ids("traduccion"), ["rv"]);
assert_eq!(ids("spa"), ["rv"]);
assert_eq!(ids("ger"), ["luther"]);
assert_eq!(ids("de").first().map(String::as_str), Some("luther"));
}
#[test]
fn searches_and_merges() {
let ebible = ebible();
let mine = Catalog::parse_toml(
r#"
name = "Mine"
[[package]]
id = "bsb"
name = "Berean Standard Bible"
abbreviation = "BSB"
language = "en"
language-name = "English"
git = "https://example.org/bsb"
"#,
)
.unwrap();
let all = mine.merge(ebible);
let ids = |q: &str| {
all.search(q)
.iter()
.map(|e| e.id.clone())
.collect::<Vec<_>>()
};
assert_eq!(ids("asv"), ["ebible-eng-asv"]);
assert_eq!(ids("english"), ["ebible-eng-asv", "bsb"]);
assert_eq!(ids("hebrew codex"), ["ebible-hebwlc"]);
assert_eq!(ids("berean"), ["bsb"]);
assert!(ids("nothing like this").is_empty());
assert_eq!(ids("").len(), 3);
assert!(Catalog::parse_toml("[[package]]\nid = 1").is_err());
}
#[test]
fn finds_what_a_package_is_about() {
let info = crate::manifest::Manifest::parse(
r#"[package]
id = "gill-romans"
name = "Gill on Romans"
abbreviation = "GILL"
version = "1.0.0"
language = "en"
kind = "commentary"
keywords = ["justification"]
categories = ["church-history"]
about-books = ["ROM"]
[formats]
usx = { path = "usx/" }
"#,
)
.unwrap()
.package
.unwrap();
let text = Catalog::toml_entry(&info, "https://example.org/gill");
assert!(text.contains("about-books = [\"ROM\"]"), "{text}");
let catalog = Catalog::parse_toml(&text).unwrap().merge(ebible());
let gill = &catalog.entries[0];
assert_eq!(
(
&gill.keywords[..],
&gill.categories[..],
&gill.about_books[..]
),
(
&["justification".to_string()][..],
&["church-history".to_string()][..],
&["ROM".to_string()][..]
)
);
assert!(gill.is_about("ROM") && !gill.is_about("GAL"));
let ids = |q: &str| {
catalog
.search(q)
.iter()
.map(|e| e.id.clone())
.collect::<Vec<_>>()
};
assert_eq!(ids("romans"), ["gill-romans"]);
assert_eq!(ids("rom"), ["gill-romans"]);
assert_eq!(ids("justification"), ["gill-romans"]);
assert_eq!(ids("church history"), ["gill-romans"]);
assert!(ids("galatians").is_empty());
}
#[test]
fn writes_an_entry() {
let manifest = crate::Manifest::parse(
"[package]\nid = \"bsb\"\nname = \"Berean Standard Bible\"\nabbreviation = \"BSB\"\nversion = \"1.0.0\"\nlanguage = \"en\"\nlicense = \"CC0-1.0\"\n[formats]\nusx = { path = \"usx/\" }\n",
)
.unwrap();
let text = Catalog::toml_entry(
manifest.package.as_ref().unwrap(),
"https://example.org/bsb",
);
let catalog = Catalog::parse_toml(&text).unwrap();
assert_eq!(catalog.entries[0].id, "bsb");
assert_eq!(
catalog.entries[0].source,
CatalogSource::Git {
url: "https://example.org/bsb".into()
}
);
}
#[test]
fn a_registry_folder_names_packages_by_path_or_id() {
let package = |id: &str| {
crate::manifest::Manifest::parse(&format!(
"[package]\nid = \"{id}\"\nname = \"{id}\"\nabbreviation = \"X\"\n\
version = \"1.0.0\"\nlanguage = \"en\"\n\
[formats]\nusx = {{ path = \"usx/\" }}\n"
))
.unwrap()
};
let vault = crate::manifest::Manifest::parse("[dependencies]\n").unwrap();
let catalog = Catalog::from_packages(
"local",
"file:///packages/",
vec![
(String::from("kjv"), package("kjv")),
(String::from("books/romans"), package("romans")),
(String::from("forks/romans"), package("romans")),
(String::from("vault"), vault),
],
);
assert_eq!(catalog.entries.len(), 3);
let url = |name: &str| match &catalog.find(name).unwrap().unwrap().source {
CatalogSource::Git { url } => url.clone(),
other => panic!("{other:?}"),
};
assert_eq!(url("kjv"), "file:///packages/kjv");
assert_eq!(url("books/romans"), "file:///packages/books/romans");
assert_eq!(url("/forks/romans/"), "file:///packages/forks/romans");
let error = catalog.find("romans").unwrap_err().to_string();
assert!(error.contains("books/romans, forks/romans"), "{error}");
assert!(catalog.find("asv").unwrap().is_none());
}
#[test]
fn registry_names_are_checked_and_split() {
assert!(check_registry_name("dorean-local").is_ok());
assert!(check_registry_name("github").is_err());
assert!(check_registry_name("Dorean").is_err());
assert!(check_registry_name("").is_err());
let registries = std::collections::BTreeMap::from([(
String::from("dorean-local"),
String::from("~/repos"),
)]);
assert_eq!(
split_registry_name("dorean-local:books/romans", ®istries),
Some(("dorean-local", "books/romans"))
);
assert_eq!(split_registry_name("github:a/b", ®istries), None);
assert_eq!(split_registry_name("dorean-local:", ®istries), None);
}
#[test]
fn a_built_catalog_names_its_packages_by_path() {
let manifest = crate::manifest::Manifest::parse(
"[package]\nid = \"romans\"\nname = \"Romans\"\nabbreviation = \"ROM\"\n\
version = \"1.2.0\"\nlanguage = \"en\"\nkind = \"book\"\n\
[formats]\nepub = { path = \"epub/\" }\n",
)
.unwrap();
let info = manifest.package.unwrap();
let mut table = Catalog::entry_table(&info, "file:///repos/books/romans");
table.insert("path", toml_edit::value("books/romans"));
table.insert("commit", toml_edit::value("3f9c2a1b"));
let text = Catalog::toml_document(Some("mine"), vec![table]);
let catalog = Catalog::parse("catalog.toml", &text).unwrap();
assert_eq!(catalog.name, "mine");
let entry = catalog.find("books/romans").unwrap().unwrap();
assert_eq!(entry.id, "romans");
assert_eq!(entry.version.as_deref(), Some("1.2.0"));
assert_eq!(entry.kind, PackageKind::Book);
assert_eq!(catalog.find("romans").unwrap(), Some(entry));
}
const ENTRY: &str =
"[[package]]\nid = \"kjv\"\nname = \"KJV\"\nabbreviation = \"KJV\"\nlanguage = \"en\"\n";
fn release(url: &str) -> String {
format!(
"{ENTRY}[[package.release]]\nversion = \"1.0.0\"\nurl = \"{url}\"\nsha256 = \"sha256:00\"\n"
)
}
#[test]
fn a_remote_catalog_names_nothing_local() {
let remote = "https://example.org/r/catalog.toml";
for url in ["/etc/passwd", "file:///etc/passwd"] {
let error = Catalog::parse(remote, &release(url)).unwrap_err();
assert!(error.to_string().contains("on this machine"), "{error}");
assert!(Catalog::parse("/srv/r/catalog.toml", &release(url)).is_ok());
}
let mirrored = format!(
"{}mirrors = [\"/srv/kjv.tar.gz\"]\n",
release("kjv-1.0.0.tar.gz")
);
assert!(Catalog::parse(remote, &mirrored).is_err());
let git = format!("{ENTRY}git = \"/srv/kjv\"\n");
assert!(Catalog::parse(remote, &git).is_err());
let catalog = Catalog::parse(remote, &release("kjv/kjv-1.0.0.tar.gz")).unwrap();
assert_eq!(
catalog.entries[0].releases[0].url,
"https://example.org/r/kjv/kjv-1.0.0.tar.gz"
);
}
#[test]
fn catalogs_say_their_format() {
assert!(Catalog::parse_toml(&format!("format = 1\n{ENTRY}git = \"x\"\n")).is_ok());
let error = Catalog::parse_toml(&format!("format = 2\n{ENTRY}git = \"x\"\n")).unwrap_err();
assert!(error.to_string().contains("upgrade lx"), "{error}");
let written = Catalog::toml_document(Some("mine"), vec![]);
assert!(written.starts_with("format = 1\n"), "{written}");
assert!(Catalog::parse_toml(&written).is_ok());
}
#[test]
fn entries_whose_ids_arent_are_passed_over() {
let entry = |id: &str| {
ENTRY.replace("\"kjv\"", &format!("\"{id}\"")) + "git = \"https://example.com/x\"\n"
};
let text: String = ["kjv", "../../etc", "KJV", "", "bsb"].map(entry).concat();
let catalog =
Catalog::parse_toml_at(Some("https://example.com/catalog.toml"), &text).unwrap();
let ids: Vec<&str> = catalog.entries.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, ["kjv", "bsb"]);
assert_eq!(catalog.warnings.len(), 3, "{:?}", catalog.warnings);
assert!(
catalog.warnings[0].starts_with("https://example.com/catalog.toml: passed over")
&& catalog.warnings[0].contains("`../../etc`"),
"{:?}",
catalog.warnings
);
let merged = Catalog::default().merge(catalog);
assert_eq!(merged.warnings.len(), 3);
}
#[test]
fn entries_follow_the_manifests_rules_by_kind() {
let text = "[[package]]\nid = \"tdp\"\nname = \"The Dorean Principle\"\nlanguage = \"en\"\n\
kind = \"book\"\ngit = \"https://example.com/tdp\"\n\n\
[[package]]\nid = \"shelf\"\nname = \"A Shelf\"\nkind = \"collection\"\n\
git = \"https://example.com/shelf\"\n\n\
[[package]]\nid = \"kjv\"\nname = \"King James\"\nlanguage = \"en\"\n\
git = \"https://example.com/kjv\"\n\n\
[[package]]\nid = \"gill\"\nname = \"Gill\"\nabbreviation = \"GILL\"\n\
kind = \"commentary\"\ngit = \"https://example.com/gill\"\n";
let catalog = Catalog::parse_toml(text).unwrap();
let ids: Vec<&str> = catalog.entries.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, ["tdp", "shelf"]);
assert_eq!(catalog.entries[0].short_name(), "tdp");
assert_eq!(catalog.entries[1].language, "und");
assert_eq!(catalog.warnings.len(), 2, "{:?}", catalog.warnings);
assert!(catalog.warnings[0].contains("kjv: a bible needs an abbreviation"));
assert!(catalog.warnings[1].contains("gill: a commentary needs a language"));
}
#[test]
fn shadowed_ids_are_found() {
let catalog = |name: &str, ids: &[&str]| {
let entries: String = (ids.iter())
.map(|id| ENTRY.replace("\"kjv\"", &format!("\"{id}\"")) + "git = \"x\"\n")
.collect();
let mut catalog = Catalog::parse_toml(&entries).unwrap();
catalog.name = name.to_string();
catalog
};
let catalogs = [
catalog("mine", &["kjv", "bsb"]),
catalog("ebible", &["web", "kjv"]),
catalog("other", &["kjv", "web", "asv"]),
];
let names = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<Vec<_>>();
assert_eq!(
Catalog::shadowed(&catalogs),
[
(String::from("kjv"), names(&["mine", "ebible", "other"])),
(String::from("web"), names(&["ebible", "other"])),
]
);
let merged = (catalogs.iter().cloned()).reduce(Catalog::merge).unwrap();
assert_eq!(merged.entries.len(), 4);
let ids: Vec<&str> = merged.entries.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, ["kjv", "bsb", "web", "asv"]);
}
}