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