dataprof-core 0.13.0

Shared core types for dataprof
Documentation
//! The analysis selection every parser and engine must honour.
//!
//! Metric packs, quality dimensions, locale, and semantic hints are all
//! *requests about what to compute*, and the report they produce has to be the
//! same on every input path. Passing them as loose parameters made that easy to
//! get wrong: a parser that took dimensions and hints but not packs or locale
//! compiled fine and silently computed work the caller had deselected.
//! [`AnalysisOptions`] bundles them so a path either carries the whole selection
//! or does not compile.

use std::collections::HashSet;

use crate::errors::DataProfilerError;
use crate::locale::Locale;
use crate::quality::{MetricPack, QualityDimension};
use crate::semantic::SemanticHints;

/// What to analyze, and how, for a single profiling run.
///
/// Construct with [`AnalysisOptions::default`] (analyze everything) and narrow
/// with the builder methods.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AnalysisOptions {
    columns: Option<Vec<String>>,
    metric_packs: Option<Vec<MetricPack>>,
    quality_dimensions: Option<Vec<QualityDimension>>,
    locale: Option<Locale>,
    semantic_hints: SemanticHints,
}

impl AnalysisOptions {
    /// Select top-level columns to profile. `None` (the default) means every column.
    ///
    /// Selection is by name and reports remain in source-schema order. An empty
    /// vector requests a schema with no profiled columns.
    pub fn with_columns(mut self, columns: Option<Vec<String>>) -> Self {
        self.columns = columns;
        self
    }

    /// Select the metric packs to compute. `None` (the default) means all.
    pub fn with_metric_packs(mut self, packs: Option<Vec<MetricPack>>) -> Self {
        self.metric_packs = packs;
        self
    }

    /// Select the quality dimensions to assess. `None` (the default) means all.
    pub fn with_quality_dimensions(mut self, dimensions: Option<Vec<QualityDimension>>) -> Self {
        self.quality_dimensions = dimensions;
        self
    }

    /// Set the locale used to rank detected patterns.
    ///
    /// Parse a user-supplied tag with [`Locale::parse_optional`] first, so an
    /// unrecognised tag is rejected where it is written rather than silently
    /// suppressing every locale-specific pattern.
    pub fn with_locale(mut self, locale: Option<Locale>) -> Self {
        self.locale = locale;
        self
    }

    /// Set the user's semantic hints.
    pub fn with_semantic_hints(mut self, hints: SemanticHints) -> Self {
        self.semantic_hints = hints;
        self
    }

    /// The packs to compute, with an empty quality-dimension selection folded in.
    ///
    /// Resolved on read rather than in the setters so the outcome does not
    /// depend on the order the selections were made in.
    pub fn effective_metric_packs(&self) -> Option<Vec<MetricPack>> {
        MetricPack::resolve_with_dimensions(
            self.metric_packs.as_deref(),
            self.quality_dimensions.as_deref(),
        )
    }

    /// Whether per-column statistics should be computed.
    pub fn include_statistics(&self) -> bool {
        MetricPack::include_statistics(self.effective_metric_packs().as_deref())
    }

    /// Whether pattern detection should run.
    pub fn include_patterns(&self) -> bool {
        MetricPack::include_patterns(self.effective_metric_packs().as_deref())
    }

    /// Whether quality metrics should be computed.
    ///
    /// `false` means the report carries no quality at all — absent, not an
    /// empty assessment — because nothing was analyzed.
    pub fn include_quality(&self) -> bool {
        MetricPack::include_quality(self.effective_metric_packs().as_deref())
    }

    /// The locale used to rank detected patterns.
    ///
    /// A locale only ranks patterns, so it has no effect of its own when
    /// [`include_patterns`](Self::include_patterns) is false and detection never
    /// runs.
    pub fn locale(&self) -> Option<Locale> {
        self.locale
    }

    /// The requested quality dimensions, if the caller narrowed them.
    pub fn quality_dimensions(&self) -> Option<&[QualityDimension]> {
        self.quality_dimensions.as_deref()
    }

    /// The user's semantic hints.
    pub fn semantic_hints(&self) -> &SemanticHints {
        &self.semantic_hints
    }

    /// The requested top-level column names, or `None` when every column is selected.
    pub fn columns(&self) -> Option<&[String]> {
        self.columns.as_deref()
    }

    /// Whether the caller selected a subset of the source columns.
    pub fn has_column_projection(&self) -> bool {
        self.columns.is_some()
    }

