1use std::collections::HashSet;
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use super::{
19 CustomNumberFormatConfig, DatetimeFormatType, KeyValueOpts, NumberSeriesStyleDefaultConfig,
20};
21use crate::utils::{CssKind, GradientStopSpec, canonicalize_gradient_stops};
22
23#[derive(Clone, Debug, Default, Deserialize, Serialize)]
28pub struct ColumnConfigSchema {
29 pub fields: Vec<ControlSpec>,
30}
31
32impl ColumnConfigSchema {
33 pub fn active_keys(&self) -> HashSet<String> {
39 let mut out = HashSet::new();
40 for spec in &self.fields {
41 for k in spec.serialized_keys() {
42 out.insert(k.to_string());
43 }
44 }
45 out
46 }
47
48 pub fn leaf_fields(&self) -> Vec<&ControlSpec> {
49 fn collect<'a>(fields: &'a [ControlSpec], out: &mut Vec<&'a ControlSpec>) {
50 for spec in fields {
51 match spec {
52 ControlSpec::Group { fields, .. } => collect(fields, out),
53 leaf => out.push(leaf),
54 }
55 }
56 }
57
58 let mut out = vec![];
59 collect(&self.fields, &mut out);
60 out
61 }
62}
63
64#[derive(Clone, Debug, Deserialize, Serialize)]
70#[serde(tag = "kind")]
71pub enum ControlSpec {
72 Enum {
73 key: String,
74 variants: Vec<EnumVariant>,
75 default: String,
76 },
77 Bool {
78 key: String,
79 default: bool,
80 },
81 Number {
82 key: String,
83 default: f64,
84
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 include: Option<bool>,
88
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 min: Option<f64>,
91
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 max: Option<f64>,
94
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 step: Option<f64>,
97 },
98 String {
99 key: String,
100 default: String,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 placeholder: Option<String>,
103 },
104 Color {
105 key: String,
106 default: String,
107 },
108 Palette {
109 key: String,
110 default: String,
111
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 max: Option<usize>,
114 },
115 GradientStops {
116 key: String,
117 default: String,
118
119 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
120 discrete: bool,
121 },
122 DatetimeFormat {
123 #[serde(default, skip_serializing_if = "Option::is_none")]
126 default: Option<DatetimeFormatType>,
127 },
128 StringFormat,
129 NumberSeriesStyle {
130 default: NumberSeriesStyleDefaultConfig,
131 },
132 Symbols {
133 default: KeyValueOpts,
134 },
135 NumberFormat {
136 #[serde(default, skip_serializing_if = "Option::is_none")]
139 default: Option<CustomNumberFormatConfig>,
140 },
141 AggregateDepth,
142
143 Group {
144 key: String,
145 #[serde(default)]
146 fields: Vec<ControlSpec>,
147 },
148}
149
150#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
151pub struct EnumVariant {
152 pub value: String,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub label: Option<String>,
155}
156
157pub fn discrete_pair(stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
160 let stops = canonicalize_gradient_stops(stops);
161 match (stops.first(), stops.last()) {
162 (Some(first), Some(last)) if stops.len() > 2 => vec![
163 GradientStopSpec {
164 color: first.color.clone(),
165 offset: 0.0,
166 },
167 GradientStopSpec {
168 color: last.color.clone(),
169 offset: 1.0,
170 },
171 ],
172 _ => stops,
173 }
174}
175
176impl ColumnConfigSchema {
177 pub fn canonicalize(self) -> Self {
178 self.canonicalize_defaults().group_format_controls()
179 }
180
181 pub fn group_format_controls(mut self) -> Self {
182 fn is_format(spec: &ControlSpec) -> bool {
183 matches!(
184 spec,
185 ControlSpec::NumberFormat { .. }
186 | ControlSpec::DatetimeFormat { .. }
187 | ControlSpec::StringFormat
188 )
189 }
190
191 fn walk(fields: &mut Vec<ControlSpec>) {
192 for spec in fields.iter_mut() {
193 if let ControlSpec::Group { key, fields } = spec
194 && key != "format"
195 {
196 walk(fields);
197 }
198 }
199
200 let first = fields.iter().position(is_format);
201 if let Some(first) = first {
202 let mut formats = vec![];
203 let mut i = first;
204 while i < fields.len() {
205 if is_format(&fields[i]) {
206 formats.push(fields.remove(i));
207 } else {
208 i += 1;
209 }
210 }
211
212 fields.insert(first, ControlSpec::Group {
213 key: "format".to_owned(),
214 fields: formats,
215 });
216 }
217 }
218
219 walk(&mut self.fields);
220 self
221 }
222
223 pub fn canonicalize_defaults(mut self) -> Self {
226 fn canonicalize_specs(fields: &mut Vec<ControlSpec>) {
227 fields.retain_mut(|spec| {
228 let (kind, key, default) = match spec {
229 ControlSpec::Group { fields, .. } => {
230 canonicalize_specs(fields);
231 return !fields.is_empty();
232 },
233 ControlSpec::Color { key, default } => (CssKind::Color, key, default),
234 ControlSpec::Palette { key, default, .. } => (CssKind::Palette, key, default),
235 ControlSpec::GradientStops { key, default, .. } => {
236 (CssKind::Gradient, key, default)
237 },
238 _ => return true,
239 };
240
241 match kind.canonicalize(default) {
242 Ok(canonical) => {
243 *default = canonical;
244 true
245 },
246 Err(error) => {
247 tracing::error!("Dropping `{key}` — invalid schema default: {error}");
248 false
249 },
250 }
251 });
252 }
253
254 canonicalize_specs(&mut self.fields);
255 self
256 }
257
258 pub fn css_kind_of(&self, key: &str) -> Option<CssKind> {
260 self.leaf_fields().into_iter().find_map(|spec| match spec {
261 ControlSpec::Color { key: k, .. } if k == key => Some(CssKind::Color),
262 ControlSpec::Palette { key: k, .. } if k == key => Some(CssKind::Palette),
263 ControlSpec::GradientStops { key: k, .. } if k == key => Some(CssKind::Gradient),
264 _ => None,
265 })
266 }
267}
268
269impl ControlSpec {
270 pub fn serialized_keys(&self) -> Vec<&str> {
276 match self {
277 ControlSpec::DatetimeFormat { .. } => vec!["date_format"],
278 ControlSpec::StringFormat => vec!["format"],
279 ControlSpec::NumberSeriesStyle { .. } => vec!["chart_type", "stack"],
280 ControlSpec::Symbols { .. } => vec!["symbols"],
281 ControlSpec::NumberFormat { .. } => vec!["number_format"],
282 ControlSpec::AggregateDepth => vec!["aggregate_depth"],
283 ControlSpec::Enum { key, .. }
284 | ControlSpec::Bool { key, .. }
285 | ControlSpec::Number { key, .. }
286 | ControlSpec::String { key, .. }
287 | ControlSpec::Color { key, .. }
288 | ControlSpec::Palette { key, .. }
289 | ControlSpec::GradientStops { key, .. } => vec![key.as_str()],
290 ControlSpec::Group { fields, .. } => {
291 fields.iter().flat_map(|f| f.serialized_keys()).collect()
292 },
293 }
294 }
295}
296
297#[derive(Clone, Debug, Deserialize, Serialize)]
302pub struct ColumnConfigFieldUpdate {
303 pub keys: Vec<String>,
304 pub value: serde_json::Map<String, Value>,
305}
306
307pub fn filter_to_schema(
311 config: &serde_json::Map<String, Value>,
312 active_keys: &HashSet<String>,
313) -> serde_json::Map<String, Value> {
314 config
315 .iter()
316 .filter(|(k, _)| active_keys.contains(k.as_str()))
317 .map(|(k, v)| (k.clone(), v.clone()))
318 .collect()
319}
320
321#[cfg(test)]
322mod tests {
323 use serde_json::json;
324
325 use super::*;
326
327 fn color(key: &str, default: &str) -> ControlSpec {
328 ControlSpec::Color {
329 key: key.to_owned(),
330 default: default.to_owned(),
331 }
332 }
333
334 fn flag(key: &str) -> ControlSpec {
335 ControlSpec::Bool {
336 key: key.to_owned(),
337 default: false,
338 }
339 }
340
341 fn group(key: &str, fields: Vec<ControlSpec>) -> ControlSpec {
342 ControlSpec::Group {
343 key: key.to_owned(),
344 fields,
345 }
346 }
347
348 #[test]
349 fn group_deserializes_recursively() {
350 let schema: ColumnConfigSchema = serde_json::from_value(json!({
351 "fields": [{
352 "kind": "Group",
353 "key": "legend",
354 "fields": [
355 { "kind": "Bool", "key": "legend_on", "default": false },
356 {
357 "kind": "Group",
358 "key": "inner",
359 "fields": [{ "kind": "Color", "key": "color", "default": "#ff0000" }]
360 }
361 ]
362 }]
363 }))
364 .unwrap();
365
366 let keys = schema.active_keys();
367 assert_eq!(
368 keys,
369 HashSet::from(["legend_on".to_owned(), "color".to_owned()])
370 );
371
372 let leaves = schema.leaf_fields();
373 assert_eq!(leaves.len(), 2);
374 assert!(
375 leaves
376 .iter()
377 .all(|s| !matches!(s, ControlSpec::Group { .. }))
378 );
379 }
380
381 #[test]
382 fn grouped_schema_is_equivalent_to_flat() {
383 let flat = ColumnConfigSchema {
384 fields: vec![flag("stack"), color("color", "#0366d6")],
385 };
386
387 let grouped = ColumnConfigSchema {
388 fields: vec![group("series", vec![
389 flag("stack"),
390 color("color", "#0366d6"),
391 ])],
392 };
393
394 assert_eq!(flat.active_keys(), grouped.active_keys());
395 assert_eq!(flat.css_kind_of("color"), grouped.css_kind_of("color"));
396 assert_eq!(flat.css_kind_of("stack"), grouped.css_kind_of("stack"));
397 }
398
399 #[test]
400 fn format_controls_group_and_merge() {
401 let schema = ColumnConfigSchema {
402 fields: vec![
403 ControlSpec::NumberFormat { default: None },
404 flag("flag"),
405 ControlSpec::StringFormat,
406 ],
407 }
408 .group_format_controls();
409
410 assert_eq!(schema.fields.len(), 2);
411 let ControlSpec::Group { key, fields } = &schema.fields[0] else {
412 panic!("expected format group first");
413 };
414
415 assert_eq!(key, "format");
416 assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
417 assert!(matches!(fields[1], ControlSpec::StringFormat));
418 assert!(matches!(&schema.fields[1], ControlSpec::Bool { .. }));
419
420 assert_eq!(
421 schema.active_keys(),
422 HashSet::from([
423 "number_format".to_owned(),
424 "format".to_owned(),
425 "flag".to_owned()
426 ])
427 );
428 }
429
430 #[test]
431 fn format_grouping_recurses_but_never_double_wraps() {
432 let schema = ColumnConfigSchema {
433 fields: vec![
434 group("format", vec![ControlSpec::NumberFormat { default: None }]),
435 group("styling", vec![flag("x"), ControlSpec::DatetimeFormat {
436 default: None,
437 }]),
438 ],
439 }
440 .group_format_controls();
441
442 let ControlSpec::Group { key, fields } = &schema.fields[0] else {
443 panic!("expected group");
444 };
445
446 assert_eq!(key, "format");
447 assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
448
449 let ControlSpec::Group { fields, .. } = &schema.fields[1] else {
450 panic!("expected group");
451 };
452
453 assert!(matches!(
454 &fields[1],
455 ControlSpec::Group { key, fields }
456 if key == "format" && matches!(fields[0], ControlSpec::DatetimeFormat { .. })
457 ));
458 }
459
460 #[test]
461 fn format_controls_deserialize_without_default_payload() {
462 let schema: ColumnConfigSchema = serde_json::from_value(json!({
463 "fields": [{ "kind": "NumberFormat" }, { "kind": "DatetimeFormat" }]
464 }))
465 .unwrap();
466
467 assert!(matches!(&schema.fields[0], ControlSpec::NumberFormat {
468 default: None
469 }));
470 assert!(matches!(&schema.fields[1], ControlSpec::DatetimeFormat {
471 default: None
472 }));
473 }
474
475 #[test]
476 fn number_format_default_payload_deserializes_flattened_families() {
477 let schema: ColumnConfigSchema = serde_json::from_value(json!({
478 "fields": [{
479 "kind": "NumberFormat",
480 "default": {
481 "notation": "compact",
482 "compactDisplay": "short",
483 "minimumFractionDigits": 0,
484 "maximumFractionDigits": 1
485 }
486 }]
487 }))
488 .unwrap();
489
490 let ControlSpec::NumberFormat {
491 default: Some(default),
492 } = &schema.fields[0]
493 else {
494 panic!("expected NumberFormat with default");
495 };
496
497 assert_eq!(
498 default._notation,
499 Some(crate::config::Notation::Compact(
500 crate::config::CompactDisplay::Short
501 ))
502 );
503 assert_eq!(default._style, None);
504 assert_eq!(default.minimum_fraction_digits, Some(0.));
505 assert_eq!(default.maximum_fraction_digits, Some(1.));
506 assert_eq!(
507 schema.active_keys(),
508 HashSet::from(["number_format".to_owned()])
509 );
510 }
511
512 #[test]
513 fn datetime_format_default_payload_deserializes_simple_arm() {
514 let schema: ColumnConfigSchema = serde_json::from_value(json!({
515 "fields": [{
516 "kind": "DatetimeFormat",
517 "default": { "dateStyle": "medium", "timeStyle": "disabled" }
518 }]
519 }))
520 .unwrap();
521
522 let ControlSpec::DatetimeFormat {
523 default: Some(DatetimeFormatType::Simple(simple)),
524 } = &schema.fields[0]
525 else {
526 panic!("expected DatetimeFormat with Simple default");
527 };
528
529 assert_eq!(
530 simple.date_style,
531 crate::config::SimpleDatetimeFormat::Medium
532 );
533 assert_eq!(
534 simple.time_style,
535 crate::config::SimpleDatetimeFormat::Disabled
536 );
537 }
538
539 #[test]
540 fn canonicalize_defaults_recurses_and_drops_empty_groups() {
541 let schema = ColumnConfigSchema {
542 fields: vec![
543 group("ok", vec![color("good", "RGB(255,0,0)"), flag("flag")]),
544 group("doomed", vec![color("bad", "not-a-color")]),
545 ],
546 }
547 .canonicalize_defaults();
548
549 assert_eq!(schema.fields.len(), 1);
550 let ControlSpec::Group { key, fields } = &schema.fields[0] else {
551 panic!("expected group");
552 };
553
554 assert_eq!(key, "ok");
555 assert!(matches!(
556 &fields[0],
557 ControlSpec::Color { default, .. } if default == "#ff0000"
558 ));
559 }
560}