Skip to main content

dataprof_runtime/
profile_report.rs

1use dataprof_core::{ColumnProfile, DataSource, ExecutionMetadata, SemanticHintBinding};
2use dataprof_metrics::{QualityAssessment, QualityMetrics};
3
4/// Version of the serialized `ProfileReport` schema written by this build.
5///
6/// This is intentionally independent of the package version: the document
7/// format only changes when the schema itself changes, not on every release.
8///
9/// Compatibility policy for readers:
10/// - Documents without a `schema_version` field are legacy pre-0.10 reports
11///   and deserialize with `schema_version == 0`.
12/// - Unknown *additive* fields written by a newer dataprof are ignored, so a
13///   reader accepts any document whose `schema_version` is at most this
14///   constant.
15/// - A document with a `schema_version` greater than this constant fails to
16///   deserialize with an explicit error instead of being partially decoded
17///   into a plausible-but-wrong report.
18pub const REPORT_SCHEMA_VERSION: u32 = 1;
19
20/// Complete profiling report for a data source.
21///
22/// Contains column-level statistics, execution metadata, and an optional
23/// Quality assessment informed by ISO 8000/25012 concepts. This is the primary output of all
24/// profiling operations (`Profiler::analyze_file`, `Profiler::analyze_source`,
25/// `Profiler::profile_stream`, etc.).
26#[derive(Debug, Clone, serde::Serialize)]
27pub struct ProfileReport {
28    /// Version of the serialized report schema (see [`REPORT_SCHEMA_VERSION`]).
29    ///
30    /// `0` means the document predates schema versioning (a 0.9-era report).
31    /// Deserialization rejects versions newer than [`REPORT_SCHEMA_VERSION`].
32    pub schema_version: u32,
33    /// Unique identifier for this report (UUID v4)
34    pub id: String,
35    /// Timestamp when the report was generated (ISO 8601 / RFC 3339)
36    pub timestamp: String,
37    /// Data source metadata (file, query, etc.)
38    pub data_source: DataSource,
39    /// Column-level profiling results
40    pub column_profiles: Vec<ColumnProfile>,
41    /// Execution metadata (timing, rows processed, truncation info, etc.)
42    pub execution: ExecutionMetadata,
43    /// Data quality assessment (optional — partial analysis may skip quality)
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub quality: Option<QualityAssessment>,
46    /// Per-column evidence of how each semantic hint bound to the data.
47    ///
48    /// Empty when no hints were supplied. Recorded for provenance: a hint proven
49    /// inert over the full data is rejected before a report is returned, so a
50    /// successful report only carries bindings that matched something or whose
51    /// evidence was sampled. Additive field — older readers ignore it.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub semantic_hint_bindings: Vec<SemanticHintBinding>,
54}
55
56impl ProfileReport {
57    /// Create a new ProfileReport with auto-generated id and timestamp
58    pub fn new(
59        data_source: DataSource,
60        column_profiles: Vec<ColumnProfile>,
61        execution: ExecutionMetadata,
62        quality: Option<QualityAssessment>,
63    ) -> Self {
64        Self {
65            schema_version: REPORT_SCHEMA_VERSION,
66            id: uuid::Uuid::new_v4().to_string(),
67            timestamp: chrono::Utc::now().to_rfc3339(),
68            data_source,
69            column_profiles,
70            execution,
71            quality,
72            semantic_hint_bindings: Vec::new(),
73        }
74    }
75
76    /// Attach per-column semantic-hint binding evidence.
77    pub fn with_semantic_hint_bindings(mut self, bindings: Vec<SemanticHintBinding>) -> Self {
78        self.semantic_hint_bindings = bindings;
79        self
80    }
81
82    /// Override the auto-generated ID (useful for deterministic caching/testing)
83    pub fn with_id(mut self, id: impl Into<String>) -> Self {
84        self.id = id.into();
85        self
86    }
87
88    /// Override the auto-generated timestamp
89    pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
90        self.timestamp = timestamp.into();
91        self
92    }
93
94    /// Calculate the overall quality score (weighted across the assessed
95    /// dimensions). Returns `None` if quality metrics were not computed, or
96    /// if no dimension had anything to assess (e.g. an empty dataset) —
97    /// absence of evidence is not a perfect score.
98    pub fn quality_score(&self) -> Option<f64> {
99        self.quality
100            .as_ref()
101            .filter(|q| !q.metrics.assessed_dimensions().is_empty())
102            .map(|q| q.score())
103    }
104
105    /// Get the data source identifier (for backwards compatibility)
106    pub fn source_identifier(&self) -> String {
107        self.data_source.identifier()
108    }
109}
110
111/// Mirror of [`ProfileReport`] carrying the field-level deserialization
112/// rules (legacy aliases, quality compat). Kept private: the public entry
113/// point is the manual [`serde::Deserialize`] impl below, which gates on
114/// `schema_version` before any of these fields decode.
115#[derive(serde::Deserialize)]
116struct ProfileReportFields {
117    #[serde(default)]
118    schema_version: u32,
119    id: String,
120    timestamp: String,
121    data_source: DataSource,
122    column_profiles: Vec<ColumnProfile>,
123    #[serde(alias = "scan_info")]
124    execution: ExecutionMetadata,
125    #[serde(
126        alias = "data_quality_metrics",
127        default,
128        deserialize_with = "deserialize_quality_compat"
129    )]
130    quality: Option<QualityAssessment>,
131    #[serde(default)]
132    semantic_hint_bindings: Vec<SemanticHintBinding>,
133}
134
135impl From<ProfileReportFields> for ProfileReport {
136    fn from(fields: ProfileReportFields) -> Self {
137        Self {
138            schema_version: fields.schema_version,
139            id: fields.id,
140            timestamp: fields.timestamp,
141            data_source: fields.data_source,
142            column_profiles: fields.column_profiles,
143            execution: fields.execution,
144            quality: fields.quality,
145            semantic_hint_bindings: fields.semantic_hint_bindings,
146        }
147    }
148}
149
150/// Manual deserialization so the `schema_version` gate runs before any other
151/// field decodes. A derived deserializer visits fields in document order, so
152/// an unsupported future document that also changed structure could fail with
153/// a confusing structural error instead of the actionable version error; by
154/// buffering the document first, the version check always wins.
155impl<'de> serde::Deserialize<'de> for ProfileReport {
156    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
157    where
158        D: serde::Deserializer<'de>,
159    {
160        use serde::de::Error;
161
162        let value = serde_json::Value::deserialize(deserializer)?;
163        match value.get("schema_version") {
164            // Absent field: legacy pre-0.10 document, defaults to version 0.
165            None => {}
166            Some(serde_json::Value::Number(n)) if n.as_u64().is_some() => {
167                let version = n.as_u64().unwrap_or_default();
168                if version > u64::from(REPORT_SCHEMA_VERSION) {
169                    return Err(D::Error::custom(format!(
170                        "report schema version {version} is newer than the latest supported \
171                         version {REPORT_SCHEMA_VERSION}; upgrade dataprof to read this report"
172                    )));
173                }
174            }
175            // An explicit null or non-integer is malformed, not legacy.
176            Some(other) => {
177                return Err(D::Error::custom(format!(
178                    "report schema_version must be a non-negative integer, got {other}"
179                )));
180            }
181        }
182        ProfileReportFields::deserialize(value)
183            .map(ProfileReport::from)
184            .map_err(D::Error::custom)
185    }
186}
187
188/// Custom deserializer that handles both legacy `DataQualityMetrics` (flat)
189/// and new `QualityAssessment` (wrapped with confidence) JSON formats.
190fn deserialize_quality_compat<'de, D>(
191    deserializer: D,
192) -> Result<Option<QualityAssessment>, D::Error>
193where
194    D: serde::Deserializer<'de>,
195{
196    use serde::Deserialize;
197
198    let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
199    match value {
200        None => Ok(None),
201        Some(v) => {
202            if v.get("metrics").is_some() && v.get("confidence").is_some() {
203                let assessment: QualityAssessment =
204                    serde_json::from_value(v).map_err(serde::de::Error::custom)?;
205                Ok(Some(assessment))
206            } else {
207                let metrics: QualityMetrics =
208                    serde_json::from_value(v).map_err(serde::de::Error::custom)?;
209                Ok(Some(QualityAssessment::exact(metrics)))
210            }
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use dataprof_core::FileFormat;
219    use dataprof_metrics::MetricConfidence;
220    use serde_json::json;
221
222    #[test]
223    fn test_profile_report_json_roundtrip() {
224        let report = ProfileReport::new(
225            DataSource::File {
226                path: "test.csv".to_string(),
227                format: FileFormat::Csv,
228                size_bytes: 1024,
229                modified_at: None,
230                parquet_metadata: None,
231            },
232            vec![],
233            ExecutionMetadata::new(100, 5, 50),
234            Some(QualityAssessment::exact(QualityMetrics::empty())),
235        );
236
237        let json = serde_json::to_string(&report).unwrap();
238        let deserialized: ProfileReport = serde_json::from_str(&json).unwrap();
239
240        assert_eq!(deserialized.id, report.id);
241        assert_eq!(deserialized.timestamp, report.timestamp);
242        assert_eq!(deserialized.source_identifier(), "test.csv");
243        assert_eq!(deserialized.execution.rows_processed, 100);
244        assert!(deserialized.quality.is_some());
245        assert_eq!(deserialized.schema_version, REPORT_SCHEMA_VERSION);
246    }
247
248    #[test]
249    fn test_serialized_report_carries_schema_version() {
250        let report = ProfileReport::new(
251            DataSource::File {
252                path: "test.csv".to_string(),
253                format: FileFormat::Csv,
254                size_bytes: 1024,
255                modified_at: None,
256                parquet_metadata: None,
257            },
258            vec![],
259            ExecutionMetadata::new(100, 5, 50),
260            None,
261        );
262
263        let value = serde_json::to_value(&report).unwrap();
264        assert_eq!(
265            value.get("schema_version").and_then(|v| v.as_u64()),
266            Some(u64::from(REPORT_SCHEMA_VERSION))
267        );
268    }
269
270    #[test]
271    fn test_profile_report_without_quality() {
272        let report = ProfileReport::new(
273            DataSource::File {
274                path: "test.csv".to_string(),
275                format: FileFormat::Csv,
276                size_bytes: 1024,
277                modified_at: None,
278                parquet_metadata: None,
279            },
280            vec![],
281            ExecutionMetadata::new(100, 5, 50),
282            None,
283        );
284
285        let json = serde_json::to_string(&report).unwrap();
286        let deserialized: ProfileReport = serde_json::from_str(&json).unwrap();
287
288        assert!(deserialized.quality.is_none());
289        assert_eq!(deserialized.execution.rows_processed, 100);
290    }
291
292    #[test]
293    fn test_profile_report_deserializes_legacy_quality_metrics() {
294        let json = json!({
295            "id": "legacy-report",
296            "timestamp": "2026-05-22T10:00:00Z",
297            "data_source": {
298                "type": "file",
299                "path": "test.csv",
300                "format": "csv",
301                "size_bytes": 42
302            },
303            "column_profiles": [],
304            "scan_info": {
305                "rows_processed": 10,
306                "columns_detected": 2,
307                "scan_time_ms": 5,
308                "error_count": 0,
309                "source_exhausted": true,
310                "sampling_applied": false
311            },
312            "data_quality_metrics": {
313                "completeness": {
314                    "missing_values_ratio": 0.0,
315                    "complete_records_ratio": 100.0,
316                    "null_columns": []
317                }
318            }
319        });
320
321        let report: ProfileReport = serde_json::from_value(json).unwrap();
322
323        assert_eq!(report.id, "legacy-report");
324        // A document without a schema_version field is a legacy pre-0.10
325        // report; it must load and be identifiable as such.
326        assert_eq!(report.schema_version, 0);
327        assert_eq!(report.execution.rows_processed, 10);
328        // Legacy metrics predate the assessability denominators: the facts
329        // stay readable, but no score is fabricated from them.
330        assert!(report.quality_score().is_none());
331        let quality = report
332            .quality
333            .expect("expected legacy quality to deserialize");
334        assert!(matches!(quality.confidence, MetricConfidence::Exact));
335        let completeness = quality
336            .metrics
337            .completeness
338            .as_ref()
339            .expect("legacy completeness facts should deserialize");
340        assert!((completeness.complete_records_ratio - 100.0).abs() < 0.01);
341        assert!(quality.metrics.assessed_dimensions().is_empty());
342    }
343
344    fn current_document() -> serde_json::Value {
345        json!({
346            "schema_version": REPORT_SCHEMA_VERSION,
347            "id": "current-report",
348            "timestamp": "2026-07-16T10:00:00Z",
349            "data_source": {
350                "type": "file",
351                "path": "test.csv",
352                "format": "csv",
353                "size_bytes": 42
354            },
355            "column_profiles": [],
356            "execution": {
357                "rows_processed": 10,
358                "columns_detected": 2,
359                "scan_time_ms": 5,
360                "error_count": 0,
361                "source_exhausted": true,
362                "sampling_applied": false
363            }
364        })
365    }
366
367    #[test]
368    fn test_additive_fields_from_newer_writer_are_ignored() {
369        // A newer dataprof may add fields within the same schema version;
370        // a compatible reader must not break on them.
371        let mut json = current_document();
372        json["a_future_additive_field"] = json!({"anything": true});
373        json["column_profiles"] = json!([]);
374
375        let report: ProfileReport = serde_json::from_value(json).unwrap();
376        assert_eq!(report.schema_version, REPORT_SCHEMA_VERSION);
377        assert_eq!(report.id, "current-report");
378    }
379
380    #[test]
381    fn test_unsupported_future_schema_version_fails_explicitly() {
382        let mut json = current_document();
383        json["schema_version"] = json!(REPORT_SCHEMA_VERSION + 1);
384
385        let err = serde_json::from_value::<ProfileReport>(json).unwrap_err();
386        let msg = err.to_string();
387        assert!(
388            msg.contains("schema version") && msg.contains("upgrade dataprof"),
389            "expected an actionable schema-version error, got: {msg}"
390        );
391    }
392
393    #[test]
394    fn test_version_error_wins_over_structural_errors() {
395        // A future document that also broke structure, with schema_version
396        // appearing after the broken field, must still fail with the version
397        // error — not a confusing type error from the field encountered first.
398        let json_text = format!(
399            r#"{{"column_profiles": "not-an-array", "schema_version": {}}}"#,
400            REPORT_SCHEMA_VERSION + 1
401        );
402        let err = serde_json::from_str::<ProfileReport>(&json_text).unwrap_err();
403        let msg = err.to_string();
404        assert!(
405            msg.contains("upgrade dataprof"),
406            "expected the schema-version error to win, got: {msg}"
407        );
408    }
409
410    #[test]
411    fn test_null_schema_version_is_malformed_not_legacy() {
412        let mut json = current_document();
413        json["schema_version"] = json!(null);
414
415        let err = serde_json::from_value::<ProfileReport>(json).unwrap_err();
416        let msg = err.to_string();
417        assert!(
418            msg.contains("schema_version must be a non-negative integer"),
419            "expected explicit rejection of null schema_version, got: {msg}"
420        );
421    }
422
423    #[test]
424    fn test_non_integer_schema_version_is_rejected() {
425        for bad in [json!("1"), json!(1.5), json!(-1), json!(true)] {
426            let mut json = current_document();
427            json["schema_version"] = bad.clone();
428            let err = serde_json::from_value::<ProfileReport>(json).unwrap_err();
429            assert!(
430                err.to_string().contains("non-negative integer"),
431                "expected rejection of {bad}, got: {err}"
432            );
433        }
434    }
435}