use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::error::{LogoxideError, Result};
pub const MANIFEST: &str = "logoxide.toml";
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Manifest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub package: Option<Package>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub formats: BTreeMap<String, FormatEntry>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub features: BTreeMap<String, Vec<String>>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub dependencies: BTreeMap<String, Dependency>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settings: Option<Settings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media: Option<crate::media::Media>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub titles: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Package {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub abbreviation: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub authors: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
pub version: semver::Version,
#[serde(default)]
pub kind: PackageKind,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub language: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub direction: Option<Direction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rights: Option<crate::license::Rights>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license_file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub documentation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub readme: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keywords: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub categories: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub about_books: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub work: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edition: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edition_year: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream: Option<Upstream>,
#[serde(default = "own", skip_serializing_if = "is_own")]
pub versification: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maps_to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keys: Option<Keys>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Upstream {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub import: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub converted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
impl Package {
pub fn text_script(&self) -> Option<String> {
(self.script.clone()).or_else(|| crate::language::script_of_tag(&self.language))
}
pub fn text_direction(&self) -> Direction {
(self.direction)
.unwrap_or_else(|| crate::language::script_direction(self.text_script().as_deref()))
}
}
fn own() -> String {
String::from("own")
}
fn is_own(versification: &str) -> bool {
versification == "own"
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PackageKind {
#[default]
Bible,
Commentary,
Lexicon,
CrossReferences,
VersificationMap,
Book,
Collection,
}
impl PackageKind {
pub fn name(self) -> &'static str {
match self {
PackageKind::Bible => "bible",
PackageKind::Commentary => "commentary",
PackageKind::Lexicon => "lexicon",
PackageKind::CrossReferences => "cross-references",
PackageKind::VersificationMap => "versification-map",
PackageKind::Book => "book",
PackageKind::Collection => "collection",
}
}
pub fn needs_abbreviation(self) -> bool {
matches!(self, PackageKind::Bible | PackageKind::Commentary)
}
pub fn needs_language(self) -> bool {
!matches!(
self,
PackageKind::Collection | PackageKind::VersificationMap
)
}
}
impl Package {
pub fn short_name(&self) -> &str {
self.abbreviation.as_deref().unwrap_or(&self.id)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Keys {
StrongsGreek,
StrongsHebrew,
Headword,
}
impl Keys {
pub fn name(self) -> &'static str {
match self {
Keys::StrongsGreek => "strongs-greek",
Keys::StrongsHebrew => "strongs-hebrew",
Keys::Headword => "headword",
}
}
pub fn from_name(name: &str) -> Option<Keys> {
Some(match name.trim().to_ascii_lowercase().as_str() {
"strongs-greek" => Keys::StrongsGreek,
"strongs-hebrew" => Keys::StrongsHebrew,
"headword" => Keys::Headword,
_ => return None,
})
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Direction {
#[default]
Ltr,
Rtl,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FormatEntry {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub feature: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Format {
Usx,
Usfm,
Usj,
Osis,
Sword,
Thml,
Gbf,
Zefania,
Epub,
Vrs,
Dictionary,
Xref,
}
impl Format {
pub fn from_name(name: &str) -> Option<Format> {
Some(match name.to_ascii_lowercase().as_str() {
"usx" => Format::Usx,
"usfm" => Format::Usfm,
"usj" => Format::Usj,
"osis" => Format::Osis,
"sword" => Format::Sword,
"thml" => Format::Thml,
"gbf" => Format::Gbf,
"zefania" => Format::Zefania,
"epub" => Format::Epub,
"vrs" => Format::Vrs,
"dictionary" => Format::Dictionary,
"xref" => Format::Xref,
_ => return None,
})
}
pub fn name(self) -> &'static str {
match self {
Format::Usx => "usx",
Format::Usfm => "usfm",
Format::Usj => "usj",
Format::Osis => "osis",
Format::Sword => "sword",
Format::Thml => "thml",
Format::Gbf => "gbf",
Format::Zefania => "zefania",
Format::Epub => "epub",
Format::Vrs => "vrs",
Format::Dictionary => "dictionary",
Format::Xref => "xref",
}
}
pub fn extensions(self) -> Option<&'static [&'static str]> {
Some(match self {
Format::Usx => &["usx"],
Format::Usfm => &["usfm", "sfm"],
Format::Usj => &["json", "usj"],
Format::Osis => &["xml", "osis"],
Format::Thml => &["xml", "thml"],
Format::Gbf => &["gbf", "txt"],
Format::Zefania => &["xml"],
Format::Epub => &["epub"],
Format::Vrs => &["vrs"],
Format::Dictionary => &["xml"],
Format::Xref => &["tsv"],
Format::Sword => return None,
})
}
pub fn readable(self) -> bool {
matches!(
self,
Format::Usx
| Format::Usfm
| Format::Usj
| Format::Osis
| Format::Thml
| Format::Gbf
| Format::Dictionary
| Format::Xref
)
}
pub fn is_verse_text(self) -> bool {
matches!(
self,
Format::Usx | Format::Usfm | Format::Usj | Format::Osis | Format::Thml | Format::Gbf
)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
pub enum Dependency {
Version(semver::VersionReq),
Detailed(DetailedDependency),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct DetailedDependency {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub import: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<semver::VersionReq>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub optional: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<String>,
#[serde(default = "yes", skip_serializing_if = "Clone::clone")]
pub default_features: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub package: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub abbreviation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aliases: Option<Vec<String>>,
#[serde(default = "yes", skip_serializing_if = "Clone::clone")]
pub enabled: bool,
}
impl Default for DetailedDependency {
fn default() -> Self {
Self {
git: None,
registry: None,
path: None,
import: None,
version: None,
tag: None,
rev: None,
branch: None,
folder: None,
optional: false,
features: vec![],
default_features: true,
package: None,
abbreviation: None,
aliases: None,
enabled: true,
}
}
}
impl Dependency {
pub fn enabled(&self) -> bool {
match self {
Dependency::Version(_) => true,
Dependency::Detailed(d) => d.enabled,
}
}
}
impl DetailedDependency {
pub fn package_id<'a>(&'a self, name: &'a str) -> &'a str {
self.package.as_deref().unwrap_or(name)
}
}
fn yes() -> bool {
true
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Settings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub compare: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub catalogs: Vec<String>,
#[serde(default = "yes", skip_serializing_if = "Clone::clone")]
pub default_catalogs: bool,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub registries: BTreeMap<String, String>,
}
impl Default for Settings {
fn default() -> Self {
Self {
default: None,
compare: Vec::new(),
catalogs: Vec::new(),
default_catalogs: true,
registries: BTreeMap::new(),
}
}
}
impl Manifest {
pub fn registries(&self) -> BTreeMap<String, String> {
(self.settings.as_ref())
.map(|s| s.registries.clone())
.unwrap_or_default()
}
pub fn catalogs(&self) -> Vec<String> {
self.catalogs_with(&[])
}
pub fn catalogs_with(&self, more: &[String]) -> Vec<String> {
let settings = self.settings.clone().unwrap_or_default();
let mut catalogs = settings.catalogs;
for catalog in more {
if !catalogs.contains(catalog) {
catalogs.push(catalog.clone());
}
}
if settings.default_catalogs {
for default in crate::catalog::DEFAULT_CATALOGS {
if !catalogs.iter().any(|c| c == default) {
catalogs.push(default.to_string());
}
}
}
catalogs
}
}
pub const CATEGORIES: [(&str, &str); 21] = [
(
"biblical-theology",
"Themes traced through Scripture (covenant, kingdom)",
),
(
"introduction",
"Old and New Testament introductions and surveys, authorship, canon",
),
(
"background",
"The Bible's history, archaeology, geography, and culture",
),
("hermeneutics", "How to interpret Scripture"),
(
"biblical-languages",
"Greek, Hebrew, and Aramaic grammars and readers",
),
("systematic-theology", "Doctrine by topic"),
(
"creeds-and-confessions",
"Creeds, confessions, and catechisms",
),
("apologetics", "Defending the faith"),
("ethics", "Christian ethics and moral theology"),
("church-history", "The church through the centuries"),
("biography", "Lives, memoirs, and letters"),
("preaching", "Homiletics: how to preach"),
("sermons", "Collected sermons"),
(
"pastoral",
"Pastoral ministry, counseling, and church leadership",
),
("worship", "Liturgy, hymns, and prayer books"),
("missions", "Missions and evangelism"),
("spiritual-life", "Discipleship, prayer, and holiness"),
("devotional", "Daily readings and meditations"),
("family", "Marriage, parenting, and the home"),
("children", "Written for children"),
(
"reference",
"Dictionaries, encyclopedias, atlases, and concordances",
),
];
pub const MAX_CATEGORIES: usize = 5;
fn check_metadata(package: &Package) -> Result<()> {
let err = |message: String| Err(LogoxideError::Manifest(message));
let mut seen = BTreeSet::new();
for keyword in &package.keywords {
if keyword.trim().is_empty() || keyword.trim() != keyword {
return err(format!(
"keyword `{keyword}`: give words, without spaces around them"
));
}
if !seen.insert(keyword.to_lowercase()) {
return err(format!("keyword `{keyword}` is listed twice"));
}
}
if package.categories.len() > MAX_CATEGORIES {
return err(format!(
"{} categories: a package can be in at most {MAX_CATEGORIES} (keywords say more)",
package.categories.len()
));
}
let mut seen = BTreeSet::new();
for category in &package.categories {
if !CATEGORIES.iter().any(|(c, _)| c == category) {
let all: Vec<&str> = CATEGORIES.iter().map(|(c, _)| *c).collect();
return err(format!(
"category `{category}` is none of logoxide's ({}); a keyword can say it",
all.join(", ")
));
}
if !seen.insert(category) {
return err(format!("category `{category}` is listed twice"));
}
}
let mut seen = BTreeSet::new();
for book in &package.about_books {
if crate::bible_data().books().search_usfm(book).is_none()
|| book.to_ascii_uppercase() != *book
{
return err(match book_code(book) {
Some(code) => format!("about-books: `{book}` is written `{code}`"),
None => format!("about-books: `{book}` is no book's USFM code (`ROM`, `1CO`)"),
});
}
if !seen.insert(book) {
return err(format!("about-books: `{book}` is listed twice"));
}
}
Ok(())
}
pub fn book_code(name: &str) -> Option<String> {
let books = crate::bible_data().books();
let id = (books.search_usfm(&name.trim().to_ascii_uppercase()))
.or_else(|| books.search(name.trim()))?;
books.get_usfm(id).cloned()
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Enabled {
pub features: BTreeSet<String>,
pub dependencies: BTreeSet<String>,
pub dependency_features: BTreeMap<String, BTreeSet<String>>,
}
impl Manifest {
pub fn parse(text: &str) -> Result<Manifest> {
let mut manifest: Manifest =
toml::from_str(text).map_err(|e| LogoxideError::Manifest(e.to_string()))?;
if let Some(package) = &mut manifest.package
&& package.language.is_empty()
&& !package.kind.needs_language()
{
package.language = String::from(crate::language::UNDETERMINED);
}
manifest.validate()?;
Ok(manifest)
}
pub fn to_toml(&self) -> Result<String> {
toml::to_string_pretty(self).map_err(|e| LogoxideError::Manifest(e.to_string()))
}
pub fn validate(&self) -> Result<()> {
let err = |message: String| Err(LogoxideError::Manifest(message));
if let Some(package) = &self.package {
if !is_valid_id(&package.id) {
return err(format!(
"package id `{}`: use lowercase letters, digits, `-`, and `_`",
package.id
));
}
check_metadata(package)?;
if package.abbreviation.is_none() && package.kind.needs_abbreviation() {
return err(format!(
"a {} needs an abbreviation (what references name it by: `BSB`)",
package.kind.name()
));
}
match (package.language.is_empty(), package.kind.needs_language()) {
(true, true) => {
return err(format!(
"a {} needs a language (BCP 47: `en`; `und` when unknown)",
package.kind.name()
));
}
(true, false) => {}
(false, _) => crate::language::check_language(&package.language)?,
}
if let Some(script) = &package.script {
crate::language::check_script(script)?;
}
if let Some(license) = &package.license {
crate::license::check_license(license)?;
}
if let Some(work) = &package.work {
check_id("work", work)?;
}
if let Some(edition) = &package.edition {
check_id("edition", edition)?;
}
let recording = (self.media.as_ref()).is_some_and(crate::media::Media::names_its_text);
match (
package.kind == PackageKind::Collection,
self.formats.is_empty(),
) {
(false, true) if !recording => {
return err(String::from(
"a package needs [formats] (a recording without a text of its own \
names the one it reads: [media] text, or versification)",
));
}
(true, false) => {
return err(String::from(
"a collection has no [formats]: its packages are its dependencies",
));
}
_ => {}
}
match (package.kind, &package.maps_to) {
(PackageKind::VersificationMap, None) => {
return err(String::from(
"a versification map names what it maps to: maps-to = \"Original\"",
));
}
(PackageKind::VersificationMap, Some(_)) if is_own(&package.versification) => {
return err(String::from(
"a versification map names what it maps from: versification = \"English\"",
));
}
(PackageKind::VersificationMap, _) | (_, None) => {}
(_, Some(_)) => {
return err(String::from(
"only a versification map has maps-to (kind = \"versification-map\")",
));
}
}
let dictionary =
(self.formats.keys()).any(|n| Format::from_name(n) == Some(Format::Dictionary));
match (dictionary, package.keys) {
(true, None) => {
return err(String::from(
"a dictionary names what its entries are keyed by: keys = \"strongs-greek\", \
\"strongs-hebrew\", or \"headword\"",
));
}
(false, Some(_)) => {
return err(String::from(
"only a dictionary has keys (a `dictionary` format)",
));
}
_ => {}
}
}
for (name, format) in &self.formats {
if let Some(feature) = &format.feature
&& !self.features.contains_key(feature)
{
return err(format!(
"format `{name}` needs feature `{feature}`, which [features] doesn't have"
));
}
}
for (feature, turns_on) in &self.features {
for item in turns_on {
if let Some(dep) = item.strip_prefix("dep:") {
match self.dependencies.get(dep) {
Some(Dependency::Detailed(d)) if d.optional => {}
_ => {
return err(format!(
"feature `{feature}`: `{item}` must name an optional dependency"
));
}
}
} else if let Some((dep, _)) = item.split_once('/') {
if !self.dependencies.contains_key(dep) {
return err(format!("feature `{feature}`: `{item}` names no dependency"));
}
} else if !self.features.contains_key(item) {
return err(format!("feature `{feature}`: `{item}` is no feature"));
}
}
}
for (name, dependency) in &self.dependencies {
let Dependency::Detailed(d) = dependency else {
continue;
};
let sources = [
d.git.is_some(),
d.path.is_some(),
d.registry.is_some(),
d.import.is_some(),
];
if sources.iter().filter(|s| **s).count() != 1 {
return err(format!(
"dependency `{name}`: give one of `git`, `registry`, `path`, or `import`"
));
}
let pins = [&d.tag, &d.rev, &d.branch]
.iter()
.filter(|p| p.is_some())
.count();
if pins > 1 || (pins == 1 && d.git.is_none()) {
return err(format!(
"dependency `{name}`: at most one of `tag`, `rev`, and `branch`, with `git`"
));
}
check_dependency(name, d)?;
}
if let Some(media) = &self.media {
for file in &media.files {
crate::media::check_sha256(&file.sha256)?;
}
if let Some(text) = &media.text
&& !self.dependencies.contains_key(text)
{
return err(format!(
"[media] text `{text}` names no dependency: the recording reads a text \
[dependencies] has"
));
}
if media
.versification
.as_deref()
.is_some_and(|v| v.trim().is_empty())
{
return err(String::from("[media] versification is empty"));
}
}
Ok(())
}
pub fn warnings(&self) -> Vec<String> {
(self.package.iter())
.filter_map(|p| crate::language::canonical_warning(&p.language))
.collect()
}
pub fn unknown_keys(text: &str) -> Result<Vec<String>> {
let table: toml::Table =
toml::from_str(text).map_err(|e| LogoxideError::Manifest(e.to_string()))?;
let mut unknown = vec![];
let check = |at: &str, value: &toml::Value, fields: &[&str], unknown: &mut Vec<String>| {
if let Some(table) = value.as_table() {
for key in table.keys() {
if !fields.contains(&key.as_str()) {
unknown.push(format!("{at}.{key}"));
}
}
}
};
for (key, value) in &table {
match key.as_str() {
"package" => {
check(key, value, fields_of::<Package>(), &mut unknown);
if let Some(upstream) = value.get("upstream") {
let fields = fields_of::<Upstream>();
check("package.upstream", upstream, fields, &mut unknown);
}
if let Some(rights) = value.get("rights") {
let fields = fields_of::<crate::license::Rights>();
check("package.rights", rights, fields, &mut unknown);
}
}
"formats" | "dependencies" => {
let fields = match key.as_str() {
"formats" => fields_of::<FormatEntry>(),
_ => fields_of::<DetailedDependency>(),
};
for (name, entry) in value.as_table().into_iter().flatten() {
check(&format!("{key}.{name}"), entry, fields, &mut unknown);
}
}
"settings" => check(key, value, fields_of::<Settings>(), &mut unknown),
"media" => {
check(key, value, fields_of::<crate::media::Media>(), &mut unknown);
let files = value.get("files").and_then(toml::Value::as_array);
for file in files.into_iter().flatten() {
let fields = fields_of::<crate::media::MediaFile>();
check("media.files", file, fields, &mut unknown);
}
}
_ if fields_of::<Manifest>().contains(&key.as_str()) => {}
_ => unknown.push(key.clone()),
}
}
unknown.sort();
unknown.dedup();
Ok(unknown)
}
}
pub fn is_valid_id(id: &str) -> bool {
!id.is_empty()
&& (id.bytes())
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
}
pub fn check_id(what: &str, id: &str) -> Result<()> {
match is_valid_id(id) {
true => Ok(()),
false => Err(LogoxideError::Manifest(format!(
"{what} `{id}`: use lowercase letters, digits, `-`, and `_`"
))),
}
}
pub fn check_rev(rev: &str) -> Result<()> {
if matches!(rev.len(), 40 | 64) && rev.bytes().all(|b| b.is_ascii_hexdigit()) {
return Ok(());
}
Err(LogoxideError::Manifest(format!(
"rev `{rev}` isn't a full commit id (40 or 64 hex digits: `git rev-parse {rev}` gives \
it); name a branch with `branch`, a tag with `tag`"
)))
}
pub fn is_import_name(name: &str) -> bool {
let Some((prefix, id)) = name.split_once(':') else {
return false;
};
!prefix.is_empty()
&& prefix
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
&& !id.is_empty()
&& !id.starts_with('-')
&& !id.chars().any(|c| c.is_control() || c.is_whitespace())
}
fn check_dependency(name: &str, d: &DetailedDependency) -> Result<()> {
let err = |message: String| Err(LogoxideError::Manifest(message));
check_id("dependency", name)?;
if let Some(package) = &d.package {
check_id(&format!("dependency `{name}`: package"), package)?;
}
for (key, value) in [
("git", &d.git),
("registry", &d.registry),
("import", &d.import),
("tag", &d.tag),
("branch", &d.branch),
("rev", &d.rev),
] {
if let Some(value) = value
&& (value.starts_with('-') || value.trim().is_empty() || value.contains('\0'))
{
return err(format!(
"dependency `{name}`: `{key} = \"{value}\"` isn't one"
));
}
}
if let Some(import) = &d.import
&& !is_import_name(import)
{
return err(format!(
"dependency `{name}`: `import = \"{import}\"` isn't an import (`ebible:eng-kjv2006`)"
));
}
if let Some(registry) = &d.registry
&& is_registry_name(registry)
{
return err(format!(
"dependency `{name}`: `registry = \"{registry}\"` names a registry by name, which \
this logoxide doesn't resolve yet: give its catalog's URL or path (lx add \
--registry <URL> {name})"
));
}
if let Some(rev) = &d.rev {
check_rev(rev).map_err(|e| LogoxideError::Manifest(format!("dependency `{name}`: {e}")))?;
}
if let Some(folder) = &d.folder {
let plain = |part: &str| {
!part.is_empty()
&& part != "."
&& part != ".."
&& !part.starts_with('-')
&& !part.contains([':', '\\', '\0', '*', '?', '[', '~', '^'])
};
if !folder.trim_matches('/').split('/').all(plain) {
return err(format!(
"dependency `{name}`: folder `{folder}` isn't a folder in the repository"
));
}
if d.git.is_none() {
return err(format!(
"dependency `{name}`: `folder` names a folder of a `git` repository"
));
}
}
Ok(())
}
pub fn is_registry_name(registry: &str) -> bool {
!registry.contains(['/', ':', '.'])
}
fn fields_of<T: serde::de::DeserializeOwned>() -> &'static [&'static str] {
use serde::de::Visitor;
#[derive(Debug)]
struct Fields(&'static [&'static str]);
impl std::fmt::Display for Fields {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "fields")
}
}
impl std::error::Error for Fields {}
impl serde::de::Error for Fields {
fn custom<M: std::fmt::Display>(_: M) -> Self {
Fields(&[])
}
}
struct Capture;
impl<'de> serde::Deserializer<'de> for Capture {
type Error = Fields;
fn deserialize_any<V: Visitor<'de>>(self, _: V) -> std::result::Result<V::Value, Fields> {
Err(Fields(&[]))
}
fn deserialize_struct<V: Visitor<'de>>(
self,
_: &'static str,
fields: &'static [&'static str],
_: V,
) -> std::result::Result<V::Value, Fields> {
Err(Fields(fields))
}
serde::forward_to_deserialize_any! {
bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf
option unit unit_struct newtype_struct seq tuple tuple_struct map enum identifier
ignored_any
}
}
match T::deserialize(Capture) {
Err(Fields(fields)) => fields,
Ok(_) => &[],
}
}
impl Manifest {
pub fn enable(&self, features: &[&str], default: bool) -> Result<Enabled> {
let mut enabled = Enabled::default();
let mut todo: Vec<String> = features.iter().map(|f| f.to_string()).collect();
if default && self.features.contains_key("default") {
todo.push(String::from("default"));
}
while let Some(feature) = todo.pop() {
if !enabled.features.insert(feature.clone()) {
continue;
}
let Some(turns_on) = self.features.get(&feature) else {
return Err(LogoxideError::Manifest(format!("no feature `{feature}`")));
};
for item in turns_on {
if let Some(dep) = item.strip_prefix("dep:") {
enabled.dependencies.insert(dep.to_string());
} else if let Some((dep, f)) = item.split_once('/') {
enabled.dependencies.insert(dep.to_string());
(enabled
.dependency_features
.entry(dep.to_string())
.or_default())
.insert(f.to_string());
} else {
todo.push(item.clone());
}
}
}
enabled.features.remove("default");
Ok(enabled)
}
pub fn formats(&self, enabled: &Enabled) -> Vec<(&str, &FormatEntry)> {
self.formats
.iter()
.filter(|(_, f)| {
f.feature
.as_ref()
.is_none_or(|f| enabled.features.contains(f))
})
.map(|(name, f)| (name.as_str(), f))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn commentary(lines: &str) -> Result<Manifest> {
Manifest::parse(&format!(
"[package]\nid = \"gill\"\nname = \"Gill on Romans\"\nabbreviation = \"GILL\"\n\
version = \"1.0.0\"\nlanguage = \"en\"\nkind = \"commentary\"\n{lines}\n\
[formats]\nusx = {{ path = \"usx/\" }}\n"
))
}
#[test]
fn abbreviation_and_language_by_kind() {
let package = |kind: &str, lines: &str| {
Manifest::parse(&format!(
"[package]\nid = \"x\"\nname = \"X\"\nversion = \"1.0.0\"\nkind = \"{kind}\"\n\
{lines}\n[formats]\nepub = {{ path = \"books/\" }}\n"
))
};
let collection = |lines: &str| {
Manifest::parse(&format!(
"[package]\nid = \"x\"\nname = \"X\"\nversion = \"1.0.0\"\nkind = \"collection\"\n{lines}\n"
))
};
for kind in ["bible", "commentary"] {
let error = package(kind, "language = \"en\"").unwrap_err().to_string();
assert!(error.contains("needs an abbreviation"), "{kind}: {error}");
}
let book = package("book", "language = \"en\"").unwrap();
let info = book.package.as_ref().unwrap();
assert_eq!(
(info.abbreviation.as_deref(), info.short_name()),
(None, "x")
);
assert!(!book.to_toml().unwrap().contains("abbreviation"));
let named = package("book", "language = \"en\"\nabbreviation = \"TDP\"").unwrap();
assert_eq!(named.package.unwrap().short_name(), "TDP");
let info = collection("").unwrap().package.unwrap();
assert_eq!(info.language, "und");
let map = package(
"versification-map",
"versification = \"English\"\nmaps-to = \"Original\"",
);
assert_eq!(map.unwrap().package.unwrap().language, "und");
let error = package("book", "").unwrap_err().to_string();
assert!(error.contains("needs a language"), "{error}");
let error = package("lexicon", "keys = \"headword\"")
.unwrap_err()
.to_string();
assert!(error.contains("needs a language"), "{error}");
assert!(collection("language = \"en_US\"").is_err());
}
#[test]
fn metadata_like_cargos() {
let manifest = commentary(
r#"keywords = ["justification", "Puritan"]
categories = ["church-history", "biography"]
about-books = ["ROM", "1CO"]
repository = "https://github.com/someone/gill"
documentation = "https://example.org/gill"
readme = "README.md""#,
)
.unwrap();
let info = manifest.package.as_ref().unwrap();
assert_eq!(info.about_books, ["ROM", "1CO"]);
assert_eq!(info.categories, ["church-history", "biography"]);
assert_eq!(info.readme.as_deref(), Some("README.md"));
assert_eq!(
Manifest::parse(&manifest.to_toml().unwrap()).unwrap(),
manifest
);
assert!(manifest.to_toml().unwrap().contains("about-books = ["));
let error = |lines: &str| commentary(lines).unwrap_err().to_string();
assert!(error(r#"about-books = ["Romans"]"#).contains("`Romans` is written `ROM`"));
assert!(error(r#"about-books = ["rom"]"#).contains("`rom` is written `ROM`"));
assert!(error(r#"about-books = ["XYZ"]"#).contains("no book's USFM code"));
assert!(error(r#"about-books = ["ROM", "ROM"]"#).contains("listed twice"));
assert!(error(r#"categories = ["reformed"]"#).contains("none of logoxide's"));
assert!(error(r#"categories = ["Church History"]"#).contains("none of logoxide's"));
assert!(error(r#"categories = ["ethics", "ethics"]"#).contains("listed twice"));
let six =
r#"categories = ["ethics", "sermons", "worship", "missions", "family", "children"]"#;
assert!(error(six).contains("at most 5"));
assert!(error(r#"keywords = [" grace"]"#).contains("without spaces"));
assert!(error(r#"keywords = ["grace", "Grace"]"#).contains("listed twice"));
}
#[test]
fn languages_scripts_and_directions() {
let with = |language: &str, lines: &str| {
Manifest::parse(&format!(
"[package]\nid = \"x\"\nname = \"X\"\nabbreviation = \"X\"\nversion = \"1.0.0\"\n\
language = \"{language}\"\n{lines}\n[formats]\nusx = {{ path = \"usx/\" }}\n"
))
.map(|m| m.package.unwrap())
};
let serbian = with("sr-Latn", "").unwrap();
assert_eq!(serbian.text_script().as_deref(), Some("Latn"));
assert_eq!(serbian.text_direction(), Direction::Ltr);
let hebrew = with("he", "script = \"Hebr\"").unwrap();
assert_eq!(hebrew.text_direction(), Direction::Rtl);
let said = with("he", "script = \"Hebr\"\ndirection = \"ltr\"").unwrap();
assert_eq!(said.text_direction(), Direction::Ltr);
assert_eq!(with("he", "").unwrap().text_direction(), Direction::Ltr);
let error = |language: &str, lines: &str| with(language, lines).unwrap_err().to_string();
assert!(error("en_US", "").contains("isn't a BCP 47 tag"));
assert!(error("en", "script = \"latin\"").contains("ISO 15924"));
let warnings = |language: &str| {
Manifest::parse(&format!(
"[package]\nid = \"x\"\nname = \"X\"\nabbreviation = \"X\"\nversion = \"1.0.0\"\n\
language = \"{language}\"\n[formats]\nusx = {{ path = \"usx/\" }}\n"
))
.map(|m| m.warnings())
};
assert_eq!(
warnings("eng-latn").unwrap(),
[
"language `eng-latn` isn't canonical BCP 47: use `en-Latn` (the shortest ISO 639 \
code, in its case)"
]
);
assert!(warnings("en-Latn").unwrap().is_empty());
assert!(warnings("grc").unwrap().is_empty());
}
#[test]
fn a_dictionary_names_its_keys() {
let dictionary = |keys: &str, format: &str| {
Manifest::parse(&format!(
"[package]\nid = \"strongs\"\nname = \"Strong's\"\nabbreviation = \"STR\"\n\
version = \"1.0.0\"\nlanguage = \"grc\"\nkind = \"lexicon\"\n{keys}\n\
[formats]\n{format}\n"
))
};
let entries = "dictionary = { path = \"entries/\" }";
let manifest = dictionary("keys = \"strongs-greek\"", entries).unwrap();
let info = manifest.package.as_ref().unwrap();
assert_eq!(info.keys, Some(Keys::StrongsGreek));
assert_eq!(
Manifest::parse(&manifest.to_toml().unwrap()).unwrap(),
manifest
);
let error = |keys: &str, format: &str| dictionary(keys, format).unwrap_err().to_string();
assert!(error("", entries).contains("keys = \"strongs-greek\""));
assert!(error("keys = \"strong\"", entries).contains("unknown variant"));
assert!(
error("keys = \"headword\"", "usx = { path = \"usx/\" }").contains("only a dictionary")
);
assert!(dictionary("", "xref = { path = \"xref/\" }").is_ok());
assert_eq!(Keys::from_name("Strongs-Hebrew"), Some(Keys::StrongsHebrew));
assert_eq!(Keys::Headword.name(), "headword");
assert_eq!(Format::from_name("dictionary"), Some(Format::Dictionary));
assert_eq!(Format::Dictionary.extensions(), Some(&["xml"][..]));
assert_eq!(Format::Xref.extensions(), Some(&["tsv"][..]));
assert_eq!(Format::Gbf.extensions(), Some(&["gbf", "txt"][..]));
for format in [Format::Gbf, Format::Dictionary, Format::Xref] {
assert_eq!(Format::from_name(format.name()), Some(format));
}
assert!(Format::Gbf.is_verse_text() && !Format::Dictionary.is_verse_text());
}
#[test]
fn book_codes_from_names() {
assert_eq!(book_code("Romans").as_deref(), Some("ROM"));
assert_eq!(book_code("rom").as_deref(), Some("ROM"));
assert_eq!(book_code("1 Corinthians").as_deref(), Some("1CO"));
assert_eq!(book_code("Song of Songs").as_deref(), Some("SNG"));
assert_eq!(book_code("nothing"), None);
}
const BSB: &str = r#"
[package]
id = "bsb"
name = "Berean Standard Bible"
abbreviation = "BSB"
aliases = ["Berean"]
version = "1.2.0"
language = "en"
license = "LicenseRef-PublicDomain"
[formats]
usx = { path = "usx/", feature = "usx" }
sword = { path = "sword/", feature = "sword" }
[features]
default = ["usx", "headings"]
usx = []
sword = []
headings = []
strongs = ["dep:strongs-greek", "tsk/full"]
[dependencies]
strongs-greek = { git = "https://example.org/strongs", version = "2", optional = true }
tsk = { git = "https://example.org/tsk", tag = "v1.0.0" }
"#;
#[test]
fn reads_a_package() {
let m = Manifest::parse(BSB).unwrap();
let p = m.package.as_ref().unwrap();
assert_eq!(
(p.id.as_str(), p.version.to_string()),
("bsb", "1.2.0".into())
);
assert_eq!(p.kind, PackageKind::Bible);
assert_eq!(p.versification, "own");
assert_eq!(Manifest::parse(&m.to_toml().unwrap()).unwrap(), m);
}
#[test]
fn features_turn_on_formats_and_dependencies() {
let m = Manifest::parse(BSB).unwrap();
let names = |e: &Enabled| {
m.formats(e)
.iter()
.map(|(n, _)| n.to_string())
.collect::<Vec<_>>()
};
let default = m.enable(&[], true).unwrap();
assert_eq!(
default.features,
BTreeSet::from(["usx".into(), "headings".into()])
);
assert_eq!(names(&default), ["usx"]);
let more = m.enable(&["sword", "strongs"], false).unwrap();
assert_eq!(names(&more), ["sword"]);
assert_eq!(
more.dependencies,
BTreeSet::from(["strongs-greek".into(), "tsk".into()])
);
assert_eq!(
more.dependency_features["tsk"],
BTreeSet::from(["full".into()])
);
assert!(m.enable(&["nope"], false).is_err());
}
#[test]
fn a_vault_has_no_package() {
let m = Manifest::parse(
r#"
[dependencies]
bsb = { git = "https://example.org/bsb", version = "1" }
mine = { path = "Bibles/Mine" }
later = "2"
[settings]
default = "bsb"
compare = ["mine"]
"#,
)
.unwrap();
assert!(m.package.is_none());
assert!(matches!(m.dependencies["later"], Dependency::Version(_)));
assert_eq!(m.settings.unwrap().default.as_deref(), Some("bsb"));
}
#[test]
fn a_vaults_catalogs_come_before_the_defaults() {
use crate::catalog::{
CCEL_CATALOG, CCEL_COMMENTARIES_CATALOG, DEFAULT_CATALOGS, EBIBLE_CATALOG,
FETCH_BIBLE_CATALOG, SWORD_CATALOG,
};
let catalogs = |settings: &str| {
Manifest::parse(&format!("[dependencies]\n[settings]\n{settings}"))
.unwrap()
.catalogs()
};
assert_eq!(Manifest::default().catalogs(), DEFAULT_CATALOGS);
assert_eq!(
catalogs("catalogs = [\"mine.toml\"]"),
[
"mine.toml",
EBIBLE_CATALOG,
FETCH_BIBLE_CATALOG,
CCEL_CATALOG,
CCEL_COMMENTARIES_CATALOG,
SWORD_CATALOG
]
);
let again = format!("catalogs = [\"{FETCH_BIBLE_CATALOG}\", \"mine.toml\"]");
assert_eq!(
catalogs(&again),
[
FETCH_BIBLE_CATALOG,
"mine.toml",
EBIBLE_CATALOG,
CCEL_CATALOG,
CCEL_COMMENTARIES_CATALOG,
SWORD_CATALOG
]
);
assert_eq!(
catalogs("catalogs = [\"mine.toml\"]\ndefault-catalogs = false"),
["mine.toml"]
);
}
#[test]
fn mistakes_are_explained() {
let bad = |extra: &str| {
Manifest::parse(&format!("{BSB}\n{extra}"))
.unwrap_err()
.to_string()
};
assert!(
bad("x = { path = \"a\", git = \"b\" }")
.contains("give one of `git`, `registry`, `path`, or `import`")
);
assert!(bad("y = { path = \"a\", tag = \"v1\" }").contains("with `git`"));
assert!(
bad("z = { import = \"ebible:eng-asv\", git = \"b\" }")
.contains("give one of `git`, `registry`, `path`, or `import`")
);
assert!(bad("z = { import = \"ebible:eng-asv\", tag = \"v1\" }").contains("with `git`"));
for import in ["eng-asv", "Ebible:x", "ebible:", "ebible:-x", "ebible:a b"] {
let error = bad(&format!("z = {{ import = \"{import}\" }}"));
assert!(error.contains("isn't an import"), "{import}: {error}");
}
assert!(
Manifest::parse(&format!(
"{BSB}\nz = {{ import = \"sword:crosswire/KJV\" }}"
))
.is_ok()
);
let wrong_dep = BSB.replace("dep:strongs-greek", "dep:tsk");
assert!(
Manifest::parse(&wrong_dep)
.unwrap_err()
.to_string()
.contains("optional dependency")
);
let typo = BSB.replace("abbreviation", "abbrev");
assert!(Manifest::parse(&typo).is_err());
for (dependency, message) in [
("x = { git = \"--upload-pack=touch /tmp/x\" }", "isn't one"),
(
"x = { git = \"https://e.org/x\", tag = \"-x\" }",
"isn't one",
),
(
"x = { git = \"https://e.org/x\", branch = \"--x\" }",
"isn't one",
),
(
"x = { git = \"https://e.org/x\", rev = \"--x\" }",
"isn't one",
),
(
"x = { git = \"https://e.org/x\", rev = \"main\" }",
"isn't a full commit id",
),
(
"x = { git = \"https://e.org/x\", rev = \"abc\" }",
"isn't a full commit id",
),
(
"x = { git = \"https://e.org/x\", rev = \"3f9c2a1b\" }",
"isn't a full commit id",
),
("x = { registry = \"-r\" }", "isn't one"),
("x = { registry = \"dorean\" }", "by name"),
("x = { registry = \"dorean_local\" }", "by name"),
("\"../x\" = { git = \"https://e.org/x\" }", "lowercase"),
(
"x = { git = \"https://e.org/x\", package = \"../x\" }",
"lowercase",
),
(
"x = { git = \"https://e.org/x\", folder = \"../x\" }",
"isn't a folder",
),
(
"x = { path = \"x\", folder = \"x\" }",
"of a `git` repository",
),
] {
let error = bad(dependency);
assert!(error.contains(message), "{dependency}: {error}");
}
let fine = format!(
"{BSB}\nx = {{ git = \"https://e.org/x\", rev = \"{}\" }}\n",
"3f".repeat(20)
);
assert!(Manifest::parse(&fine).is_ok());
let sha256 = format!(
"{BSB}\nx = {{ git = \"https://e.org/x\", rev = \"{}\" }}\n",
"3f".repeat(32)
);
assert!(Manifest::parse(&sha256).is_ok());
let folder = format!("{BSB}\nx = {{ git = \"https://e.org/x\", folder = \"books/x\" }}\n");
let folder = Manifest::parse(&folder).unwrap();
let Dependency::Detailed(x) = &folder.dependencies["x"] else {
panic!("detailed")
};
assert_eq!(x.folder.as_deref(), Some("books/x"));
}
#[test]
fn unknown_keys_are_named_not_refused() {
let newer = BSB
.replace(
"license = \"LicenseRef-PublicDomain\"",
"license = \"LicenseRef-PublicDomain\"\nfunding = \"https://e.org\"\nupstream = { url = \"u\", import = \"ebible:x\", date = \"d\" }",
)
.replace(
"tag = \"v1.0.0\" }",
"tag = \"v1.0.0\", mirror = \"https://m.org\" }",
)
.replace(
"usx = { path = \"usx/\", feature = \"usx\" }",
"usx = { path = \"usx/\", feature = \"usx\", encoding = \"utf-8\" }",
)
+ "\n[settings]\ndefault = \"bsb\"\ntheme = \"dark\"\n\n[media]\nplayer = \"x\"\n\n[[media.files]]\npath = \"a.mp3\"\nurl = \"u\"\nsize = 0\nsha256 = \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\nlength = 3\n\n[plugins]\nx = 1\n";
let manifest = Manifest::parse(&newer).unwrap();
assert_eq!(manifest.package.unwrap().id, "bsb");
assert_eq!(
Manifest::unknown_keys(&newer).unwrap(),
[
"dependencies.tsk.mirror",
"formats.usx.encoding",
"media.files.length",
"media.player",
"package.funding",
"package.upstream.date",
"plugins",
"settings.theme"
]
);
assert!(Manifest::unknown_keys(BSB).unwrap().is_empty());
assert!(Manifest::parse(&BSB.replace("version = \"1.2.0\"", "version = 3")).is_err());
let id = BSB.replace("id = \"bsb\"", "id = \"BSB 2\"");
assert!(
Manifest::parse(&id)
.unwrap_err()
.to_string()
.contains("lowercase")
);
}
}