Skip to main content

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}