diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e4cb5c..36e5a77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,43 +15,19 @@ - **Sample app restructured** into a 5-tab navigation app (Core, Scale & Context, Financial, Format & Stats, Validation) showcasing all library features. -## [0.2.0] - 2026-03-20 +- **`toString()` now preserves trailing zeros** — `Deci("1.50").toString()` returns + `"1.50"`, and `Deci("1.2300").toString()` returns `"1.2300"`. The JVM/Android + constructor no longer strips trailing zeros. -### Breaking Changes +- **`toString()` never uses scientific notation** — guaranteed across all platforms + (JVM, Android, JS, wasmJs, Apple). For example, `Deci("100000000000000000000").toString()` + always returns `"100000000000000000000"`, never `"1E+20"`. -- **`toString()` may now return scientific notation** for very large or very small values. - On JVM/Android this delegates to `BigDecimal.toString()`, on JS/WASM to `decimal.js`'s `toString()`. - Previously, `toString()` always returned a plain decimal string. +## [0.2.0] - 2026-03-20 ### Added -- **`toPlainString()`** — returns the string representation without scientific notation, - preserving the scale (e.g. `"1.50"` stays `"1.50"`). This is the behaviour that - `toString()` had in 0.1.x. - -### Migration Guide - -Replace `toString()` with `toPlainString()` wherever you need a plain decimal string: - -```kotlin -// Before (0.1.x) -val text = myDeci.toString() // always "100000000000000000000" - -// After (0.2.0) -val text = myDeci.toPlainString() // always "100000000000000000000" -val debug = myDeci.toString() // may return "1E+20" on JVM -``` - -Common patterns to update: - -| Old pattern | New pattern | -|---|---| -| `deci.toString()` (for display/storage) | `deci.toPlainString()` | -| `Deci(someDeci.toString())` | `Deci(someDeci.toPlainString())` | -| `"Amount: $deci"` (if plain format needed) | `"Amount: ${deci.toPlainString()}"` | - -**Serialization is unaffected** — the built-in `DeciSerializer` has been updated internally -to use `toPlainString()`, so JSON output remains a plain decimal string. +- Comprehensive sample app with tabbed navigation ## [0.1.1] diff --git a/deci/api/android/deci.api b/deci/api/android/deci.api index ad5797a..d1bd540 100644 --- a/deci/api/android/deci.api +++ b/deci/api/android/deci.api @@ -25,7 +25,6 @@ public final class org/kimplify/deci/Deci : java/lang/Comparable { public final fun setScale (ILorg/kimplify/deci/RoundingMode;)Lorg/kimplify/deci/Deci; public final fun times (Lorg/kimplify/deci/Deci;)Lorg/kimplify/deci/Deci; public final fun toDouble ()D - public final fun toPlainString ()Ljava/lang/String; public fun toString ()Ljava/lang/String; public final fun unaryMinus ()Lorg/kimplify/deci/Deci; } diff --git a/deci/api/jvm/deci.api b/deci/api/jvm/deci.api index ad5797a..d1bd540 100644 --- a/deci/api/jvm/deci.api +++ b/deci/api/jvm/deci.api @@ -25,7 +25,6 @@ public final class org/kimplify/deci/Deci : java/lang/Comparable { public final fun setScale (ILorg/kimplify/deci/RoundingMode;)Lorg/kimplify/deci/Deci; public final fun times (Lorg/kimplify/deci/Deci;)Lorg/kimplify/deci/Deci; public final fun toDouble ()D - public final fun toPlainString ()Ljava/lang/String; public fun toString ()Ljava/lang/String; public final fun unaryMinus ()Lorg/kimplify/deci/Deci; } diff --git a/deci/src/androidMain/kotlin/org/kimplify/deci/Deci.kt b/deci/src/androidMain/kotlin/org/kimplify/deci/Deci.kt index d7129eb..e22f0bd 100644 --- a/deci/src/androidMain/kotlin/org/kimplify/deci/Deci.kt +++ b/deci/src/androidMain/kotlin/org/kimplify/deci/Deci.kt @@ -15,7 +15,7 @@ actual class Deci( private val internal: BigDecimal, ) : Comparable { actual constructor(value: String) : this( - BigDecimal(validateAndNormalizeDecimalLiteral(value)).stripTrailingZeros(), + BigDecimal(validateAndNormalizeDecimalLiteral(value)), ) actual constructor(value: Long) : this(value.toString()) @@ -103,9 +103,7 @@ actual class Deci( return Deci(internal.setScale(scale, convert(roundingMode))) } - actual override fun toString(): String = internal.toString() - - actual fun toPlainString(): String = internal.toPlainString() + actual override fun toString(): String = internal.toPlainString() actual fun toDouble(): Double = internal.toDouble() diff --git a/deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt b/deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt index a867b64..647d117 100644 --- a/deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt +++ b/deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable import org.kimplify.deci.config.DeciConfiguration import org.kimplify.deci.exception.DeciDivisionByZeroException import org.kimplify.deci.exception.DeciScaleException +import org.kimplify.deci.parser.extractScale import org.kimplify.deci.parser.validateAndNormalizeDecimalLiteral import platform.Foundation.NSDecimalNumber import platform.Foundation.NSDecimalNumberHandler @@ -19,6 +20,7 @@ actual class Deci private constructor( ) : Comparable { actual constructor(value: String) : this( NSDecimalNumber(validateAndNormalizeDecimalLiteral(value)), + extractScale(validateAndNormalizeDecimalLiteral(value)), ) actual constructor(value: Long) : this(value.toString()) @@ -119,7 +121,7 @@ actual class Deci private constructor( raiseOnUnderflow = false, raiseOnDivideByZero = false, ) - return Deci(raw.decimalNumberByRoundingAccordingToBehavior(handler).stringValue) + return Deci(raw.decimalNumberByRoundingAccordingToBehavior(handler), policy.fractionalDigits) } actual fun divide( @@ -171,16 +173,6 @@ actual class Deci private constructor( return if (currentScale >= scale) str else str + "0".repeat(scale - currentScale) } - actual fun toPlainString(): String { - val str = internal.stringValue - val scale = _scale ?: return str - if (scale == 0) return str.split(".")[0] - val parts = str.split(".") - val intPart = parts[0] - val fracPart = if (parts.size > 1) parts[1] else "" - return "$intPart.${fracPart.padEnd(scale, '0')}" - } - actual fun toDouble(): Double = internal.doubleValue actual fun isZero(): Boolean = internal.compare(NSDecimalNumber.zero) == 0L diff --git a/deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt b/deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt index d46dc20..d7ee8d4 100644 --- a/deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt +++ b/deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt @@ -129,23 +129,13 @@ expect class Deci : Comparable { roundingMode: RoundingMode, ): Deci - /** - * Returns the string representation of this [Deci]. - * - * For very large or very small values, the result may use scientific notation - * (e.g. `"1E+20"`) depending on the platform. Use [toPlainString] when a - * plain decimal string is required. - */ - override fun toString(): String - /** * Returns the string representation of this [Deci] without scientific notation, * preserving the scale (e.g. `"1.50"` stays `"1.50"`). * - * Unlike [toString], this method never uses exponential notation regardless - * of the value's magnitude. + * This method never uses exponential notation regardless of the value's magnitude. */ - fun toPlainString(): String + override fun toString(): String /** * Converts this [Deci] to a [Double]. diff --git a/deci/src/commonMain/kotlin/org/kimplify/deci/DeciSerializer.kt b/deci/src/commonMain/kotlin/org/kimplify/deci/DeciSerializer.kt index ca1a9fa..1a36a5c 100644 --- a/deci/src/commonMain/kotlin/org/kimplify/deci/DeciSerializer.kt +++ b/deci/src/commonMain/kotlin/org/kimplify/deci/DeciSerializer.kt @@ -15,6 +15,7 @@ import org.kimplify.deci.exception.DeciSerializationException * For example, `Deci("1.50")` is serialized as the JSON string `"1.50"`, not the * JSON number `1.5`. * + * Serialization uses [Deci.toString], which never produces scientific notation. * Deserialization parses the decoded string via the [Deci] string constructor. * * @throws [org.kimplify.deci.exception.DeciSerializationException] if the decoded string @@ -27,7 +28,7 @@ object DeciSerializer : KSerializer { encoder: Encoder, value: Deci, ) { - encoder.encodeString(value.toPlainString()) + encoder.encodeString(value.toString()) } override fun deserialize(decoder: Decoder): Deci { diff --git a/deci/src/commonMain/kotlin/org/kimplify/deci/extension/DeciExtensions.kt b/deci/src/commonMain/kotlin/org/kimplify/deci/extension/DeciExtensions.kt index de95f32..fed9cc8 100644 --- a/deci/src/commonMain/kotlin/org/kimplify/deci/extension/DeciExtensions.kt +++ b/deci/src/commonMain/kotlin/org/kimplify/deci/extension/DeciExtensions.kt @@ -31,7 +31,7 @@ fun Deci.toLong(): Long = toLongExact() */ fun Deci.toLongOrNull(): Long? { val truncated = this.setScale(0, RoundingMode.DOWN) - val str = truncated.toPlainString() + val str = truncated.toString() return str.toLongOrNull() } @@ -53,7 +53,7 @@ fun Deci.toLongExact(): Long = * @return the scale (number of fractional digits), or `0` if there is no decimal separator. */ fun Deci.scale(): Int { - val text = toPlainString() + val text = toString() val separatorIndex = text.indexOf('.') if (separatorIndex < 0) return 0 return text.length - separatorIndex - 1 @@ -65,7 +65,7 @@ fun Deci.scale(): Int { * @return the total number of digit characters in the canonical string representation. */ fun Deci.precision(): Int { - val text = toPlainString() + val text = toString() return text.count { it.isDigit() } } diff --git a/deci/src/commonMain/kotlin/org/kimplify/deci/formatting/DeciFormatting.kt b/deci/src/commonMain/kotlin/org/kimplify/deci/formatting/DeciFormatting.kt index e8feb1d..d24cccd 100644 --- a/deci/src/commonMain/kotlin/org/kimplify/deci/formatting/DeciFormatting.kt +++ b/deci/src/commonMain/kotlin/org/kimplify/deci/formatting/DeciFormatting.kt @@ -34,7 +34,7 @@ fun Deci.formatCurrency( * @return Formatted string with thousands separators */ fun Deci.formatWithThousandsSeparator(separator: String = ","): String { - val str = this.toPlainString() + val str = this.toString() val parts = str.split(".") val integerPart = parts[0] val decimalPart = if (parts.size > 1) ".${parts[1]}" else "" @@ -66,7 +66,7 @@ fun Deci.formatAsPercentage( ): String { val percentage = this * DeciConstants.HUNDRED val rounded = percentage.setScale(scale, RoundingMode.HALF_UP) - return "${rounded.toPlainString()}$symbol" + return "$rounded$symbol" } /** @@ -88,7 +88,7 @@ fun Deci.formatAsPercentage( fun Deci.toScientificNotation(precision: Int = 6): String { if (this.isZero()) return "0.0E+0" - val str = this.abs().toPlainString() + val str = this.abs().toString() val allDigits = str.filter { it.isDigit() } val firstNonZeroIdx = allDigits.indexOfFirst { it != '0' } if (firstNonZeroIdx == -1) return "0.0E+0" @@ -138,9 +138,9 @@ fun Deci.toScientificNotation(precision: Int = 6): String { */ fun Deci.format(pattern: String): String = when (pattern) { - "0.00" -> this.setScale(2, RoundingMode.HALF_UP).toPlainString() + "0.00" -> this.setScale(2, RoundingMode.HALF_UP).toString() "#,##0.00" -> this.setScale(2, RoundingMode.HALF_UP).formatWithThousandsSeparator() - "0.0000" -> this.setScale(4, RoundingMode.HALF_UP).toPlainString() + "0.0000" -> this.setScale(4, RoundingMode.HALF_UP).toString() "#,##0" -> this.setScale(0, RoundingMode.HALF_UP).formatWithThousandsSeparator() else -> throw DeciFormatException(pattern = pattern) } @@ -156,7 +156,7 @@ fun Deci.toWords(): String { val isNegative = this.isNegative() val abs = this.abs() - val parts = abs.toPlainString().split(".") + val parts = abs.toString().split(".") val integerPart = parts[0].toLongOrNull() ?: return "number too large" val fractionalPart = @@ -254,7 +254,7 @@ fun Deci.pad( padChar: Char = ' ', padLeft: Boolean = true, ): String { - val str = this.toPlainString() + val str = this.toString() return if (padLeft) { str.padStart(width, padChar) } else { diff --git a/deci/src/commonMain/kotlin/org/kimplify/deci/math/DeciMath.kt b/deci/src/commonMain/kotlin/org/kimplify/deci/math/DeciMath.kt index 10db6ed..10efe4a 100644 --- a/deci/src/commonMain/kotlin/org/kimplify/deci/math/DeciMath.kt +++ b/deci/src/commonMain/kotlin/org/kimplify/deci/math/DeciMath.kt @@ -152,7 +152,7 @@ fun Deci.roundToSignificantDigits(digits: Int): Deci { if (this.isZero()) return Deci.ZERO val absValue = this.abs() - val str = absValue.toPlainString() + val str = absValue.toString() val firstSigDigitIndex = str.indexOfFirst { it.isDigit() && it != '0' } if (firstSigDigitIndex == -1) return Deci.ZERO diff --git a/deci/src/commonMain/kotlin/org/kimplify/deci/parser/StringUtils.kt b/deci/src/commonMain/kotlin/org/kimplify/deci/parser/StringUtils.kt index 9997739..9992b9e 100644 --- a/deci/src/commonMain/kotlin/org/kimplify/deci/parser/StringUtils.kt +++ b/deci/src/commonMain/kotlin/org/kimplify/deci/parser/StringUtils.kt @@ -78,3 +78,17 @@ private fun normalizeWithDecimal( return "$safeInteger.$decimalPart" } + +/** + * Extracts the scale (number of digits after the decimal point) from a normalized decimal string. + * + * Examples: + * "1.2300" -> 4 + * "123" -> 0 + * "-2.5" -> 1 + * "0.5" -> 1 + */ +internal fun extractScale(normalizedValue: String): Int { + val dotIndex = normalizedValue.indexOf('.') + return if (dotIndex < 0) 0 else normalizedValue.length - dotIndex - 1 +} diff --git a/deci/src/commonMain/kotlin/org/kimplify/deci/validation/DeciValidation.kt b/deci/src/commonMain/kotlin/org/kimplify/deci/validation/DeciValidation.kt index 449ec8f..9a3fcbd 100644 --- a/deci/src/commonMain/kotlin/org/kimplify/deci/validation/DeciValidation.kt +++ b/deci/src/commonMain/kotlin/org/kimplify/deci/validation/DeciValidation.kt @@ -63,7 +63,7 @@ fun Deci.clamp( * @return True if this is a whole number */ fun Deci.isWhole(): Boolean { - val str = this.toPlainString() + val str = this.toString() return !str.contains('.') || str.endsWith(".0") || str.substringAfter('.').all { it == '0' } } @@ -115,7 +115,7 @@ fun Deci.safeDivide( fun Deci.hasValidDecimalPlaces(maxDecimalPlaces: Int): Boolean { require(maxDecimalPlaces >= 0) { "Max decimal places must be non-negative: $maxDecimalPlaces" } - val str = this.toPlainString() + val str = this.toString() val decimalIndex = str.indexOf('.') return if (decimalIndex == -1) { @@ -234,7 +234,7 @@ fun Deci.validateForForm( if (this < min) { return ValidationResult( false, - "Value must be at least ${min.toPlainString()}", + "Value must be at least $min", ) } } @@ -243,7 +243,7 @@ fun Deci.validateForForm( if (this > max) { return ValidationResult( false, - "Value must be at most ${max.toPlainString()}", + "Value must be at most $max", ) } } diff --git a/deci/src/commonTest/kotlin/org/kimplify/deci/DeciPropertyTest.kt b/deci/src/commonTest/kotlin/org/kimplify/deci/DeciPropertyTest.kt index f414a11..87a8ed9 100644 --- a/deci/src/commonTest/kotlin/org/kimplify/deci/DeciPropertyTest.kt +++ b/deci/src/commonTest/kotlin/org/kimplify/deci/DeciPropertyTest.kt @@ -204,7 +204,7 @@ class DeciPropertyTest { fun `compareTo zero implies equality`() = runTest { checkAll(config, arbDeci()) { a -> - val copy = Deci(a.toPlainString()) + val copy = Deci(a.toString()) assertTrue(a.compareTo(copy) == 0, "a=$a not equal to copy") assertEquals(a, copy) } diff --git a/deci/src/commonTest/kotlin/org/kimplify/deci/DeciSerializationTest.kt b/deci/src/commonTest/kotlin/org/kimplify/deci/DeciSerializationTest.kt index 6e5d47c..7cbc181 100644 --- a/deci/src/commonTest/kotlin/org/kimplify/deci/DeciSerializationTest.kt +++ b/deci/src/commonTest/kotlin/org/kimplify/deci/DeciSerializationTest.kt @@ -80,7 +80,7 @@ class DeciSerializationTest { val encoded = json.encodeToString(Deci.serializer(), d) val restored = json.decodeFromString(Deci.serializer(), encoded) assertEquals(d, restored, "Round-trip failed for $s") - assertEquals(s, restored.toPlainString(), "String representation changed for $s") + assertEquals(s, restored.toString(), "String representation changed for $s") } } diff --git a/deci/src/commonTest/kotlin/org/kimplify/deci/DeciTest.kt b/deci/src/commonTest/kotlin/org/kimplify/deci/DeciTest.kt index 7607607..fed1834 100644 --- a/deci/src/commonTest/kotlin/org/kimplify/deci/DeciTest.kt +++ b/deci/src/commonTest/kotlin/org/kimplify/deci/DeciTest.kt @@ -8,6 +8,7 @@ import org.kimplify.deci.exception.DeciParseException import org.kimplify.deci.exception.DeciScaleException import org.kimplify.deci.extension.precision import org.kimplify.deci.extension.scale +import org.kimplify.deci.formatting.toScientificNotation import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -63,7 +64,7 @@ class DeciTest { @Test fun `div should divide with default scale`() { val result = Deci("5") / Deci("2") - assertEquals("2.5", result.toString()) + assertEquals("2.50000000000000000000", result.toString()) } @Test @@ -255,10 +256,10 @@ class DeciTest { assertEquals(Deci("10"), Deci.TEN) } - @Test fun `toDouble and toPlainString roundtrip for simple values`() { + @Test fun `toDouble and toString roundtrip for simple values`() { listOf("0", "1.5", "-2.75").forEach { s -> val d = Deci(s) - assertEquals(s, d.toPlainString()) + assertEquals(s, d.toString()) assertEquals(s.toDouble(), d.toDouble()) } } @@ -282,18 +283,20 @@ class DeciTest { assertEquals(Deci.ZERO, Deci("-0")) } - @Test fun `trailing zeros are stripped by constructor`() { - assertEquals("1.23", Deci("1.2300").toPlainString()) + @Test fun `trailing zeros are preserved by constructor`() { + assertEquals("1.2300", Deci("1.2300").toString()) } - @Test fun `toPlainString never uses scientific notation`() { - assertEquals("100000000000000000000", Deci("100000000000000000000").toPlainString()) - assertEquals("0.000000001", Deci("0.000000001").toPlainString()) + @Test fun `toString never uses scientific notation`() { + assertEquals("100000000000000000000", Deci("100000000000000000000").toString()) + assertEquals("0.000000001", Deci("0.000000001").toString()) + assertEquals("0.00000000000001", Deci("0.00000000000001").toString()) } - @Test fun `toString may use scientific notation for extreme values`() { - val large = Deci("100000000000000000000") - assertEquals(large, Deci(large.toPlainString())) + @Test fun `toString and toScientificNotation for very small values`() { + val small = Deci("0.00000000000001") + assertEquals("0.00000000000001", small.toString()) + assertEquals("1E-14", small.toScientificNotation()) } @Test fun `invalid string formats throw`() { diff --git a/deci/src/commonTest/kotlin/org/kimplify/deci/bulk/DeciBulkOperationsTest.kt b/deci/src/commonTest/kotlin/org/kimplify/deci/bulk/DeciBulkOperationsTest.kt index 25ecb2b..23ea7b3 100644 --- a/deci/src/commonTest/kotlin/org/kimplify/deci/bulk/DeciBulkOperationsTest.kt +++ b/deci/src/commonTest/kotlin/org/kimplify/deci/bulk/DeciBulkOperationsTest.kt @@ -264,7 +264,7 @@ class DeciBulkOperationsTest { @Test fun `divideAllBy uses default context when not specified`() { val result = listOf(Deci("10")).divideAllBy(Deci("3")) - assertTrue(result[0].toPlainString().length > 5) + assertTrue(result[0].toString().length > 5) } @Test diff --git a/deci/src/commonTest/kotlin/org/kimplify/deci/math/DeciMathExtendedTest.kt b/deci/src/commonTest/kotlin/org/kimplify/deci/math/DeciMathExtendedTest.kt index c9ffa97..ade4f2c 100644 --- a/deci/src/commonTest/kotlin/org/kimplify/deci/math/DeciMathExtendedTest.kt +++ b/deci/src/commonTest/kotlin/org/kimplify/deci/math/DeciMathExtendedTest.kt @@ -265,7 +265,7 @@ class DeciMathExtendedTest { @Test fun `roundToSignificantDigits preserves more digits than available`() { val result = Deci("12").roundToSignificantDigits(5) - assertTrue(result.toPlainString().startsWith("12")) + assertTrue(result.toString().startsWith("12")) } @Test diff --git a/deci/src/jsMain/kotlin/org/kimplify/deci/Deci.kt b/deci/src/jsMain/kotlin/org/kimplify/deci/Deci.kt index c9ed747..6af7e05 100644 --- a/deci/src/jsMain/kotlin/org/kimplify/deci/Deci.kt +++ b/deci/src/jsMain/kotlin/org/kimplify/deci/Deci.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable import org.kimplify.deci.config.DeciConfiguration import org.kimplify.deci.exception.DeciDivisionByZeroException import org.kimplify.deci.exception.DeciScaleException +import org.kimplify.deci.parser.extractScale import org.kimplify.deci.parser.validateAndNormalizeDecimalLiteral @Serializable(with = DeciSerializer::class) @@ -13,6 +14,7 @@ actual class Deci private constructor( ) : Comparable { actual constructor(value: String) : this( DecimalJs(validateAndNormalizeDecimalLiteral(value)), + extractScale(validateAndNormalizeDecimalLiteral(value)), ) actual constructor(value: Long) : this(value.toString()) @@ -42,7 +44,7 @@ actual class Deci private constructor( val policy = DeciConfiguration.divisionPolicy val raw = internal.div(other.internal) val rounded = raw.toDecimalPlaces(policy.fractionalDigits, convert(policy.roundingMode)) - return Deci(rounded) + return Deci(rounded, policy.fractionalDigits) } actual operator fun rem(other: Deci): Deci { @@ -77,14 +79,8 @@ actual class Deci private constructor( } actual override fun toString(): String { - val scale = _scale ?: return internal.toString() - if (scale <= 0) return internal.toString() - return internal.toFixed(scale) - } - - actual fun toPlainString(): String { val scale = _scale - return if (scale != null) internal.toFixed(scale) else internal.toFixed() + return if (scale != null && scale > 0) internal.toFixed(scale) else internal.toFixed() } actual fun toDouble(): Double = internal.toNumber() diff --git a/deci/src/jvmMain/kotlin/org/kimplify/deci/Deci.kt b/deci/src/jvmMain/kotlin/org/kimplify/deci/Deci.kt index d7129eb..e22f0bd 100644 --- a/deci/src/jvmMain/kotlin/org/kimplify/deci/Deci.kt +++ b/deci/src/jvmMain/kotlin/org/kimplify/deci/Deci.kt @@ -15,7 +15,7 @@ actual class Deci( private val internal: BigDecimal, ) : Comparable { actual constructor(value: String) : this( - BigDecimal(validateAndNormalizeDecimalLiteral(value)).stripTrailingZeros(), + BigDecimal(validateAndNormalizeDecimalLiteral(value)), ) actual constructor(value: Long) : this(value.toString()) @@ -103,9 +103,7 @@ actual class Deci( return Deci(internal.setScale(scale, convert(roundingMode))) } - actual override fun toString(): String = internal.toString() - - actual fun toPlainString(): String = internal.toPlainString() + actual override fun toString(): String = internal.toPlainString() actual fun toDouble(): Double = internal.toDouble() diff --git a/deci/src/wasmJsMain/kotlin/org/kimplify/deci/Deci.kt b/deci/src/wasmJsMain/kotlin/org/kimplify/deci/Deci.kt index c9ed747..6af7e05 100644 --- a/deci/src/wasmJsMain/kotlin/org/kimplify/deci/Deci.kt +++ b/deci/src/wasmJsMain/kotlin/org/kimplify/deci/Deci.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable import org.kimplify.deci.config.DeciConfiguration import org.kimplify.deci.exception.DeciDivisionByZeroException import org.kimplify.deci.exception.DeciScaleException +import org.kimplify.deci.parser.extractScale import org.kimplify.deci.parser.validateAndNormalizeDecimalLiteral @Serializable(with = DeciSerializer::class) @@ -13,6 +14,7 @@ actual class Deci private constructor( ) : Comparable { actual constructor(value: String) : this( DecimalJs(validateAndNormalizeDecimalLiteral(value)), + extractScale(validateAndNormalizeDecimalLiteral(value)), ) actual constructor(value: Long) : this(value.toString()) @@ -42,7 +44,7 @@ actual class Deci private constructor( val policy = DeciConfiguration.divisionPolicy val raw = internal.div(other.internal) val rounded = raw.toDecimalPlaces(policy.fractionalDigits, convert(policy.roundingMode)) - return Deci(rounded) + return Deci(rounded, policy.fractionalDigits) } actual operator fun rem(other: Deci): Deci { @@ -77,14 +79,8 @@ actual class Deci private constructor( } actual override fun toString(): String { - val scale = _scale ?: return internal.toString() - if (scale <= 0) return internal.toString() - return internal.toFixed(scale) - } - - actual fun toPlainString(): String { val scale = _scale - return if (scale != null) internal.toFixed(scale) else internal.toFixed() + return if (scale != null && scale > 0) internal.toFixed(scale) else internal.toFixed() } actual fun toDouble(): Double = internal.toNumber() diff --git a/docs/superpowers/plans/2026-03-27-remove-toplainstring.md b/docs/superpowers/plans/2026-03-27-remove-toplainstring.md new file mode 100644 index 0000000..ec2a6b9 --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-remove-toplainstring.md @@ -0,0 +1,533 @@ +# Remove `toPlainString()` — Unify String Representation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove `toPlainString()` from the Deci API and make `toString()` the single string representation method that never uses scientific notation. + +**Architecture:** Remove `toPlainString()` from the expect declaration and all actuals. On JVM/Android, switch `toString()` to delegate to `BigDecimal.toPlainString()`. Migrate all internal and test call sites from `toPlainString()` to `toString()`. + +**Tech Stack:** Kotlin Multiplatform, BigDecimal (JVM), DecimalJS (JS/wasmJs), NSDecimalNumber (Apple) + +--- + +### Task 1: Remove `toPlainString()` from expect declaration and update `toString()` KDoc + +**Files:** +- Modify: `deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt:131-148` + +- [ ] **Step 1: Remove `toPlainString()` and update `toString()` KDoc** + +Replace lines 131-148 with: + +```kotlin + + /** + * Returns the string representation of this [Deci] without scientific notation, + * preserving the scale (e.g. `"1.50"` stays `"1.50"`). + * + * This method never uses exponential notation regardless of the value's magnitude. + */ + override fun toString(): String +``` + +- [ ] **Step 2: Verify commonMain compiles** + +Run: `./gradlew :deci:compileCommonMainKotlinMetadata` +Expected: Compilation errors in actuals and call sites (expected at this stage) + +- [ ] **Step 3: Commit** + +```bash +git add deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt +git commit -m "Remove toPlainString from expect declaration, update toString KDoc" +``` + +--- + +### Task 2: Update JVM actual — remove `toPlainString()`, fix `toString()` + +**Files:** +- Modify: `deci/src/jvmMain/kotlin/org/kimplify/deci/Deci.kt:106-108` + +- [ ] **Step 1: Remove `toPlainString()` and change `toString()` to use `BigDecimal.toPlainString()`** + +Replace: + +```kotlin + actual override fun toString(): String = internal.toString() + + actual fun toPlainString(): String = internal.toPlainString() +``` + +With: + +```kotlin + actual override fun toString(): String = internal.toPlainString() +``` + +- [ ] **Step 2: Commit** + +```bash +git add deci/src/jvmMain/kotlin/org/kimplify/deci/Deci.kt +git commit -m "JVM: remove toPlainString, make toString use BigDecimal.toPlainString" +``` + +--- + +### Task 3: Update Android actual — remove `toPlainString()`, fix `toString()` + +**Files:** +- Modify: `deci/src/androidMain/kotlin/org/kimplify/deci/Deci.kt:106-108` + +- [ ] **Step 1: Remove `toPlainString()` and change `toString()` to use `BigDecimal.toPlainString()`** + +Replace: + +```kotlin + actual override fun toString(): String = internal.toString() + + actual fun toPlainString(): String = internal.toPlainString() +``` + +With: + +```kotlin + actual override fun toString(): String = internal.toPlainString() +``` + +- [ ] **Step 2: Commit** + +```bash +git add deci/src/androidMain/kotlin/org/kimplify/deci/Deci.kt +git commit -m "Android: remove toPlainString, make toString use BigDecimal.toPlainString" +``` + +--- + +### Task 4: Update JS actual — remove `toPlainString()`, fix `toString()` + +**Files:** +- Modify: `deci/src/jsMain/kotlin/org/kimplify/deci/Deci.kt:79-88` + +- [ ] **Step 1: Remove `toPlainString()` and fix `toString()` to never use scientific notation** + +`DecimalJs.toString()` can use scientific notation for extreme values. Replace both methods with a single `toString()` that uses `toFixed()` (which never uses scientific notation): + +Replace: + +```kotlin + actual override fun toString(): String { + val scale = _scale ?: return internal.toString() + if (scale <= 0) return internal.toString() + return internal.toFixed(scale) + } + + actual fun toPlainString(): String { + val scale = _scale + return if (scale != null) internal.toFixed(scale) else internal.toFixed() + } +``` + +With: + +```kotlin + actual override fun toString(): String { + val scale = _scale + return if (scale != null && scale > 0) internal.toFixed(scale) else internal.toFixed() + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add deci/src/jsMain/kotlin/org/kimplify/deci/Deci.kt +git commit -m "JS: remove toPlainString, fix toString to never use scientific notation" +``` + +--- + +### Task 5: Update wasmJs actual — remove `toPlainString()`, fix `toString()` + +**Files:** +- Modify: `deci/src/wasmJsMain/kotlin/org/kimplify/deci/Deci.kt:79-88` + +- [ ] **Step 1: Remove `toPlainString()` and fix `toString()` to never use scientific notation** + +Same fix as JS — `DecimalJs.toString()` can use scientific notation. Replace both methods: + +Replace: + +```kotlin + actual override fun toString(): String { + val scale = _scale ?: return internal.toString() + if (scale <= 0) return internal.toString() + return internal.toFixed(scale) + } + + actual fun toPlainString(): String { + val scale = _scale + return if (scale != null) internal.toFixed(scale) else internal.toFixed() + } +``` + +With: + +```kotlin + actual override fun toString(): String { + val scale = _scale + return if (scale != null && scale > 0) internal.toFixed(scale) else internal.toFixed() + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add deci/src/wasmJsMain/kotlin/org/kimplify/deci/Deci.kt +git commit -m "wasmJs: remove toPlainString, fix toString to never use scientific notation" +``` + +--- + +### Task 6: Update Apple actual — remove `toPlainString()` + +**Files:** +- Modify: `deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt:174-182` + +- [ ] **Step 1: Remove `toPlainString()`** + +Remove these lines: + +```kotlin + actual fun toPlainString(): String { + val str = internal.stringValue + val scale = _scale ?: return str + if (scale == 0) return str.split(".")[0] + val parts = str.split(".") + val intPart = parts[0] + val fracPart = if (parts.size > 1) parts[1] else "" + return "$intPart.${fracPart.padEnd(scale, '0')}" + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt +git commit -m "Apple: remove toPlainString" +``` + +--- + +### Task 7: Migrate internal call sites from `toPlainString()` to `toString()` + +**Files:** +- Modify: `deci/src/commonMain/kotlin/org/kimplify/deci/DeciSerializer.kt:30` +- Modify: `deci/src/commonMain/kotlin/org/kimplify/deci/formatting/DeciFormatting.kt:37,69,91,141,143,159,257` +- Modify: `deci/src/commonMain/kotlin/org/kimplify/deci/validation/DeciValidation.kt:66,118,237,246` +- Modify: `deci/src/commonMain/kotlin/org/kimplify/deci/math/DeciMath.kt:155` +- Modify: `deci/src/commonMain/kotlin/org/kimplify/deci/extension/DeciExtensions.kt:34` + +- [ ] **Step 1: Update DeciSerializer** + +In `DeciSerializer.kt` line 30, replace: + +```kotlin + encoder.encodeString(value.toPlainString()) +``` + +With: + +```kotlin + encoder.encodeString(value.toString()) +``` + +Also update the class KDoc — replace: + +```kotlin + * For example, `Deci("1.50")` is serialized as the JSON string `"1.50"`, not the + * JSON number `1.5`. + * + * Deserialization parses the decoded string via the [Deci] string constructor. +``` + +With: + +```kotlin + * For example, `Deci("1.50")` is serialized as the JSON string `"1.50"`, not the + * JSON number `1.5`. + * + * Serialization uses [Deci.toString], which never produces scientific notation. + * Deserialization parses the decoded string via the [Deci] string constructor. +``` + +- [ ] **Step 2: Update DeciFormatting** + +In `DeciFormatting.kt`, replace all `.toPlainString()` with `.toString()`. There are 7 occurrences: + +Line 37: `val str = this.toPlainString()` → `val str = this.toString()` +Line 69: `return "${rounded.toPlainString()}$symbol"` → `return "${rounded.toString()}$symbol"` +Line 91: `val str = this.abs().toPlainString()` → `val str = this.abs().toString()` +Line 141: `"0.00" -> this.setScale(2, RoundingMode.HALF_UP).toPlainString()` → `"0.00" -> this.setScale(2, RoundingMode.HALF_UP).toString()` +Line 143: `"0.0000" -> this.setScale(4, RoundingMode.HALF_UP).toPlainString()` → `"0.0000" -> this.setScale(4, RoundingMode.HALF_UP).toString()` +Line 159: `val parts = abs.toPlainString().split(".")` → `val parts = abs.toString().split(".")` +Line 257: `val str = this.toPlainString()` → `val str = this.toString()` + +- [ ] **Step 3: Update DeciValidation** + +In `DeciValidation.kt`, replace all `.toPlainString()` with `.toString()`. There are 4 occurrences: + +Line 66: `val str = this.toPlainString()` → `val str = this.toString()` +Line 118: `val str = this.toPlainString()` → `val str = this.toString()` +Line 237: `"Value must be at least ${min.toPlainString()}"` → `"Value must be at least ${min.toString()}"` +Line 246: `"Value must be at most ${max.toPlainString()}"` → `"Value must be at most ${max.toString()}"` + +- [ ] **Step 4: Update DeciMath** + +In `DeciMath.kt` line 155, replace: + +```kotlin + val str = absValue.toPlainString() +``` + +With: + +```kotlin + val str = absValue.toString() +``` + +- [ ] **Step 5: Update DeciExtensions** + +In `DeciExtensions.kt` line 34, replace: + +```kotlin + val str = truncated.toPlainString() +``` + +With: + +```kotlin + val str = truncated.toString() +``` + +- [ ] **Step 6: Commit** + +```bash +git add deci/src/commonMain/kotlin/org/kimplify/deci/DeciSerializer.kt \ + deci/src/commonMain/kotlin/org/kimplify/deci/formatting/DeciFormatting.kt \ + deci/src/commonMain/kotlin/org/kimplify/deci/validation/DeciValidation.kt \ + deci/src/commonMain/kotlin/org/kimplify/deci/math/DeciMath.kt \ + deci/src/commonMain/kotlin/org/kimplify/deci/extension/DeciExtensions.kt +git commit -m "Migrate all internal toPlainString calls to toString" +``` + +--- + +### Task 8: Update tests + +**Files:** +- Modify: `deci/src/commonTest/kotlin/org/kimplify/deci/DeciTest.kt:258-297` +- Modify: `deci/src/commonTest/kotlin/org/kimplify/deci/DeciPropertyTest.kt:207` +- Modify: `deci/src/commonTest/kotlin/org/kimplify/deci/DeciSerializationTest.kt:83` +- Modify: `deci/src/commonTest/kotlin/org/kimplify/deci/math/DeciMathExtendedTest.kt:268` +- Modify: `deci/src/commonTest/kotlin/org/kimplify/deci/bulk/DeciBulkOperationsTest.kt:267` + +- [ ] **Step 1: Update DeciTest.kt — fix roundtrip test** + +Replace: + +```kotlin + @Test fun `toDouble and toPlainString roundtrip for simple values`() { + listOf("0", "1.5", "-2.75").forEach { s -> + val d = Deci(s) + assertEquals(s, d.toPlainString()) + assertEquals(s.toDouble(), d.toDouble()) + } + } +``` + +With: + +```kotlin + @Test fun `toDouble and toString roundtrip for simple values`() { + listOf("0", "1.5", "-2.75").forEach { s -> + val d = Deci(s) + assertEquals(s, d.toString()) + assertEquals(s.toDouble(), d.toDouble()) + } + } +``` + +- [ ] **Step 2: Update DeciTest.kt — flip trailing zeros test** + +Replace: + +```kotlin + @Test fun `trailing zeros are stripped by constructor`() { + assertEquals("1.23", Deci("1.2300").toPlainString()) + } +``` + +With: + +```kotlin + @Test fun `trailing zeros are preserved by constructor`() { + assertEquals("1.2300", Deci("1.2300").toString()) + } +``` + +- [ ] **Step 3: Update DeciTest.kt — rename scientific notation test** + +Replace: + +```kotlin + @Test fun `toPlainString never uses scientific notation`() { + assertEquals("100000000000000000000", Deci("100000000000000000000").toPlainString()) + assertEquals("0.000000001", Deci("0.000000001").toPlainString()) + } +``` + +With: + +```kotlin + @Test fun `toString never uses scientific notation`() { + assertEquals("100000000000000000000", Deci("100000000000000000000").toString()) + assertEquals("0.000000001", Deci("0.000000001").toString()) + } +``` + +- [ ] **Step 4: Update DeciTest.kt — delete the old scientific notation test** + +Delete: + +```kotlin + @Test fun `toString may use scientific notation for extreme values`() { + val large = Deci("100000000000000000000") + assertEquals(large, Deci(large.toPlainString())) + } +``` + +- [ ] **Step 5: Update DeciPropertyTest.kt** + +Line 207, replace: + +```kotlin + val copy = Deci(a.toPlainString()) +``` + +With: + +```kotlin + val copy = Deci(a.toString()) +``` + +- [ ] **Step 6: Update DeciSerializationTest.kt** + +Line 83, replace: + +```kotlin + assertEquals(s, restored.toPlainString(), "String representation changed for $s") +``` + +With: + +```kotlin + assertEquals(s, restored.toString(), "String representation changed for $s") +``` + +- [ ] **Step 7: Update DeciMathExtendedTest.kt** + +Line 268, replace: + +```kotlin + assertTrue(result.toPlainString().startsWith("12")) +``` + +With: + +```kotlin + assertTrue(result.toString().startsWith("12")) +``` + +- [ ] **Step 8: Update DeciBulkOperationsTest.kt** + +Line 267, replace: + +```kotlin + assertTrue(result[0].toPlainString().length > 5) +``` + +With: + +```kotlin + assertTrue(result[0].toString().length > 5) +``` + +- [ ] **Step 9: Commit** + +```bash +git add deci/src/commonTest/kotlin/org/kimplify/deci/DeciTest.kt \ + deci/src/commonTest/kotlin/org/kimplify/deci/DeciPropertyTest.kt \ + deci/src/commonTest/kotlin/org/kimplify/deci/DeciSerializationTest.kt \ + deci/src/commonTest/kotlin/org/kimplify/deci/math/DeciMathExtendedTest.kt \ + deci/src/commonTest/kotlin/org/kimplify/deci/bulk/DeciBulkOperationsTest.kt +git commit -m "Update all tests: toPlainString -> toString, fix trailing zeros assertion" +``` + +--- + +### Task 9: Update sample app + +**Files:** +- Modify: `sample/composeApp/src/commonMain/kotlin/org/kimplify/screens/ValidationScreen.kt:284` + +- [ ] **Step 1: Replace `toPlainString()` call** + +Line 284, replace: + +```kotlin + original.toPlainString() == deserialized.toPlainString() +``` + +With: + +```kotlin + original.toString() == deserialized.toString() +``` + +- [ ] **Step 2: Commit** + +```bash +git add sample/composeApp/src/commonMain/kotlin/org/kimplify/screens/ValidationScreen.kt +git commit -m "Sample app: toPlainString -> toString" +``` + +--- + +### Task 10: Build, test, and update API dump + +- [ ] **Step 1: Build all targets** + +Run: `./gradlew build` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 2: Run all tests** + +Run: `./gradlew allTests` +Expected: All tests pass + +- [ ] **Step 3: Update API dump** + +Run: `./gradlew apiDump` +Expected: `.api` file updated, `toPlainString` removed from public API + +- [ ] **Step 4: Verify API dump is clean** + +Run: `./gradlew apiCheck` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 5: Commit API dump** + +```bash +git add deci/api/ +git commit -m "Update API dump: remove toPlainString from public API" +``` diff --git a/docs/superpowers/specs/2026-03-27-remove-toplainstring-design.md b/docs/superpowers/specs/2026-03-27-remove-toplainstring-design.md new file mode 100644 index 0000000..560e3b3 --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-remove-toplainstring-design.md @@ -0,0 +1,77 @@ +# Remove `toPlainString()` — Unify String Representation + +**Date:** 2026-03-27 +**Status:** Approved + +## Problem + +Deci has two string conversion methods: `toString()` (may use scientific notation on JVM/Android) and `toPlainString()` (never scientific notation). This creates confusion and cross-platform inconsistency. For a financial library, scientific notation is never appropriate, and having two methods that *should* behave identically is unnecessary API surface. + +Additionally, the JVM/Android `stripTrailingZeros()` call in the constructor has already been removed (on the `fix/remove-strip-trailing-zeros` branch), so trailing zeros are now preserved. + +## Decision + +Remove `toPlainString()` entirely. Make `toString()` the single string representation method, and guarantee it never uses scientific notation on any platform. + +## Changes + +### 1. API Surface (commonMain) + +- Remove `fun toPlainString(): String` from the `expect` declaration in `commonMain/Deci.kt` +- Update `toString()` KDoc: remove the scientific notation caveat, state it always returns plain decimal form preserving scale + +### 2. Platform Implementations + +**JVM (`jvmMain/Deci.kt`) and Android (`androidMain/Deci.kt`):** +- Remove `actual fun toPlainString()` +- Change `toString()` from `internal.toString()` to `internal.toPlainString()` (BigDecimal's method) + +**JS (`jsMain/Deci.kt`) and wasmJs (`wasmJsMain/Deci.kt`):** +- Remove `actual fun toPlainString()` +- `toString()` unchanged (already never uses scientific notation) + +**Apple (`appleMain/Deci.kt`):** +- Remove `actual fun toPlainString()` +- `toString()` unchanged (already never uses scientific notation) + +### 3. Internal Call Site Migrations (`toPlainString()` -> `toString()`) + +All these files call `Deci.toPlainString()` and must switch to `toString()`: + +- `DeciSerializer.kt:30` +- `DeciFormatting.kt:37, 69, 91, 141, 143, 159, 257` +- `DeciValidation.kt:66, 118, 237, 246` +- `DeciMath.kt:155` +- `DeciExtensions.kt:34` + +**Note:** JVM/Android files that call `BigDecimal.toPlainString()` (e.g., in `operate()`, `divide()`, `abs()`, `negate()`) are calling Java's method, not Deci's. These do not change. + +### 4. Test Updates + +- `DeciTest.kt:261` — `d.toPlainString()` -> `d.toString()` +- `DeciTest.kt:285-286` — flip assertion: `Deci("1.2300").toString()` should equal `"1.2300"` (trailing zeros preserved) +- `DeciTest.kt:289-291` — rename test to "toString never uses scientific notation", change `toPlainString()` calls to `toString()` +- `DeciTest.kt:294-297` — delete "toString may use scientific notation" test +- `DeciPropertyTest.kt:207` — `a.toPlainString()` -> `a.toString()` +- `DeciSerializationTest.kt:83` — `restored.toPlainString()` -> `restored.toString()` +- `DeciMathExtendedTest.kt:268` — `result.toPlainString()` -> `result.toString()` +- `DeciBulkOperationsTest.kt:267` — `result[0].toPlainString()` -> `result[0].toString()` + +### 5. Sample App + +- `ValidationScreen.kt:284` — two `.toPlainString()` calls -> `.toString()` + +### 6. Documentation + +- `commonMain/Deci.kt` KDoc on `toString()` — remove scientific notation language, document plain decimal form +- `DeciSerializer.kt` KDoc — remove `toPlainString` reference, say `toString()` is used +- README.MD and CLAUDE.md have no direct references to `toPlainString`, no changes needed + +### 7. API Compatibility + +- Run `./gradlew apiDump` after all changes to update the `.api` file (removing `toPlainString` from the public API) + +## Out of Scope + +- Changing how arithmetic propagates scale (e.g., what scale `Deci("1.50") + Deci("2.5")` produces) — that's a separate concern +- Adding locale-aware formatting — `toString()` always uses `.` as separator; locale formatting belongs in `DeciFormatting` diff --git a/sample/composeApp/src/commonMain/kotlin/org/kimplify/screens/ValidationScreen.kt b/sample/composeApp/src/commonMain/kotlin/org/kimplify/screens/ValidationScreen.kt index d60c391..96eff62 100644 --- a/sample/composeApp/src/commonMain/kotlin/org/kimplify/screens/ValidationScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/org/kimplify/screens/ValidationScreen.kt @@ -281,7 +281,7 @@ private fun SerializationSection() { DemoItem("Deserialized: $deserialized") DemoItem( "Trailing zeros preserved: ${ - original.toPlainString() == deserialized.toPlainString() + original.toString() == deserialized.toString() }", )