Skip to content

Commit 84608eb

Browse files
committed
feat(currency): Add accounting formatting and relax constructor validation (#8177)
1 parent ab5ab2a commit 84608eb

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

@@ -146,10 +145,6 @@ impl CurrencyFormatter<Decimal> {
146145
let essential =
147146
load_with_fallback::<CurrencyEssentialsV1>(&crate::provider::Baked, ids)?.payload;
148147

149-
if essential.get().standard_pattern().is_none() {
150-
return Err(DataError::custom("missing standard pattern"));
151-
}
152-
153148
let fractions = crate::provider::Baked.load(Default::default())?.payload;
154149

155150
Ok(Self {
@@ -188,10 +183,6 @@ impl CurrencyFormatter<Decimal> {
188183
let essential =
189184
load_with_fallback::<CurrencyEssentialsV1>(&crate::provider::Baked, ids)?.payload;
190185

191-
if essential.get().standard_pattern().is_none() {
192-
return Err(DataError::custom("missing standard pattern"));
193-
}
194-
195186
let fractions = crate::provider::Baked.load(Default::default())?.payload;
196187

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

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

247234
Ok(Self {
@@ -284,10 +271,6 @@ impl CurrencyFormatter<Decimal> {
284271
let ids = req_id.into_iter().chain(core::iter::once(default_id));
285272
let essential = load_with_fallback::<CurrencyEssentialsV1>(provider, ids)?.payload;
286273

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

293276
Ok(Self {
@@ -477,18 +460,18 @@ impl CurrencyFormatter<Decimal> {
477460
// an exact decimal/money representation.
478461
let (currency_str, pos_pattern, neg_pattern, pattern_selection) = essential
479462
.get()
480-
.name_and_pattern(*width, &self.bound_currency);
463+
.name_and_pattern(*width, self.options.usage, &self.bound_currency);
481464

482465
let mut sign = value.sign;
483466
let pattern = if sign == Sign::Negative {
484467
if let Some(neg_pattern) = neg_pattern {
485468
sign = Sign::None;
486469
neg_pattern
487470
} else {
488-
pos_pattern.unwrap_or_else(|| <&DoublePlaceholderPattern>::default())
471+
pos_pattern
489472
}
490473
} else {
491-
pos_pattern.unwrap_or_else(|| <&DoublePlaceholderPattern>::default())
474+
pos_pattern
492475
};
493476

494477
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
@@ -13,7 +13,7 @@ use zerovec::{VarZeroVec, ZeroMap, ZeroVec};
1313
use icu_pattern::DoublePlaceholderPattern;
1414

1515
use crate::dimension::currency::CurrencyCode;
16-
use crate::dimension::currency::options::Width;
16+
use crate::dimension::currency::options::{CurrencyUsage, Width};
1717

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

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

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

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

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

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

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

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

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

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

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

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

0 commit comments

Comments
 (0)