1use crate::fork::{ChangeSummary, StagedChange, StagedOp};
2use crate::model::{FillDescriptor, WorkbookId};
3use crate::state::AppState;
4use crate::styles::descriptor_from_style;
5use crate::tools::param_enums::BatchMode;
6use crate::utils::make_short_random_id;
7use crate::{rules::conditional_format, styles::normalize_color_hex};
8use anyhow::{Result, anyhow, bail};
9use chrono::Utc;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13use std::fs;
14use std::path::Path;
15use std::sync::Arc;
16use umya_spreadsheet::{
17 ConditionalFormattingOperatorValues, DataValidation, DataValidationOperatorValues,
18 DataValidationValues, DataValidations,
19};
20
21#[derive(Debug, Deserialize, JsonSchema)]
22pub struct RulesBatchParams {
23 pub fork_id: String,
24 pub ops: Vec<RulesOp>,
25 #[serde(default)]
26 pub mode: Option<BatchMode>, pub label: Option<String>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
31#[serde(tag = "kind", rename_all = "snake_case")]
32pub enum RulesOp {
33 SetDataValidation {
34 sheet_name: String,
35 target_range: String,
36 validation: DataValidationSpec,
37 },
38 AddConditionalFormat {
39 sheet_name: String,
40 target_range: String,
41 rule: ConditionalFormatRuleSpec,
42 #[serde(default)]
43 style: ConditionalFormatStyleSpec,
44 },
45 SetConditionalFormat {
46 sheet_name: String,
47 target_range: String,
48 rule: ConditionalFormatRuleSpec,
49 #[serde(default)]
50 style: ConditionalFormatStyleSpec,
51 },
52 ClearConditionalFormats {
53 sheet_name: String,
54 target_range: String,
55 },
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59#[serde(tag = "kind", rename_all = "snake_case")]
60pub enum ConditionalFormatRuleSpec {
61 CellIs {
62 operator: ConditionalFormatOperator,
63 formula: String,
64 },
65 Expression {
66 formula: String,
67 },
68}
69
70#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
71#[serde(rename_all = "snake_case")]
72pub enum ConditionalFormatOperator {
73 #[serde(alias = "lessThan")]
74 LessThan,
75 #[serde(alias = "lessThanOrEqual")]
76 LessThanOrEqual,
77 #[serde(alias = "greaterThan")]
78 GreaterThan,
79 #[serde(alias = "greaterThanOrEqual")]
80 GreaterThanOrEqual,
81 #[serde(alias = "equal")]
82 Equal,
83 #[serde(alias = "notEqual")]
84 NotEqual,
85 #[serde(alias = "between")]
86 Between,
87 #[serde(alias = "notBetween")]
88 NotBetween,
89}
90
91impl ConditionalFormatOperator {
92 fn to_umya(self) -> ConditionalFormattingOperatorValues {
93 match self {
94 Self::LessThan => ConditionalFormattingOperatorValues::LessThan,
95 Self::LessThanOrEqual => ConditionalFormattingOperatorValues::LessThanOrEqual,
96 Self::GreaterThan => ConditionalFormattingOperatorValues::GreaterThan,
97 Self::GreaterThanOrEqual => ConditionalFormattingOperatorValues::GreaterThanOrEqual,
98 Self::Equal => ConditionalFormattingOperatorValues::Equal,
99 Self::NotEqual => ConditionalFormattingOperatorValues::NotEqual,
100 Self::Between => ConditionalFormattingOperatorValues::Between,
101 Self::NotBetween => ConditionalFormattingOperatorValues::NotBetween,
102 }
103 }
104}
105
106#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
107pub struct ConditionalFormatStyleSpec {
108 #[serde(default)]
109 pub fill_color: Option<String>,
110 #[serde(default)]
111 pub font_color: Option<String>,
112 #[serde(default)]
113 pub bold: Option<bool>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
117pub struct DataValidationSpec {
118 pub kind: DataValidationKind,
119 pub formula1: String,
120 #[serde(default)]
121 pub formula2: Option<String>,
122 #[serde(default)]
123 pub allow_blank: Option<bool>,
124 #[serde(default)]
125 pub prompt: Option<ValidationMessage>,
126 #[serde(default)]
127 pub error: Option<ValidationMessage>,
128}
129
130#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
131#[serde(rename_all = "snake_case")]
132pub enum DataValidationKind {
133 List,
134 Whole,
135 Decimal,
136 Date,
137 Custom,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
141pub struct ValidationMessage {
142 pub title: String,
143 pub message: String,
144}
145
146#[derive(Debug, Serialize, JsonSchema)]
147pub struct RulesBatchResponse {
148 pub fork_id: String,
149 pub mode: String,
150 pub change_id: Option<String>,
151 pub ops_applied: usize,
152 pub summary: ChangeSummary,
153}
154
155#[derive(Debug, Serialize, Deserialize)]
156pub(crate) struct RulesBatchStagedPayload {
157 pub(crate) ops: Vec<RulesOp>,
158}
159
160pub async fn rules_batch(
161 state: Arc<AppState>,
162 params: RulesBatchParams,
163) -> Result<RulesBatchResponse> {
164 let registry = state
165 .fork_registry()
166 .ok_or_else(|| anyhow!("fork registry not available"))?;
167
168 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
169 let work_path = fork_ctx.work_path.clone();
170
171 let fork_workbook_id = WorkbookId(params.fork_id.clone());
173 let workbook = state.open_workbook(&fork_workbook_id).await?;
174 for op in ¶ms.ops {
175 match op {
176 RulesOp::SetDataValidation { sheet_name, .. } => {
177 let _ = workbook.with_sheet(sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
178 }
179 RulesOp::AddConditionalFormat { sheet_name, .. }
180 | RulesOp::SetConditionalFormat { sheet_name, .. }
181 | RulesOp::ClearConditionalFormats { sheet_name, .. } => {
182 let _ = workbook.with_sheet(sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
183 }
184 }
185 }
186
187 let mode = params.mode.unwrap_or_default();
188
189 if mode.is_preview() {
190 let change_id = make_short_random_id("chg", 12);
191 let snapshot_path = crate::tools::fork::stage_snapshot_path(¶ms.fork_id, &change_id);
192 fs::create_dir_all(snapshot_path.parent().unwrap())?;
193 fs::copy(&work_path, &snapshot_path)?;
194
195 let snapshot_for_apply = snapshot_path.clone();
196 let ops_for_apply = params.ops.clone();
197 let apply_result = tokio::task::spawn_blocking(move || {
198 apply_rules_ops_to_file(&snapshot_for_apply, &ops_for_apply)
199 })
200 .await??;
201
202 let mut summary = apply_result.summary;
203 summary
204 .flags
205 .insert("recalc_needed".to_string(), fork_ctx.recalc_needed);
206
207 let staged_op = StagedOp {
208 kind: "rules_batch".to_string(),
209 payload: serde_json::to_value(RulesBatchStagedPayload {
210 ops: params.ops.clone(),
211 })?,
212 };
213 let staged = StagedChange {
214 change_id: change_id.clone(),
215 created_at: Utc::now(),
216 label: params.label.clone(),
217 ops: vec![staged_op],
218 summary: summary.clone(),
219 fork_path_snapshot: Some(snapshot_path),
220 };
221 registry.add_staged_change(¶ms.fork_id, staged)?;
222
223 Ok(RulesBatchResponse {
224 fork_id: params.fork_id,
225 mode: mode.as_str().to_string(),
226 change_id: Some(change_id),
227 ops_applied: apply_result.ops_applied,
228 summary,
229 })
230 } else {
231 let work_path_for_apply = work_path.clone();
232 let ops_for_apply = params.ops.clone();
233 let apply_result = tokio::task::spawn_blocking(move || {
234 apply_rules_ops_to_file(&work_path_for_apply, &ops_for_apply)
235 })
236 .await??;
237
238 let mut summary = apply_result.summary;
239 summary
240 .flags
241 .insert("recalc_needed".to_string(), fork_ctx.recalc_needed);
242
243 let _ = state.close_workbook(&fork_workbook_id);
244
245 Ok(RulesBatchResponse {
246 fork_id: params.fork_id,
247 mode: mode.as_str().to_string(),
248 change_id: None,
249 ops_applied: apply_result.ops_applied,
250 summary,
251 })
252 }
253}
254
255pub(crate) struct RulesApplyResult {
256 pub(crate) ops_applied: usize,
257 pub(crate) summary: ChangeSummary,
258}
259
260pub(crate) fn apply_rules_ops_to_file(path: &Path, ops: &[RulesOp]) -> Result<RulesApplyResult> {
261 let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
262
263 let mut affected_sheets: BTreeSet<String> = BTreeSet::new();
264 let mut affected_bounds: Vec<String> = Vec::new();
265 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
266 let mut warnings: Vec<String> = Vec::new();
267
268 let mut validations_set: u64 = 0;
269 let mut validations_replaced: u64 = 0;
270 let mut conditional_formats_added: u64 = 0;
271 let mut conditional_formats_skipped: u64 = 0;
272 let mut conditional_formats_set: u64 = 0;
273 let mut conditional_formats_replaced: u64 = 0;
274 let mut conditional_formats_set_skipped: u64 = 0;
275 let mut conditional_formats_cleared: u64 = 0;
276
277 let mut warned_not_parsed = false;
278 let mut warned_cf_structure = false;
279
280 for op in ops {
281 match op {
282 RulesOp::SetDataValidation {
283 sheet_name,
284 target_range,
285 validation,
286 } => {
287 let sheet = book
288 .get_sheet_by_name_mut(sheet_name)
289 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
290
291 affected_sheets.insert(sheet_name.clone());
292 affected_bounds.push(target_range.clone());
293
294 if !warned_not_parsed {
295 warnings.push(
296 "WARN_VALIDATION_FORMULA_NOT_PARSED: Validation formulas are applied verbatim (not parsed or validated)."
297 .to_string(),
298 );
299 warned_not_parsed = true;
300 }
301
302 let (set_inc, replaced_inc) =
303 set_data_validation(sheet, target_range, validation, &mut warnings)?;
304 validations_set += set_inc;
305 validations_replaced += replaced_inc;
306 }
307 RulesOp::AddConditionalFormat {
308 sheet_name,
309 target_range,
310 rule,
311 style,
312 } => {
313 let sheet = book
314 .get_sheet_by_name_mut(sheet_name)
315 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
316
317 affected_sheets.insert(sheet_name.clone());
318 affected_bounds.push(target_range.clone());
319
320 if !warned_cf_structure {
321 warnings.push("WARN_CF_FORMULA_NOT_ADJUSTED_ON_STRUCTURE: Conditional format formulas are not automatically rewritten on structural edits; re-apply or review after row/col insertion/deletion.".to_string());
322 warned_cf_structure = true;
323 }
324
325 let (added, skipped) =
326 add_conditional_format(sheet, target_range, rule, style, &mut warnings)?;
327 conditional_formats_added += added;
328 conditional_formats_skipped += skipped;
329 }
330 RulesOp::SetConditionalFormat {
331 sheet_name,
332 target_range,
333 rule,
334 style,
335 } => {
336 let sheet = book
337 .get_sheet_by_name_mut(sheet_name)
338 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
339
340 affected_sheets.insert(sheet_name.clone());
341 affected_bounds.push(target_range.clone());
342
343 if !warned_cf_structure {
344 warnings.push("WARN_CF_FORMULA_NOT_ADJUSTED_ON_STRUCTURE: Conditional format formulas are not automatically rewritten on structural edits; re-apply or review after row/col insertion/deletion.".to_string());
345 warned_cf_structure = true;
346 }
347
348 let (set, replaced, skipped) =
349 set_conditional_format(sheet, target_range, rule, style, &mut warnings)?;
350 conditional_formats_set += set;
351 conditional_formats_replaced += replaced;
352 conditional_formats_set_skipped += skipped;
353 }
354 RulesOp::ClearConditionalFormats {
355 sheet_name,
356 target_range,
357 } => {
358 let sheet = book
359 .get_sheet_by_name_mut(sheet_name)
360 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
361
362 affected_sheets.insert(sheet_name.clone());
363 affected_bounds.push(target_range.clone());
364
365 let cleared = clear_conditional_formats(sheet, target_range)?;
366 conditional_formats_cleared += cleared;
367 }
368 }
369 }
370
371 umya_spreadsheet::writer::xlsx::write(&book, path)?;
372
373 counts.insert("validations_set".to_string(), validations_set);
374 counts.insert("validations_replaced".to_string(), validations_replaced);
375 counts.insert(
376 "conditional_formats_added".to_string(),
377 conditional_formats_added,
378 );
379 counts.insert(
380 "conditional_formats_skipped".to_string(),
381 conditional_formats_skipped,
382 );
383 counts.insert(
384 "conditional_formats_set".to_string(),
385 conditional_formats_set,
386 );
387 counts.insert(
388 "conditional_formats_replaced".to_string(),
389 conditional_formats_replaced,
390 );
391 counts.insert(
392 "conditional_formats_set_skipped".to_string(),
393 conditional_formats_set_skipped,
394 );
395 counts.insert(
396 "conditional_formats_cleared".to_string(),
397 conditional_formats_cleared,
398 );
399
400 Ok(RulesApplyResult {
401 ops_applied: ops.len(),
402 summary: ChangeSummary {
403 op_kinds: vec!["rules_batch".to_string()],
404 affected_sheets: affected_sheets.into_iter().collect(),
405 affected_bounds,
406 counts,
407 warnings,
408 ..Default::default()
409 },
410 })
411}
412
413fn normalize_sqref(input: &str) -> Result<String> {
414 let trimmed = input.trim();
415 if trimmed.is_empty() {
416 bail!("target_range is required");
417 }
418 Ok(trimmed.replace(' ', "").to_ascii_uppercase())
420}
421
422fn normalize_cf_formula(field: &str, value: &str, warnings: &mut Vec<String>) -> Result<String> {
423 let trimmed = value.trim();
424 if trimmed.is_empty() {
425 bail!("{field} is required");
426 }
427 if let Some(stripped) = trimmed.strip_prefix('=') {
428 warnings.push(format!(
429 "WARN_CF_FORMULA_PREFIX: Stripped leading '=' from {field}"
430 ));
431 return Ok(stripped.to_string());
432 }
433 Ok(trimmed.to_string())
434}
435
436fn normalize_argb_color(field: &str, input: &str, warnings: &mut Vec<String>) -> Result<String> {
437 let trimmed = input.trim();
438 let Some((argb, defaulted_alpha)) = normalize_color_hex(trimmed) else {
439 bail!("invalid color for {field}: expected #RGB/#RRGGBB/#AARRGGBB");
440 };
441 if defaulted_alpha {
442 warnings.push(format!(
443 "WARN_COLOR_ALPHA_DEFAULT: Defaulted alpha to FF for {field}"
444 ));
445 }
446 Ok(argb)
447}
448
449fn add_conditional_format(
450 sheet: &mut umya_spreadsheet::Worksheet,
451 target_range: &str,
452 rule: &ConditionalFormatRuleSpec,
453 style: &ConditionalFormatStyleSpec,
454 warnings: &mut Vec<String>,
455) -> Result<(u64, u64)> {
456 let sqref = normalize_sqref(target_range)?;
457
458 let desired = match rule {
459 ConditionalFormatRuleSpec::Expression { formula } => (
460 umya_spreadsheet::ConditionalFormatValues::Expression,
461 None,
462 normalize_cf_formula("rule.formula", formula, warnings)?,
463 ),
464 ConditionalFormatRuleSpec::CellIs { operator, formula } => (
465 umya_spreadsheet::ConditionalFormatValues::CellIs,
466 Some(operator.to_umya()),
467 normalize_cf_formula("rule.formula", formula, warnings)?,
468 ),
469 };
470
471 let fill = style.fill_color.as_deref().unwrap_or("FFFFE0E0");
473 let font = style.font_color.as_deref().unwrap_or("FF000000");
474 let bold = style.bold.unwrap_or(false);
475
476 let fill_argb = normalize_argb_color("style.fill_color", fill, warnings)?;
477 let font_argb = normalize_argb_color("style.font_color", font, warnings)?;
478
479 for existing in sheet.get_conditional_formatting_collection() {
481 let existing_sqref = existing.get_sequence_of_references().get_sqref();
482 let existing_norm = existing_sqref.replace(' ', "").to_ascii_uppercase();
483 if existing_norm != sqref {
484 continue;
485 }
486 for existing_rule in existing.get_conditional_collection() {
487 if existing_rule.get_type() != &desired.0 {
488 continue;
489 }
490 if let Some(ref op) = desired.1 {
491 if existing_rule.get_operator() != op {
492 continue;
493 }
494 }
495 let existing_formula = existing_rule
496 .get_formula()
497 .map(|f| f.get_address_str())
498 .unwrap_or_default();
499 if existing_formula == desired.2 {
500 return Ok((0, 1));
501 }
502 }
503 }
504
505 let dxf_style = conditional_format::build_simple_dxf_style(&fill_argb, &font_argb, bold);
506
507 match desired.0 {
508 umya_spreadsheet::ConditionalFormatValues::Expression => {
509 conditional_format::append_cf_expression_rule(sheet, &sqref, &desired.2, dxf_style);
510 }
511 umya_spreadsheet::ConditionalFormatValues::CellIs => {
512 conditional_format::append_cf_cellis_rule(
513 sheet,
514 &sqref,
515 desired
516 .1
517 .clone()
518 .unwrap_or(ConditionalFormattingOperatorValues::LessThan),
519 &desired.2,
520 dxf_style,
521 );
522 }
523 _ => unreachable!("only expression and cellIs are supported"),
524 }
525
526 Ok((1, 0))
527}
528
529fn clear_conditional_formats(
530 sheet: &mut umya_spreadsheet::Worksheet,
531 target_range: &str,
532) -> Result<u64> {
533 let sqref = normalize_sqref(target_range)?;
534 let before = sheet.get_conditional_formatting_collection().len();
535 if before == 0 {
536 return Ok(0);
537 }
538
539 let mut kept: Vec<umya_spreadsheet::ConditionalFormatting> = Vec::new();
540 for cf in sheet.get_conditional_formatting_collection() {
541 let existing = cf.get_sequence_of_references().get_sqref();
542 let existing_norm = existing.replace(' ', "").to_ascii_uppercase();
543 if existing_norm != sqref {
544 kept.push(cf.clone());
545 }
546 }
547
548 let removed = before.saturating_sub(kept.len()) as u64;
549 if removed > 0 {
550 sheet.set_conditional_formatting_collection(kept);
551 }
552 Ok(removed)
553}
554
555fn cf_rule_core_matches(
556 existing: &umya_spreadsheet::ConditionalFormattingRule,
557 desired_kind: &umya_spreadsheet::ConditionalFormatValues,
558 desired_operator: Option<&ConditionalFormattingOperatorValues>,
559 desired_formula: &str,
560) -> bool {
561 if existing.get_type() != desired_kind {
562 return false;
563 }
564 if let Some(op) = desired_operator {
565 if existing.get_operator() != op {
566 return false;
567 }
568 }
569 let existing_formula = existing
570 .get_formula()
571 .map(|f| f.get_address_str())
572 .unwrap_or_default();
573 existing_formula == desired_formula
574}
575
576fn cf_rule_style_matches(
577 existing: &umya_spreadsheet::ConditionalFormattingRule,
578 desired_fill_argb: &str,
579 desired_font_argb: &str,
580 desired_bold: bool,
581) -> bool {
582 let Some(style) = existing.get_style() else {
583 return false;
584 };
585
586 let desc = descriptor_from_style(style);
587 let existing_bold = desc.font.as_ref().and_then(|f| f.bold).unwrap_or(false);
588 if existing_bold != desired_bold {
589 return false;
590 }
591 if desc.font.as_ref().and_then(|f| f.color.as_deref()) != Some(desired_font_argb) {
592 return false;
593 }
594
595 match &desc.fill {
596 Some(FillDescriptor::Pattern(p)) => {
597 p.foreground_color.as_deref() == Some(desired_fill_argb)
598 }
599 _ => false,
600 }
601}
602
603fn set_conditional_format(
604 sheet: &mut umya_spreadsheet::Worksheet,
605 target_range: &str,
606 rule: &ConditionalFormatRuleSpec,
607 style: &ConditionalFormatStyleSpec,
608 warnings: &mut Vec<String>,
609) -> Result<(u64, u64, u64)> {
610 let sqref = normalize_sqref(target_range)?;
611
612 let desired_kind;
613 let desired_operator: Option<ConditionalFormattingOperatorValues>;
614 let desired_formula: String;
615 match rule {
616 ConditionalFormatRuleSpec::Expression { formula } => {
617 desired_kind = umya_spreadsheet::ConditionalFormatValues::Expression;
618 desired_operator = None;
619 desired_formula = normalize_cf_formula("rule.formula", formula, warnings)?;
620 }
621 ConditionalFormatRuleSpec::CellIs { operator, formula } => {
622 desired_kind = umya_spreadsheet::ConditionalFormatValues::CellIs;
623 desired_operator = Some(operator.to_umya());
624 desired_formula = normalize_cf_formula("rule.formula", formula, warnings)?;
625 }
626 }
627
628 let fill = style.fill_color.as_deref().unwrap_or("FFFFE0E0");
630 let font = style.font_color.as_deref().unwrap_or("FF000000");
631 let bold = style.bold.unwrap_or(false);
632 let fill_argb = normalize_argb_color("style.fill_color", fill, warnings)?;
633 let font_argb = normalize_argb_color("style.font_color", font, warnings)?;
634
635 let matches: Vec<&umya_spreadsheet::ConditionalFormatting> = sheet
637 .get_conditional_formatting_collection()
638 .iter()
639 .filter(|cf| {
640 let existing = cf.get_sequence_of_references().get_sqref();
641 let existing_norm = existing.replace(' ', "").to_ascii_uppercase();
642 existing_norm == sqref
643 })
644 .collect();
645 if matches.len() == 1 {
646 let rules = matches[0].get_conditional_collection();
647 if rules.len() == 1 {
648 let existing = &rules[0];
649 if cf_rule_core_matches(
650 existing,
651 &desired_kind,
652 desired_operator.as_ref(),
653 &desired_formula,
654 ) && cf_rule_style_matches(existing, &fill_argb, &font_argb, bold)
655 {
656 return Ok((0, 0, 1));
657 }
658 }
659 }
660
661 let mut replaced: u64 = 0;
663 if !sheet.get_conditional_formatting_collection().is_empty() {
664 let mut kept: Vec<umya_spreadsheet::ConditionalFormatting> = Vec::new();
665 for cf in sheet.get_conditional_formatting_collection() {
666 let existing = cf.get_sequence_of_references().get_sqref();
667 let existing_norm = existing.replace(' ', "").to_ascii_uppercase();
668 if existing_norm == sqref {
669 replaced += 1;
670 } else {
671 kept.push(cf.clone());
672 }
673 }
674 if replaced > 0 {
675 sheet.set_conditional_formatting_collection(kept);
676 }
677 }
678
679 let dxf_style = conditional_format::build_simple_dxf_style(&fill_argb, &font_argb, bold);
680 match desired_kind {
681 umya_spreadsheet::ConditionalFormatValues::Expression => {
682 conditional_format::append_cf_expression_rule(
683 sheet,
684 &sqref,
685 &desired_formula,
686 dxf_style,
687 );
688 }
689 umya_spreadsheet::ConditionalFormatValues::CellIs => {
690 conditional_format::append_cf_cellis_rule(
691 sheet,
692 &sqref,
693 desired_operator
694 .clone()
695 .unwrap_or(ConditionalFormattingOperatorValues::LessThan),
696 &desired_formula,
697 dxf_style,
698 );
699 }
700 _ => unreachable!("only expression and cellIs are supported"),
701 }
702
703 Ok((1, replaced, 0))
704}
705
706fn normalize_dv_formula(field: &str, value: &str, warnings: &mut Vec<String>) -> String {
707 let trimmed = value.trim();
708 if let Some(stripped) = trimmed.strip_prefix('=') {
709 warnings.push(format!(
710 "WARN_VALIDATION_FORMULA_PREFIX: Stripped leading '=' from {field}"
711 ));
712 stripped.to_string()
713 } else {
714 trimmed.to_string()
715 }
716}
717
718fn set_data_validation(
719 sheet: &mut umya_spreadsheet::Worksheet,
720 target_range: &str,
721 spec: &DataValidationSpec,
722 warnings: &mut Vec<String>,
723) -> Result<(u64, u64)> {
724 let sqref = normalize_sqref(target_range)?;
725
726 if sheet.get_data_validations_mut().is_none() {
727 sheet.set_data_validations(DataValidations::default());
728 }
729 let dvs = sheet
730 .get_data_validations_mut()
731 .ok_or_else(|| anyhow!("failed to initialize data validations"))?;
732
733 let list = dvs.get_data_validation_list_mut();
735 let before = list.len();
736 list.retain(|dv| {
737 let existing = dv.get_sequence_of_references().get_sqref();
738 let existing_norm = existing.replace(' ', "").to_ascii_uppercase();
739 existing_norm != sqref
740 });
741 let removed = before.saturating_sub(list.len());
742
743 let mut dv = DataValidation::default();
744 dv.set_type(spec.kind.to_umya());
745 dv.get_sequence_of_references_mut().set_sqref(sqref.clone());
746
747 if let Some(allow_blank) = spec.allow_blank {
748 dv.set_allow_blank(allow_blank);
749 }
750
751 let formula1 = normalize_dv_formula("formula1", &spec.formula1, warnings);
753 dv.set_formula1(formula1);
754 if let Some(f2) = spec.formula2.as_ref() {
755 let formula2 = normalize_dv_formula("formula2", f2, warnings);
756 if !formula2.is_empty() {
757 dv.set_formula2(formula2);
758 }
759 }
760
761 match spec.kind {
763 DataValidationKind::Whole | DataValidationKind::Decimal | DataValidationKind::Date => {
764 let op = if spec.formula2.as_ref().is_some_and(|s| !s.trim().is_empty()) {
765 DataValidationOperatorValues::Between
766 } else {
767 DataValidationOperatorValues::Equal
768 };
769 dv.set_operator(op);
770 }
771 DataValidationKind::List | DataValidationKind::Custom => {}
772 }
773
774 if let Some(prompt) = spec.prompt.as_ref() {
775 dv.set_show_input_message(true);
776 if !prompt.title.is_empty() {
777 dv.set_prompt_title(prompt.title.clone());
778 }
779 if !prompt.message.is_empty() {
780 dv.set_prompt(prompt.message.clone());
781 }
782 }
783
784 if let Some(error) = spec.error.as_ref() {
785 dv.set_show_error_message(true);
786 if !error.title.is_empty() {
787 dv.set_error_title(error.title.clone());
788 }
789 if !error.message.is_empty() {
790 dv.set_error_message(error.message.clone());
791 }
792 }
793
794 dvs.add_data_validation_list(dv);
795
796 Ok((1, if removed > 0 { 1 } else { 0 }))
797}
798
799impl DataValidationKind {
800 fn to_umya(self) -> DataValidationValues {
801 match self {
802 DataValidationKind::List => DataValidationValues::List,
803 DataValidationKind::Whole => DataValidationValues::Whole,
804 DataValidationKind::Decimal => DataValidationValues::Decimal,
805 DataValidationKind::Date => DataValidationValues::Date,
806 DataValidationKind::Custom => DataValidationValues::Custom,
807 }
808 }
809}