use std::collections::{BTreeMap, HashMap};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::{
books::Book,
error::{LogoxideError, Result},
package::Package,
reader::{TextFacts, TextReader},
storage::Storage,
};
pub const SUMMARY: &str = "logoxide.summary.toml";
pub const VERSES: &str = "logoxide.verses.bin";
pub const ENTRIES: &str = "logoxide.entries.bin";
const SUMMARY_VERSION: u32 = 1;
const MAGIC: &[u8; 4] = b"LXV2";
const HEADER: usize = 8;
const RECORD: usize = 12;
const ENTRIES_MAGIC: &[u8; 4] = b"LXE1";
const ENTRY_RECORD: usize = KEY_HASH_BYTES + HASH_BYTES;
pub const HASH_BYTES: usize = 7;
pub const KEY_HASH_BYTES: usize = 8;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct EntryHash {
pub key: [u8; KEY_HASH_BYTES],
pub hash: [u8; HASH_BYTES],
}
pub fn verse_hash(text: &str) -> [u8; HASH_BYTES] {
let full = Sha256::digest(text.as_bytes());
let mut hash = [0; HASH_BYTES];
hash.copy_from_slice(&full[..HASH_BYTES]);
hash
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum HashRule {
One,
}
impl HashRule {
pub const CURRENT: HashRule = HashRule::One;
pub fn number(self) -> u16 {
match self {
HashRule::One => 1,
}
}
pub fn from_number(number: u16) -> Option<HashRule> {
match number {
1 => Some(HashRule::One),
_ => None,
}
}
}
fn newer_rule(file: &str, number: u16) -> LogoxideError {
LogoxideError::Newer(format!(
"{file}: verse-hash rule {number} (this lx knows rule {})",
HashRule::CURRENT.number()
))
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Summary {
pub summary: u32,
pub verse_hash: u16,
pub id: String,
pub version: semver::Version,
pub text_checksum: String,
pub files_checksum: String,
pub verses: u64,
pub sizes: BTreeMap<String, u64>,
pub books: IndexMap<String, Vec<u8>>,
pub contents: BTreeMap<String, u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media: Option<MediaSummary>,
#[serde(default, skip_serializing_if = "is_zero")]
pub references: u64,
#[serde(default, skip_serializing_if = "is_zero")]
pub mappings: u64,
#[serde(default, skip_serializing_if = "is_zero")]
pub entries: u64,
#[serde(skip)]
pub cited: Vec<crate::references::Cited>,
#[serde(skip)]
pub entry_hashes: Vec<EntryHash>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub titles: BTreeMap<String, String>,
}
fn is_zero(n: &u64) -> bool {
*n == 0
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MediaSummary {
pub files: u64,
pub bytes: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct VerseHash {
pub book: Book,
pub chapter: u8,
pub verse: u8,
pub last: u8,
pub part: u8,
pub hash: [u8; HASH_BYTES],
}
impl VerseHash {
pub fn key(&self) -> (Book, u8, u8, u8) {
(self.book, self.chapter, self.verse, self.part)
}
fn number(&self) -> (Book, u8, u8) {
(self.book, self.chapter, self.verse)
}
pub fn reference(&self) -> String {
let code = self.book.name();
let part = match self.part {
1..=26 => char::from(b'a' + self.part - 1).to_string(),
0 => String::new(),
n => format!("({n})"),
};
match self.last > self.verse {
true => format!("{code} {}:{}{part}-{}", self.chapter, self.verse, self.last),
false => format!("{code} {}:{}{part}", self.chapter, self.verse),
}
}
}
#[cfg(feature = "read")]
pub fn book_hashes(book: &logoxide_text::Book) -> Result<Vec<VerseHash>> {
crate::reader::verse_hashes(book, HashRule::CURRENT)
}
pub fn is_summary_file(path: &str) -> bool {
path == SUMMARY || path == VERSES || path == ENTRIES || path == crate::references::REFERENCES
}
pub fn summarize(package: &Package, reader: &dyn TextReader) -> Result<(Summary, Vec<VerseHash>)> {
let facts = reader.facts(package, HashRule::CURRENT)?;
summarize_facts(package, facts)
}
pub fn summarize_facts(package: &Package, facts: TextFacts) -> Result<(Summary, Vec<VerseHash>)> {
let info = package.info();
let storage = package.storage();
let declared = crate::package::declared_files(storage.as_ref())?;
let text_files: Vec<String> = (declared.into_iter())
.filter(|p| !is_summary_file(p))
.collect();
let manifest = package.manifest();
let format_of = |path: &str| {
(manifest.formats.iter())
.find(|(_, f)| {
let dir = f.path.trim_end_matches('/');
path == dir || path.starts_with(&format!("{dir}/"))
})
.map(|(name, _)| name.clone())
};
let mut sizes: BTreeMap<String, u64> = (manifest.formats.keys())
.map(|name| (name.clone(), 0))
.collect();
let mut format_files = vec![];
for path in &text_files {
if let Some(name) = format_of(path) {
*sizes.entry(name).or_default() += storage.read(path)?.len() as u64;
format_files.push(path.clone());
}
}
let summary = Summary {
summary: SUMMARY_VERSION,
verse_hash: HashRule::CURRENT.number(),
id: info.id.clone(),
version: info.version.clone(),
text_checksum: crate::package::checksum_of(storage.as_ref(), &text_files)?,
files_checksum: crate::package::checksum_of(storage.as_ref(), &format_files)?,
verses: facts.verses.len() as u64,
sizes,
books: facts.books,
contents: facts.contents,
media: (package.manifest().media.as_ref())
.filter(|m| !m.files.is_empty())
.map(|m| MediaSummary {
files: m.files.len() as u64,
bytes: m.files.iter().map(|f| f.size).sum(),
}),
references: facts.references,
mappings: facts.mappings,
entries: facts.entries.len() as u64,
cited: facts.cited,
entry_hashes: facts.entries,
titles: facts.titles,
};
Ok((summary, facts.verses))
}
impl Summary {
pub fn parse(text: &str) -> Result<Summary> {
let summary: Summary =
toml::from_str(text).map_err(|e| LogoxideError::Manifest(format!("{SUMMARY}: {e}")))?;
if summary.summary != SUMMARY_VERSION {
return Err(LogoxideError::Manifest(format!(
"{SUMMARY}: format {} (this logoxide reads {SUMMARY_VERSION})",
summary.summary
)));
}
if HashRule::from_number(summary.verse_hash).is_none() {
return Err(newer_rule(SUMMARY, summary.verse_hash));
}
Ok(summary)
}
pub fn to_toml(&self) -> Result<String> {
let body = toml::to_string(self).map_err(|e| LogoxideError::Manifest(e.to_string()))?;
Ok(format!(
"# Written by `lx release`: what this version holds. `lx verify` checks it.\n{body}"
))
}
pub fn read(storage: &dyn Storage) -> Result<(Summary, Vec<VerseHash>)> {
let mut summary = Summary::parse(&storage.read_string(SUMMARY)?)?;
let (rule, verses) = decode_verses(&storage.read(VERSES)?)?;
if rule.number() != summary.verse_hash {
return Err(LogoxideError::Manifest(format!(
"{SUMMARY} says verse-hash rule {}, but {VERSES} was written with rule {}",
summary.verse_hash,
rule.number()
)));
}
if summary.references > 0 {
let bytes = storage.read(crate::references::REFERENCES)?;
summary.cited = crate::references::decode(&bytes)?;
}
if summary.entries > 0 {
let (entries_rule, entries) = decode_entries(&storage.read(ENTRIES)?)?;
if entries_rule != rule {
return Err(LogoxideError::Manifest(format!(
"{ENTRIES} was written with rule {}, but {VERSES} with rule {}",
entries_rule.number(),
rule.number()
)));
}
if entries.len() as u64 != summary.entries {
return Err(LogoxideError::Manifest(format!(
"{SUMMARY} says {} entries, but {ENTRIES} has {}",
summary.entries,
entries.len()
)));
}
summary.entry_hashes = entries;
}
Ok((summary, verses))
}
pub fn write(&self, verses: &[VerseHash], storage: &dyn Storage) -> Result<()> {
let rule = HashRule::from_number(self.verse_hash)
.ok_or_else(|| newer_rule(SUMMARY, self.verse_hash))?;
storage.write(SUMMARY, self.to_toml()?.as_bytes())?;
storage.write(VERSES, &encode_verses(rule, verses))?;
if !self.cited.is_empty() {
let bytes = crate::references::encode(&self.cited)?;
storage.write(crate::references::REFERENCES, &bytes)?;
}
if !self.entry_hashes.is_empty() {
storage.write(ENTRIES, &encode_entries(rule, &self.entry_hashes))?;
}
Ok(())
}
}
pub fn encode_entries(rule: HashRule, entries: &[EntryHash]) -> Vec<u8> {
let mut out = Vec::with_capacity(HEADER + entries.len() * ENTRY_RECORD);
out.extend_from_slice(ENTRIES_MAGIC);
out.extend_from_slice(&rule.number().to_le_bytes());
out.extend_from_slice(&[ENTRY_RECORD as u8, 0]);
for e in entries {
out.extend_from_slice(&e.key);
out.extend_from_slice(&e.hash);
}
out
}
pub fn decode_entries(bytes: &[u8]) -> Result<(HashRule, Vec<EntryHash>)> {
let bad = |why: &str| LogoxideError::Manifest(format!("{ENTRIES}: {why}"));
let (header, body) = (bytes.split_at_checked(HEADER))
.filter(|(h, _)| h.starts_with(ENTRIES_MAGIC))
.ok_or_else(|| bad("not an entry hash file"))?;
let number = u16::from_le_bytes([header[4], header[5]]);
let rule = HashRule::from_number(number).ok_or_else(|| newer_rule(ENTRIES, number))?;
let record = usize::from(header[6]);
if record < ENTRY_RECORD || body.len() % record != 0 {
return Err(bad(&format!(
"records of {record} bytes don't fit (at least {ENTRY_RECORD}, and the file a whole number of them)"
)));
}
let entries = (body.chunks_exact(record))
.map(|c| {
let mut key = [0; KEY_HASH_BYTES];
key.copy_from_slice(&c[..KEY_HASH_BYTES]);
let mut hash = [0; HASH_BYTES];
hash.copy_from_slice(&c[KEY_HASH_BYTES..ENTRY_RECORD]);
EntryHash { key, hash }
})
.collect();
Ok((rule, entries))
}
pub fn encode_verses(rule: HashRule, verses: &[VerseHash]) -> Vec<u8> {
let mut out = Vec::with_capacity(HEADER + verses.len() * RECORD);
out.extend_from_slice(MAGIC);
out.extend_from_slice(&rule.number().to_le_bytes());
out.extend_from_slice(&[RECORD as u8, 0]);
for v in verses {
out.extend_from_slice(&[v.book.0, v.chapter, v.verse, v.last, v.part]);
out.extend_from_slice(&v.hash);
}
out
}
pub fn decode_verses(bytes: &[u8]) -> Result<(HashRule, Vec<VerseHash>)> {
let bad = |why: &str| LogoxideError::Manifest(format!("{VERSES}: {why}"));
let (header, body) = (bytes.split_at_checked(HEADER))
.filter(|(h, _)| h.starts_with(MAGIC))
.ok_or_else(|| bad("not a verse hash file"))?;
let number = u16::from_le_bytes([header[4], header[5]]);
let rule = HashRule::from_number(number).ok_or_else(|| newer_rule(VERSES, number))?;
let record = usize::from(header[6]);
if record < RECORD || body.len() % record != 0 {
return Err(bad(&format!(
"records of {record} bytes don't fit (at least {RECORD}, and the file a whole number of them)"
)));
}
let verses = (body.chunks_exact(record))
.map(|c| {
let mut hash = [0; HASH_BYTES];
hash.copy_from_slice(&c[5..RECORD]);
VerseHash {
book: Book(c[0]),
chapter: c[1],
verse: c[2],
last: c[3],
part: c[4],
hash,
}
})
.collect();
Ok((rule, verses))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Change {
Patch,
Minor,
Major,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Diff {
pub removed: Vec<VerseHash>,
pub added: Vec<VerseHash>,
pub changed: Vec<VerseHash>,
pub moved: Vec<(VerseHash, VerseHash)>,
pub joined: Vec<(VerseHash, VerseHash)>,
pub split: Vec<VerseHash>,
pub bridged: Vec<VerseHash>,
pub files_changed: bool,
pub retitled: Vec<(String, String, String)>,
pub entries_removed: Vec<EntryHash>,
pub entries_added: Vec<EntryHash>,
pub entries_changed: Vec<EntryHash>,
}
pub fn diff_summaries(old: (&Summary, &[VerseHash]), new: (&Summary, &[VerseHash])) -> Diff {
let mut d = diff(old.1, new.1);
d.files_changed = old.1.is_empty()
&& new.1.is_empty()
&& old.0.entry_hashes.is_empty()
&& new.0.entry_hashes.is_empty()
&& old.0.files_checksum != new.0.files_checksum;
d.retitled = (old.0.titles.iter())
.filter_map(|(path, old)| {
let new = new.0.titles.get(path)?;
(new != old).then(|| (path.clone(), old.clone(), new.clone()))
})
.collect();
let entries = diff_entries(&old.0.entry_hashes, &new.0.entry_hashes);
d.entries_removed = entries.entries_removed;
d.entries_added = entries.entries_added;
d.entries_changed = entries.entries_changed;
d
}
pub fn diff_entries(old: &[EntryHash], new: &[EntryHash]) -> Diff {
let old_by_key: HashMap<_, _> = old.iter().map(|e| (e.key, e)).collect();
let new_by_key: HashMap<_, _> = new.iter().map(|e| (e.key, e)).collect();
let mut d = Diff::default();
for e in old {
match new_by_key.get(&e.key) {
None => d.entries_removed.push(*e),
Some(n) if n.hash != e.hash => d.entries_changed.push(**n),
Some(_) => {}
}
}
d.entries_added = (new.iter())
.filter(|e| !old_by_key.contains_key(&e.key))
.copied()
.collect();
d
}
fn bridged_numbers(verses: &[VerseHash]) -> HashMap<(Book, u8, u8), &VerseHash> {
let mut covered = HashMap::new();
for v in verses {
for n in v.verse.saturating_add(1)..=v.last {
covered.entry((v.book, v.chapter, n)).or_insert(v);
}
}
covered
}
fn by_number(verses: &[VerseHash]) -> HashMap<(Book, u8, u8), Vec<&VerseHash>> {
let mut numbers: HashMap<_, Vec<_>> = HashMap::new();
for v in verses {
numbers.entry(v.number()).or_default().push(v);
}
numbers
}
pub fn diff(old: &[VerseHash], new: &[VerseHash]) -> Diff {
let new_by_key: HashMap<_, _> = new.iter().map(|v| (v.key(), v)).collect();
let old_by_key: HashMap<_, _> = old.iter().map(|v| (v.key(), v)).collect();
let new_numbers = by_number(new);
let old_numbers = by_number(old);
let new_bridged = bridged_numbers(new);
let old_bridged = bridged_numbers(old);
let mut new_by_hash: HashMap<_, Vec<_>> = HashMap::new();
for v in new {
new_by_hash.entry(v.hash).or_default().push(v);
}
let empty = verse_hash("");
let moved_to = |v: &VerseHash| {
if v.hash == empty {
return None;
}
(new_by_hash.get(&v.hash)?.iter())
.find(|to| {
to.key() != v.key()
&& (old_by_key.get(&to.key())).is_none_or(|before| before.hash != v.hash)
})
.copied()
};
let present = |numbers: &HashMap<(Book, u8, u8), Vec<&VerseHash>>,
bridged: &HashMap<(Book, u8, u8), &VerseHash>,
v: &VerseHash,
n: u8| {
let number = (v.book, v.chapter, n);
numbers.contains_key(&number) || bridged.contains_key(&number)
};
let other_parts = |numbers: &HashMap<(Book, u8, u8), Vec<&'_ VerseHash>>, v: &VerseHash| {
(numbers.get(&v.number()).into_iter().flatten())
.find(|o| (o.part == 0) != (v.part == 0))
.map(|o| **o)
};
let mut diff = Diff::default();
for v in old {
match new_by_key.get(&v.key()) {
None => match (
new_bridged.get(&v.number()),
other_parts(&new_numbers, v),
moved_to(v),
) {
(Some(by), _, _) => diff.joined.push((*v, **by)),
(None, Some(part), _) if v.part == 0 => diff.split.push(part),
(None, Some(whole), _) => diff.joined.push((*v, whole)),
(None, None, Some(to)) => diff.moved.push((*v, *to)),
(None, None, None) => diff.removed.push(*v),
},
Some(n) if n.last < v.last => {
match (n.last + 1..=v.last).all(|k| present(&new_numbers, &new_bridged, v, k)) {
true => diff.split.push(**n),
false => diff.bridged.push(**n),
}
}
Some(n) if n.last > v.last => {
let took_in =
(v.last + 1..=n.last).any(|k| present(&old_numbers, &old_bridged, v, k));
if !took_in && n.hash != v.hash {
diff.changed.push(**n);
}
}
Some(n) if n.hash != v.hash => match moved_to(v) {
Some(to) => diff.moved.push((*v, *to)),
None => diff.changed.push(**n),
},
Some(_) => {}
}
}
diff.added = new
.iter()
.filter(|v| {
!old_by_key.contains_key(&v.key())
&& !old_bridged.contains_key(&v.number())
&& other_parts(&old_numbers, v).is_none()
})
.copied()
.collect();
diff
}
impl Diff {
pub fn change(&self) -> Change {
if !self.removed.is_empty()
|| !self.moved.is_empty()
|| !self.joined.is_empty()
|| !self.bridged.is_empty()
|| !self.entries_removed.is_empty()
{
Change::Major
} else if !self.changed.is_empty()
|| !self.added.is_empty()
|| !self.split.is_empty()
|| !self.retitled.is_empty()
|| !self.entries_added.is_empty()
|| !self.entries_changed.is_empty()
|| self.files_changed
{
Change::Minor
} else {
Change::Patch
}
}
}
pub fn version_change(old: &semver::Version, new: &semver::Version) -> Option<Change> {
if new <= old {
return None;
}
let renumbered = (new.major, new.minor, new.patch) != (old.major, old.minor, old.patch);
Some(
if new.major != old.major
|| (old.major == 0 && new.minor != old.minor)
|| (old.major == 0 && old.minor == 0 && renumbered)
{
Change::Major
} else if old.major == 0 || new.minor != old.minor {
Change::Minor
} else {
Change::Patch
},
)
}
pub fn next_version(old: &semver::Version, change: Change) -> semver::Version {
let v = |major, minor, patch| semver::Version::new(major, minor, patch);
match (change, old.major, old.minor) {
(_, 0, 0) => v(0, 0, old.patch + 1),
(Change::Major, 0, _) => v(0, old.minor + 1, 0),
(Change::Major, _, _) => v(old.major + 1, 0, 0),
(Change::Minor, 0, _) => v(0, old.minor, old.patch + 1),
(Change::Minor, _, _) => v(old.major, old.minor + 1, 0),
(Change::Patch, _, _) => v(old.major, old.minor, old.patch + 1),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn verse(chapter: u8, verse: u8, text: &str) -> VerseHash {
VerseHash {
book: Book(43),
chapter,
verse,
last: verse,
part: 0,
hash: verse_hash(text),
}
}
fn bridge(chapter: u8, verse: u8, last: u8, text: &str) -> VerseHash {
VerseHash {
last,
..self::verse(chapter, verse, text)
}
}
#[test]
fn a_split_bridge_is_minor() {
let old = [verse(3, 3, "c"), bridge(3, 4, 5, "d e"), verse(3, 6, "f")];
let new = [
verse(3, 3, "c"),
verse(3, 4, "d"),
verse(3, 5, "e"),
verse(3, 6, "f"),
];
let d = diff(&old, &new);
assert_eq!(d.split, [verse(3, 4, "d")]);
assert!(d.added.is_empty() && d.changed.is_empty() && d.removed.is_empty());
assert_eq!(d.change(), Change::Minor);
let reworded = [
verse(3, 3, "c"),
verse(3, 4, "d!"),
verse(3, 5, "e!"),
verse(3, 6, "f"),
];
let d = diff(&old, &reworded);
assert_eq!((d.split.len(), d.change()), (1, Change::Minor));
let old = [bridge(3, 4, 6, "d e f")];
let new = [verse(3, 4, "d"), bridge(3, 5, 6, "e f")];
let d = diff(&old, &new);
assert_eq!((d.split.len(), d.change()), (1, Change::Minor));
let d = diff(&[bridge(3, 4, 5, "d e")], &[verse(3, 4, "d e")]);
assert_eq!((d.bridged.len(), d.change()), (1, Change::Major));
}
#[test]
fn joined_verses_are_major() {
let old = [
verse(3, 3, "c"),
verse(3, 4, "d"),
verse(3, 5, "e"),
verse(3, 6, "f"),
];
let new = [verse(3, 3, "c"), bridge(3, 4, 5, "d e"), verse(3, 6, "f")];
let d = diff(&old, &new);
assert_eq!(d.joined, [(verse(3, 5, "e"), bridge(3, 4, 5, "d e"))]);
assert!(d.removed.is_empty() && d.changed.is_empty() && d.moved.is_empty());
assert_eq!(d.change(), Change::Major);
assert_eq!(d.joined[0].1.reference(), "JHN 3:4-5");
let old = [bridge(3, 4, 5, "d e"), verse(3, 6, "f")];
let new = [bridge(3, 4, 6, "d e f")];
let d = diff(&old, &new);
assert_eq!(d.joined, [(verse(3, 6, "f"), bridge(3, 4, 6, "d e f"))]);
assert_eq!(d.change(), Change::Major);
let d = diff(&[verse(17, 20, "a")], &[bridge(17, 20, 21, "a b")]);
assert_eq!((d.changed.len(), d.change()), (1, Change::Minor));
}
#[test]
fn diffs_and_versions() {
let old = [verse(3, 16, "a"), verse(3, 17, "b"), verse(3, 18, "c")];
assert_eq!(diff(&old, &old).change(), Change::Patch);
let worded = [verse(3, 16, "a!"), verse(3, 17, "b"), verse(3, 18, "c")];
let d = diff(&old, &worded);
assert_eq!((d.change(), d.changed.len()), (Change::Minor, 1));
assert_eq!(d.changed[0].reference(), "JHN 3:16");
let added = [
verse(3, 16, "a"),
verse(3, 17, "b"),
verse(3, 18, "c"),
verse(3, 19, "d"),
];
assert_eq!(diff(&old, &added).change(), Change::Minor);
assert_eq!(diff(&old, &old[..2]).change(), Change::Major);
let renumbered = [verse(3, 16, "a"), verse(3, 17, "x"), verse(3, 18, "b")];
let d = diff(&old, &renumbered);
assert_eq!((d.change(), d.moved.len()), (Change::Major, 1));
let v = |s: &str| semver::Version::parse(s).unwrap();
assert_eq!(
version_change(&v("1.2.0"), &v("2.0.0")),
Some(Change::Major)
);
assert_eq!(
version_change(&v("1.2.0"), &v("1.3.0")),
Some(Change::Minor)
);
assert_eq!(
version_change(&v("1.2.0"), &v("1.2.1")),
Some(Change::Patch)
);
assert_eq!(
version_change(&v("0.2.0"), &v("0.3.0")),
Some(Change::Major)
);
assert_eq!(
version_change(&v("0.2.0"), &v("0.2.1")),
Some(Change::Minor)
);
assert_eq!(version_change(&v("1.2.0"), &v("1.2.0")), None);
assert_eq!(next_version(&v("1.2.3"), Change::Minor), v("1.3.0"));
assert_eq!(next_version(&v("0.2.3"), Change::Major), v("0.3.0"));
let bytes = encode_verses(HashRule::One, &old);
assert_eq!(bytes.len(), 8 + 3 * 12);
assert_eq!(&bytes[..8], b"LXV2\x01\x00\x0c\x00");
assert_eq!(
decode_verses(&bytes).unwrap(),
(HashRule::One, old.to_vec())
);
assert!(decode_verses(b"nope").is_err());
}
#[test]
fn identical_verses_reworded_are_not_moves() {
let refrain = "Oh that men would praise the LORD";
let psalm = |eight: &str| {
[
verse(107, 8, eight),
verse(107, 9, "b"),
verse(107, 15, refrain),
verse(107, 21, refrain),
verse(107, 31, refrain),
]
};
let old = psalm(refrain);
let d = diff(&old, &psalm("Oh that men would praise the LORD!"));
assert_eq!((d.change(), d.changed.len()), (Change::Minor, 1));
assert!(d.moved.is_empty());
let d = diff(&old, &old[1..]);
assert_eq!((d.removed.len(), d.moved.len()), (1, 0));
let empty = [verse(3, 1, ""), verse(3, 2, ""), verse(3, 3, "c")];
let filled = [verse(3, 1, "a"), verse(3, 2, ""), verse(3, 3, "c")];
let d = diff(&empty, &filled);
assert_eq!((d.change(), d.moved.len()), (Change::Minor, 0));
let mut moved = psalm("x");
moved[1] = verse(107, 9, refrain);
let d = diff(&old, &moved);
assert_eq!((d.change(), d.moved.len()), (Change::Major, 1));
assert_eq!(d.moved[0].1.reference(), "JHN 107:9");
}
#[test]
fn before_0_1_every_change_is_major() {
let v = |s: &str| semver::Version::parse(s).unwrap();
assert_eq!(
version_change(&v("0.0.1"), &v("0.0.2")),
Some(Change::Major)
);
assert_eq!(
version_change(&v("0.0.1"), &v("0.1.0")),
Some(Change::Major)
);
assert_eq!(version_change(&v("0.0.2"), &v("0.0.1")), None);
for change in [Change::Patch, Change::Minor, Change::Major] {
let old = v("0.0.3");
let next = next_version(&old, change);
assert_eq!(next, v("0.0.4"));
assert!(version_change(&old, &next) >= Some(change));
}
}
#[test]
fn a_changed_title_is_a_minor_change() {
let summary = |title: &str| Summary {
summary: SUMMARY_VERSION,
verse_hash: 1,
id: "b".into(),
version: "1.0.0".parse().unwrap(),
text_checksum: "sha256:00".into(),
files_checksum: "sha256:00".into(),
verses: 0,
sizes: BTreeMap::new(),
books: IndexMap::new(),
contents: BTreeMap::new(),
media: None,
references: 0,
cited: vec![],
mappings: 0,
entries: 0,
entry_hashes: vec![],
titles: [("epub/b.epub".to_string(), title.to_string())].into(),
};
let d = diff_summaries((&summary("Freely"), &[]), (&summary("Freely Given"), &[]));
assert_eq!(
d.retitled,
[("epub/b.epub".into(), "Freely".into(), "Freely Given".into())]
);
assert_eq!(d.change(), Change::Minor);
let same = diff_summaries((&summary("Freely"), &[]), (&summary("Freely"), &[]));
assert_eq!(same.change(), Change::Patch);
}
fn entry(key: &str, text: &str) -> EntryHash {
let full = Sha256::digest(key.as_bytes());
let mut hash = [0; KEY_HASH_BYTES];
hash.copy_from_slice(&full[..KEY_HASH_BYTES]);
EntryHash {
key: hash,
hash: verse_hash(text),
}
}
#[test]
fn entries_removed_are_major_reworded_and_added_minor() {
let old = [entry("G25", "to love"), entry("G26", "love")];
assert_eq!(diff_entries(&old, &old).change(), Change::Patch);
let reworded = [entry("G25", "to love, cherish"), entry("G26", "love")];
let d = diff_entries(&old, &reworded);
assert_eq!((d.entries_changed.len(), d.change()), (1, Change::Minor));
let added = [old[0], old[1], entry("G27", "beloved")];
let d = diff_entries(&old, &added);
assert_eq!((d.entries_added.len(), d.change()), (1, Change::Minor));
let d = diff_entries(&old, &old[..1]);
assert_eq!((d.entries_removed.len(), d.change()), (1, Change::Major));
let renamed = [entry("G25", "to love"), entry("G26a", "love")];
let d = diff_entries(&old, &renamed);
assert_eq!(
(d.entries_removed.len(), d.entries_added.len(), d.change()),
(1, 1, Change::Major)
);
let bytes = encode_entries(HashRule::One, &old);
assert_eq!(&bytes[..8], b"LXE1\x01\x00\x0f\x00");
assert_eq!(bytes.len(), 8 + 2 * 15);
assert_eq!(
decode_entries(&bytes).unwrap(),
(HashRule::One, old.to_vec())
);
let mut longer = bytes[..8].to_vec();
longer[6] = 16;
for record in bytes[8..].chunks(15) {
longer.extend_from_slice(record);
longer.push(0xff);
}
assert_eq!(decode_entries(&longer).unwrap().1, old);
let mut newer = bytes.clone();
newer[4] = 2;
assert!(
decode_entries(&newer)
.unwrap_err()
.to_string()
.contains("newer lx")
);
assert!(decode_entries(b"LXV2").is_err());
assert!(is_summary_file(ENTRIES));
}
fn part(chapter: u8, verse: u8, part: u8, text: &str) -> VerseHash {
VerseHash {
part,
..self::verse(chapter, verse, text)
}
}
#[test]
fn parts_are_verses_of_their_own() {
let old = [part(3, 20, 1, "a"), part(3, 20, 2, "b"), verse(3, 21, "c")];
let d = diff(&old, &old);
assert_eq!(d.change(), Change::Patch);
let reworded = [part(3, 20, 1, "a"), part(3, 20, 2, "b!"), verse(3, 21, "c")];
let d = diff(&old, &reworded);
assert_eq!(d.changed, [part(3, 20, 2, "b!")]);
assert_eq!(d.changed[0].reference(), "JHN 3:20b");
let whole = [verse(3, 20, "a b"), verse(3, 21, "c")];
let d = diff(&whole, &old);
assert_eq!(
(d.split.len(), d.added.len(), d.change()),
(1, 0, Change::Minor)
);
let d = diff(&old, &whole);
assert_eq!(
(d.joined.len(), d.added.len(), d.change()),
(2, 0, Change::Major)
);
let d = diff(&old, &[part(3, 20, 1, "a"), verse(3, 21, "c")]);
assert_eq!((d.removed.len(), d.change()), (1, Change::Major));
}
#[test]
fn the_verse_file_header() {
let verses = [part(3, 20, 1, "a"), bridge(3, 21, 22, "b")];
let bytes = encode_verses(HashRule::One, &verses);
let mut longer = bytes[..8].to_vec();
longer[6] = 14;
for record in bytes[8..].chunks(12) {
longer.extend_from_slice(record);
longer.extend_from_slice(&[0xff, 0xff]);
}
assert_eq!(decode_verses(&longer).unwrap().1, verses);
let mut newer = bytes.clone();
newer[4] = 2;
let error = decode_verses(&newer).unwrap_err().to_string();
assert!(error.contains("newer lx"), "{error}");
assert!(decode_verses(b"LXV1\x2b\x03\x10\x10abcdefgh").is_err());
let mut short = bytes.clone();
short[6] = 11;
assert!(decode_verses(&short).is_err());
}
}