Skip to content

Commit 085f6cb

Browse files
committed
feat(currency): Add accounting formatting and relax constructor validation (#8177)
1 parent 4ee1998 commit 085f6cb

6 files changed

Lines changed: 146 additions & 66 deletions

File tree

components/experimental/src/dimension/currency/compact_formatter.rs

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ use super::{
2222
CurrencyCode,
2323
options::{CurrencyFormatterOptions, CurrencyUsage},
2424
};
25-
use icu_pattern::DoublePlaceholderPattern;
2625

2726
extern crate alloc;
2827

@@ -116,10 +115,6 @@ impl CompactCurrencyFormatter {
116115
})?
117116
.payload;
118117

119-
if essential.get().standard_pattern().is_none() {
120-
return Err(DataError::custom("missing standard pattern"));
121-
}
122-
123118
let decimal_formatter = DecimalFormatter::try_new((&prefs).into(), Default::default())?;
124119

125120
let compact_data = DataProvider::<icu_decimal::provider::DecimalCompactShortV1>::load(
@@ -202,10 +197,6 @@ impl CompactCurrencyFormatter {
202197
})?
203198
.payload;
204199

205-
if essential.get().standard_pattern().is_none() {
206-
return Err(DataError::custom("missing standard pattern"));
207-
}
208-
209200
let fractions = provider.load(Default::default())?.payload;
210201

