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::{Concat, 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 {} after a `,`, and a thousands mark \
117                 separates groups of three. Write `#,##0` for a number written `2,899`",
118                if *places == 1 { "place" } else { "places" }
119            ),
120            Self::DigitsAfterSymbol => f.write_str(
121                "the format has digit places on both sides of the symbol. The symbol opens the \
122                 value or closes it — `$#,##0.00` or `#,##0.00 kr` — and a number written in two \
123                 halves is not covered",
124            ),
125            Self::TooManyPictures => f.write_str(
126                "the format holds more than two pictures. One picture says how the source writes \
127                 a value; a second, after a `;`, says how it writes a negative one",
128            ),
129            Self::NegativeIsNotBracketed => f.write_str(
130                "the second picture is not the first one in brackets. It says the source writes a \
131                 negative the way a ledger does, so it is the first picture inside `(` and `)` — \
132                 `$#,##0.00;($#,##0.00)`. A source that writes a leading minus needs no second \
133                 picture at all",
134            ),
135        }
136    }
137}
138
139impl std::error::Error for NumberFormatError {}
140
141/// **How a source writes an amount, read once.**
142///
143/// Built from the authored picture and never rebuilt: every engine is handed
144/// the same facts, so two engines cannot disagree about what the design said.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct NumberFormat {
147    spelled: String,
148    opens_with: String,
149    closes_with: String,
150    grouped: bool,
151    decimals: usize,
152    negatives: Negatives,
153}
154
155/// The characters a picture reserves for itself.
156const DIGIT_PLACES: [char; 2] = ['#', '0'];
157const THOUSANDS_MARK: char = ',';
158const DECIMAL_POINT: char = '.';
159
160impl NumberFormat {
161    /// Read a picture, or say what is wrong with it.
162    ///
163    /// # Errors
164    ///
165    /// Nothing at all, no digit place, a thousands mark in the wrong place or
166    /// with the wrong number of places after it, two decimal points, digits on
167    /// both sides of the symbol, or a second picture that is not the first one
168    /// in brackets.
169    pub fn parse(spelled: &str) -> Result<Self, NumberFormatError> {
170        if spelled.is_empty() {
171            return Err(NumberFormatError::Empty);
172        }
173        let mut pictures = spelled.split(';');
174        let positive = pictures.next().unwrap_or_default();
175        let negative = pictures.next();
176        if pictures.next().is_some() {
177            return Err(NumberFormatError::TooManyPictures);
178        }
179        let mut read = Self::read_one(positive)?;
180        if let Some(negative) = negative {
181            if negative != format!("({positive})") {
182                return Err(NumberFormatError::NegativeIsNotBracketed);
183            }
184            read.negatives = Negatives::Parentheses;
185        }
186        spelled.clone_into(&mut read.spelled);
187        Ok(read)
188    }
189
190    /// One picture — the symbol that opens the value, the number, the symbol
191    /// that closes it.
192    fn read_one(picture: &str) -> Result<Self, NumberFormatError> {
193        let is_body = |c: char| DIGIT_PLACES.contains(&c) || c == THOUSANDS_MARK;
194        let first =
195            picture.find(|c| DIGIT_PLACES.contains(&c)).ok_or(NumberFormatError::NoDigits)?;
196        let opens_with = picture[..first].to_owned();
197        let rest = &picture[first..];
198
199        // The number runs to the end of the digit places, the thousands marks
200        // and the one decimal point with its places.
201        let mut body = String::new();
202        let mut seen_point = false;
203        let mut closes_at = rest.len();
204        for (at, character) in rest.char_indices() {
205            if is_body(character) {
206                if seen_point && character == THOUSANDS_MARK {
207                    return Err(NumberFormatError::MarkAfterPoint);
208                }
209                body.push(character);
210                continue;
211            }
212            if character == DECIMAL_POINT {
213                if seen_point {
214                    return Err(NumberFormatError::TwoPoints);
215                }
216                seen_point = true;
217                body.push(character);
218                continue;
219            }
220            closes_at = at;
221            break;
222        }
223        let closes_with = rest[closes_at..].to_owned();
224        if closes_with.contains(DIGIT_PLACES) {
225            return Err(NumberFormatError::DigitsAfterSymbol);
226        }
227
228        let (whole, fraction) = match body.split_once(DECIMAL_POINT) {
229            Some((whole, fraction)) => (whole, fraction),
230            None => (body.as_str(), ""),
231        };
232        if fraction.contains(THOUSANDS_MARK) {
233            return Err(NumberFormatError::MarkAfterPoint);
234        }
235        let grouped = whole.contains(THOUSANDS_MARK);
236        if grouped {
237            for group in whole.split(THOUSANDS_MARK).skip(1) {
238                if group.chars().count() != 3 {
239                    return Err(NumberFormatError::GroupIsNotThree(group.chars().count()));
240                }
241            }
242        }
243
244        Ok(Self {
245            spelled: picture.to_owned(),
246            opens_with,
247            closes_with,
248            grouped,
249            decimals: fraction.chars().count(),
250            negatives: Negatives::Minus,
251        })
252    }
253
254    /// The picture as the design wrote it — what a refusal quotes back.
255    #[must_use]
256    pub fn spelled(&self) -> &str {
257        &self.spelled
258    }
259
260    /// The symbol in front of the value, empty where there is none.
261    #[must_use]
262    pub fn opens_with(&self) -> &str {
263        &self.opens_with
264    }
265
266    /// The symbol after the value, empty where there is none.
267    #[must_use]
268    pub fn closes_with(&self) -> &str {
269        &self.closes_with
270    }
271
272    /// Whether the source writes the thousands in groups.
273    #[must_use]
274    pub fn grouped(&self) -> bool {
275        self.grouped
276    }
277
278    /// How many places the source writes after the decimal point.
279    #[must_use]
280    pub fn decimals(&self) -> usize {
281        self.decimals
282    }
283
284    /// How the source writes a value below zero.
285    #[must_use]
286    pub fn negatives(&self) -> Negatives {
287        self.negatives
288    }
289
290    /// **The marks that are not part of the number**, in the order a reader
291    /// takes them out.
292    ///
293    /// The symbol that opens the value, the symbol that closes it, the
294    /// thousands mark, and the brackets where the source writes a negative in
295    /// them. What is left is digits, at most one decimal point and at most a
296    /// leading minus.
297    #[must_use]
298    pub fn marks(&self) -> Vec<String> {
299        let mut marks = Vec::new();
300        if !self.opens_with.is_empty() {
301            marks.push(self.opens_with.clone());
302        }
303        if !self.closes_with.is_empty() {
304            marks.push(self.closes_with.clone());
305        }
306        if self.grouped {
307            marks.push(THOUSANDS_MARK.to_string());
308        }
309        if self.negatives == Negatives::Parentheses {
310            marks.push("(".to_owned());
311            marks.push(")".to_owned());
312        }
313        marks
314    }
315
316    /// **The shape a value has to have**, as a regular expression, for an
317    /// engine whose own conversion raises rather than answering nothing.
318    ///
319    /// It is built from the same picture the marks come from, so the guard and
320    /// the reading cannot hold two opinions about one spelling. The value is
321    /// matched with its surrounding spaces already gone.
322    #[must_use]
323    pub fn shape_pattern(&self) -> String {
324        let digits = if self.grouped { "[0-9][0-9,]*" } else { "[0-9]+" };
325        let fraction = if self.decimals > 0 { "([.][0-9]+)?" } else { "" };
326        let (open, close) = match self.negatives {
327            Negatives::Minus => ("-?", ""),
328            Negatives::Parentheses => ("[(]?", "[)]?"),
329        };
330        format!(
331            "^{open}{}{digits}{fraction}{}{close}$",
332            optional_literal(&self.opens_with),
333            optional_literal(&self.closes_with),
334        )
335    }
336}
337
338impl fmt::Display for NumberFormat {
339    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340        f.write_str(&self.spelled)
341    }
342}
343
344/// One literal run, made safe for a regular expression and made optional.
345///
346/// Optional because a file that writes `$6.62` on most lines and `6.62` on one
347/// has still written a price on both, and a reader that demanded the symbol
348/// would set aside a row nothing is wrong with.
349fn optional_literal(text: &str) -> String {
350    if text.is_empty() {
351        return String::new();
352    }
353    let mut pattern = String::new();
354    for character in text.chars() {
355        if character.is_ascii_alphanumeric() {
356            pattern.push(character);
357        } else {
358            pattern.push('[');
359            pattern.push(character);
360            pattern.push(']');
361        }
362    }
363    format!("({pattern})?")
364}
365
366/// **Whether this is a type an amount can land in.**
367///
368/// A picture says how a number is written down. On a name or a day there is
369/// nothing for it to be about, so an engine asked to read one into anything
370/// else states nothing and razeri refuses the column by name.
371#[must_use]
372pub fn is_a_number(target: &DataType) -> bool {
373    matches!(target, DataType::Integer(_) | DataType::Decimal(_) | DataType::Float(_))
374}
375
376/// **The value with the picture's marks taken out**, in this engine's own
377/// spelling of `TRIM` and `REPLACE`.
378///
379/// Those two words are already written down per engine, on the vocabulary the
380/// surrogate key is derived through, and a second table of the same two words
381/// would be a second answer to one question.
382///
383/// Parameters:
384/// - `format`: the picture the design stated;
385/// - `spelling`: this engine's vocabulary;
386/// - `expr`: an already-rendered text expression.
387#[must_use]
388pub fn strip_marks(format: &NumberFormat, spelling: SidSpelling, expr: &str) -> String {
389    let replace = spelling.lower.replace;
390    let prefix = spelling.lower.pattern_literal_prefix;
391    format.marks().iter().fold(normalize_trim(spelling, expr), |text, mark| {
392        format!("{replace}({text}, {prefix}'{}', {prefix}'')", mark.replace('\'', "''"))
393    })
394}
395
396/// **The one thing the picture's marks cannot come out of a value without.**
397///
398/// Taking the thousands mark out of `2.899,99` leaves `2.89999`, which
399/// converts on every engine to a price nobody wrote — the worst answer
400/// available, because it is a number and it is wrong. The picture says the
401/// thousands come before the decimal point, so a value writing them after it
402/// is not written that way, and this is that sentence as a condition.
403///
404/// `LIKE` and nothing else, because this has to hold on the engines with no
405/// way to match a pattern as much as on the ones that have. `None` where the
406/// picture names no thousands mark: there is nothing to take out of the wrong
407/// place.
408fn separators_in_order(format: &NumberFormat, spelling: SidSpelling, expr: &str) -> Option<String> {
409    if !format.grouped() {
410        return None;
411    }
412    let prefix = spelling.lower.pattern_literal_prefix;
413    Some(format!(
414        "{} NOT LIKE {prefix}'%{DECIMAL_POINT}%{THOUSANDS_MARK}%'",
415        normalize_trim(spelling, expr)
416    ))
417}
418
419/// **The picture's shape, as conditions an engine with no pattern match can
420/// hold a value to** — `LIKE` and nothing else.
421///
422/// [`NumberFormat::shape_pattern`] is the shape a pattern-matching engine
423/// holds a value to before its marks come out. An engine that converts with a
424/// cast that answers nothing, or checks the digits left, used to hold none:
425/// the marks came out wherever they stood, so `12$`, `$$12`, `+5` and `.5`
426/// read as numbers there while a pattern-matching engine set them aside
427/// (`fr-1019-86`). These are the same rules in `LIKE`'s words:
428///
429/// - a symbol at most once, and only at its own end of the value — after a
430///   leading minus, or inside an opening bracket, where the picture writes a
431///   negative that way;
432/// - with the marks out, nothing left but digits, at most one decimal point
433///   and at most a minus, so a plus sign, an exponent or a word is not a
434///   number here;
435/// - a digit before the decimal point, and at least one after it;
436/// - no decimal point at all where the picture has no places;
437/// - a minus only as the value's first character, and none where the picture
438///   writes a negative in brackets.
439///
440/// **The value is named as few times as the rules allow**, because it is often
441/// a long expression of its own and every mention repeats it. The digits,
442/// the point and the minus are asked in one condition ([`canonical_shape`]):
443/// a value of the picture's shape comes out as the text `x0` and nothing
444/// else. Only a symbol opening the value needs the minus asked again, of the
445/// value as written, since `$-12` and `-$12` are the same once it is out.
446///
447/// A symbol holding `%`, `_` or `[` is not held to its place: those are
448/// `LIKE`'s own wildcards somewhere.
449fn shape_conditions(format: &NumberFormat, spelling: SidSpelling, expr: &str) -> Vec<String> {
450    let prefix = spelling.lower.pattern_literal_prefix;
451    let value = normalize_trim(spelling, expr);
452    let stripped = strip_marks(format, spelling, expr);
453    let like = |subject: &str, pattern: &str| {
454        format!("{subject} LIKE {prefix}'{}'", pattern.replace('\'', "''"))
455    };
456    let not_like = |subject: &str, pattern: &str| {
457        format!("{subject} NOT LIKE {prefix}'{}'", pattern.replace('\'', "''"))
458    };
459    let mut conditions = Vec::new();
460    let bracketed = format.negatives() == Negatives::Parentheses;
461    let plain = |symbol: &str| !symbol.contains(['%', '_', '['].as_slice());
462    let opens = format.opens_with();
463    if !opens.is_empty() && plain(opens) {
464        let before = if bracketed { "(" } else { "-" };
465        conditions.push(not_like(&value, &format!("%{opens}%{opens}%")));
466        conditions.push(format!(
467            "({} OR {} OR {})",
468            not_like(&value, &format!("%{opens}%")),
469            like(&value, &format!("{opens}%")),
470            like(&value, &format!("{before}{opens}%")),
471        ));
472    }
473    let closes = format.closes_with();
474    if !closes.is_empty() && plain(closes) {
475        let after = if bracketed { ")" } else { "" };
476        conditions.push(not_like(&value, &format!("%{closes}%{closes}%")));
477        conditions.push(format!(
478            "({} OR {} OR {})",
479            not_like(&value, &format!("%{closes}%")),
480            like(&value, &format!("%{closes}")),
481            like(&value, &format!("%{closes}{after}")),
482        ));
483    }
484    conditions.push(like(&canonical_shape(format, spelling, &stripped), "x0"));
485    if !bracketed && !opens.is_empty() {
486        conditions.push(not_like(&value, "_%-%"));
487    }
488    conditions
489}
490
491/// **A value with its marks out, written as the shape it has**: `x0` for one
492/// of digits — with a point between two runs of them where the picture has
493/// places, and a leading minus where it writes a negative that way — and
494/// anything else for any other value.
495///
496/// Every digit becomes `0`, a run of zeros becomes one, `0.0` becomes `0`, the
497/// text is put behind a letter, and a minus straight after the letter comes
498/// out: `-1,234.50` is `x0`, while `.5` is left `x.0`, `5.` is `x0.`, `1.2.3`
499/// is `x0.0`, `1e3` is `x0e0` and `--5` is `x-0`. Eight halvings take a run of
500/// up to 256 digits to one, far past any number a column holds. `REPLACE`,
501/// joining and `LIKE` alone, so it reads the same on every engine, a large
502/// object included; the letter keeps an engine that reads an empty text as
503/// nothing from comparing nothing.
504fn canonical_shape(format: &NumberFormat, spelling: SidSpelling, stripped: &str) -> String {
505    let replace = spelling.lower.replace;
506    let prefix = spelling.lower.pattern_literal_prefix;
507    let swap = |text: String, from: &str, to: &str| {
508        format!("{replace}({text}, {prefix}'{from}', {prefix}'{to}')")
509    };
510    let zeros =
511        (1..10u8).fold(stripped.to_owned(), |text, digit| swap(text, &digit.to_string(), "0"));
512    let run = (0..8).fold(zeros, |text, _| swap(text, "00", "0"));
513    let pointed =
514        if format.decimals() > 0 { swap(run, &format!("0{DECIMAL_POINT}0"), "0") } else { run };
515    let lettered = join_text(spelling, &format!("{prefix}'x'"), &pointed);
516    if format.negatives() == Negatives::Parentheses {
517        // A bracketed picture writes no minus, so one left is not its shape.
518        lettered
519    } else {
520        swap(lettered, "x-", "x")
521    }
522}
523
524/// **Two texts joined**, in this engine's own words for joining strings
525/// ([`SidSpelling::concat`]).
526fn join_text(spelling: SidSpelling, left: &str, right: &str) -> String {
527    match spelling.concat {
528        Concat::Function(name) | Concat::FunctionBinary(name) => format!("{name}({left}, {right})"),
529        Concat::Plus => format!("{left} + {right}"),
530        Concat::Operator(operator) => format!("{left} {operator} {right}"),
531    }
532}
533
534/// **The sign a bracketed negative carries**, as a factor to multiply by.
535///
536/// A picture that says nothing about brackets needs no factor: the minus sign
537/// the source writes survives the marks coming out, and the conversion reads
538/// it.
539fn bracket_sign(format: &NumberFormat, spelling: SidSpelling, expr: &str) -> Option<String> {
540    if format.negatives() != Negatives::Parentheses {
541        return None;
542    }
543    Some(format!("CASE WHEN {} LIKE '(%' THEN -1 ELSE 1 END", normalize_trim(spelling, expr)))
544}
545
546/// Multiply the reading by the sign, where the picture asks for one.
547fn signed(reading: String, sign: Option<String>) -> String {
548    match sign {
549        None => reading,
550        Some(sign) => format!("({reading}) * ({sign})"),
551    }
552}
553
554/// **For an engine whose own conversion answers nothing for a value it cannot
555/// read** — `TRY_CAST`, `SAFE_CAST`, `TRY_CONVERT` and their neighbours.
556///
557/// The value is held to the picture's shape first, in `LIKE`'s words (the
558/// rules [`NumberFormat::shape_pattern`] states); then the marks come out and
559/// the engine converts what is left. A value outside the shape, or one the conversion cannot read,
560/// answers nothing, which is what razeri reads as *this row did not parse*.
561///
562/// Parameters:
563/// - `format`: the picture the design stated;
564/// - `spelling`: this engine's vocabulary;
565/// - `expr`: an already-rendered text expression;
566/// - `safe_cast`: this engine's conversion, given the stripped text.
567#[must_use]
568pub fn read_with_safe_cast(
569    format: &NumberFormat,
570    spelling: SidSpelling,
571    expr: &str,
572    safe_cast: &dyn Fn(&str) -> String,
573) -> String {
574    let reading = safe_cast(&strip_marks(format, spelling, expr));
575    let mut conditions = shape_conditions(format, spelling, expr);
576    conditions.extend(separators_in_order(format, spelling, expr));
577    let guarded = format!("CASE WHEN {} THEN {reading} END", conditions.join(" AND "));
578    signed(guarded, bracket_sign(format, spelling, expr))
579}
580
581/// **For an engine whose own conversion raises, or answers a number for text
582/// that is not one** — the shape is checked first, and a value that fails the
583/// check is never handed to the conversion at all.
584///
585/// Parameters:
586/// - `format`: the picture the design stated;
587/// - `spelling`: this engine's vocabulary;
588/// - `expr`: an already-rendered text expression;
589/// - `cast`: this engine's conversion, given the stripped text;
590/// - `matches`: this engine's own test of a value against a regular
591///   expression, given the value and the pattern as a quoted literal.
592#[must_use]
593pub fn read_with_pattern_guard(
594    format: &NumberFormat,
595    spelling: SidSpelling,
596    expr: &str,
597    cast: &dyn Fn(&str) -> String,
598    matches: &dyn Fn(&str, &str) -> String,
599) -> String {
600    let trimmed = normalize_trim(spelling, expr);
601    let pattern = format!("'{}'", format.shape_pattern().replace('\'', "''"));
602    let reading = cast(&strip_marks(format, spelling, expr));
603    // The pattern already holds the thousands mark in front of the decimal
604    // point, so this arm needs no second condition for it.
605    let guarded = format!("CASE WHEN {} THEN {reading} END", matches(&trimmed, &pattern));
606    signed(guarded, bracket_sign(format, spelling, expr))
607}
608
609/// **For an engine with neither a conversion that answers nothing nor a way to
610/// match a pattern** — the value's shape, asked in `LIKE`'s words, is what
611/// says whether it was a number.
612///
613/// With the picture's marks already out, a value that was a number holds
614/// digits, at most one decimal point with a digit either side, and at most a
615/// leading minus. Write every digit as `0` and fold each run of zeros to one,
616/// and such a value is `0`, `0.0` or either with a minus in front, and nothing
617/// else — the same conditions [`read_with_safe_cast`] holds a value to.
618///
619/// Parameters:
620/// - `format`: the picture the design stated;
621/// - `spelling`: this engine's vocabulary;
622/// - `expr`: an already-rendered text expression;
623/// - `cast`: this engine's conversion, given the stripped text.
624#[must_use]
625pub fn read_with_digit_check(
626    format: &NumberFormat,
627    spelling: SidSpelling,
628    expr: &str,
629    cast: &dyn Fn(&str) -> String,
630) -> String {
631    let reading = cast(&strip_marks(format, spelling, expr));
632    let mut conditions = shape_conditions(format, spelling, expr);
633    conditions.extend(separators_in_order(format, spelling, expr));
634    let guarded = format!("CASE WHEN {} THEN {reading} END", conditions.join(" AND "));
635    signed(guarded, bracket_sign(format, spelling, expr))
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    #[test]
643    fn a_price_picture_says_a_symbol_thousands_and_two_places() {
644        let format = NumberFormat::parse("$#,##0.00").expect("a lawful picture");
645        assert_eq!(format.opens_with(), "$");
646        assert_eq!(format.closes_with(), "");
647        assert!(format.grouped());
648        assert_eq!(format.decimals(), 2);
649        assert_eq!(format.negatives(), Negatives::Minus);
650        assert_eq!(format.marks(), vec!["$".to_owned(), ",".to_owned()]);
651    }
652
653    #[test]
654    fn a_symbol_may_close_the_value_instead_of_opening_it() {
655        let format = NumberFormat::parse("#,##0.00 kr").expect("a lawful picture");
656        assert_eq!(format.opens_with(), "");
657        assert_eq!(format.closes_with(), " kr");
658        assert_eq!(format.marks(), vec![" kr".to_owned(), ",".to_owned()]);
659    }
660
661    #[test]
662    fn a_plain_picture_names_no_marks_at_all() {
663        let format = NumberFormat::parse("0.000").expect("a lawful picture");
664        assert!(!format.grouped());
665        assert_eq!(format.decimals(), 3);
666        assert!(format.marks().is_empty());
667        assert_eq!(format.shape_pattern(), "^-?[0-9]+([.][0-9]+)?$");
668    }
669
670    #[test]
671    fn a_ledger_negative_is_the_same_picture_in_brackets() {
672        let format = NumberFormat::parse("$#,##0.00;($#,##0.00)").expect("a lawful picture");
673        assert_eq!(format.negatives(), Negatives::Parentheses);
674        assert_eq!(
675            format.marks(),
676            vec!["$".to_owned(), ",".to_owned(), "(".to_owned(), ")".to_owned()]
677        );
678        assert_eq!(format.spelled(), "$#,##0.00;($#,##0.00)");
679    }
680
681    #[test]
682    fn the_shape_holds_the_symbol_and_the_groups_where_the_picture_put_them() {
683        let format = NumberFormat::parse("$#,##0.00").expect("a lawful picture");
684        assert_eq!(format.shape_pattern(), "^-?([$])?[0-9][0-9,]*([.][0-9]+)?$");
685    }
686
687    #[test]
688    fn the_reading_holds_the_thousands_mark_in_front_of_the_decimal_point() {
689        let format = NumberFormat::parse("$#,##0.00").expect("a lawful picture");
690        let read = read_with_safe_cast(&format, crate::ANSI_SID_SPELLING, "v", &|text| {
691            format!("TRY_CAST({text} AS DECIMAL(12, 2))")
692        });
693        // Taking the thousands mark out of `2.899,99` leaves `2.89999`, which
694        // converts to a price nobody wrote. The condition is what stops it.
695        assert!(read.contains("TRIM(v) NOT LIKE '%.%,%'"), "{read}");
696    }
697
698    #[test]
699    fn a_picture_with_no_thousands_mark_needs_no_such_condition() {
700        let format = NumberFormat::parse("0.00").expect("a lawful picture");
701        let read = read_with_safe_cast(&format, crate::ANSI_SID_SPELLING, "v", &|text| {
702            format!("TRY_CAST({text} AS DECIMAL(12, 2))")
703        });
704        assert!(!read.contains("'%.%,%'"), "no thousands mark to put in its place: {read}");
705        assert!(read.ends_with("THEN TRY_CAST(TRIM(v) AS DECIMAL(12, 2)) END"), "{read}");
706    }
707
708    /// **A safe-cast reading holds the value to the picture's shape first**
709    /// (`fr-1019-86`): a symbol at most once and at its own end, nothing but
710    /// digits, a point and a minus left, a digit either side of the point, a
711    /// minus only first. Executed on SQL
712    /// Server, `DuckDB` and `SQLite` over `12$`, `$$12`, `+5`, `.5` and `$-12`,
713    /// each set aside on all three.
714    #[test]
715    fn a_safe_cast_reading_holds_the_value_to_the_pictures_shape() {
716        let format = NumberFormat::parse("$#,##0.00").expect("a lawful picture");
717        let read = read_with_safe_cast(&format, crate::ANSI_SID_SPELLING, "v", &|text| {
718            format!("TRY_CAST({text} AS DECIMAL(12, 2))")
719        });
720        for guard in [
721            "TRIM(v) NOT LIKE '%$%$%'",
722            "TRIM(v) LIKE '-$%'",
723            "'00', '0'), '0.0', '0'))",
724            "'x-', 'x') LIKE 'x0'",
725            "TRIM(v) NOT LIKE '_%-%'",
726        ] {
727            assert!(read.contains(guard), "no {guard}: {read}");
728        }
729        let closing = NumberFormat::parse("#,##0.00 kr").expect("a lawful picture");
730        let read = read_with_safe_cast(&closing, crate::ANSI_SID_SPELLING, "v", &|text| {
731            format!("TRY_CAST({text} AS DECIMAL(12, 2))")
732        });
733        assert!(read.contains("TRIM(v) LIKE '% kr'"), "{read}");
734    }
735
736    #[test]
737    fn a_whole_numbers_digit_check_leaves_no_room_for_a_decimal_point() {
738        let whole = NumberFormat::parse("#0").expect("a lawful picture");
739        let read = read_with_digit_check(&whole, crate::ANSI_SID_SPELLING, "v", &|text| {
740            format!("CAST({text} AS INTEGER)")
741        });
742        // `1.5` is not a whole number, and an engine with no safe conversion
743        // would otherwise answer `1`.
744        assert!(read.contains("'x-', 'x') LIKE 'x0'") && !read.contains("'0.0', '0')"), "{read}");
745        let fractional = NumberFormat::parse("#0.0").expect("a lawful picture");
746        let read = read_with_digit_check(&fractional, crate::ANSI_SID_SPELLING, "v", &|text| {
747            format!("CAST({text} AS DECIMAL(18, 6))")
748        });
749        assert!(read.contains("'0.0', '0')), 'x-', 'x') LIKE 'x0'"), "{read}");
750    }
751
752    /// **The value is named a fixed few times, however its shape is asked.**
753    /// It is often a long expression of its own — a column read through a
754    /// guard of its width — and every mention repeats it, so a check that
755    /// named it once per digit doubled a whole load's text.
756    #[test]
757    fn a_reading_names_the_value_a_fixed_few_times() {
758        let value = "COLUMN_VALUE";
759        for picture in ["#0", "#0.0", "0.00"] {
760            let format = NumberFormat::parse(picture).expect("a lawful picture");
761            let read = read_with_safe_cast(&format, crate::ANSI_SID_SPELLING, value, &|text| {
762                format!("TRY_CAST({text} AS DECIMAL(12, 2))")
763            });
764            // The shape and the reading itself.
765            assert_eq!(read.matches(value).count(), 2, "{picture}: {read}");
766        }
767    }
768
769    #[test]
770    fn a_bracketed_negative_is_read_as_a_sign_rather_than_a_mark() {
771        let format = NumberFormat::parse("$#,##0.00;($#,##0.00)").expect("a lawful picture");
772        let read = read_with_safe_cast(&format, crate::ANSI_SID_SPELLING, "v", &|text| {
773            format!("TRY_CAST({text} AS DECIMAL(12, 2))")
774        });
775        assert!(read.contains("CASE WHEN TRIM(v) LIKE '(%' THEN -1 ELSE 1 END"), "{read}");
776        assert!(read.contains("REPLACE(REPLACE(REPLACE(REPLACE(TRIM(v)"), "{read}");
777    }
778
779    #[test]
780    fn a_picture_with_no_digits_is_refused() {
781        assert_eq!(NumberFormat::parse("$"), Err(NumberFormatError::NoDigits));
782        assert_eq!(NumberFormat::parse(""), Err(NumberFormatError::Empty));
783    }
784
785    #[test]
786    fn a_source_writing_the_separators_the_other_way_round_is_refused_by_name() {
787        let refused = NumberFormat::parse("#.##0,00").expect_err("not covered");
788        assert_eq!(refused, NumberFormatError::MarkAfterPoint);
789        assert!(refused.to_string().contains("2.899,99"), "{refused}");
790    }
791
792    #[test]
793    fn a_thousands_mark_separates_groups_of_three() {
794        assert_eq!(NumberFormat::parse("#,##0,0"), Err(NumberFormatError::GroupIsNotThree(1)));
795    }
796
797    #[test]
798    fn two_decimal_points_are_refused() {
799        assert_eq!(NumberFormat::parse("0.0.0"), Err(NumberFormatError::TwoPoints));
800    }
801
802    #[test]
803    fn digits_on_both_sides_of_the_symbol_are_refused() {
804        assert_eq!(NumberFormat::parse("0 kr 0"), Err(NumberFormatError::DigitsAfterSymbol));
805    }
806
807    #[test]
808    fn a_second_picture_that_is_not_the_first_in_brackets_is_refused() {
809        assert_eq!(
810            NumberFormat::parse("$#,##0.00;-$#,##0.00"),
811            Err(NumberFormatError::NegativeIsNotBracketed)
812        );
813        assert_eq!(NumberFormat::parse("0;(0);0"), Err(NumberFormatError::TooManyPictures));
814    }
815}