diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cd78e64 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + pull_request: + 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: Compile check + run: cargo check --all-targets + + - 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 diff --git a/Cargo.toml b/Cargo.toml index bd195b6..e1658c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,24 @@ [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"] [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" 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) - diff --git a/build.rs b/build.rs index 1f866b6..bf41e7b 100644 --- a/build.rs +++ b/build.rs @@ -1,5 +1,7 @@ -extern crate napi_build; - +#[cfg(feature = "napi")] fn main() { - napi_build::setup(); + napi_build::setup(); } + +#[cfg(not(feature = "napi"))] +fn main() {} diff --git a/index.d.ts b/index.d.ts index 23d6216..d6d88fe 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,5 +3,20 @@ /* 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. + * The cents value is accepted as a string for large integer amounts. + */ +export function rmbToRmbFromCents(cents: string): 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 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 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 +} diff --git a/rustfmt.toml b/rustfmt.toml deleted file mode 100644 index cab5731..0000000 --- a/rustfmt.toml +++ /dev/null @@ -1,2 +0,0 @@ -tab_spaces = 2 -edition = "2021" diff --git a/src/lib.rs b/src/lib.rs index b473823..f802b31 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_cents_str, to_rmb_upper_from_f64, + to_rmb_upper_from_str, RmbError, MAX_CENTS, MAX_INTEGER, +}; diff --git a/src/napi_bindings.rs b/src/napi_bindings.rs new file mode 100644 index 0000000..90009a4 --- /dev/null +++ b/src/napi_bindings.rs @@ -0,0 +1,29 @@ +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. +/// +/// 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: 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. +#[napi] +pub fn rmb_to_rmb_from_string(amount: String) -> napi::Result { + crate::to_rmb_upper_from_str(&amount).map_err(to_napi_error) +} diff --git a/src/rmb_to_rmb.rs b/src/rmb_to_rmb.rs index 6ffebad..4fb89e5 100644 --- a/src/rmb_to_rmb.rs +++ b/src/rmb_to_rmb.rs @@ -1,158 +1,75 @@ +use std::error::Error; +use std::fmt; -const UPPER_RMB: [&str; 10] = [ - "零", "壹", "贰", "叁", "肆", - "伍", "陆", "柒", "捌", "玖" - ]; -const UNIT: [&str; 6] = [ - "", "拾", "佰", "仟", "万", "亿" - ]; -const PART_UNIT: [&str; 4] = [ - "", "万", "亿", "兆" - ]; +mod format; +mod parse; -#[napi] -pub fn rmb_to_rmb(n: f64) -> String { +#[cfg(test)] +mod tests; - let mut u_rmb = String::new(); // Upper_RMB: 大写人民币 +pub const MAX_INTEGER: i128 = 9_999_999_999_999_999; +pub const MAX_CENTS: i128 = MAX_INTEGER * 100 + 99; - - let zs: u64 = n as u64; //整数部分 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RmbError { + NegativeAmount, + NonFiniteAmount, + InvalidFormat, + TooManyDecimalPlaces, + 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()); +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; + if cents > MAX_CENTS { + return Err(RmbError::TooLarge); } - - u_rmb.insert_str(0, "元"); - u_rmb.insert_str(0, integer_part(zs).as_str()); + let integer = (cents / 100) as u128; + let fraction = (cents % 100) as u8; + Ok(format::format_amount(integer, fraction)) +} - // u_rmb.push_str(&n.to_string()); 【用于debug】 +pub fn to_rmb_upper_from_cents_str(cents: &str) -> Result { + parse::parse_cents(cents).and_then(to_rmb_upper_from_cents) +} - u_rmb +pub fn to_rmb_upper_from_str(amount: &str) -> Result { + parse::parse_decimal_amount(amount).and_then(to_rmb_upper_from_cents) } -// 小数部分 -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("零"); +pub fn to_rmb_upper_from_f64(amount: f64) -> Result { + if !amount.is_finite() { + return Err(RmbError::NonFiniteAmount); } - /* 分 */ - let _f = xs % 10; - if _f != 0 { - u_rmb.push_str(UPPER_RMB[_f as usize]); - u_rmb.push_str("分"); + if amount < 0.0 { + return Err(RmbError::NegativeAmount); } - - u_rmb -} -// 整数部分 -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; + let cents = (amount * 100.0).round(); + if cents > MAX_CENTS as f64 { + return Err(RmbError::TooLarge); } - u_rmb + to_rmb_upper_from_cents(cents as i128) } - - -// 整数部分的4位一拆分处理 -fn integer_mid_part(n: u64) -> String { - if n == 0 { return String::from("") } - - let mut u_rmb = String::new(); - let mut unit = 0; //目前单位的位置 - let mut rmb = n; //当前部分的数字 - let mut now = n % 10; //当前位的数字 - - let mut last_was_zero = false; //是否需要加零 - let mut is_alway_zero = true; //是否从头是零 - - 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; - - } else if last_was_zero { - u_rmb.insert_str(0, "零"); - last_was_zero = false; - } - - u_rmb.insert_str(0, UNIT[unit as usize]); - u_rmb.insert_str(0, UPPER_RMB[now as usize]); - - is_alway_zero = false; - - unit += 1; - rmb /= 10; - now = rmb % 10; - } - - - u_rmb -} \ No newline at end of file diff --git a/src/rmb_to_rmb/format.rs b/src/rmb_to_rmb/format.rs new file mode 100644 index 0000000..8d007c7 --- /dev/null +++ b/src/rmb_to_rmb/format.rs @@ -0,0 +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 +} + +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")); +} diff --git a/src/rmb_to_rmb/parse.rs b/src/rmb_to_rmb/parse.rs new file mode 100644 index 0000000..ee9e946 --- /dev/null +++ b/src/rmb_to_rmb/parse.rs @@ -0,0 +1,88 @@ +use super::{RmbError, MAX_CENTS, MAX_INTEGER}; + +pub fn parse_cents(input: &str) -> Result { + 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) +} + +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(); + 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)) +} + +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 => { + let digit = input.chars().next().unwrap().to_digit(10).unwrap(); + digit as i128 * 10 + } + 2 => input.parse::().map_err(|_| RmbError::InvalidFormat)?, + _ => unreachable!(), + }; + Ok(value) +} diff --git a/src/rmb_to_rmb/tests.rs b/src/rmb_to_rmb/tests.rs new file mode 100644 index 0000000..09ed36a --- /dev/null +++ b/src/rmb_to_rmb/tests.rs @@ -0,0 +1,89 @@ +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), + ); +}