#![cfg(feature = "fs")]
use std::collections::{BTreeMap, btree_map::Entry};
use std::path::{Path, PathBuf};
use crate::pack::{Pack, PackPathRole};
#[derive(Debug, Clone, Default)]
pub struct ExtractOptions {
pub packages: bool,
pub fonts: bool,
pub force: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ExtractReport {
pub written: Vec<PathBuf>,
}
#[derive(Debug, thiserror::Error)]
pub enum ExtractError {
#[error(
"extraction path `{first_path}` ({first_role}) conflicts with `{second_path}` ({second_role})"
)]
PlannedPathConflict {
first_path: PathBuf,
first_role: PackPathRole,
second_path: PathBuf,
second_role: PackPathRole,
},
#[error("`{0}` already exists (pass force to overwrite)")]
Exists(PathBuf),
#[error("existing destination entry `{0}` conflicts with extraction")]
DestinationConflict(PathBuf),
#[error("failed to write `{path}`: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
pub fn extract(
pack: &Pack,
dir: &Path,
options: &ExtractOptions,
) -> Result<ExtractReport, ExtractError> {
let mut plan = BTreeMap::new();
for (path, data) in pack.files() {
add_to_plan(
&mut plan,
PathBuf::from(path),
PackPathRole::ProjectFile,
Some(data.as_slice()),
)?;
}
if options.packages {
for (spec, files) in pack.packages() {
let base = PathBuf::from("packages")
.join(spec.namespace.as_str())
.join(spec.name.as_str())
.join(spec.version.to_string());
for (path, data) in files {
add_to_plan(
&mut plan,
base.join(path),
PackPathRole::PackageFile,
Some(data.as_slice()),
)?;
}
}
}
if options.fonts {
for font in pack.fonts() {
add_to_plan(
&mut plan,
PathBuf::from(font.manifest().path()),
PackPathRole::FontData,
Some(font.data().as_slice()),
)?;
}
}
validate_plan(&plan)?;
preflight_destination(&plan, dir, options.force)?;
let mut report = ExtractReport::default();
for (relative, planned) in plan {
if let Some(data) = planned.data {
write_file(dir, &relative, data, &mut report)?;
}
}
Ok(report)
}
struct PlannedPath<'a> {
role: PackPathRole,
data: Option<&'a [u8]>,
}
fn add_to_plan<'a>(
plan: &mut BTreeMap<PathBuf, PlannedPath<'a>>,
relative: PathBuf,
role: PackPathRole,
data: Option<&'a [u8]>,
) -> Result<(), ExtractError> {
match plan.entry(relative) {
Entry::Occupied(existing) => {
if existing.get().role != role {
return Err(ExtractError::PlannedPathConflict {
first_path: existing.key().clone(),
first_role: existing.get().role,
second_path: existing.key().clone(),
second_role: role,
});
}
}
Entry::Vacant(entry) => {
entry.insert(PlannedPath { role, data });
}
}
Ok(())
}
fn validate_plan(plan: &BTreeMap<PathBuf, PlannedPath<'_>>) -> Result<(), ExtractError> {
let mut ancestors = Vec::<(&Path, PackPathRole)>::new();
let mut role_counts = [0usize; 5];
for (relative, planned) in plan {
while ancestors
.last()
.is_some_and(|(ancestor, _)| !relative.starts_with(ancestor))
{
let (_, role) = ancestors.pop().expect("an ancestor was present");
role_counts[role_index(role)] -= 1;
}
if ancestors.len() != role_counts[role_index(planned.role)] {
let (ancestor, ancestor_role) = ancestors
.iter()
.rev()
.find(|(_, role)| *role != planned.role)
.expect("a conflicting ancestor role was counted");
return Err(ExtractError::PlannedPathConflict {
first_path: ancestor.to_path_buf(),
first_role: *ancestor_role,
second_path: relative.clone(),
second_role: planned.role,
});
}
ancestors.push((relative, planned.role));
role_counts[role_index(planned.role)] += 1;
}
Ok(())
}
fn role_index(role: PackPathRole) -> usize {
match role {
PackPathRole::PackManifest => 0,
PackPathRole::Entrypoint => 1,
PackPathRole::ProjectFile => 2,
PackPathRole::PackageFile => 3,
PackPathRole::FontData => 4,
}
}
fn preflight_destination(
plan: &BTreeMap<PathBuf, PlannedPath<'_>>,
dir: &Path,
force: bool,
) -> Result<(), ExtractError> {
for (relative, planned) in plan {
if planned.data.is_none() {
continue;
}
let target = dir.join(relative);
match std::fs::symlink_metadata(&target) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err(ExtractError::DestinationConflict(target));
}
if metadata.is_file() {
if !force {
return Err(ExtractError::Exists(target));
}
} else {
return Err(ExtractError::DestinationConflict(target));
}
}
Err(source)
if matches!(
source.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) => {}
Err(source) => {
return Err(ExtractError::Io {
path: target,
source,
});
}
}
let mut parent = target.parent();
while let Some(path) = parent.filter(|path| path.starts_with(dir)) {
match std::fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
return Err(ExtractError::DestinationConflict(path.to_owned()));
}
Ok(_) => {}
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
Err(source) => {
return Err(ExtractError::Io {
path: path.to_owned(),
source,
});
}
}
if path == dir {
break;
}
parent = path.parent();
}
}
Ok(())
}
fn write_file(
dir: &Path,
relative: &Path,
data: &[u8],
report: &mut ExtractReport,
) -> Result<(), ExtractError> {
let target = dir.join(relative);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|source| ExtractError::Io {
path: parent.to_owned(),
source,
})?;
}
std::fs::write(&target, data).map_err(|source| ExtractError::Io {
path: target.clone(),
source,
})?;
report.written.push(relative.to_owned());
Ok(())
}