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