Skip to main content

dataprof_core/
validation.rs

1use crate::errors::DataProfilerError;
2use std::collections::HashSet;
3
4/// Reject repeated names in an ordered column-name list, before any per-column
5/// state is built.
6///
7/// Every engine keys its per-column accumulators by name, so a repeated name is
8/// otherwise silently merged (file/Arrow paths) or shadowed at mapping access
9/// (columnar path), yielding a report where `total_count` can exceed the row
10/// count or a column simply disappears. Rejecting up front is the only behavior
11/// all engines can share.
12///
13/// `names` is the final list each engine uses as profile keys, so the check runs
14/// on already-normalized names (the caller's stringification is the normalization
15/// step). `source` is a short transport label — e.g. `"CSV header"`, `"CSV bytes"`,
16/// `"Arrow/Parquet schema"` — surfaced to the user. Duplicates are reported in
17/// first-seen order, deduped; no cell values are ever embedded.
18pub fn validate_unique_column_names(
19    names: &[String],
20    source: &str,
21) -> Result<(), DataProfilerError> {
22    let mut seen: HashSet<&str> = HashSet::with_capacity(names.len());
23    let mut reported: HashSet<&str> = HashSet::new();
24    let mut duplicates: Vec<String> = Vec::new();
25    for name in names {
26        if !seen.insert(name.as_str()) && reported.insert(name.as_str()) {
27            duplicates.push(name.clone());
28        }
29    }
30    if duplicates.is_empty() {
31        Ok(())
32    } else {
33        Err(DataProfilerError::duplicate_column_name(
34            &duplicates,
35            source,
36        ))
37    }
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43
44    fn names(v: &[&str]) -> Vec<String> {
45        v.iter().map(|s| s.to_string()).collect()
46    }
47
48    #[test]
49    fn unique_column_names_pass() {
50        assert!(validate_unique_column_names(&names(&["x", "y", "z"]), "CSV header").is_ok());
51        assert!(validate_unique_column_names(&[], "CSV header").is_ok());
52    }
53
54    #[test]
55    fn duplicate_first_middle_last_rejected() {
56        for cols in [
57            names(&["x", "x", "y"]), // first
58            names(&["a", "x", "x"]), // last
59            names(&["a", "x", "b", "x", "c"]),
60        ] {
61            let err = validate_unique_column_names(&cols, "CSV header").unwrap_err();
62            assert!(matches!(err, DataProfilerError::DuplicateColumnName { .. }));
63        }
64    }
65
66    #[test]
67    fn duplicate_error_names_offender_and_source_only() {
68        let cols = names(&["id", "amount", "id", "amount", "note"]);
69        let err = validate_unique_column_names(&cols, "CSV bytes").unwrap_err();
70        let msg = err.to_string();
71        // Each offender named once, source present, no cell values.
72        assert!(msg.contains("'id'"));
73        assert!(msg.contains("'amount'"));
74        assert_eq!(msg.matches("'id'").count(), 1);
75        assert!(msg.contains("CSV bytes"));
76        assert!(msg.contains("Duplicate column names:")); // plural "s"
77    }
78}