1use dataprof_core::{ColumnProfile, DataSource, ExecutionMetadata, SemanticHintBinding};
2use dataprof_metrics::{QualityAssessment, QualityMetrics};
3
4pub const REPORT_SCHEMA_VERSION: u32 = 1;
19
20#[derive(Debug, Clone, serde::Serialize)]
27pub struct ProfileReport {
28 pub schema_version: u32,
33 pub id: String,
35 pub timestamp: String,
37 pub data_source: DataSource,
39 pub column_profiles: Vec<ColumnProfile>,
41 pub execution: ExecutionMetadata,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub quality: Option<QualityAssessment>,
46 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub semantic_hint_bindings: Vec<SemanticHintBinding>,
54}
55
56impl ProfileReport {
57 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 pub fn with_semantic_hint_bindings(mut self, bindings: Vec<SemanticHintBinding>) -> Self {
78 self.semantic_hint_bindings = bindings;
79 self
80 }
81
82 pub fn with_id(mut self, id: impl Into<String>) -> Self {
84 self.id = id.into();
85 self
86 }
87
88 pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
90 self.timestamp = timestamp.into();
91 self
92 }
93
94 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 pub fn source_identifier(&self) -> String {
107 self.data_source.identifier()
108 }
109}
110
111#[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
150impl<'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 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 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
188fn 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 assert_eq!(report.schema_version, 0);
327 assert_eq!(report.execution.rows_processed, 10);
328 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 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 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}