Skip to main content

spreadsheet_kit/tools/
rules_batch.rs

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