use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use energyml_core::path;
use energyml_core::registry::DynObject;
use energyml_core::value::{FieldValue, OwnedValue, Reflect, Scalar};
use crate::array::ArrayLocation;
use crate::epc::EpcStore;
use crate::error::{Error, Result};
use crate::graph;
use crate::resource::ObjectSummary;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Keep {
Current,
Imported,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Datasets {
#[default]
Undecided,
Separate {
names: BTreeMap<String, String>,
},
Merge {
into: Option<String>,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Request {
pub wanted: Vec<String>,
pub all: Option<Keep>,
pub each: BTreeMap<String, Keep>,
pub datasets: Datasets,
}
impl Request {
fn decision_for(&self, uuid: &str) -> Option<Keep> {
let key = uuid.to_ascii_lowercase();
self.each
.iter()
.find(|(k, _)| k.to_ascii_lowercase() == key)
.map(|(_, keep)| *keep)
.or(self.all)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Fate {
New,
Identical,
Replaces,
KeptCurrent,
Undecided,
Refused,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Incoming {
pub uri: String,
pub uuid: String,
pub qualified_type: String,
pub title: Option<String>,
pub asked: bool,
pub fate: Fate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConflictKind {
Differs,
Release,
Concept,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Conflict {
pub uuid: String,
pub here: String,
pub there: String,
pub title_here: Option<String>,
pub title_there: Option<String>,
pub kind: ConflictKind,
pub differences: Vec<String>,
pub total: usize,
pub decision: Option<Keep>,
pub pointed_by_imported: Vec<String>,
pub pointed_by_current: Vec<String>,
}
pub const DIFFERENCES: usize = 20;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pointer {
pub object: String,
pub field_path: String,
pub proxy: Option<String>,
pub from_file: String,
pub from_path: String,
pub to_file: String,
pub to_path: String,
pub new_uri: Option<String>,
pub new_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DatasetCopy {
pub from_file: String,
pub from_path: String,
pub to_file: String,
pub to_path: String,
pub relocated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileCarry {
pub from: String,
pub held: String,
pub to: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DatasetPlan {
pub mode: Datasets,
pub decided: bool,
pub files_here: Vec<String>,
pub files_there: Vec<String>,
pub target: Option<String>,
pub proposed: BTreeMap<String, String>,
pub pointers: Vec<Pointer>,
pub copies: Vec<DatasetCopy>,
pub carries: Vec<FileCarry>,
pub proxies: BTreeMap<String, String>,
pub shared_proxies: Vec<String>,
pub orphaned: Vec<String>,
pub inherited: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Plan {
pub objects: Vec<Incoming>,
pub unknown: Vec<String>,
pub added: Vec<String>,
pub resolved_here: Vec<String>,
pub missing: Vec<String>,
pub conflicts: Vec<Conflict>,
pub new_versions: Vec<String>,
pub datasets: DatasetPlan,
pub refusals: Vec<String>,
}
impl Plan {
pub fn undecided(&self) -> usize {
self.objects
.iter()
.filter(|one| one.fate == Fate::Undecided)
.count()
+ usize::from(!self.datasets.decided)
}
pub fn to_write(&self) -> usize {
self.objects
.iter()
.filter(|one| matches!(one.fate, Fate::New | Fate::Replaces))
.count()
}
pub fn is_allowed(&self) -> bool {
self.refusals.is_empty() && self.undecided() == 0
}
}
pub fn plan(from: &EpcStore, into: &EpcStore, request: &Request) -> Plan {
let mut out = Plan::default();
let (there, unreadable_there) = from.read_objects(None);
let (here, unreadable_here) = into.read_objects(None);
let graph_there = graph::build(there.iter().map(|(s, o)| (s, o.as_reflect())));
let graph_here = graph::build(here.iter().map(|(s, o)| (s, o.as_reflect())));
let object_there: HashMap<&str, (&ObjectSummary, &dyn DynObject)> = there
.iter()
.map(|(s, o)| (s.uri.as_str(), (s, o.as_ref())))
.collect();
let mut here_all: HashMap<String, Vec<HereEntry<'_>>> = HashMap::new();
for (s, o) in &here {
here_all.entry(s.uuid.to_ascii_lowercase()).or_default().push((
s,
o.as_ref(),
version_of(o.as_reflect()),
));
}
let here_by_uuid: HashMap<String, (&ObjectSummary, &dyn DynObject)> = here_all
.iter()
.filter_map(|(uuid, held)| held.first().map(|(s, o, _)| (uuid.clone(), (*s, *o))))
.collect();
let listed_there: Vec<ObjectSummary> = from.resources();
let listed: HashSet<&str> = listed_there.iter().map(|s| s.uri.as_str()).collect();
let seeds: Vec<String> = if request.wanted.is_empty() {
there.iter().map(|(s, _)| s.uri.clone()).collect()
} else {
request.wanted.clone()
};
for uri in &request.wanted {
if !listed.contains(uri.as_str()) {
out.unknown.push(uri.clone());
out.refusals.push(format!("{uri} is not in the imported package"));
} else if !object_there.contains_key(uri.as_str()) {
out.refusals.push(format!(
"{uri} is in the imported package and cannot be read, so it cannot be imported"
));
}
}
let closed = graph::closure(&graph_there, &seeds);
let asked: HashSet<&str> = seeds.iter().map(String::as_str).collect();
out.added = closed
.needed
.iter()
.filter(|uri| !asked.contains(uri.as_str()))
.cloned()
.collect();
let uuid_of_node: HashMap<&str, &str> = graph_there
.nodes
.iter()
.map(|n| (n.uri.as_str(), n.uuid.as_str()))
.collect();
for uri in &closed.missing {
let uuid = uuid_of_node.get(uri.as_str()).copied().unwrap_or_default();
if from.find_by_uuid(uuid).is_some() {
out.refusals.push(format!(
"{uri} is needed by what is imported, and the imported package holds it but \
cannot read it{}",
unreadable_there
.iter()
.find(|why| why.contains(uuid))
.map(|why| format!(" ({why})"))
.unwrap_or_default()
));
} else if here_by_uuid.contains_key(&uuid.to_ascii_lowercase()) {
out.resolved_here.push(uri.clone());
} else {
out.missing.push(uri.clone());
}
}
let uuid_of_here: HashMap<&str, &str> = graph_here
.nodes
.iter()
.map(|n| (n.uri.as_str(), n.uuid.as_str()))
.collect();
let mut referrers_here: HashMap<String, Vec<String>> = HashMap::new();
for edge in &graph_here.edges {
if let Some(uuid) = uuid_of_here.get(edge.to.as_str()) {
push_once(
referrers_here.entry(uuid.to_ascii_lowercase()).or_default(),
&edge.from,
);
}
}
let needed: HashSet<&str> = closed.needed.iter().map(String::as_str).collect();
let mut referrers_there: HashMap<String, Vec<String>> = HashMap::new();
for edge in &graph_there.edges {
if !needed.contains(edge.from.as_str()) {
continue;
}
if let Some(uuid) = uuid_of_node.get(edge.to.as_str()) {
push_once(
referrers_there.entry(uuid.to_ascii_lowercase()).or_default(),
&edge.from,
);
}
}
let type_key = |qualified: &str| -> String {
graph_there
.nodes
.iter()
.chain(graph_here.nodes.iter())
.find(|n| n.qualified_type == qualified)
.map(|n| n.type_key.clone())
.unwrap_or_else(|| qualified.to_string())
};
for uri in &closed.needed {
let Some((summary, object)) = object_there.get(uri.as_str()).copied() else {
continue;
};
let key = summary.uuid.to_ascii_lowercase();
let title = energyml_core::identity::title_of_reflect(object.as_reflect()).map(str::to_string);
let mut incoming = Incoming {
uri: summary.uri.clone(),
uuid: summary.uuid.clone(),
qualified_type: summary.qualified_type.clone(),
title: title.clone(),
asked: asked.contains(uri.as_str()),
fate: Fate::New,
};
if !here_by_uuid.contains_key(&key) && into.find_by_uuid(&summary.uuid).is_some() {
out.refusals.push(format!(
"{} is held by the open package and cannot be read there, so nothing can say \
whether importing {} would replace a different object{}",
summary.uuid,
summary.uri,
unreadable_here
.iter()
.find(|why| why.contains(&summary.uuid))
.map(|why| format!(" ({why})"))
.unwrap_or_default()
));
incoming.fate = Fate::Refused;
} else if let Some((current, current_object)) = {
let held = here_all.get(&key).map(Vec::as_slice).unwrap_or_default();
let version_there = version_of(object.as_reflect());
let same_type: Vec<_> = held
.iter()
.filter(|(s, _, _)| s.qualified_type == summary.qualified_type)
.collect();
match same_type
.iter()
.find(|(_, _, held_version)| *held_version == version_there)
{
Some((s, o, _)) => Some((*s, *o)),
None if !same_type.is_empty() => {
out.new_versions.push(summary.uri.clone());
None
}
None => here_by_uuid.get(&key).copied(),
}
} {
if is_proxy(&summary.qualified_type) && is_proxy(¤t.qualified_type) {
incoming.fate = Fate::KeptCurrent;
} else {
let kind = if current.qualified_type == summary.qualified_type {
ConflictKind::Differs
} else if type_key(¤t.qualified_type) == type_key(&summary.qualified_type) {
ConflictKind::Release
} else {
ConflictKind::Concept
};
let (differences, total) = if kind == ConflictKind::Differs {
differences(current_object.as_reflect(), object.as_reflect(), DIFFERENCES)
} else {
(
vec![format!("{} → {}", current.qualified_type, summary.qualified_type)],
1,
)
};
if total == 0 {
incoming.fate = Fate::Identical;
} else {
let decision = request.decision_for(&summary.uuid);
incoming.fate = match (kind, decision) {
(ConflictKind::Concept, _) => {
out.refusals.push(format!(
"{} is a {} here and a {} in the imported package: two concepts \
under one uuid, and neither can stand for the other",
summary.uuid, current.qualified_type, summary.qualified_type
));
Fate::Refused
}
(ConflictKind::Release, Some(Keep::Imported)) => {
out.refusals.push(format!(
"{} is a {} here and a {} in the imported package: a release is \
not replaced in place (a translation mints a new uuid), so only \
the current one can be kept",
summary.uuid, current.qualified_type, summary.qualified_type
));
Fate::Refused
}
(_, Some(Keep::Current)) => Fate::KeptCurrent,
(_, Some(Keep::Imported)) => Fate::Replaces,
(_, None) => Fate::Undecided,
};
out.conflicts.push(Conflict {
uuid: summary.uuid.clone(),
here: current.uri.clone(),
there: summary.uri.clone(),
title_here: energyml_core::identity::title_of_reflect(current_object.as_reflect())
.map(str::to_string),
title_there: title,
kind,
differences,
total,
decision,
pointed_by_imported: referrers_there.get(&key).cloned().unwrap_or_default(),
pointed_by_current: referrers_here.get(&key).cloned().unwrap_or_default(),
});
}
}
}
out.objects.push(incoming);
}
out.datasets = plan_datasets(
from,
into,
request,
&out.objects,
&object_there,
&here,
&mut out.refusals,
);
if out.refusals.is_empty() && out.undecided() == 0 && out.to_write() == 0 {
out.refusals.push(
"nothing to import: every object of the imported set is already here, identical \
or kept"
.to_string(),
);
}
out
}
type HereEntry<'a> = (&'a ObjectSummary, &'a dyn DynObject, Option<String>);
fn push_once(list: &mut Vec<String>, value: &str) {
if !list.iter().any(|one| one == value) {
list.push(value.to_string());
}
}
fn version_of(object: &dyn Reflect) -> Option<String> {
energyml_core::identity::object_version_of_reflect(object)
.map(str::trim)
.filter(|version| !version.is_empty())
.map(str::to_string)
}
fn is_proxy(qualified_type: &str) -> bool {
qualified_type.contains("EpcExternalPartReference")
}
#[allow(clippy::too_many_arguments)]
fn plan_datasets(
from: &EpcStore,
into: &EpcStore,
request: &Request,
objects: &[Incoming],
object_there: &HashMap<&str, (&ObjectSummary, &dyn DynObject)>,
here: &[(ObjectSummary, Box<dyn DynObject>)],
refusals: &mut Vec<String>,
) -> DatasetPlan {
let mut out = DatasetPlan {
mode: request.datasets.clone(),
..Default::default()
};
let replaced: HashSet<String> = objects
.iter()
.filter(|one| one.fate == Fate::Replaces)
.map(|one| one.uuid.to_ascii_lowercase())
.collect();
let mut files_here: Vec<String> = Vec::new();
let mut taken: HashSet<(String, String)> = HashSet::new();
let mut kept_paths: HashSet<(String, String)> = HashSet::new();
let mut replaced_paths: Vec<(String, String)> = Vec::new();
for (summary, object) in here {
for location in into.arrays_of(object.as_reflect()) {
if location.file.is_empty() {
continue;
}
let file = bare(&location.file).to_string();
push_once(&mut files_here, &file);
let key = (file, location.path_in_file.clone());
taken.insert(key.clone());
if replaced.contains(&summary.uuid.to_ascii_lowercase()) {
replaced_paths.push(key);
} else {
kept_paths.insert(key);
}
}
}
for file in into.external_file_map().into_values() {
push_once(&mut files_here, bare(&file));
}
out.files_here = files_here;
for (file, path) in replaced_paths {
if !kept_paths.contains(&(file.clone(), path.clone())) {
push_once(&mut out.orphaned, &format!("{file}:{path}"));
}
}
struct Raw {
object: String,
location: ArrayLocation,
}
let mut raw: Vec<Raw> = Vec::new();
for one in objects
.iter()
.filter(|one| matches!(one.fate, Fate::New | Fate::Replaces))
{
let Some((_, object)) = object_there.get(one.uri.as_str()).copied() else {
continue;
};
for location in from.arrays_of(object.as_reflect()) {
raw.push(Raw {
object: one.uri.clone(),
location,
});
}
}
for pointer in &raw {
if !pointer.location.file.is_empty() {
push_once(&mut out.files_there, &pointer.location.file);
}
}
if raw.is_empty() {
out.decided = true;
return out;
}
let held_here = |uuid: &str| into.find_by_uuid(uuid).is_some();
for pointer in &raw {
if let Some(proxy) = &pointer.location.proxy_uuid
&& held_here(proxy)
{
push_once(&mut out.shared_proxies, &proxy.to_ascii_lowercase());
}
}
match &request.datasets {
Datasets::Undecided => {
out.decided = false;
}
Datasets::Separate { names } => {
out.decided = true;
separate(
from,
into,
names,
&raw.iter()
.map(|r| (r.object.as_str(), &r.location))
.collect::<Vec<_>>(),
&mut out,
refusals,
);
}
Datasets::Merge { into: chosen } => {
out.decided = true;
merge(
from,
into,
chosen.as_deref(),
&raw.iter()
.map(|r| (r.object.as_str(), &r.location))
.collect::<Vec<_>>(),
&taken,
&mut out,
refusals,
);
}
}
out
}
fn separate(
from: &EpcStore,
into: &EpcStore,
names: &BTreeMap<String, String>,
raw: &[(&str, &ArrayLocation)],
out: &mut DatasetPlan,
refusals: &mut Vec<String>,
) {
for proxy in &out.shared_proxies {
refusals.push(format!(
"the proxy {proxy} is in both packages and imported objects go through it: kept \
apart, one proxy would stand for two files - merge the datasets instead"
));
}
let used_here = |name: &str| -> bool {
out.files_here.iter().any(|one| bare(one) == bare(name))
|| into
.external_files_ref()
.present()
.iter()
.any(|one| bare(one) == bare(name))
|| (!into.external_files_ref().is_virtual()
&& into
.external_files_ref()
.destination_for(bare(name))
.is_ok_and(|path| path.exists()))
};
let mut chosen: BTreeMap<String, String> = BTreeMap::new();
let mut claimed: BTreeSet<String> = BTreeSet::new();
for file in out.files_there.clone() {
let proposal = if used_here(&file) {
free_name(bare(&file), |candidate| {
used_here(candidate) || claimed.contains(candidate)
})
} else {
bare(&file).to_string()
};
out.proposed.insert(file.clone(), proposal.clone());
let name = names
.iter()
.find(|(k, _)| k.as_str() == file || bare(k) == bare(&file))
.map(|(_, v)| v.clone())
.unwrap_or(proposal);
if name.trim().is_empty() {
refusals.push(format!("the name given to `{file}` is empty"));
} else if name.contains('/') || name.contains('\\') {
refusals.push(format!(
"`{name}` is a path; EPC §3.2.3 names an external file by a bare name"
));
} else if used_here(&name) {
refusals.push(format!(
"`{name}` is already a data file of the open package: two files under one name \
would become one pointer"
));
} else if !claimed.insert(name.clone()) {
refusals.push(format!("two imported files would both be called `{name}`"));
}
let files = from.external_files_ref();
let held = if files.is_virtual() {
let present = files.present();
present
.iter()
.find(|one| bare(one) == bare(&file))
.cloned()
.or_else(|| (present.len() == 1).then(|| present[0].clone()))
} else {
files.resolve(&file).ok().map(|path| path.display().to_string())
};
match held {
Some(held) => out.carries.push(FileCarry {
from: file.clone(),
held,
to: name.clone(),
}),
None => refusals.push(format!(
"`{file}` is named by the imported objects and is not beside the imported package"
)),
}
chosen.insert(file, name);
}
for (object, location) in raw {
if location.file.is_empty() {
push_once(
&mut out.inherited,
&format!(
"{object}: {} names no file the imported package can resolve",
location.field_path
),
);
continue;
}
let to_file = chosen
.get(&location.file)
.cloned()
.unwrap_or_else(|| location.file.clone());
let new_uri = (location.proxy_uuid.is_none() && bare(&location.file) != to_file)
.then(|| renamed(&location.file, &to_file));
if let Some(proxy) = &location.proxy_uuid
&& !out.shared_proxies.contains(&proxy.to_ascii_lowercase())
{
out.proxies.insert(proxy.to_ascii_lowercase(), to_file.clone());
}
out.pointers.push(Pointer {
object: object.to_string(),
field_path: location.field_path.clone(),
proxy: location.proxy_uuid.clone(),
from_file: location.file.clone(),
from_path: location.path_in_file.clone(),
to_file,
to_path: location.path_in_file.clone(),
new_uri,
new_path: None,
});
}
}
fn merge(
from: &EpcStore,
into: &EpcStore,
chosen: Option<&str>,
raw: &[(&str, &ArrayLocation)],
taken: &HashSet<(String, String)>,
out: &mut DatasetPlan,
refusals: &mut Vec<String>,
) {
let target = match chosen.map(str::trim).filter(|one| !one.is_empty()) {
Some(name) if name.contains('/') || name.contains('\\') => {
refusals.push(format!(
"`{name}` is a path; EPC §3.2.3 names an external file by a bare name"
));
return;
}
Some(name) => name.to_string(),
None => match out.files_here.as_slice() {
[only] => only.clone(),
[] => {
refusals.push(
"the open package names no data file: name one to merge the datasets into".to_string(),
);
return;
}
several => {
refusals.push(format!(
"the open package names {} data files ({}): say which one to merge into",
several.len(),
several.join(", ")
));
return;
}
},
};
out.target = Some(target.clone());
let file_for = |location: &ArrayLocation| -> String {
match &location.proxy_uuid {
Some(proxy) if out.shared_proxies.contains(&proxy.to_ascii_lowercase()) => into
.external_file_of(proxy)
.map(|file| bare(&file).to_string())
.unwrap_or_else(|| target.clone()),
_ => target.clone(),
}
};
let mut copies: Vec<DatasetCopy> = Vec::new();
let mut index: HashMap<(String, String), usize> = HashMap::new();
for (object, location) in raw {
if index.contains_key(&(location.file.clone(), location.path_in_file.clone())) {
continue;
}
let probe = ArrayLocation {
file: location.file.clone(),
path_in_file: location.path_in_file.clone(),
mime: location.mime.clone(),
..Default::default()
};
if location.file.is_empty() {
refusals.push(format!(
"{object}: {} names no file the imported package can resolve, so there is \
nothing to copy",
location.field_path
));
continue;
}
if let Err(why) = from.array_metadata(&probe) {
refusals.push(format!(
"{object}: the imported package cannot read `{}:{}` ({why}), and merging would \
point at numbers never copied",
location.file, location.path_in_file
));
continue;
}
index.insert(
(location.file.clone(), location.path_in_file.clone()),
copies.len(),
);
copies.push(DatasetCopy {
from_file: location.file.clone(),
from_path: location.path_in_file.clone(),
to_file: file_for(location),
to_path: location.path_in_file.clone(),
relocated: false,
});
}
let probed_taken = |file: &str, path_in_file: &str| -> bool {
taken.contains(&(file.to_string(), path_in_file.to_string()))
|| into.holds_array(&ArrayLocation {
file: file.to_string(),
path_in_file: path_in_file.to_string(),
..Default::default()
})
};
let mut assigned: HashSet<(String, String)> = HashSet::new();
let mut colliding: Vec<usize> = Vec::new();
for (at, copy) in copies.iter().enumerate() {
let key = (copy.to_file.clone(), copy.to_path.clone());
if probed_taken(©.to_file, ©.to_path) || !assigned.insert(key) {
colliding.push(at);
}
}
if !colliding.is_empty() {
let mut n = 1usize;
let relocated = |n: usize, copy: &DatasetCopy| prefixed(n, ©.to_path);
while colliding.iter().any(|at| {
let copy = &copies[*at];
let moved = relocated(n, copy);
probed_taken(©.to_file, &moved) || assigned.contains(&(copy.to_file.clone(), moved))
}) {
n += 1;
}
for at in colliding {
let moved = relocated(n, &copies[at]);
copies[at].to_path = moved;
copies[at].relocated = true;
}
}
for (object, location) in raw {
let Some(at) = index.get(&(location.file.clone(), location.path_in_file.clone())) else {
continue;
};
let copy = &copies[*at];
let new_uri =
(location.proxy_uuid.is_none() && location.file != copy.to_file).then(|| copy.to_file.clone());
if let Some(proxy) = &location.proxy_uuid
&& !out.shared_proxies.contains(&proxy.to_ascii_lowercase())
{
out.proxies
.insert(proxy.to_ascii_lowercase(), copy.to_file.clone());
}
out.pointers.push(Pointer {
object: object.to_string(),
field_path: location.field_path.clone(),
proxy: location.proxy_uuid.clone(),
from_file: location.file.clone(),
from_path: location.path_in_file.clone(),
to_file: copy.to_file.clone(),
to_path: copy.to_path.clone(),
new_uri,
new_path: copy.relocated.then(|| copy.to_path.clone()),
});
}
out.copies = copies;
}
fn prefixed(n: usize, path_in_file: &str) -> String {
if path_in_file.starts_with('/') {
format!("/import-{n}{path_in_file}")
} else {
format!("/import-{n}/{path_in_file}")
}
}
fn free_name(name: &str, used: impl Fn(&str) -> bool) -> String {
let (stem, extension) = match name.rfind('.') {
Some(at) if at > 0 => (&name[..at], &name[at..]),
_ => (name, ""),
};
let mut n = 1usize;
loop {
let candidate = if n == 1 {
format!("{stem}-import{extension}")
} else {
format!("{stem}-import-{n}{extension}")
};
if !used(&candidate) {
return candidate;
}
n += 1;
}
}
fn bare(name: &str) -> &str {
name.rsplit(['/', '\\']).next().unwrap_or(name)
}
fn renamed(declared: &str, to: &str) -> String {
match declared.rfind(['/', '\\']) {
Some(at) => format!("{}{to}", &declared[..=at]),
None => to.to_string(),
}
}
pub fn differences(a: &dyn Reflect, b: &dyn Reflect, limit: usize) -> (Vec<String>, usize) {
let mut found = Vec::new();
let mut total = 0usize;
walk_objects(a, b, String::new(), limit, &mut found, &mut total, 0);
(found, total)
}
fn note(path: String, limit: usize, found: &mut Vec<String>, total: &mut usize) {
*total += 1;
if found.len() < limit {
found.push(if path.is_empty() {
"(the object itself)".to_string()
} else {
path
});
}
}
fn walk_objects(
a: &dyn Reflect,
b: &dyn Reflect,
path: String,
limit: usize,
found: &mut Vec<String>,
total: &mut usize,
depth: u32,
) {
if depth > 64 {
return;
}
let (ta, tb) = (a.type_info(), b.type_info());
if ta.name != tb.name || ta.schema_version != tb.schema_version {
note(path, limit, found, total);
return;
}
for field in ta.fields {
let at = if path.is_empty() {
field.xml_name.to_string()
} else {
format!("{path}.{}", field.xml_name)
};
walk_values(
a.field(field.name),
b.field(field.name),
at,
limit,
found,
total,
depth,
);
}
}
fn walk_values(
a: Option<FieldValue<'_>>,
b: Option<FieldValue<'_>>,
path: String,
limit: usize,
found: &mut Vec<String>,
total: &mut usize,
depth: u32,
) {
let empty = |v: &Option<FieldValue<'_>>| v.as_ref().is_none_or(FieldValue::is_empty);
if empty(&a) && empty(&b) {
return;
}
match (a, b) {
(Some(FieldValue::Scalar(x)), Some(FieldValue::Scalar(y))) => {
if !same_scalar(x, y) {
note(path, limit, found, total);
}
}
(Some(FieldValue::Object(x)), Some(FieldValue::Object(y))) => {
walk_objects(x, y, path, limit, found, total, depth + 1);
}
(Some(FieldValue::List(xs)), Some(FieldValue::List(ys))) => {
if xs.len() != ys.len() {
note(
format!("{path} ({} → {} entries)", xs.len(), ys.len()),
limit,
found,
total,
);
return;
}
for (i, (x, y)) in xs.into_iter().zip(ys).enumerate() {
walk_values(
Some(x),
Some(y),
format!("{path}[{i}]"),
limit,
found,
total,
depth,
);
}
}
_ => note(path, limit, found, total),
}
}
fn same_scalar(a: Scalar<'_>, b: Scalar<'_>) -> bool {
match (a, b) {
(Scalar::Double(x), Scalar::Double(y)) => x == y || (x.is_nan() && y.is_nan()),
(x, y) => x == y,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Applied {
pub written: Vec<String>,
pub replaced: Vec<String>,
pub new_versions: Vec<String>,
pub fields: usize,
pub proxies: usize,
pub copies: Vec<DatasetCopy>,
pub carries: Vec<FileCarry>,
}
pub fn apply(
from: &EpcStore,
into: &mut EpcStore,
request: &Request,
shown: Option<&Plan>,
) -> Result<Applied> {
let quote = plan(from, into, request);
if shown.is_some_and(|shown| *shown != quote) {
return Err(Error::Package(
"the packages changed since this import was planned: plan it again".to_string(),
));
}
apply_plan(from, into, "e)
}
pub fn apply_plan(from: &EpcStore, into: &mut EpcStore, quote: &Plan) -> Result<Applied> {
if !quote.is_allowed() {
let mut why = quote.refusals.clone();
let undecided = quote
.objects
.iter()
.filter(|one| one.fate == Fate::Undecided)
.count();
if undecided > 0 {
why.push(format!("{undecided} conflict(s) not decided"));
}
if !quote.datasets.decided {
why.push("what becomes of the datasets is not decided".to_string());
}
return Err(Error::Package(format!(
"this import was refused: {}",
why.join("; ")
)));
}
let mut done = Applied::default();
let mut by_object: HashMap<&str, Vec<&Pointer>> = HashMap::new();
for pointer in "e.datasets.pointers {
by_object
.entry(pointer.object.as_str())
.or_default()
.push(pointer);
}
for one in quote
.objects
.iter()
.filter(|one| matches!(one.fate, Fate::New | Fate::Replaces))
{
let mut object = from.get_object(&one.uri)?;
for pointer in by_object.get(one.uri.as_str()).into_iter().flatten() {
done.fields += rewrite(object.as_reflect_mut(), pointer)?;
}
if quote.new_versions.contains(&one.uri) {
into.put_as_new_version(object)?;
done.new_versions.push(one.uri.clone());
} else {
into.put(object)?;
}
done.written.push(one.uri.clone());
if one.fate == Fate::Replaces {
done.replaced.push(one.uri.clone());
}
}
for (proxy, file) in "e.datasets.proxies {
into.register_external_file(proxy, file.clone());
done.proxies += 1;
}
if into.external_files_ref().is_virtual() {
for carry in "e.datasets.carries {
into.external_files_mut().declare_present(carry.to.clone());
}
if let Some(target) = "e.datasets.target {
into.external_files_mut().declare_present(target.clone());
}
}
done.copies = quote.datasets.copies.clone();
done.carries = quote.datasets.carries.clone();
Ok(done)
}
fn rewrite(object: &mut dyn Reflect, pointer: &Pointer) -> Result<usize> {
if pointer.new_uri.is_none() && pointer.new_path.is_none() {
return Ok(0);
}
let part = path::object_mut(object, &pointer.field_path)?;
let path_field = crate::array::path_field(part.type_info().name);
let file_field = crate::array::file_field(part.type_info().name);
let mut fields = 0;
if let Some(uri) = &pointer.new_uri {
part.set_field(file_field, OwnedValue::Str(uri.clone()))?;
fields += 1;
}
if let Some(moved) = &pointer.new_path {
part.set_field(path_field, OwnedValue::Str(moved.clone()))?;
fields += 1;
}
Ok(fields)
}
pub fn copy_datasets(from: &EpcStore, into: &EpcStore, copies: &[DatasetCopy]) -> Vec<Option<String>> {
copies
.iter()
.map(|copy| {
if into.external_files_ref().is_virtual() {
return Some(format!(
"{}:{} → {}:{}: the open package has no disk; whoever holds its files copies \
the dataset",
copy.from_file, copy.from_path, copy.to_file, copy.to_path
));
}
let source = ArrayLocation {
file: copy.from_file.clone(),
path_in_file: copy.from_path.clone(),
..Default::default()
};
from.read_array(&source)
.and_then(|data| into.write_array_to(©.to_file, ©.to_path, &data))
.err()
.map(|why| {
format!(
"{}:{} → {}:{}: {why}",
copy.from_file, copy.from_path, copy.to_file, copy.to_path
)
})
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Carried {
NotOnDisk,
Copied(std::path::PathBuf),
}
pub fn carry_files(into: &EpcStore, carries: &[FileCarry]) -> Result<Vec<Carried>> {
let mut out = Vec::new();
for carry in carries {
if into.external_files_ref().is_virtual() {
out.push(Carried::NotOnDisk);
continue;
}
let destination = into.external_files_ref().destination_for(&carry.to)?;
if destination.exists() {
return Err(Error::Package(format!(
"{} exists already; the import will not write over it",
destination.display()
)));
}
std::fs::copy(&carry.held, &destination).map_err(|why| {
Error::Package(format!(
"copying {} to {}: {why}",
carry.held,
destination.display()
))
})?;
into.data_file_written(&carry.to);
out.push(Carried::Copied(destination));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_free_name_keeps_the_extension() {
assert_eq!(free_name("model.h5", |_| false), "model-import.h5");
assert_eq!(
free_name("model.h5", |n| n == "model-import.h5"),
"model-import-2.h5"
);
assert_eq!(free_name("model", |_| false), "model-import");
}
#[test]
fn a_relocation_is_a_prefix() {
assert_eq!(prefixed(1, "/RESQML/abc/points"), "/import-1/RESQML/abc/points");
assert_eq!(prefixed(2, "points"), "/import-2/points");
}
#[test]
fn a_rename_keeps_what_led_to_the_file() {
assert_eq!(renamed("./data/model.h5", "new.h5"), "./data/new.h5");
assert_eq!(renamed("model.h5", "new.h5"), "new.h5");
}
#[test]
fn nan_equals_nan_and_nothing_else_does() {
assert!(same_scalar(Scalar::Double(f64::NAN), Scalar::Double(f64::NAN)));
assert!(!same_scalar(Scalar::Double(1.0), Scalar::Double(f64::NAN)));
assert!(same_scalar(Scalar::Str("a"), Scalar::Str("a")));
assert!(!same_scalar(Scalar::Int(1), Scalar::Int(2)));
}
}