    /// Resolve the selected columns against a source schema.
    ///
    /// Returned indices follow source order, not request order, so every input
    /// path preserves the same stable report ordering. Unknown and duplicate
    /// requests fail rather than producing a plausible partial profile.
    pub fn column_indices(
        &self,
        available: &[String],
    ) -> Result<Option<Vec<usize>>, DataProfilerError> {
        let Some(requested) = &self.columns else {
            return Ok(None);
        };

        let mut seen = HashSet::with_capacity(requested.len());
        let duplicates = requested
            .iter()
            .filter(|name| !seen.insert(name.as_str()))
            .cloned()
            .collect::<Vec<_>>();
        if !duplicates.is_empty() {
            return Err(DataProfilerError::invalid_config(
                &format!(
                    "column projection contains duplicate name(s): {}",
                    duplicates.join(", ")
                ),
                "List each projected column once.",
            ));
        }

        let available_set = available.iter().map(String::as_str).collect::<HashSet<_>>();
        let missing = requested
            .iter()
            .filter(|name| !available_set.contains(name.as_str()))
            .cloned()
            .collect::<Vec<_>>();
        if !missing.is_empty() {
            return Err(DataProfilerError::invalid_config(
                &format!(
                    "column projection names column(s) absent from the source: {}",
                    missing.join(", ")
                ),
                "Choose column names from the source schema.",
            ));
        }

        Ok(Some(
            available
                .iter()
                .enumerate()
                .filter_map(|(index, name)| seen.contains(name.as_str()).then_some(index))
                .collect(),
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_analyzes_everything() {
        let options = AnalysisOptions::default();
        assert!(options.include_statistics());
        assert!(options.include_patterns());
        assert!(options.include_quality());
        assert_eq!(options.locale(), None);
        assert_eq!(options.quality_dimensions(), None);
        assert_eq!(options.columns(), None);
        assert!(!options.has_column_projection());
    }

    #[test]
    fn schema_only_deselects_every_other_pack() {
        let options = AnalysisOptions::default().with_metric_packs(Some(vec![MetricPack::Schema]));
        assert!(!options.include_statistics());
        assert!(!options.include_patterns());
        assert!(!options.include_quality());
    }

    #[test]
    fn empty_dimension_selection_removes_the_quality_pack() {
        let options = AnalysisOptions::default().with_quality_dimensions(Some(vec![]));
        assert!(!options.include_quality());
        // Deselecting quality says nothing about the other packs.
        assert!(options.include_statistics());
        assert!(options.include_patterns());
    }

    #[test]
    fn resolution_does_not_depend_on_setter_order() {
        let packs = vec![MetricPack::Schema, MetricPack::Quality];
        let dims_first = AnalysisOptions::default()
            .with_quality_dimensions(Some(vec![]))
            .with_metric_packs(Some(packs.clone()));
        let packs_first = AnalysisOptions::default()
            .with_metric_packs(Some(packs))
            .with_quality_dimensions(Some(vec![]));
        assert_eq!(
            dims_first.effective_metric_packs(),
            packs_first.effective_metric_packs()
        );
        assert!(!dims_first.include_quality());
    }

    #[test]
    fn a_locale_alone_does_not_re_enable_pattern_detection() {
        let options = AnalysisOptions::default()
            .with_locale(Some(Locale::It))
            .with_metric_packs(Some(vec![MetricPack::Schema]));
        assert_eq!(options.locale(), Some(Locale::It));
        assert!(!options.include_patterns());
    }

    #[test]
    fn column_projection_resolves_in_source_order() {
        let options = AnalysisOptions::default()
            .with_columns(Some(vec!["city".to_string(), "id".to_string()]));
        let available = vec!["id".to_string(), "name".to_string(), "city".to_string()];

        assert_eq!(
            options.column_indices(&available).unwrap(),
            Some(vec![0, 2])
        );
        assert!(options.has_column_projection());
    }

    #[test]
    fn column_projection_rejects_unknown_and_duplicate_names() {
        let available = vec!["id".to_string(), "city".to_string()];
        let unknown = AnalysisOptions::default().with_columns(Some(vec!["name".to_string()]));
        assert!(unknown.column_indices(&available).is_err());

        let duplicate =
            AnalysisOptions::default().with_columns(Some(vec!["id".to_string(), "id".to_string()]));
        assert!(duplicate.column_indices(&available).is_err());
    }
}