Skip to main content

sheetkit_core/
validation.rs

1//! Data validation builder and utilities.
2//!
3//! Provides a high-level API for adding, querying, and removing data validation
4//! rules on worksheet cells.
5
6use crate::error::Result;
7use sheetkit_xml::worksheet::{DataValidation, DataValidations, WorksheetXml};
8
9/// The type of data validation to apply.
10#[derive(Debug, Clone, PartialEq)]
11pub enum ValidationType {
12    Whole,
13    Decimal,
14    List,
15    Date,
16    Time,
17    TextLength,
18    Custom,
19}
20
21impl ValidationType {
22    /// Convert to the XML attribute string.
23    pub fn as_str(&self) -> &str {
24        match self {
25            ValidationType::Whole => "whole",
26            ValidationType::Decimal => "decimal",
27            ValidationType::List => "list",
28            ValidationType::Date => "date",
29            ValidationType::Time => "time",
30            ValidationType::TextLength => "textLength",
31            ValidationType::Custom => "custom",
32        }
33    }
34
35    /// Parse from the XML attribute string.
36    pub fn parse(s: &str) -> Option<Self> {
37        match s {
38            "whole" => Some(ValidationType::Whole),
39            "decimal" => Some(ValidationType::Decimal),
40            "list" => Some(ValidationType::List),
41            "date" => Some(ValidationType::Date),
42            "time" => Some(ValidationType::Time),
43            "textLength" => Some(ValidationType::TextLength),
44            "custom" => Some(ValidationType::Custom),
45            _ => None,
46        }
47    }
48}
49
50/// The comparison operator for data validation.
51#[derive(Debug, Clone, PartialEq)]
52pub enum ValidationOperator {
53    Between,
54    NotBetween,
55    Equal,
56    NotEqual,
57    LessThan,
58    LessThanOrEqual,
59    GreaterThan,
60    GreaterThanOrEqual,
61}
62
63impl ValidationOperator {
64    /// Convert to the XML attribute string.
65    pub fn as_str(&self) -> &str {
66        match self {
67            ValidationOperator::Between => "between",
68            ValidationOperator::NotBetween => "notBetween",
69            ValidationOperator::Equal => "equal",
70            ValidationOperator::NotEqual => "notEqual",
71            ValidationOperator::LessThan => "lessThan",
72            ValidationOperator::LessThanOrEqual => "lessThanOrEqual",
73            ValidationOperator::GreaterThan => "greaterThan",
74            ValidationOperator::GreaterThanOrEqual => "greaterThanOrEqual",
75        }
76    }
77
78    /// Parse from the XML attribute string.
79    pub fn parse(s: &str) -> Option<Self> {
80        match s {
81            "between" => Some(ValidationOperator::Between),
82            "notBetween" => Some(ValidationOperator::NotBetween),
83            "equal" => Some(ValidationOperator::Equal),
84            "notEqual" => Some(ValidationOperator::NotEqual),
85            "lessThan" => Some(ValidationOperator::LessThan),
86            "lessThanOrEqual" => Some(ValidationOperator::LessThanOrEqual),
87            "greaterThan" => Some(ValidationOperator::GreaterThan),
88            "greaterThanOrEqual" => Some(ValidationOperator::GreaterThanOrEqual),
89            _ => None,
90        }
91    }
92}
93
94/// The error display style for validation failures.
95#[derive(Debug, Clone, PartialEq)]
96pub enum ErrorStyle {
97    Stop,
98    Warning,
99    Information,
100}
101
102impl ErrorStyle {
103    /// Convert to the XML attribute string.
104    pub fn as_str(&self) -> &str {
105        match self {
106            ErrorStyle::Stop => "stop",
107            ErrorStyle::Warning => "warning",
108            ErrorStyle::Information => "information",
109        }
110    }
111
112    /// Parse from the XML attribute string.
113    pub fn parse(s: &str) -> Option<Self> {
114        match s {
115            "stop" => Some(ErrorStyle::Stop),
116            "warning" => Some(ErrorStyle::Warning),
117            "information" => Some(ErrorStyle::Information),
118            _ => None,
119        }
120    }
121}
122
123/// Configuration for a data validation rule.
124#[derive(Debug, Clone)]
125pub struct DataValidationConfig {
126    /// The cell range to apply validation to (e.g. "A1:A100").
127    pub sqref: String,
128    /// The type of validation.
129    pub validation_type: ValidationType,
130    /// The comparison operator (not used for list validations).
131    pub operator: Option<ValidationOperator>,
132    /// The first formula/value for the validation constraint.
133    pub formula1: Option<String>,
134    /// The second formula/value (used with Between/NotBetween operators).
135    pub formula2: Option<String>,
136    /// Whether blank cells are allowed.
137    pub allow_blank: bool,
138    /// The error display style.
139    pub error_style: Option<ErrorStyle>,
140    /// The title for the error dialog.
141    pub error_title: Option<String>,
142    /// The message for the error dialog.
143    pub error_message: Option<String>,
144    /// The title for the input prompt.
145    pub prompt_title: Option<String>,
146    /// The message for the input prompt.
147    pub prompt_message: Option<String>,
148    /// Whether to show the input message when the cell is selected.
149    pub show_input_message: bool,
150    /// Whether to show the error message on invalid input.
151    pub show_error_message: bool,
152}
153
154impl DataValidationConfig {
155    /// Create a dropdown list validation.
156    ///
157    /// The items are joined with commas and quoted for the formula.
158    pub fn dropdown(sqref: &str, items: &[&str]) -> Self {
159        let formula = format!("\"{}\"", items.join(","));
160        Self {
161            sqref: sqref.to_string(),
162            validation_type: ValidationType::List,
163            operator: None,
164            formula1: Some(formula),
165            formula2: None,
166            allow_blank: true,
167            error_style: Some(ErrorStyle::Stop),
168            error_title: None,
169            error_message: None,
170            prompt_title: None,
171            prompt_message: None,
172            show_input_message: true,
173            show_error_message: true,
174        }
175    }
176
177    /// Create a whole number range validation (between min and max).
178    pub fn whole_number(sqref: &str, min: i64, max: i64) -> Self {
179        Self {
180            sqref: sqref.to_string(),
181            validation_type: ValidationType::Whole,
182            operator: Some(ValidationOperator::Between),
183            formula1: Some(min.to_string()),
184            formula2: Some(max.to_string()),
185            allow_blank: true,
186            error_style: Some(ErrorStyle::Stop),
187            error_title: None,
188            error_message: None,
189            prompt_title: None,
190            prompt_message: None,
191            show_input_message: true,
192            show_error_message: true,
193        }
194    }
195
196    /// Create a decimal range validation (between min and max).
197    pub fn decimal(sqref: &str, min: f64, max: f64) -> Self {
198        Self {
199            sqref: sqref.to_string(),
200            validation_type: ValidationType::Decimal,
201            operator: Some(ValidationOperator::Between),
202            formula1: Some(min.to_string()),
203            formula2: Some(max.to_string()),
204            allow_blank: true,
205            error_style: Some(ErrorStyle::Stop),
206            error_title: None,
207            error_message: None,
208            prompt_title: None,
209            prompt_message: None,
210            show_input_message: true,
211            show_error_message: true,
212        }
213    }
214
215    /// Create a text length validation.
216    pub fn text_length(sqref: &str, operator: ValidationOperator, length: u32) -> Self {
217        Self {
218            sqref: sqref.to_string(),
219            validation_type: ValidationType::TextLength,
220            operator: Some(operator),
221            formula1: Some(length.to_string()),
222            formula2: None,
223            allow_blank: true,
224            error_style: Some(ErrorStyle::Stop),
225            error_title: None,
226            error_message: None,
227            prompt_title: None,
228            prompt_message: None,
229            show_input_message: true,
230            show_error_message: true,
231        }
232    }
233}
234
235/// Convert a `DataValidationConfig` to the XML `DataValidation` struct.
236pub fn config_to_xml(config: &DataValidationConfig) -> DataValidation {
237    DataValidation {
238        validation_type: Some(config.validation_type.as_str().to_string()),
239        operator: config.operator.as_ref().map(|o| o.as_str().to_string()),
240        allow_blank: if config.allow_blank { Some(true) } else { None },
241        show_input_message: if config.show_input_message {
242            Some(true)
243        } else {
244            None
245        },
246        show_error_message: if config.show_error_message {
247            Some(true)
248        } else {
249            None
250        },
251        error_style: config.error_style.as_ref().map(|e| e.as_str().to_string()),
252        error_title: config.error_title.clone(),
253        error: config.error_message.clone(),
254        prompt_title: config.prompt_title.clone(),
255        prompt: config.prompt_message.clone(),
256        sqref: config.sqref.clone(),
257        formula1: config.formula1.clone(),
258        formula2: config.formula2.clone(),
259    }
260}
261
262/// Convert an XML `DataValidation` to a `DataValidationConfig`.
263fn xml_to_config(dv: &DataValidation) -> DataValidationConfig {
264    DataValidationConfig {
265        sqref: dv.sqref.clone(),
266        validation_type: dv
267            .validation_type
268            .as_deref()
269            .and_then(ValidationType::parse)
270            .unwrap_or(ValidationType::Whole),
271        operator: dv.operator.as_deref().and_then(ValidationOperator::parse),
272        formula1: dv.formula1.clone(),
273        formula2: dv.formula2.clone(),
274        allow_blank: dv.allow_blank.unwrap_or(false),
275        error_style: dv.error_style.as_deref().and_then(ErrorStyle::parse),
276        error_title: dv.error_title.clone(),
277        error_message: dv.error.clone(),
278        prompt_title: dv.prompt_title.clone(),
279        prompt_message: dv.prompt.clone(),
280        show_input_message: dv.show_input_message.unwrap_or(false),
281        show_error_message: dv.show_error_message.unwrap_or(false),
282    }
283}
284
285/// Add a data validation to a worksheet.
286pub fn add_validation(ws: &mut WorksheetXml, config: &DataValidationConfig) -> Result<()> {
287    let dv = config_to_xml(config);
288    let dvs = ws.data_validations.get_or_insert_with(|| DataValidations {
289        count: Some(0),
290        data_validations: Vec::new(),
291    });
292    dvs.data_validations.push(dv);
293    dvs.count = Some(dvs.data_validations.len() as u32);
294    Ok(())
295}
296
297/// Get all data validations from a worksheet.
298pub fn get_validations(ws: &WorksheetXml) -> Vec<DataValidationConfig> {
299    match &ws.data_validations {
300        Some(dvs) => dvs.data_validations.iter().map(xml_to_config).collect(),
301        None => Vec::new(),
302    }
303}
304
305/// Remove validations matching a specific cell range from a worksheet.
306///
307/// Returns `Ok(())` regardless of whether any validations were actually removed.
308pub fn remove_validation(ws: &mut WorksheetXml, sqref: &str) -> Result<()> {
309    if let Some(ref mut dvs) = ws.data_validations {
310        dvs.data_validations.retain(|dv| dv.sqref != sqref);
311        dvs.count = Some(dvs.data_validations.len() as u32);
312        if dvs.data_validations.is_empty() {
313            ws.data_validations = None;
314        }
315    }
316    Ok(())
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_dropdown_validation() {
325        let config = DataValidationConfig::dropdown("A1:A100", &["Yes", "No", "Maybe"]);
326        assert_eq!(config.sqref, "A1:A100");
327        assert_eq!(config.validation_type, ValidationType::List);
328        assert_eq!(config.formula1, Some("\"Yes,No,Maybe\"".to_string()));
329        assert!(config.allow_blank);
330        assert!(config.show_input_message);
331        assert!(config.show_error_message);
332    }
333
334    #[test]
335    fn test_whole_number_validation() {
336        let config = DataValidationConfig::whole_number("B1:B50", 1, 100);
337        assert_eq!(config.sqref, "B1:B50");
338        assert_eq!(config.validation_type, ValidationType::Whole);
339        assert_eq!(config.operator, Some(ValidationOperator::Between));
340        assert_eq!(config.formula1, Some("1".to_string()));
341        assert_eq!(config.formula2, Some("100".to_string()));
342    }
343
344    #[test]
345    fn test_decimal_validation() {
346        let config = DataValidationConfig::decimal("C1:C10", 0.0, 99.99);
347        assert_eq!(config.sqref, "C1:C10");
348        assert_eq!(config.validation_type, ValidationType::Decimal);
349        assert_eq!(config.operator, Some(ValidationOperator::Between));
350        assert_eq!(config.formula1, Some("0".to_string()));
351        assert_eq!(config.formula2, Some("99.99".to_string()));
352    }
353
354    #[test]
355    fn test_text_length_validation() {
356        let config =
357            DataValidationConfig::text_length("D1:D10", ValidationOperator::LessThanOrEqual, 255);
358        assert_eq!(config.sqref, "D1:D10");
359        assert_eq!(config.validation_type, ValidationType::TextLength);
360        assert_eq!(config.operator, Some(ValidationOperator::LessThanOrEqual));
361        assert_eq!(config.formula1, Some("255".to_string()));
362    }
363
364    #[test]
365    fn test_config_to_xml_roundtrip() {
366        let config = DataValidationConfig::dropdown("A1:A10", &["Red", "Blue"]);
367        let xml = config_to_xml(&config);
368        assert_eq!(xml.validation_type, Some("list".to_string()));
369        assert_eq!(xml.sqref, "A1:A10");
370        assert_eq!(xml.formula1, Some("\"Red,Blue\"".to_string()));
371        assert_eq!(xml.allow_blank, Some(true));
372        assert_eq!(xml.show_input_message, Some(true));
373        assert_eq!(xml.show_error_message, Some(true));
374    }
375
376    #[test]
377    fn test_add_validation_to_worksheet() {
378        let mut ws = WorksheetXml::default();
379        let config = DataValidationConfig::dropdown("A1:A100", &["Yes", "No"]);
380        add_validation(&mut ws, &config).unwrap();
381
382        assert!(ws.data_validations.is_some());
383        let dvs = ws.data_validations.as_ref().unwrap();
384        assert_eq!(dvs.count, Some(1));
385        assert_eq!(dvs.data_validations.len(), 1);
386        assert_eq!(dvs.data_validations[0].sqref, "A1:A100");
387    }
388
389    #[test]
390    fn test_add_multiple_validations() {
391        let mut ws = WorksheetXml::default();
392        let config1 = DataValidationConfig::dropdown("A1:A100", &["Yes", "No"]);
393        let config2 = DataValidationConfig::whole_number("B1:B100", 1, 100);
394        add_validation(&mut ws, &config1).unwrap();
395        add_validation(&mut ws, &config2).unwrap();
396
397        let dvs = ws.data_validations.as_ref().unwrap();
398        assert_eq!(dvs.count, Some(2));
399        assert_eq!(dvs.data_validations.len(), 2);
400    }
401
402    #[test]
403    fn test_get_validations() {
404        let mut ws = WorksheetXml::default();
405        let config = DataValidationConfig::dropdown("A1:A100", &["Yes", "No"]);
406        add_validation(&mut ws, &config).unwrap();
407
408        let configs = get_validations(&ws);
409        assert_eq!(configs.len(), 1);
410        assert_eq!(configs[0].sqref, "A1:A100");
411        assert_eq!(configs[0].validation_type, ValidationType::List);
412    }
413
414    #[test]
415    fn test_get_validations_empty() {
416        let ws = WorksheetXml::default();
417        let configs = get_validations(&ws);
418        assert!(configs.is_empty());
419    }
420
421    #[test]
422    fn test_remove_validation() {
423        let mut ws = WorksheetXml::default();
424        let config1 = DataValidationConfig::dropdown("A1:A100", &["Yes", "No"]);
425        let config2 = DataValidationConfig::whole_number("B1:B100", 1, 100);
426        add_validation(&mut ws, &config1).unwrap();
427        add_validation(&mut ws, &config2).unwrap();
428
429        remove_validation(&mut ws, "A1:A100").unwrap();
430
431        let dvs = ws.data_validations.as_ref().unwrap();
432        assert_eq!(dvs.count, Some(1));
433        assert_eq!(dvs.data_validations.len(), 1);
434        assert_eq!(dvs.data_validations[0].sqref, "B1:B100");
435    }
436
437    #[test]
438    fn test_remove_last_validation_clears_container() {
439        let mut ws = WorksheetXml::default();
440        let config = DataValidationConfig::dropdown("A1:A100", &["Yes", "No"]);
441        add_validation(&mut ws, &config).unwrap();
442        remove_validation(&mut ws, "A1:A100").unwrap();
443
444        assert!(ws.data_validations.is_none());
445    }
446
447    #[test]
448    fn test_remove_nonexistent_validation() {
449        let mut ws = WorksheetXml::default();
450        // Should not error when removing from empty worksheet
451        remove_validation(&mut ws, "Z1:Z99").unwrap();
452        assert!(ws.data_validations.is_none());
453    }
454
455    #[test]
456    fn test_validation_xml_serialization_roundtrip() {
457        let mut ws = WorksheetXml::default();
458        let config = DataValidationConfig::dropdown("A1:A10", &["Apple", "Banana"]);
459        add_validation(&mut ws, &config).unwrap();
460
461        let xml = quick_xml::se::to_string(&ws).unwrap();
462        assert!(xml.contains("dataValidations"));
463        assert!(xml.contains("A1:A10"));
464
465        let parsed: WorksheetXml = quick_xml::de::from_str(&xml).unwrap();
466        assert!(parsed.data_validations.is_some());
467        let dvs = parsed.data_validations.as_ref().unwrap();
468        assert_eq!(dvs.data_validations.len(), 1);
469        assert_eq!(dvs.data_validations[0].sqref, "A1:A10");
470        assert_eq!(
471            dvs.data_validations[0].validation_type,
472            Some("list".to_string())
473        );
474    }
475
476    #[test]
477    fn test_whole_number_validation_xml_roundtrip() {
478        let mut ws = WorksheetXml::default();
479        let config = DataValidationConfig::whole_number("B1:B50", 10, 200);
480        add_validation(&mut ws, &config).unwrap();
481
482        let xml = quick_xml::se::to_string(&ws).unwrap();
483        let parsed: WorksheetXml = quick_xml::de::from_str(&xml).unwrap();
484
485        let configs = get_validations(&parsed);
486        assert_eq!(configs.len(), 1);
487        assert_eq!(configs[0].sqref, "B1:B50");
488        assert_eq!(configs[0].validation_type, ValidationType::Whole);
489        assert_eq!(configs[0].operator, Some(ValidationOperator::Between));
490        assert_eq!(configs[0].formula1, Some("10".to_string()));
491        assert_eq!(configs[0].formula2, Some("200".to_string()));
492    }
493
494    #[test]
495    fn test_decimal_validation_xml_roundtrip() {
496        let mut ws = WorksheetXml::default();
497        let config = DataValidationConfig::decimal("C1:C10", 1.5, 99.9);
498        add_validation(&mut ws, &config).unwrap();
499
500        let xml = quick_xml::se::to_string(&ws).unwrap();
501        let parsed: WorksheetXml = quick_xml::de::from_str(&xml).unwrap();
502
503        let configs = get_validations(&parsed);
504        assert_eq!(configs.len(), 1);
505        assert_eq!(configs[0].validation_type, ValidationType::Decimal);
506    }
507
508    #[test]
509    fn test_validation_type_as_str() {
510        assert_eq!(ValidationType::Whole.as_str(), "whole");
511        assert_eq!(ValidationType::Decimal.as_str(), "decimal");
512        assert_eq!(ValidationType::List.as_str(), "list");
513        assert_eq!(ValidationType::Date.as_str(), "date");
514        assert_eq!(ValidationType::Time.as_str(), "time");
515        assert_eq!(ValidationType::TextLength.as_str(), "textLength");
516        assert_eq!(ValidationType::Custom.as_str(), "custom");
517    }
518
519    #[test]
520    fn test_validation_operator_as_str() {
521        assert_eq!(ValidationOperator::Between.as_str(), "between");
522        assert_eq!(ValidationOperator::NotBetween.as_str(), "notBetween");
523        assert_eq!(ValidationOperator::Equal.as_str(), "equal");
524        assert_eq!(ValidationOperator::NotEqual.as_str(), "notEqual");
525        assert_eq!(ValidationOperator::LessThan.as_str(), "lessThan");
526        assert_eq!(
527            ValidationOperator::LessThanOrEqual.as_str(),
528            "lessThanOrEqual"
529        );
530        assert_eq!(ValidationOperator::GreaterThan.as_str(), "greaterThan");
531        assert_eq!(
532            ValidationOperator::GreaterThanOrEqual.as_str(),
533            "greaterThanOrEqual"
534        );
535    }
536
537    #[test]
538    fn test_error_style_as_str() {
539        assert_eq!(ErrorStyle::Stop.as_str(), "stop");
540        assert_eq!(ErrorStyle::Warning.as_str(), "warning");
541        assert_eq!(ErrorStyle::Information.as_str(), "information");
542    }
543}