1use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10
11use crate::Kind;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum OptionType {
17 String,
18 Char,
20 Boolean,
21 Integer,
23 Number,
24 Strings,
26 List,
27 Map,
28 Any,
30}
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct OptionField {
35 pub name: String,
36 #[serde(rename = "type")]
37 pub ty: OptionType,
38 #[serde(default)]
39 pub description: String,
40 #[serde(default)]
41 pub required: bool,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub default: Option<Value>,
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 pub choices: Vec<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub min: Option<f64>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub max: Option<f64>,
52}
53
54impl OptionField {
55 pub fn new(name: &str, ty: OptionType, description: &str) -> Self {
56 OptionField {
57 name: name.into(),
58 ty,
59 description: description.into(),
60 required: false,
61 default: None,
62 choices: Vec::new(),
63 min: None,
64 max: None,
65 }
66 }
67 #[must_use]
68 pub fn required(mut self) -> Self {
69 self.required = true;
70 self
71 }
72 #[must_use]
73 pub fn default(mut self, v: impl Into<Value>) -> Self {
74 self.default = Some(v.into());
75 self
76 }
77 #[must_use]
78 pub fn choices(mut self, c: &[&str]) -> Self {
79 self.choices = c.iter().map(|s| s.to_string()).collect();
80 self
81 }
82 #[must_use]
83 pub fn range(mut self, min: Option<f64>, max: Option<f64>) -> Self {
84 self.min = min;
85 self.max = max;
86 self
87 }
88}
89
90pub fn is_template(s: &str) -> bool {
93 s.contains("{{") || s.contains("{%")
94}
95
96fn show(s: &str) -> String {
98 if s.chars().any(char::is_control) {
99 Value::String(s.into()).to_string()
100 } else {
101 format!("`{s}`")
102 }
103}
104
105fn one_of(choices: &[String]) -> String {
106 let shown: Vec<String> = choices.iter().map(|c| show(c)).collect();
107 match shown.as_slice() {
108 [a, b] => format!("{a} or {b}"),
109 _ => format!("one of {}", shown.join(", ")),
110 }
111}
112
113fn number(n: f64) -> String {
114 if n.fract() == 0.0 {
115 format!("{}", n as i64)
116 } else {
117 n.to_string()
118 }
119}
120
121pub fn check(kind: Kind, name: &str, fields: &[OptionField], options: &Map<String, Value>) -> Vec<String> {
123 let mut errs = Vec::new();
124 for k in options.keys() {
125 if fields.iter().any(|f| &f.name == k) {
126 continue;
127 }
128 errs.push(if fields.is_empty() {
129 format!("the `{name}` {kind} takes no options, but got `{k}`; check the key's spelling")
130 } else {
131 let known: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
132 format!(
133 "unknown option `{k}` for {kind} `{name}`; expected one of {}",
134 known.join(", ")
135 )
136 });
137 }
138 for f in fields {
139 let v = match options.get(&f.name) {
140 None | Some(Value::Null) => {
141 if f.required {
142 errs.push(format!("`{}` is required", f.name));
143 }
144 continue;
145 }
146 Some(v) => v,
147 };
148 if v.as_str().is_some_and(is_template) {
149 continue;
150 }
151 if let Some(e) = check_value(f, v) {
152 errs.push(format!("`{}` {e}", f.name));
153 }
154 }
155 errs
156}
157
158fn check_value(f: &OptionField, v: &Value) -> Option<String> {
159 let ok = match f.ty {
160 OptionType::String => v.is_string(),
161 OptionType::Char => v.as_str().is_some_and(|s| s.chars().count() == 1),
162 OptionType::Boolean => v.is_boolean(),
163 OptionType::Integer => v.is_i64() || v.is_u64(),
164 OptionType::Number => v.is_number(),
165 OptionType::Strings => v.is_string() || v.as_array().is_some_and(|a| a.iter().all(Value::is_string)),
166 OptionType::List => v.is_array(),
167 OptionType::Map => v.is_object(),
168 OptionType::Any => true,
169 };
170 let bounded = matches!(f.ty, OptionType::Integer | OptionType::Number);
171 let in_range = v
172 .as_f64()
173 .is_none_or(|n| f.min.is_none_or(|m| n >= m) && f.max.is_none_or(|m| n <= m));
174 if !ok || (bounded && !in_range) {
175 let what = match f.ty {
176 OptionType::String => "a string",
177 OptionType::Char => "a single character",
178 OptionType::Boolean => "true or false",
179 OptionType::Integer => "a whole number",
180 OptionType::Number => "a number",
181 OptionType::Strings => "a string or a list of strings",
182 OptionType::List => "a list",
183 OptionType::Map => "a map",
184 OptionType::Any => "",
185 };
186 let bounds = match (bounded, f.min, f.max) {
187 (true, Some(a), Some(b)) => format!(" from {} to {}", number(a), number(b)),
188 (true, Some(a), None) => format!(" of at least {}", number(a)),
189 (true, None, Some(b)) => format!(" of at most {}", number(b)),
190 _ => String::new(),
191 };
192 return Some(format!("must be {what}{bounds}"));
193 }
194 if !f.choices.is_empty()
195 && let Some(s) = v.as_str()
196 && !f.choices.iter().any(|c| c == s)
197 {
198 return Some(format!("must be {}", one_of(&f.choices)));
199 }
200 None
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum NumFormatClass {
206 Number,
208 DateTime,
210 Text,
212}
213
214impl NumFormatClass {
215 pub fn describe(self) -> &'static str {
216 match self {
217 NumFormatClass::Number => "a number format",
218 NumFormatClass::DateTime => "a date/time format",
219 NumFormatClass::Text => "a text format",
220 }
221 }
222}
223
224const LITERALS: &str = "$-+/():!^&'~{}<>= ";
226
227pub fn check_num_format(code: &str) -> Result<NumFormatClass, String> {
231 if code.trim().is_empty() {
232 return Err("is empty".into());
233 }
234 let mut sections = vec![String::new()];
235 let mut chars = code.chars().peekable();
236 let mut elapsed = vec![false];
238 while let Some(c) = chars.next() {
239 match c {
240 '"' => loop {
241 match chars.next() {
242 Some('"') => break,
243 Some(_) => {}
244 None => return Err("has an unclosed `\"` quote".into()),
245 }
246 },
247 '[' => {
248 let mut inner = String::new();
249 loop {
250 match chars.next() {
251 Some(']') => break,
252 Some('[') => return Err("has a `[` inside `[ ]`".into()),
253 Some(ch) => inner.push(ch),
254 None => return Err("has an unclosed `[` bracket".into()),
255 }
256 }
257 let lower = inner.to_ascii_lowercase();
258 if !lower.is_empty()
259 && ["h", "m", "s"]
260 .iter()
261 .any(|u| lower.chars().all(|ch| ch.to_string() == *u))
262 && let Some(e) = elapsed.last_mut()
263 {
264 *e = true;
265 }
266 }
267 ']' => return Err("has a `]` without a matching `[`".into()),
268 '\\' | '_' | '*' => {
269 if chars.next().is_none() {
270 return Err(format!("ends with `{c}`, which needs a character after it"));
271 }
272 }
273 ';' => {
274 sections.push(String::new());
275 elapsed.push(false);
276 }
277 _ => {
278 if let Some(s) = sections.last_mut() {
279 s.push(c);
280 }
281 }
282 }
283 }
284 if sections.len() > 4 {
285 return Err(format!(
286 "has {} `;` sections; Excel allows at most four (positive;negative;zero;text)",
287 sections.len()
288 ));
289 }
290 let mut class = None;
291 for (s, elapsed) in sections.iter().zip(elapsed) {
292 let general = s.to_ascii_lowercase().contains("general");
293 let s = remove_general(s);
294 let mut date = elapsed;
295 let mut number = general;
296 let upper = s.to_ascii_uppercase();
297 let mut rest = upper.as_str();
298 while let Some(c) = rest.chars().next() {
299 if rest.starts_with("AM/PM") {
300 date = true;
301 rest = &rest[5..];
302 continue;
303 }
304 if rest.starts_with("A/P") {
305 date = true;
306 rest = &rest[3..];
307 continue;
308 }
309 match c {
310 '0' | '#' | '?' | '%' | '.' | ',' => number = true,
311 '1'..='9' => {}
312 'E' if rest[1..].starts_with(['+', '-']) => {
313 number = true;
314 rest = &rest[2..];
315 continue;
316 }
317 'Y' | 'M' | 'D' | 'H' | 'S' | 'E' | 'B' | 'G' => date = true,
318 '@' => {}
319 c if LITERALS.contains(c) => {}
320 c => {
321 return Err(format!(
322 "has `{c}`, which isn't part of an Excel number format; put literal text in double quotes"
323 ));
324 }
325 }
326 rest = &rest[c.len_utf8()..];
327 }
328 let this = if date {
329 Some(NumFormatClass::DateTime)
330 } else if number {
331 Some(NumFormatClass::Number)
332 } else {
333 None
334 };
335 class = class.or(this);
336 }
337 Ok(class.unwrap_or(NumFormatClass::Text))
338}
339
340fn remove_general(s: &str) -> String {
342 let lower = s.to_ascii_lowercase();
343 if !lower.contains("general") {
344 return s.to_string();
345 }
346 let mut out = String::new();
347 let mut i = 0;
348 while i < s.len() {
349 if lower[i..].starts_with("general") {
350 i += "general".len();
351 } else {
352 let c = s[i..]
353 .chars()
354 .next()
355 .expect("i is below the length, on a char boundary");
356 out.push(c);
357 i += c.len_utf8();
358 }
359 }
360 out
361}
362
363pub const COLUMN_OPTION_KEYS: &[&str] = &["format", "formula", "total", "width", "style"];
365
366pub const TOTAL_FUNCTIONS: &[(&str, &str)] = &[
368 ("sum", "SUM"),
369 ("average", "AVERAGE"),
370 ("count", "COUNTA"),
371 ("min", "MIN"),
372 ("max", "MAX"),
373];
374
375#[derive(Debug, Clone, PartialEq, Eq)]
377pub enum FormulaPart {
378 Text(String),
380 Cell(String),
382 Column(String),
384}
385
386pub fn parse_formula(f: &str) -> Result<Vec<FormulaPart>, String> {
389 if !f.starts_with('=') {
390 return Err("must start with `=`".into());
391 }
392 let mut parts = Vec::new();
393 let mut text = String::new();
394 let mut chars = f.chars().peekable();
395 while let Some(c) = chars.next() {
396 match c {
397 '{' if chars.peek() == Some(&'{') => {
398 chars.next();
399 text.push('{');
400 }
401 '}' if chars.peek() == Some(&'}') => {
402 chars.next();
403 text.push('}');
404 }
405 '}' => return Err("has a `}` without a matching `{` (write `}}` for a literal brace)".into()),
406 '{' => {
407 let mut name = String::new();
408 loop {
409 match chars.next() {
410 Some('}') => break,
411 Some('{') => return Err("has a `{` inside `{ }`".into()),
412 Some(ch) => name.push(ch),
413 None => return Err("has an unclosed `{`".into()),
414 }
415 }
416 if !text.is_empty() {
417 parts.push(FormulaPart::Text(std::mem::take(&mut text)));
418 }
419 let part = match name.strip_suffix(":*") {
420 Some(col) => FormulaPart::Column(col.trim().to_string()),
421 None => FormulaPart::Cell(name.trim().to_string()),
422 };
423 if matches!(&part, FormulaPart::Cell(n) | FormulaPart::Column(n) if n.is_empty()) {
424 return Err("has an empty `{}` reference".into());
425 }
426 parts.push(part);
427 }
428 _ => text.push(c),
429 }
430 }
431 if !text.is_empty() {
432 parts.push(FormulaPart::Text(text));
433 }
434 Ok(parts)
435}
436
437pub fn check_row_formula(f: &str) -> Result<Vec<FormulaPart>, String> {
439 let parts = parse_formula(f)?;
440 if let Some(FormulaPart::Column(n)) = parts.iter().find(|p| matches!(p, FormulaPart::Column(_))) {
441 return Err(format!(
442 "uses `{{{n}:*}}`, a whole column, which only a `total` can use; a row formula refers to cells on its own row, like `{{{n}}}`"
443 ));
444 }
445 Ok(parts)
446}
447
448pub fn check_total(t: &str) -> Result<(), String> {
450 if t.starts_with('=') {
451 let parts = parse_formula(t)?;
452 if let Some(FormulaPart::Cell(n)) = parts.iter().find(|p| matches!(p, FormulaPart::Cell(_))) {
453 return Err(format!(
454 "uses `{{{n}}}`, a cell on the same row, which a totals row doesn't have; use `{{{n}:*}}` for the whole column"
455 ));
456 }
457 return Ok(());
458 }
459 if TOTAL_FUNCTIONS.iter().any(|(k, _)| *k == t) {
460 return Ok(());
461 }
462 Err(format!(
463 "must be one of {} or a formula starting with `=`",
464 TOTAL_FUNCTIONS
465 .iter()
466 .map(|(k, _)| format!("`{k}`"))
467 .collect::<Vec<_>>()
468 .join(", ")
469 ))
470}
471
472pub fn parse_columns(
476 v: &Value,
477) -> (
478 std::collections::BTreeMap<String, crate::msg::ColumnOptions>,
479 Vec<String>,
480) {
481 let mut out = std::collections::BTreeMap::new();
482 let mut errs = Vec::new();
483 let Some(m) = v.as_object() else {
484 errs.push("`columns` must be a map from column name to options like `{format: \"#,##0.00\"}`".into());
485 return (out, errs);
486 };
487 for (name, opts) in m {
488 let Some(o) = opts.as_object() else {
489 errs.push(format!(
490 "column `{name}` must be a map of options like `{{format: \"#,##0.00\"}}`"
491 ));
492 continue;
493 };
494 let mut col = crate::msg::ColumnOptions::default();
495 for (k, val) in o {
496 match k.as_str() {
497 "format" => match val.as_str() {
498 Some(code) => match check_num_format(code) {
499 Ok(_) => col.format = Some(code.to_string()),
500 Err(e) => errs.push(format!("column `{name}`: format `{code}` {e}")),
501 },
502 None => errs.push(format!("column `{name}`: `format` must be a string")),
503 },
504 "formula" => match val.as_str() {
505 Some(f) => match check_row_formula(f) {
506 Ok(_) => col.formula = Some(f.to_string()),
507 Err(e) => errs.push(format!("column `{name}`: formula `{f}` {e}")),
508 },
509 None => errs.push(format!("column `{name}`: `formula` must be a string")),
510 },
511 "style" => {
512 let (s, e) = crate::style::parse_cell(val);
513 errs.extend(e.into_iter().map(|e| format!("column `{name}`: `style`: {e}")));
514 col.style = Some(s);
515 }
516 "width" => match (val, val.as_f64().filter(|w| *w > 0.0 && *w <= 255.0)) {
517 (Value::String(s), _) if s == "auto" => {
518 col.width = Some(crate::msg::ColumnWidth::Auto(crate::msg::AutoWidth::Auto))
519 }
520 (Value::Number(_), Some(w)) => col.width = Some(crate::msg::ColumnWidth::Chars(w)),
521 _ => errs.push(format!(
522 "column `{name}`: `width` must be `auto` or a number of characters from 1 to 255"
523 )),
524 },
525 "total" => match val.as_str() {
526 Some(t) => match check_total(t) {
527 Ok(()) => col.total = Some(t.to_string()),
528 Err(e) => errs.push(format!("column `{name}`: total `{t}` {e}")),
529 },
530 None => errs.push(format!("column `{name}`: `total` must be a string")),
531 },
532 _ => errs.push(format!(
533 "column `{name}`: unknown key `{k}`; expected {}",
534 COLUMN_OPTION_KEYS
535 .iter()
536 .map(|k| format!("`{k}`"))
537 .collect::<Vec<_>>()
538 .join(", ")
539 )),
540 }
541 }
542 out.insert(name.clone(), col);
543 }
544 (out, errs)
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550 use serde_json::json;
551
552 fn fields() -> Vec<OptionField> {
553 vec![
554 OptionField::new("sep", OptionType::Char, ""),
555 OptionField::new("mode", OptionType::String, "").choices(&["a", "b", "c"]),
556 OptionField::new("eol", OptionType::String, "").choices(&["\n", "\r\n"]),
557 OptionField::new("rows", OptionType::Integer, "").range(Some(1.0), Some(10.0)),
558 OptionField::new("to", OptionType::Strings, ""),
559 OptionField::new("cols", OptionType::List, "").required(),
560 ]
561 }
562
563 fn errs(o: Value) -> Vec<String> {
564 let Value::Object(o) = o else { panic!() };
565 check(Kind::Format, "x", &fields(), &o)
566 }
567
568 #[test]
569 fn accepts_valid_options() {
570 assert!(
571 errs(json!({"sep": "|", "mode": "b", "eol": "\n", "rows": 3, "to": ["a"], "cols": []}))
572 .is_empty()
573 );
574 }
575
576 #[test]
577 fn reports_every_problem() {
578 assert_eq!(
579 errs(json!({"sep": "||", "mode": "z", "eol": "\r", "rows": 11, "to": [1], "extra": 1})),
580 vec![
581 "unknown option `extra` for format `x`; expected one of sep, mode, eol, rows, to, cols",
582 "`sep` must be a single character",
583 "`mode` must be one of `a`, `b`, `c`",
584 r#"`eol` must be "\n" or "\r\n""#,
585 "`rows` must be a whole number from 1 to 10",
586 "`to` must be a string or a list of strings",
587 "`cols` is required",
588 ]
589 );
590 }
591
592 #[test]
593 fn templated_values_are_left_for_the_plugin() {
594 assert!(errs(json!({"mode": "{{ var('m') }}", "cols": []})).is_empty());
595 }
596
597 #[test]
598 fn a_plugin_without_options_refuses_any() {
599 let Value::Object(o) = json!({"to": "x"}) else {
600 panic!()
601 };
602 assert_eq!(
603 check(Kind::Destination, "sftp", &[], &o),
604 vec!["the `sftp` destination takes no options, but got `to`; check the key's spelling"]
605 );
606 }
607
608 #[test]
609 fn number_format_codes() {
610 use NumFormatClass::*;
611 let ok = [
612 ("General", Number),
613 ("0", Number),
614 ("#,##0.00", Number),
615 ("0.0%", Number),
616 ("0.00E+00", Number),
617 ("# ?/?", Number),
618 ("[$€-x-euro2] #,##0.00", Number),
619 ("[$$-409]#,##0.00", Number),
620 ("#,##0.00;[Red](#,##0.00)", Number),
621 ("#,##0;(#,##0);\"-\";@", Number),
622 ("_(* #,##0.00_);_(* (#,##0.00);_(* \"-\"??_);_(@_)", Number),
623 ("[>=1000]#,##0;0", Number),
624 ("\\€#,##0", Number),
625 ("yyyy-mm-dd", DateTime),
626 ("dd/mm/yyyy", DateTime),
627 ("mmm yyyy", DateTime),
628 ("yyyy-mm-dd hh:mm:ss", DateTime),
629 ("h:mm AM/PM", DateTime),
630 ("h:mm A/P", DateTime),
631 ("[h]:mm:ss", DateTime),
632 ("[mm]", DateTime),
633 ("hh:mm:ss.000", DateTime),
634 ("@", Text),
635 ("\"Total: \"@", Text),
636 ];
637 for (code, class) in ok {
638 assert_eq!(check_num_format(code), Ok(class), "{code}");
639 }
640 let bad = [
641 ("", "is empty"),
642 (
643 "0;0;0;@;0",
644 "has 5 `;` sections; Excel allows at most four (positive;negative;zero;text)",
645 ),
646 ("\"abc", "has an unclosed `\"` quote"),
647 ("[Red#,##0", "has an unclosed `[` bracket"),
648 ("#,##0]", "has a `]` without a matching `[`"),
649 ("0\\", "ends with `\\`, which needs a character after it"),
650 (
651 "0 units",
652 "has `U`, which isn't part of an Excel number format; put literal text in double quotes",
653 ),
654 ];
655 for (code, err) in bad {
656 assert_eq!(check_num_format(code), Err(err.to_string()), "{code}");
657 }
658 }
659
660 #[test]
661 fn column_maps() {
662 let (cols, errs) = parse_columns(
663 &json!({"a": {"format": "0.0%"}, "b": {"fromat": "0"}, "c": "0", "d": {"format": "\"x"}}),
664 );
665 assert_eq!(cols["a"].format.as_deref(), Some("0.0%"));
666 assert_eq!(
667 errs,
668 vec![
669 "column `b`: unknown key `fromat`; expected `format`, `formula`, `total`, `width`, `style`",
670 "column `c` must be a map of options like `{format: \"#,##0.00\"}`",
671 "column `d`: format `\"x` has an unclosed `\"` quote",
672 ]
673 );
674 }
675
676 #[test]
677 fn formulas() {
678 use FormulaPart::*;
679 assert_eq!(
680 parse_formula("={qty}*{ price }+{{1}}").unwrap(),
681 vec![
682 Text("=".into()),
683 Cell("qty".into()),
684 Text("*".into()),
685 Cell("price".into()),
686 Text("+{1}".into())
687 ]
688 );
689 assert_eq!(
690 parse_formula("=SUM({amount:*})").unwrap(),
691 vec![Text("=SUM(".into()), Column("amount".into()), Text(")".into())]
692 );
693 for (f, err) in [
694 ("{a}*2", "must start with `=`"),
695 ("={a", "has an unclosed `{`"),
696 (
697 "=a}",
698 "has a `}` without a matching `{` (write `}}` for a literal brace)",
699 ),
700 ("={}", "has an empty `{}` reference"),
701 ("={a{b}}", "has a `{` inside `{ }`"),
702 ] {
703 assert_eq!(parse_formula(f), Err(err.to_string()), "{f}");
704 }
705 assert!(
706 check_row_formula("={a}/SUM({a:*})")
707 .unwrap_err()
708 .contains("only a `total`")
709 );
710 assert!(check_total("sum").is_ok());
711 assert!(check_total("=SUM({a:*})-MIN({b:*})").is_ok());
712 assert!(check_total("={a}").unwrap_err().contains("doesn't have"));
713 assert_eq!(
714 check_total("total"),
715 Err(
716 "must be one of `sum`, `average`, `count`, `min`, `max` or a formula starting with `=`"
717 .into()
718 )
719 );
720 let (cols, errs) = parse_columns(&json!({
721 "t": {"formula": "={a}*2", "format": "0.00", "total": "sum"},
722 "u": {"formula": "a*2"},
723 "v": {"total": "median"}
724 }));
725 assert_eq!(cols["t"].formula.as_deref(), Some("={a}*2"));
726 assert_eq!(cols["t"].total.as_deref(), Some("sum"));
727 assert_eq!(errs.len(), 2, "{errs:?}");
728 assert!(errs[0].starts_with("column `u`: formula `a*2` must start with `=`"));
729 assert!(errs[1].starts_with("column `v`: total `median` must be one of"));
730 }
731}