Skip to main content

dataprof_core/
analysis_options.rs

1//! The analysis selection every parser and engine must honour.
2//!
3//! Metric packs, quality dimensions, locale, and semantic hints are all
4//! *requests about what to compute*, and the report they produce has to be the
5//! same on every input path. Passing them as loose parameters made that easy to
6//! get wrong: a parser that took dimensions and hints but not packs or locale
7//! compiled fine and silently computed work the caller had deselected.
8//! [`AnalysisOptions`] bundles them so a path either carries the whole selection
9//! or does not compile.
10
11use std::collections::HashSet;
12
13use crate::errors::DataProfilerError;
14use crate::locale::Locale;
15use crate::quality::{MetricPack, QualityDimension};
16use crate::semantic::SemanticHints;
17
18/// What to analyze, and how, for a single profiling run.
19///
20/// Construct with [`AnalysisOptions::default`] (analyze everything) and narrow
21/// with the builder methods.
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct AnalysisOptions {
24    columns: Option<Vec<String>>,
25    metric_packs: Option<Vec<MetricPack>>,
26    quality_dimensions: Option<Vec<QualityDimension>>,
27    locale: Option<Locale>,
28    semantic_hints: SemanticHints,
29}
30
31impl AnalysisOptions {
32    /// Select top-level columns to profile. `None` (the default) means every column.
33    ///
34    /// Selection is by name and reports remain in source-schema order. An empty
35    /// vector requests a schema with no profiled columns.
36    pub fn with_columns(mut self, columns: Option<Vec<String>>) -> Self {
37        self.columns = columns;
38        self
39    }
40
41    /// Select the metric packs to compute. `None` (the default) means all.
42    pub fn with_metric_packs(mut self, packs: Option<Vec<MetricPack>>) -> Self {
43        self.metric_packs = packs;
44        self
45    }
46
47    /// Select the quality dimensions to assess. `None` (the default) means all.
48    pub fn with_quality_dimensions(mut self, dimensions: Option<Vec<QualityDimension>>) -> Self {
49        self.quality_dimensions = dimensions;
50        self
51    }
52
53    /// Set the locale used to rank detected patterns.
54    ///
55    /// Parse a user-supplied tag with [`Locale::parse_optional`] first, so an
56    /// unrecognised tag is rejected where it is written rather than silently
57    /// suppressing every locale-specific pattern.
58    pub fn with_locale(mut self, locale: Option<Locale>) -> Self {
59        self.locale = locale;
60        self
61    }
62
63    /// Set the user's semantic hints.
64    pub fn with_semantic_hints(mut self, hints: SemanticHints) -> Self {
65        self.semantic_hints = hints;
66        self
67    }
68
69    /// The packs to compute, with an empty quality-dimension selection folded in.
70    ///
71    /// Resolved on read rather than in the setters so the outcome does not
72    /// depend on the order the selections were made in.
73    pub fn effective_metric_packs(&self) -> Option<Vec<MetricPack>> {
74        MetricPack::resolve_with_dimensions(
75            self.metric_packs.as_deref(),
76            self.quality_dimensions.as_deref(),
77        )
78    }
79
80    /// Whether per-column statistics should be computed.
81    pub fn include_statistics(&self) -> bool {
82        MetricPack::include_statistics(self.effective_metric_packs().as_deref())
83    }
84
85    /// Whether pattern detection should run.
86    pub fn include_patterns(&self) -> bool {
87        MetricPack::include_patterns(self.effective_metric_packs().as_deref())
88    }
89
90    /// Whether quality metrics should be computed.
91    ///
92    /// `false` means the report carries no quality at all — absent, not an
93    /// empty assessment — because nothing was analyzed.
94    pub fn include_quality(&self) -> bool {
95        MetricPack::include_quality(self.effective_metric_packs().as_deref())
96    }
97
98    /// The locale used to rank detected patterns.
99    ///
100    /// A locale only ranks patterns, so it has no effect of its own when
101    /// [`include_patterns`](Self::include_patterns) is false and detection never
102    /// runs.
103    pub fn locale(&self) -> Option<Locale> {
104        self.locale
105    }
106
107    /// The requested quality dimensions, if the caller narrowed them.
108    pub fn quality_dimensions(&self) -> Option<&[QualityDimension]> {
109        self.quality_dimensions.as_deref()
110    }
111
112    /// The user's semantic hints.
113    pub fn semantic_hints(&self) -> &SemanticHints {
114        &self.semantic_hints
115    }
116
117    /// The requested top-level column names, or `None` when every column is selected.
118    pub fn columns(&self) -> Option<&[String]> {
119        self.columns.as_deref()
120    }
121
122    /// Whether the caller selected a subset of the source columns.
123    pub fn has_column_projection(&self) -> bool {
124        self.columns.is_some()
125    }
126
127    /// Resolve the selected columns against a source schema.
128    ///
129    /// Returned indices follow source order, not request order, so every input
130    /// path preserves the same stable report ordering. Unknown and duplicate
131    /// requests fail rather than producing a plausible partial profile.
132    pub fn column_indices(
133        &self,
134        available: &[String],
135    ) -> Result<Option<Vec<usize>>, DataProfilerError> {
136        let Some(requested) = &self.columns else {
137            return Ok(None);
138        };
139
140        let mut seen = HashSet::with_capacity(requested.len());
141        let duplicates = requested
142            .iter()
143            .filter(|name| !seen.insert(name.as_str()))
144            .cloned()
145            .collect::<Vec<_>>();
146        if !duplicates.is_empty() {
147            return Err(DataProfilerError::invalid_config(
148                &format!(
149                    "column projection contains duplicate name(s): {}",
150                    duplicates.join(", ")
151                ),
152                "List each projected column once.",
153            ));
154        }
155
156        let available_set = available.iter().map(String::as_str).collect::<HashSet<_>>();
157        let missing = requested
158            .iter()
159            .filter(|name| !available_set.contains(name.as_str()))
160            .cloned()
161            .collect::<Vec<_>>();
162        if !missing.is_empty() {
163            return Err(DataProfilerError::invalid_config(
164                &format!(
165                    "column projection names column(s) absent from the source: {}",
166                    missing.join(", ")
167                ),
168                "Choose column names from the source schema.",
169            ));
170        }
171
172        Ok(Some(
173            available
174                .iter()
175                .enumerate()
176                .filter_map(|(index, name)| seen.contains(name.as_str()).then_some(index))
177                .collect(),
178        ))
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn default_analyzes_everything() {
188        let options = AnalysisOptions::default();
189        assert!(options.include_statistics());
190        assert!(options.include_patterns());
191        assert!(options.include_quality());
192        assert_eq!(options.locale(), None);
193        assert_eq!(options.quality_dimensions(), None);
194        assert_eq!(options.columns(), None);
195        assert!(!options.has_column_projection());
196    }
197
198    #[test]
199    fn schema_only_deselects_every_other_pack() {
200        let options = AnalysisOptions::default().with_metric_packs(Some(vec![MetricPack::Schema]));
201        assert!(!options.include_statistics());
202        assert!(!options.include_patterns());
203        assert!(!options.include_quality());
204    }
205
206    #[test]
207    fn empty_dimension_selection_removes_the_quality_pack() {
208        let options = AnalysisOptions::default().with_quality_dimensions(Some(vec![]));
209        assert!(!options.include_quality());
210        // Deselecting quality says nothing about the other packs.
211        assert!(options.include_statistics());
212        assert!(options.include_patterns());
213    }
214
215    #[test]
216    fn resolution_does_not_depend_on_setter_order() {
217        let packs = vec![MetricPack::Schema, MetricPack::Quality];
218        let dims_first = AnalysisOptions::default()
219            .with_quality_dimensions(Some(vec![]))
220            .with_metric_packs(Some(packs.clone()));
221        let packs_first = AnalysisOptions::default()
222            .with_metric_packs(Some(packs))
223            .with_quality_dimensions(Some(vec![]));
224        assert_eq!(
225            dims_first.effective_metric_packs(),
226            packs_first.effective_metric_packs()
227        );
228        assert!(!dims_first.include_quality());
229    }
230
231    #[test]
232    fn a_locale_alone_does_not_re_enable_pattern_detection() {
233        let options = AnalysisOptions::default()
234            .with_locale(Some(Locale::It))
235            .with_metric_packs(Some(vec![MetricPack::Schema]));
236        assert_eq!(options.locale(), Some(Locale::It));
237        assert!(!options.include_patterns());
238    }
239
240    #[test]
241    fn column_projection_resolves_in_source_order() {
242        let options = AnalysisOptions::default()
243            .with_columns(Some(vec!["city".to_string(), "id".to_string()]));
244        let available = vec!["id".to_string(), "name".to_string(), "city".to_string()];
245
246        assert_eq!(
247            options.column_indices(&available).unwrap(),
248            Some(vec![0, 2])
249        );
250        assert!(options.has_column_projection());
251    }
252
253    #[test]
254    fn column_projection_rejects_unknown_and_duplicate_names() {
255        let available = vec!["id".to_string(), "city".to_string()];
256        let unknown = AnalysisOptions::default().with_columns(Some(vec!["name".to_string()]));
257        assert!(unknown.column_indices(&available).is_err());
258
259        let duplicate =
260            AnalysisOptions::default().with_columns(Some(vec!["id".to_string(), "id".to_string()]));
261        assert!(duplicate.column_indices(&available).is_err());
262    }
263}