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", "width", "style"];
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 "style" => {
500 let (s, e) = crate::style::parse_cell(val);
501 errs.extend(e.into_iter().map(|e| format!("column `{name}`: `style`: {e}")));
502 col.style = Some(s);
503 }
504 "width" => match val {
505 Value::String(s) if s == "auto" => {
506 col.width = Some(crate::msg::ColumnWidth::Auto(crate::msg::AutoWidth::Auto))
507 }
508 Value::Number(n) if n.as_f64().is_some_and(|w| w > 0.0 && w <= 255.0) => {
509 col.width = Some(crate::msg::ColumnWidth::Chars(n.as_f64().unwrap()))
510 }
511 _ => errs.push(format!(
512 "column `{name}`: `width` must be `auto` or a number of characters from 1 to 255"
513 )),
514 },
515 "total" => match val.as_str() {
516 Some(t) => match check_total(t) {
517 Ok(()) => col.total = Some(t.to_string()),
518 Err(e) => errs.push(format!("column `{name}`: total `{t}` {e}")),
519 },
520 None => errs.push(format!("column `{name}`: `total` must be a string")),
521 },
522 _ => errs.push(format!(
523 "column `{name}`: unknown key `{k}`; expected {}",
524 COLUMN_OPTION_KEYS
525 .iter()
526 .map(|k| format!("`{k}`"))
527 .collect::<Vec<_>>()
528 .join(", ")
529 )),
530 }
531 }
532 out.insert(name.clone(), col);
533 }
534 (out, errs)
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use serde_json::json;
541
542 fn fields() -> Vec<OptionField> {
543 vec![
544 OptionField::new("sep", OptionType::Char, ""),
545 OptionField::new("mode", OptionType::String, "").choices(&["a", "b", "c"]),
546 OptionField::new("eol", OptionType::String, "").choices(&["\n", "\r\n"]),
547 OptionField::new("rows", OptionType::Integer, "").range(Some(1.0), Some(10.0)),
548 OptionField::new("to", OptionType::Strings, ""),
549 OptionField::new("cols", OptionType::List, "").required(),
550 ]
551 }
552
553 fn errs(o: Value) -> Vec<String> {
554 let Value::Object(o) = o else { panic!() };
555 check(Kind::Format, "x", &fields(), &o)
556 }
557
558 #[test]
559 fn accepts_valid_options() {
560 assert!(
561 errs(json!({"sep": "|", "mode": "b", "eol": "\n", "rows": 3, "to": ["a"], "cols": []}))
562 .is_empty()
563 );
564 }
565
566 #[test]
567 fn reports_every_problem() {
568 assert_eq!(
569 errs(json!({"sep": "||", "mode": "z", "eol": "\r", "rows": 11, "to": [1], "extra": 1})),
570 vec![
571 "unknown option `extra` for format `x`; expected one of sep, mode, eol, rows, to, cols",
572 "`sep` must be a single character",
573 "`mode` must be one of `a`, `b`, `c`",
574 r#"`eol` must be "\n" or "\r\n""#,
575 "`rows` must be a whole number from 1 to 10",
576 "`to` must be a string or a list of strings",
577 "`cols` is required",
578 ]
579 );
580 }
581
582 #[test]
583 fn templated_values_are_left_for_the_plugin() {
584 assert!(errs(json!({"mode": "{{ var('m') }}", "cols": []})).is_empty());
585 }
586
587 #[test]
588 fn a_plugin_without_options_refuses_any() {
589 let Value::Object(o) = json!({"to": "x"}) else {
590 panic!()
591 };
592 assert_eq!(
593 check(Kind::Destination, "sftp", &[], &o),
594 vec!["the `sftp` destination takes no options, but got `to`; check the key's spelling"]
595 );
596 }
597
598 #[test]
599 fn number_format_codes() {
600 use NumFormatClass::*;
601 let ok = [
602 ("General", Number),
603 ("0", Number),
604 ("#,##0.00", Number),
605 ("0.0%", Number),
606 ("0.00E+00", Number),
607 ("# ?/?", Number),
608 ("[$€-x-euro2] #,##0.00", Number),
609 ("[$$-409]#,##0.00", Number),
610 ("#,##0.00;[Red](#,##0.00)", Number),
611 ("#,##0;(#,##0);\"-\";@", Number),
612 ("_(* #,##0.00_);_(* (#,##0.00);_(* \"-\"??_);_(@_)", Number),
613 ("[>=1000]#,##0;0", Number),
614 ("\\€#,##0", Number),
615 ("yyyy-mm-dd", DateTime),
616 ("dd/mm/yyyy", DateTime),
617 ("mmm yyyy", DateTime),
618 ("yyyy-mm-dd hh:mm:ss", DateTime),
619 ("h:mm AM/PM", DateTime),
620 ("h:mm A/P", DateTime),
621 ("[h]:mm:ss", DateTime),
622 ("[mm]", DateTime),
623 ("hh:mm:ss.000", DateTime),
624 ("@", Text),
625 ("\"Total: \"@", Text),
626 ];
627 for (code, class) in ok {
628 assert_eq!(check_num_format(code), Ok(class), "{code}");
629 }
630 let bad = [
631 ("", "is empty"),
632 (
633 "0;0;0;@;0",
634 "has 5 `;` sections; Excel allows at most four (positive;negative;zero;text)",
635 ),
636 ("\"abc", "has an unclosed `\"` quote"),
637 ("[Red#,##0", "has an unclosed `[` bracket"),
638 ("#,##0]", "has a `]` without a matching `[`"),
639 ("0\\", "ends with `\\`, which needs a character after it"),
640 (
641 "0 units",
642 "has `U`, which isn't part of an Excel number format; put literal text in double quotes",
643 ),
644 ];
645 for (code, err) in bad {
646 assert_eq!(check_num_format(code), Err(err.to_string()), "{code}");
647 }
648 }
649
650 #[test]
651 fn column_maps() {
652 let (cols, errs) = parse_columns(
653 &json!({"a": {"format": "0.0%"}, "b": {"fromat": "0"}, "c": "0", "d": {"format": "\"x"}}),
654 );
655 assert_eq!(cols["a"].format.as_deref(), Some("0.0%"));
656 assert_eq!(
657 errs,
658 vec![
659 "column `b`: unknown key `fromat`; expected `format`, `formula`, `total`, `width`, `style`",
660 "column `c` must be a map of options like `{format: \"#,##0.00\"}`",
661 "column `d`: format `\"x` has an unclosed `\"` quote",
662 ]
663 );
664 }
665
666 #[test]
667 fn formulas() {
668 use FormulaPart::*;
669 assert_eq!(
670 parse_formula("={qty}*{ price }+{{1}}").unwrap(),
671 vec![
672 Text("=".into()),
673 Cell("qty".into()),
674 Text("*".into()),
675 Cell("price".into()),
676 Text("+{1}".into())
677 ]
678 );
679 assert_eq!(
680 parse_formula("=SUM({amount:*})").unwrap(),
681 vec![Text("=SUM(".into()), Column("amount".into()), Text(")".into())]
682 );
683 for (f, err) in [
684 ("{a}*2", "must start with `=`"),
685 ("={a", "has an unclosed `{`"),
686 (
687 "=a}",
688 "has a `}` without a matching `{` (write `}}` for a literal brace)",
689 ),
690 ("={}", "has an empty `{}` reference"),
691 ("={a{b}}", "has a `{` inside `{ }`"),
692 ] {
693 assert_eq!(parse_formula(f), Err(err.to_string()), "{f}");
694 }
695 assert!(
696 check_row_formula("={a}/SUM({a:*})")
697 .unwrap_err()
698 .contains("only a `total`")
699 );
700 assert!(check_total("sum").is_ok());
701 assert!(check_total("=SUM({a:*})-MIN({b:*})").is_ok());
702 assert!(check_total("={a}").unwrap_err().contains("doesn't have"));
703 assert_eq!(
704 check_total("total"),
705 Err(
706 "must be one of `sum`, `average`, `count`, `min`, `max` or a formula starting with `=`"
707 .into()
708 )
709 );
710 let (cols, errs) = parse_columns(&json!({
711 "t": {"formula": "={a}*2", "format": "0.00", "total": "sum"},
712 "u": {"formula": "a*2"},
713 "v": {"total": "median"}
714 }));
715 assert_eq!(cols["t"].formula.as_deref(), Some("={a}*2"));
716 assert_eq!(cols["t"].total.as_deref(), Some("sum"));
717 assert_eq!(errs.len(), 2, "{errs:?}");
718 assert!(errs[0].starts_with("column `u`: formula `a*2` must start with `=`"));
719 assert!(errs[1].starts_with("column `v`: total `median` must be one of"));
720 }
721}