csv_managed/
data.rs

1use std::fmt;
2
3use anyhow::{Context, Result, anyhow, bail, ensure};
4use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
5use evalexpr;
6use rust_decimal::Decimal;
7use rust_decimal::RoundingStrategy;
8use rust_decimal::prelude::ToPrimitive;
9use serde::{Deserialize, Deserializer, Serialize, Serializer, de, ser::SerializeStruct};
10use std::str::FromStr;
11use uuid::Uuid;
12
13use crate::schema::{ColumnType, DecimalSpec};
14
15pub const CURRENCY_ALLOWED_SCALES: [u32; 2] = [2, 4];
16
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
18pub struct FixedDecimalValue {
19    amount: Decimal,
20    precision: u32,
21    scale: u32,
22}
23
24impl FixedDecimalValue {
25    pub fn parse(raw: &str, spec: &DecimalSpec) -> Result<Self> {
26        let decimal = parse_decimal_literal(raw)?;
27        Self::from_decimal(decimal, spec, None)
28    }
29
30    pub fn from_decimal(
31        value: Decimal,
32        spec: &DecimalSpec,
33        strategy: Option<&str>,
34    ) -> Result<Self> {
35        let mut decimal = value;
36        if let Some(strategy) = strategy {
37            decimal = match strategy {
38                "truncate" => decimal.round_dp_with_strategy(spec.scale, RoundingStrategy::ToZero),
39                "round" | "round-half-up" | "roundhalfup" => decimal
40                    .round_dp_with_strategy(spec.scale, RoundingStrategy::MidpointAwayFromZero),
41                other => bail!("Unsupported decimal rounding strategy '{other}'"),
42            };
43        }
44        Self::validate_decimal(&decimal, spec)?;
45        let mut quantized = decimal;
46        if quantized.scale() < spec.scale {
47            quantized.rescale(spec.scale);
48        }
49        Ok(Self {
50            amount: quantized,
51            precision: spec.precision,
52            scale: spec.scale,
53        })
54    }
55
56    pub fn amount(&self) -> &Decimal {
57        &self.amount
58    }
59
60    pub fn precision(&self) -> u32 {
61        self.precision
62    }
63
64    pub fn scale(&self) -> u32 {
65        self.scale
66    }
67
68    pub fn to_string_fixed(&self) -> String {
69        format_decimal_with_scale(self.amount, self.scale as usize)
70    }
71
72    pub fn to_f64(&self) -> Option<f64> {
73        self.amount.to_f64()
74    }
75
76    fn validate_decimal(decimal: &Decimal, spec: &DecimalSpec) -> Result<()> {
77        ensure!(
78            decimal.scale() <= spec.scale,
79            "Decimal values must not exceed scale {} (found {})",
80            spec.scale,
81            decimal.scale()
82        );
83        let integer_digits = count_integer_digits(decimal);
84        let max_integer_digits = (spec.precision - spec.scale) as usize;
85        ensure!(
86            integer_digits <= max_integer_digits,
87            "Decimal values must not exceed {} digit(s) to the left of the decimal point",
88            max_integer_digits
89        );
90        Ok(())
91    }
92}
93
94impl Serialize for FixedDecimalValue {
95    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
96    where
97        S: Serializer,
98    {
99        let mut state = serializer.serialize_struct("FixedDecimalValue", 3)?;
100        state.serialize_field("amount", &self.to_string_fixed())?;
101        state.serialize_field("precision", &self.precision)?;
102        state.serialize_field("scale", &self.scale)?;
103        state.end()
104    }
105}
106
107impl<'de> Deserialize<'de> for FixedDecimalValue {
108    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
109    where
110        D: Deserializer<'de>,
111    {
112        #[derive(Deserialize)]
113        struct FixedDecimalValueRepr {
114            amount: String,
115            precision: u32,
116            scale: u32,
117        }
118
119        let repr = FixedDecimalValueRepr::deserialize(deserializer)?;
120        let spec = DecimalSpec::new(repr.precision, repr.scale)
121            .map_err(|err| de::Error::custom(err.to_string()))?;
122        let decimal =
123            Decimal::from_str(&repr.amount).map_err(|err| de::Error::custom(err.to_string()))?;
124        FixedDecimalValue::from_decimal(decimal, &spec, None)
125            .map_err(|err| de::Error::custom(err.to_string()))
126    }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
130pub struct CurrencyValue {
131    amount: Decimal,
132}
133
134impl CurrencyValue {
135    pub fn parse(raw: &str) -> Result<Self> {
136        let decimal = parse_currency_decimal(raw)?;
137        Self::from_decimal(decimal).with_context(|| format!("Parsing '{raw}' as currency"))
138    }
139
140    pub fn from_decimal(mut amount: Decimal) -> Result<Self> {
141        match amount.scale() {
142            0 => {
143                amount.rescale(2);
144            }
145            scale if CURRENCY_ALLOWED_SCALES.contains(&scale) => {}
146            other => {
147                bail!("Currency values must have 2 or 4 decimal places (found {other})");
148            }
149        }
150        Ok(Self { amount })
151    }
152
153    pub fn quantize(mut amount: Decimal, scale: u32, strategy: Option<&str>) -> Result<Self> {
154        ensure!(
155            CURRENCY_ALLOWED_SCALES.contains(&scale),
156            "Currency scale must be 2 or 4"
157        );
158        match strategy {
159            Some("truncate") => {
160                amount = amount.round_dp_with_strategy(scale, RoundingStrategy::ToZero);
161            }
162            Some("round") | Some("round-half-up") | Some("roundhalfup") | None => {
163                amount =
164                    amount.round_dp_with_strategy(scale, RoundingStrategy::MidpointAwayFromZero);
165            }
166            Some(other) => {
167                bail!("Unsupported currency rounding strategy '{other}'");
168            }
169        }
170        Self::from_decimal(amount)
171    }
172
173    pub fn amount(&self) -> &Decimal {
174        &self.amount
175    }
176
177    pub fn scale(&self) -> u32 {
178        self.amount.scale()
179    }
180
181    pub fn to_string_fixed(&self) -> String {
182        format_decimal_with_scale(self.amount, self.amount.scale() as usize)
183    }
184
185    pub fn to_f64(&self) -> Option<f64> {
186        self.amount.to_f64()
187    }
188}
189
190impl Serialize for CurrencyValue {
191    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
192    where
193        S: Serializer,
194    {
195        serializer.serialize_str(&self.to_string_fixed())
196    }
197}
198
199impl<'de> Deserialize<'de> for CurrencyValue {
200    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
201    where
202        D: Deserializer<'de>,
203    {
204        let token = String::deserialize(deserializer)?;
205        CurrencyValue::parse(&token).map_err(|err| de::Error::custom(err.to_string()))
206    }
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
210pub enum Value {
211    String(String),
212    Integer(i64),
213    Float(f64),
214    Boolean(bool),
215    Date(NaiveDate),
216    DateTime(NaiveDateTime),
217    Time(NaiveTime),
218    Guid(Uuid),
219    Decimal(FixedDecimalValue),
220    Currency(CurrencyValue),
221}
222
223impl Eq for Value {}
224
225impl Value {
226    pub fn as_display(&self) -> String {
227        match self {
228            Value::String(s) => s.clone(),
229            Value::Integer(i) => i.to_string(),
230            Value::Float(f) => {
231                if f.fract() == 0.0 {
232                    (*f as i64).to_string()
233                } else {
234                    f.to_string()
235                }
236            }
237            Value::Boolean(b) => b.to_string(),
238            Value::Date(d) => d.format("%Y-%m-%d").to_string(),
239            Value::DateTime(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
240            Value::Time(t) => t.format("%H:%M:%S").to_string(),
241            Value::Guid(g) => g.to_string(),
242            Value::Decimal(d) => d.to_string_fixed(),
243            Value::Currency(c) => c.to_string_fixed(),
244        }
245    }
246}
247
248impl Ord for Value {
249    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
250        match (self, other) {
251            (Value::String(a), Value::String(b)) => a.cmp(b),
252            (Value::Integer(a), Value::Integer(b)) => a.cmp(b),
253            (Value::Float(a), Value::Float(b)) => a.total_cmp(b),
254            (Value::Boolean(a), Value::Boolean(b)) => a.cmp(b),
255            (Value::Date(a), Value::Date(b)) => a.cmp(b),
256            (Value::DateTime(a), Value::DateTime(b)) => a.cmp(b),
257            (Value::Time(a), Value::Time(b)) => a.cmp(b),
258            (Value::Guid(a), Value::Guid(b)) => a.cmp(b),
259            (Value::Decimal(a), Value::Decimal(b)) => a.cmp(b),
260            (Value::Currency(a), Value::Currency(b)) => a.cmp(b),
261            _ => panic!("Cannot compare heterogeneous Value variants"),
262        }
263    }
264}
265
266impl PartialOrd for Value {
267    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
268        Some(self.cmp(other))
269    }
270}
271
272impl fmt::Display for Value {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        write!(f, "{}", self.as_display())
275    }
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
279pub struct ComparableValue(pub Option<Value>);
280
281impl Ord for ComparableValue {
282    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
283        match (&self.0, &other.0) {
284            (None, None) => std::cmp::Ordering::Equal,
285            (None, Some(_)) => std::cmp::Ordering::Less,
286            (Some(_), None) => std::cmp::Ordering::Greater,
287            (Some(left), Some(right)) => left.cmp(right),
288        }
289    }
290}
291
292impl PartialOrd for ComparableValue {
293    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
294        Some(self.cmp(other))
295    }
296}
297
298pub fn parse_naive_date(value: &str) -> Result<NaiveDate> {
299    const DATE_FORMATS: &[&str] = &["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%Y/%m/%d", "%d-%m-%Y"];
300    for fmt in DATE_FORMATS {
301        if let Ok(parsed) = NaiveDate::parse_from_str(value, fmt) {
302            return Ok(parsed);
303        }
304    }
305    Err(anyhow!("Failed to parse '{value}' as date"))
306}
307
308pub fn parse_naive_datetime(value: &str) -> Result<NaiveDateTime> {
309    const DATETIME_FORMATS: &[&str] = &[
310        "%Y-%m-%d %H:%M:%S",
311        "%Y-%m-%dT%H:%M:%S",
312        "%d/%m/%Y %H:%M:%S",
313        "%m/%d/%Y %H:%M:%S",
314        "%Y-%m-%d %H:%M",
315        "%Y-%m-%dT%H:%M",
316    ];
317    for fmt in DATETIME_FORMATS {
318        if let Ok(parsed) = NaiveDateTime::parse_from_str(value, fmt) {
319            return Ok(parsed);
320        }
321    }
322    Err(anyhow!("Failed to parse '{value}' as datetime"))
323}
324
325pub fn parse_naive_time(value: &str) -> Result<NaiveTime> {
326    const TIME_FORMATS: &[&str] = &["%H:%M:%S", "%H:%M"];
327    for fmt in TIME_FORMATS {
328        if let Ok(parsed) = NaiveTime::parse_from_str(value, fmt) {
329            return Ok(parsed);
330        }
331    }
332    Err(anyhow!("Failed to parse '{value}' as time"))
333}
334
335pub fn normalize_column_name(name: &str) -> String {
336    let mut normalized: String = name
337        .chars()
338        .map(|c| match c {
339            'a'..='z' | 'A'..='Z' | '0'..='9' => c,
340            _ => '_',
341        })
342        .collect();
343
344    if normalized.is_empty() {
345        normalized.push_str("column");
346    }
347
348    if normalized
349        .chars()
350        .next()
351        .is_none_or(|c| !(c.is_ascii_alphabetic() || c == '_'))
352    {
353        normalized.insert(0, '_');
354    }
355
356    normalized.to_ascii_lowercase()
357}
358
359pub fn parse_typed_value(value: &str, ty: &ColumnType) -> Result<Option<Value>> {
360    if value.is_empty() {
361        return Ok(None);
362    }
363    let parsed = match ty {
364        ColumnType::String => Value::String(value.to_string()),
365        ColumnType::Integer => {
366            let parsed: i64 = value
367                .parse()
368                .with_context(|| format!("Failed to parse '{value}' as integer"))?;
369            Value::Integer(parsed)
370        }
371        ColumnType::Float => {
372            let parsed: f64 = value
373                .parse()
374                .with_context(|| format!("Failed to parse '{value}' as float"))?;
375            Value::Float(parsed)
376        }
377        ColumnType::Boolean => {
378            let lowered = value.to_ascii_lowercase();
379            let parsed = match lowered.as_str() {
380                "true" | "t" | "yes" | "y" | "1" => true,
381                "false" | "f" | "no" | "n" | "0" => false,
382                _ => bail!("Failed to parse '{value}' as boolean"),
383            };
384            Value::Boolean(parsed)
385        }
386        ColumnType::Date => {
387            let parsed = parse_naive_date(value)?;
388            Value::Date(parsed)
389        }
390        ColumnType::DateTime => {
391            let parsed = parse_naive_datetime(value)?;
392            Value::DateTime(parsed)
393        }
394        ColumnType::Time => {
395            let parsed = parse_naive_time(value)?;
396            Value::Time(parsed)
397        }
398        ColumnType::Guid => {
399            let trimmed = value.trim().trim_matches(|c| matches!(c, '{' | '}'));
400            let parsed = Uuid::parse_str(trimmed)
401                .with_context(|| format!("Failed to parse '{value}' as GUID"))?;
402            Value::Guid(parsed)
403        }
404        ColumnType::Decimal(spec) => {
405            let parsed = FixedDecimalValue::parse(value, spec)?;
406            Value::Decimal(parsed)
407        }
408        ColumnType::Currency => {
409            let parsed = CurrencyValue::parse(value)?;
410            Value::Currency(parsed)
411        }
412    };
413    Ok(Some(parsed))
414}
415
416pub fn value_to_evalexpr(value: &Value) -> evalexpr::Value {
417    match value {
418        Value::String(s) => evalexpr::Value::String(s.clone()),
419        Value::Integer(i) => evalexpr::Value::Int(*i),
420        Value::Float(f) => evalexpr::Value::Float(*f),
421        Value::Boolean(b) => evalexpr::Value::Boolean(*b),
422        Value::Date(d) => evalexpr::Value::String(d.format("%Y-%m-%d").to_string()),
423        Value::DateTime(dt) => evalexpr::Value::String(dt.format("%Y-%m-%d %H:%M:%S").to_string()),
424        Value::Time(t) => evalexpr::Value::String(t.format("%H:%M:%S").to_string()),
425        Value::Guid(g) => evalexpr::Value::String(g.to_string()),
426        Value::Decimal(d) => d
427            .to_f64()
428            .map(evalexpr::Value::Float)
429            .unwrap_or_else(|| evalexpr::Value::String(d.to_string_fixed())),
430        Value::Currency(c) => c
431            .to_f64()
432            .map(evalexpr::Value::Float)
433            .unwrap_or_else(|| evalexpr::Value::String(c.to_string_fixed())),
434    }
435}
436
437pub fn parse_decimal_literal(raw: &str) -> Result<Decimal> {
438    let trimmed = raw.trim();
439    if trimmed.is_empty() {
440        bail!("Decimal value is empty");
441    }
442
443    let mut negative = false;
444    let mut body = trimmed;
445    if body.starts_with('(') && body.ends_with(')') {
446        negative = true;
447        body = &body[1..body.len() - 1];
448    }
449
450    body = body.trim();
451    if body.starts_with('-') {
452        negative = true;
453        body = &body[1..];
454    } else if body.starts_with('+') {
455        body = &body[1..];
456    }
457
458    body = body.trim();
459    let mut sanitized = String::with_capacity(body.len() + 1);
460    let mut decimal_seen = false;
461    for ch in body.chars() {
462        match ch {
463            '0'..='9' => sanitized.push(ch),
464            '.' => {
465                if decimal_seen {
466                    bail!("Decimal value '{raw}' contains multiple decimal points");
467                }
468                decimal_seen = true;
469                sanitized.push(ch);
470            }
471            ',' | '_' | ' ' => {
472                // Skip common thousands separators and spacing.
473            }
474            _ => {
475                bail!("Decimal value '{raw}' contains unsupported character '{ch}'");
476            }
477        }
478    }
479
480    ensure!(
481        sanitized.chars().any(|c| c.is_ascii_digit()),
482        "Decimal value '{raw}' does not contain digits"
483    );
484
485    if negative {
486        sanitized.insert(0, '-');
487    }
488
489    Decimal::from_str(&sanitized).with_context(|| format!("Parsing '{raw}' as decimal"))
490}
491
492fn format_decimal_with_scale(mut value: Decimal, scale: usize) -> String {
493    let target_scale = scale as u32;
494    if value.scale() < target_scale {
495        value.rescale(target_scale);
496    }
497    if scale == 0 {
498        let mut rendered = value.to_string();
499        if let Some(idx) = rendered.find('.') {
500            rendered.truncate(idx);
501        }
502        return rendered;
503    }
504    let rendered = value.to_string();
505    let actual = rendered
506        .split_once('.')
507        .map(|(_, frac)| frac.len())
508        .unwrap_or(0);
509    if actual == scale {
510        return rendered;
511    }
512    if let Some((whole, frac)) = rendered.split_once('.') {
513        let mut buf = String::new();
514        buf.push_str(whole);
515        buf.push('.');
516        buf.push_str(frac);
517        for _ in 0..(scale.saturating_sub(actual)) {
518            buf.push('0');
519        }
520        return buf;
521    }
522    let mut buf = String::new();
523    buf.push_str(&rendered);
524    buf.push('.');
525    for _ in 0..scale {
526        buf.push('0');
527    }
528    buf
529}
530
531fn count_integer_digits(decimal: &Decimal) -> usize {
532    let abs = decimal.abs();
533    if abs < Decimal::ONE {
534        return 0;
535    }
536    abs.trunc()
537        .to_string()
538        .chars()
539        .filter(|c| c.is_ascii_digit())
540        .count()
541}
542
543pub fn parse_currency_decimal(raw: &str) -> Result<Decimal> {
544    let trimmed = raw.trim();
545    if trimmed.is_empty() {
546        bail!("Currency value is empty");
547    }
548
549    let mut negative = false;
550    let mut body = trimmed;
551    if body.starts_with('(') && body.ends_with(')') {
552        negative = true;
553        body = &body[1..body.len() - 1];
554    }
555
556    body = body.trim();
557    if body.starts_with('-') {
558        negative = true;
559        body = &body[1..];
560    } else if body.starts_with('+') {
561        body = &body[1..];
562    }
563
564    body = body.trim();
565    let mut sanitized = String::with_capacity(body.len() + 1);
566    let mut decimal_seen = false;
567    for ch in body.chars() {
568        match ch {
569            '0'..='9' => sanitized.push(ch),
570            '.' => {
571                if decimal_seen {
572                    bail!("Currency value '{raw}' contains multiple decimal points");
573                }
574                decimal_seen = true;
575                sanitized.push(ch);
576            }
577            ',' | '_' | ' ' => {
578                // Skip common thousands separators and spacing.
579            }
580            '$' | '€' | '£' | '¥' => {
581                // Skip well-known currency symbols.
582            }
583            _ => {
584                bail!("Currency value '{raw}' contains unsupported character '{ch}'");
585            }
586        }
587    }
588
589    ensure!(
590        sanitized.chars().any(|c| c.is_ascii_digit()),
591        "Currency value '{raw}' does not contain digits"
592    );
593
594    if negative {
595        sanitized.insert(0, '-');
596    }
597
598    Decimal::from_str(&sanitized).with_context(|| format!("Parsing '{raw}' as decimal"))
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604    use crate::schema::{ColumnType, DecimalSpec};
605    use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
606    use evalexpr::Value as EvalValue;
607    use rust_decimal::Decimal;
608    use std::str::FromStr;
609    use uuid::Uuid;
610
611    #[test]
612    fn normalize_column_name_replaces_non_alphanumeric() {
613        assert_eq!(normalize_column_name("Order ID"), "order_id");
614        assert_eq!(normalize_column_name("$Percent%"), "_percent_");
615        assert_eq!(normalize_column_name("123Metric"), "_123metric");
616        assert_eq!(normalize_column_name(""), "column");
617    }
618
619    #[test]
620    fn parse_naive_date_supports_multiple_formats() {
621        let expected = NaiveDate::from_ymd_opt(2024, 5, 6).unwrap();
622        assert_eq!(parse_naive_date("2024-05-06").unwrap(), expected);
623        assert_eq!(parse_naive_date("06/05/2024").unwrap(), expected);
624        assert_eq!(parse_naive_date("2024/05/06").unwrap(), expected);
625    }
626
627    #[test]
628    fn parse_naive_datetime_supports_multiple_formats() {
629        let expected =
630            NaiveDateTime::parse_from_str("2024-05-06 14:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
631        assert_eq!(
632            parse_naive_datetime("2024-05-06T14:30:00").unwrap(),
633            expected
634        );
635        assert_eq!(
636            parse_naive_datetime("06/05/2024 14:30:00").unwrap(),
637            expected
638        );
639        assert_eq!(parse_naive_datetime("2024-05-06 14:30").unwrap(), expected);
640    }
641
642    #[test]
643    fn parse_naive_time_supports_multiple_formats() {
644        let expected = NaiveTime::from_hms_opt(14, 30, 0).unwrap();
645        assert_eq!(parse_naive_time("14:30:00").unwrap(), expected);
646        assert_eq!(parse_naive_time("14:30").unwrap(), expected);
647        assert!(parse_naive_time("24:61").is_err());
648    }
649
650    #[test]
651    fn parse_typed_value_handles_empty_and_boolean_inputs() {
652        assert_eq!(parse_typed_value("", &ColumnType::Integer).unwrap(), None);
653
654        let truthy = parse_typed_value("Yes", &ColumnType::Boolean)
655            .unwrap()
656            .unwrap();
657        assert_eq!(truthy, Value::Boolean(true));
658
659        let falsy = parse_typed_value("0", &ColumnType::Boolean)
660            .unwrap()
661            .unwrap();
662        assert_eq!(falsy, Value::Boolean(false));
663
664        assert!(parse_typed_value("maybe", &ColumnType::Boolean).is_err());
665    }
666
667    #[test]
668    fn parse_typed_value_supports_guid_inputs() {
669        let raw = "550e8400-e29b-41d4-a716-446655440000";
670        let parsed = parse_typed_value(raw, &ColumnType::Guid).unwrap().unwrap();
671        match parsed {
672            Value::Guid(g) => {
673                assert_eq!(g, Uuid::parse_str(raw).unwrap());
674            }
675            other => panic!("Expected GUID value, got {other:?}"),
676        }
677
678        let braced = "{550e8400-e29b-41d4-a716-446655440000}";
679        let parsed_braced = parse_typed_value(braced, &ColumnType::Guid)
680            .unwrap()
681            .unwrap();
682        assert!(matches!(parsed_braced, Value::Guid(_)));
683
684        assert!(parse_typed_value("not-a-guid", &ColumnType::Guid).is_err());
685    }
686
687    #[test]
688    fn value_to_evalexpr_preserves_variants() {
689        assert_eq!(value_to_evalexpr(&Value::Integer(42)), EvalValue::Int(42));
690        assert_eq!(
691            value_to_evalexpr(&Value::Boolean(false)),
692            EvalValue::Boolean(false)
693        );
694
695        let date = NaiveDate::from_ymd_opt(2024, 5, 6).unwrap();
696        assert_eq!(
697            value_to_evalexpr(&Value::Date(date)),
698            EvalValue::String("2024-05-06".to_string())
699        );
700    }
701
702    #[test]
703    fn comparable_value_orders_none_before_some() {
704        let none = ComparableValue(None);
705        let some = ComparableValue(Some(Value::Integer(0)));
706        assert!(none < some);
707    }
708
709    #[test]
710    fn parse_currency_values_accepts_two_and_four_decimals() {
711        let two = parse_typed_value("$1,234.56", &ColumnType::Currency)
712            .unwrap()
713            .unwrap();
714        let four = parse_typed_value("123.4567", &ColumnType::Currency)
715            .unwrap()
716            .unwrap();
717        match (two, four) {
718            (Value::Currency(a), Value::Currency(b)) => {
719                assert_eq!(a.scale(), 2);
720                assert_eq!(a.to_string_fixed(), "1234.56");
721                assert_eq!(b.scale(), 4);
722                assert_eq!(b.to_string_fixed(), "123.4567");
723            }
724            _ => panic!("Expected currency values"),
725        }
726    }
727
728    #[test]
729    fn parse_currency_rejects_invalid_precision() {
730        assert!(parse_typed_value("1.234", &ColumnType::Currency).is_err());
731        assert!(parse_typed_value("abc", &ColumnType::Currency).is_err());
732    }
733
734    #[test]
735    fn parse_currency_rejects_embedded_letters() {
736        let err = parse_typed_value("12a.34", &ColumnType::Currency)
737            .expect_err("currency parser should reject embedded letters");
738        assert!(err.to_string().contains("contains unsupported character"));
739    }
740
741    #[test]
742    fn currency_quantize_rounds_half_away_from_zero() {
743        let decimal = Decimal::from_str("10.005").unwrap();
744        let value = CurrencyValue::quantize(decimal, 2, None).expect("round currency");
745        assert_eq!(value.to_string_fixed(), "10.01");
746    }
747
748    #[test]
749    fn currency_quantize_truncates_values() {
750        let decimal = Decimal::from_str("7.899").unwrap();
751        let value =
752            CurrencyValue::quantize(decimal, 2, Some("truncate")).expect("truncate currency");
753        assert_eq!(value.to_string_fixed(), "7.89");
754    }
755
756    #[test]
757    fn currency_quantize_truncates_four_decimal_precision() {
758        let decimal = Decimal::from_str("1.234567").unwrap();
759        let value =
760            CurrencyValue::quantize(decimal, 4, Some("truncate")).expect("truncate currency");
761        assert_eq!(value.to_string_fixed(), "1.2345");
762    }
763
764    #[test]
765    fn currency_quantize_rejects_invalid_strategy() {
766        let decimal = Decimal::from_str("1.00").unwrap();
767        assert!(CurrencyValue::quantize(decimal, 2, Some("ceil")).is_err());
768    }
769
770    #[test]
771    fn currency_quantize_rejects_invalid_scale() {
772        let decimal = Decimal::from_str("1.00").unwrap();
773        assert!(CurrencyValue::quantize(decimal, 3, None).is_err());
774    }
775
776    #[test]
777    fn currency_to_string_fixed_pads_fractional_zeros() {
778        let value = CurrencyValue::parse("42").expect("parse integer currency");
779        assert_eq!(value.to_string_fixed(), "42.00");
780    }
781
782    #[test]
783    fn fixed_decimal_value_truncate_strategy_respects_scale() {
784        let spec = DecimalSpec::new(8, 2).expect("valid decimal spec");
785        let decimal = Decimal::from_str("123.456").expect("valid decimal literal");
786        let value = FixedDecimalValue::from_decimal(decimal, &spec, Some("truncate"))
787            .expect("truncate decimal");
788        assert_eq!(value.to_string_fixed(), "123.45");
789        assert_eq!(value.scale(), 2);
790    }
791
792    #[test]
793    fn fixed_decimal_value_round_strategy_respects_scale() {
794        let spec = DecimalSpec::new(10, 3).expect("valid decimal spec");
795        let decimal = Decimal::from_str("-87.6549").expect("valid decimal literal");
796        let value =
797            FixedDecimalValue::from_decimal(decimal, &spec, Some("round")).expect("round decimal");
798        assert_eq!(value.to_string_fixed(), "-87.655");
799        assert_eq!(value.scale(), 3);
800    }
801
802    #[test]
803    fn fixed_decimal_value_rejects_precision_overflow() {
804        let spec = DecimalSpec::new(6, 2).expect("valid decimal spec");
805        let decimal = Decimal::from_str("12345.67").expect("decimal literal");
806        let err =
807            FixedDecimalValue::from_decimal(decimal, &spec, None).expect_err("precision overflow");
808        assert!(err.to_string().contains("must not exceed"));
809    }
810
811    #[test]
812    fn fixed_decimal_value_rescales_short_fractional_parts() {
813        let spec = DecimalSpec::new(12, 4).expect("valid decimal spec");
814        let decimal = Decimal::from_str("42").expect("whole number decimal");
815        let value = FixedDecimalValue::from_decimal(decimal, &spec, None).expect("rescale decimal");
816        assert_eq!(value.to_string_fixed(), "42.0000");
817        assert_eq!(value.scale(), 4);
818    }
819
820    #[test]
821    fn parse_decimal_literal_supports_parentheses_and_separators() {
822        let parsed = parse_decimal_literal("(1,234.50)").expect("parse negative grouped decimal");
823        assert_eq!(parsed, Decimal::from_str("-1234.50").unwrap());
824    }
825
826    #[test]
827    fn parse_decimal_literal_supports_positive_sign_and_underscores() {
828        let parsed = parse_decimal_literal(" +7_654.321 ").expect("parse underscored decimal");
829        assert_eq!(parsed, Decimal::from_str("7654.321").unwrap());
830    }
831
832    #[test]
833    fn parse_decimal_literal_rejects_invalid_characters() {
834        assert!(parse_decimal_literal("12a.34").is_err());
835        assert!(parse_decimal_literal("#42.0").is_err());
836    }
837
838    #[test]
839    fn parse_decimal_literal_rejects_multiple_decimal_points() {
840        assert!(parse_decimal_literal("1.2.3").is_err());
841    }
842
843    #[test]
844    fn parse_decimal_values_enforce_precision_and_scale() {
845        let spec = DecimalSpec::new(10, 4).expect("valid decimal spec");
846        let decimal_type = ColumnType::Decimal(spec.clone());
847        let parsed = parse_typed_value("123.4567", &decimal_type)
848            .expect("parse decimal")
849            .expect("non-empty decimal");
850        match parsed {
851            Value::Decimal(value) => {
852                assert_eq!(value.scale(), 4);
853                assert_eq!(value.precision(), 10);
854                assert_eq!(value.to_string_fixed(), "123.4567");
855            }
856            other => panic!("Expected decimal value, got {other:?}"),
857        }
858
859        let narrow_spec = DecimalSpec::new(6, 2).expect("valid decimal spec");
860        let narrow_type = ColumnType::Decimal(narrow_spec);
861        assert!(parse_typed_value("123.456", &narrow_type).is_err());
862        assert!(parse_typed_value("1234567", &narrow_type).is_err());
863    }
864}