Skip to main content

phasesmith_persistence/
report.rs

1//! Stable JSON project summary records for desktop and scripting hosts.
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7
8use phasesmith_model::{ProjectRecord, RadiationProbe};
9use serde::Serialize;
10
11use crate::{PROJECT_FORMAT_VERSION, PersistenceError};
12
13/// Project-summary report write behavior.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15pub struct ProjectReportSaveOptions {
16    /// Replace an existing report file when true.
17    pub overwrite: bool,
18}
19
20/// One histogram entry in a project summary.
21#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
22pub struct HistogramSummary {
23    /// Stable histogram ID.
24    pub histogram_id: String,
25    /// Human-readable label.
26    pub name: String,
27    /// Pattern sample count.
28    pub sample_count: usize,
29    /// Whether observed intensities are present.
30    pub has_observations: bool,
31    /// X-ray or neutron probe.
32    pub probe: String,
33    /// Ordered active phase IDs.
34    pub phase_ids: Vec<String>,
35}
36
37/// One phase entry in a project summary.
38#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
39pub struct PhaseSummary {
40    /// Stable phase ID.
41    pub phase_id: String,
42    /// Human-readable label.
43    pub name: String,
44    /// Stored reflection-family count.
45    pub reflection_count: usize,
46    /// Asymmetric-site count.
47    pub site_count: usize,
48    /// Required external provider identifiers with versions.
49    pub required_providers: Vec<String>,
50}
51
52/// Versioned, display-safe project summary without bulk numerical arrays.
53#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
54pub struct ProjectSummaryReport {
55    /// Native project wire version summarized by this report.
56    pub format_version: u32,
57    /// Stable project ID.
58    pub project_id: String,
59    /// Snapshot revision.
60    pub revision: u64,
61    /// Human-readable project label.
62    pub name: String,
63    /// Number of histograms.
64    pub histogram_count: usize,
65    /// Number of project-owned phases.
66    pub phase_count: usize,
67    /// Total samples over all histograms.
68    pub total_sample_count: usize,
69    /// Histogram summaries in project order.
70    pub histograms: Vec<HistogramSummary>,
71    /// Phase summaries in project order.
72    pub phases: Vec<PhaseSummary>,
73    /// Project textual metadata.
74    pub metadata: BTreeMap<String, String>,
75}
76
77impl ProjectSummaryReport {
78    /// Build a stable report after recursively validating the project.
79    ///
80    /// # Errors
81    ///
82    /// Returns [`PersistenceError::Domain`] for invalid live project state or
83    /// [`PersistenceError::InvalidRecord`] if sample counts overflow.
84    pub fn from_project(project: &ProjectRecord) -> Result<Self, PersistenceError> {
85        project.validate().map_err(PersistenceError::Domain)?;
86        let total_sample_count =
87            project
88                .histograms
89                .iter()
90                .try_fold(0_usize, |total, histogram| {
91                    total
92                        .checked_add(histogram.pattern.sample_count())
93                        .ok_or_else(|| PersistenceError::InvalidRecord {
94                            message: "project summary sample count overflow".to_owned(),
95                        })
96                })?;
97        Ok(Self {
98            format_version: PROJECT_FORMAT_VERSION,
99            project_id: project.project_id.as_str().to_owned(),
100            revision: project.revision,
101            name: project.name.clone(),
102            histogram_count: project.histograms.len(),
103            phase_count: project.phases.len(),
104            total_sample_count,
105            histograms: project
106                .histograms
107                .iter()
108                .map(|histogram| HistogramSummary {
109                    histogram_id: histogram.histogram_id.as_str().to_owned(),
110                    name: histogram.name.clone(),
111                    sample_count: histogram.pattern.sample_count(),
112                    has_observations: histogram.pattern.observed_y.is_some(),
113                    probe: match histogram.experiment.radiation.probe() {
114                        RadiationProbe::Xray => "xray",
115                        RadiationProbe::Neutron => "neutron",
116                    }
117                    .to_owned(),
118                    phase_ids: histogram
119                        .phase_ids
120                        .iter()
121                        .map(|value| value.as_str().to_owned())
122                        .collect(),
123                })
124                .collect(),
125            phases: project
126                .phases
127                .iter()
128                .map(|phase| PhaseSummary {
129                    phase_id: phase.phase_id.as_str().to_owned(),
130                    name: phase.name.clone(),
131                    reflection_count: phase.definition.hkl.len(),
132                    site_count: phase.definition.fractional_xyz.len(),
133                    required_providers: phase
134                        .required_providers
135                        .iter()
136                        .map(|requirement| {
137                            format!(
138                                "{}@{}",
139                                requirement.provider_id, requirement.provider_version
140                            )
141                        })
142                        .collect(),
143                })
144                .collect(),
145            metadata: project.metadata.clone(),
146        })
147    }
148}
149
150/// Serialize one validated summary to deterministic pretty JSON.
151///
152/// # Errors
153///
154/// Returns [`PersistenceError`] for invalid project state or serialization.
155pub fn project_summary_json(project: &ProjectRecord) -> Result<String, PersistenceError> {
156    let report = ProjectSummaryReport::from_project(project)?;
157    let mut encoded = serde_json::to_string_pretty(&report)?;
158    encoded.push('\n');
159    Ok(encoded)
160}
161
162/// Write one validated project summary as JSON.
163///
164/// # Errors
165///
166/// Returns [`PersistenceError`] for validation, serialization, or filesystem
167/// failures.
168pub fn write_project_summary_json(
169    project: &ProjectRecord,
170    path: impl AsRef<Path>,
171) -> Result<PathBuf, PersistenceError> {
172    write_project_summary_json_with_options(
173        project,
174        path,
175        ProjectReportSaveOptions { overwrite: true },
176    )
177}
178
179/// Write one validated project summary with an explicit overwrite policy.
180///
181/// # Errors
182///
183/// Returns [`PersistenceError`] for validation, serialization, destination, or
184/// filesystem failures.
185pub fn write_project_summary_json_with_options(
186    project: &ProjectRecord,
187    path: impl AsRef<Path>,
188    options: ProjectReportSaveOptions,
189) -> Result<PathBuf, PersistenceError> {
190    let path = path.as_ref();
191    if let Some(parent) = path.parent()
192        && !parent.as_os_str().is_empty()
193    {
194        fs::create_dir_all(parent)?;
195    }
196    let encoded = project_summary_json(project)?;
197    if options.overwrite {
198        fs::write(path, encoded)?;
199    } else {
200        fs::OpenOptions::new()
201            .write(true)
202            .create_new(true)
203            .open(path)
204            .map_err(|error| {
205                if error.kind() == std::io::ErrorKind::AlreadyExists {
206                    PersistenceError::InvalidDestination {
207                        message: format!("report destination already exists: {}", path.display()),
208                    }
209                } else {
210                    PersistenceError::Io(error)
211                }
212            })?
213            .write_all(encoded.as_bytes())?;
214    }
215    Ok(path.to_owned())
216}