use std::sync::Arc;
use sha2::{Digest, Sha256};
#[cfg(feature = "read")]
use topos_bible::data::books::BookId;
use crate::{
error::{LogoxideError, Result},
manifest::{self, Enabled, Format, MANIFEST, Manifest},
storage::Storage,
};
#[cfg(feature = "read")]
use logoxide_text::{Source, source::NoText};
pub struct Package {
manifest: Manifest,
storage: Arc<dyn Storage>,
enabled: Enabled,
text: Option<(Format, String)>,
#[cfg(feature = "read")]
source: Box<dyn Source>,
}
#[derive(Clone, Debug)]
pub struct Features<'a> {
pub features: &'a [&'a str],
pub default: bool,
}
impl Default for Features<'_> {
fn default() -> Self {
Self {
features: &[],
default: true,
}
}
}
impl Package {
pub fn open(storage: Arc<dyn Storage>, features: Features) -> Result<Package> {
let manifest = Manifest::parse(&storage.read_string(MANIFEST)?)?;
if manifest.package.is_none() {
return Err(LogoxideError::Manifest(String::from(
"no [package]: this is a vault's manifest",
)));
}
let enabled = manifest.enable(features.features, features.default)?;
let mut formats: Vec<(Format, &str)> = manifest
.formats(&enabled)
.into_iter()
.filter_map(|(name, entry)| Some((Format::from_name(name)?, entry.path.as_str())))
.filter(|(format, _)| format.readable())
.collect();
formats.sort();
let kind = (manifest.package.as_ref())
.map(|p| p.kind)
.unwrap_or_default();
if kind == manifest::PackageKind::Book {
formats.clear();
}
let Some(&(format, path)) = formats.first() else {
let enabled_formats = manifest.formats(&enabled);
let recording = (manifest.formats.is_empty())
&& (manifest.media.as_ref()).is_some_and(crate::media::Media::names_its_text);
if kind == manifest::PackageKind::Bible && !recording {
let names: Vec<&str> = enabled_formats.iter().map(|(n, _)| *n).collect();
return Err(LogoxideError::NoReadableFormat(names.join(", ")));
}
return Ok(Package {
manifest,
storage,
enabled,
text: None,
#[cfg(feature = "read")]
source: Box::new(NoText::default()),
});
};
let dir = path.trim_end_matches('/').to_string();
#[cfg(feature = "read")]
let source: Box<dyn Source> = {
let text_format = (format.text_format()).expect("only readable formats are chosen");
let files = std::sync::Arc::new(crate::reader::StorageFiles(storage.clone()));
logoxide_text::source::open(files, text_format, &dir)?
};
Ok(Package {
manifest,
storage,
enabled,
text: Some((format, dir)),
#[cfg(feature = "read")]
source,
})
}
pub fn text_format(&self) -> Option<(Format, &str)> {
self.text.as_ref().map(|(f, dir)| (*f, dir.as_str()))
}
pub fn manifest(&self) -> &Manifest {
&self.manifest
}
pub fn info(&self) -> &manifest::Package {
self.manifest.package.as_ref().expect("checked when opened")
}
pub fn enabled(&self) -> &Enabled {
&self.enabled
}
#[cfg(feature = "read")]
pub fn source(&self) -> &dyn Source {
self.source.as_ref()
}
#[cfg(feature = "read")]
pub fn into_source(self) -> Box<dyn Source> {
self.source
}
pub fn storage(&self) -> &Arc<dyn Storage> {
&self.storage
}
#[cfg(feature = "read")]
pub fn books(&self) -> Vec<BookId> {
self.source.books()
}
pub fn checksum(&self) -> Result<String> {
checksum(self.storage.as_ref())
}
}
pub fn declared_files(storage: &dyn Storage) -> Result<Vec<String>> {
let manifest = Manifest::parse(&storage.read_string(MANIFEST)?)?;
let mut all = vec![];
for name in storage.list("")? {
match name.strip_suffix('/') {
Some(".git") => {}
Some(dir) => all.extend(storage.walk(dir)?),
None => all.push(name),
}
}
let mut files: Vec<String> = all
.into_iter()
.filter(|path| declares(&manifest, path))
.collect();
files.sort();
Ok(files)
}
pub fn declares(manifest: &Manifest, path: &str) -> bool {
let under = |dir: &str| {
let dir = dir.trim_matches('/');
dir.is_empty() || path == dir || path.starts_with(&format!("{dir}/"))
};
let extension = path
.rsplit_once('/')
.map_or(path, |(_, name)| name)
.rsplit_once('.')
.map(|(_, e)| e);
let has = |extensions: &[&str]| {
extension.is_some_and(|e| extensions.iter().any(|x| e.eq_ignore_ascii_case(x)))
};
let info = manifest.package.as_ref();
path == MANIFEST
|| crate::summary::is_summary_file(path)
|| info.and_then(|i| i.license_file.as_deref()) == Some(path)
|| info.and_then(|i| i.readme.as_deref()) == Some(path)
|| manifest.formats.iter().any(|(name, entry)| {
under(&entry.path)
&& (Format::from_name(name).and_then(Format::extensions))
.is_none_or(|extensions| has(extensions) || path == entry.path)
})
|| (manifest.media.as_ref())
.and_then(|m| m.timings.as_deref())
.is_some_and(|dir| under(dir) && has(&["vtt"]))
|| (manifest.media.iter())
.flat_map(|m| &m.files)
.any(|f| f.timings.as_deref() == Some(path))
|| info.is_some_and(|i| i.include.iter().any(|pattern| glob(pattern, path)))
}
pub(crate) fn glob(pattern: &str, path: &str) -> bool {
if let Some(dir) = pattern.strip_suffix('/') {
return path.starts_with(&format!("{dir}/"));
}
let pattern: Vec<&str> = pattern.split('/').collect();
let path: Vec<&str> = path.split('/').collect();
glob_parts(&pattern, &path)
}
fn glob_parts(pattern: &[&str], path: &[&str]) -> bool {
wildcard(
pattern,
path,
|part| *part == "**",
|part, name| glob_name(part.as_bytes(), name.as_bytes()),
)
}
fn glob_name(pattern: &[u8], name: &[u8]) -> bool {
wildcard(pattern, name, |b| *b == b'*', |p, c| *p == b'?' || p == c)
}
fn wildcard<P, T>(
pattern: &[P],
text: &[T],
star: impl Fn(&P) -> bool,
one: impl Fn(&P, &T) -> bool,
) -> bool {
let (mut p, mut t) = (0, 0);
let mut last: Option<(usize, usize)> = None;
while t < text.len() {
match pattern.get(p) {
Some(item) if star(item) => {
last = Some((p, t));
p += 1;
}
Some(item) if one(item, &text[t]) => {
p += 1;
t += 1;
}
_ => match last {
Some((star_at, run_end)) => {
last = Some((star_at, run_end + 1));
p = star_at + 1;
t = run_end + 1;
}
None => return false,
},
}
}
pattern[p..].iter().all(star)
}
pub fn checksum(storage: &dyn Storage) -> Result<String> {
checksum_of(storage, &declared_files(storage)?)
}
pub fn other_files(storage: &dyn Storage) -> Result<Vec<String>> {
let declared = declared_files(storage)?;
let mut others = vec![];
for name in storage.list("")? {
let paths = match name.strip_suffix('/') {
Some(".git") => continue,
Some(dir) => storage.walk(dir)?,
None => vec![name],
};
others.extend(paths.into_iter().filter(|p| !declared.contains(p)));
}
others.sort();
Ok(others)
}
pub fn checksum_of(storage: &dyn Storage, paths: &[String]) -> Result<String> {
let mut hasher = Sha256::new();
for path in paths {
storage.with_bytes(path, &mut |bytes| {
hasher.update(path.as_bytes());
hasher.update([0]);
hasher.update((bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
})?;
}
let hex: String = hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
Ok(format!("sha256:{hex}"))
}
pub fn only_declared(storage: &dyn Storage) -> Result<crate::MemoryStorage> {
let copy = crate::MemoryStorage::new();
for path in declared_files(storage)? {
copy.write(&path, &storage.read(&path)?)?;
}
Ok(copy)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::MemoryStorage;
#[test]
fn globs() {
assert!(glob("README.md", "README.md"));
assert!(!glob("README.md", "docs/README.md"));
assert!(glob("*.md", "NOTES.md"));
assert!(!glob("*.md", "docs/NOTES.md"));
assert!(glob("images/**", "images/a/b.png"));
assert!(glob("**/*.png", "a.png"));
assert!(glob("**/*.png", "x/y/a.png"));
assert!(glob("ch?.txt", "ch1.txt"));
assert!(glob("extras/", "extras/a/b"));
assert!(!glob("extras/", "extras"));
assert!(glob("**", "a/b/c"));
assert!(glob("a/**/c/*.txt", "a/c/x.txt"));
assert!(glob("a/**/c/*.txt", "a/b/b/c/x.txt"));
assert!(!glob("a/**/c/*.txt", "a/b/c/d/x.txt"));
assert!(glob("*a*b", "xaxxab"));
assert!(!glob("*a*b", "xaxxa"));
assert!(!glob("ch?.txt", "ch.txt"));
assert!(glob("**/x/**/y", "x/y"));
assert!(!glob("**/x/**/y", "x/z"));
}
#[test]
fn pathological_globs_finish_fast() {
let start = std::time::Instant::now();
let name = "a".repeat(200);
assert!(!glob(&format!("{}b", "*a".repeat(30)), &name));
let path = vec!["a"; 60].join("/");
let pattern = format!("{}b", "**/a/".repeat(25));
assert!(!glob(&pattern, &path));
assert!(glob(&vec!["**"; 25].join("/"), &path));
assert!(start.elapsed() < std::time::Duration::from_secs(1));
}
#[test]
fn a_package_is_what_it_declares() {
let files = MemoryStorage::new();
let manifest = "[package]\nid = \"t\"\nname = \"T\"\nabbreviation = \"T\"\nversion = \"1.0.0\"\nlanguage = \"en\"\nlicense-file = \"LICENSE.txt\"\ninclude = [\"images/**\"]\n\n[formats]\nusx = { path = \"usx/\" }\nsword = { path = \"sword/\" }\n\n[media]\ntimings = \"timings/\"\n";
for (path, text) in [
("logoxide.toml", manifest),
("LICENSE.txt", "free"),
("README.md", "not declared"),
("usx/JHN.usx", "<usx/>"),
("usx/JHN - Annotations.md", "a reader's note"),
("sword/mods.d/t.conf", "[T]"),
("timings/JHN.vtt", "WEBVTT"),
("timings/notes.md", "not a timing"),
("images/a/map.png", "png"),
(".git/HEAD", "ref"),
] {
files.write(path, text.as_bytes()).unwrap();
}
assert_eq!(
declared_files(&files).unwrap(),
[
"LICENSE.txt",
"images/a/map.png",
"logoxide.toml",
"sword/mods.d/t.conf",
"timings/JHN.vtt",
"usx/JHN.usx"
]
);
assert_eq!(
other_files(&files).unwrap(),
["README.md", "timings/notes.md", "usx/JHN - Annotations.md"]
);
let before = checksum(&files).unwrap();
files.write("usx/JHN - Annotations.md", b"more").unwrap();
assert_eq!(checksum(&files).unwrap(), before);
files.write("usx/JHN.usx", b"<usx>changed</usx>").unwrap();
assert_ne!(checksum(&files).unwrap(), before);
}
}