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
7mod arrays;
8mod report;
9mod rietveld_wire;
10mod wire;
11
12use std::collections::BTreeMap;
13use std::error::Error;
14use std::fmt::{Display, Formatter};
15use std::fs;
16use std::io::{Read, Write};
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicU64, Ordering};
19
20use arrays::{ArrayDescriptor, read_npz, sha256_hex, write_npz};
21use phasesmith_model::{DomainError, ProjectRecord};
22use phasesmith_workflows::RietveldProjectState;
23use serde::{Deserialize, Serialize};
24
25pub use report::{
26    HistogramSummary, PhaseSummary, ProjectReportSaveOptions, ProjectSummaryReport,
27    project_summary_json, write_project_summary_json, write_project_summary_json_with_options,
28};
29
30/// Current native project bundle wire version.
31pub const PROJECT_FORMAT_VERSION: u32 = 2;
32/// Canonical manifest filename within a project directory.
33pub const PROJECT_MANIFEST_NAME: &str = "manifest.json";
34/// Canonical `NumPy` archive filename within a project directory.
35pub const PROJECT_ARRAYS_NAME: &str = "arrays.npz";
36
37const MANIFEST_BACKUP_NAME: &str = ".manifest.json.phasesmith-backup";
38const ARRAYS_BACKUP_NAME: &str = ".arrays.npz.phasesmith-backup";
39
40static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
41
42/// Resource limits applied before allocating project records and arrays.
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub struct ProjectReadLimits {
45    /// Maximum manifest byte count.
46    pub max_manifest_bytes: u64,
47    /// Maximum compressed NPZ byte count.
48    pub max_archive_bytes: u64,
49    /// Maximum number of declared arrays.
50    pub max_arrays: usize,
51    /// Maximum element count of one array.
52    pub max_array_elements: usize,
53    /// Maximum combined uncompressed NPY bytes.
54    pub max_uncompressed_array_bytes: u64,
55    /// Maximum histogram count.
56    pub max_histograms: usize,
57    /// Maximum structural phase count.
58    pub max_phases: usize,
59}
60
61impl Default for ProjectReadLimits {
62    fn default() -> Self {
63        Self {
64            max_manifest_bytes: 16 * 1024 * 1024,
65            max_archive_bytes: 256 * 1024 * 1024,
66            max_arrays: 10_000,
67            max_array_elements: 50_000_000,
68            max_uncompressed_array_bytes: 512 * 1024 * 1024,
69            max_histograms: 10_000,
70            max_phases: 10_000,
71        }
72    }
73}
74
75impl ProjectReadLimits {
76    fn validate(self) -> Result<(), PersistenceError> {
77        if self.max_manifest_bytes == 0
78            || self.max_archive_bytes == 0
79            || self.max_arrays == 0
80            || self.max_array_elements == 0
81            || self.max_uncompressed_array_bytes == 0
82            || self.max_histograms == 0
83            || self.max_phases == 0
84        {
85            return Err(PersistenceError::InvalidLimits);
86        }
87        Ok(())
88    }
89}
90
91/// Native project save behavior.
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub struct ProjectSaveOptions {
94    /// Replace only the two library-owned files in an existing directory.
95    pub overwrite: bool,
96}
97
98/// Structured native persistence failure.
99#[derive(Debug)]
100pub enum PersistenceError {
101    /// A configured read limit is zero.
102    InvalidLimits,
103    /// Input exceeded an explicit resource limit.
104    LimitExceeded {
105        /// Stable explanation naming the limit.
106        message: String,
107    },
108    /// A path has the wrong kind or overwrite policy.
109    InvalidDestination {
110        /// Stable explanation.
111        message: String,
112    },
113    /// Filesystem operation failed.
114    Io(std::io::Error),
115    /// Manifest JSON syntax or shape is invalid.
116    Json(serde_json::Error),
117    /// Unsupported project format version.
118    UnsupportedVersion {
119        /// Rejected version.
120        version: u32,
121    },
122    /// Array descriptor, shape, dtype, or value is invalid.
123    InvalidArray {
124        /// Stable explanation.
125        message: String,
126    },
127    /// NPZ/NPY archive structure or hashes are invalid.
128    InvalidArchive {
129        /// Stable explanation.
130        message: String,
131    },
132    /// Wire record is invalid or inconsistent.
133    InvalidRecord {
134        /// Stable explanation.
135        message: String,
136    },
137    /// Reconstructed domain project failed validation.
138    Domain(DomainError),
139}
140
141impl Display for PersistenceError {
142    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
143        match self {
144            Self::InvalidLimits => formatter.write_str("all project read limits must be positive"),
145            Self::LimitExceeded { message }
146            | Self::InvalidDestination { message }
147            | Self::InvalidArray { message }
148            | Self::InvalidArchive { message }
149            | Self::InvalidRecord { message } => formatter.write_str(message),
150            Self::Io(error) => Display::fmt(error, formatter),
151            Self::Json(error) => Display::fmt(error, formatter),
152            Self::UnsupportedVersion { version } => {
153                write!(formatter, "unsupported native project format {version}")
154            }
155            Self::Domain(error) => Display::fmt(error, formatter),
156        }
157    }
158}
159
160impl Error for PersistenceError {
161    fn source(&self) -> Option<&(dyn Error + 'static)> {
162        match self {
163            Self::Io(error) => Some(error),
164            Self::Json(error) => Some(error),
165            Self::Domain(error) => Some(error),
166            _ => None,
167        }
168    }
169}
170
171impl From<std::io::Error> for PersistenceError {
172    fn from(error: std::io::Error) -> Self {
173        Self::Io(error)
174    }
175}
176
177impl From<serde_json::Error> for PersistenceError {
178    fn from(error: serde_json::Error) -> Self {
179        Self::Json(error)
180    }
181}
182
183#[derive(Debug, Serialize, Deserialize)]
184#[serde(deny_unknown_fields)]
185struct ArchiveRecord {
186    file: String,
187    sha256: String,
188}
189
190#[derive(Debug, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192struct ProjectManifest {
193    format_version: u32,
194    archive: ArchiveRecord,
195    arrays: BTreeMap<String, ArrayDescriptor>,
196    project: wire::WireProject,
197    #[serde(default)]
198    rietveld_analyses: Option<Vec<rietveld_wire::WireRietveldAnalysis>>,
199}
200
201#[derive(Debug, Deserialize)]
202struct ProjectVersionProbe {
203    format_version: u32,
204}
205
206/// Save one validated native project as canonical JSON plus NPZ.
207///
208/// Only `manifest.json` and `arrays.npz` are created or replaced; unrelated
209/// files in an existing directory remain untouched.
210///
211/// # Errors
212///
213/// Returns [`PersistenceError`] for invalid domain state, unsupported values,
214/// serialization, archive, filesystem, or overwrite failures.
215pub fn save_project(
216    path: impl AsRef<Path>,
217    project: &ProjectRecord,
218    options: ProjectSaveOptions,
219) -> Result<PathBuf, PersistenceError> {
220    project.validate().map_err(PersistenceError::Domain)?;
221    save_project_parts(path.as_ref(), project, Vec::new(), options)
222}
223
224/// Save one validated project and all runnable native Rietveld analyses.
225///
226/// # Errors
227///
228/// Returns [`PersistenceError`] for invalid cross-record state, serialization,
229/// archive, filesystem, or overwrite failures.
230pub fn save_rietveld_project(
231    path: impl AsRef<Path>,
232    state: &RietveldProjectState,
233    options: ProjectSaveOptions,
234) -> Result<PathBuf, PersistenceError> {
235    state
236        .validate()
237        .map_err(|error| PersistenceError::InvalidRecord {
238            message: format!("invalid native Rietveld project state: {error}"),
239        })?;
240    save_project_parts(
241        path.as_ref(),
242        &state.project,
243        rietveld_wire::encode_analyses(state),
244        options,
245    )
246}
247
248fn save_project_parts(
249    path: &Path,
250    project: &ProjectRecord,
251    rietveld_analyses: Vec<rietveld_wire::WireRietveldAnalysis>,
252    options: ProjectSaveOptions,
253) -> Result<PathBuf, PersistenceError> {
254    let destination = absolute_path(path)?;
255    if destination.is_dir() {
256        recover_interrupted_save(&destination)?;
257    }
258    validate_destination(&destination, options)?;
259    let (wire_project, arrays) = wire::encode_project(project)?;
260    let encoded_archive = write_npz(&arrays)?;
261    let descriptors = arrays
262        .iter()
263        .map(|(name, value)| (name.clone(), value.descriptor()))
264        .collect();
265    let manifest = ProjectManifest {
266        format_version: PROJECT_FORMAT_VERSION,
267        archive: ArchiveRecord {
268            file: PROJECT_ARRAYS_NAME.to_owned(),
269            sha256: sha256_hex(&encoded_archive),
270        },
271        arrays: descriptors,
272        project: wire_project,
273        rietveld_analyses: Some(rietveld_analyses),
274    };
275    let mut encoded_manifest = serde_json::to_string_pretty(&manifest)?;
276    encoded_manifest.push('\n');
277
278    let parent = destination
279        .parent()
280        .ok_or_else(|| PersistenceError::InvalidDestination {
281            message: "project destination has no parent directory".to_owned(),
282        })?;
283    fs::create_dir_all(parent)?;
284    let temporary = create_temporary_directory(parent, &destination)?;
285    let write_result: Result<(), PersistenceError> = (|| {
286        write_synced_file(&temporary.join(PROJECT_ARRAYS_NAME), &encoded_archive)?;
287        write_synced_file(
288            &temporary.join(PROJECT_MANIFEST_NAME),
289            encoded_manifest.as_bytes(),
290        )?;
291        fs::create_dir_all(&destination)?;
292        if options.overwrite {
293            backup_owned_file(
294                &destination.join(PROJECT_MANIFEST_NAME),
295                &destination.join(MANIFEST_BACKUP_NAME),
296            )?;
297            backup_owned_file(
298                &destination.join(PROJECT_ARRAYS_NAME),
299                &destination.join(ARRAYS_BACKUP_NAME),
300            )?;
301        }
302        fs::rename(
303            temporary.join(PROJECT_ARRAYS_NAME),
304            destination.join(PROJECT_ARRAYS_NAME),
305        )?;
306        fs::rename(
307            temporary.join(PROJECT_MANIFEST_NAME),
308            destination.join(PROJECT_MANIFEST_NAME),
309        )?;
310        sync_directory(&destination)?;
311        remove_if_exists(&destination.join(MANIFEST_BACKUP_NAME))?;
312        remove_if_exists(&destination.join(ARRAYS_BACKUP_NAME))?;
313        sync_directory(&destination)?;
314        Ok(())
315    })();
316    if write_result.is_err() && destination.is_dir() {
317        let _ = recover_interrupted_save(&destination);
318    }
319    let _ = fs::remove_dir_all(&temporary);
320    write_result?;
321    Ok(destination)
322}
323
324/// Load and fully validate one canonical native project directory.
325///
326/// # Errors
327///
328/// Returns [`PersistenceError`] for resource, filesystem, JSON, hash, archive,
329/// wire-record, or domain validation failures.
330pub fn load_project(
331    path: impl AsRef<Path>,
332    limits: ProjectReadLimits,
333) -> Result<ProjectRecord, PersistenceError> {
334    load_rietveld_project(path, limits).map(|state| state.project)
335}
336
337/// Load and validate one project plus all native Rietveld analyses.
338///
339/// Version-1 native projects load with an empty analysis list.
340///
341/// # Errors
342///
343/// Returns [`PersistenceError`] for resource, filesystem, JSON, hash, archive,
344/// wire-record, domain, or Rietveld validation failures.
345pub fn load_rietveld_project(
346    path: impl AsRef<Path>,
347    limits: ProjectReadLimits,
348) -> Result<RietveldProjectState, PersistenceError> {
349    limits.validate()?;
350    let source = absolute_path(path.as_ref())?;
351    if source.is_dir() {
352        recover_interrupted_save(&source)?;
353    }
354    let manifest_path = source.join(PROJECT_MANIFEST_NAME);
355    let archive_path = source.join(PROJECT_ARRAYS_NAME);
356    let manifest_bytes = read_bounded_file(
357        &manifest_path,
358        limits.max_manifest_bytes,
359        "project manifest exceeds max_manifest_bytes",
360    )?;
361    let version: ProjectVersionProbe = serde_json::from_slice(&manifest_bytes)?;
362    if !(1..=PROJECT_FORMAT_VERSION).contains(&version.format_version) {
363        return Err(PersistenceError::UnsupportedVersion {
364            version: version.format_version,
365        });
366    }
367    let manifest: ProjectManifest = serde_json::from_slice(&manifest_bytes)?;
368    let rietveld_analyses = match (manifest.format_version, manifest.rietveld_analyses) {
369        (1, None) => Vec::new(),
370        (1, Some(_)) => {
371            return Err(PersistenceError::InvalidRecord {
372                message: "native project format 1 cannot declare Rietveld analyses".to_owned(),
373            });
374        }
375        (_, Some(analyses)) => analyses,
376        (_, None) => {
377            return Err(PersistenceError::InvalidRecord {
378                message: "native project format 2 requires Rietveld analyses".to_owned(),
379            });
380        }
381    };
382    if manifest.archive.file != PROJECT_ARRAYS_NAME {
383        return Err(PersistenceError::InvalidArchive {
384            message: "project archive filename is invalid".to_owned(),
385        });
386    }
387    if manifest.arrays.len() > limits.max_arrays {
388        return Err(PersistenceError::LimitExceeded {
389            message: "project manifest exceeds max_arrays".to_owned(),
390        });
391    }
392    let archive_bytes = read_bounded_file(
393        &archive_path,
394        limits.max_archive_bytes,
395        "project archive exceeds max_archive_bytes",
396    )?;
397    if sha256_hex(&archive_bytes) != manifest.archive.sha256 {
398        return Err(PersistenceError::InvalidArchive {
399            message: "project archive SHA-256 mismatch".to_owned(),
400        });
401    }
402    let arrays = read_npz(&archive_bytes, &manifest.arrays, limits)?;
403    let project = wire::decode_project(manifest.project, arrays, limits)?;
404    rietveld_wire::decode_state(project, rietveld_analyses, limits)
405}
406
407fn read_bounded_file(
408    path: &Path,
409    maximum_bytes: u64,
410    limit_message: &str,
411) -> Result<Vec<u8>, PersistenceError> {
412    let mut bytes = Vec::new();
413    fs::File::open(path)?
414        .take(maximum_bytes.saturating_add(1))
415        .read_to_end(&mut bytes)?;
416    if u64::try_from(bytes.len()).map_or(true, |length| length > maximum_bytes) {
417        return Err(PersistenceError::LimitExceeded {
418            message: limit_message.to_owned(),
419        });
420    }
421    Ok(bytes)
422}
423
424fn absolute_path(path: &Path) -> Result<PathBuf, PersistenceError> {
425    if path.is_absolute() {
426        return Ok(path.to_owned());
427    }
428    Ok(std::env::current_dir()?.join(path))
429}
430
431fn validate_destination(
432    destination: &Path,
433    options: ProjectSaveOptions,
434) -> Result<(), PersistenceError> {
435    if destination.exists() && !destination.is_dir() {
436        return Err(PersistenceError::InvalidDestination {
437            message: format!(
438                "project path exists and is not a directory: {}",
439                destination.display()
440            ),
441        });
442    }
443    if destination.exists() && !options.overwrite {
444        return Err(PersistenceError::InvalidDestination {
445            message: format!(
446                "project directory already exists: {}",
447                destination.display()
448            ),
449        });
450    }
451    Ok(())
452}
453
454fn create_temporary_directory(
455    parent: &Path,
456    destination: &Path,
457) -> Result<PathBuf, PersistenceError> {
458    let stem = destination
459        .file_name()
460        .and_then(|value| value.to_str())
461        .unwrap_or("project");
462    for _ in 0..100 {
463        let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
464        let candidate = parent.join(format!(".{stem}-{}-{sequence}.tmp", std::process::id()));
465        match fs::create_dir(&candidate) {
466            Ok(()) => return Ok(candidate),
467            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
468            Err(error) => return Err(PersistenceError::Io(error)),
469        }
470    }
471    Err(PersistenceError::InvalidDestination {
472        message: "could not allocate a temporary project directory".to_owned(),
473    })
474}
475
476fn write_synced_file(path: &Path, bytes: &[u8]) -> Result<(), PersistenceError> {
477    let mut file = fs::File::create(path)?;
478    file.write_all(bytes)?;
479    file.sync_all()?;
480    Ok(())
481}
482
483#[cfg(not(windows))]
484fn sync_directory(path: &Path) -> Result<(), PersistenceError> {
485    fs::File::open(path)?.sync_all()?;
486    Ok(())
487}
488
489#[cfg(windows)]
490fn sync_directory(_path: &Path) -> Result<(), PersistenceError> {
491    // `File::open` cannot open directories on Windows. The project files are
492    // individually synced before they are renamed, but the standard library
493    // does not expose a portable equivalent of a Unix directory fsync.
494    Ok(())
495}
496
497fn backup_owned_file(source: &Path, backup: &Path) -> Result<(), PersistenceError> {
498    remove_if_exists(backup)?;
499    if source.exists() {
500        fs::rename(source, backup)?;
501    }
502    Ok(())
503}
504
505fn remove_if_exists(path: &Path) -> Result<(), PersistenceError> {
506    match fs::remove_file(path) {
507        Ok(()) => Ok(()),
508        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
509        Err(error) => Err(PersistenceError::Io(error)),
510    }
511}
512
513fn recover_interrupted_save(directory: &Path) -> Result<(), PersistenceError> {
514    let manifest = directory.join(PROJECT_MANIFEST_NAME);
515    let arrays = directory.join(PROJECT_ARRAYS_NAME);
516    let manifest_backup = directory.join(MANIFEST_BACKUP_NAME);
517    let arrays_backup = directory.join(ARRAYS_BACKUP_NAME);
518    let has_manifest_backup = manifest_backup.exists();
519    let has_arrays_backup = arrays_backup.exists();
520    if !has_manifest_backup && !has_arrays_backup {
521        return Ok(());
522    }
523    if manifest.exists() && arrays.exists() {
524        remove_if_exists(&manifest_backup)?;
525        remove_if_exists(&arrays_backup)?;
526        sync_directory(directory)?;
527        return Ok(());
528    }
529    for (current, backup) in [(&arrays, &arrays_backup), (&manifest, &manifest_backup)] {
530        if backup.exists() {
531            remove_if_exists(current)?;
532            fs::rename(backup, current)?;
533        }
534    }
535    sync_directory(directory)?;
536    Ok(())
537}