Skip to main content

razeri_plugin_contract/
number_format.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! **How a source writes an amount** — the picture a design states, read once
6//! and handed to every engine as a small set of facts rather than as text.
7//!
8//! A file writes a price `$2,899.99 `. The dollar sign is not part of the
9//! number, the comma is not a decimal point, and the trailing space is not
10//! anything at all. Handed to an engine as it stands, that text converts to
11//! nothing on one engine, to `2` on another and to an error on a third. So the
12//! design says how the source writes it, and this is what that statement is.
13//!
14//! ## The picture
15//!
16//! A format is written the way a spreadsheet writes one: the value with its
17//! digits replaced by digit places.
18//!
19//! ```text
20//! $#,##0.00      $2,899.99        a dollar sign, thousands in groups of
21//!                                 three, two places after the point
22//! #,##0          2,899            a whole number written in groups
23//! 0.000          1.158            three places after the point and no
24//!                                 grouping
25//! #,##0.00 kr    2 899,00 kr      no — see below
26//! ```
27//!
28//! - `0` and `#` are digit places. Both mean *digits go here*; a reader does
29//!   not care which, and the two are allowed side by side because that is how
30//!   the picture is normally written.
31//! - `,` is the thousands mark and `.` is the decimal point. They mean that
32//!   whichever characters the picture uses, so a picture writing them the
33//!   other way round is refused rather than read backwards. A source that
34//!   writes `2.899,99` is not covered today, and the refusal says so.
35//! - Everything before the first digit place is the symbol that opens the
36//!   value, and everything after the last digit place is the symbol that
37//!   closes it. Either may be empty.
38//! - A space around the whole value is always allowed and never written in
39//!   the picture. `$2,899.99 ` and `$2,899.99` are the same value.
40//! - A minus sign in front of the value is always allowed. A source that
41//!   writes a negative in brackets instead says so with a second picture
42//!   after a semicolon: `$#,##0.00;($#,##0.00)`.
43//!
44//! ## What an engine does with it
45//!
46//! The picture names the marks that are not part of the number. A reader takes
47//! those marks out and converts what is left, so the work every engine has to
48//! do is the same work, and the value that lands is the same value. What
49//! differs between engines is only how each one spells *take this out* and
50//! *convert this, and answer nothing if you cannot* —
51//! [`read_with_safe_cast`], [`read_with_pattern_guard`] and
52//! [`read_with_digit_check`] are those three answers, written once here so an
53//! engine states its own vocabulary rather than its own rules.
54
55use std::fmt;
56
57use crate::DataType;
58use crate::sid::normalize_trim;
59use crate::sid_spelling::SidSpelling;
60
61/// How the source writes a value below zero.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Negatives {
64    /// A minus sign in front of the value — `-2,899.99`. The ordinary case,
65    /// and what a picture with no second half means.
66    Minus,
67    /// Brackets around the whole value — `($2,899.99)`, the way a ledger
68    /// writes one. Stated with a second picture after a semicolon.
69    Parentheses,
70}
71
72/// What is wrong with a picture, in the words the refusal uses.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum NumberFormatError {
75    /// A picture saying nothing at all.
76    Empty,
77    /// A picture with no digit place in it.
78    NoDigits,
79    /// A thousands mark after the decimal point.
80    MarkAfterPoint,
81    /// More than one decimal point.
82    TwoPoints,
83    /// A thousands mark with something other than three digit places after it.
84    GroupIsNotThree(usize),
85    /// A digit place after the symbol that closes the value.
86    DigitsAfterSymbol,
87    /// More than two pictures separated by semicolons.
88    TooManyPictures,
89    /// A second picture that is not the first one in brackets.
90    NegativeIsNotBracketed,
91}
92
93impl fmt::Display for NumberFormatError {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        match self {
96            Self::Empty => f.write_str(
97                "the format is empty. Write how the source writes the value, with its digits \
98                 replaced by digit places — `$#,##0.00` for `$2,899.99`, `0.000` for `1.158`",
99            ),
100            Self::NoDigits => f.write_str(
101                "the format holds no digit place, so there is no number in it. A digit place is \
102                 `0` or `#` — `$#,##0.00` for `$2,899.99`",
103            ),
104            Self::MarkAfterPoint => f.write_str(
105                "the format puts a `,` after the `.`. In a picture `,` is the thousands mark and \
106                 `.` is the decimal point, whichever characters the source itself uses, so the \
107                 thousands come first. A source that writes `2.899,99` is not covered today",
108            ),
109            Self::TwoPoints => f.write_str(
110                "the format has two decimal points, and a number has one. If one of them is the \
111                 thousands mark, write it as `,` — `$#,##0.00` is two thousand eight hundred and \
112                 ninety-nine point nine nine",
113            ),
114            Self::GroupIsNotThree(places) => write!(
115                f,
116                "the format puts {places} digit place(s) after a `,`, and a thousands mark \
117                 separates groups of three. Write `#,##0` for a number written `2,899`"
118            ),
119            Self::DigitsAfterSymbol => f.write_str(
120                "the format has digit places on both sides of the symbol. The symbol opens the \
121                 value or closes it — `$#,##0.00` or `#,##0.00 kr` — and a number written in two \
122                 halves is not covered",
123            ),
124            Self::TooManyPictures => f.write_str(
125                "the format holds more than two pictures. One picture says how the source writes \
126                 a value; a second, after a `;`, says how it writes a negative one",
127            ),
128            Self::NegativeIsNotBracketed => f.write_str(
129                "the second picture is not the first one in brackets. It says the source writes a \
130                 negative the way a ledger does, so it is the first picture inside `(` and `)` — \
131                 `$#,##0.00;($#,##0.00)`. A source that writes a leading minus needs no second \
132                 picture at all",
133            ),
134        }
135    }
136}
137
138impl std::error::Error for NumberFormatError {}
139
140/// **How a source writes an amount, read once.**
141///
142/// Built from the authored picture and never rebuilt: every engine is handed
143/// the same facts, so two engines cannot disagree about what the design said.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct NumberFormat {
146    spelled: String,
147    opens_with: String,
148    closes_with: String,
149    grouped: bool,
150    decimals: usize,
151    negatives: Negatives,
152}
153
154/// The characters a picture reserves for itself.
155const DIGIT_PLACES: [char; 2] = ['#', '0'];
156const THOUSANDS_MARK: char = ',';
157const DECIMAL_POINT: char = '.';
158
159impl NumberFormat {
160    /// Read a picture, or say what is wrong with it.
161    ///
162    /// # Errors
163    ///
164    /// Nothing at all, no digit place, a thousands mark in the wrong place or
165    /// with the wrong number of places after it, two decimal points, digits on
166    /// both sides of the symbol, or a second picture that is not the first one
167    /// in brackets.
168    pub fn parse(spelled: &str) -> Result<Self, NumberFormatError> {
169        if spelled.is_empty() {
170            return Err(NumberFormatError::Empty);
171        }
172        let mut pictures = spelled.split(';');
173        let positive = pictures.next().unwrap_or_default();
174        let negative = pictures.next();
175        if pictures.next().is_some() {
176            return Err(NumberFormatError::TooManyPictures);
177        }
178        let mut read = Self::read_one(positive)?;
179        if let Some(negative) = negative {
180            if negative != format!("({positive})") {
181                return Err(NumberFormatError::NegativeIsNotBracketed);
182            }
183            read.negatives = Negatives::Parentheses;
184        }
185        spelled.clone_into(&mut read.spelled);
186        Ok(read)
187    }
188
189    /// One picture — the symbol that opens the value, the number, the symbol
190    /// that closes it.
191    fn read_one(picture: &str) -> Result<Self, NumberFormatError> {
192        let is_body = |c: char| DIGIT_PLACES.contains(&c) || c == THOUSANDS_MARK;
193        let first =
194            picture.find(|c| DIGIT_PLACES.contains(&c)).ok_or(NumberFormatError::NoDigits)?;
195        let opens_with = picture[..first].to_owned();
196        let rest = &picture[first..];
197
198        // The number runs to the end of the digit places, the thousands marks
199        // and the one decimal point with its places.
200        let mut body = String::new();
201        let mut seen_point = false;
202        let mut closes_at = rest.len();
203        for (at, character) in rest.char_indices() {
204            if is_body(character) {
205                if seen_point && character == THOUSANDS_MARK {
206                    return Err(NumberFormatError::MarkAfterPoint);
207                }
208                body.push(character);
209                continue;
210            }
211            if character == DECIMAL_POINT {
212                if seen_point {
213                    return Err(NumberFormatError::TwoPoints);
214                }
215                seen_point = true;
216                body.push(character);
217                continue;
218            }
219            closes_at = at;
220            break;
221        }
222        let closes_with = rest[closes_at..].to_owned();
223        if closes_with.contains(DIGIT_PLACES) {
224            return Err(NumberFormatError::DigitsAfterSymbol);
225        }
226
227        let (whole, fraction) = match body.split_once(DECIMAL_POINT) {
228            Some((whole, fraction)) => (whole, fraction),
229            None => (body.as_str(), ""),
230        };
231        if fraction.contains(THOUSANDS_MARK) {
232            return Err(NumberFormatError::MarkAfterPoint);
233        }
234        let grouped = whole.contains(THOUSANDS_MARK);
235        if grouped {
236            for group in whole.split(THOUSANDS_MARK).skip(1) {
237                if group.chars().count() != 3 {
238                    return Err(NumberFormatError::GroupIsNotThree(group.chars().count()));
239                }
240            }
241        }
242
243        Ok(Self {
244            spelled: picture.to_owned(),
245            opens_with,
246            closes_with,
247            grouped,
248            decimals: fraction.chars().count(),
249            negatives: Negatives::Minus,
250        })
251    }
252
253    /// The picture as the design wrote it — what a refusal quotes back.
254    #[must_use]
255    pub fn spelled(&self) -> &str {
256        &self.spelled
257    }
258
259    /// The symbol in front of the value, empty where there is none.
260    #[must_use]
261    pub fn opens_with(&self) -> &str {
262        &self.opens_with
263    }
264
265    /// The symbol after the value, empty where there is none.
266    #[must_use]
267    pub fn closes_with(&self) -> &str {
268        &self.closes_with
269    }
270
271    /// Whether the source writes the thousands in groups.
272    #[must_use]
273    pub fn grouped(&self) -> bool {
274        self.grouped
275    }
276
277    /// How many places the source writes after the decimal point.
278    #[must_use]
279    pub fn decimals(&self) -> usize {
280        self.decimals
281    }
282
283    /// How the source writes a value below zero.
284    #[must_use]
285    pub fn negatives(&self) -> Negatives {
286        self.negatives
287    }
288
289    /// **The marks that are not part of the number**, in the order a reader
290    /// takes them out.
291    ///
292    /// The symbol that opens the value, the symbol that closes it, the
293    /// thousands mark, and the brackets where the source writes a negative in
294    /// them. What is left is digits, at most one decimal point and at most a
295    /// leading minus.
296    #[must_use]
297    pub fn marks(&self) -> Vec<String> {
298        let mut marks = Vec::new();
299        if !self.opens_with.is_empty() {
300            marks.push(self.opens_with.clone());
301        }
302        if !self.closes_with.is_empty() {
303            marks.push(self.closes_with.clone());
304        }
305        if self.grouped {
306            marks.push(THOUSANDS_MARK.to_string());
307        }
308        if self.negatives == Negatives::Parentheses {
309            marks.push("(".to_owned());
310            marks.push(")".to_owned());
311        }
312        marks
313    }
314
315    /// **The shape a value has to have**, as a regular expression, for an
316    /// engine whose own conversion raises rather than answering nothing.
317    ///
318    /// It is built from the same picture the marks come from, so the guard and
319    /// the reading cannot hold two opinions about one spelling. The value is
320    /// matched with its surrounding spaces already gone.
321    #[must_use]
322    pub fn shape_pattern(&self) -> String {
323        let digits = if self.grouped { "[0-9][0-9,]*" } else { "[0-9]+" };
324        let fraction = if self.decimals > 0 { "([.][0-9]+)?" } else { "" };
325        let (open, close) = match self.negatives {
326            Negatives::Minus => ("-?", ""),
327            Negatives::Parentheses => ("[(]?", "[)]?"),
328        };
329        format!(
330            "^{open}{}{digits}{fraction}{}{close}$",
331            optional_literal(&self.opens_with),
332            optional_literal(&self.closes_with),
333        )
334    }
335}
336
337impl fmt::Display for NumberFormat {
338    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339        f.write_str(&self.spelled)
340    }
341}
342
343/// One literal run, made safe for a regular expression and made optional.
344///
345/// Optional because a file that writes `$6.62` on most lines and `6.62` on one
346/// has still written a price on both, and a reader that demanded the symbol
347/// would set aside a row nothing is wrong with.
348fn optional_literal(text: &str) -> String {
349    if text.is_empty() {
350        return String::new();
351    }
352    let mut pattern = String::new();
353    for character in text.chars() {
354        if character.is_ascii_alphanumeric() {
355            pattern.push(character);
356        } else {
357            pattern.push('[');
358            pattern.push(character);
359            pattern.push(']');
360        }
361    }
362    format!("({pattern})?")
363}
364
365/// **Whether this is a type an amount can land in.**
366///
367/// A picture says how a number is written down. On a name or a day there is
368/// nothing for it to be about, so an engine asked to read one into anything
369/// else states nothing and razeri refuses the column by name.
370#[must_use]
371pub fn is_a_number(target: &DataType) -> bool {
372    matches!(target, DataType::Integer(_) | DataType::Decimal(_) | DataType::Float(_))
373}
374
375/// **The value with the picture's marks taken out**, in this engine's own
376/// spelling of `TRIM` and `REPLACE`.
377///
378/// Those two words are already written down per engine, on the vocabulary the
379/// surrogate key is derived through, and a second table of the same two words
380/// would be a second answer to one question.
381///
382/// Parameters:
383/// - `format`: the picture the design stated;
384/// - `spelling`: this engine's vocabulary;
385/// - `expr`: an already-rendered text expression.
386#[must_use]
387pub fn strip_marks(format: &NumberFormat, spelling: SidSpelling, expr: &str) -> String {
388    let replace = spelling.lower.replace;
389    let prefix = spelling.lower.pattern_literal_prefix;
390    format.marks().iter().fold(normalize_trim(spelling, expr), |text, mark| {
391        format!("{replace}({text}, {prefix}'{}', {prefix}'')", mark.replace('\'', "''"))
392    })
393}
394
395/// **The one thing the picture's marks cannot come out of a value without.**
396///
397/// Taking the thousands mark out of `2.899,99` leaves `2.89999`, which
398/// converts on every engine to a price nobody wrote — the worst answer
399/// available, because it is a number and it is wrong. The picture says the
400/// thousands come before the decimal point, so a value writing them after it
401/// is not written that way, and this is that sentence as a condition.
402///
403/// `LIKE` and nothing else, because this has to hold on the engines with no
404/// way to match a pattern as much as on the ones that have. `None` where the
405/// picture names no thousands mark: there is nothing to take out of the wrong
406/// place.
407fn separators_in_order(format: &NumberFormat, spelling: SidSpelling, expr: &str) -> Option<String> {
408    if !format.grouped() {
409        return None;
410    }
411    let prefix = spelling.lower.pattern_literal_prefix;
412    Some(format!(
413        "{} NOT LIKE {prefix}'%{DECIMAL_POINT}%{THOUSANDS_MARK}%'",
414        normalize_trim(spelling, expr)
415    ))
416}
417
418/// **The sign a bracketed negative carries**, as a factor to multiply by.
419///
420/// A picture that says nothing about brackets needs no factor: the minus sign
421/// the source writes survives the marks coming out, and the conversion reads
422/// it.
423fn bracket_sign(format: &NumberFormat, spelling: SidSpelling, expr: &str) -> Option<String> {
424    if format.negatives() != Negatives::Parentheses {
425        return None;
426    }
427    Some(format!("CASE WHEN {} LIKE '(%' THEN -1 ELSE 1 END", normalize_trim(spelling, expr)))
428}
429
430/// Multiply the reading by the sign, where the picture asks for one.
431fn signed(reading: String, sign: Option<String>) -> String {
432    match sign {
433        None => reading,
434        Some(sign) => format!("({reading}) * ({sign})"),
435    }
436}
437
438/// **For an engine whose own conversion answers nothing for a value it cannot
439/// read** — `TRY_CAST`, `SAFE_CAST`, `TRY_CONVERT` and their neighbours.
440///
441/// The marks come out and the engine converts what is left. A value the
442/// picture does not cover converts to nothing, which is what razeri reads as
443/// *this row did not parse*.
444///
445/// Parameters:
446/// - `format`: the picture the design stated;
447/// - `spelling`: this engine's vocabulary;
448/// - `expr`: an already-rendered text expression;
449/// - `safe_cast`: this engine's conversion, given the stripped text.
450#[must_use]
451pub fn read_with_safe_cast(
452    format: &NumberFormat,
453    spelling: SidSpelling,
454    expr: &str,
455    safe_cast: &dyn Fn(&str) -> String,
456) -> String {
457    let reading = safe_cast(&strip_marks(format, spelling, expr));
458    let reading = match separators_in_order(format, spelling, expr) {
459        None => reading,
460        Some(ordered) => format!("CASE WHEN {ordered} THEN {reading} END"),
461    };
462    signed(reading, bracket_sign(format, spelling, expr))
463}
464
465/// **For an engine whose own conversion raises, or answers a number for text
466/// that is not one** — the shape is checked first, and a value that fails the
467/// check is never handed to the conversion at all.
468///
469/// Parameters:
470/// - `format`: the picture the design stated;
471/// - `spelling`: this engine's vocabulary;
472/// - `expr`: an already-rendered text expression;
473/// - `cast`: this engine's conversion, given the stripped text;
474/// - `matches`: this engine's own test of a value against a regular
475///   expression, given the value and the pattern as a quoted literal.
476#[must_use]
477pub fn read_with_pattern_guard(
478    format: &NumberFormat,
479    spelling: SidSpelling,
480    expr: &str,
481    cast: &dyn Fn(&str) -> String,
482    matches: &dyn Fn(&str, &str) -> String,
483) -> String {
484    let trimmed = normalize_trim(spelling, expr);
485    let pattern = format!("'{}'", format.shape_pattern().replace('\'', "''"));
486    let reading = cast(&strip_marks(format, spelling, expr));
487    // The pattern already holds the thousands mark in front of the decimal
488    // point, so this arm needs no second condition for it.
489    let guarded = format!("CASE WHEN {} THEN {reading} END", matches(&trimmed, &pattern));
490    signed(guarded, bracket_sign(format, spelling, expr))
491}
492
493/// **For an engine with neither a conversion that answers nothing nor a way to
494/// match a pattern** — the value less its digits is what says whether it was a
495/// number.
496///
497/// With the picture's marks already out, a value that was a number holds
498/// digits, at most one decimal point and at most a leading minus. Take the
499/// digits out of it and what remains can only be one of four short strings, so
500/// the check is an equality rather than a search.
501///
502/// Parameters:
503/// - `format`: the picture the design stated;
504/// - `spelling`: this engine's vocabulary;
505/// - `expr`: an already-rendered text expression;
506/// - `cast`: this engine's conversion, given the stripped text.
507#[must_use]
508pub fn read_with_digit_check(
509    format: &NumberFormat,
510    spelling: SidSpelling,
511    expr: &str,
512    cast: &dyn Fn(&str) -> String,
513) -> String {
514    let replace = spelling.lower.replace;
515    let prefix = spelling.lower.pattern_literal_prefix;
516    let stripped = strip_marks(format, spelling, expr);
517    let bare = (0..10u8).fold(stripped.clone(), |text, digit| {
518        format!("{replace}({text}, {prefix}'{digit}', {prefix}'')")
519    });
520    let reading = cast(&stripped);
521    // **A picture with no places after the point leaves no point behind.** A
522    // whole number written `1.5` is not a whole number, and this engine's own
523    // conversion would answer `1` — so the remainder a value may leave follows
524    // the picture rather than being one fixed list.
525    let remainders = if format.decimals() > 0 {
526        format!("{prefix}'', {prefix}'.', {prefix}'-', {prefix}'-.'")
527    } else {
528        format!("{prefix}'', {prefix}'-'")
529    };
530    let mut conditions =
531        vec![format!("{stripped} <> {prefix}''"), format!("{bare} IN ({remainders})")];
532    conditions.extend(separators_in_order(format, spelling, expr));
533    let guarded = format!("CASE WHEN {} THEN {reading} END", conditions.join(" AND "));
534    signed(guarded, bracket_sign(format, spelling, expr))
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn a_price_picture_says_a_symbol_thousands_and_two_places() {
543        let format = NumberFormat::parse("$#,##0.00").expect("a lawful picture");
544        assert_eq!(format.opens_with(), "$");
545        assert_eq!(format.closes_with(), "");
546        assert!(format.grouped());
547        assert_eq!(format.decimals(), 2);
548        assert_eq!(format.negatives(), Negatives::Minus);
549        assert_eq!(format.marks(), vec!["$".to_owned(), ",".to_owned()]);
550    }
551
552    #[test]
553    fn a_symbol_may_close_the_value_instead_of_opening_it() {
554        let format = NumberFormat::parse("#,##0.00 kr").expect("a lawful picture");
555        assert_eq!(format.opens_with(), "");
556        assert_eq!(format.closes_with(), " kr");
557        assert_eq!(format.marks(), vec![" kr".to_owned(), ",".to_owned()]);
558    }
559
560    #[test]
561    fn a_plain_picture_names_no_marks_at_all() {
562        let format = NumberFormat::parse("0.000").expect("a lawful picture");
563        assert!(!format.grouped());
564        assert_eq!(format.decimals(), 3);
565        assert!(format.marks().is_empty());
566        assert_eq!(format.shape_pattern(), "^-?[0-9]+([.][0-9]+)?$");
567    }
568
569    #[test]
570    fn a_ledger_negative_is_the_same_picture_in_brackets() {
571        let format = NumberFormat::parse("$#,##0.00;($#,##0.00)").expect("a lawful picture");
572        assert_eq!(format.negatives(), Negatives::Parentheses);
573        assert_eq!(
574            format.marks(),
575            vec!["$".to_owned(), ",".to_owned(), "(".to_owned(), ")".to_owned()]
576        );
577        assert_eq!(format.spelled(), "$#,##0.00;($#,##0.00)");
578    }
579
580    #[test]
581    fn the_shape_holds_the_symbol_and_the_groups_where_the_picture_put_them() {
582        let format = NumberFormat::parse("$#,##0.00").expect("a lawful picture");
583        assert_eq!(format.shape_pattern(), "^-?([$])?[0-9][0-9,]*([.][0-9]+)?$");
584    }
585
586    #[test]
587    fn the_reading_holds_the_thousands_mark_in_front_of_the_decimal_point() {
588        let format = NumberFormat::parse("$#,##0.00").expect("a lawful picture");
589        let read = read_with_safe_cast(&format, crate::ANSI_SID_SPELLING, "v", &|text| {
590            format!("TRY_CAST({text} AS DECIMAL(12, 2))")
591        });
592        // Taking the thousands mark out of `2.899,99` leaves `2.89999`, which
593        // converts to a price nobody wrote. The condition is what stops it.
594        assert!(read.contains("TRIM(v) NOT LIKE '%.%,%'"), "{read}");
595    }
596
597    #[test]
598    fn a_picture_with_no_thousands_mark_needs_no_such_condition() {
599        let format = NumberFormat::parse("0.00").expect("a lawful picture");
600        let read = read_with_safe_cast(&format, crate::ANSI_SID_SPELLING, "v", &|text| {
601            format!("TRY_CAST({text} AS DECIMAL(12, 2))")
602        });
603        assert_eq!(
604            read, "TRY_CAST(TRIM(v) AS DECIMAL(12, 2))",
605            "nothing to take out, nothing to hold"
606        );
607    }
608
609    #[test]
610    fn a_whole_numbers_digit_check_leaves_no_room_for_a_decimal_point() {
611        let whole = NumberFormat::parse("#0").expect("a lawful picture");
612        let read = read_with_digit_check(&whole, crate::ANSI_SID_SPELLING, "v", &|text| {
613            format!("CAST({text} AS INTEGER)")
614        });
615        // `1.5` is not a whole number, and an engine with no safe conversion
616        // would otherwise answer `1`.
617        assert!(read.contains("IN ('', '-')"), "{read}");
618        let fractional = NumberFormat::parse("#0.0").expect("a lawful picture");
619        let read = read_with_digit_check(&fractional, crate::ANSI_SID_SPELLING, "v", &|text| {
620            format!("CAST({text} AS DECIMAL(18, 6))")
621        });
622        assert!(read.contains("IN ('', '.', '-', '-.')"), "{read}");
623    }
624
625    #[test]
626    fn a_bracketed_negative_is_read_as_a_sign_rather_than_a_mark() {
627        let format = NumberFormat::parse("$#,##0.00;($#,##0.00)").expect("a lawful picture");
628        let read = read_with_safe_cast(&format, crate::ANSI_SID_SPELLING, "v", &|text| {
629            format!("TRY_CAST({text} AS DECIMAL(12, 2))")
630        });
631        assert!(read.contains("CASE WHEN TRIM(v) LIKE '(%' THEN -1 ELSE 1 END"), "{read}");
632        assert!(read.contains("REPLACE(REPLACE(REPLACE(REPLACE(TRIM(v)"), "{read}");
633    }
634
635    #[test]
636    fn a_picture_with_no_digits_is_refused() {
637        assert_eq!(NumberFormat::parse("$"), Err(NumberFormatError::NoDigits));
638        assert_eq!(NumberFormat::parse(""), Err(NumberFormatError::Empty));
639    }
640
641    #[test]
642    fn a_source_writing_the_separators_the_other_way_round_is_refused_by_name() {
643        let refused = NumberFormat::parse("#.##0,00").expect_err("not covered");
644        assert_eq!(refused, NumberFormatError::MarkAfterPoint);
645        assert!(refused.to_string().contains("2.899,99"), "{refused}");
646    }
647
648    #[test]
649    fn a_thousands_mark_separates_groups_of_three() {
650        assert_eq!(NumberFormat::parse("#,##0,0"), Err(NumberFormatError::GroupIsNotThree(1)));
651    }
652
653    #[test]
654    fn two_decimal_points_are_refused() {
655        assert_eq!(NumberFormat::parse("0.0.0"), Err(NumberFormatError::TwoPoints));
656    }
657
658    #[test]
659    fn digits_on_both_sides_of_the_symbol_are_refused() {
660        assert_eq!(NumberFormat::parse("0 kr 0"), Err(NumberFormatError::DigitsAfterSymbol));
661    }
662
663    #[test]
664    fn a_second_picture_that_is_not_the_first_in_brackets_is_refused() {
665        assert_eq!(
666            NumberFormat::parse("$#,##0.00;-$#,##0.00"),
667            Err(NumberFormatError::NegativeIsNotBracketed)
668        );
669        assert_eq!(NumberFormat::parse("0;(0);0"), Err(NumberFormatError::TooManyPictures));
670    }
671}