Skip to main content

spreadsheet_mcp/tools/
rules_batch.rs

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>, // preview|apply (default apply)
27    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(&params.fork_id)?;
169    let work_path = fork_ctx.work_path.clone();
170
171    // Validate sheet names early against current fork snapshot.
172    let fork_workbook_id = WorkbookId(params.fork_id.clone());
173    let workbook = state.open_workbook(&fork_workbook_id).await?;
174    for op in &params.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(&params.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(&params.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    // DV sqref is space-separated list of ranges; v1 uses a single range.
419    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    // Defaults aim for determinism and readability.
472    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    // Deduplicate exact matches (sqref + kind/operator + formula).
480    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    // Defaults aim for determinism and readability.
629    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    // If already exactly set (one cf block, one rule, matches core + style), skip.
636    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    // Remove all existing CF blocks targeting the same sqref.
662    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    // Remove any existing validations targeting the same sqref.
734    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    // Excel stores DV formulas without a leading '=' in OOXML.
752    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    // Operator: keep surface minimal; default to between when formula2 is provided.
762    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}