mod archive;
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use serde::Deserialize;
const INDEXED: &[&str] = &["StructureDefinition", "ValueSet", "CodeSystem"];
const MANIFEST: &str = "package.json";
const INDEX: &str = ".index.json";
const BOM: &[u8] = b"\xEF\xBB\xBF";
#[derive(Debug, Clone)]
pub struct Package {
name: String,
version: String,
dependencies: BTreeMap<String, String>,
source: PathBuf,
resources: Vec<Resource>,
}
#[derive(Debug, Clone)]
pub(crate) struct Resource {
pub(crate) file: String,
pub(crate) type_name: String,
pub(crate) url: Option<String>,
pub(crate) version: Option<String>,
pub(crate) content: Content,
}
#[derive(Debug, Clone)]
pub(crate) enum Content {
Bytes(Arc<[u8]>),
File(PathBuf),
}
impl Content {
pub(crate) fn read(&self) -> std::io::Result<Arc<[u8]>> {
match self {
Self::Bytes(bytes) => Ok(Arc::clone(bytes)),
Self::File(path) => Ok(Arc::from(strip_bom(&std::fs::read(path)?))),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum PackageError {
#[error("cannot read {path}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{path} is neither a .tgz package nor a directory")]
NotAPackage {
path: PathBuf,
},
#[error("{path} holds no package/package.json")]
NoManifest {
path: PathBuf,
},
#[error("{file} of the package at {path} is not the JSON a package carries")]
Json {
path: PathBuf,
file: String,
#[source]
source: serde_json::Error,
},
#[error(".index.json of the package at {path} names {file}, which the package does not hold")]
IndexedFileMissing {
path: PathBuf,
file: String,
},
}
#[derive(Debug, Deserialize)]
struct Manifest {
name: String,
version: String,
dependencies: Option<BTreeMap<String, String>>,
}
#[derive(Debug, Deserialize)]
struct Index {
files: Vec<IndexEntry>,
}
#[derive(Debug, Deserialize)]
struct IndexEntry {
filename: String,
#[serde(rename = "resourceType")]
type_name: String,
url: Option<String>,
version: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Header {
#[serde(rename = "resourceType")]
type_name: Option<String>,
url: Option<String>,
version: Option<String>,
}
trait Files {
fn read(&mut self, file: &str) -> Result<Option<Vec<u8>>, PackageError>;
fn content(&mut self, file: &str) -> Result<Option<Content>, PackageError>;
fn names(&mut self) -> Result<Vec<String>, PackageError>;
}
impl Package {
pub fn read(path: &Path) -> Result<Self, PackageError> {
let metadata = std::fs::metadata(path).map_err(|source| PackageError::Io {
path: path.to_owned(),
source,
})?;
if metadata.is_dir() {
let mut files = Directory {
root: path.join("package"),
};
Self::from_files(path, &mut files)
} else if metadata.is_file() {
let mut files = archive::Archive::read(path)?;
Self::from_files(path, &mut files)
} else {
Err(PackageError::NotAPackage {
path: path.to_owned(),
})
}
}
fn from_files(path: &Path, files: &mut dyn Files) -> Result<Self, PackageError> {
let manifest_bytes = files
.read(MANIFEST)?
.ok_or_else(|| PackageError::NoManifest {
path: path.to_owned(),
})?;
let manifest: Manifest = parse(path, MANIFEST, &manifest_bytes)?;
let resources = match files.read(INDEX)? {
Some(bytes) => indexed(path, files, &parse::<Index>(path, INDEX, &bytes)?)?,
None => scanned(path, files)?,
};
Ok(Self {
name: manifest.name,
version: manifest.version,
dependencies: manifest.dependencies.unwrap_or_default(),
source: path.to_owned(),
resources,
})
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn version(&self) -> &str {
&self.version
}
#[must_use]
pub const fn dependencies(&self) -> &BTreeMap<String, String> {
&self.dependencies
}
#[must_use]
pub fn source(&self) -> &Path {
&self.source
}
#[must_use]
pub fn structure_definitions(&self) -> usize {
self.resources
.iter()
.filter(|resource| resource.type_name == "StructureDefinition")
.count()
}
pub(crate) fn resources(&self) -> &[Resource] {
&self.resources
}
}
fn indexed(
path: &Path,
files: &mut dyn Files,
index: &Index,
) -> Result<Vec<Resource>, PackageError> {
let mut resources = Vec::new();
for entry in &index.files {
if !INDEXED.contains(&entry.type_name.as_str()) {
continue;
}
let Some(content) = files.content(&entry.filename)? else {
return Err(PackageError::IndexedFileMissing {
path: path.to_owned(),
file: entry.filename.clone(),
});
};
resources.push(Resource {
file: entry.filename.clone(),
type_name: entry.type_name.clone(),
url: entry.url.clone(),
version: entry.version.clone(),
content,
});
}
Ok(resources)
}
fn scanned(path: &Path, files: &mut dyn Files) -> Result<Vec<Resource>, PackageError> {
let mut resources = Vec::new();
for file in files.names()? {
if file == MANIFEST || file == INDEX {
continue;
}
let Some(bytes) = files.read(&file)? else {
continue;
};
let header: Header = parse(path, &file, &bytes)?;
let Some(type_name) = header.type_name else {
continue;
};
if !INDEXED.contains(&type_name.as_str()) {
continue;
}
resources.push(Resource {
file,
type_name,
url: header.url,
version: header.version,
content: Content::Bytes(Arc::from(strip_bom(&bytes))),
});
}
Ok(resources)
}
pub(crate) fn strip_bom(bytes: &[u8]) -> &[u8] {
bytes.strip_prefix(BOM).unwrap_or(bytes)
}
fn parse<T: serde::de::DeserializeOwned>(
path: &Path,
file: &str,
bytes: &[u8],
) -> Result<T, PackageError> {
serde_json::from_slice(strip_bom(bytes)).map_err(|source| PackageError::Json {
path: path.to_owned(),
file: file.to_owned(),
source,
})
}
struct Directory {
root: PathBuf,
}
impl Files for Directory {
fn read(&mut self, file: &str) -> Result<Option<Vec<u8>>, PackageError> {
let path = self.root.join(file);
match std::fs::read(&path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(PackageError::Io { path, source }),
}
}
fn content(&mut self, file: &str) -> Result<Option<Content>, PackageError> {
let path = self.root.join(file);
match std::fs::metadata(&path) {
Ok(metadata) if !metadata.is_dir() => Ok(Some(Content::File(path))),
Ok(_) => Ok(None),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(PackageError::Io { path, source }),
}
}
fn names(&mut self) -> Result<Vec<String>, PackageError> {
let io = |source| PackageError::Io {
path: self.root.clone(),
source,
};
let mut names = Vec::new();
for entry in std::fs::read_dir(&self.root).map_err(io)? {
let entry = entry.map_err(io)?;
if entry.file_type().map_err(io)?.is_dir() {
continue;
}
if let Some(name) = entry.file_name().to_str()
&& Path::new(name)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
{
names.push(name.to_owned());
}
}
names.sort();
Ok(names)
}
}
#[cfg(test)]
mod tests {
use super::strip_bom;
#[test]
fn a_leading_byte_order_mark_is_stripped() {
assert_eq!(strip_bom(b"\xEF\xBB\xBF{}"), b"{}");
assert_eq!(strip_bom(b"{}"), b"{}");
}
}