211202
Ok(Self {
@@ -243,18 +234,18 @@ impl CompactCurrencyFormatter {
243234
let (currency_placeholder, pos_pattern, neg_pattern, pattern_selection) = self
244235
.essential
245236
.get()
246-
.name_and_pattern(self.options.width, currency_code);
237+
.name_and_pattern(self.options.width, self.options.usage, currency_code);
247238

248239
let mut sign = value.sign;
249240
let pattern = if sign == Sign::Negative {
250241
if let Some(neg_pattern) = neg_pattern {
251242
sign = Sign::None;
252243
neg_pattern
253244
} else {
254-
pos_pattern.unwrap_or_else(|| <&DoublePlaceholderPattern>::default())
245+
pos_pattern
255246
}
256247
} else {
257-
pos_pattern.unwrap_or_else(|| <&DoublePlaceholderPattern>::default())
248+
pos_pattern
258249
};
259250

260251
let fraction_info = self.resolve_fraction_info(

components/experimental/src/dimension/currency/format.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,4 +396,42 @@ mod tests {
396396
"-$12K"
397397
);
398398
}
399+
400+
#[test]
401+
pub fn test_accounting_formatting() {
402+
let prefs: CurrencyFormatterPreferences = locale!("en").into();
403+
let currency_code = CurrencyCode(tinystr!(3, "USD"));
404+
let value = "-12345.67".parse().unwrap();
405+
406+
let fmt_accounting = CurrencyFormatter::<Decimal>::try_new_short(
407+
prefs,
408+
CurrencyFormatterOptions {
409+
usage: CurrencyUsage::Accounting,
410+
..Default::default()
411+
},
412+
&currency_code,
413+
)
414+
.unwrap();
415+
assert_writeable_eq!(fmt_accounting.format_fixed_decimal(&value), "($12,345.67)");
416+
}
417+
418+
#[test]
419+
pub fn test_compact_accounting_formatting() {
420+
let prefs: CompactCurrencyFormatterPreferences = locale!("en").into();
421+
let currency_code = CurrencyCode(tinystr!(3, "USD"));
422+
let value = "-12345.67".parse().unwrap();
423+
424+
let fmt_accounting = CompactCurrencyFormatter::try_new(
425+
prefs,
426+
CurrencyFormatterOptions {
427+
usage: CurrencyUsage::Accounting,
428+
..Default::default()
429+
},
430+
)
431+
.unwrap();
432+
assert_writeable_eq!(
433+
fmt_accounting.format_fixed_decimal(&value, &currency_code),
434+
"($12K)"
435+
);
436+
}
399437
}

components/experimental/src/dimension/currency/formatter.rs

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ use super::super::provider::currency::{
2323
use super::CurrencyCode;
2424
use super::options::{CurrencyFormatterOptions, CurrencyUsage, Width};
2525
use fixed_decimal::{RoundingIncrement, Sign, SignedRoundingMode, UnsignedRoundingMode};
26-
use icu_pattern::DoublePlaceholderPattern;
2726

2827
extern crate alloc;
2928

@@ -153,10 +152,6 @@ impl CurrencyFormatter<Decimal> {
153152
let essential =
154153
load_with_fallback::<CurrencyEssentialsV1>(&crate::provider::Baked, ids)?.payload;
155154

156-
if essential.get().standard_pattern().is_none() {
157-
return Err(DataError::custom("missing standard pattern"));
158-
}
159-
160155
let fractions = crate::provider::Baked.load(Default::default())?.payload;
161156

162157
Ok(Self {
@@ -192,10 +187,6 @@ impl CurrencyFormatter<Decimal> {
192187
let essential =
193188
load_with_fallback::<CurrencyEssentialsV1>(&crate::provider::Baked, ids)?.payload;
194189

195-
if essential.get().standard_pattern().is_none() {
196-
return Err(DataError::custom("missing standard pattern"));
197-
}
198-
199190
let fractions = crate::provider::Baked.load(Default::default())?.payload;
200191

201192
Ok(Self {
@@ -239,10 +230,6 @@ impl CurrencyFormatter<Decimal> {
239230
let ids = req_id.into_iter().chain(core::iter::once(default_id));
240231
let essential = load_with_fallback::<CurrencyEssentialsV1>(provider, ids)?.payload;
241232

242-
if essential.get().standard_pattern().is_none() {
243-
return Err(DataError::custom("missing standard pattern"));
244-
}
245-
246233
let fractions = provider.load(Default::default())?.payload;
247234

248235
Ok(Self {
@@ -282,10 +269,6 @@ impl CurrencyFormatter<Decimal> {
282269
let ids = req_id.into_iter().chain(core::iter::once(default_id));
283270
let essential = load_with_fallback::<CurrencyEssentialsV1>(provider, ids)?.payload;
284271

285-
if essential.get().standard_pattern().is_none() {
286-
return Err(DataError::custom("missing standard pattern"));
287-
}
288-
289272
let fractions = provider.load(Default::default())?.payload;
290273

291274
Ok(Self {
@@ -472,18 +455,18 @@ impl CurrencyFormatter<Decimal> {
472455
// an exact decimal/money representation.
473456
let (currency_str, pos_pattern, neg_pattern, pattern_selection) = essential
474457
.get()
475-
.name_and_pattern(self.options.width, &self.bound_currency);
458+
.name_and_pattern(self.options.width, self.options.usage, &self.bound_currency);
476459

477460
let mut sign = value.sign;
478461
let pattern = if sign == Sign::Negative {
479462
if let Some(neg_pattern) = neg_pattern {
480463
sign = Sign::None;
481464
neg_pattern
482465
} else {
483-
pos_pattern.unwrap_or_else(|| <&DoublePlaceholderPattern>::default())
466+
pos_pattern
484467
}
485468
} else {
486-
pos_pattern.unwrap_or_else(|| <&DoublePlaceholderPattern>::default())
469+
pos_pattern
487470
};
488471

489472
let fraction_info =

components/experimental/src/dimension/currency/options.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ pub enum CurrencyUsage {
4040
Standard,
4141
/// Cash currency formatting (may use different rounding).
4242
Cash,
43+
/// Accounting currency formatting (may use different patterns, e.g. parentheses for negative values).
44+
Accounting,
4345
}
4446

4547
#[derive(Default, Debug, Eq, PartialEq, Clone, Copy, Hash)]

components/experimental/src/dimension/provider/currency/essentials.rs

Lines changed: 96 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use zerovec::{VarZeroVec, ZeroMap, ZeroVec};
1414
use icu_pattern::DoublePlaceholderPattern;
1515

1616
use crate::dimension::currency::CurrencyCode;
17-
use crate::dimension::currency::options::Width;
17+
use crate::dimension::currency::options::{CurrencyUsage, Width};
1818

1919
#[cfg(feature = "compiled_data")]
2020
/// Baked data
@@ -144,17 +144,26 @@ pub struct CurrencyPatternConfig {
144144
pub narrow_placeholder_value: Option<PlaceholderValue>,
145145
}
146146

147+
// Fallback pattern: "{0} {1}" (number followed by currency with space, e.g. "10 $")
148+
//
149+
// Even though the baked data handles the fallback at data generation time,
150+
// we have the fallback here for users feeding their own data without
151+
// handling the fallback logic in their data generation.
152+
const FALLBACK_PATTERN: &DoublePlaceholderPattern =
153+
DoublePlaceholderPattern::from_ref_store_unchecked("\x02\x05 ");
154+
147155
impl<'a> CurrencyEssentials<'a> {
148156
/// Returns the formatted currency name/symbol,
149157
/// the currency pattern for the given width and currency,
150158
/// and the pattern selection.
151159
pub(crate) fn name_and_pattern(
152160
&'a self,
153161
width: Width,
162+
usage: CurrencyUsage,
154163
currency: &'a CurrencyCode,
155164
) -> (
156165
&'a str,
157-
Option<&'a DoublePlaceholderPattern>,
166+
&'a DoublePlaceholderPattern,
158167
Option<&'a DoublePlaceholderPattern>,
159168
PatternSelection,
160169
) {
@@ -179,16 +188,28 @@ impl<'a> CurrencyEssentials<'a> {
179188
Width::Narrow => config.narrow_pattern_selection,
180189
};
181190

182-
let pos_pattern = match pattern_selection {
183-
PatternSelection::Standard => self.standard_pattern(),
184-
PatternSelection::StandardAlphaNextToNumber => {
191+
let pos_pattern = match (usage, pattern_selection) {
192+
(CurrencyUsage::Accounting, PatternSelection::Standard) => {
193+
self.accounting_positive_pattern()
194+
}
195+
(CurrencyUsage::Accounting, PatternSelection::StandardAlphaNextToNumber) => {
196+
self.accounting_alpha_next_to_number_positive_pattern()
197+
}
198+
(_, PatternSelection::Standard) => self.standard_pattern(),
199+
(_, PatternSelection::StandardAlphaNextToNumber) => {
185200
self.standard_alpha_next_to_number_pattern()
186201
}
187202
};
188203

189-
let neg_pattern = match pattern_selection {
190-
PatternSelection::Standard => self.standard_negative_pattern(),
191-
PatternSelection::StandardAlphaNextToNumber => {
204+
let neg_pattern = match (usage, pattern_selection) {
205+
(CurrencyUsage::Accounting, PatternSelection::Standard) => {
206+
self.accounting_negative_pattern()
207+
}
208+
(CurrencyUsage::Accounting, PatternSelection::StandardAlphaNextToNumber) => {
209+
self.accounting_alpha_next_to_number_negative_pattern()
210+
}
211+
(_, PatternSelection::Standard) => self.standard_negative_pattern(),
212+
(_, PatternSelection::StandardAlphaNextToNumber) => {
192213
self.standard_alpha_next_to_number_negative_pattern()
193214
}
194215
};
@@ -197,10 +218,23 @@ impl<'a> CurrencyEssentials<'a> {
197218
}
198219

199220
/// Returns the standard pattern.
200-
pub fn standard_pattern(&self) -> Option<&DoublePlaceholderPattern> {
221+
///
222+
/// If the standard pattern is missing (which should only happen in corrupt
223+
/// or incomplete custom data), this returns a default safety pattern `"{1}{0}"`.
224+
/// Note that this is a safety default, not a CLDR-defined fallback.
225+
pub fn standard_pattern(&self) -> &DoublePlaceholderPattern {
201226
self.patterns
202227
.get(self.indices.standard as usize)
203-
.or_else(|| self.patterns.get(0))
228+
.unwrap_or_else(|| {
229+
debug_assert!(
230+
false,
231+
"Standard pattern index {} is out of bounds for patterns of length {}",
232+
self.indices.standard,
233+
self.patterns.len()
234+
);
235+
// GIGO
236+
FALLBACK_PATTERN
237+
})
204238
}
205239

206240
/// Returns the standard negative pattern if specified.
@@ -210,14 +244,24 @@ impl<'a> CurrencyEssentials<'a> {
210244
.and_then(|idx| self.patterns.get(idx as usize))
211245
}
212246

213-
/// Returns the `standard_alpha_next_to_number` pattern, falling back to `standard_pattern` if not present.
214-
pub fn standard_alpha_next_to_number_pattern(&self) -> Option<&DoublePlaceholderPattern> {
247+
/// Returns the `standard_alpha_next_to_number` pattern.
248+
///
249+
/// Fallback hierarchy:
250+
/// `standard_alpha_next_to_number` -> `standard`
251+
///
252+
/// Even though the baked data handles the fallback at data generation time,
253+
/// we have the fallback here for users feeding their own data without
254+
/// handling the fallback logic in their data generation.
255+
pub fn standard_alpha_next_to_number_pattern(&self) -> &DoublePlaceholderPattern {
215256
self.patterns
216257
.get(self.indices.standard_alpha_next_to_number as usize)
217-
.or_else(|| self.standard_pattern())
258+
.unwrap_or_else(|| self.standard_pattern())
218259
}
219260

220261
/// Returns the `standard_alpha_next_to_number` negative pattern if specified, falling back to standard negative.
262+
///
263+
/// Fallback hierarchy:
264+
/// `standard_alpha_next_to_number_negative` -> `standard_negative`
221265
pub fn standard_alpha_next_to_number_negative_pattern(
222266
&self,
223267
) -> Option<&DoublePlaceholderPattern> {
@@ -227,30 +271,49 @@ impl<'a> CurrencyEssentials<'a> {
227271
.or_else(|| self.standard_negative_pattern())
228272
}
229273

230-
/// Returns the positive accounting pattern, falling back to `standard_pattern` if not present.
231-
pub fn accounting_positive_pattern(&self) -> Option<&DoublePlaceholderPattern> {
274+
/// Returns the positive accounting pattern.
275+
///
276+
/// Fallback hierarchy:
277+
/// `accounting_positive` -> `standard`
278+
///
279+
/// Even though the baked data handles the fallback at data generation time,
280+
/// we have the fallback here for users feeding their own data without
281+
/// handling the fallback logic in their data generation.
282+
pub fn accounting_positive_pattern(&self) -> &DoublePlaceholderPattern {
232283
self.patterns
233284
.get(self.indices.accounting_positive as usize)
234-
.or_else(|| self.standard_pattern())
285+
.unwrap_or_else(|| self.standard_pattern())
235286
}
236287

237-
/// Returns the negative accounting pattern if present.
288+
/// Returns the negative accounting pattern if present, falling back to standard negative.
289+
///
290+
/// Fallback hierarchy:
291+
/// `accounting_negative` -> `standard_negative`
238292
pub fn accounting_negative_pattern(&self) -> Option<&DoublePlaceholderPattern> {
239293
self.indices
240294
.accounting_negative
241295
.and_then(|idx| self.patterns.get(idx as usize))
296+
.or_else(|| self.standard_negative_pattern())
242297
}
243298

244-
/// Returns the positive `accounting_alpha_next_to_number` pattern, falling back to accounting or standard.
245-
pub fn accounting_alpha_next_to_number_positive_pattern(
246-
&self,
247-
) -> Option<&DoublePlaceholderPattern> {
299+
/// Returns the positive `accounting_alpha_next_to_number` pattern.
300+
///
301+
/// Fallback hierarchy:
302+
/// `accounting_alpha_next_to_number_positive` -> `standard_alpha_next_to_number` -> `standard`
303+
///
304+
/// Even though the baked data handles the fallback at data generation time,
305+
/// we have the fallback here for users feeding their own data without
306+
/// handling the fallback logic in their data generation.
307+
pub fn accounting_alpha_next_to_number_positive_pattern(&self) -> &DoublePlaceholderPattern {
248308
self.patterns
249309
.get(self.indices.accounting_alpha_next_to_number_positive as usize)
250-
.or_else(|| self.accounting_positive_pattern())
310+
.unwrap_or_else(|| self.standard_alpha_next_to_number_pattern())
251311
}
252312

253313
/// Returns the negative `accounting_alpha_next_to_number` pattern, falling back to `accounting_negative_pattern`.
314+
///
315+
/// Fallback hierarchy:
316+
/// `accounting_alpha_next_to_number_negative` -> `accounting_negative` -> `standard_negative`
254317
pub fn accounting_alpha_next_to_number_negative_pattern(
255318
&self,
256319
) -> Option<&DoublePlaceholderPattern> {
@@ -260,3 +323,14 @@ impl<'a> CurrencyEssentials<'a> {
260323
.or_else(|| self.accounting_negative_pattern())
261324
}
262325
}
326+
327+
#[cfg(test)]
328+
mod tests {
329+
use super::*;
330+
use writeable::assert_writeable_eq;
331+
332+
#[test]
333+
fn test_fallback_pattern() {
334+
assert_writeable_eq!(FALLBACK_PATTERN.interpolate(("10", "$")), "10 $");
335+
}
336+
}

0 commit comments

Comments
 (0)