energyml_storage/rename.rs
1//! Renaming the external data file a package points at.
2//!
3//! Asked for as: rename the EPC and the h5 from the interface. For the h5, check
4//! that the rels will be updated, and the objects that reference the file name
5//! too.
6//!
7//! # Why this is an operation and not a string replace
8//!
9//! The name of an external file is written in **three** places that have to go
10//! on agreeing, and they are not the same kind of thing:
11//!
12//! 1. in every `ExternalDataArrayPart.Uri` (eml 2.3) - one field per array, and
13//! an object may carry several;
14//! 2. in the `.rels` of the part, for the older arrangement where the object
15//! names an `EpcExternalPartReference` and the relationship carries the file
16//! name (EPC §3.2.3) - and for the entries real producers write on ordinary
17//! objects, which this crate honours;
18//! 3. on the disk.
19//!
20//! Forgetting one gives a package that opens and whose arrays cannot be found -
21//! exactly the fault `energyml check` reports, made by the tool that is supposed
22//! to prevent it.
23//!
24//! What is **not** here: the `uuid` attribute EPC §3.2.3 makes the HDF5 file
25//! carry. It is the uuid of the proxy, not a name, so a rename leaves it alone;
26//! [`EpcStore::verify_external_file`] goes on agreeing with it afterwards.
27//!
28//! # A quote first, then the writing
29//!
30//! The shape the workspace uses everywhere it changes more than one thing -
31//! `extract::plan` then `extract`, `Operation::plan` then `apply`. It is also
32//! the answer to the question the request itself asks - update automatically,
33//! or ask? - because a caller that can show **what will
34//! change** does not have to choose between the two: it shows the quote and
35//! someone says yes.
36//!
37//! There is deliberately no third mode that renames the file without touching
38//! the references. It produces a broken package, and this workspace refuses
39//! rather than draws.
40
41use std::path::PathBuf;
42
43use energyml_core::path;
44use energyml_core::value::OwnedValue;
45
46use crate::error::{Error, Result};
47
48use crate::EpcStore;
49use crate::array::locate;
50
51/// What renaming a data file would touch, and what it refuses.
52#[derive(Debug, Clone, Default, PartialEq, Eq)]
53pub struct Rename {
54 /// The file as the package names it today.
55 pub from: String,
56 /// The bare file name it would take.
57 pub to: String,
58 /// The uris of the objects that name the file in a field of their own.
59 pub objects: Vec<String>,
60 /// How many fields would be rewritten - an object may carry several.
61 pub fields: usize,
62 /// How many `.rels` entries would be rewritten.
63 pub relationships: usize,
64 /// Why this rename cannot be carried out, in words a person can act on.
65 ///
66 /// **Not an error and not a silence.** A caller draws these next to the
67 /// button; [`apply`] refuses to do anything while there is one.
68 pub refusals: Vec<String>,
69}
70
71impl Rename {
72 /// Whether the rename can be carried out at all.
73 pub fn is_allowed(&self) -> bool {
74 self.refusals.is_empty()
75 }
76
77 /// Everything that would be rewritten inside the package.
78 pub fn touches(&self) -> usize {
79 self.fields + self.relationships
80 }
81}
82
83/// What [`apply`] did to the file itself.
84///
85/// Three states and not two: *there was nothing to move* and *this store has no
86/// disk* are different answers, and a caller that conflated them would tell
87/// somebody their file is missing when it is sitting in a browser tab.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum FileMove {
90 /// The store holds its files somewhere this crate cannot reach - a browser,
91 /// a test with an in-memory backend. Whoever owns the bytes renames them.
92 NotOnDisk,
93 /// Nothing was at the old name. Not an error: a package whose `.h5` never
94 /// shipped is already in that state, and the rename does not make it worse.
95 Absent,
96 /// Moved, and where to.
97 Moved(PathBuf),
98}
99
100/// What [`apply`] changed.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Applied {
103 /// Fields rewritten inside the objects.
104 pub fields: usize,
105 /// `.rels` entries rewritten.
106 pub relationships: usize,
107 /// What happened to the file itself.
108 pub file: FileMove,
109}
110
111/// What renaming `from` to `to` would touch.
112///
113/// Reads every object once, as `energyml check` and the reference graph do.
114/// An object that cannot be read is a **refusal** rather than a skip: nothing
115/// here can tell whether it names the file, and renaming around it would leave
116/// a pointer at a file that no longer exists.
117pub fn plan(store: &EpcStore, from: &str, to: &str) -> Rename {
118 let mut out = Rename {
119 from: from.to_string(),
120 to: to.to_string(),
121 ..Default::default()
122 };
123
124 // The new name, judged on its own first: these do not depend on the package
125 // and saying them early keeps the reason next to the thing that is wrong.
126 if to.trim().is_empty() {
127 out.refusals.push("the new name is empty".to_string());
128 }
129 if to.contains('/') || to.contains('\\') {
130 out.refusals.push(format!(
131 "`{to}` is a path; EPC §3.2.3 names an external file by a bare name"
132 ));
133 }
134 if from == to {
135 out.refusals
136 .push(format!("`{to}` is the name the file already has"));
137 }
138 if from.trim().is_empty() {
139 out.refusals.push("no file was named to rename".to_string());
140 }
141
142 let (objects, unreadable) = store.read_objects(None);
143 for name in unreadable {
144 out.refusals
145 .push(format!("{name} - so this cannot tell whether it names `{from}`"));
146 }
147
148 for (summary, object) in &objects {
149 let mut fields = 0;
150 for location in locate(object.as_reflect()) {
151 // The older forms carry no file name of their own: they point at a
152 // proxy, and the name lives in that proxy's `.rels`. Counted below.
153 if location.proxy_uuid.is_some() {
154 continue;
155 }
156 if names_file(&location.file, from) {
157 fields += 1;
158 } else if names_file(&location.file, to) {
159 // Renaming onto a name the package already uses would merge two
160 // files into one pointer, silently, and there is no way back.
161 out.refusals.push(format!(
162 "`{to}` is already named by {} ({}), so renaming onto it would \
163 make two files one",
164 summary.uri, location.field_path
165 ));
166 }
167 }
168 if fields > 0 {
169 out.objects.push(summary.uri.clone());
170 out.fields += fields;
171 }
172 }
173
174 for (_, file) in store.external_file_map() {
175 if names_file(&file, from) {
176 out.relationships += 1;
177 } else if names_file(&file, to) {
178 out.refusals.push(format!(
179 "`{to}` is already the file of an external part reference"
180 ));
181 }
182 }
183 for (_, file) in store.object_external_file_map() {
184 if names_file(&file, from) {
185 out.relationships += 1;
186 }
187 }
188
189 if out.touches() == 0 && out.is_allowed() {
190 // Renaming nothing is not a rename, and a caller whose button did
191 // nothing would have no way to learn why.
192 out.refusals
193 .push(format!("nothing in this package names `{from}`"));
194 }
195 out
196}
197
198/// Carry out a rename, or refuse the whole of it.
199///
200/// **The package first, the disk afterwards**, and the order is the answer to
201/// what a half-done rename leaves behind. Everything here changes memory, which
202/// reaches the file only on a save, so a failure on the way leaves an unsaved
203/// package and a file where it was. Moving the file first and failing afterwards
204/// would leave a file nothing points at.
205pub fn apply(store: &mut EpcStore, plan: &Rename) -> Result<Applied> {
206 if !plan.is_allowed() {
207 return Err(Error::Package(format!(
208 "this rename was refused: {}",
209 plan.refusals.join("; ")
210 )));
211 }
212
213 // 1 - the fields the objects carry themselves.
214 let mut fields = 0;
215 for uri in &plan.objects {
216 let object = store.edit(uri)?;
217 // Read the paths out first: the walk borrows the object, and writing
218 // through it needs the borrow back.
219 let targets: Vec<(String, String)> = locate(&*object)
220 .iter()
221 .filter(|found| found.proxy_uuid.is_none() && names_file(&found.file, &plan.from))
222 .map(|found| (found.field_path.clone(), renamed(&found.file, &plan.to)))
223 .collect();
224 for (at, value) in targets {
225 let part = path::object_mut(&mut *object, &at)?;
226 // `uri` for an eml 2.3 part, `file_uri` for a WITSML channel
227 // data in HDF5 - the extension `crate::array` locates.
228 let field = crate::array::file_field(part.type_info().name);
229 part.set_field(field, OwnedValue::Str(value))?;
230 fields += 1;
231 }
232 }
233
234 // 2 - the names the relationships carry. Both populations, and they are
235 // disjoint: a proxy names its file in its own `.rels` (§3.2.3), an ordinary
236 // object may carry one too, which this crate honours rather than drops.
237 let mut relationships = 0;
238 let due: Vec<(String, String)> = store
239 .external_file_map()
240 .into_iter()
241 .chain(store.object_external_file_map())
242 .filter(|(_, file)| names_file(file, &plan.from))
243 .map(|(uuid, file)| (uuid, renamed(&file, &plan.to)))
244 .collect();
245 for (uuid, file) in due {
246 store.register_external_file(&uuid, file);
247 relationships += 1;
248 }
249
250 let file = move_file(store, plan)?;
251 Ok(Applied {
252 fields,
253 relationships,
254 file,
255 })
256}
257
258/// Move the file itself, when this store has one to move.
259fn move_file(store: &mut EpcStore, plan: &Rename) -> Result<FileMove> {
260 if store.external_files_ref().is_virtual() {
261 // A store whose files live somewhere this crate cannot reach - a
262 // browser holding them in memory. The bytes are the caller's to rename;
263 // what belongs here is the store's own bookkeeping, so that a read
264 // under the **new** name finds the backend instead of being refused as
265 // *file not found* the moment the rename succeeds. The old name is left
266 // declared: it is a list of names known to exist, nothing points at it
267 // any more, and dropping it would be this deciding what the caller's
268 // file map holds.
269 store.external_files_mut().declare_present(plan.to.clone());
270 return Ok(FileMove::NotOnDisk);
271 }
272 let Ok(source) = store.external_files_ref().resolve(&plan.from) else {
273 return Ok(FileMove::Absent);
274 };
275 if !source.exists() {
276 return Ok(FileMove::Absent);
277 }
278 let destination = store.external_files_ref().destination_for(&plan.to)?;
279 // **What this process holds open, it closes first.** An `.h5` is kept open
280 // between reads (`hdf5::close_cached_files`), and on Windows an open handle
281 // makes a rename fail outright - so a package that had been read once could
282 // not be renamed, which is every package a person would want to rename.
283 #[cfg(feature = "hdf5")]
284 crate::hdf5::close_cached_files();
285 std::fs::rename(&source, &destination).map_err(|why| {
286 Error::Package(format!(
287 "the package now names `{}`, but the file could not be moved from {} to {}: {why} \
288 - the package has not been saved, so nothing on disk is inconsistent yet",
289 plan.to,
290 source.display(),
291 destination.display()
292 ))
293 })?;
294 Ok(FileMove::Moved(destination))
295}
296
297/// Whether a declared location names this file.
298///
299/// Exact, or the **last segment** of a path or URI: eml 2.3 says `Uri`, and
300/// producers write anything from `model.h5` to `./data/model.h5`. Comparing the
301/// whole string would miss the second; comparing loosely - ignoring case, say -
302/// would rename a file the package did not mean, and a zip entry is
303/// case-sensitive.
304fn names_file(declared: &str, wanted: &str) -> bool {
305 !declared.is_empty() && (declared == wanted || last_segment(declared) == wanted)
306}
307
308/// The same location with its file name replaced, **keeping whatever led to
309/// it**: `./data/model.h5` renamed to `new.h5` is `./data/new.h5`, not a bare
310/// name in a package that was pointing into a subdirectory.
311fn renamed(declared: &str, to: &str) -> String {
312 match declared.rfind(['/', '\\']) {
313 Some(at) => format!("{}{to}", &declared[..=at]),
314 None => to.to_string(),
315 }
316}
317
318fn last_segment(declared: &str) -> &str {
319 match declared.rfind(['/', '\\']) {
320 Some(at) => &declared[at + 1..],
321 None => declared,
322 }
323}