Skip to main content

phasesmith_persistence/
lib.rs

1//! Canonical, Python-free project persistence and stable summary reporting.
2//!
3//! Project bundles are directories containing `manifest.json` plus a numeric
4//! `arrays.npz`. Wire records are explicit and versioned; live domain types do
5//! not derive serialization directly.
6//! Applications normally use this crate through
7//! [`phasesmith::persistence`](https://docs.rs/phasesmith/latest/phasesmith/).
8//!
9//! # Bundle contract
10//!
11//! - `manifest.json` contains the format version, explicit wire records, array
12//!   descriptors, shapes, dtypes, and hashes.
13//! - `arrays.npz` contains typed little-endian numeric arrays.
14//! - loads validate the exact member set, hashes, shapes, dtypes, finiteness,
15//!   cross-record identities, and caller-selected [`ProjectReadLimits`].
16//! - overwrite replaces only the two library-owned files and preserves
17//!   unrelated application content in the directory.
18//!
19//! # Saving and loading
20//!
21//! ```no_run
22//! use std::collections::BTreeMap;
23//! use phasesmith_model::{ProjectRecord, RecordId};
24//! use phasesmith_persistence::{
25//!     ProjectReadLimits, ProjectSaveOptions, load_project, save_project,
26//! };
27//!
28//! let project = ProjectRecord {
29//!     project_id: RecordId::new("example")?,
30//!     revision: 0,
31//!     name: "Example".into(),
32//!     histograms: Vec::new(),
33//!     tof_histograms: Vec::new(),
34//!     phases: Vec::new(),
35//!     metadata: BTreeMap::new(),
36//! };
37//! save_project("example.psproj", &project, ProjectSaveOptions::default())?;
38//! let restored = load_project("example.psproj", ProjectReadLimits::default())?;
39//! assert_eq!(restored.project_id, project.project_id);
40//! # Ok::<(), Box<dyn std::error::Error>>(())
41//! ```
42//!
43//! Use [`save_rietveld_project`] / [`load_rietveld_project`] or
44//! [`save_tof_lebail_project`] / [`load_tof_lebail_project`] when the bundle
45//! must retain runnable single-histogram analyses. Joint TOF geometry state uses
46//! [`save_tof_multibank_geometry_project`] and
47//! [`load_tof_multibank_geometry_project`]. Structural multi-bank TOF state uses
48//! [`save_structural_tof_multibank_project`] and
49//! [`load_structural_tof_multibank_project`]. TOF histograms keep microseconds
50//! separate from constant-wavelength degree coordinates. Reporting functions
51//! produce stable JSON summaries without exposing internal wire records.
52
53mod arrays;
54mod report;
55mod rietveld_wire;
56mod tof_multibank_wire;
57mod tof_structural_wire;
58mod tof_wire;
59mod wire;
60
61use std::collections::BTreeMap;
62use std::error::Error;
63use std::fmt::{Display, Formatter};
64use std::fs;
65use std::io::{Read, Write};
66use std::path::{Path, PathBuf};
67use std::sync::atomic::{AtomicU64, Ordering};
68
69use arrays::{ArrayDescriptor, read_npz, sha256_hex, write_npz};
70use phasesmith_model::{DomainError, ProjectRecord};
71use phasesmith_workflows::{
72    RietveldProjectState, StructuralTofMultiBankProjectState, TofLeBailProjectState,
73    TofMultiBankGeometryProjectState,
74};
75use serde::{Deserialize, Serialize};
76
77pub use report::{
78    HistogramSummary, PhaseSummary, ProjectReportSaveOptions, ProjectSummaryReport,
79    project_summary_json, write_project_summary_json, write_project_summary_json_with_options,
80};
81
82/// Current native project bundle wire version.
83pub const PROJECT_FORMAT_VERSION: u32 = 5;
84/// Canonical manifest filename within a project directory.
85pub const PROJECT_MANIFEST_NAME: &str = "manifest.json";
86/// Canonical `NumPy` archive filename within a project directory.
87pub const PROJECT_ARRAYS_NAME: &str = "arrays.npz";
88
89const MANIFEST_BACKUP_NAME: &str = ".manifest.json.phasesmith-backup";
90const ARRAYS_BACKUP_NAME: &str = ".arrays.npz.phasesmith-backup";
91
92static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
93
94/// Resource limits applied before allocating project records and arrays.
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub struct ProjectReadLimits {
97    /// Maximum manifest byte count.
98    pub max_manifest_bytes: u64,
99    /// Maximum compressed NPZ byte count.
100    pub max_archive_bytes: u64,
101    /// Maximum number of declared arrays.
102    pub max_arrays: usize,
103    /// Maximum element count of one array.
104    pub max_array_elements: usize,
105    /// Maximum combined uncompressed NPY bytes.
106    pub max_uncompressed_array_bytes: u64,
107    /// Maximum histogram count.
108    pub max_histograms: usize,
109    /// Maximum structural phase count.
110    pub max_phases: usize,
111}
112
113impl Default for ProjectReadLimits {
114    fn default() -> Self {
115        Self {
116            max_manifest_bytes: 16 * 1024 * 1024,
117            max_archive_bytes: 256 * 1024 * 1024,
118            max_arrays: 10_000,
119            max_array_elements: 50_000_000,
120            max_uncompressed_array_bytes: 512 * 1024 * 1024,
121            max_histograms: 10_000,
122            max_phases: 10_000,
123        }
124    }
125}
126
127impl ProjectReadLimits {
128    fn validate(self) -> Result<(), PersistenceError> {
129        if self.max_manifest_bytes == 0
130            || self.max_archive_bytes == 0
131            || self.max_arrays == 0
132            || self.max_array_elements == 0
133            || self.max_uncompressed_array_bytes == 0
134            || self.max_histograms == 0
135            || self.max_phases == 0
136        {
137            return Err(PersistenceError::InvalidLimits);
138        }
139        Ok(())
140    }
141}
142
143/// Native project save behavior.
144#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
145pub struct ProjectSaveOptions {
146    /// Replace only the two library-owned files in an existing directory.
147    pub overwrite: bool,
148}
149
150/// Structured native persistence failure.
151#[derive(Debug)]
152pub enum PersistenceError {
153    /// A configured read limit is zero.
154    InvalidLimits,
155    /// Input exceeded an explicit resource limit.
156    LimitExceeded {
157        /// Stable explanation naming the limit.
158        message: String,
159    },
160    /// A path has the wrong kind or overwrite policy.
161    InvalidDestination {
162        /// Stable explanation.
163        message: String,
164    },
165    /// Filesystem operation failed.
166    Io(std::io::Error),
167    /// Manifest JSON syntax or shape is invalid.
168    Json(serde_json::Error),
169    /// Unsupported project format version.
170    UnsupportedVersion {
171        /// Rejected version.
172        version: u32,
173    },
174    /// Array descriptor, shape, dtype, or value is invalid.
175    InvalidArray {
176        /// Stable explanation.
177        message: String,
178    },
179    /// NPZ/NPY archive structure or hashes are invalid.
180    InvalidArchive {
181        /// Stable explanation.
182        message: String,
183    },
184    /// Wire record is invalid or inconsistent.
185    InvalidRecord {
186        /// Stable explanation.
187        message: String,
188    },
189    /// Reconstructed domain project failed validation.
190    Domain(DomainError),
191}
192
193impl Display for PersistenceError {
194    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
195        match self {
196            Self::InvalidLimits => formatter.write_str("all project read limits must be positive"),
197            Self::LimitExceeded { message }
198            | Self::InvalidDestination { message }
199            | Self::InvalidArray { message }
200            | Self::InvalidArchive { message }
201            | Self::InvalidRecord { message } => formatter.write_str(message),
202            Self::Io(error) => Display::fmt(error, formatter),
203            Self::Json(error) => Display::fmt(error, formatter),
204            Self::UnsupportedVersion { version } => {
205                write!(formatter, "unsupported native project format {version}")
206            }
207            Self::Domain(error) => Display::fmt(error, formatter),
208        }
209    }
210}
211
212impl Error for PersistenceError {
213    fn source(&self) -> Option<&(dyn Error + 'static)> {
214        match self {
215            Self::Io(error) => Some(error),
216            Self::Json(error) => Some(error),
217            Self::Domain(error) => Some(error),
218            _ => None,
219        }
220    }
221}
222
223impl From<std::io::Error> for PersistenceError {
224    fn from(error: std::io::Error) -> Self {
225        Self::Io(error)
226    }
227}
228
229impl From<serde_json::Error> for PersistenceError {
230    fn from(error: serde_json::Error) -> Self {
231        Self::Json(error)
232    }
233}
234
235#[derive(Debug, Serialize, Deserialize)]
236#[serde(deny_unknown_fields)]
237struct ArchiveRecord {
238    file: String,
239    sha256: String,
240}
241
242#[derive(Debug, Serialize, Deserialize)]
243#[serde(deny_unknown_fields)]
244struct ProjectManifest {
245    format_version: u32,
246    archive: ArchiveRecord,
247    arrays: BTreeMap<String, ArrayDescriptor>,
248    project: wire::WireProject,
249    #[serde(default)]
250    rietveld_analyses: Option<Vec<rietveld_wire::WireRietveldAnalysis>>,
251    #[serde(default)]
252    tof_lebail_analyses: Option<Vec<tof_wire::WireTofLeBailAnalysis>>,
253    #[serde(default)]
254    tof_multibank_geometry_analyses:
255        Option<Vec<tof_multibank_wire::WireTofMultiBankGeometryAnalysis>>,
256    #[serde(default)]
257    structural_tof_multibank_analyses: Option<Vec<tof_structural_wire::WireStructuralTofAnalysis>>,
258}
259
260#[derive(Debug, Deserialize)]
261struct ProjectVersionProbe {
262    format_version: u32,
263}
264
265type LoadedProjectParts = (
266    ProjectRecord,
267    Vec<rietveld_wire::WireRietveldAnalysis>,
268    Vec<tof_wire::WireTofLeBailAnalysis>,
269    Vec<tof_multibank_wire::WireTofMultiBankGeometryAnalysis>,
270    Vec<tof_structural_wire::WireStructuralTofAnalysis>,
271    BTreeMap<String, arrays::ArrayData>,
272);
273
274/// Save one validated native project as canonical JSON plus NPZ.
275///
276/// Only `manifest.json` and `arrays.npz` are created or replaced; unrelated
277/// files in an existing directory remain untouched.
278///
279/// # Errors
280///
281/// Returns [`PersistenceError`] for invalid domain state, unsupported values,
282/// serialization, archive, filesystem, or overwrite failures.
283pub fn save_project(
284    path: impl AsRef<Path>,
285    project: &ProjectRecord,
286    options: ProjectSaveOptions,
287) -> Result<PathBuf, PersistenceError> {
288    project.validate().map_err(PersistenceError::Domain)?;
289    save_project_parts(
290        path.as_ref(),
291        project,
292        Vec::new(),
293        Vec::new(),
294        Vec::new(),
295        Vec::new(),
296        BTreeMap::new(),
297        options,
298    )
299}
300
301/// Save one validated project and all runnable native Rietveld analyses.
302///
303/// # Errors
304///
305/// Returns [`PersistenceError`] for invalid cross-record state, serialization,
306/// archive, filesystem, or overwrite failures.
307pub fn save_rietveld_project(
308    path: impl AsRef<Path>,
309    state: &RietveldProjectState,
310    options: ProjectSaveOptions,
311) -> Result<PathBuf, PersistenceError> {
312    state
313        .validate()
314        .map_err(|error| PersistenceError::InvalidRecord {
315            message: format!("invalid native Rietveld project state: {error}"),
316        })?;
317    save_project_parts(
318        path.as_ref(),
319        &state.project,
320        rietveld_wire::encode_analyses(state),
321        Vec::new(),
322        Vec::new(),
323        Vec::new(),
324        BTreeMap::new(),
325        options,
326    )
327}
328
329/// Save one validated project and all resumable fixed-instrument TOF Le Bail analyses.
330///
331/// # Errors
332///
333/// Returns [`PersistenceError`] for invalid cross-record state, serialization,
334/// archive, filesystem, or overwrite failures.
335pub fn save_tof_lebail_project(
336    path: impl AsRef<Path>,
337    state: &TofLeBailProjectState,
338    options: ProjectSaveOptions,
339) -> Result<PathBuf, PersistenceError> {
340    state
341        .validate()
342        .map_err(|error| PersistenceError::InvalidRecord {
343            message: format!("invalid native TOF project state: {error}"),
344        })?;
345    let (analyses, arrays) = tof_wire::encode_analyses(state)?;
346    save_project_parts(
347        path.as_ref(),
348        &state.project,
349        Vec::new(),
350        analyses,
351        Vec::new(),
352        Vec::new(),
353        arrays,
354        options,
355    )
356}
357
358/// Save one validated project and all resumable joint multi-bank TOF analyses.
359///
360/// # Errors
361///
362/// Returns [`PersistenceError`] for invalid cross-record state, serialization,
363/// archive, filesystem, or overwrite failures.
364pub fn save_tof_multibank_geometry_project(
365    path: impl AsRef<Path>,
366    state: &TofMultiBankGeometryProjectState,
367    options: ProjectSaveOptions,
368) -> Result<PathBuf, PersistenceError> {
369    state
370        .validate()
371        .map_err(|error| PersistenceError::InvalidRecord {
372            message: format!("invalid joint TOF project state: {error}"),
373        })?;
374    let (analyses, arrays) = tof_multibank_wire::encode_analyses(state)?;
375    save_project_parts(
376        path.as_ref(),
377        &state.project,
378        Vec::new(),
379        Vec::new(),
380        analyses,
381        Vec::new(),
382        arrays,
383        options,
384    )
385}
386
387/// Save one validated project and all resumable structural multi-bank TOF analyses.
388///
389/// # Errors
390///
391/// Returns [`PersistenceError`] for invalid cross-record state, serialization,
392/// archive, filesystem, or overwrite failures.
393pub fn save_structural_tof_multibank_project(
394    path: impl AsRef<Path>,
395    state: &StructuralTofMultiBankProjectState,
396    options: ProjectSaveOptions,
397) -> Result<PathBuf, PersistenceError> {
398    state
399        .validate()
400        .map_err(|error| PersistenceError::InvalidRecord {
401            message: format!("invalid structural TOF project state: {error}"),
402        })?;
403    save_project_parts(
404        path.as_ref(),
405        &state.project,
406        Vec::new(),
407        Vec::new(),
408        Vec::new(),
409        tof_structural_wire::encode_analyses(state),
410        BTreeMap::new(),
411        options,
412    )
413}
414
415#[allow(clippy::too_many_arguments)] // One explicit slot per independently versioned analysis family.
416fn save_project_parts(
417    path: &Path,
418    project: &ProjectRecord,
419    rietveld_analyses: Vec<rietveld_wire::WireRietveldAnalysis>,
420    tof_lebail_analyses: Vec<tof_wire::WireTofLeBailAnalysis>,
421    tof_multibank_geometry_analyses: Vec<tof_multibank_wire::WireTofMultiBankGeometryAnalysis>,
422    structural_tof_multibank_analyses: Vec<tof_structural_wire::WireStructuralTofAnalysis>,
423    analysis_arrays: BTreeMap<String, arrays::ArrayData>,
424    options: ProjectSaveOptions,
425) -> Result<PathBuf, PersistenceError> {
426    let destination = absolute_path(path)?;
427    if destination.is_dir() {
428        recover_interrupted_save(&destination)?;
429    }
430    validate_destination(&destination, options)?;
431    let (wire_project, mut arrays) = wire::encode_project(project)?;
432    for (name, value) in analysis_arrays {
433        if arrays.insert(name.clone(), value).is_some() {
434            return Err(PersistenceError::InvalidRecord {
435                message: format!("duplicate project/analysis array name {name:?}"),
436            });
437        }
438    }
439    let encoded_archive = write_npz(&arrays)?;
440    let descriptors = arrays
441        .iter()
442        .map(|(name, value)| (name.clone(), value.descriptor()))
443        .collect();
444    let manifest = ProjectManifest {
445        format_version: PROJECT_FORMAT_VERSION,
446        archive: ArchiveRecord {
447            file: PROJECT_ARRAYS_NAME.to_owned(),
448            sha256: sha256_hex(&encoded_archive),
449        },
450        arrays: descriptors,
451        project: wire_project,
452        rietveld_analyses: Some(rietveld_analyses),
453        tof_lebail_analyses: Some(tof_lebail_analyses),
454        tof_multibank_geometry_analyses: Some(tof_multibank_geometry_analyses),
455        structural_tof_multibank_analyses: Some(structural_tof_multibank_analyses),
456    };
457    let mut encoded_manifest = serde_json::to_string_pretty(&manifest)?;
458    encoded_manifest.push('\n');
459
460    let parent = destination
461        .parent()
462        .ok_or_else(|| PersistenceError::InvalidDestination {
463            message: "project destination has no parent directory".to_owned(),
464        })?;
465    fs::create_dir_all(parent)?;
466    let temporary = create_temporary_directory(parent, &destination)?;
467    let write_result: Result<(), PersistenceError> = (|| {
468        write_synced_file(&temporary.join(PROJECT_ARRAYS_NAME), &encoded_archive)?;
469        write_synced_file(
470            &temporary.join(PROJECT_MANIFEST_NAME),
471            encoded_manifest.as_bytes(),
472        )?;
473        fs::create_dir_all(&destination)?;
474        if options.overwrite {
475            backup_owned_file(
476                &destination.join(PROJECT_MANIFEST_NAME),
477                &destination.join(MANIFEST_BACKUP_NAME),
478            )?;
479            backup_owned_file(
480                &destination.join(PROJECT_ARRAYS_NAME),
481                &destination.join(ARRAYS_BACKUP_NAME),
482            )?;
483        }
484        fs::rename(
485            temporary.join(PROJECT_ARRAYS_NAME),
486            destination.join(PROJECT_ARRAYS_NAME),
487        )?;
488        fs::rename(
489            temporary.join(PROJECT_MANIFEST_NAME),
490            destination.join(PROJECT_MANIFEST_NAME),
491        )?;
492        sync_directory(&destination)?;
493        remove_if_exists(&destination.join(MANIFEST_BACKUP_NAME))?;
494        remove_if_exists(&destination.join(ARRAYS_BACKUP_NAME))?;
495        sync_directory(&destination)?;
496        Ok(())
497    })();
498    if write_result.is_err() && destination.is_dir() {
499        let _ = recover_interrupted_save(&destination);
500    }
501    let _ = fs::remove_dir_all(&temporary);
502    write_result?;
503    Ok(destination)
504}
505
506/// Load and fully validate one canonical native project directory.
507///
508/// # Errors
509///
510/// Returns [`PersistenceError`] for resource, filesystem, JSON, hash, archive,
511/// wire-record, or domain validation failures.
512pub fn load_project(
513    path: impl AsRef<Path>,
514    limits: ProjectReadLimits,
515) -> Result<ProjectRecord, PersistenceError> {
516    let (
517        project,
518        rietveld_analyses,
519        tof_analyses,
520        multibank_analyses,
521        structural_analyses,
522        mut arrays,
523    ) = load_project_parts(path.as_ref(), limits)?;
524    let project = tof_structural_wire::decode_state(project, structural_analyses, limits)?.project;
525    let project =
526        tof_multibank_wire::decode_state(project, multibank_analyses, &mut arrays, limits)?.project;
527    let project = tof_wire::decode_state(project, tof_analyses, &mut arrays, limits)?.project;
528    if !arrays.is_empty() {
529        return Err(PersistenceError::InvalidRecord {
530            message: "manifest contains arrays that are not referenced by the project".to_owned(),
531        });
532    }
533    Ok(rietveld_wire::decode_state(project, rietveld_analyses, limits)?.project)
534}
535
536/// Load and validate one project plus all native Rietveld analyses.
537///
538/// Version-1 native projects load with an empty analysis list.
539///
540/// # Errors
541///
542/// Returns [`PersistenceError`] for resource, filesystem, JSON, hash, archive,
543/// wire-record, domain, or Rietveld validation failures.
544pub fn load_rietveld_project(
545    path: impl AsRef<Path>,
546    limits: ProjectReadLimits,
547) -> Result<RietveldProjectState, PersistenceError> {
548    let (
549        project,
550        rietveld_analyses,
551        tof_analyses,
552        multibank_analyses,
553        structural_analyses,
554        mut arrays,
555    ) = load_project_parts(path.as_ref(), limits)?;
556    let project = tof_structural_wire::decode_state(project, structural_analyses, limits)?.project;
557    let project =
558        tof_multibank_wire::decode_state(project, multibank_analyses, &mut arrays, limits)?.project;
559    let project = tof_wire::decode_state(project, tof_analyses, &mut arrays, limits)?.project;
560    if !arrays.is_empty() {
561        return Err(PersistenceError::InvalidRecord {
562            message: "manifest contains arrays that are not referenced by the project".to_owned(),
563        });
564    }
565    rietveld_wire::decode_state(project, rietveld_analyses, limits)
566}
567
568/// Load and validate one project plus all resumable fixed-instrument TOF Le Bail analyses.
569///
570/// Version-1 and version-2 native projects load with an empty analysis list.
571///
572/// # Errors
573///
574/// Returns [`PersistenceError`] for resource, filesystem, JSON, hash, archive,
575/// wire-record, domain, or TOF workflow validation failures.
576pub fn load_tof_lebail_project(
577    path: impl AsRef<Path>,
578    limits: ProjectReadLimits,
579) -> Result<TofLeBailProjectState, PersistenceError> {
580    let (
581        project,
582        rietveld_analyses,
583        tof_analyses,
584        multibank_analyses,
585        structural_analyses,
586        mut arrays,
587    ) = load_project_parts(path.as_ref(), limits)?;
588    let project = tof_structural_wire::decode_state(project, structural_analyses, limits)?.project;
589    let project =
590        tof_multibank_wire::decode_state(project, multibank_analyses, &mut arrays, limits)?.project;
591    rietveld_wire::decode_state(project.clone(), rietveld_analyses, limits)?;
592    let state = tof_wire::decode_state(project, tof_analyses, &mut arrays, limits)?;
593    if !arrays.is_empty() {
594        return Err(PersistenceError::InvalidRecord {
595            message: "manifest contains arrays that are not referenced by the project".to_owned(),
596        });
597    }
598    Ok(state)
599}
600
601/// Load and validate one project plus all resumable joint multi-bank TOF analyses.
602///
603/// Versions 1 through 3 load with an empty joint-analysis list.
604///
605/// # Errors
606///
607/// Returns [`PersistenceError`] for resource, filesystem, JSON, hash, archive,
608/// wire-record, domain, or joint TOF validation failures.
609pub fn load_tof_multibank_geometry_project(
610    path: impl AsRef<Path>,
611    limits: ProjectReadLimits,
612) -> Result<TofMultiBankGeometryProjectState, PersistenceError> {
613    let (
614        project,
615        rietveld_analyses,
616        tof_analyses,
617        multibank_analyses,
618        structural_analyses,
619        mut arrays,
620    ) = load_project_parts(path.as_ref(), limits)?;
621    let project = tof_structural_wire::decode_state(project, structural_analyses, limits)?.project;
622    rietveld_wire::decode_state(project.clone(), rietveld_analyses, limits)?;
623    let project = tof_wire::decode_state(project, tof_analyses, &mut arrays, limits)?.project;
624    let state = tof_multibank_wire::decode_state(project, multibank_analyses, &mut arrays, limits)?;
625    if !arrays.is_empty() {
626        return Err(PersistenceError::InvalidRecord {
627            message: "manifest contains arrays that are not referenced by the project".to_owned(),
628        });
629    }
630    Ok(state)
631}
632
633/// Load and validate one project plus all resumable structural multi-bank TOF analyses.
634///
635/// Versions 1 through 4 load with an empty structural-analysis list.
636///
637/// # Errors
638///
639/// Returns [`PersistenceError`] for resource, filesystem, JSON, hash, archive,
640/// wire-record, domain, or structural TOF validation failures.
641pub fn load_structural_tof_multibank_project(
642    path: impl AsRef<Path>,
643    limits: ProjectReadLimits,
644) -> Result<StructuralTofMultiBankProjectState, PersistenceError> {
645    let (
646        project,
647        rietveld_analyses,
648        tof_analyses,
649        multibank_analyses,
650        structural_analyses,
651        mut arrays,
652    ) = load_project_parts(path.as_ref(), limits)?;
653    rietveld_wire::decode_state(project.clone(), rietveld_analyses, limits)?;
654    let project = tof_wire::decode_state(project, tof_analyses, &mut arrays, limits)?.project;
655    let project =
656        tof_multibank_wire::decode_state(project, multibank_analyses, &mut arrays, limits)?.project;
657    let state = tof_structural_wire::decode_state(project, structural_analyses, limits)?;
658    if !arrays.is_empty() {
659        return Err(PersistenceError::InvalidRecord {
660            message: "manifest contains arrays that are not referenced by the project".to_owned(),
661        });
662    }
663    Ok(state)
664}
665
666#[allow(clippy::too_many_lines)] // The explicit version/field matrix is kept together for auditability.
667fn load_project_parts(
668    path: &Path,
669    limits: ProjectReadLimits,
670) -> Result<LoadedProjectParts, PersistenceError> {
671    limits.validate()?;
672    let source = absolute_path(path)?;
673    if source.is_dir() {
674        recover_interrupted_save(&source)?;
675    }
676    let manifest_path = source.join(PROJECT_MANIFEST_NAME);
677    let archive_path = source.join(PROJECT_ARRAYS_NAME);
678    let manifest_bytes = read_bounded_file(
679        &manifest_path,
680        limits.max_manifest_bytes,
681        "project manifest exceeds max_manifest_bytes",
682    )?;
683    let version: ProjectVersionProbe = serde_json::from_slice(&manifest_bytes)?;
684    if !(1..=PROJECT_FORMAT_VERSION).contains(&version.format_version) {
685        return Err(PersistenceError::UnsupportedVersion {
686            version: version.format_version,
687        });
688    }
689    let manifest: ProjectManifest = serde_json::from_slice(&manifest_bytes)?;
690    let rietveld_analyses = match (manifest.format_version, manifest.rietveld_analyses) {
691        (1, None) => Vec::new(),
692        (1, Some(_)) => {
693            return Err(PersistenceError::InvalidRecord {
694                message: "native project format 1 cannot declare Rietveld analyses".to_owned(),
695            });
696        }
697        (2..=5, Some(analyses)) => analyses,
698        (2..=5, None) => {
699            return Err(PersistenceError::InvalidRecord {
700                message: format!(
701                    "native project format {} requires Rietveld analyses",
702                    manifest.format_version
703                ),
704            });
705        }
706        _ => unreachable!("format version checked above"),
707    };
708    let tof_analyses = match (manifest.format_version, manifest.tof_lebail_analyses) {
709        (1 | 2, None) => Vec::new(),
710        (1 | 2, Some(_)) => {
711            return Err(PersistenceError::InvalidRecord {
712                message: format!(
713                    "native project format {} cannot declare TOF Le Bail analyses",
714                    manifest.format_version
715                ),
716            });
717        }
718        (3..=5, Some(analyses)) => analyses,
719        (3..=5, None) => {
720            return Err(PersistenceError::InvalidRecord {
721                message: format!(
722                    "native project format {} requires TOF Le Bail analyses",
723                    manifest.format_version
724                ),
725            });
726        }
727        _ => unreachable!("format version checked above"),
728    };
729    let multibank_analyses = match (
730        manifest.format_version,
731        manifest.tof_multibank_geometry_analyses,
732    ) {
733        (1..=3, None) => Vec::new(),
734        (1..=3, Some(_)) => {
735            return Err(PersistenceError::InvalidRecord {
736                message: format!(
737                    "native project format {} cannot declare joint TOF analyses",
738                    manifest.format_version
739                ),
740            });
741        }
742        (4 | 5, Some(analyses)) => analyses,
743        (4 | 5, None) => {
744            return Err(PersistenceError::InvalidRecord {
745                message: format!(
746                    "native project format {} requires joint TOF analyses",
747                    manifest.format_version
748                ),
749            });
750        }
751        _ => unreachable!("format version checked above"),
752    };
753    let structural_analyses = match (
754        manifest.format_version,
755        manifest.structural_tof_multibank_analyses,
756    ) {
757        (1..=4, None) => Vec::new(),
758        (1..=4, Some(_)) => {
759            return Err(PersistenceError::InvalidRecord {
760                message: format!(
761                    "native project format {} cannot declare structural TOF analyses",
762                    manifest.format_version
763                ),
764            });
765        }
766        (5, Some(analyses)) => analyses,
767        (5, None) => {
768            return Err(PersistenceError::InvalidRecord {
769                message: "native project format 5 requires structural TOF analyses".to_owned(),
770            });
771        }
772        _ => unreachable!("format version checked above"),
773    };
774    if manifest.format_version < 3 && wire::has_tof_histograms(&manifest.project) {
775        return Err(PersistenceError::InvalidRecord {
776            message: format!(
777                "native project format {} cannot declare TOF histograms",
778                manifest.format_version
779            ),
780        });
781    }
782    if manifest.archive.file != PROJECT_ARRAYS_NAME {
783        return Err(PersistenceError::InvalidArchive {
784            message: "project archive filename is invalid".to_owned(),
785        });
786    }
787    if manifest.arrays.len() > limits.max_arrays {
788        return Err(PersistenceError::LimitExceeded {
789            message: "project manifest exceeds max_arrays".to_owned(),
790        });
791    }
792    let archive_bytes = read_bounded_file(
793        &archive_path,
794        limits.max_archive_bytes,
795        "project archive exceeds max_archive_bytes",
796    )?;
797    if sha256_hex(&archive_bytes) != manifest.archive.sha256 {
798        return Err(PersistenceError::InvalidArchive {
799            message: "project archive SHA-256 mismatch".to_owned(),
800        });
801    }
802    let mut arrays = read_npz(&archive_bytes, &manifest.arrays, limits)?;
803    let project = wire::decode_project_parts(manifest.project, &mut arrays, limits)?;
804    Ok((
805        project,
806        rietveld_analyses,
807        tof_analyses,
808        multibank_analyses,
809        structural_analyses,
810        arrays,
811    ))
812}
813
814fn read_bounded_file(
815    path: &Path,
816    maximum_bytes: u64,
817    limit_message: &str,
818) -> Result<Vec<u8>, PersistenceError> {
819    let mut bytes = Vec::new();
820    fs::File::open(path)?
821        .take(maximum_bytes.saturating_add(1))
822        .read_to_end(&mut bytes)?;
823    if u64::try_from(bytes.len()).map_or(true, |length| length > maximum_bytes) {
824        return Err(PersistenceError::LimitExceeded {
825            message: limit_message.to_owned(),
826        });
827    }
828    Ok(bytes)
829}
830
831fn absolute_path(path: &Path) -> Result<PathBuf, PersistenceError> {
832    if path.is_absolute() {
833        return Ok(path.to_owned());
834    }
835    Ok(std::env::current_dir()?.join(path))
836}
837
838fn validate_destination(
839    destination: &Path,
840    options: ProjectSaveOptions,
841) -> Result<(), PersistenceError> {
842    if destination.exists() && !destination.is_dir() {
843        return Err(PersistenceError::InvalidDestination {
844            message: format!(
845                "project path exists and is not a directory: {}",
846                destination.display()
847            ),
848        });
849    }
850    if destination.exists() && !options.overwrite {
851        return Err(PersistenceError::InvalidDestination {
852            message: format!(
853                "project directory already exists: {}",
854                destination.display()
855            ),
856        });
857    }
858    Ok(())
859}
860
861fn create_temporary_directory(
862    parent: &Path,
863    destination: &Path,
864) -> Result<PathBuf, PersistenceError> {
865    let stem = destination
866        .file_name()
867        .and_then(|value| value.to_str())
868        .unwrap_or("project");
869    for _ in 0..100 {
870        let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
871        let candidate = parent.join(format!(".{stem}-{}-{sequence}.tmp", std::process::id()));
872        match fs::create_dir(&candidate) {
873            Ok(()) => return Ok(candidate),
874            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
875            Err(error) => return Err(PersistenceError::Io(error)),
876        }
877    }
878    Err(PersistenceError::InvalidDestination {
879        message: "could not allocate a temporary project directory".to_owned(),
880    })
881}
882
883fn write_synced_file(path: &Path, bytes: &[u8]) -> Result<(), PersistenceError> {
884    let mut file = fs::File::create(path)?;
885    file.write_all(bytes)?;
886    file.sync_all()?;
887    Ok(())
888}
889
890#[cfg(not(windows))]
891fn sync_directory(path: &Path) -> Result<(), PersistenceError> {
892    fs::File::open(path)?.sync_all()?;
893    Ok(())
894}
895
896#[cfg(windows)]
897fn sync_directory(_path: &Path) -> Result<(), PersistenceError> {
898    // `File::open` cannot open directories on Windows. The project files are
899    // individually synced before they are renamed, but the standard library
900    // does not expose a portable equivalent of a Unix directory fsync.
901    Ok(())
902}
903
904fn backup_owned_file(source: &Path, backup: &Path) -> Result<(), PersistenceError> {
905    remove_if_exists(backup)?;
906    if source.exists() {
907        fs::rename(source, backup)?;
908    }
909    Ok(())
910}
911
912fn remove_if_exists(path: &Path) -> Result<(), PersistenceError> {
913    match fs::remove_file(path) {
914        Ok(()) => Ok(()),
915        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
916        Err(error) => Err(PersistenceError::Io(error)),
917    }
918}
919
920fn recover_interrupted_save(directory: &Path) -> Result<(), PersistenceError> {
921    let manifest = directory.join(PROJECT_MANIFEST_NAME);
922    let arrays = directory.join(PROJECT_ARRAYS_NAME);
923    let manifest_backup = directory.join(MANIFEST_BACKUP_NAME);
924    let arrays_backup = directory.join(ARRAYS_BACKUP_NAME);
925    let has_manifest_backup = manifest_backup.exists();
926    let has_arrays_backup = arrays_backup.exists();
927    if !has_manifest_backup && !has_arrays_backup {
928        return Ok(());
929    }
930    if manifest.exists() && arrays.exists() {
931        remove_if_exists(&manifest_backup)?;
932        remove_if_exists(&arrays_backup)?;
933        sync_directory(directory)?;
934        return Ok(());
935    }
936    for (current, backup) in [(&arrays, &arrays_backup), (&manifest, &manifest_backup)] {
937        if backup.exists() {
938            remove_if_exists(current)?;
939            fs::rename(backup, current)?;
940        }
941    }
942    sync_directory(directory)?;
943    Ok(())
944}