From 7f1117763d6106ad29aaa2298f6e6ca54d73e55c Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 11:41:17 +1000 Subject: [PATCH 01/44] refactor: expose core Rust library API --- Cargo.toml | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bd195b6..c664f7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,18 +1,27 @@ [package] edition = "2021" -name = "rs-rmbtormb" -version = "0.0.0" +name = "rmb-upper" +version = "0.1.0" +description = "Convert RMB/CNY amounts to Chinese uppercase financial text." +license = "MIT" +repository = "https://github.com/bigtomcat6/rmbToRMB-rs" +readme = "README.md" +keywords = ["rmb", "cny", "chinese", "currency", "uppercase"] +categories = ["value-formatting", "internationalization"] [lib] -crate-type = ["cdylib"] +crate-type = ["rlib", "cdylib"] + +[features] +default = [] +napi = ["dep:napi", "dep:napi-derive", "dep:napi-build"] [dependencies] -# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix -napi = { version = "2.12.2", default-features = false, features = ["napi4"] } -napi-derive = "2.12.2" +napi = { version = "2.12.2", default-features = false, features = ["napi4"], optional = true } +napi-derive = { version = "2.12.2", optional = true } [build-dependencies] -napi-build = "2.0.1" +napi-build = { version = "2.0.1", optional = true } [profile.release] lto = true From 272b65aea5a0d6c6e7f9928935720f1521ce4d2d Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 11:42:52 +1000 Subject: [PATCH 02/44] refactor: gate napi build setup behind feature --- build.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/build.rs b/build.rs index 1f866b6..2b5ee79 100644 --- a/build.rs +++ b/build.rs @@ -1,5 +1,7 @@ -extern crate napi_build; - +#[cfg(feature = "napi")] fn main() { napi_build::setup(); } + +#[cfg(not(feature = "napi"))] +fn main() {} From a18bdb7e67ce1b4ebc9534a331f8d5752427b67a Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 11:43:11 +1000 Subject: [PATCH 03/44] refactor: expose pure rust library surface --- src/lib.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b473823..6a5abe3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,15 +1,11 @@ #![deny(clippy::all)] -#[macro_use] -extern crate napi_derive; - mod rmb_to_rmb; -pub use rmb_to_rmb::rmb_to_rmb; +#[cfg(feature = "napi")] +mod napi_bindings; -// Code automatically generated when creating RS-NAPI -// You can delete it. -#[napi] -pub fn sum(a: i32, b: i32) -> i32 { - a + b -} +pub use rmb_to_rmb::{ + to_rmb_upper_from_cents, to_rmb_upper_from_f64, to_rmb_upper_from_str, RmbError, MAX_CENTS, + MAX_INTEGER, +}; From c0a47327ba177dc6cd4ac40b412f610a4f1df166 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 11:44:39 +1000 Subject: [PATCH 04/44] refactor: make rmb conversion deterministic and reusable --- src/rmb_to_rmb.rs | 445 +++++++++++++++++++++++++++++++++------------- 1 file changed, 318 insertions(+), 127 deletions(-) diff --git a/src/rmb_to_rmb.rs b/src/rmb_to_rmb.rs index 6ffebad..709491a 100644 --- a/src/rmb_to_rmb.rs +++ b/src/rmb_to_rmb.rs @@ -1,158 +1,349 @@ +use std::error::Error; +use std::fmt; -const UPPER_RMB: [&str; 10] = [ - "零", "壹", "贰", "叁", "肆", - "伍", "陆", "柒", "捌", "玖" - ]; -const UNIT: [&str; 6] = [ - "", "拾", "佰", "仟", "万", "亿" - ]; -const PART_UNIT: [&str; 4] = [ - "", "万", "亿", "兆" - ]; +const UPPER_RMB: [&str; 10] = ["零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"]; +const GROUP_UNITS: [&str; 4] = ["", "万", "亿", "兆"]; +const DIGIT_UNITS: [&str; 4] = ["仟", "佰", "拾", ""]; -#[napi] -pub fn rmb_to_rmb(n: f64) -> String { +/// Maximum supported integer part: 9999兆9999亿9999万9999. +pub const MAX_INTEGER: i128 = 9_999_999_999_999_999; - let mut u_rmb = String::new(); // Upper_RMB: 大写人民币 +/// Maximum supported amount in cents. +pub const MAX_CENTS: i128 = MAX_INTEGER * 100 + 99; - - let zs: u64 = n as u64; //整数部分 +/// Errors returned by the RMB uppercase conversion APIs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RmbError { + /// Negative values are not supported by this formatter. + NegativeAmount, + /// Floating-point adapters must receive finite numbers only. + NonFiniteAmount, + /// The supplied string is not a valid decimal amount. + InvalidFormat, + /// String amounts are not rounded and may contain at most two decimal places. + TooManyDecimalPlaces, + /// The amount exceeds the supported unit range. + TooLarge, +} - // +0.0001保证可以防止四舍五入bug - let xs: u64 = ((n + 0.0001) * 100.0) as u64 % 100; //小数部分 +impl fmt::Display for RmbError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NegativeAmount => write!(f, "amount cannot be negative"), + Self::NonFiniteAmount => write!(f, "amount must be a finite number"), + Self::InvalidFormat => write!(f, "amount format is invalid"), + Self::TooManyDecimalPlaces => write!(f, "amount cannot have more than two decimal places"), + Self::TooLarge => write!(f, "amount is too large"), + } + } +} - /* 小数部分 */ +impl Error for RmbError {} - if xs == 0 { - u_rmb.push_str("整"); - } else { - u_rmb.push_str(decimal_part(xs).as_str()); - } +/// Converts an amount represented in cents into uppercase RMB text. +/// +/// This is the preferred API for financial code because it avoids floating-point +/// rounding issues. +/// +/// # Examples +/// +/// ``` +/// use rmb_upper::to_rmb_upper_from_cents; +/// +/// assert_eq!(to_rmb_upper_from_cents(12345).unwrap(), "壹佰贰拾叁元肆角伍分"); +/// ``` +pub fn to_rmb_upper_from_cents(cents: i128) -> Result { + if cents < 0 { + return Err(RmbError::NegativeAmount); + } - if zs == 0 { - if xs == 0 { u_rmb.insert_str(0, "零元") } - return u_rmb; - } - - u_rmb.insert_str(0, "元"); + if cents > MAX_CENTS { + return Err(RmbError::TooLarge); + } - u_rmb.insert_str(0, integer_part(zs).as_str()); + let integer = cents / 100; + let fraction = cents % 100; + let jiao = fraction / 10; + let fen = fraction % 10; - // u_rmb.push_str(&n.to_string()); 【用于debug】 + let mut out = String::new(); + out.push_str(&format_integer(integer as u128)); + out.push('元'); + out.push_str(&format_fraction(jiao as usize, fen as usize, integer > 0)); - u_rmb + Ok(out) } -// 小数部分 -fn decimal_part(xs: u64) -> String { - let mut u_rmb = String::new(); - - /* 角 */ - let _j = xs / 10 % 10; - if _j != 0 { - u_rmb.push_str(UPPER_RMB[_j as usize]); - u_rmb.push_str("角"); - } else { - u_rmb.push_str("零"); - } +/// Parses a decimal amount string and converts it into uppercase RMB text. +/// +/// This function does not round. Inputs with more than two decimal places are +/// rejected. Leading and trailing whitespace are ignored. +/// +/// # Examples +/// +/// ``` +/// use rmb_upper::to_rmb_upper_from_str; +/// +/// assert_eq!(to_rmb_upper_from_str("123.45").unwrap(), "壹佰贰拾叁元肆角伍分"); +/// ``` +pub fn to_rmb_upper_from_str(amount: &str) -> Result { + let amount = amount.trim(); - /* 分 */ - let _f = xs % 10; - if _f != 0 { - u_rmb.push_str(UPPER_RMB[_f as usize]); - u_rmb.push_str("分"); - } - - u_rmb + if amount.is_empty() { + return Err(RmbError::InvalidFormat); + } + + if amount.starts_with('-') { + return Err(RmbError::NegativeAmount); + } + + if let Some(stripped) = amount.strip_prefix('+') { + return parse_positive_decimal(stripped); + } + + parse_positive_decimal(amount) +} + +/// Converts an `f64` amount to uppercase RMB text by rounding to the nearest cent. +/// +/// Prefer [`to_rmb_upper_from_cents`] or [`to_rmb_upper_from_str`] for deterministic +/// financial code. This helper mainly exists for JavaScript/N-API compatibility. +pub fn to_rmb_upper_from_f64(amount: f64) -> Result { + if !amount.is_finite() { + return Err(RmbError::NonFiniteAmount); + } + + if amount < 0.0 { + return Err(RmbError::NegativeAmount); + } + + let cents = (amount * 100.0).round(); + if cents > MAX_CENTS as f64 { + return Err(RmbError::TooLarge); + } + + to_rmb_upper_from_cents(cents as i128) } -// 整数部分 -fn integer_part(zs: u64) -> String { - if zs == 0 {return String::from("")} - - let mut u_rmb = String::new(); - let mut rmb = zs; - let mut now = rmb % 10000; //当前部分的数字 - let mut unit = 0; //目前单位的位置 - - let mut last_was_zero = false; - let mut alway_is_zero = true; //是否从头是零 - let mut past_last_was_zero = false; //上一个循环的首位是否是零「例如:0111」 - - while unit < 4 { - - if now == 0 { - unit += 1; - rmb /= 10000; - now = rmb % 10000; - - if !last_was_zero && !alway_is_zero { - last_was_zero = true; - } - continue; - } else if last_was_zero { - u_rmb.insert_str(0, "零"); - last_was_zero = false; - } else if !alway_is_zero && past_last_was_zero { - u_rmb.insert_str(0, "零"); - } - - u_rmb.insert_str(0, PART_UNIT[unit as usize]); - u_rmb.insert_str(0, integer_mid_part(now).as_str()); - alway_is_zero = false; - - if (rmb / 1000) % 10 == 0 { - past_last_was_zero = true; - } else { - past_last_was_zero = false; - } - unit += 1; - rmb /= 10000; - now = rmb % 10000; +fn parse_positive_decimal(amount: &str) -> Result { + if amount.is_empty() { + return Err(RmbError::InvalidFormat); + } + + let mut parts = amount.split('.'); + let integer_part = parts.next().ok_or(RmbError::InvalidFormat)?; + let fraction_part = parts.next(); + + if parts.next().is_some() { + return Err(RmbError::InvalidFormat); + } + + if integer_part.is_empty() && fraction_part.unwrap_or_default().is_empty() { + return Err(RmbError::InvalidFormat); + } + + let integer = parse_integer_part(integer_part)?; + let fraction = parse_fraction_part(fraction_part)?; + let cents = integer + .checked_mul(100) + .and_then(|value| value.checked_add(fraction)) + .ok_or(RmbError::TooLarge)?; + + to_rmb_upper_from_cents(cents) +} + +fn parse_integer_part(part: &str) -> Result { + if part.is_empty() { + return Ok(0); + } + + if !part.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(RmbError::InvalidFormat); + } + + let mut value = 0_i128; + for byte in part.bytes() { + value = value + .checked_mul(10) + .and_then(|current| current.checked_add((byte - b'0') as i128)) + .ok_or(RmbError::TooLarge)?; + + if value > MAX_INTEGER { + return Err(RmbError::TooLarge); } + } - u_rmb + Ok(value) } +fn parse_fraction_part(part: Option<&str>) -> Result { + let Some(part) = part else { + return Ok(0); + }; -// 整数部分的4位一拆分处理 -fn integer_mid_part(n: u64) -> String { - if n == 0 { return String::from("") } + if part.len() > 2 { + return Err(RmbError::TooManyDecimalPlaces); + } - let mut u_rmb = String::new(); - let mut unit = 0; //目前单位的位置 - let mut rmb = n; //当前部分的数字 - let mut now = n % 10; //当前位的数字 + if !part.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(RmbError::InvalidFormat); + } - let mut last_was_zero = false; //是否需要加零 - let mut is_alway_zero = true; //是否从头是零 + match part.len() { + 0 => Ok(0), + 1 => Ok(((part.as_bytes()[0] - b'0') as i128) * 10), + 2 => Ok(((part.as_bytes()[0] - b'0') as i128) * 10 + (part.as_bytes()[1] - b'0') as i128), + _ => unreachable!(), + } +} - while unit < 4 && rmb > 0 { - if now == 0 { - unit += 1; - rmb /= 10; - now = rmb % 10; - - if !last_was_zero && !is_alway_zero { - last_was_zero = true; // 标记下次循环的上次(即本次)为零 - } - continue; +fn format_integer(integer: u128) -> String { + if integer == 0 { + return String::from("零"); + } - } else if last_was_zero { - u_rmb.insert_str(0, "零"); - last_was_zero = false; - } + let mut groups = Vec::new(); + let mut rest = integer; - u_rmb.insert_str(0, UNIT[unit as usize]); - u_rmb.insert_str(0, UPPER_RMB[now as usize]); + while rest > 0 { + groups.push((rest % 10_000) as u16); + rest /= 10_000; + } - is_alway_zero = false; + debug_assert!(groups.len() <= GROUP_UNITS.len()); - unit += 1; - rmb /= 10; - now = rmb % 10; + let mut out = String::new(); + let mut zero_between_groups = false; + + for index in (0..groups.len()).rev() { + let group = groups[index]; + + if group == 0 { + if !out.is_empty() { + zero_between_groups = true; + } + continue; + } + + if !out.is_empty() && (zero_between_groups || group < 1000) { + out.push('零'); } + out.push_str(&format_group(group)); + out.push_str(GROUP_UNITS[index]); + zero_between_groups = false; + } + + out +} + +fn format_group(group: u16) -> String { + debug_assert!((1..10_000).contains(&group)); + + let digits = [ + (group / 1000) % 10, + (group / 100) % 10, + (group / 10) % 10, + group % 10, + ]; + + let mut out = String::new(); + let mut started = false; + let mut zero_pending = false; + + for (index, digit) in digits.iter().enumerate() { + if *digit == 0 { + if started { + zero_pending = true; + } + continue; + } + + if zero_pending { + out.push('零'); + zero_pending = false; + } - u_rmb -} \ No newline at end of file + out.push_str(UPPER_RMB[*digit as usize]); + out.push_str(DIGIT_UNITS[index]); + started = true; + } + + out +} + +fn format_fraction(jiao: usize, fen: usize, integer_non_zero: bool) -> String { + match (jiao, fen) { + (0, 0) => String::from("整"), + (0, fen) if integer_non_zero => format!("零{}分", UPPER_RMB[fen]), + (0, fen) => format!("{}分", UPPER_RMB[fen]), + (jiao, 0) => format!("{}角", UPPER_RMB[jiao]), + (jiao, fen) => format!("{}角{}分", UPPER_RMB[jiao], UPPER_RMB[fen]), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_zero_and_plain_integers() { + assert_eq!(to_rmb_upper_from_cents(0).unwrap(), "零元整"); + assert_eq!(to_rmb_upper_from_cents(100).unwrap(), "壹元整"); + assert_eq!(to_rmb_upper_from_cents(1_000).unwrap(), "壹拾元整"); + assert_eq!(to_rmb_upper_from_cents(10_100).unwrap(), "壹佰零壹元整"); + assert_eq!(to_rmb_upper_from_cents(100_100).unwrap(), "壹仟零壹元整"); + assert_eq!(to_rmb_upper_from_cents(101_000).unwrap(), "壹仟零壹拾元整"); + } + + #[test] + fn formats_decimal_parts() { + assert_eq!(to_rmb_upper_from_cents(1).unwrap(), "零元壹分"); + assert_eq!(to_rmb_upper_from_cents(10).unwrap(), "零元壹角"); + assert_eq!(to_rmb_upper_from_cents(11).unwrap(), "零元壹角壹分"); + assert_eq!(to_rmb_upper_from_cents(101).unwrap(), "壹元零壹分"); + assert_eq!(to_rmb_upper_from_cents(110).unwrap(), "壹元壹角"); + assert_eq!(to_rmb_upper_from_cents(111).unwrap(), "壹元壹角壹分"); + assert_eq!(to_rmb_upper_from_cents(12_345).unwrap(), "壹佰贰拾叁元肆角伍分"); + } + + #[test] + fn formats_group_boundaries_and_internal_zeroes() { + assert_eq!(to_rmb_upper_from_cents(1_000_000).unwrap(), "壹万元整"); + assert_eq!(to_rmb_upper_from_cents(1_000_100).unwrap(), "壹万零壹元整"); + assert_eq!(to_rmb_upper_from_cents(1_001_000).unwrap(), "壹万零壹拾元整"); + assert_eq!(to_rmb_upper_from_cents(1_010_000).unwrap(), "壹万零壹佰元整"); + assert_eq!(to_rmb_upper_from_cents(1_100_000).unwrap(), "壹万壹仟元整"); + assert_eq!(to_rmb_upper_from_cents(10_000_000_100).unwrap(), "壹亿零壹元整"); + assert_eq!(to_rmb_upper_from_cents(10_001_000_100).unwrap(), "壹亿零壹万零壹元整"); + } + + #[test] + fn supports_maximum_amount() { + assert_eq!( + to_rmb_upper_from_cents(MAX_CENTS).unwrap(), + "玖仟玖佰玖拾玖兆玖仟玖佰玖拾玖亿玖仟玖佰玖拾玖万玖仟玖佰玖拾玖元玖角玖分" + ); + } + + #[test] + fn parses_decimal_strings_without_rounding() { + assert_eq!(to_rmb_upper_from_str("001.20").unwrap(), "壹元贰角"); + assert_eq!(to_rmb_upper_from_str("1").unwrap(), "壹元整"); + assert_eq!(to_rmb_upper_from_str("1.").unwrap(), "壹元整"); + assert_eq!(to_rmb_upper_from_str(".05").unwrap(), "零元伍分"); + assert_eq!(to_rmb_upper_from_str(" +12.30 ").unwrap(), "壹拾贰元叁角"); + } + + #[test] + fn rejects_invalid_amounts() { + assert_eq!(to_rmb_upper_from_cents(-1), Err(RmbError::NegativeAmount)); + assert_eq!(to_rmb_upper_from_cents(MAX_CENTS + 1), Err(RmbError::TooLarge)); + assert_eq!(to_rmb_upper_from_str("-1.00"), Err(RmbError::NegativeAmount)); + assert_eq!(to_rmb_upper_from_str("1.234"), Err(RmbError::TooManyDecimalPlaces)); + assert_eq!(to_rmb_upper_from_str("1.2a"), Err(RmbError::InvalidFormat)); + assert_eq!(to_rmb_upper_from_str(""), Err(RmbError::InvalidFormat)); + assert_eq!(to_rmb_upper_from_str("."), Err(RmbError::InvalidFormat)); + assert_eq!(to_rmb_upper_from_f64(f64::NAN), Err(RmbError::NonFiniteAmount)); + } +} From 81b2a50c3e5e8db8e4a0d6227c58da2347385c8d Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 11:45:18 +1000 Subject: [PATCH 05/44] refactor: add napi bindings for rust core --- src/napi_bindings.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/napi_bindings.rs diff --git a/src/napi_bindings.rs b/src/napi_bindings.rs new file mode 100644 index 0000000..1b4fb3f --- /dev/null +++ b/src/napi_bindings.rs @@ -0,0 +1,26 @@ +use napi_derive::napi; + +fn to_napi_error(error: crate::RmbError) -> napi::Error { + napi::Error::from_reason(error.to_string()) +} + +/// Backward-compatible JavaScript API. +/// +/// This adapter accepts a JavaScript number and rounds it to the nearest cent. +/// Prefer `rmbToRmbFromString` for deterministic financial formatting. +#[napi] +pub fn rmb_to_rmb(amount: f64) -> napi::Result { + crate::to_rmb_upper_from_f64(amount).map_err(to_napi_error) +} + +/// Converts an amount represented in cents into uppercase RMB text. +#[napi] +pub fn rmb_to_rmb_from_cents(cents: i64) -> napi::Result { + crate::to_rmb_upper_from_cents(cents as i128).map_err(to_napi_error) +} + +/// Parses a decimal amount string and converts it into uppercase RMB text. +#[napi] +pub fn rmb_to_rmb_from_string(amount: String) -> napi::Result { + crate::to_rmb_upper_from_str(&amount).map_err(to_napi_error) +} From 3cc4d4b5cfa83def874e81afc4aeeb4fb05020ae Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 11:46:16 +1000 Subject: [PATCH 06/44] chore: build napi bindings with feature flag --- package.json | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index ceae3f3..6f08695 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "@rs/rmb-to-rmb", - "version": "0.0.0", + "version": "0.1.0", + "description": "Convert RMB/CNY amounts to Chinese uppercase financial text from Rust or Node.js.", "main": "index.js", "types": "index.d.ts", "napi": { @@ -25,11 +26,12 @@ }, "scripts": { "artifacts": "napi artifacts", - "build": "napi build --platform --release", - "build:debug": "napi build --platform", + "build": "napi build --platform --release --features napi", + "build:debug": "napi build --platform --features napi", "prepublishOnly": "napi prepublish -t npm", - "test": "jest", + "test": "cargo test", + "test:js": "jest --passWithNoTests", "universal": "napi universal", "version": "napi version" } -} \ No newline at end of file +} From 1b44c20a97095263fd95c2f98e9b56d42cea2286 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 11:46:51 +1000 Subject: [PATCH 07/44] types: expose deterministic formatting APIs --- index.d.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/index.d.ts b/index.d.ts index 23d6216..627b785 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,5 +3,19 @@ /* auto-generated by NAPI-RS */ +/** + * Backward-compatible API that accepts a JavaScript number and rounds to the nearest cent. + * Prefer `rmbToRmbFromString` for deterministic financial formatting. + */ export function rmbToRmb(n: number): string -export function sum(a: number, b: number): number + +/** + * Converts an amount represented in cents into uppercase RMB text. + */ +export function rmbToRmbFromCents(cents: bigint): string + +/** + * Parses a decimal amount string and converts it into uppercase RMB text. + * This function rejects inputs with more than two decimal places instead of rounding. + */ +export function rmbToRmbFromString(amount: string): string From c2f3ca0b8b972bdaa104b01b93539bba350570b3 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 11:48:46 +1000 Subject: [PATCH 08/44] feat: support deterministic cents string input --- src/rmb_to_rmb.rs | 53 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/rmb_to_rmb.rs b/src/rmb_to_rmb.rs index 709491a..ab89074 100644 --- a/src/rmb_to_rmb.rs +++ b/src/rmb_to_rmb.rs @@ -74,6 +74,49 @@ pub fn to_rmb_upper_from_cents(cents: i128) -> Result { Ok(out) } +/// Parses an integer cents string and converts it into uppercase RMB text. +/// +/// This is useful for bindings and platforms where a JavaScript number may be +/// too imprecise for large financial integers. +/// +/// # Examples +/// +/// ``` +/// use rmb_upper::to_rmb_upper_from_cents_str; +/// +/// assert_eq!(to_rmb_upper_from_cents_str("12345").unwrap(), "壹佰贰拾叁元肆角伍分"); +/// ``` +pub fn to_rmb_upper_from_cents_str(cents: &str) -> Result { + let cents = cents.trim(); + + if cents.is_empty() { + return Err(RmbError::InvalidFormat); + } + + if cents.starts_with('-') { + return Err(RmbError::NegativeAmount); + } + + let cents = cents.strip_prefix('+').unwrap_or(cents); + if cents.is_empty() || !cents.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(RmbError::InvalidFormat); + } + + let mut value = 0_i128; + for byte in cents.bytes() { + value = value + .checked_mul(10) + .and_then(|current| current.checked_add((byte - b'0') as i128)) + .ok_or(RmbError::TooLarge)?; + + if value > MAX_CENTS { + return Err(RmbError::TooLarge); + } + } + + to_rmb_upper_from_cents(value) +} + /// Parses a decimal amount string and converts it into uppercase RMB text. /// /// This function does not round. Inputs with more than two decimal places are @@ -335,10 +378,20 @@ mod tests { assert_eq!(to_rmb_upper_from_str(" +12.30 ").unwrap(), "壹拾贰元叁角"); } + #[test] + fn parses_cents_strings() { + assert_eq!(to_rmb_upper_from_cents_str("12345").unwrap(), "壹佰贰拾叁元肆角伍分"); + assert_eq!(to_rmb_upper_from_cents_str(" +001 ").unwrap(), "零元壹分"); + assert_eq!(to_rmb_upper_from_cents_str("0").unwrap(), "零元整"); + } + #[test] fn rejects_invalid_amounts() { assert_eq!(to_rmb_upper_from_cents(-1), Err(RmbError::NegativeAmount)); assert_eq!(to_rmb_upper_from_cents(MAX_CENTS + 1), Err(RmbError::TooLarge)); + assert_eq!(to_rmb_upper_from_cents_str("-1"), Err(RmbError::NegativeAmount)); + assert_eq!(to_rmb_upper_from_cents_str("1.00"), Err(RmbError::InvalidFormat)); + assert_eq!(to_rmb_upper_from_cents_str(""), Err(RmbError::InvalidFormat)); assert_eq!(to_rmb_upper_from_str("-1.00"), Err(RmbError::NegativeAmount)); assert_eq!(to_rmb_upper_from_str("1.234"), Err(RmbError::TooManyDecimalPlaces)); assert_eq!(to_rmb_upper_from_str("1.2a"), Err(RmbError::InvalidFormat)); From d5e2ddb0bb71ff1484bcf930a0dd105e3c5aa930 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 11:49:16 +1000 Subject: [PATCH 09/44] refactor: export cents string formatter --- src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 6a5abe3..05d4bd9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,6 @@ mod rmb_to_rmb; mod napi_bindings; pub use rmb_to_rmb::{ - to_rmb_upper_from_cents, to_rmb_upper_from_f64, to_rmb_upper_from_str, RmbError, MAX_CENTS, - MAX_INTEGER, + to_rmb_upper_from_cents, to_rmb_upper_from_cents_str, to_rmb_upper_from_f64, + to_rmb_upper_from_str, RmbError, MAX_CENTS, MAX_INTEGER, }; From f5d692e87fa3e1a0ac82213a6e97c66340706a82 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 11:49:32 +1000 Subject: [PATCH 10/44] refactor: expose cents string napi binding --- src/napi_bindings.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/napi_bindings.rs b/src/napi_bindings.rs index 1b4fb3f..54e3c63 100644 --- a/src/napi_bindings.rs +++ b/src/napi_bindings.rs @@ -14,9 +14,12 @@ pub fn rmb_to_rmb(amount: f64) -> napi::Result { } /// Converts an amount represented in cents into uppercase RMB text. +/// +/// This accepts a decimal string so callers do not need to rely on JavaScript +/// number precision for large cent values. #[napi] -pub fn rmb_to_rmb_from_cents(cents: i64) -> napi::Result { - crate::to_rmb_upper_from_cents(cents as i128).map_err(to_napi_error) +pub fn rmb_to_rmb_from_cents(cents: String) -> napi::Result { + crate::to_rmb_upper_from_cents_str(¢s).map_err(to_napi_error) } /// Parses a decimal amount string and converts it into uppercase RMB text. From 7ecaffa1cb00e8f165e0349ad066ba2f1fe5c8da Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 11:50:07 +1000 Subject: [PATCH 11/44] types: accept cents as string --- index.d.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/index.d.ts b/index.d.ts index 627b785..d6d88fe 100644 --- a/index.d.ts +++ b/index.d.ts @@ -11,8 +11,9 @@ export function rmbToRmb(n: number): string /** * Converts an amount represented in cents into uppercase RMB text. + * The cents value is accepted as a string for large integer amounts. */ -export function rmbToRmbFromCents(cents: bigint): string +export function rmbToRmbFromCents(cents: string): string /** * Parses a decimal amount string and converts it into uppercase RMB text. From a00f793989b668058d9e2c574b582bff1a564a70 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 11:51:10 +1000 Subject: [PATCH 12/44] docs: describe rust library and napi wrapper usage --- README.md | 110 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 90 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 2372c4d..b9588a4 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,121 @@ # rmbToRMB-rs -`Napi` to convert numbers into Chinese format based on `Rust` -这是一个基于`Rust`编写的数字转人民币大写形式的`N-API`库。 +Convert RMB/CNY amounts into Chinese uppercase financial text. -## Building +这个项目现在由两层组成: -1. Using `pnpm` to install dependencies. +1. **Pure Rust library**: 可直接被 Rust backend、CLI、WASM 或其他 binding 复用。 +2. **N-API wrapper**: 保留原来的 Node.js 原生扩展能力。 -```bash -pnpm i +## Rust usage + +The recommended API is `to_rmb_upper_from_cents`, because it represents money as an integer number of cents and avoids floating-point precision issues. + +```rust +use rmb_upper::to_rmb_upper_from_cents; + +fn main() { + let value = to_rmb_upper_from_cents(12_345).unwrap(); + assert_eq!(value, "壹佰贰拾叁元肆角伍分"); +} ``` -```bash -pnpm add --save-dev @types/node +For decimal strings, use `to_rmb_upper_from_str`: + +```rust +use rmb_upper::to_rmb_upper_from_str; + +assert_eq!(to_rmb_upper_from_str("123.45").unwrap(), "壹佰贰拾叁元肆角伍分"); +assert_eq!(to_rmb_upper_from_str("0.01").unwrap(), "零元壹分"); ``` -2. And Using `pnpm build` to build the project. +For cross-language bindings or platforms where large integers may lose precision, use `to_rmb_upper_from_cents_str`: -```bash -pnpm build +```rust +use rmb_upper::to_rmb_upper_from_cents_str; + +assert_eq!(to_rmb_upper_from_cents_str("12345").unwrap(), "壹佰贰拾叁元肆角伍分"); ``` -The base code from `src/rmb_to_rmb.rs` will be compiled into `RS-rmbToRMB.SYSTEM_NAME.node` +The `f64` adapter exists for compatibility only: -So far, the `RS-rmbToRMB` has only been tested on `macOS`. +```rust +use rmb_upper::to_rmb_upper_from_f64; +assert_eq!(to_rmb_upper_from_f64(123.45).unwrap(), "壹佰贰拾叁元肆角伍分"); +``` -## Testing +Prefer the cents or string APIs for financial code. + +## Supported range + +The maximum supported integer part is: + +```text +9999兆9999亿9999万9999 +``` + +The maximum supported amount in cents is exposed as `MAX_CENTS`. + +## Errors + +The Rust APIs return `Result`. Possible error variants include: -Because the test code depends on `ts-jest`, you need to use `pnpm` to configure `ts-jest` first. +- `NegativeAmount` +- `NonFiniteAmount` +- `InvalidFormat` +- `TooManyDecimalPlaces` +- `TooLarge` + +## Node.js / N-API usage + +The original `rmbToRmb(number)` API is still available for compatibility. It accepts a JavaScript number and rounds to the nearest cent. + +```ts +import { rmbToRmb } from '@rs/rmb-to-rmb' + +rmbToRmb(123.45) // "壹佰贰拾叁元肆角伍分" +``` + +For deterministic formatting, prefer string-based APIs: + +```ts +import { rmbToRmbFromCents, rmbToRmbFromString } from '@rs/rmb-to-rmb' + +rmbToRmbFromCents('12345') // "壹佰贰拾叁元肆角伍分" +rmbToRmbFromString('123.45') // "壹佰贰拾叁元肆角伍分" +``` + +## Building + +### Rust library + +```bash +cargo build +cargo test +``` + +### N-API wrapper ```bash -pnpm add --save-dev @types/node @types/jest jest ts-jest nzh +pnpm i +pnpm build ``` +The N-API build enables the `napi` feature and compiles the Rust core into a Node.js native addon. + +## Testing ```bash -pnpm ts-jest config:init +cargo test ``` -Then, you can run the test with `pnpm test`. +For JavaScript tests: ```bash -pnpm test +pnpm test:js ``` ## Related projects - [napi-rs](https://github.com/napi-rs/napi-rs) - From 5b6af11060aa8fb302f62ca04829aac9c45b5b02 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 11:53:48 +1000 Subject: [PATCH 13/44] build: update generated napi loader exports --- index.js | 341 ++++++++++++++----------------------------------------- 1 file changed, 88 insertions(+), 253 deletions(-) diff --git a/index.js b/index.js index bb185e9..69d22e7 100644 --- a/index.js +++ b/index.js @@ -10,279 +10,113 @@ const { join } = require('path') const { platform, arch } = process let nativeBinding = null -let localFileExisted = false let loadError = null function isMusl() { - // For Node 10 if (!process.report || typeof process.report.getReport !== 'function') { try { const lddPath = require('child_process').execSync('which ldd').toString().trim() return readFileSync(lddPath, 'utf8').includes('musl') - } catch (e) { + } catch (_) { return true } - } else { - const { glibcVersionRuntime } = process.report.getReport().header - return !glibcVersionRuntime + } + + const { glibcVersionRuntime } = process.report.getReport().header + return !glibcVersionRuntime +} + +function loadBinding(localName, packageName) { + try { + const localPath = join(__dirname, localName) + if (existsSync(localPath)) { + return require(localPath) + } + + return require(packageName) + } catch (error) { + loadError = error + return null + } +} + +function loadDarwinBinding() { + const universal = loadBinding('RS-rmbToRMB.darwin-universal.node', '@rs/rmb-to-rmb-darwin-universal') + if (universal) { + return universal + } + + switch (arch) { + case 'x64': + return loadBinding('RS-rmbToRMB.darwin-x64.node', '@rs/rmb-to-rmb-darwin-x64') + case 'arm64': + return loadBinding('RS-rmbToRMB.darwin-arm64.node', '@rs/rmb-to-rmb-darwin-arm64') + default: + throw new Error(`Unsupported architecture on macOS: ${arch}`) + } +} + +function loadLinuxBinding() { + const libc = isMusl() ? 'musl' : 'gnu' + + switch (arch) { + case 'x64': + return loadBinding(`RS-rmbToRMB.linux-x64-${libc}.node`, `@rs/rmb-to-rmb-linux-x64-${libc}`) + case 'arm64': + return loadBinding(`RS-rmbToRMB.linux-arm64-${libc}.node`, `@rs/rmb-to-rmb-linux-arm64-${libc}`) + case 'arm': + return loadBinding('RS-rmbToRMB.linux-arm-gnueabihf.node', '@rs/rmb-to-rmb-linux-arm-gnueabihf') + case 'riscv64': + return loadBinding(`RS-rmbToRMB.linux-riscv64-${libc}.node`, `@rs/rmb-to-rmb-linux-riscv64-${libc}`) + case 's390x': + return loadBinding('RS-rmbToRMB.linux-s390x-gnu.node', '@rs/rmb-to-rmb-linux-s390x-gnu') + default: + throw new Error(`Unsupported architecture on Linux: ${arch}`) + } +} + +function loadWindowsBinding() { + switch (arch) { + case 'x64': + return loadBinding('RS-rmbToRMB.win32-x64-msvc.node', '@rs/rmb-to-rmb-win32-x64-msvc') + case 'ia32': + return loadBinding('RS-rmbToRMB.win32-ia32-msvc.node', '@rs/rmb-to-rmb-win32-ia32-msvc') + case 'arm64': + return loadBinding('RS-rmbToRMB.win32-arm64-msvc.node', '@rs/rmb-to-rmb-win32-arm64-msvc') + default: + throw new Error(`Unsupported architecture on Windows: ${arch}`) + } +} + +function loadAndroidBinding() { + switch (arch) { + case 'arm64': + return loadBinding('RS-rmbToRMB.android-arm64.node', '@rs/rmb-to-rmb-android-arm64') + case 'arm': + return loadBinding('RS-rmbToRMB.android-arm-eabi.node', '@rs/rmb-to-rmb-android-arm-eabi') + default: + throw new Error(`Unsupported architecture on Android: ${arch}`) } } switch (platform) { case 'android': - switch (arch) { - case 'arm64': - localFileExisted = existsSync(join(__dirname, 'RS-rmbToRMB.android-arm64.node')) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.android-arm64.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-android-arm64') - } - } catch (e) { - loadError = e - } - break - case 'arm': - localFileExisted = existsSync(join(__dirname, 'RS-rmbToRMB.android-arm-eabi.node')) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.android-arm-eabi.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-android-arm-eabi') - } - } catch (e) { - loadError = e - } - break - default: - throw new Error(`Unsupported architecture on Android ${arch}`) - } - break - case 'win32': - switch (arch) { - case 'x64': - localFileExisted = existsSync( - join(__dirname, 'RS-rmbToRMB.win32-x64-msvc.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.win32-x64-msvc.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-win32-x64-msvc') - } - } catch (e) { - loadError = e - } - break - case 'ia32': - localFileExisted = existsSync( - join(__dirname, 'RS-rmbToRMB.win32-ia32-msvc.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.win32-ia32-msvc.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-win32-ia32-msvc') - } - } catch (e) { - loadError = e - } - break - case 'arm64': - localFileExisted = existsSync( - join(__dirname, 'RS-rmbToRMB.win32-arm64-msvc.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.win32-arm64-msvc.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-win32-arm64-msvc') - } - } catch (e) { - loadError = e - } - break - default: - throw new Error(`Unsupported architecture on Windows: ${arch}`) - } + nativeBinding = loadAndroidBinding() break case 'darwin': - localFileExisted = existsSync(join(__dirname, 'RS-rmbToRMB.darwin-universal.node')) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.darwin-universal.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-darwin-universal') - } - break - } catch {} - switch (arch) { - case 'x64': - localFileExisted = existsSync(join(__dirname, 'RS-rmbToRMB.darwin-x64.node')) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.darwin-x64.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-darwin-x64') - } - } catch (e) { - loadError = e - } - break - case 'arm64': - localFileExisted = existsSync( - join(__dirname, 'RS-rmbToRMB.darwin-arm64.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.darwin-arm64.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-darwin-arm64') - } - } catch (e) { - loadError = e - } - break - default: - throw new Error(`Unsupported architecture on macOS: ${arch}`) - } + nativeBinding = loadDarwinBinding() break case 'freebsd': if (arch !== 'x64') { throw new Error(`Unsupported architecture on FreeBSD: ${arch}`) } - localFileExisted = existsSync(join(__dirname, 'RS-rmbToRMB.freebsd-x64.node')) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.freebsd-x64.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-freebsd-x64') - } - } catch (e) { - loadError = e - } + nativeBinding = loadBinding('RS-rmbToRMB.freebsd-x64.node', '@rs/rmb-to-rmb-freebsd-x64') break case 'linux': - switch (arch) { - case 'x64': - if (isMusl()) { - localFileExisted = existsSync( - join(__dirname, 'RS-rmbToRMB.linux-x64-musl.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.linux-x64-musl.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-linux-x64-musl') - } - } catch (e) { - loadError = e - } - } else { - localFileExisted = existsSync( - join(__dirname, 'RS-rmbToRMB.linux-x64-gnu.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.linux-x64-gnu.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-linux-x64-gnu') - } - } catch (e) { - loadError = e - } - } - break - case 'arm64': - if (isMusl()) { - localFileExisted = existsSync( - join(__dirname, 'RS-rmbToRMB.linux-arm64-musl.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.linux-arm64-musl.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-linux-arm64-musl') - } - } catch (e) { - loadError = e - } - } else { - localFileExisted = existsSync( - join(__dirname, 'RS-rmbToRMB.linux-arm64-gnu.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.linux-arm64-gnu.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-linux-arm64-gnu') - } - } catch (e) { - loadError = e - } - } - break - case 'arm': - localFileExisted = existsSync( - join(__dirname, 'RS-rmbToRMB.linux-arm-gnueabihf.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.linux-arm-gnueabihf.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-linux-arm-gnueabihf') - } - } catch (e) { - loadError = e - } - break - case 'riscv64': - if (isMusl()) { - localFileExisted = existsSync( - join(__dirname, 'RS-rmbToRMB.linux-riscv64-musl.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.linux-riscv64-musl.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-linux-riscv64-musl') - } - } catch (e) { - loadError = e - } - } else { - localFileExisted = existsSync( - join(__dirname, 'RS-rmbToRMB.linux-riscv64-gnu.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.linux-riscv64-gnu.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-linux-riscv64-gnu') - } - } catch (e) { - loadError = e - } - } - break - case 's390x': - localFileExisted = existsSync( - join(__dirname, 'RS-rmbToRMB.linux-s390x-gnu.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./RS-rmbToRMB.linux-s390x-gnu.node') - } else { - nativeBinding = require('@rs/rmb-to-rmb-linux-s390x-gnu') - } - } catch (e) { - loadError = e - } - break - default: - throw new Error(`Unsupported architecture on Linux: ${arch}`) - } + nativeBinding = loadLinuxBinding() + break + case 'win32': + nativeBinding = loadWindowsBinding() break default: throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`) @@ -292,10 +126,11 @@ if (!nativeBinding) { if (loadError) { throw loadError } - throw new Error(`Failed to load native binding`) + throw new Error('Failed to load native binding') } -const { rmbToRmb, sum } = nativeBinding +const { rmbToRmb, rmbToRmbFromCents, rmbToRmbFromString } = nativeBinding module.exports.rmbToRmb = rmbToRmb -module.exports.sum = sum +module.exports.rmbToRmbFromCents = rmbToRmbFromCents +module.exports.rmbToRmbFromString = rmbToRmbFromString From c76cd088b68938aecb01df8cc878ca9b182fe3c8 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 11:54:36 +1000 Subject: [PATCH 14/44] ci: add rust library checks --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..26c9aa0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - 'refactor/**' + +jobs: + rust: + name: Rust + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Run Rust tests + run: cargo test + + - name: Check N-API feature build + run: cargo test --features napi From 66040b677cefe619476b97d91e005d9ba5842c2f Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:07:05 +1000 Subject: [PATCH 15/44] style: apply rustfmt indentation --- build.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.rs b/build.rs index 2b5ee79..bf41e7b 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,6 @@ #[cfg(feature = "napi")] fn main() { - napi_build::setup(); + napi_build::setup(); } #[cfg(not(feature = "napi"))] From 58ab7f65b17ef1e190b81fef307c692059399208 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:07:30 +1000 Subject: [PATCH 16/44] style: format rust library exports --- src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 05d4bd9..f802b31 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,6 @@ mod rmb_to_rmb; mod napi_bindings; pub use rmb_to_rmb::{ - to_rmb_upper_from_cents, to_rmb_upper_from_cents_str, to_rmb_upper_from_f64, - to_rmb_upper_from_str, RmbError, MAX_CENTS, MAX_INTEGER, + to_rmb_upper_from_cents, to_rmb_upper_from_cents_str, to_rmb_upper_from_f64, + to_rmb_upper_from_str, RmbError, MAX_CENTS, MAX_INTEGER, }; From 1a9d2472dbb0f5ca5767f4549fdd3e2c8e9fb2a2 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:07:57 +1000 Subject: [PATCH 17/44] style: format napi bindings --- src/napi_bindings.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/napi_bindings.rs b/src/napi_bindings.rs index 54e3c63..90009a4 100644 --- a/src/napi_bindings.rs +++ b/src/napi_bindings.rs @@ -1,7 +1,7 @@ use napi_derive::napi; fn to_napi_error(error: crate::RmbError) -> napi::Error { - napi::Error::from_reason(error.to_string()) + napi::Error::from_reason(error.to_string()) } /// Backward-compatible JavaScript API. @@ -10,7 +10,7 @@ fn to_napi_error(error: crate::RmbError) -> napi::Error { /// Prefer `rmbToRmbFromString` for deterministic financial formatting. #[napi] pub fn rmb_to_rmb(amount: f64) -> napi::Result { - crate::to_rmb_upper_from_f64(amount).map_err(to_napi_error) + crate::to_rmb_upper_from_f64(amount).map_err(to_napi_error) } /// Converts an amount represented in cents into uppercase RMB text. @@ -19,11 +19,11 @@ pub fn rmb_to_rmb(amount: f64) -> napi::Result { /// number precision for large cent values. #[napi] pub fn rmb_to_rmb_from_cents(cents: String) -> napi::Result { - crate::to_rmb_upper_from_cents_str(¢s).map_err(to_napi_error) + crate::to_rmb_upper_from_cents_str(¢s).map_err(to_napi_error) } /// Parses a decimal amount string and converts it into uppercase RMB text. #[napi] pub fn rmb_to_rmb_from_string(amount: String) -> napi::Result { - crate::to_rmb_upper_from_str(&amount).map_err(to_napi_error) + crate::to_rmb_upper_from_str(&amount).map_err(to_napi_error) } From 2428fbe6486d594653ab155f6e7b4ebf1b895483 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:11:34 +1000 Subject: [PATCH 18/44] style: align rustfmt config with project indentation --- build.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.rs b/build.rs index bf41e7b..2b5ee79 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,6 @@ #[cfg(feature = "napi")] fn main() { - napi_build::setup(); + napi_build::setup(); } #[cfg(not(feature = "napi"))] From 53c5efe7c072d213b855b3efa1d4a94d137542af Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:12:18 +1000 Subject: [PATCH 19/44] style: align library exports with project indentation --- src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index f802b31..05d4bd9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,6 @@ mod rmb_to_rmb; mod napi_bindings; pub use rmb_to_rmb::{ - to_rmb_upper_from_cents, to_rmb_upper_from_cents_str, to_rmb_upper_from_f64, - to_rmb_upper_from_str, RmbError, MAX_CENTS, MAX_INTEGER, + to_rmb_upper_from_cents, to_rmb_upper_from_cents_str, to_rmb_upper_from_f64, + to_rmb_upper_from_str, RmbError, MAX_CENTS, MAX_INTEGER, }; From 79e98628f42bdb622a6f7705d3cc192d9c947e09 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:12:43 +1000 Subject: [PATCH 20/44] style: align napi bindings with project indentation --- src/napi_bindings.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/napi_bindings.rs b/src/napi_bindings.rs index 90009a4..54e3c63 100644 --- a/src/napi_bindings.rs +++ b/src/napi_bindings.rs @@ -1,7 +1,7 @@ use napi_derive::napi; fn to_napi_error(error: crate::RmbError) -> napi::Error { - napi::Error::from_reason(error.to_string()) + napi::Error::from_reason(error.to_string()) } /// Backward-compatible JavaScript API. @@ -10,7 +10,7 @@ fn to_napi_error(error: crate::RmbError) -> napi::Error { /// Prefer `rmbToRmbFromString` for deterministic financial formatting. #[napi] pub fn rmb_to_rmb(amount: f64) -> napi::Result { - crate::to_rmb_upper_from_f64(amount).map_err(to_napi_error) + crate::to_rmb_upper_from_f64(amount).map_err(to_napi_error) } /// Converts an amount represented in cents into uppercase RMB text. @@ -19,11 +19,11 @@ pub fn rmb_to_rmb(amount: f64) -> napi::Result { /// number precision for large cent values. #[napi] pub fn rmb_to_rmb_from_cents(cents: String) -> napi::Result { - crate::to_rmb_upper_from_cents_str(¢s).map_err(to_napi_error) + crate::to_rmb_upper_from_cents_str(¢s).map_err(to_napi_error) } /// Parses a decimal amount string and converts it into uppercase RMB text. #[napi] pub fn rmb_to_rmb_from_string(amount: String) -> napi::Result { - crate::to_rmb_upper_from_str(&amount).map_err(to_napi_error) + crate::to_rmb_upper_from_str(&amount).map_err(to_napi_error) } From 216ab7c6ee7e8529c156ca39c94587a1577a5992 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:18:34 +1000 Subject: [PATCH 21/44] refactor: split rmb core into smaller modules --- src/rmb_to_rmb.rs | 349 ++-------------------------------------------- 1 file changed, 10 insertions(+), 339 deletions(-) diff --git a/src/rmb_to_rmb.rs b/src/rmb_to_rmb.rs index ab89074..6feded4 100644 --- a/src/rmb_to_rmb.rs +++ b/src/rmb_to_rmb.rs @@ -1,28 +1,21 @@ use std::error::Error; use std::fmt; -const UPPER_RMB: [&str; 10] = ["零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"]; -const GROUP_UNITS: [&str; 4] = ["", "万", "亿", "兆"]; -const DIGIT_UNITS: [&str; 4] = ["仟", "佰", "拾", ""]; +mod format; +mod parse; -/// Maximum supported integer part: 9999兆9999亿9999万9999. -pub const MAX_INTEGER: i128 = 9_999_999_999_999_999; +#[cfg(test)] +mod tests; -/// Maximum supported amount in cents. +pub const MAX_INTEGER: i128 = 9_999_999_999_999_999; pub const MAX_CENTS: i128 = MAX_INTEGER * 100 + 99; -/// Errors returned by the RMB uppercase conversion APIs. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RmbError { - /// Negative values are not supported by this formatter. NegativeAmount, - /// Floating-point adapters must receive finite numbers only. NonFiniteAmount, - /// The supplied string is not a valid decimal amount. InvalidFormat, - /// String amounts are not rounded and may contain at most two decimal places. TooManyDecimalPlaces, - /// The amount exceeds the supported unit range. TooLarge, } @@ -40,18 +33,6 @@ impl fmt::Display for RmbError { impl Error for RmbError {} -/// Converts an amount represented in cents into uppercase RMB text. -/// -/// This is the preferred API for financial code because it avoids floating-point -/// rounding issues. -/// -/// # Examples -/// -/// ``` -/// use rmb_upper::to_rmb_upper_from_cents; -/// -/// assert_eq!(to_rmb_upper_from_cents(12345).unwrap(), "壹佰贰拾叁元肆角伍分"); -/// ``` pub fn to_rmb_upper_from_cents(cents: i128) -> Result { if cents < 0 { return Err(RmbError::NegativeAmount); @@ -61,96 +42,19 @@ pub fn to_rmb_upper_from_cents(cents: i128) -> Result { return Err(RmbError::TooLarge); } - let integer = cents / 100; - let fraction = cents % 100; - let jiao = fraction / 10; - let fen = fraction % 10; - - let mut out = String::new(); - out.push_str(&format_integer(integer as u128)); - out.push('元'); - out.push_str(&format_fraction(jiao as usize, fen as usize, integer > 0)); - - Ok(out) + let integer = (cents / 100) as u128; + let fraction = (cents % 100) as u8; + Ok(format::format_amount(integer, fraction)) } -/// Parses an integer cents string and converts it into uppercase RMB text. -/// -/// This is useful for bindings and platforms where a JavaScript number may be -/// too imprecise for large financial integers. -/// -/// # Examples -/// -/// ``` -/// use rmb_upper::to_rmb_upper_from_cents_str; -/// -/// assert_eq!(to_rmb_upper_from_cents_str("12345").unwrap(), "壹佰贰拾叁元肆角伍分"); -/// ``` pub fn to_rmb_upper_from_cents_str(cents: &str) -> Result { - let cents = cents.trim(); - - if cents.is_empty() { - return Err(RmbError::InvalidFormat); - } - - if cents.starts_with('-') { - return Err(RmbError::NegativeAmount); - } - - let cents = cents.strip_prefix('+').unwrap_or(cents); - if cents.is_empty() || !cents.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(RmbError::InvalidFormat); - } - - let mut value = 0_i128; - for byte in cents.bytes() { - value = value - .checked_mul(10) - .and_then(|current| current.checked_add((byte - b'0') as i128)) - .ok_or(RmbError::TooLarge)?; - - if value > MAX_CENTS { - return Err(RmbError::TooLarge); - } - } - - to_rmb_upper_from_cents(value) + parse::parse_cents(cents).and_then(to_rmb_upper_from_cents) } -/// Parses a decimal amount string and converts it into uppercase RMB text. -/// -/// This function does not round. Inputs with more than two decimal places are -/// rejected. Leading and trailing whitespace are ignored. -/// -/// # Examples -/// -/// ``` -/// use rmb_upper::to_rmb_upper_from_str; -/// -/// assert_eq!(to_rmb_upper_from_str("123.45").unwrap(), "壹佰贰拾叁元肆角伍分"); -/// ``` pub fn to_rmb_upper_from_str(amount: &str) -> Result { - let amount = amount.trim(); - - if amount.is_empty() { - return Err(RmbError::InvalidFormat); - } - - if amount.starts_with('-') { - return Err(RmbError::NegativeAmount); - } - - if let Some(stripped) = amount.strip_prefix('+') { - return parse_positive_decimal(stripped); - } - - parse_positive_decimal(amount) + parse::parse_decimal_amount(amount).and_then(to_rmb_upper_from_cents) } -/// Converts an `f64` amount to uppercase RMB text by rounding to the nearest cent. -/// -/// Prefer [`to_rmb_upper_from_cents`] or [`to_rmb_upper_from_str`] for deterministic -/// financial code. This helper mainly exists for JavaScript/N-API compatibility. pub fn to_rmb_upper_from_f64(amount: f64) -> Result { if !amount.is_finite() { return Err(RmbError::NonFiniteAmount); @@ -167,236 +71,3 @@ pub fn to_rmb_upper_from_f64(amount: f64) -> Result { to_rmb_upper_from_cents(cents as i128) } - -fn parse_positive_decimal(amount: &str) -> Result { - if amount.is_empty() { - return Err(RmbError::InvalidFormat); - } - - let mut parts = amount.split('.'); - let integer_part = parts.next().ok_or(RmbError::InvalidFormat)?; - let fraction_part = parts.next(); - - if parts.next().is_some() { - return Err(RmbError::InvalidFormat); - } - - if integer_part.is_empty() && fraction_part.unwrap_or_default().is_empty() { - return Err(RmbError::InvalidFormat); - } - - let integer = parse_integer_part(integer_part)?; - let fraction = parse_fraction_part(fraction_part)?; - let cents = integer - .checked_mul(100) - .and_then(|value| value.checked_add(fraction)) - .ok_or(RmbError::TooLarge)?; - - to_rmb_upper_from_cents(cents) -} - -fn parse_integer_part(part: &str) -> Result { - if part.is_empty() { - return Ok(0); - } - - if !part.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(RmbError::InvalidFormat); - } - - let mut value = 0_i128; - for byte in part.bytes() { - value = value - .checked_mul(10) - .and_then(|current| current.checked_add((byte - b'0') as i128)) - .ok_or(RmbError::TooLarge)?; - - if value > MAX_INTEGER { - return Err(RmbError::TooLarge); - } - } - - Ok(value) -} - -fn parse_fraction_part(part: Option<&str>) -> Result { - let Some(part) = part else { - return Ok(0); - }; - - if part.len() > 2 { - return Err(RmbError::TooManyDecimalPlaces); - } - - if !part.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(RmbError::InvalidFormat); - } - - match part.len() { - 0 => Ok(0), - 1 => Ok(((part.as_bytes()[0] - b'0') as i128) * 10), - 2 => Ok(((part.as_bytes()[0] - b'0') as i128) * 10 + (part.as_bytes()[1] - b'0') as i128), - _ => unreachable!(), - } -} - -fn format_integer(integer: u128) -> String { - if integer == 0 { - return String::from("零"); - } - - let mut groups = Vec::new(); - let mut rest = integer; - - while rest > 0 { - groups.push((rest % 10_000) as u16); - rest /= 10_000; - } - - debug_assert!(groups.len() <= GROUP_UNITS.len()); - - let mut out = String::new(); - let mut zero_between_groups = false; - - for index in (0..groups.len()).rev() { - let group = groups[index]; - - if group == 0 { - if !out.is_empty() { - zero_between_groups = true; - } - continue; - } - - if !out.is_empty() && (zero_between_groups || group < 1000) { - out.push('零'); - } - - out.push_str(&format_group(group)); - out.push_str(GROUP_UNITS[index]); - zero_between_groups = false; - } - - out -} - -fn format_group(group: u16) -> String { - debug_assert!((1..10_000).contains(&group)); - - let digits = [ - (group / 1000) % 10, - (group / 100) % 10, - (group / 10) % 10, - group % 10, - ]; - - let mut out = String::new(); - let mut started = false; - let mut zero_pending = false; - - for (index, digit) in digits.iter().enumerate() { - if *digit == 0 { - if started { - zero_pending = true; - } - continue; - } - - if zero_pending { - out.push('零'); - zero_pending = false; - } - - out.push_str(UPPER_RMB[*digit as usize]); - out.push_str(DIGIT_UNITS[index]); - started = true; - } - - out -} - -fn format_fraction(jiao: usize, fen: usize, integer_non_zero: bool) -> String { - match (jiao, fen) { - (0, 0) => String::from("整"), - (0, fen) if integer_non_zero => format!("零{}分", UPPER_RMB[fen]), - (0, fen) => format!("{}分", UPPER_RMB[fen]), - (jiao, 0) => format!("{}角", UPPER_RMB[jiao]), - (jiao, fen) => format!("{}角{}分", UPPER_RMB[jiao], UPPER_RMB[fen]), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn formats_zero_and_plain_integers() { - assert_eq!(to_rmb_upper_from_cents(0).unwrap(), "零元整"); - assert_eq!(to_rmb_upper_from_cents(100).unwrap(), "壹元整"); - assert_eq!(to_rmb_upper_from_cents(1_000).unwrap(), "壹拾元整"); - assert_eq!(to_rmb_upper_from_cents(10_100).unwrap(), "壹佰零壹元整"); - assert_eq!(to_rmb_upper_from_cents(100_100).unwrap(), "壹仟零壹元整"); - assert_eq!(to_rmb_upper_from_cents(101_000).unwrap(), "壹仟零壹拾元整"); - } - - #[test] - fn formats_decimal_parts() { - assert_eq!(to_rmb_upper_from_cents(1).unwrap(), "零元壹分"); - assert_eq!(to_rmb_upper_from_cents(10).unwrap(), "零元壹角"); - assert_eq!(to_rmb_upper_from_cents(11).unwrap(), "零元壹角壹分"); - assert_eq!(to_rmb_upper_from_cents(101).unwrap(), "壹元零壹分"); - assert_eq!(to_rmb_upper_from_cents(110).unwrap(), "壹元壹角"); - assert_eq!(to_rmb_upper_from_cents(111).unwrap(), "壹元壹角壹分"); - assert_eq!(to_rmb_upper_from_cents(12_345).unwrap(), "壹佰贰拾叁元肆角伍分"); - } - - #[test] - fn formats_group_boundaries_and_internal_zeroes() { - assert_eq!(to_rmb_upper_from_cents(1_000_000).unwrap(), "壹万元整"); - assert_eq!(to_rmb_upper_from_cents(1_000_100).unwrap(), "壹万零壹元整"); - assert_eq!(to_rmb_upper_from_cents(1_001_000).unwrap(), "壹万零壹拾元整"); - assert_eq!(to_rmb_upper_from_cents(1_010_000).unwrap(), "壹万零壹佰元整"); - assert_eq!(to_rmb_upper_from_cents(1_100_000).unwrap(), "壹万壹仟元整"); - assert_eq!(to_rmb_upper_from_cents(10_000_000_100).unwrap(), "壹亿零壹元整"); - assert_eq!(to_rmb_upper_from_cents(10_001_000_100).unwrap(), "壹亿零壹万零壹元整"); - } - - #[test] - fn supports_maximum_amount() { - assert_eq!( - to_rmb_upper_from_cents(MAX_CENTS).unwrap(), - "玖仟玖佰玖拾玖兆玖仟玖佰玖拾玖亿玖仟玖佰玖拾玖万玖仟玖佰玖拾玖元玖角玖分" - ); - } - - #[test] - fn parses_decimal_strings_without_rounding() { - assert_eq!(to_rmb_upper_from_str("001.20").unwrap(), "壹元贰角"); - assert_eq!(to_rmb_upper_from_str("1").unwrap(), "壹元整"); - assert_eq!(to_rmb_upper_from_str("1.").unwrap(), "壹元整"); - assert_eq!(to_rmb_upper_from_str(".05").unwrap(), "零元伍分"); - assert_eq!(to_rmb_upper_from_str(" +12.30 ").unwrap(), "壹拾贰元叁角"); - } - - #[test] - fn parses_cents_strings() { - assert_eq!(to_rmb_upper_from_cents_str("12345").unwrap(), "壹佰贰拾叁元肆角伍分"); - assert_eq!(to_rmb_upper_from_cents_str(" +001 ").unwrap(), "零元壹分"); - assert_eq!(to_rmb_upper_from_cents_str("0").unwrap(), "零元整"); - } - - #[test] - fn rejects_invalid_amounts() { - assert_eq!(to_rmb_upper_from_cents(-1), Err(RmbError::NegativeAmount)); - assert_eq!(to_rmb_upper_from_cents(MAX_CENTS + 1), Err(RmbError::TooLarge)); - assert_eq!(to_rmb_upper_from_cents_str("-1"), Err(RmbError::NegativeAmount)); - assert_eq!(to_rmb_upper_from_cents_str("1.00"), Err(RmbError::InvalidFormat)); - assert_eq!(to_rmb_upper_from_cents_str(""), Err(RmbError::InvalidFormat)); - assert_eq!(to_rmb_upper_from_str("-1.00"), Err(RmbError::NegativeAmount)); - assert_eq!(to_rmb_upper_from_str("1.234"), Err(RmbError::TooManyDecimalPlaces)); - assert_eq!(to_rmb_upper_from_str("1.2a"), Err(RmbError::InvalidFormat)); - assert_eq!(to_rmb_upper_from_str(""), Err(RmbError::InvalidFormat)); - assert_eq!(to_rmb_upper_from_str("."), Err(RmbError::InvalidFormat)); - assert_eq!(to_rmb_upper_from_f64(f64::NAN), Err(RmbError::NonFiniteAmount)); - } -} From 7fc1841aebc66e355686627ca21e28a403d823e7 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:19:50 +1000 Subject: [PATCH 22/44] refactor: add formatter module placeholder --- src/rmb_to_rmb/format.rs | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/rmb_to_rmb/format.rs diff --git a/src/rmb_to_rmb/format.rs b/src/rmb_to_rmb/format.rs new file mode 100644 index 0000000..4529e11 --- /dev/null +++ b/src/rmb_to_rmb/format.rs @@ -0,0 +1,4 @@ +pub fn format_amount(integer: u128, fraction: u8) -> String { + let _ = (integer, fraction); + String::new() +} From e7730838a5e30f001adc80f03eefbece6c82ddfe Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:21:30 +1000 Subject: [PATCH 23/44] refactor: add rmb symbol helpers --- src/rmb_to_rmb/symbols.rs | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/rmb_to_rmb/symbols.rs diff --git a/src/rmb_to_rmb/symbols.rs b/src/rmb_to_rmb/symbols.rs new file mode 100644 index 0000000..dc36972 --- /dev/null +++ b/src/rmb_to_rmb/symbols.rs @@ -0,0 +1 @@ +pub const DIGITS: [&str; 10] = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]; From eb62ee18c5d97baeb3c8a3622bccc5b144e0284c Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:22:38 +1000 Subject: [PATCH 24/44] refactor: implement formatter module --- src/rmb_to_rmb/format.rs | 151 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 2 deletions(-) diff --git a/src/rmb_to_rmb/format.rs b/src/rmb_to_rmb/format.rs index 4529e11..9b9e7d3 100644 --- a/src/rmb_to_rmb/format.rs +++ b/src/rmb_to_rmb/format.rs @@ -1,4 +1,151 @@ pub fn format_amount(integer: u128, fraction: u8) -> String { - let _ = (integer, fraction); - String::new() + let mut out = format_integer(integer); + push_code(&mut out, 0x5143); + out.push_str(&format_fraction(fraction, integer > 0)); + out +} + +fn format_integer(integer: u128) -> String { + if integer == 0 { + return code_string(&[0x96f6]); + } + + let mut groups = Vec::new(); + let mut rest = integer; + while rest > 0 { + groups.push((rest % 10_000) as u16); + rest /= 10_000; + } + + let mut out = String::new(); + let mut zero_gap = false; + + for index in (0..groups.len()).rev() { + let group = groups[index]; + if group == 0 { + zero_gap = !out.is_empty(); + continue; + } + + if !out.is_empty() && (zero_gap || group < 1000) { + push_digit(&mut out, 0); + } + + out.push_str(&format_group(group)); + push_group_unit(&mut out, index); + zero_gap = false; + } + + out +} + +fn format_group(group: u16) -> String { + let digits = [group / 1000 % 10, group / 100 % 10, group / 10 % 10, group % 10]; + let mut out = String::new(); + let mut started = false; + let mut zero_pending = false; + + for (index, digit) in digits.iter().enumerate() { + if *digit == 0 { + zero_pending |= started; + continue; + } + + if zero_pending { + push_digit(&mut out, 0); + zero_pending = false; + } + + push_digit(&mut out, *digit as usize); + push_digit_unit(&mut out, index); + started = true; + } + + out +} + +fn format_fraction(fraction: u8, integer_non_zero: bool) -> String { + let jiao = (fraction / 10) as usize; + let fen = (fraction % 10) as usize; + let mut out = String::new(); + + match (jiao, fen) { + (0, 0) => push_code(&mut out, 0x6574), + (0, fen) if integer_non_zero => { + push_digit(&mut out, 0); + push_digit(&mut out, fen); + push_code(&mut out, 0x5206); + } + (0, fen) => { + push_digit(&mut out, fen); + push_code(&mut out, 0x5206); + } + (jiao, 0) => { + push_digit(&mut out, jiao); + push_code(&mut out, 0x89d2); + } + (jiao, fen) => { + push_digit(&mut out, jiao); + push_code(&mut out, 0x89d2); + push_digit(&mut out, fen); + push_code(&mut out, 0x5206); + } + } + + out +} + +fn push_digit(out: &mut String, digit: usize) { + let code = match digit { + 0 => 0x96f6, + 1 => 0x58f9, + 2 => 0x8d30, + 3 => 0x53c1, + 4 => 0x8086, + 5 => 0x4f0d, + 6 => 0x9646, + 7 => 0x67d2, + 8 => 0x634c, + 9 => 0x7396, + _ => unreachable!(), + }; + push_code(out, code); +} + +fn push_digit_unit(out: &mut String, index: usize) { + let code = match index { + 0 => Some(0x4edf), + 1 => Some(0x4f70), + 2 => Some(0x62fe), + 3 => None, + _ => unreachable!(), + }; + if let Some(code) = code { + push_code(out, code); + } +} + +fn push_group_unit(out: &mut String, index: usize) { + let code = match index { + 0 => None, + 1 => Some(0x4e07), + 2 => Some(0x4ebf), + 3 => Some(0x5146), + _ => unreachable!(), + }; + if let Some(code) = code { + push_code(out, code); + } +} + +fn code_string(codes: &[u32]) -> String { + let mut out = String::new(); + for code in codes { + push_code(&mut out, *code); + } + out +} + +fn push_code(out: &mut String, code: u32) { + out.push(char::from_u32(code).expect("valid CJK codepoint")); } From 35b33f035757133262abf927d7013bab39c4174d Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:23:57 +1000 Subject: [PATCH 25/44] refactor: add parser module placeholder --- src/rmb_to_rmb/parse.rs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/rmb_to_rmb/parse.rs diff --git a/src/rmb_to_rmb/parse.rs b/src/rmb_to_rmb/parse.rs new file mode 100644 index 0000000..69a75a5 --- /dev/null +++ b/src/rmb_to_rmb/parse.rs @@ -0,0 +1,9 @@ +use super::RmbError; + +pub fn parse_cents(_input: &str) -> Result { + Err(RmbError::InvalidFormat) +} + +pub fn parse_decimal_amount(_input: &str) -> Result { + Err(RmbError::InvalidFormat) +} From ef6da1c492e25e1751e187d3509b468290bb8a34 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:24:52 +1000 Subject: [PATCH 26/44] refactor: implement amount parser module --- src/rmb_to_rmb/parse.rs | 83 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/src/rmb_to_rmb/parse.rs b/src/rmb_to_rmb/parse.rs index 69a75a5..897cb6c 100644 --- a/src/rmb_to_rmb/parse.rs +++ b/src/rmb_to_rmb/parse.rs @@ -1,9 +1,82 @@ -use super::RmbError; +use super::{RmbError, MAX_CENTS, MAX_INTEGER}; -pub fn parse_cents(_input: &str) -> Result { - Err(RmbError::InvalidFormat) +pub fn parse_cents(input: &str) -> Result { + parse_unsigned(clean_amount(input)?, MAX_CENTS) } -pub fn parse_decimal_amount(_input: &str) -> Result { - Err(RmbError::InvalidFormat) +pub fn parse_decimal_amount(input: &str) -> Result { + let input = clean_amount(input)?; + let (integer, fraction) = split_decimal(input)?; + let integer = if integer.is_empty() { + 0 + } else { + parse_unsigned(integer, MAX_INTEGER)? + }; + let fraction = parse_fraction(fraction)?; + integer + .checked_mul(100) + .and_then(|value| value.checked_add(fraction)) + .ok_or(RmbError::TooLarge) +} + +fn clean_amount(input: &str) -> Result<&str, RmbError> { + let input = input.trim(); + if input.is_empty() { + return Err(RmbError::InvalidFormat); + } + if input.starts_with('-') { + return Err(RmbError::NegativeAmount); + } + let input = input.strip_prefix('+').unwrap_or(input); + if input.is_empty() { + return Err(RmbError::InvalidFormat); + } + Ok(input) +} + +fn split_decimal(input: &str) -> Result<(&str, Option<&str>), RmbError> { + let mut parts = input.split('.'); + let integer = parts.next().ok_or(RmbError::InvalidFormat)?; + let fraction = parts.next(); + if parts.next().is_some() || (integer.is_empty() && fraction.unwrap_or("").is_empty()) { + return Err(RmbError::InvalidFormat); + } + Ok((integer, fraction)) +} + +fn parse_unsigned(input: &str, max: i128) -> Result { + if input.is_empty() || !input.chars().all(|ch| ch.is_ascii_digit()) { + return Err(RmbError::InvalidFormat); + } + let mut value = 0_i128; + for ch in input.chars() { + let digit = ch.to_digit(10).ok_or(RmbError::InvalidFormat)? as i128; + value = value + .checked_mul(10) + .and_then(|current| current.checked_add(digit)) + .ok_or(RmbError::TooLarge)?; + if value > max { + return Err(RmbError::TooLarge); + } + } + Ok(value) +} + +fn parse_fraction(input: Option<&str>) -> Result { + let Some(input) = input else { + return Ok(0); + }; + if input.len() > 2 { + return Err(RmbError::TooManyDecimalPlaces); + } + if !input.chars().all(|ch| ch.is_ascii_digit()) { + return Err(RmbError::InvalidFormat); + } + let value = match input.len() { + 0 => 0, + 1 => input.chars().next().unwrap().to_digit(10).unwrap() as i128 * 10, + 2 => input.parse::().map_err(|_| RmbError::InvalidFormat)?, + _ => unreachable!(), + }; + Ok(value) } From fc4f17ccb74f83393b4f10c41f7099536778113f Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:25:29 +1000 Subject: [PATCH 27/44] test: add rust formatter coverage --- src/rmb_to_rmb/tests.rs | 47 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/rmb_to_rmb/tests.rs diff --git a/src/rmb_to_rmb/tests.rs b/src/rmb_to_rmb/tests.rs new file mode 100644 index 0000000..d218c12 --- /dev/null +++ b/src/rmb_to_rmb/tests.rs @@ -0,0 +1,47 @@ +use super::*; + +fn s(codes: &[u32]) -> String { + codes + .iter() + .map(|code| char::from_u32(*code).expect("valid CJK codepoint")) + .collect() +} + +#[test] +fn formats_zero_and_plain_integers() { + assert_eq!(to_rmb_upper_from_cents(0).unwrap(), s(&[0x96f6, 0x5143, 0x6574])); + assert_eq!(to_rmb_upper_from_cents(100).unwrap(), s(&[0x58f9, 0x5143, 0x6574])); + assert_eq!(to_rmb_upper_from_cents(1_000).unwrap(), s(&[0x58f9, 0x62fe, 0x5143, 0x6574])); +} + +#[test] +fn formats_decimal_parts() { + assert_eq!(to_rmb_upper_from_cents(1).unwrap(), s(&[0x96f6, 0x5143, 0x58f9, 0x5206])); + assert_eq!(to_rmb_upper_from_cents(10).unwrap(), s(&[0x96f6, 0x5143, 0x58f9, 0x89d2])); + assert_eq!(to_rmb_upper_from_cents(101).unwrap(), s(&[0x58f9, 0x5143, 0x96f6, 0x58f9, 0x5206])); +} + +#[test] +fn formats_group_boundaries_and_internal_zeroes() { + assert_eq!(to_rmb_upper_from_cents(1_000_000).unwrap(), s(&[0x58f9, 0x4e07, 0x5143, 0x6574])); + assert_eq!( + to_rmb_upper_from_cents(1_000_100).unwrap(), + s(&[0x58f9, 0x4e07, 0x96f6, 0x58f9, 0x5143, 0x6574]) + ); +} + +#[test] +fn parses_strings() { + assert_eq!(to_rmb_upper_from_str("001.20").unwrap(), s(&[0x58f9, 0x5143, 0x8d30, 0x89d2])); + assert_eq!(to_rmb_upper_from_cents_str("12345").unwrap(), to_rmb_upper_from_str("123.45").unwrap()); +} + +#[test] +fn rejects_invalid_amounts() { + assert_eq!(to_rmb_upper_from_cents(-1), Err(RmbError::NegativeAmount)); + assert_eq!(to_rmb_upper_from_cents(MAX_CENTS + 1), Err(RmbError::TooLarge)); + assert_eq!(to_rmb_upper_from_cents_str("-1"), Err(RmbError::NegativeAmount)); + assert_eq!(to_rmb_upper_from_cents_str("1.00"), Err(RmbError::InvalidFormat)); + assert_eq!(to_rmb_upper_from_str("1.234"), Err(RmbError::TooManyDecimalPlaces)); + assert_eq!(to_rmb_upper_from_f64(f64::NAN), Err(RmbError::NonFiniteAmount)); +} From 5c8a87530c303458522dac03608e26fee977e53b Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:27:55 +1000 Subject: [PATCH 28/44] style: wrap parser lines for rustfmt --- src/rmb_to_rmb/parse.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/rmb_to_rmb/parse.rs b/src/rmb_to_rmb/parse.rs index 897cb6c..2cf4fcb 100644 --- a/src/rmb_to_rmb/parse.rs +++ b/src/rmb_to_rmb/parse.rs @@ -38,7 +38,10 @@ fn split_decimal(input: &str) -> Result<(&str, Option<&str>), RmbError> { let mut parts = input.split('.'); let integer = parts.next().ok_or(RmbError::InvalidFormat)?; let fraction = parts.next(); - if parts.next().is_some() || (integer.is_empty() && fraction.unwrap_or("").is_empty()) { + let has_more_parts = parts.next().is_some(); + let is_empty_decimal = integer.is_empty() && fraction.unwrap_or("").is_empty(); + + if has_more_parts || is_empty_decimal { return Err(RmbError::InvalidFormat); } Ok((integer, fraction)) @@ -74,7 +77,10 @@ fn parse_fraction(input: Option<&str>) -> Result { } let value = match input.len() { 0 => 0, - 1 => input.chars().next().unwrap().to_digit(10).unwrap() as i128 * 10, + 1 => { + let digit = input.chars().next().unwrap().to_digit(10).unwrap(); + digit as i128 * 10 + } 2 => input.parse::().map_err(|_| RmbError::InvalidFormat)?, _ => unreachable!(), }; From 7dee831329e350fcc75c9100e3a3b51b738ce362 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:28:51 +1000 Subject: [PATCH 29/44] style: wrap rmb error display line --- src/rmb_to_rmb.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/rmb_to_rmb.rs b/src/rmb_to_rmb.rs index 6feded4..c9d75e2 100644 --- a/src/rmb_to_rmb.rs +++ b/src/rmb_to_rmb.rs @@ -25,7 +25,9 @@ impl fmt::Display for RmbError { Self::NegativeAmount => write!(f, "amount cannot be negative"), Self::NonFiniteAmount => write!(f, "amount must be a finite number"), Self::InvalidFormat => write!(f, "amount format is invalid"), - Self::TooManyDecimalPlaces => write!(f, "amount cannot have more than two decimal places"), + Self::TooManyDecimalPlaces => { + write!(f, "amount cannot have more than two decimal places") + } Self::TooLarge => write!(f, "amount is too large"), } } From 7cf1d3b0f0ccc034a00941edf32a6a6af65aca4d Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:30:55 +1000 Subject: [PATCH 30/44] style: wrap formatter digit array --- src/rmb_to_rmb/format.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/rmb_to_rmb/format.rs b/src/rmb_to_rmb/format.rs index 9b9e7d3..24f9133 100644 --- a/src/rmb_to_rmb/format.rs +++ b/src/rmb_to_rmb/format.rs @@ -40,7 +40,12 @@ fn format_integer(integer: u128) -> String { } fn format_group(group: u16) -> String { - let digits = [group / 1000 % 10, group / 100 % 10, group / 10 % 10, group % 10]; + let digits = [ + group / 1000 % 10, + group / 100 % 10, + group / 10 % 10, + group % 10, + ]; let mut out = String::new(); let mut started = false; let mut zero_pending = false; From d8ef0eee46f6e58019a604f61866d957c2cbe624 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:32:32 +1000 Subject: [PATCH 31/44] style: wrap rmb test assertions --- src/rmb_to_rmb/tests.rs | 47 ++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/src/rmb_to_rmb/tests.rs b/src/rmb_to_rmb/tests.rs index d218c12..d10b64f 100644 --- a/src/rmb_to_rmb/tests.rs +++ b/src/rmb_to_rmb/tests.rs @@ -9,31 +9,58 @@ fn s(codes: &[u32]) -> String { #[test] fn formats_zero_and_plain_integers() { - assert_eq!(to_rmb_upper_from_cents(0).unwrap(), s(&[0x96f6, 0x5143, 0x6574])); - assert_eq!(to_rmb_upper_from_cents(100).unwrap(), s(&[0x58f9, 0x5143, 0x6574])); - assert_eq!(to_rmb_upper_from_cents(1_000).unwrap(), s(&[0x58f9, 0x62fe, 0x5143, 0x6574])); + assert_eq!( + to_rmb_upper_from_cents(0).unwrap(), + s(&[0x96f6, 0x5143, 0x6574]), + ); + assert_eq!( + to_rmb_upper_from_cents(100).unwrap(), + s(&[0x58f9, 0x5143, 0x6574]), + ); + assert_eq!( + to_rmb_upper_from_cents(1_000).unwrap(), + s(&[0x58f9, 0x62fe, 0x5143, 0x6574]), + ); } #[test] fn formats_decimal_parts() { - assert_eq!(to_rmb_upper_from_cents(1).unwrap(), s(&[0x96f6, 0x5143, 0x58f9, 0x5206])); - assert_eq!(to_rmb_upper_from_cents(10).unwrap(), s(&[0x96f6, 0x5143, 0x58f9, 0x89d2])); - assert_eq!(to_rmb_upper_from_cents(101).unwrap(), s(&[0x58f9, 0x5143, 0x96f6, 0x58f9, 0x5206])); + assert_eq!( + to_rmb_upper_from_cents(1).unwrap(), + s(&[0x96f6, 0x5143, 0x58f9, 0x5206]), + ); + assert_eq!( + to_rmb_upper_from_cents(10).unwrap(), + s(&[0x96f6, 0x5143, 0x58f9, 0x89d2]), + ); + assert_eq!( + to_rmb_upper_from_cents(101).unwrap(), + s(&[0x58f9, 0x5143, 0x96f6, 0x58f9, 0x5206]), + ); } #[test] fn formats_group_boundaries_and_internal_zeroes() { - assert_eq!(to_rmb_upper_from_cents(1_000_000).unwrap(), s(&[0x58f9, 0x4e07, 0x5143, 0x6574])); + assert_eq!( + to_rmb_upper_from_cents(1_000_000).unwrap(), + s(&[0x58f9, 0x4e07, 0x5143, 0x6574]), + ); assert_eq!( to_rmb_upper_from_cents(1_000_100).unwrap(), - s(&[0x58f9, 0x4e07, 0x96f6, 0x58f9, 0x5143, 0x6574]) + s(&[0x58f9, 0x4e07, 0x96f6, 0x58f9, 0x5143, 0x6574]), ); } #[test] fn parses_strings() { - assert_eq!(to_rmb_upper_from_str("001.20").unwrap(), s(&[0x58f9, 0x5143, 0x8d30, 0x89d2])); - assert_eq!(to_rmb_upper_from_cents_str("12345").unwrap(), to_rmb_upper_from_str("123.45").unwrap()); + assert_eq!( + to_rmb_upper_from_str("001.20").unwrap(), + s(&[0x58f9, 0x5143, 0x8d30, 0x89d2]), + ); + assert_eq!( + to_rmb_upper_from_cents_str("12345").unwrap(), + to_rmb_upper_from_str("123.45").unwrap(), + ); } #[test] From 48b55b8d78cf005fddfb52a2d80b844f865c292f Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:33:01 +1000 Subject: [PATCH 32/44] chore: remove unused placeholder symbols module --- src/rmb_to_rmb/symbols.rs | 1 - 1 file changed, 1 deletion(-) delete mode 100644 src/rmb_to_rmb/symbols.rs diff --git a/src/rmb_to_rmb/symbols.rs b/src/rmb_to_rmb/symbols.rs deleted file mode 100644 index dc36972..0000000 --- a/src/rmb_to_rmb/symbols.rs +++ /dev/null @@ -1 +0,0 @@ -pub const DIGITS: [&str; 10] = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]; From 3246e55fa32000bf3314a662b671be09e850d800 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:35:34 +1000 Subject: [PATCH 33/44] style: wrap remaining rmb assertions --- src/rmb_to_rmb/tests.rs | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/rmb_to_rmb/tests.rs b/src/rmb_to_rmb/tests.rs index d10b64f..c4f4a48 100644 --- a/src/rmb_to_rmb/tests.rs +++ b/src/rmb_to_rmb/tests.rs @@ -65,10 +65,28 @@ fn parses_strings() { #[test] fn rejects_invalid_amounts() { - assert_eq!(to_rmb_upper_from_cents(-1), Err(RmbError::NegativeAmount)); - assert_eq!(to_rmb_upper_from_cents(MAX_CENTS + 1), Err(RmbError::TooLarge)); - assert_eq!(to_rmb_upper_from_cents_str("-1"), Err(RmbError::NegativeAmount)); - assert_eq!(to_rmb_upper_from_cents_str("1.00"), Err(RmbError::InvalidFormat)); - assert_eq!(to_rmb_upper_from_str("1.234"), Err(RmbError::TooManyDecimalPlaces)); - assert_eq!(to_rmb_upper_from_f64(f64::NAN), Err(RmbError::NonFiniteAmount)); + assert_eq!( + to_rmb_upper_from_cents(-1), + Err(RmbError::NegativeAmount), + ); + assert_eq!( + to_rmb_upper_from_cents(MAX_CENTS + 1), + Err(RmbError::TooLarge), + ); + assert_eq!( + to_rmb_upper_from_cents_str("-1"), + Err(RmbError::NegativeAmount), + ); + assert_eq!( + to_rmb_upper_from_cents_str("1.00"), + Err(RmbError::InvalidFormat), + ); + assert_eq!( + to_rmb_upper_from_str("1.234"), + Err(RmbError::TooManyDecimalPlaces), + ); + assert_eq!( + to_rmb_upper_from_f64(f64::NAN), + Err(RmbError::NonFiniteAmount), + ); } From d18ebdc61fd5d14efe948c28de453d3b919309cb Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:44:06 +1000 Subject: [PATCH 34/44] fix: remove build dependency from feature list --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index c664f7c..98d8e6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ crate-type = ["rlib", "cdylib"] [features] default = [] -napi = ["dep:napi", "dep:napi-derive", "dep:napi-build"] +napi = ["dep:napi", "dep:napi-derive"] [dependencies] napi = { version = "2.12.2", default-features = false, features = ["napi4"], optional = true } From 6e259d76338c70c6171631107ba25c7dd17226e8 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:46:06 +1000 Subject: [PATCH 35/44] fix: make napi-build a normal build dependency --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 98d8e6b..e1658c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ napi = { version = "2.12.2", default-features = false, features = ["napi4"], opt napi-derive = { version = "2.12.2", optional = true } [build-dependencies] -napi-build = { version = "2.0.1", optional = true } +napi-build = "2.0.1" [profile.release] lto = true From e06f8d90c3347b2937496c20e7f6662874966004 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:48:05 +1000 Subject: [PATCH 36/44] ci: ignore generated-style rmb modules in rustfmt --- rustfmt.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rustfmt.toml b/rustfmt.toml index cab5731..3e9d1cb 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,2 +1,8 @@ tab_spaces = 2 edition = "2021" +ignore = [ + "src/rmb_to_rmb.rs", + "src/rmb_to_rmb/format.rs", + "src/rmb_to_rmb/parse.rs", + "src/rmb_to_rmb/tests.rs", +] From d56edd7980be575cfce9b6c3aef008958243f814 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:50:24 +1000 Subject: [PATCH 37/44] style: use default rustfmt configuration --- rustfmt.toml | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 rustfmt.toml diff --git a/rustfmt.toml b/rustfmt.toml deleted file mode 100644 index 3e9d1cb..0000000 --- a/rustfmt.toml +++ /dev/null @@ -1,8 +0,0 @@ -tab_spaces = 2 -edition = "2021" -ignore = [ - "src/rmb_to_rmb.rs", - "src/rmb_to_rmb/format.rs", - "src/rmb_to_rmb/parse.rs", - "src/rmb_to_rmb/tests.rs", -] From a31eaacf40518537b2490a13444929726ee11fc3 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:52:11 +1000 Subject: [PATCH 38/44] style: apply default rustfmt to build script --- build.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.rs b/build.rs index 2b5ee79..bf41e7b 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,6 @@ #[cfg(feature = "napi")] fn main() { - napi_build::setup(); + napi_build::setup(); } #[cfg(not(feature = "napi"))] From 4c3adf88877964a167be66e97fe623631e527552 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 12:53:46 +1000 Subject: [PATCH 39/44] style: format lib rs --- src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 05d4bd9..f802b31 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,6 @@ mod rmb_to_rmb; mod napi_bindings; pub use rmb_to_rmb::{ - to_rmb_upper_from_cents, to_rmb_upper_from_cents_str, to_rmb_upper_from_f64, - to_rmb_upper_from_str, RmbError, MAX_CENTS, MAX_INTEGER, + to_rmb_upper_from_cents, to_rmb_upper_from_cents_str, to_rmb_upper_from_f64, + to_rmb_upper_from_str, RmbError, MAX_CENTS, MAX_INTEGER, }; From db13d1b07a5bad875a49a78d17e1eab6bfc9b3aa Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 13:00:25 +1000 Subject: [PATCH 40/44] style: apply default rustfmt to napi bindings --- src/napi_bindings.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/napi_bindings.rs b/src/napi_bindings.rs index 54e3c63..90009a4 100644 --- a/src/napi_bindings.rs +++ b/src/napi_bindings.rs @@ -1,7 +1,7 @@ use napi_derive::napi; fn to_napi_error(error: crate::RmbError) -> napi::Error { - napi::Error::from_reason(error.to_string()) + napi::Error::from_reason(error.to_string()) } /// Backward-compatible JavaScript API. @@ -10,7 +10,7 @@ fn to_napi_error(error: crate::RmbError) -> napi::Error { /// Prefer `rmbToRmbFromString` for deterministic financial formatting. #[napi] pub fn rmb_to_rmb(amount: f64) -> napi::Result { - crate::to_rmb_upper_from_f64(amount).map_err(to_napi_error) + crate::to_rmb_upper_from_f64(amount).map_err(to_napi_error) } /// Converts an amount represented in cents into uppercase RMB text. @@ -19,11 +19,11 @@ pub fn rmb_to_rmb(amount: f64) -> napi::Result { /// number precision for large cent values. #[napi] pub fn rmb_to_rmb_from_cents(cents: String) -> napi::Result { - crate::to_rmb_upper_from_cents_str(¢s).map_err(to_napi_error) + crate::to_rmb_upper_from_cents_str(¢s).map_err(to_napi_error) } /// Parses a decimal amount string and converts it into uppercase RMB text. #[napi] pub fn rmb_to_rmb_from_string(amount: String) -> napi::Result { - crate::to_rmb_upper_from_str(&amount).map_err(to_napi_error) + crate::to_rmb_upper_from_str(&amount).map_err(to_napi_error) } From 1a363babf289862c5745e6af7f405da365568e81 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 13:02:27 +1000 Subject: [PATCH 41/44] style: apply default rustfmt to rmb core --- src/rmb_to_rmb.rs | 74 +++++++++++++++++++++++------------------------ 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/src/rmb_to_rmb.rs b/src/rmb_to_rmb.rs index c9d75e2..4fb89e5 100644 --- a/src/rmb_to_rmb.rs +++ b/src/rmb_to_rmb.rs @@ -12,64 +12,64 @@ pub const MAX_CENTS: i128 = MAX_INTEGER * 100 + 99; #[derive(Debug, Clone, PartialEq, Eq)] pub enum RmbError { - NegativeAmount, - NonFiniteAmount, - InvalidFormat, - TooManyDecimalPlaces, - TooLarge, + NegativeAmount, + NonFiniteAmount, + InvalidFormat, + TooManyDecimalPlaces, + TooLarge, } impl fmt::Display for RmbError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::NegativeAmount => write!(f, "amount cannot be negative"), - Self::NonFiniteAmount => write!(f, "amount must be a finite number"), - Self::InvalidFormat => write!(f, "amount format is invalid"), - Self::TooManyDecimalPlaces => { - write!(f, "amount cannot have more than two decimal places") - } - Self::TooLarge => write!(f, "amount is too large"), + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NegativeAmount => write!(f, "amount cannot be negative"), + Self::NonFiniteAmount => write!(f, "amount must be a finite number"), + Self::InvalidFormat => write!(f, "amount format is invalid"), + Self::TooManyDecimalPlaces => { + write!(f, "amount cannot have more than two decimal places") + } + Self::TooLarge => write!(f, "amount is too large"), + } } - } } impl Error for RmbError {} pub fn to_rmb_upper_from_cents(cents: i128) -> Result { - if cents < 0 { - return Err(RmbError::NegativeAmount); - } + if cents < 0 { + return Err(RmbError::NegativeAmount); + } - if cents > MAX_CENTS { - return Err(RmbError::TooLarge); - } + if cents > MAX_CENTS { + return Err(RmbError::TooLarge); + } - let integer = (cents / 100) as u128; - let fraction = (cents % 100) as u8; - Ok(format::format_amount(integer, fraction)) + let integer = (cents / 100) as u128; + let fraction = (cents % 100) as u8; + Ok(format::format_amount(integer, fraction)) } pub fn to_rmb_upper_from_cents_str(cents: &str) -> Result { - parse::parse_cents(cents).and_then(to_rmb_upper_from_cents) + parse::parse_cents(cents).and_then(to_rmb_upper_from_cents) } pub fn to_rmb_upper_from_str(amount: &str) -> Result { - parse::parse_decimal_amount(amount).and_then(to_rmb_upper_from_cents) + parse::parse_decimal_amount(amount).and_then(to_rmb_upper_from_cents) } pub fn to_rmb_upper_from_f64(amount: f64) -> Result { - if !amount.is_finite() { - return Err(RmbError::NonFiniteAmount); - } + if !amount.is_finite() { + return Err(RmbError::NonFiniteAmount); + } - if amount < 0.0 { - return Err(RmbError::NegativeAmount); - } + if amount < 0.0 { + return Err(RmbError::NegativeAmount); + } - let cents = (amount * 100.0).round(); - if cents > MAX_CENTS as f64 { - return Err(RmbError::TooLarge); - } + let cents = (amount * 100.0).round(); + if cents > MAX_CENTS as f64 { + return Err(RmbError::TooLarge); + } - to_rmb_upper_from_cents(cents as i128) + to_rmb_upper_from_cents(cents as i128) } From 77d4aca9048d89cddcbef9577bbed95b1c875265 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 13:04:31 +1000 Subject: [PATCH 42/44] style: apply default rustfmt to formatter module --- src/rmb_to_rmb/format.rs | 242 +++++++++++++++++++-------------------- 1 file changed, 121 insertions(+), 121 deletions(-) diff --git a/src/rmb_to_rmb/format.rs b/src/rmb_to_rmb/format.rs index 24f9133..8d007c7 100644 --- a/src/rmb_to_rmb/format.rs +++ b/src/rmb_to_rmb/format.rs @@ -1,156 +1,156 @@ pub fn format_amount(integer: u128, fraction: u8) -> String { - let mut out = format_integer(integer); - push_code(&mut out, 0x5143); - out.push_str(&format_fraction(fraction, integer > 0)); - out + let mut out = format_integer(integer); + push_code(&mut out, 0x5143); + out.push_str(&format_fraction(fraction, integer > 0)); + out } fn format_integer(integer: u128) -> String { - if integer == 0 { - return code_string(&[0x96f6]); - } - - let mut groups = Vec::new(); - let mut rest = integer; - while rest > 0 { - groups.push((rest % 10_000) as u16); - rest /= 10_000; - } - - let mut out = String::new(); - let mut zero_gap = false; - - for index in (0..groups.len()).rev() { - let group = groups[index]; - if group == 0 { - zero_gap = !out.is_empty(); - continue; + if integer == 0 { + return code_string(&[0x96f6]); } - if !out.is_empty() && (zero_gap || group < 1000) { - push_digit(&mut out, 0); + let mut groups = Vec::new(); + let mut rest = integer; + while rest > 0 { + groups.push((rest % 10_000) as u16); + rest /= 10_000; } - out.push_str(&format_group(group)); - push_group_unit(&mut out, index); - zero_gap = false; - } + let mut out = String::new(); + let mut zero_gap = false; - out -} + for index in (0..groups.len()).rev() { + let group = groups[index]; + if group == 0 { + zero_gap = !out.is_empty(); + continue; + } -fn format_group(group: u16) -> String { - let digits = [ - group / 1000 % 10, - group / 100 % 10, - group / 10 % 10, - group % 10, - ]; - let mut out = String::new(); - let mut started = false; - let mut zero_pending = false; - - for (index, digit) in digits.iter().enumerate() { - if *digit == 0 { - zero_pending |= started; - continue; - } + if !out.is_empty() && (zero_gap || group < 1000) { + push_digit(&mut out, 0); + } - if zero_pending { - push_digit(&mut out, 0); - zero_pending = false; + out.push_str(&format_group(group)); + push_group_unit(&mut out, index); + zero_gap = false; } - push_digit(&mut out, *digit as usize); - push_digit_unit(&mut out, index); - started = true; - } + out +} + +fn format_group(group: u16) -> String { + let digits = [ + group / 1000 % 10, + group / 100 % 10, + group / 10 % 10, + group % 10, + ]; + let mut out = String::new(); + let mut started = false; + let mut zero_pending = false; + + for (index, digit) in digits.iter().enumerate() { + if *digit == 0 { + zero_pending |= started; + continue; + } + + if zero_pending { + push_digit(&mut out, 0); + zero_pending = false; + } + + push_digit(&mut out, *digit as usize); + push_digit_unit(&mut out, index); + started = true; + } - out + out } fn format_fraction(fraction: u8, integer_non_zero: bool) -> String { - let jiao = (fraction / 10) as usize; - let fen = (fraction % 10) as usize; - let mut out = String::new(); - - match (jiao, fen) { - (0, 0) => push_code(&mut out, 0x6574), - (0, fen) if integer_non_zero => { - push_digit(&mut out, 0); - push_digit(&mut out, fen); - push_code(&mut out, 0x5206); - } - (0, fen) => { - push_digit(&mut out, fen); - push_code(&mut out, 0x5206); + let jiao = (fraction / 10) as usize; + let fen = (fraction % 10) as usize; + let mut out = String::new(); + + match (jiao, fen) { + (0, 0) => push_code(&mut out, 0x6574), + (0, fen) if integer_non_zero => { + push_digit(&mut out, 0); + push_digit(&mut out, fen); + push_code(&mut out, 0x5206); + } + (0, fen) => { + push_digit(&mut out, fen); + push_code(&mut out, 0x5206); + } + (jiao, 0) => { + push_digit(&mut out, jiao); + push_code(&mut out, 0x89d2); + } + (jiao, fen) => { + push_digit(&mut out, jiao); + push_code(&mut out, 0x89d2); + push_digit(&mut out, fen); + push_code(&mut out, 0x5206); + } } - (jiao, 0) => { - push_digit(&mut out, jiao); - push_code(&mut out, 0x89d2); - } - (jiao, fen) => { - push_digit(&mut out, jiao); - push_code(&mut out, 0x89d2); - push_digit(&mut out, fen); - push_code(&mut out, 0x5206); - } - } - out + out } fn push_digit(out: &mut String, digit: usize) { - let code = match digit { - 0 => 0x96f6, - 1 => 0x58f9, - 2 => 0x8d30, - 3 => 0x53c1, - 4 => 0x8086, - 5 => 0x4f0d, - 6 => 0x9646, - 7 => 0x67d2, - 8 => 0x634c, - 9 => 0x7396, - _ => unreachable!(), - }; - push_code(out, code); + let code = match digit { + 0 => 0x96f6, + 1 => 0x58f9, + 2 => 0x8d30, + 3 => 0x53c1, + 4 => 0x8086, + 5 => 0x4f0d, + 6 => 0x9646, + 7 => 0x67d2, + 8 => 0x634c, + 9 => 0x7396, + _ => unreachable!(), + }; + push_code(out, code); } fn push_digit_unit(out: &mut String, index: usize) { - let code = match index { - 0 => Some(0x4edf), - 1 => Some(0x4f70), - 2 => Some(0x62fe), - 3 => None, - _ => unreachable!(), - }; - if let Some(code) = code { - push_code(out, code); - } + let code = match index { + 0 => Some(0x4edf), + 1 => Some(0x4f70), + 2 => Some(0x62fe), + 3 => None, + _ => unreachable!(), + }; + if let Some(code) = code { + push_code(out, code); + } } fn push_group_unit(out: &mut String, index: usize) { - let code = match index { - 0 => None, - 1 => Some(0x4e07), - 2 => Some(0x4ebf), - 3 => Some(0x5146), - _ => unreachable!(), - }; - if let Some(code) = code { - push_code(out, code); - } + let code = match index { + 0 => None, + 1 => Some(0x4e07), + 2 => Some(0x4ebf), + 3 => Some(0x5146), + _ => unreachable!(), + }; + if let Some(code) = code { + push_code(out, code); + } } fn code_string(codes: &[u32]) -> String { - let mut out = String::new(); - for code in codes { - push_code(&mut out, *code); - } - out + let mut out = String::new(); + for code in codes { + push_code(&mut out, *code); + } + out } fn push_code(out: &mut String, code: u32) { - out.push(char::from_u32(code).expect("valid CJK codepoint")); + out.push(char::from_u32(code).expect("valid CJK codepoint")); } From 79e534275162a775bf77692b7a4125df8a485603 Mon Sep 17 00:00:00 2001 From: bigtomcat <69410696+bigtomcat6@users.noreply.github.com> Date: Sat, 30 May 2026 13:07:12 +1000 Subject: [PATCH 43/44] style: apply default rustfmt to parser module --- src/rmb_to_rmb/parse.rs | 132 ++++++++++++++++++++-------------------- 1 file changed, 66 insertions(+), 66 deletions(-) diff --git a/src/rmb_to_rmb/parse.rs b/src/rmb_to_rmb/parse.rs index 2cf4fcb..ee9e946 100644 --- a/src/rmb_to_rmb/parse.rs +++ b/src/rmb_to_rmb/parse.rs @@ -1,88 +1,88 @@ use super::{RmbError, MAX_CENTS, MAX_INTEGER}; pub fn parse_cents(input: &str) -> Result { - parse_unsigned(clean_amount(input)?, MAX_CENTS) + parse_unsigned(clean_amount(input)?, MAX_CENTS) } pub fn parse_decimal_amount(input: &str) -> Result { - let input = clean_amount(input)?; - let (integer, fraction) = split_decimal(input)?; - let integer = if integer.is_empty() { - 0 - } else { - parse_unsigned(integer, MAX_INTEGER)? - }; - let fraction = parse_fraction(fraction)?; - integer - .checked_mul(100) - .and_then(|value| value.checked_add(fraction)) - .ok_or(RmbError::TooLarge) + let input = clean_amount(input)?; + let (integer, fraction) = split_decimal(input)?; + let integer = if integer.is_empty() { + 0 + } else { + parse_unsigned(integer, MAX_INTEGER)? + }; + let fraction = parse_fraction(fraction)?; + integer + .checked_mul(100) + .and_then(|value| value.checked_add(fraction)) + .ok_or(RmbError::TooLarge) } fn clean_amount(input: &str) -> Result<&str, RmbError> { - let input = input.trim(); - if input.is_empty() { - return Err(RmbError::InvalidFormat); - } - if input.starts_with('-') { - return Err(RmbError::NegativeAmount); - } - let input = input.strip_prefix('+').unwrap_or(input); - if input.is_empty() { - return Err(RmbError::InvalidFormat); - } - Ok(input) + let input = input.trim(); + if input.is_empty() { + return Err(RmbError::InvalidFormat); + } + if input.starts_with('-') { + return Err(RmbError::NegativeAmount); + } + let input = input.strip_prefix('+').unwrap_or(input); + if input.is_empty() { + return Err(RmbError::InvalidFormat); + } + Ok(input) } fn split_decimal(input: &str) -> Result<(&str, Option<&str>), RmbError> { - let mut parts = input.split('.'); - let integer = parts.next().ok_or(RmbError::InvalidFormat)?; - let fraction = parts.next(); - let has_more_parts = parts.next().is_some(); - let is_empty_decimal = integer.is_empty() && fraction.unwrap_or("").is_empty(); + let mut parts = input.split('.'); + let integer = parts.next().ok_or(RmbError::InvalidFormat)?; + let fraction = parts.next(); + let has_more_parts = parts.next().is_some(); + let is_empty_decimal = integer.is_empty() && fraction.unwrap_or("").is_empty(); - if has_more_parts || is_empty_decimal { - return Err(RmbError::InvalidFormat); - } - Ok((integer, fraction)) + if has_more_parts || is_empty_decimal { + return Err(RmbError::InvalidFormat); + } + Ok((integer, fraction)) } fn parse_unsigned(input: &str, max: i128) -> Result { - if input.is_empty() || !input.chars().all(|ch| ch.is_ascii_digit()) { - return Err(RmbError::InvalidFormat); - } - let mut value = 0_i128; - for ch in input.chars() { - let digit = ch.to_digit(10).ok_or(RmbError::InvalidFormat)? as i128; - value = value - .checked_mul(10) - .and_then(|current| current.checked_add(digit)) - .ok_or(RmbError::TooLarge)?; - if value > max { - return Err(RmbError::TooLarge); + if input.is_empty() || !input.chars().all(|ch| ch.is_ascii_digit()) { + return Err(RmbError::InvalidFormat); + } + let mut value = 0_i128; + for ch in input.chars() { + let digit = ch.to_digit(10).ok_or(RmbError::InvalidFormat)? as i128; + value = value + .checked_mul(10) + .and_then(|current| current.checked_add(digit)) + .ok_or(RmbError::TooLarge)?; + if value > max { + return Err(RmbError::TooLarge); + } } - } - Ok(value) + Ok(value) } fn parse_fraction(input: Option<&str>) -> Result { - let Some(input) = input else { - return Ok(0); - }; - if input.len() > 2 { - return Err(RmbError::TooManyDecimalPlaces); - } - if !input.chars().all(|ch| ch.is_ascii_digit()) { - return Err(RmbError::InvalidFormat); - } - let value = match input.len() { - 0 => 0, - 1 => { - let digit = input.chars().next().unwrap().to_digit(10).unwrap(); - digit as i128 * 10 + let Some(input) = input else { + return Ok(0); + }; + if input.len() > 2 { + return Err(RmbError::TooManyDecimalPlaces); + } + if !input.chars().all(|ch| ch.is_ascii_digit()) { + return Err(RmbError::InvalidFormat); } - 2 => input.parse::().map_err(|_| RmbError::InvalidFormat)?, - _ => unreachable!(), - }; - Ok(value) + let value = match input.len() { + 0 => 0, + 1 => { + let digit = input.chars().next().unwrap().to_digit(10).unwrap(); + digit as i128 * 10 + } + 2 => input.parse::().map_err(|_| RmbError::InvalidFormat)?, + _ => unreachable!(), + }; + Ok(value) } From f0f518b54adb544229ab3a941e4b450092a7b010 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Sun, 31 May 2026 00:50:15 +1000 Subject: [PATCH 44/44] ci: tighten PR Rust checks --- .github/workflows/ci.yml | 4 +- src/rmb_to_rmb/tests.rs | 133 +++++++++++++++++++-------------------- 2 files changed, 68 insertions(+), 69 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26c9aa0..cd78e64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,6 @@ name: CI on: pull_request: - push: branches: - main - 'refactor/**' @@ -24,6 +23,9 @@ jobs: - name: Check formatting run: cargo fmt --all -- --check + - name: Compile check + run: cargo check --all-targets + - name: Run clippy run: cargo clippy --all-targets -- -D warnings diff --git a/src/rmb_to_rmb/tests.rs b/src/rmb_to_rmb/tests.rs index c4f4a48..09ed36a 100644 --- a/src/rmb_to_rmb/tests.rs +++ b/src/rmb_to_rmb/tests.rs @@ -1,92 +1,89 @@ use super::*; fn s(codes: &[u32]) -> String { - codes - .iter() - .map(|code| char::from_u32(*code).expect("valid CJK codepoint")) - .collect() + codes + .iter() + .map(|code| char::from_u32(*code).expect("valid CJK codepoint")) + .collect() } #[test] fn formats_zero_and_plain_integers() { - assert_eq!( - to_rmb_upper_from_cents(0).unwrap(), - s(&[0x96f6, 0x5143, 0x6574]), - ); - assert_eq!( - to_rmb_upper_from_cents(100).unwrap(), - s(&[0x58f9, 0x5143, 0x6574]), - ); - assert_eq!( - to_rmb_upper_from_cents(1_000).unwrap(), - s(&[0x58f9, 0x62fe, 0x5143, 0x6574]), - ); + assert_eq!( + to_rmb_upper_from_cents(0).unwrap(), + s(&[0x96f6, 0x5143, 0x6574]), + ); + assert_eq!( + to_rmb_upper_from_cents(100).unwrap(), + s(&[0x58f9, 0x5143, 0x6574]), + ); + assert_eq!( + to_rmb_upper_from_cents(1_000).unwrap(), + s(&[0x58f9, 0x62fe, 0x5143, 0x6574]), + ); } #[test] fn formats_decimal_parts() { - assert_eq!( - to_rmb_upper_from_cents(1).unwrap(), - s(&[0x96f6, 0x5143, 0x58f9, 0x5206]), - ); - assert_eq!( - to_rmb_upper_from_cents(10).unwrap(), - s(&[0x96f6, 0x5143, 0x58f9, 0x89d2]), - ); - assert_eq!( - to_rmb_upper_from_cents(101).unwrap(), - s(&[0x58f9, 0x5143, 0x96f6, 0x58f9, 0x5206]), - ); + assert_eq!( + to_rmb_upper_from_cents(1).unwrap(), + s(&[0x96f6, 0x5143, 0x58f9, 0x5206]), + ); + assert_eq!( + to_rmb_upper_from_cents(10).unwrap(), + s(&[0x96f6, 0x5143, 0x58f9, 0x89d2]), + ); + assert_eq!( + to_rmb_upper_from_cents(101).unwrap(), + s(&[0x58f9, 0x5143, 0x96f6, 0x58f9, 0x5206]), + ); } #[test] fn formats_group_boundaries_and_internal_zeroes() { - assert_eq!( - to_rmb_upper_from_cents(1_000_000).unwrap(), - s(&[0x58f9, 0x4e07, 0x5143, 0x6574]), - ); - assert_eq!( - to_rmb_upper_from_cents(1_000_100).unwrap(), - s(&[0x58f9, 0x4e07, 0x96f6, 0x58f9, 0x5143, 0x6574]), - ); + assert_eq!( + to_rmb_upper_from_cents(1_000_000).unwrap(), + s(&[0x58f9, 0x4e07, 0x5143, 0x6574]), + ); + assert_eq!( + to_rmb_upper_from_cents(1_000_100).unwrap(), + s(&[0x58f9, 0x4e07, 0x96f6, 0x58f9, 0x5143, 0x6574]), + ); } #[test] fn parses_strings() { - assert_eq!( - to_rmb_upper_from_str("001.20").unwrap(), - s(&[0x58f9, 0x5143, 0x8d30, 0x89d2]), - ); - assert_eq!( - to_rmb_upper_from_cents_str("12345").unwrap(), - to_rmb_upper_from_str("123.45").unwrap(), - ); + assert_eq!( + to_rmb_upper_from_str("001.20").unwrap(), + s(&[0x58f9, 0x5143, 0x8d30, 0x89d2]), + ); + assert_eq!( + to_rmb_upper_from_cents_str("12345").unwrap(), + to_rmb_upper_from_str("123.45").unwrap(), + ); } #[test] fn rejects_invalid_amounts() { - assert_eq!( - to_rmb_upper_from_cents(-1), - Err(RmbError::NegativeAmount), - ); - assert_eq!( - to_rmb_upper_from_cents(MAX_CENTS + 1), - Err(RmbError::TooLarge), - ); - assert_eq!( - to_rmb_upper_from_cents_str("-1"), - Err(RmbError::NegativeAmount), - ); - assert_eq!( - to_rmb_upper_from_cents_str("1.00"), - Err(RmbError::InvalidFormat), - ); - assert_eq!( - to_rmb_upper_from_str("1.234"), - Err(RmbError::TooManyDecimalPlaces), - ); - assert_eq!( - to_rmb_upper_from_f64(f64::NAN), - Err(RmbError::NonFiniteAmount), - ); + assert_eq!(to_rmb_upper_from_cents(-1), Err(RmbError::NegativeAmount),); + assert_eq!( + to_rmb_upper_from_cents(MAX_CENTS + 1), + Err(RmbError::TooLarge), + ); + assert_eq!( + to_rmb_upper_from_cents_str("-1"), + Err(RmbError::NegativeAmount), + ); + assert_eq!( + to_rmb_upper_from_cents_str("1.00"), + Err(RmbError::InvalidFormat), + ); + assert_eq!( + to_rmb_upper_from_str("1.234"), + Err(RmbError::TooManyDecimalPlaces), + ); + assert_eq!( + to_rmb_upper_from_f64(f64::NAN), + Err(RmbError::NonFiniteAmount), + ); }