Skip to main content

dataprof_runtime/
profile_report.rs

1use std::sync::OnceLock;
2
3use dataprof_core::{
4    AnalysisOptions, ColumnProfile, DataSource, ExecutionMetadata, KeyColumnSource,
5    KeyUniquenessUnits, Locale, NullTokenSet, OutlierUnits, PatternMatchCount, QualityScoreWeights,
6    RangeRuleCount, SemanticHintBinding, ShapeMomentEstimator, TextLengthUnit,
7};
8use dataprof_metrics::{
9    AccuracyMetrics, CompletenessMetrics, ConsistencyMetrics, MetricConfidence, PrecisionMetrics,
10    QualityAssessment, QualityMetrics, TimelinessMetrics, ValidityMetrics,
11};
12
13/// Version of the serialized `ProfileReport` schema written by this build.
14///
15/// This is intentionally independent of the package version: the document
16/// format only changes when the schema itself changes, not on every release.
17///
18/// Compatibility policy for readers:
19/// - Documents without a `schema_version` field are legacy pre-0.10 reports
20///   and deserialize with `schema_version == 0`.
21/// - Unknown *additive* fields written by a newer dataprof are ignored, so a
22///   reader accepts any document whose `schema_version` is at most this
23///   constant.
24/// - A document with a `schema_version` greater than this constant fails to
25///   deserialize with an explicit error instead of being partially decoded
26///   into a plausible-but-wrong report.
27pub const REPORT_SCHEMA_VERSION: u32 = 1;
28
29/// Why a report does or does not carry a quality assessment.
30///
31/// `quality` alone cannot answer that. A run that never asked for quality
32/// metrics and a run whose quality computation failed both leave it `None`,
33/// so absence itself became the plausible value that hid the failure. This
34/// names the difference: a consumer deciding on a report can tell "you did
35/// not ask for this" from "this broke" from "there was nothing to measure".
36#[derive(
37    Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
38)]
39#[serde(tag = "state", rename_all = "snake_case")]
40pub enum QualityAnalysisStatus {
41    /// Computed: `quality` carries the assessment.
42    Computed,
43    /// Not requested for this run — the quality pack was deselected.
44    NotRequested,
45    /// Requested, but no quality sample was supplied to the assembler.
46    /// An empty sample is analyzed and reports `Computed`, even without columns.
47    NoData,
48    /// Requested, but every requested dimension measures whole rows and the
49    /// run profiled a subset of columns. Completeness and uniqueness mean
50    /// something else after projection, and the report cannot label only
51    /// their row-level fields as projected, so they are withheld rather than
52    /// published under full-row names.
53    WithheldByProjection,
54    /// Requested and attempted; the computation failed. `quality` is absent
55    /// because the computation broke, not because nothing was asked for.
56    Failed {
57        /// The error the metrics calculator reported.
58        error: String,
59    },
60    /// Written by a release that did not record this. Only reachable by
61    /// deserializing a document from before the field existed that carries no
62    /// quality assessment; a stored assessment is read back as `Computed`.
63    Unrecorded,
64}
65
66/// How the measurements in a report were defined.
67///
68/// `schema_version` answers whether a document validates. It cannot answer
69/// whether two valid documents measured the same way: text lengths counted
70/// UTF-8 bytes through 0.11 and count Unicode scalar values since, with no
71/// change to the document's shape. This records each intentionally changed
72/// definition by name, so a comparison can check exactly the ones both
73/// reports declare.
74///
75/// Every field is optional. A field missing from a stored report means the
76/// release that wrote it did not record that definition: unknown, never the
77/// current one. Later releases add fields, which older readers ignore.
78#[derive(
79    Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
80)]
81#[non_exhaustive]
82pub struct MetricSemantics {
83    /// Unit of `min_length`, `max_length` and `avg_length` on text columns.
84    ///
85    /// Absent means not recorded; an explicit null is malformed, so neither
86    /// the reader nor the schema accepts one.
87    #[serde(
88        default,
89        skip_serializing_if = "Option::is_none",
90        deserialize_with = "present_value"
91    )]
92    #[schemars(with = "TextLengthUnit")]
93    pub text_length_unit: Option<TextLengthUnit>,
94    /// The text values counted as null, which every `null_count` and every
95    /// completeness figure depend on.
96    ///
97    /// Absent means not recorded: the release that wrote the report counted
98    /// only empty, `null` and `nan`, or recorded nothing at all.
99    #[serde(
100        default,
101        skip_serializing_if = "Option::is_none",
102        deserialize_with = "present_value"
103    )]
104    #[schemars(with = "NullTokenSet")]
105    pub null_tokens: Option<NullTokenSet>,
106    /// What every pattern's `match_count` and `match_percentage` count.
107    ///
108    /// Absent means not recorded: through 0.12 they counted every regular
109    /// expression match, validated or not.
110    #[serde(
111        default,
112        skip_serializing_if = "Option::is_none",
113        deserialize_with = "present_value"
114    )]
115    #[schemars(with = "PatternMatchCount")]
116    pub pattern_match_count: Option<PatternMatchCount>,
117    /// The estimator behind every column's `skewness` and `kurtosis`.
118    ///
119    /// Absent means not recorded: through 0.12 both used a form that matches
120    /// neither the population nor the adjusted estimators.
121    #[serde(
122        default,
123        skip_serializing_if = "Option::is_none",
124        deserialize_with = "present_value"
125    )]
126    #[schemars(with = "ShapeMomentEstimator")]
127    pub shape_moments: Option<ShapeMomentEstimator>,
128    /// What the range and sign rules behind the accuracy score count.
129    ///
130    /// Absent means not recorded: through 0.12 every rule failure counted,
131    /// over the numeric values of every column.
132    #[serde(
133        default,
134        skip_serializing_if = "Option::is_none",
135        deserialize_with = "present_value"
136    )]
137    #[schemars(with = "RangeRuleCount")]
138    pub range_rule_count: Option<RangeRuleCount>,
139    /// What `key_uniqueness` divides the key's distinct values by.
140    ///
141    /// Absent means not recorded: through 0.12 it divided by every row,
142    /// nulls included.
143    #[serde(
144        default,
145        skip_serializing_if = "Option::is_none",
146        deserialize_with = "present_value"
147    )]
148    #[schemars(with = "KeyUniquenessUnits")]
149    pub key_uniqueness_units: Option<KeyUniquenessUnits>,
150    /// What `outlier_ratio` is measured over.
151    ///
152    /// Absent means not recorded: through 0.12 an untested source read 0 and
153    /// still assessed accuracy.
154    #[serde(
155        default,
156        skip_serializing_if = "Option::is_none",
157        deserialize_with = "present_value"
158    )]
159    #[schemars(with = "OutlierUnits")]
160    pub outlier_units: Option<OutlierUnits>,
161    /// Which column `key_uniqueness` may measure.
162    ///
163    /// Absent means not recorded: through 0.12 the first column with an
164    /// id-like name was the key when none was declared.
165    #[serde(
166        default,
167        skip_serializing_if = "Option::is_none",
168        deserialize_with = "present_value"
169    )]
170    #[schemars(with = "KeyColumnSource")]
171    pub key_column_source: Option<KeyColumnSource>,
172}
173
174/// Decode a field that may be absent (serde's `default` covers that) but,
175/// when present, must carry a value: `Option<T>` alone reads `null` as `None`.
176pub(crate) fn present_value<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
177where
178    D: serde::Deserializer<'de>,
179    T: serde::Deserialize<'de>,
180{
181    T::deserialize(deserializer).map(Some)
182}
183
184impl MetricSemantics {
185    /// The definitions this build measures with.
186    pub fn current() -> Self {
187        Self {
188            text_length_unit: Some(TextLengthUnit::UnicodeScalar),
189            null_tokens: Some(NullTokenSet::CommonMarkers),
190            pattern_match_count: Some(PatternMatchCount::Validated),
191            shape_moments: Some(ShapeMomentEstimator::AdjustedSample),
192            range_rule_count: Some(RangeRuleCount::ViolatingValues),
193            key_uniqueness_units: Some(KeyUniquenessUnits::NonNullValues),
194            outlier_units: Some(OutlierUnits::TestedValues),
195            key_column_source: Some(KeyColumnSource::Declared),
196        }
197    }
198
199    /// Whether every definition this build knows is recorded. A comparison
200    /// can only vouch for definitions both reports declare, so an unrecorded
201    /// one leaves it unknown. Destructured, so a new field fails to compile
202    /// here until it is counted.
203    pub(crate) fn is_fully_recorded(&self) -> bool {
204        let Self {
205            text_length_unit,
206            null_tokens,
207            pattern_match_count,
208            shape_moments,
209            range_rule_count,
210            key_uniqueness_units,
211            outlier_units,
212            key_column_source,
213        } = self;
214        text_length_unit.is_some()
215            && null_tokens.is_some()
216            && pattern_match_count.is_some()
217            && shape_moments.is_some()
218            && range_rule_count.is_some()
219            && key_uniqueness_units.is_some()
220            && outlier_units.is_some()
221            && key_column_source.is_some()
222    }
223}
224
225/// The locale a report's detected patterns were ranked with: `none`, or the
226/// ISO 3166-1 alpha-2 code of a [`Locale`] the pattern catalogue supports.
227#[derive(
228    Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
229)]
230#[non_exhaustive]
231pub enum PatternLocale {
232    /// No locale: patterns were ranked without a locale preference.
233    #[serde(rename = "none")]
234    Unset,
235    /// Canada.
236    #[serde(rename = "CA")]
237    Ca,
238    /// Germany.
239    #[serde(rename = "DE")]
240    De,
241    /// France.
242    #[serde(rename = "FR")]
243    Fr,
244    /// United Kingdom.
245    #[serde(rename = "GB")]
246    Gb,
247    /// Italy.
248    #[serde(rename = "IT")]
249    It,
250    /// United States.
251    #[serde(rename = "US")]
252    Us,
253}
254
255impl From<Option<Locale>> for PatternLocale {
256    fn from(locale: Option<Locale>) -> Self {
257        match locale {
258            None => Self::Unset,
259            Some(Locale::Ca) => Self::Ca,
260            Some(Locale::De) => Self::De,
261            Some(Locale::Fr) => Self::Fr,
262            Some(Locale::Gb) => Self::Gb,
263            Some(Locale::It) => Self::It,
264            Some(Locale::Us) => Self::Us,
265        }
266    }
267}
268
269impl PatternLocale {
270    /// The locale, or `None` when patterns were ranked without one.
271    pub fn locale(self) -> Option<Locale> {
272        match self {
273            Self::Unset => None,
274            Self::Ca => Some(Locale::Ca),
275            Self::De => Some(Locale::De),
276            Self::Fr => Some(Locale::Fr),
277            Self::Gb => Some(Locale::Gb),
278            Self::It => Some(Locale::It),
279            Self::Us => Some(Locale::Us),
280        }
281    }
282}
283
284/// Which of the source's columns a run profiled.
285///
286/// A projection that names a column the source lacks is refused before
287/// profiling, so a report with [`ColumnSelection::All`] that has no profile
288/// for a column shows the source has no such column, and one with
289/// [`ColumnSelection::Listed`] shows only that the caller did not select it.
290#[derive(
291    Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
292)]
293#[serde(tag = "selection", rename_all = "snake_case")]
294#[non_exhaustive]
295pub enum ColumnSelection {
296    /// Every column of the source.
297    All,
298    /// Only the named columns.
299    #[non_exhaustive]
300    Listed {
301        /// The names as the caller gave them.
302        names: Vec<String>,
303    },
304}
305
306impl ColumnSelection {
307    /// The selection `options` profile with.
308    pub fn from_options(options: &AnalysisOptions) -> Self {
309        match options.columns() {
310            None => Self::All,
311            Some(names) => Self::Listed {
312                names: names.to_vec(),
313            },
314        }
315    }
316
317    /// Whether the selection includes `column`, which a source that has the
318    /// column profiles.
319    pub fn includes(&self, column: &str) -> bool {
320        match self {
321            Self::All => true,
322            Self::Listed { names } => names.iter().any(|name| name == column),
323        }
324    }
325}
326
327/// The analysis settings a report was profiled with that change what its
328/// scores measure on unchanged data and are recorded nowhere else in it.
329///
330/// [`MetricSemantics`] records how a release defines its measurements; this
331/// records what the caller chose. The score weights live in the quality
332/// assessment and the semantic hints in the hint bindings, so they are not
333/// repeated here. Later releases add fields, which older readers ignore, and a
334/// field missing from a stored report means the release did not record it.
335#[derive(
336    Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
337)]
338#[non_exhaustive]
339pub struct AnalysisSettings {
340    /// The locale detected patterns were ranked with, which the validity
341    /// score reads.
342    pub pattern_locale: PatternLocale,
343    /// The columns the run profiled. A dataset-level score has the column
344    /// set as its subject, and a report without a column's profile needs this
345    /// to tell a column the source lacks from one that was not selected.
346    pub columns: ColumnSelection,
347}
348
349impl AnalysisSettings {
350    /// The settings `options` profile with.
351    pub fn from_options(options: &AnalysisOptions) -> Self {
352        Self {
353            pattern_locale: options.locale().into(),
354            columns: ColumnSelection::from_options(options),
355        }
356    }
357}
358
359/// The dataprof build that produced a report.
360///
361/// `version` alone cannot tell a development build from the release it
362/// follows: both carry the workspace version until the next release bumps it.
363/// The commit and the dirty flag tell them apart where the build knows them.
364///
365/// Absent `commit` and `dirty` mean the build did not record them: a crate
366/// built from a package rather than a git checkout, or a build without a
367/// usable `git`. An explicit null is malformed, so neither the reader nor the
368/// schema accepts one.
369#[derive(
370    Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
371)]
372#[non_exhaustive]
373pub struct ReportGenerator {
374    /// The dataprof package version, such as `"0.13.0"`.
375    pub version: String,
376    /// The full git commit hash the build was compiled from.
377    #[serde(
378        default,
379        skip_serializing_if = "Option::is_none",
380        deserialize_with = "present_value"
381    )]
382    #[schemars(with = "String")]
383    pub commit: Option<String>,
384    /// Whether the compiled sources (`Cargo.toml`, `Cargo.lock` and `crates/`)
385    /// had uncommitted changes when the build ran.
386    #[serde(
387        default,
388        skip_serializing_if = "Option::is_none",
389        deserialize_with = "present_value"
390    )]
391    #[schemars(with = "bool")]
392    pub dirty: Option<bool>,
393}
394
395/// The commit and dirty flag the embedding build recorded, if any.
396static BUILD_IDENTITY: OnceLock<(Option<&'static str>, Option<bool>)> = OnceLock::new();
397
398/// Record the build identity of the binary embedding this crate.
399///
400/// A build script here would rerun on every source change and rebuild every
401/// crate that depends on this one, so the leaf crate that owns the build
402/// (the Python extension) reads git and records the result once, at load.
403/// Only the first call takes effect; reports built before it, and in builds
404/// that never call it, record the version alone.
405#[doc(hidden)]
406pub fn record_build_identity(commit: Option<&'static str>, dirty: Option<bool>) {
407    let _ = BUILD_IDENTITY.set((commit, dirty));
408}
409
410impl ReportGenerator {
411    /// The build running now.
412    pub fn current() -> Self {
413        let (commit, dirty) = BUILD_IDENTITY.get().copied().unwrap_or((None, None));
414        Self {
415            version: env!("CARGO_PKG_VERSION").to_string(),
416            commit: commit.map(str::to_string),
417            dirty,
418        }
419    }
420}
421
422/// Complete profiling report for a data source.
423///
424/// Contains column-level statistics, execution metadata, and an optional
425/// Quality assessment informed by ISO 8000/25012 concepts. This is the primary output of all
426/// profiling operations (`Profiler::analyze_file`, `Profiler::analyze_source`,
427/// `Profiler::profile_stream`, etc.).
428#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
429pub struct ProfileReport {
430    /// Version of the serialized report schema (see [`REPORT_SCHEMA_VERSION`]).
431    ///
432    /// `0` means the document predates schema versioning (a 0.9-era report).
433    /// Deserialization rejects versions newer than [`REPORT_SCHEMA_VERSION`].
434    #[schemars(schema_with = "schema_version_schema")]
435    pub schema_version: u32,
436    /// Unique identifier for this report (UUID v4)
437    pub id: String,
438    /// Timestamp when the report was generated (ISO 8601 / RFC 3339)
439    pub timestamp: String,
440    /// Data source metadata (file, query, etc.)
441    pub data_source: DataSource,
442    /// Column-level profiling results
443    pub column_profiles: Vec<ColumnProfile>,
444    /// Execution metadata (timing, rows processed, truncation info, etc.)
445    pub execution: ExecutionMetadata,
446    /// Data quality assessment (optional — partial analysis may skip quality)
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub quality: Option<QualityAssessment>,
449    /// What happened to the quality computation. Always present: it is the
450    /// reason `quality` is or is not there, and a report must never leave a
451    /// failed computation to a log line. Additive field — documents written
452    /// before it deserialize as [`QualityAnalysisStatus::Unrecorded`], or as
453    /// `Computed` when they carry an assessment.
454    ///
455    /// This and `quality` are two halves of one fact:
456    /// [`Computed`](QualityAnalysisStatus::Computed) accompanies an assessment
457    /// and every other status accompanies its absence. Construction goes
458    /// through [`ReportAssembler`](crate::ReportAssembler), which derives one
459    /// from the other, and deserialization rejects a document that pairs them
460    /// any other way. Assigning either field directly can still break the
461    /// pair, and such a report serializes into a document this crate will
462    /// refuse to read back.
463    pub quality_status: QualityAnalysisStatus,
464    /// Per-column evidence of how each semantic hint bound to the data.
465    ///
466    /// Empty when no hints were supplied. Recorded for provenance: a hint proven
467    /// inert over the full data is rejected before a report is returned, so a
468    /// successful report only carries bindings that matched something or whose
469    /// evidence was sampled. Additive field — older readers ignore it.
470    #[serde(default, skip_serializing_if = "Vec::is_empty")]
471    pub semantic_hint_bindings: Vec<SemanticHintBinding>,
472    /// How this report's measurements were defined; see [`MetricSemantics`].
473    ///
474    /// Every report this build produces records [`MetricSemantics::current`].
475    /// `None` is a document written before dataprof recorded it, and its
476    /// definitions are unknown. Loading and saving keep the value the
477    /// document had rather than the reader's. Additive field.
478    #[serde(skip_serializing_if = "Option::is_none")]
479    #[schemars(with = "MetricSemantics")]
480    pub metric_semantics: Option<MetricSemantics>,
481    /// The analysis settings it was profiled with; see [`AnalysisSettings`].
482    ///
483    /// Every report a profiling run assembles records them. `None` is a
484    /// document written before dataprof recorded them, or a report built
485    /// directly with [`ProfileReport::new`]: the settings are unknown, not the
486    /// defaults. Additive field.
487    #[serde(skip_serializing_if = "Option::is_none")]
488    #[schemars(with = "AnalysisSettings")]
489    pub analysis_settings: Option<AnalysisSettings>,
490    /// The dataprof build that produced the report; see [`ReportGenerator`].
491    ///
492    /// Every report this build produces records [`ReportGenerator::current`].
493    /// `None` is a document written before dataprof recorded it. Loading and
494    /// saving keep the value the document had rather than the reader's.
495    /// Additive field.
496    #[serde(skip_serializing_if = "Option::is_none")]
497    #[schemars(with = "ReportGenerator")]
498    pub generator: Option<ReportGenerator>,
499}
500
501fn schema_version_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
502    schemars::json_schema!({
503        "type": "integer",
504        "const": REPORT_SCHEMA_VERSION,
505        "minimum": 0
506    })
507}
508
509/// The canonical persisted report and the legacy Python compatibility summary.
510///
511/// New JSON saves in both languages use `ProfileReport`. Python's `to_dict()`
512/// remains a convenience projection, produced by `PythonProfileReportDocument`
513/// below. Existing flat documents cannot acquire the source/quality provenance
514/// they never recorded, so the v1 union remains an intentional compatibility
515/// surface for loading and resaving those documents.
516#[allow(dead_code)]
517#[derive(serde::Serialize, schemars::JsonSchema)]
518#[serde(untagged)]
519#[schemars(title = "ProfileReport")]
520enum SerializedProfileReport {
521    Rust(Box<ProfileReport>),
522    Python(Box<PythonProfileReportDocument>),
523}
524
525#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
526struct PythonProfileReportDocument {
527    #[schemars(schema_with = "schema_version_schema")]
528    schema_version: u32,
529    source: String,
530    source_type: PythonSourceType,
531    execution: PythonExecutionDocument,
532    columns: Vec<PythonColumnDocument>,
533    quality: Option<PythonQualityDocument>,
534    quality_status: QualityAnalysisStatus,
535    #[serde(skip_serializing_if = "Vec::is_empty")]
536    semantic_hint_bindings: Vec<SemanticHintBinding>,
537    #[serde(skip_serializing_if = "Option::is_none")]
538    #[schemars(with = "MetricSemantics")]
539    metric_semantics: Option<MetricSemantics>,
540    #[serde(skip_serializing_if = "Option::is_none")]
541    #[schemars(with = "AnalysisSettings")]
542    analysis_settings: Option<AnalysisSettings>,
543    #[serde(skip_serializing_if = "Option::is_none")]
544    #[schemars(with = "ReportGenerator")]
545    generator: Option<ReportGenerator>,
546}
547
548#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
549#[serde(rename_all = "lowercase")]
550enum PythonSourceType {
551    File,
552    Query,
553    Dataframe,
554    Stream,
555    Bytes,
556}
557
558#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
559struct PythonExecutionDocument {
560    engine: Option<String>,
561    #[serde(skip_serializing_if = "Option::is_none")]
562    recovery_events: Option<Vec<dataprof_core::execution::RecoveryEvent>>,
563    rows_processed: usize,
564    columns_detected: usize,
565    scan_time_ms: u128,
566    source_exhausted: bool,
567    truncation_reason: Option<String>,
568    bytes_consumed: Option<u64>,
569    #[serde(serialize_with = "dataprof_core::serde_helpers::round_4_opt")]
570    throughput_rows_sec: Option<f64>,
571    #[serde(serialize_with = "dataprof_core::serde_helpers::round_2_opt")]
572    memory_peak_mb: Option<f64>,
573    error_count: usize,
574    ragged_row_count: usize,
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    unterminated_quote: Option<bool>,
577    sampling_applied: bool,
578    #[serde(serialize_with = "dataprof_core::serde_helpers::round_4_opt")]
579    sampling_ratio: Option<f64>,
580    #[serde(skip_serializing_if = "Option::is_none")]
581    sampled_row_ranges: Option<Vec<[u64; 2]>>,
582}
583
584#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
585struct PythonColumnDocument {
586    name: String,
587    data_type: PythonDataType,
588    total_count: usize,
589    null_count: usize,
590    #[serde(serialize_with = "dataprof_core::serde_helpers::round_2_opt")]
591    null_percentage: Option<f64>,
592    unique_count: Option<usize>,
593    unique_count_is_approximate: Option<bool>,
594    #[serde(serialize_with = "dataprof_core::serde_helpers::round_4_opt")]
595    uniqueness_ratio: Option<f64>,
596    #[serde(skip_serializing_if = "Option::is_none")]
597    invalid_count: Option<usize>,
598    /// Counts per lexical class. The Python dialect writes them as a plain
599    /// mapping, but it is the same four-key object the Rust dialect writes, so
600    /// it is described by the same definition: a reader that accepted a partial
601    /// mapping here would validate a document the Python loader then discards
602    /// as incomplete rather than inventing the missing counts.
603    #[serde(skip_serializing_if = "Option::is_none")]
604    type_homogeneity: Option<dataprof_core::TypeHomogeneity>,
605    #[serde(skip_serializing_if = "Option::is_none")]
606    locale_number_count: Option<usize>,
607    #[serde(skip_serializing_if = "Option::is_none")]
608    stats: Option<PythonColumnStatsDocument>,
609    #[serde(skip_serializing_if = "Option::is_none")]
610    patterns: Option<Vec<PythonPatternDocument>>,
611}
612
613#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
614#[serde(rename_all = "lowercase")]
615enum PythonDataType {
616    String,
617    Identifier,
618    Integer,
619    Float,
620    Date,
621    Boolean,
622    Nested,
623}
624
625#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
626struct PythonColumnStatsDocument {
627    #[serde(skip_serializing_if = "Option::is_none")]
628    min: Option<f64>,
629    #[serde(skip_serializing_if = "Option::is_none")]
630    max: Option<f64>,
631    #[serde(skip_serializing_if = "Option::is_none")]
632    mean: Option<f64>,
633    // Outer None means this statistic does not apply to the column. An inner
634    // None is a numeric spread that overflowed: keep its explicit JSON null.
635    #[serde(
636        default,
637        deserialize_with = "deserialize_present_nullable_stat",
638        skip_serializing_if = "Option::is_none"
639    )]
640    #[schemars(with = "Option<f64>")]
641    std_dev: Option<Option<f64>>,
642    #[serde(
643        default,
644        deserialize_with = "deserialize_present_nullable_stat",
645        skip_serializing_if = "Option::is_none"
646    )]
647    #[schemars(with = "Option<f64>")]
648    variance: Option<Option<f64>>,
649    #[serde(skip_serializing_if = "Option::is_none")]
650    median: Option<f64>,
651    #[serde(skip_serializing_if = "Option::is_none")]
652    mode: Option<f64>,
653    #[serde(skip_serializing_if = "Option::is_none")]
654    skewness: Option<f64>,
655    #[serde(skip_serializing_if = "Option::is_none")]
656    kurtosis: Option<f64>,
657    #[serde(skip_serializing_if = "Option::is_none")]
658    coefficient_of_variation: Option<f64>,
659    #[serde(skip_serializing_if = "Option::is_none")]
660    quartiles: Option<dataprof_core::Quartiles>,
661    #[serde(skip_serializing_if = "Option::is_none")]
662    is_approximate: Option<bool>,
663    #[serde(skip_serializing_if = "Option::is_none")]
664    outlier_count: Option<usize>,
665    /// Shortest value, in Unicode scalar values. Not UTF-8 bytes and not
666    /// grapheme clusters: ASCII text is unaffected by that distinction, while a
667    /// combining sequence counts each scalar, so the decomposed and precomposed
668    /// spellings of the same word report different lengths.
669    #[serde(skip_serializing_if = "Option::is_none")]
670    min_length: Option<usize>,
671    /// Longest value, in Unicode scalar values. See `min_length`.
672    #[serde(skip_serializing_if = "Option::is_none")]
673    max_length: Option<usize>,
674    /// Mean length, in Unicode scalar values. See `min_length`.
675    #[serde(skip_serializing_if = "Option::is_none")]
676    avg_length: Option<f64>,
677    #[serde(skip_serializing_if = "Option::is_none")]
678    true_count: Option<usize>,
679    #[serde(skip_serializing_if = "Option::is_none")]
680    false_count: Option<usize>,
681    #[serde(skip_serializing_if = "Option::is_none")]
682    true_ratio: Option<f64>,
683}
684
685fn deserialize_present_nullable_stat<'de, D>(
686    deserializer: D,
687) -> Result<Option<Option<f64>>, D::Error>
688where
689    D: serde::Deserializer<'de>,
690{
691    serde::Deserialize::deserialize(deserializer).map(Some)
692}
693
694#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
695struct PythonPatternDocument {
696    name: String,
697    regex: String,
698    match_count: usize,
699    #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
700    match_percentage: f64,
701    category: dataprof_core::PatternCategory,
702    #[serde(serialize_with = "dataprof_core::serde_helpers::round_4")]
703    confidence: f64,
704}
705
706#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
707struct PythonQualityDocument {
708    /// Null when no dimension was assessable; `assessed_dimensions` is then
709    /// empty and every entry in `dimension_scores` is null.
710    overall_score: Option<f64>,
711    assessed_dimensions: Vec<PythonQualityDimension>,
712    dimension_scores: std::collections::BTreeMap<PythonQualityDimension, Option<f64>>,
713    low_sample_warning: bool,
714    /// Metric components computed from a retained quality sample rather than
715    /// from every scanned row. Additive field: a document written before it
716    /// existed omits it, and its absence is preserved on reload because
717    /// unknown coverage is not the same statement as "nothing was sampled".
718    /// Uniqueness appears here as `key_uniqueness` and `duplicate_rows`.
719    #[serde(skip_serializing_if = "Option::is_none")]
720    sampled_dimensions: Option<Vec<String>>,
721    /// Where the scores over every scanned row lie, when some were computed over a
722    /// retained sample. Additive field, absent when every score is exact and
723    /// on documents written before it existed.
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    score_bounds: Option<dataprof_metrics::ScoreBounds>,
726    /// Weights behind `overall_score`, always written as the canonical
727    /// document writes them, default weights included (#749). Summaries
728    /// written before 0.13 omitted default weights, and before the field
729    /// existed dropped custom ones too, so its absence there does not show
730    /// which weights applied.
731    #[serde(default)]
732    score_weights: QualityScoreWeights,
733    #[serde(skip_serializing_if = "Option::is_none")]
734    completeness: Option<CompletenessMetrics>,
735    #[serde(skip_serializing_if = "Option::is_none")]
736    consistency: Option<ConsistencyMetrics>,
737    #[serde(skip_serializing_if = "Option::is_none")]
738    uniqueness: Option<PythonUniquenessDocument>,
739    #[serde(skip_serializing_if = "Option::is_none")]
740    accuracy: Option<AccuracyMetrics>,
741    #[serde(skip_serializing_if = "Option::is_none")]
742    timeliness: Option<TimelinessMetrics>,
743    #[serde(skip_serializing_if = "Option::is_none")]
744    validity: Option<ValidityMetrics>,
745    #[serde(skip_serializing_if = "Option::is_none")]
746    precision: Option<PrecisionMetrics>,
747}
748
749#[derive(
750    Debug,
751    Clone,
752    PartialEq,
753    Eq,
754    PartialOrd,
755    Ord,
756    Hash,
757    serde::Serialize,
758    serde::Deserialize,
759    schemars::JsonSchema,
760)]
761#[serde(rename_all = "lowercase")]
762enum PythonQualityDimension {
763    Completeness,
764    Consistency,
765    Uniqueness,
766    Accuracy,
767    Timeliness,
768    Validity,
769    Precision,
770}
771
772#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
773struct PythonUniquenessDocument {
774    duplicate_rows: usize,
775    key_uniqueness: f64,
776    high_cardinality_warning: bool,
777    rows_checked: usize,
778    key_column: Option<String>,
779    #[serde(default, skip_serializing_if = "Option::is_none")]
780    key_values_checked: Option<usize>,
781    duplicate_rows_approximate: bool,
782}
783
784impl PythonProfileReportDocument {
785    fn from_report(report: &ProfileReport) -> serde_json::Result<Self> {
786        use dataprof_core::{ColumnStats, DataType, TruncationReason};
787
788        // Reuse the serialized metric blocks: their Serde implementations own
789        // rounding and omission, rather than repeating those rules in Python.
790        let columns = report
791            .column_profiles
792            .iter()
793            .map(|column| {
794                let stats = match &column.stats {
795                    ColumnStats::Numeric(stats) => Some(serde_json::to_value(stats)?),
796                    ColumnStats::Text(stats) if column.total_count > 0 => {
797                        Some(serde_json::to_value(stats)?)
798                    }
799                    ColumnStats::Boolean(stats) => Some(serde_json::to_value(stats)?),
800                    _ => None,
801                }
802                .map(serde_json::from_value)
803                .transpose()?;
804                Ok(PythonColumnDocument {
805                    name: column.name.clone(),
806                    data_type: match column.data_type {
807                        DataType::String => PythonDataType::String,
808                        DataType::Identifier => PythonDataType::Identifier,
809                        DataType::Integer => PythonDataType::Integer,
810                        DataType::Float => PythonDataType::Float,
811                        DataType::Date => PythonDataType::Date,
812                        DataType::Boolean => PythonDataType::Boolean,
813                        DataType::Nested => PythonDataType::Nested,
814                    },
815                    total_count: column.total_count,
816                    null_count: column.null_count,
817                    null_percentage: (column.total_count > 0)
818                        .then(|| column.null_count as f64 / column.total_count as f64 * 100.0),
819                    unique_count: column.unique_count,
820                    unique_count_is_approximate: column.unique_count_is_approximate,
821                    uniqueness_ratio: column
822                        .unique_count
823                        .filter(|_| column.total_count > 0)
824                        .map(|count| count as f64 / column.total_count as f64),
825                    invalid_count: column.invalid_count,
826                    type_homogeneity: column.type_homogeneity,
827                    locale_number_count: column.locale_number_count,
828                    stats,
829                    patterns: column.patterns.as_ref().map(|patterns| {
830                        patterns
831                            .iter()
832                            .map(|pattern| PythonPatternDocument {
833                                name: pattern.name.clone(),
834                                regex: pattern.regex.clone(),
835                                match_count: pattern.match_count,
836                                match_percentage: pattern.match_percentage,
837                                category: pattern.category.clone(),
838                                confidence: pattern.confidence,
839                            })
840                            .collect()
841                    }),
842                })
843            })
844            .collect::<serde_json::Result<Vec<_>>>()?;
845
846        let mut execution = serde_json::to_value(&report.execution)?;
847        execution["truncation_reason"] =
848            serde_json::to_value(report.execution.truncation_reason.as_ref().map(|reason| {
849                match reason {
850                    TruncationReason::MaxRows(n) => format!("max_rows({n})"),
851                    TruncationReason::MaxBytes(n) => format!("max_bytes({n})"),
852                    TruncationReason::MemoryPressure => "memory_pressure".to_string(),
853                    TruncationReason::StopCondition(s) => format!("stop_condition({s})"),
854                    TruncationReason::StreamClosed => "stream_closed".to_string(),
855                    TruncationReason::Timeout => "timeout".to_string(),
856                }
857            }))?;
858
859        let quality = report
860            .quality
861            .as_ref()
862            .map(|assessment| {
863                let metrics = &assessment.metrics;
864                let mut document = serde_json::to_value(metrics)?;
865                let scores = serde_json::to_value(assessment.scores())?;
866                document["overall_score"] = scores["overall_score"].clone();
867                // A document from a later v1 release may retain scores for a
868                // dimension this build does not know. The canonical document
869                // keeps them; the summary lists only the dimensions it types.
870                let known: Vec<String> = dataprof_core::QualityDimension::all()
871                    .into_iter()
872                    .map(|dimension| dimension.to_string())
873                    .collect();
874                document["dimension_scores"] = scores["dimension_scores"]
875                    .as_object()
876                    .expect("dimension scores serialize as an object")
877                    .iter()
878                    .filter(|(name, _)| known.contains(name))
879                    .map(|(name, score)| (name.clone(), score.clone()))
880                    .collect();
881                document["assessed_dimensions"] =
882                    serde_json::to_value(metrics.assessed_dimensions())?;
883                document["low_sample_warning"] =
884                    serde_json::Value::Bool(metrics.low_sample_warning);
885                if let Some(sampled) = assessment.sampled_dimensions() {
886                    document["sampled_dimensions"] = serde_json::to_value(sampled)?;
887                }
888                if let Some(bounds) = assessment.score_bounds() {
889                    document["score_bounds"] = serde_json::to_value(bounds)?;
890                }
891                // The summary has always included this boolean, even when false.
892                if let Some(uniqueness) = document.get_mut("uniqueness") {
893                    uniqueness["duplicate_rows_approximate"] = serde_json::Value::Bool(
894                        metrics
895                            .uniqueness
896                            .as_ref()
897                            .expect("serialized uniqueness is present")
898                            .duplicate_rows_approximate,
899                    );
900                }
901                serde_json::from_value(document)
902            })
903            .transpose()?;
904
905        Ok(Self {
906            schema_version: report.schema_version,
907            source: report.data_source.identifier(),
908            source_type: match report.data_source {
909                DataSource::File { .. } => PythonSourceType::File,
910                DataSource::Query { .. } => PythonSourceType::Query,
911                DataSource::DataFrame { .. } => PythonSourceType::Dataframe,
912                DataSource::Stream { .. } => PythonSourceType::Stream,
913                DataSource::Bytes { .. } => PythonSourceType::Bytes,
914            },
915            execution: serde_json::from_value(execution)?,
916            columns,
917            quality,
918            quality_status: report.quality_status.clone(),
919            semantic_hint_bindings: report.semantic_hint_bindings.clone(),
920            metric_semantics: report.metric_semantics.clone(),
921            analysis_settings: report.analysis_settings.clone(),
922            generator: report.generator.clone(),
923        })
924    }
925}
926
927/// Generate the JSON Schema 2020-12 document for the current serialized report.
928///
929/// This is primarily used by the repository's schema generator and drift tests.
930/// The committed, versioned document under `docs/schema/` is the public
931/// interoperability contract.
932#[doc(hidden)]
933pub fn profile_report_schema_document() -> serde_json::Value {
934    let settings = schemars::generate::SchemaSettings::draft2020_12().for_serialize();
935    let schema = settings
936        .into_generator()
937        .into_root_schema_for::<SerializedProfileReport>();
938    let mut document =
939        serde_json::to_value(schema).expect("a Schemars schema must serialize to a JSON document");
940    let object = document
941        .as_object_mut()
942        .expect("a root Schemars schema must be a JSON object");
943    object.insert(
944        "$id".to_string(),
945        serde_json::Value::String(format!(
946            "https://andreabozzo.github.io/dataprof/schema/profile-report.v{REPORT_SCHEMA_VERSION}.schema.json"
947        )),
948    );
949    make_compatibility_defaults_optional(&mut document);
950    allow_additive_properties(&mut document);
951    canonicalize_key_order(&mut document);
952    document
953}
954
955/// Sort every object's keys so the committed schema has one canonical byte
956/// layout.
957///
958/// Key order carries no meaning in JSON Schema, but the committed document is a
959/// reviewed artifact: it must not churn because `serde_json` switched its map
960/// backing (`preserve_order`) or because Schemars changed the order in which it
961/// builds a schema. Arrays such as `required` and `oneOf` keep their order,
962/// which *is* meaningful.
963fn canonicalize_key_order(value: &mut serde_json::Value) {
964    match value {
965        serde_json::Value::Object(object) => {
966            for child in object.values_mut() {
967                canonicalize_key_order(child);
968            }
969            let mut sorted: Vec<(String, serde_json::Value)> =
970                std::mem::take(object).into_iter().collect();
971            sorted.sort_by(|(left, _), (right, _)| left.cmp(right));
972            object.extend(sorted);
973        }
974        serde_json::Value::Array(items) => {
975            for item in items {
976                canonicalize_key_order(item);
977            }
978        }
979        _ => {}
980    }
981}
982
983/// Drop fields that this build always writes but a reader supplies a default
984/// for, from the schema's `required` lists.
985///
986/// A document written under an earlier v1 build does not carry them. Declaring
987/// them required would fail validation for documents the readers accept, which
988/// is the opposite of what an additive widening means.
989fn make_compatibility_defaults_optional(document: &mut serde_json::Value) {
990    for (pointer, field) in [
991        ("/$defs/ExecutionMetadata/required", "ragged_row_count"),
992        ("/$defs/ProfileReport/required", "quality_status"),
993        (
994            "/$defs/PythonProfileReportDocument/required",
995            "quality_status",
996        ),
997        (
998            "/$defs/PythonQualityDocument/required",
999            "sampled_dimensions",
1000        ),
1001        ("/$defs/QualityAssessment/required", "scores"),
1002        // Written always since 0.13 (#749), omitted at the defaults before.
1003        ("/$defs/QualityMetrics/required", "score_weights"),
1004        ("/$defs/PythonQualityDocument/required", "score_weights"),
1005        // Written only when some score was sampled.
1006        ("/$defs/QualityAssessment/required", "score_bounds"),
1007        // Written always since 0.13 (#1005), absent before.
1008        ("/$defs/AccuracyMetrics/required", "rule_values_checked"),
1009        ("/$defs/AccuracyMetrics/required", "rule_violating_values"),
1010    ] {
1011        if let Some(required) = document
1012            .pointer_mut(pointer)
1013            .and_then(serde_json::Value::as_array_mut)
1014        {
1015            required.retain(|declared| declared.as_str() != Some(field));
1016        }
1017    }
1018    // A definition left with nothing required carries no `required` list.
1019    if let Some(definitions) = document
1020        .pointer_mut("/$defs")
1021        .and_then(serde_json::Value::as_object_mut)
1022    {
1023        for definition in definitions.values_mut() {
1024            if let Some(object) = definition.as_object_mut()
1025                && object
1026                    .get("required")
1027                    .and_then(serde_json::Value::as_array)
1028                    .is_some_and(Vec::is_empty)
1029            {
1030                object.remove("required");
1031            }
1032        }
1033    }
1034}
1035
1036fn allow_additive_properties(value: &mut serde_json::Value) {
1037    match value {
1038        serde_json::Value::Object(object) => {
1039            if object.get("additionalProperties") == Some(&serde_json::Value::Bool(false)) {
1040                object.remove("additionalProperties");
1041            }
1042            for child in object.values_mut() {
1043                allow_additive_properties(child);
1044            }
1045        }
1046        serde_json::Value::Array(items) => {
1047            for item in items {
1048                allow_additive_properties(item);
1049            }
1050        }
1051        _ => {}
1052    }
1053}
1054
1055impl ProfileReport {
1056    /// Serialize the canonical persisted document with deterministic object-key
1057    /// order. Array order (columns, patterns, recovery events) is preserved.
1058    pub fn to_json(&self) -> serde_json::Result<String> {
1059        let mut document = serde_json::to_value(self)?;
1060        canonicalize_key_order(&mut document);
1061        serde_json::to_string_pretty(&document)
1062    }
1063
1064    /// Serialize Python's historical summary projection. It deliberately omits
1065    /// some runtime metadata; use [`Self::to_json`] for lossless persistence.
1066    #[doc(hidden)]
1067    pub fn summary_json(&self) -> serde_json::Result<String> {
1068        let summary = PythonProfileReportDocument::from_report(self)?;
1069        serde_json::to_string(&summary)
1070    }
1071
1072    /// Create a new ProfileReport with auto-generated id and timestamp
1073    pub fn new(
1074        data_source: DataSource,
1075        column_profiles: Vec<ColumnProfile>,
1076        execution: ExecutionMetadata,
1077        quality: Option<QualityAssessment>,
1078    ) -> Self {
1079        Self {
1080            schema_version: REPORT_SCHEMA_VERSION,
1081            id: uuid::Uuid::new_v4().to_string(),
1082            timestamp: chrono::Utc::now().to_rfc3339(),
1083            data_source,
1084            column_profiles,
1085            execution,
1086            quality_status: match quality {
1087                Some(_) => QualityAnalysisStatus::Computed,
1088                // A caller constructing a report directly did not compute
1089                // quality; `ReportAssembler` overrides this with the reason it
1090                // actually observed.
1091                None => QualityAnalysisStatus::NotRequested,
1092            },
1093            quality,
1094            semantic_hint_bindings: Vec::new(),
1095            metric_semantics: Some(MetricSemantics::current()),
1096            // Only the assembler knows the options a report was profiled
1097            // with; it records them through `with_analysis_settings`.
1098            analysis_settings: None,
1099            generator: Some(ReportGenerator::current()),
1100        }
1101    }
1102
1103    /// Record the analysis settings the report was profiled with.
1104    pub(crate) fn with_analysis_settings(mut self, settings: Option<AnalysisSettings>) -> Self {
1105        self.analysis_settings = settings;
1106        self
1107    }
1108
1109    /// Record what happened to the quality computation.
1110    ///
1111    /// Deliberately not public. `quality_status` and `quality` are two halves
1112    /// of one fact, and [`check_quality_pairing`](Self::check_quality_pairing)
1113    /// refuses to decode a document that pairs them wrongly — so a public
1114    /// setter would let a caller build a report that serializes and then
1115    /// cannot be read back by this same crate. Every path assigns the status
1116    /// through [`ReportAssembler`](crate::ReportAssembler), which derives it
1117    /// from the computation that produced the assessment.
1118    pub(crate) fn with_quality_status(mut self, status: QualityAnalysisStatus) -> Self {
1119        self.quality_status = status;
1120        self
1121    }
1122
1123    /// Attach per-column semantic-hint binding evidence.
1124    pub fn with_semantic_hint_bindings(mut self, bindings: Vec<SemanticHintBinding>) -> Self {
1125        self.semantic_hint_bindings = bindings;
1126        self
1127    }
1128
1129    /// Override the auto-generated ID (useful for deterministic caching/testing)
1130    pub fn with_id(mut self, id: impl Into<String>) -> Self {
1131        self.id = id.into();
1132        self
1133    }
1134
1135    /// Override the auto-generated timestamp
1136    pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
1137        self.timestamp = timestamp.into();
1138        self
1139    }
1140
1141    /// Calculate the overall quality score (weighted across the assessed
1142    /// dimensions). Returns `None` if quality metrics were not computed, or
1143    /// if no dimension had anything to assess (e.g. an empty dataset) —
1144    /// absence of evidence is neither a perfect score nor a zero.
1145    pub fn quality_score(&self) -> Option<f64> {
1146        self.quality.as_ref().and_then(|q| q.score())
1147    }
1148
1149    /// Get the data source identifier (for backwards compatibility)
1150    pub fn source_identifier(&self) -> String {
1151        self.data_source.identifier()
1152    }
1153
1154    /// Reject a document whose `quality_status` contradicts its `quality`.
1155    ///
1156    /// `Computed` and an assessment imply each other: no writer can produce one
1157    /// without the other, because [`ReportAssembler`](crate::ReportAssembler)
1158    /// derives the status from the computation that produced the assessment. A
1159    /// document that pairs them any other way is internally inconsistent, and
1160    /// loading it would hand a consumer a report that states a verdict its own
1161    /// contents contradict — a gate could act on an assessment the report says
1162    /// failed. Failing the decode is the answer the rest of this crate gives to
1163    /// a malformed document, rather than repairing it into something plausible.
1164    fn check_quality_pairing(self) -> Result<Self, String> {
1165        let assessed = self.quality.is_some();
1166        let claims_computed = matches!(self.quality_status, QualityAnalysisStatus::Computed);
1167        if claims_computed == assessed {
1168            return Ok(self);
1169        }
1170        Err(if assessed {
1171            format!(
1172                "report carries a quality assessment but reports quality_status \
1173                 {:?}; only `computed` may accompany an assessment",
1174                self.quality_status
1175            )
1176        } else {
1177            "report reports quality_status `computed` but carries no quality assessment".to_string()
1178        })
1179    }
1180}
1181
1182/// Mirror of [`ProfileReport`] carrying the field-level deserialization
1183/// rules (legacy aliases, quality compat). Kept private: the public entry
1184/// point is the manual [`serde::Deserialize`] impl below, which gates on
1185/// `schema_version` before any of these fields decode.
1186#[derive(serde::Deserialize)]
1187struct ProfileReportFields {
1188    #[serde(default)]
1189    schema_version: u32,
1190    id: String,
1191    timestamp: String,
1192    data_source: DataSource,
1193    column_profiles: Vec<ColumnProfile>,
1194    #[serde(alias = "scan_info")]
1195    execution: ExecutionMetadata,
1196    #[serde(
1197        alias = "data_quality_metrics",
1198        default,
1199        deserialize_with = "deserialize_quality_compat"
1200    )]
1201    quality: Option<QualityAssessment>,
1202    #[serde(default)]
1203    quality_status: Option<QualityAnalysisStatus>,
1204    #[serde(default)]
1205    semantic_hint_bindings: Vec<SemanticHintBinding>,
1206    #[serde(default)]
1207    metric_semantics: Option<MetricSemantics>,
1208    #[serde(default)]
1209    analysis_settings: Option<AnalysisSettings>,
1210    #[serde(default)]
1211    generator: Option<ReportGenerator>,
1212}
1213
1214impl From<ProfileReportFields> for ProfileReport {
1215    fn from(fields: ProfileReportFields) -> Self {
1216        Self {
1217            schema_version: fields.schema_version,
1218            id: fields.id,
1219            timestamp: fields.timestamp,
1220            data_source: fields.data_source,
1221            column_profiles: fields.column_profiles,
1222            execution: fields.execution,
1223            // A document written before the field: a stored assessment proves
1224            // the computation ran, and nothing else about it is knowable.
1225            quality_status: fields.quality_status.unwrap_or({
1226                if fields.quality.is_some() {
1227                    QualityAnalysisStatus::Computed
1228                } else {
1229                    QualityAnalysisStatus::Unrecorded
1230                }
1231            }),
1232            quality: fields.quality,
1233            semantic_hint_bindings: fields.semantic_hint_bindings,
1234            metric_semantics: fields.metric_semantics,
1235            analysis_settings: fields.analysis_settings,
1236            generator: fields.generator,
1237        }
1238    }
1239}
1240
1241/// Manual deserialization so the `schema_version` gate runs before any other
1242/// field decodes. A derived deserializer visits fields in document order, so
1243/// an unsupported future document that also changed structure could fail with
1244/// a confusing structural error instead of the actionable version error; by
1245/// buffering the document first, the version check always wins.
1246impl<'de> serde::Deserialize<'de> for ProfileReport {
1247    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1248    where
1249        D: serde::Deserializer<'de>,
1250    {
1251        use serde::de::Error;
1252
1253        let value = serde_json::Value::deserialize(deserializer)?;
1254        match value.get("schema_version") {
1255            // Absent field: legacy pre-0.10 document, defaults to version 0.
1256            None => {}
1257            Some(serde_json::Value::Number(n)) if n.as_u64().is_some() => {
1258                let version = n.as_u64().unwrap_or_default();
1259                if version > u64::from(REPORT_SCHEMA_VERSION) {
1260                    return Err(D::Error::custom(format!(
1261                        "report schema version {version} is newer than the latest supported \
1262                         version {REPORT_SCHEMA_VERSION}; upgrade dataprof to read this report"
1263                    )));
1264                }
1265            }
1266            // An explicit null or non-integer is malformed, not legacy.
1267            Some(other) => {
1268                return Err(D::Error::custom(format!(
1269                    "report schema_version must be a non-negative integer, got {other}"
1270                )));
1271            }
1272        }
1273        // `quality_status` deserializes through `Option`, which cannot tell an
1274        // absent field from an explicit null. Only the absent one is legacy;
1275        // a null is a malformed current document, and the fallback would
1276        // silently repair it into a plausible reason it never recorded.
1277        if value.get("quality_status") == Some(&serde_json::Value::Null) {
1278            return Err(D::Error::custom(
1279                "report quality_status must be an object; an explicit null is malformed, \
1280                 not a document written before the field existed",
1281            ));
1282        }
1283        // Same rule: absent is "not recorded", which reads back as unknown;
1284        // a null would be a current writer's malformed output.
1285        for key in ["metric_semantics", "analysis_settings", "generator"] {
1286            if value.get(key) == Some(&serde_json::Value::Null) {
1287                return Err(D::Error::custom(format!(
1288                    "report {key} must be an object; an explicit null is malformed, \
1289                     not a document written before the field existed"
1290                )));
1291            }
1292        }
1293        let report = ProfileReportFields::deserialize(value)
1294            .map(ProfileReport::from)
1295            .map_err(D::Error::custom)?;
1296        report.check_quality_pairing().map_err(D::Error::custom)
1297    }
1298}
1299
1300/// Custom deserializer that handles both legacy `DataQualityMetrics` (flat)
1301/// and new `QualityAssessment` (wrapped with confidence) JSON formats.
1302fn deserialize_quality_compat<'de, D>(
1303    deserializer: D,
1304) -> Result<Option<QualityAssessment>, D::Error>
1305where
1306    D: serde::Deserializer<'de>,
1307{
1308    use serde::Deserialize;
1309
1310    let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
1311    match value {
1312        None => Ok(None),
1313        Some(mut v) => {
1314            // Either wrapper key marks the canonical shape. Read as flat
1315            // metrics, a canonical object matches no field and decodes as an
1316            // empty assessment, dropping every metric without an error.
1317            if v.get("metrics").is_some() || v.get("confidence").is_some() {
1318                if let Some(object) = v.as_object_mut() {
1319                    // Metrics whose provenance was lost are kept, and the
1320                    // provenance stays unknown, as for a flat legacy object.
1321                    object
1322                        .entry("confidence")
1323                        .or_insert_with(|| serde_json::json!("Unrecorded"));
1324                }
1325                let assessment: QualityAssessment =
1326                    serde_json::from_value(v).map_err(serde::de::Error::custom)?;
1327                Ok(Some(assessment))
1328            } else {
1329                let metrics: QualityMetrics =
1330                    serde_json::from_value(v).map_err(serde::de::Error::custom)?;
1331                // A flat legacy quality object says nothing about how its
1332                // numbers were obtained. Recording `Exact` here would invent
1333                // that, and a gate asked about the whole source would then
1334                // rest a verdict on numbers that may have come from a sample.
1335                Ok(Some(QualityAssessment::new(
1336                    metrics,
1337                    MetricConfidence::Unrecorded,
1338                )))
1339            }
1340        }
1341    }
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346    use super::*;
1347    use dataprof_core::FileFormat;
1348    use dataprof_metrics::MetricConfidence;
1349    use serde_json::json;
1350
1351    fn report_without_quality() -> ProfileReport {
1352        ProfileReport::new(
1353            DataSource::File {
1354                path: "test.csv".to_string(),
1355                format: FileFormat::Csv,
1356                size_bytes: 1024,
1357                modified_at: None,
1358                parquet_metadata: None,
1359            },
1360            vec![],
1361            ExecutionMetadata::new(100, 5, 50),
1362            None,
1363        )
1364    }
1365
1366    /// The `state` strings are the wire vocabulary: consumers branch on them,
1367    /// the Python binding repeats them by hand, and the committed schema
1368    /// enumerates them. Pin them here so a variant rename has to be deliberate.
1369    #[test]
1370    fn every_status_has_a_stable_wire_name() {
1371        let cases = [
1372            (QualityAnalysisStatus::Computed, "computed"),
1373            (QualityAnalysisStatus::NotRequested, "not_requested"),
1374            (QualityAnalysisStatus::NoData, "no_data"),
1375            (
1376                QualityAnalysisStatus::WithheldByProjection,
1377                "withheld_by_projection",
1378            ),
1379            (
1380                QualityAnalysisStatus::Failed {
1381                    error: "boom".to_string(),
1382                },
1383                "failed",
1384            ),
1385            (QualityAnalysisStatus::Unrecorded, "unrecorded"),
1386        ];
1387
1388        for (status, name) in cases {
1389            let value = serde_json::to_value(&status).unwrap();
1390            assert_eq!(value.get("state"), Some(&json!(name)), "{status:?}");
1391            let restored: QualityAnalysisStatus = serde_json::from_value(value).unwrap();
1392            assert_eq!(restored, status);
1393        }
1394    }
1395
1396    /// A document may not claim one thing in `quality_status` and another in
1397    /// `quality`. Loading one would let a consumer act on an assessment the
1398    /// report says never completed.
1399    #[test]
1400    fn contradictory_status_and_assessment_fail_to_decode() {
1401        let with_assessment =
1402            report_without_quality().with_quality_status(QualityAnalysisStatus::Failed {
1403                error: "boom".to_string(),
1404            });
1405        let mut document = serde_json::to_value(&with_assessment).unwrap();
1406        document.as_object_mut().unwrap().insert(
1407            "quality".to_string(),
1408            serde_json::to_value(QualityAssessment::exact(QualityMetrics::empty())).unwrap(),
1409        );
1410        let error = serde_json::from_value::<ProfileReport>(document)
1411            .expect_err("an assessment under a `failed` status is not a readable report");
1412        assert!(
1413            error.to_string().contains("only `computed`"),
1414            "unhelpful error: {error}"
1415        );
1416
1417        let mut document = serde_json::to_value(
1418            report_without_quality().with_quality_status(QualityAnalysisStatus::Computed),
1419        )
1420        .unwrap();
1421        document.as_object_mut().unwrap().remove("quality");
1422        let error = serde_json::from_value::<ProfileReport>(document)
1423            .expect_err("`computed` without an assessment is not a readable report");
1424        assert!(
1425            error.to_string().contains("carries no quality assessment"),
1426            "unhelpful error: {error}"
1427        );
1428    }
1429
1430    /// An absent field is a document written before it existed. An explicit
1431    /// null is a malformed current one, and the legacy fallback would repair it
1432    /// into a reason the writer never recorded.
1433    #[test]
1434    fn an_explicit_null_status_is_malformed_not_legacy() {
1435        let mut document = serde_json::to_value(report_without_quality()).unwrap();
1436        document
1437            .as_object_mut()
1438            .unwrap()
1439            .insert("quality_status".to_string(), serde_json::Value::Null);
1440
1441        let error = serde_json::from_value::<ProfileReport>(document)
1442            .expect_err("an explicit null is not a readable status");
1443        assert!(
1444            error.to_string().contains("explicit null is malformed"),
1445            "unhelpful error: {error}"
1446        );
1447    }
1448
1449    fn summary_value(report: &ProfileReport) -> serde_json::Value {
1450        serde_json::from_str(&report.summary_json().unwrap()).unwrap()
1451    }
1452
1453    /// A report built by this crate records how it measured, in both dialects.
1454    /// A document written before the field reads back as unknown, and saving
1455    /// it again must not stamp it with the reader's definitions.
1456    #[test]
1457    fn metric_semantics_are_recorded_and_absence_survives_a_roundtrip() {
1458        let report = report_without_quality();
1459        assert_eq!(report.metric_semantics, Some(MetricSemantics::current()));
1460        let mut document = serde_json::to_value(&report).unwrap();
1461        let recorded = json!({
1462            "text_length_unit": "unicode_scalar",
1463            "null_tokens": "common_markers",
1464            "pattern_match_count": "validated",
1465            "shape_moments": "adjusted_sample",
1466            "range_rule_count": "violating_values",
1467            "key_uniqueness_units": "non_null_values",
1468            "outlier_units": "tested_values",
1469            "key_column_source": "declared",
1470        });
1471        assert_eq!(document["metric_semantics"], recorded);
1472        assert_eq!(summary_value(&report)["metric_semantics"], recorded);
1473
1474        document.as_object_mut().unwrap().remove("metric_semantics");
1475        let legacy: ProfileReport = serde_json::from_value(document).unwrap();
1476        assert_eq!(legacy.metric_semantics, None);
1477        assert!(
1478            serde_json::to_value(&legacy)
1479                .unwrap()
1480                .get("metric_semantics")
1481                .is_none()
1482        );
1483        assert!(summary_value(&legacy).get("metric_semantics").is_none());
1484    }
1485
1486    #[test]
1487    fn malformed_metric_semantics_fail_to_decode() {
1488        for value in [
1489            serde_json::Value::Null,
1490            json!("unicode_scalar"),
1491            json!({"text_length_unit": "utf8_byte"}),
1492            json!({"text_length_unit": null}),
1493            json!({"null_tokens": "empty_null_nan"}),
1494            json!({"null_tokens": null}),
1495            json!({"pattern_match_count": "regex"}),
1496            json!({"pattern_match_count": null}),
1497            json!({"shape_moments": "population"}),
1498            json!({"shape_moments": null}),
1499            json!({"range_rule_count": "rule_failures"}),
1500            json!({"range_rule_count": null}),
1501            json!({"key_uniqueness_units": "rows"}),
1502            json!({"key_uniqueness_units": null}),
1503            json!({"outlier_units": "numeric_values"}),
1504            json!({"outlier_units": null}),
1505            json!({"key_column_source": "name_heuristic"}),
1506            json!({"key_column_source": null}),
1507        ] {
1508            let mut document = serde_json::to_value(report_without_quality()).unwrap();
1509            document["metric_semantics"] = value.clone();
1510            assert!(
1511                serde_json::from_value::<ProfileReport>(document).is_err(),
1512                "{value} decoded"
1513            );
1514        }
1515    }
1516
1517    /// Later releases add definitions; an older reader keeps the ones it knows.
1518    #[test]
1519    fn unknown_metric_definitions_are_ignored() {
1520        let mut document = serde_json::to_value(report_without_quality()).unwrap();
1521        document["metric_semantics"]["grapheme_policy"] = json!("extended");
1522        let restored: ProfileReport = serde_json::from_value(document).unwrap();
1523        assert_eq!(restored.metric_semantics, Some(MetricSemantics::current()));
1524    }
1525
1526    #[test]
1527    fn quality_status_survives_a_json_roundtrip() {
1528        let report = report_without_quality().with_quality_status(QualityAnalysisStatus::Failed {
1529            error: "Metrics calculation failed: no data columns found".to_string(),
1530        });
1531
1532        let json = serde_json::to_string(&report).unwrap();
1533        let restored: ProfileReport = serde_json::from_str(&json).unwrap();
1534
1535        assert_eq!(restored.quality_status, report.quality_status);
1536        assert!(restored.quality.is_none());
1537    }
1538
1539    /// The serialized document is the contract, so the two states have to be
1540    /// distinguishable there and not only in the Rust value.
1541    #[test]
1542    fn serialized_failure_and_skip_differ() {
1543        let failed = serde_json::to_value(report_without_quality().with_quality_status(
1544            QualityAnalysisStatus::Failed {
1545                error: "boom".to_string(),
1546            },
1547        ))
1548        .unwrap();
1549        let skipped = serde_json::to_value(
1550            report_without_quality().with_quality_status(QualityAnalysisStatus::NotRequested),
1551        )
1552        .unwrap();
1553
1554        assert_eq!(
1555            failed.get("quality_status"),
1556            Some(&json!({"state": "failed", "error": "boom"}))
1557        );
1558        assert_eq!(
1559            skipped.get("quality_status"),
1560            Some(&json!({"state": "not_requested"}))
1561        );
1562        assert!(failed.get("quality").is_none());
1563        assert!(skipped.get("quality").is_none());
1564    }
1565
1566    /// A document written before the field existed: an assessment proves the
1567    /// computation ran, and its absence proves nothing.
1568    #[test]
1569    fn documents_without_the_field_read_back_honestly() {
1570        let mut document = serde_json::to_value(report_without_quality()).unwrap();
1571        document.as_object_mut().unwrap().remove("quality_status");
1572
1573        let restored: ProfileReport = serde_json::from_value(document.clone()).unwrap();
1574        assert_eq!(restored.quality_status, QualityAnalysisStatus::Unrecorded);
1575
1576        document.as_object_mut().unwrap().insert(
1577            "quality".to_string(),
1578            serde_json::to_value(QualityAssessment::exact(QualityMetrics::empty())).unwrap(),
1579        );
1580        let restored: ProfileReport = serde_json::from_value(document).unwrap();
1581        assert_eq!(restored.quality_status, QualityAnalysisStatus::Computed);
1582    }
1583
1584    #[test]
1585    fn test_profile_report_json_roundtrip() {
1586        let report = ProfileReport::new(
1587            DataSource::File {
1588                path: "test.csv".to_string(),
1589                format: FileFormat::Csv,
1590                size_bytes: 1024,
1591                modified_at: None,
1592                parquet_metadata: None,
1593            },
1594            vec![],
1595            ExecutionMetadata::new(100, 5, 50),
1596            Some(QualityAssessment::exact(QualityMetrics::empty())),
1597        );
1598
1599        let json = serde_json::to_string(&report).unwrap();
1600        let deserialized: ProfileReport = serde_json::from_str(&json).unwrap();
1601
1602        assert_eq!(deserialized.id, report.id);
1603        assert_eq!(deserialized.timestamp, report.timestamp);
1604        assert_eq!(deserialized.source_identifier(), "test.csv");
1605        assert_eq!(deserialized.execution.rows_processed, 100);
1606        assert!(deserialized.quality.is_some());
1607        assert_eq!(deserialized.schema_version, REPORT_SCHEMA_VERSION);
1608    }
1609
1610    /// The weights determine the aggregate score, so a summary that drops
1611    /// custom ones reloads with a score its weights no longer produce.
1612    #[test]
1613    fn summary_names_its_score_weights_including_defaults() {
1614        let summary_quality = |weights| {
1615            let mut metrics = QualityMetrics::empty();
1616            metrics.score_weights = weights;
1617            let report = ProfileReport::new(
1618                DataSource::File {
1619                    path: "test.csv".to_string(),
1620                    format: FileFormat::Csv,
1621                    size_bytes: 1024,
1622                    modified_at: None,
1623                    parquet_metadata: None,
1624                },
1625                vec![],
1626                ExecutionMetadata::new(100, 5, 50),
1627                Some(QualityAssessment::exact(metrics)),
1628            );
1629            let summary: serde_json::Value =
1630                serde_json::from_str(&report.summary_json().unwrap()).unwrap();
1631            summary["quality"].clone()
1632        };
1633
1634        let custom = QualityScoreWeights {
1635            completeness: 1.0,
1636            consistency: 0.0,
1637            uniqueness: 0.0,
1638            accuracy: 0.0,
1639            timeliness: 0.0,
1640            validity: 0.0,
1641            precision: 3.0,
1642        };
1643        assert_eq!(
1644            summary_quality(custom)["score_weights"],
1645            serde_json::to_value(custom).unwrap()
1646        );
1647        // Defaults too: a stored score has to name the weights behind it in
1648        // case a later release changes the defaults (#749).
1649        assert_eq!(
1650            summary_quality(QualityScoreWeights::default())["score_weights"],
1651            serde_json::to_value(QualityScoreWeights::default()).unwrap()
1652        );
1653    }
1654
1655    #[test]
1656    fn test_serialized_report_carries_schema_version() {
1657        let report = ProfileReport::new(
1658            DataSource::File {
1659                path: "test.csv".to_string(),
1660                format: FileFormat::Csv,
1661                size_bytes: 1024,
1662                modified_at: None,
1663                parquet_metadata: None,
1664            },
1665            vec![],
1666            ExecutionMetadata::new(100, 5, 50),
1667            None,
1668        );
1669
1670        let value = serde_json::to_value(&report).unwrap();
1671        assert_eq!(
1672            value.get("schema_version").and_then(|v| v.as_u64()),
1673            Some(u64::from(REPORT_SCHEMA_VERSION))
1674        );
1675    }
1676
1677    #[test]
1678    fn test_profile_report_without_quality() {
1679        let report = ProfileReport::new(
1680            DataSource::File {
1681                path: "test.csv".to_string(),
1682                format: FileFormat::Csv,
1683                size_bytes: 1024,
1684                modified_at: None,
1685                parquet_metadata: None,
1686            },
1687            vec![],
1688            ExecutionMetadata::new(100, 5, 50),
1689            None,
1690        );
1691
1692        let json = serde_json::to_string(&report).unwrap();
1693        let deserialized: ProfileReport = serde_json::from_str(&json).unwrap();
1694
1695        assert!(deserialized.quality.is_none());
1696        assert_eq!(deserialized.execution.rows_processed, 100);
1697    }
1698
1699    #[test]
1700    fn test_profile_report_deserializes_legacy_quality_metrics() {
1701        let json = json!({
1702            "id": "legacy-report",
1703            "timestamp": "2026-05-22T10:00:00Z",
1704            "data_source": {
1705                "type": "file",
1706                "path": "test.csv",
1707                "format": "csv",
1708                "size_bytes": 42
1709            },
1710            "column_profiles": [],
1711            "scan_info": {
1712                "rows_processed": 10,
1713                "columns_detected": 2,
1714                "scan_time_ms": 5,
1715                "error_count": 0,
1716                "source_exhausted": true,
1717                "sampling_applied": false
1718            },
1719            "data_quality_metrics": {
1720                "completeness": {
1721                    "missing_values_ratio": 0.0,
1722                    "complete_records_ratio": 100.0,
1723                    "null_columns": []
1724                }
1725            }
1726        });
1727
1728        let report: ProfileReport = serde_json::from_value(json).unwrap();
1729
1730        assert_eq!(report.id, "legacy-report");
1731        // A document without a schema_version field is a legacy pre-0.10
1732        // report; it must load and be identifiable as such.
1733        assert_eq!(report.schema_version, 0);
1734        assert_eq!(report.execution.rows_processed, 10);
1735        // Legacy metrics predate the assessability denominators: the facts
1736        // stay readable, but no score is fabricated from them.
1737        assert!(report.quality_score().is_none());
1738        let quality = report
1739            .quality
1740            .expect("expected legacy quality to deserialize");
1741        // Nothing was assessable, so the assessment says so rather than
1742        // claiming exactness about a score it never produced (#571).
1743        assert!(matches!(quality.confidence, MetricConfidence::NotAssessed));
1744        let completeness = quality
1745            .metrics
1746            .completeness
1747            .as_ref()
1748            .expect("legacy completeness facts should deserialize");
1749        assert!((completeness.complete_records_ratio - 100.0).abs() < 0.01);
1750        assert!(quality.metrics.assessed_dimensions().is_empty());
1751    }
1752
1753    /// A canonical quality object that lost its `confidence` still holds its
1754    /// metrics. The flat legacy reader dropped every one of them without an
1755    /// error; they are kept, and the missing provenance stays unknown.
1756    #[test]
1757    fn a_canonical_quality_without_confidence_keeps_its_metrics() {
1758        let mut document = current_document();
1759        document["quality"] = json!({
1760            "metrics": {
1761                "completeness": {
1762                    "missing_values_ratio": 0.0,
1763                    "complete_records_ratio": 100.0,
1764                    "null_columns": []
1765                }
1766            }
1767        });
1768
1769        let report: ProfileReport = serde_json::from_value(document).unwrap();
1770
1771        let quality = report.quality.expect("the assessment survives");
1772        let completeness = quality
1773            .metrics
1774            .completeness
1775            .as_ref()
1776            .expect("the metrics survive");
1777        assert!((completeness.complete_records_ratio - 100.0).abs() < 0.01);
1778        assert!(!matches!(quality.confidence, MetricConfidence::Exact));
1779    }
1780
1781    /// A `confidence` with no `metrics` is a malformed canonical object, not
1782    /// an empty legacy one.
1783    #[test]
1784    fn a_canonical_quality_without_metrics_is_malformed() {
1785        let mut document = current_document();
1786        document["quality"] = json!({ "confidence": "Exact" });
1787
1788        assert!(serde_json::from_value::<ProfileReport>(document).is_err());
1789    }
1790
1791    fn current_document() -> serde_json::Value {
1792        json!({
1793            "schema_version": REPORT_SCHEMA_VERSION,
1794            "id": "current-report",
1795            "timestamp": "2026-07-16T10:00:00Z",
1796            "data_source": {
1797                "type": "file",
1798                "path": "test.csv",
1799                "format": "csv",
1800                "size_bytes": 42
1801            },
1802            "column_profiles": [],
1803            "execution": {
1804                "rows_processed": 10,
1805                "columns_detected": 2,
1806                "scan_time_ms": 5,
1807                "error_count": 0,
1808                "source_exhausted": true,
1809                "sampling_applied": false
1810            }
1811        })
1812    }
1813
1814    #[test]
1815    fn test_additive_fields_from_newer_writer_are_ignored() {
1816        // A newer dataprof may add fields within the same schema version;
1817        // a compatible reader must not break on them.
1818        let mut json = current_document();
1819        json["a_future_additive_field"] = json!({"anything": true});
1820        json["column_profiles"] = json!([]);
1821
1822        let report: ProfileReport = serde_json::from_value(json).unwrap();
1823        assert_eq!(report.schema_version, REPORT_SCHEMA_VERSION);
1824        assert_eq!(report.id, "current-report");
1825    }
1826
1827    #[test]
1828    fn test_unsupported_future_schema_version_fails_explicitly() {
1829        let mut json = current_document();
1830        json["schema_version"] = json!(REPORT_SCHEMA_VERSION + 1);
1831
1832        let err = serde_json::from_value::<ProfileReport>(json).unwrap_err();
1833        let msg = err.to_string();
1834        assert!(
1835            msg.contains("schema version") && msg.contains("upgrade dataprof"),
1836            "expected an actionable schema-version error, got: {msg}"
1837        );
1838    }
1839
1840    #[test]
1841    fn test_version_error_wins_over_structural_errors() {
1842        // A future document that also broke structure, with schema_version
1843        // appearing after the broken field, must still fail with the version
1844        // error — not a confusing type error from the field encountered first.
1845        let json_text = format!(
1846            r#"{{"column_profiles": "not-an-array", "schema_version": {}}}"#,
1847            REPORT_SCHEMA_VERSION + 1
1848        );
1849        let err = serde_json::from_str::<ProfileReport>(&json_text).unwrap_err();
1850        let msg = err.to_string();
1851        assert!(
1852            msg.contains("upgrade dataprof"),
1853            "expected the schema-version error to win, got: {msg}"
1854        );
1855    }
1856
1857    #[test]
1858    fn test_null_schema_version_is_malformed_not_legacy() {
1859        let mut json = current_document();
1860        json["schema_version"] = json!(null);
1861
1862        let err = serde_json::from_value::<ProfileReport>(json).unwrap_err();
1863        let msg = err.to_string();
1864        assert!(
1865            msg.contains("schema_version must be a non-negative integer"),
1866            "expected explicit rejection of null schema_version, got: {msg}"
1867        );
1868    }
1869
1870    #[test]
1871    fn test_non_integer_schema_version_is_rejected() {
1872        for bad in [json!("1"), json!(1.5), json!(-1), json!(true)] {
1873            let mut json = current_document();
1874            json["schema_version"] = bad.clone();
1875            let err = serde_json::from_value::<ProfileReport>(json).unwrap_err();
1876            assert!(
1877                err.to_string().contains("non-negative integer"),
1878                "expected rejection of {bad}, got: {err}"
1879            );
1880        }
1881    }
1882}