Skip to main content

wsi_dicom/application/
export_workflow.rs

1use std::error::Error as StdError;
2use std::fmt;
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use serde::Serialize;
7
8use super::MetadataInput;
9use crate::{
10    export_dicom, validate_dicom_path, AnnotationCoordinateSpace, ColorManagement, Error,
11    ExportOptions, ExportReport, ExportRequest, QuPathAnnotationOptions, ValidationOptions,
12    ValidationReport,
13};
14
15/// Stage of the application workflow responsible for an outcome or failure.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "snake_case")]
18#[non_exhaustive]
19pub enum ExportWorkflowStage {
20    /// Resolve and validate the selected metadata input.
21    Metadata,
22    /// Read and normalize annotation inputs before the WSI export.
23    Annotations,
24    /// Open the source, encode instances, and publish the WSI generation.
25    Export,
26    /// Validate the completed output when requested.
27    Validation,
28    /// Serialize or persist the combined machine report.
29    Report,
30}
31
32impl fmt::Display for ExportWorkflowStage {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        let label = match self {
35            Self::Metadata => "metadata",
36            Self::Annotations => "annotations",
37            Self::Export => "export",
38            Self::Validation => "validation",
39            Self::Report => "report",
40        };
41        formatter.write_str(label)
42    }
43}
44
45/// Structured error preserving the application stage and underlying library error.
46#[derive(Debug)]
47pub struct ExportWorkflowError {
48    stage: ExportWorkflowStage,
49    source: Error,
50}
51
52impl ExportWorkflowError {
53    pub(crate) const fn new(stage: ExportWorkflowStage, source: Error) -> Self {
54        Self { stage, source }
55    }
56
57    /// Return the stage that failed.
58    #[must_use]
59    pub const fn stage(&self) -> ExportWorkflowStage {
60        self.stage
61    }
62
63    /// Return the structured library error that caused the failure.
64    #[must_use]
65    pub const fn source_error(&self) -> &Error {
66        &self.source
67    }
68}
69
70impl fmt::Display for ExportWorkflowError {
71    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72        write!(formatter, "{} stage failed: {}", self.stage, self.source)
73    }
74}
75
76impl StdError for ExportWorkflowError {
77    fn source(&self) -> Option<&(dyn StdError + 'static)> {
78        Some(&self.source)
79    }
80}
81
82/// Coarse progress event emitted at application workflow boundaries.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84#[non_exhaustive]
85pub enum ExportWorkflowProgressEvent {
86    /// Metadata input is being resolved.
87    ResolvingMetadata,
88    /// Annotation inputs are being prepared.
89    PreparingAnnotations,
90    /// WSI export is running.
91    Exporting,
92    /// Optional DICOM validation is running.
93    Validating,
94    /// Optional machine report persistence is running.
95    PersistingReport,
96    /// Every requested workflow stage completed.
97    Complete,
98}
99
100/// Thread-safe receiver for coarse application workflow progress.
101pub trait ExportWorkflowProgress: Send + Sync {
102    /// Receive a progress event. Implementations should return promptly.
103    fn on_progress(&self, event: ExportWorkflowProgressEvent);
104}
105
106/// Frontend-independent request for export, annotations, validation, and reporting.
107pub struct ExportWorkflowRequest {
108    /// Source slide path.
109    pub source_path: PathBuf,
110    /// Destination directory for the exported DICOM generation.
111    pub output_dir: PathBuf,
112    /// Flat public export options; normalized by the export preparation boundary.
113    pub options: ExportOptions,
114    /// Required color-management behavior.
115    pub color_management: ColorManagement,
116    /// Exactly one metadata selection.
117    pub metadata: MetadataInput,
118    /// Optional source pyramid level filter.
119    pub level_filter: Option<u32>,
120    /// Optional prepared-before-export QuPath annotation conversion.
121    pub annotations: Option<QuPathAnnotationOptions>,
122    /// Optional validation to run against the completed output.
123    pub validation: Option<ValidationOptions>,
124    /// Optional destination for the combined JSON report.
125    pub report_path: Option<PathBuf>,
126    /// Optional coarse progress receiver.
127    pub progress: Option<Arc<dyn ExportWorkflowProgress>>,
128}
129
130impl ExportWorkflowRequest {
131    /// Create a workflow request with optional stages disabled.
132    #[must_use]
133    pub fn new(
134        source_path: impl Into<PathBuf>,
135        output_dir: impl Into<PathBuf>,
136        options: ExportOptions,
137        color_management: ColorManagement,
138        metadata: MetadataInput,
139    ) -> Self {
140        Self {
141            source_path: source_path.into(),
142            output_dir: output_dir.into(),
143            options,
144            color_management,
145            metadata,
146            level_filter: None,
147            annotations: None,
148            validation: None,
149            report_path: None,
150            progress: None,
151        }
152    }
153}
154
155/// Combined machine report from the shared application workflow.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
157#[non_exhaustive]
158pub struct ExportWorkflowReport {
159    /// WSI export and optional annotation sidecar report.
160    pub export: ExportReport,
161    /// Optional DICOM validation report.
162    pub validation: Option<ValidationReport>,
163}
164
165impl ExportWorkflowReport {
166    /// Serialize the stable combined report shape as pretty JSON.
167    pub fn to_pretty_json(&self) -> Result<String, Error> {
168        serde_json::to_string_pretty(self).map_err(|error| Error::JsonSerialize {
169            message: error.to_string(),
170        })
171    }
172
173    /// Build a concise frontend-neutral status summary.
174    #[must_use]
175    pub fn summary(&self) -> String {
176        let annotation_instances = self
177            .export
178            .annotations
179            .as_ref()
180            .map_or(0, |annotations| annotations.instances.len());
181        match &self.validation {
182            Some(validation) => format!(
183                "Exported {} WSI instance(s) and {} annotation sidecar(s); validation passed={} failed={} skipped={}.",
184                self.export.instances.len(),
185                annotation_instances,
186                validation.passed_checks(),
187                validation.failed_checks(),
188                validation.skipped_checks(),
189            ),
190            None => format!(
191                "Exported {} WSI instance(s) and {} annotation sidecar(s).",
192                self.export.instances.len(),
193                annotation_instances,
194            ),
195        }
196    }
197}
198
199/// Execute the frontend-independent export workflow.
200pub fn run_export_workflow(
201    request: ExportWorkflowRequest,
202) -> Result<ExportWorkflowReport, ExportWorkflowError> {
203    emit(
204        &request.progress,
205        ExportWorkflowProgressEvent::ResolvingMetadata,
206    );
207    let metadata = request.metadata.resolve()?;
208
209    emit(
210        &request.progress,
211        ExportWorkflowProgressEvent::PreparingAnnotations,
212    );
213    let prepared_annotations = match request.annotations {
214        Some(annotations) => {
215            if annotations.coordinate_space == AnnotationCoordinateSpace::Level0Pixels
216                && request.level_filter.is_some_and(|level| level != 0)
217            {
218                return Err(ExportWorkflowError::new(
219                    ExportWorkflowStage::Annotations,
220                    Error::InvalidOptions {
221                        reason: "level-zero QuPath coordinates require exporting level 0".into(),
222                    },
223                ));
224            }
225            Some(annotations.prepare().map_err(|error| {
226                ExportWorkflowError::new(ExportWorkflowStage::Annotations, error)
227            })?)
228        }
229        None => None,
230    };
231
232    emit(&request.progress, ExportWorkflowProgressEvent::Exporting);
233    let mut export = export_dicom(ExportRequest {
234        source_path: request.source_path,
235        output_dir: request.output_dir.clone(),
236        options: request.options,
237        color_management: request.color_management,
238        metadata,
239        level_filter: request.level_filter,
240    })
241    .map_err(|error| ExportWorkflowError::new(ExportWorkflowStage::Export, error))?;
242
243    if let Some(annotations) = prepared_annotations {
244        export.annotations =
245            Some(annotations.export_for(&export).map_err(|error| {
246                ExportWorkflowError::new(ExportWorkflowStage::Annotations, error)
247            })?);
248    }
249
250    let validation = match request.validation {
251        Some(options) => {
252            emit(&request.progress, ExportWorkflowProgressEvent::Validating);
253            Some(
254                validate_dicom_path(&request.output_dir, &options).map_err(|error| {
255                    ExportWorkflowError::new(ExportWorkflowStage::Validation, error)
256                })?,
257            )
258        }
259        None => None,
260    };
261
262    let report = ExportWorkflowReport { export, validation };
263    if let Some(path) = request.report_path {
264        emit(
265            &request.progress,
266            ExportWorkflowProgressEvent::PersistingReport,
267        );
268        let json = report
269            .to_pretty_json()
270            .map_err(|error| ExportWorkflowError::new(ExportWorkflowStage::Report, error))?;
271        std::fs::write(&path, json).map_err(|source| {
272            ExportWorkflowError::new(ExportWorkflowStage::Report, Error::Io { path, source })
273        })?;
274    }
275    emit(&request.progress, ExportWorkflowProgressEvent::Complete);
276    Ok(report)
277}
278
279fn emit(progress: &Option<Arc<dyn ExportWorkflowProgress>>, event: ExportWorkflowProgressEvent) {
280    if let Some(progress) = progress {
281        progress.on_progress(event);
282    }
283}