445 lines
16 KiB
Zig
445 lines
16 KiB
Zig
//! Money: a thin wrapper around `f64` representing a dollar amount,
|
|
//! with format methods that integrate with `std.fmt`'s `{f}`
|
|
//! specifier.
|
|
//!
|
|
//! Storage stays `f64` (matches existing zfin convention; no decimal
|
|
//! arithmetic). The wrapper exists for two reasons:
|
|
//!
|
|
//! 1. **Format-method dispatch.** Zig 0.15+'s format-method
|
|
//! protocol (`pub fn format(self, w: *Writer) !void`) lets
|
|
//! `try writer.print("{f}", .{m})` render a Money value
|
|
//! directly with no buffer ceremony. Distinct rendering
|
|
//! modes — whole-dollar, trim-trailing-`.00`, signed — are
|
|
//! exposed as wrapper-returning methods on `Money`:
|
|
//! `m.whole()`, `m.trim()`, `m.signed()`. Each returns a
|
|
//! small wrapper struct whose own `format` method emits the
|
|
//! variant.
|
|
//!
|
|
//! 2. **Single home for money formatting.** Replaces the
|
|
//! previous scatter across `src/format.zig` (fmtMoneyAbs,
|
|
//! fmtMoneyAbsWhole, fmtMoneyAbsTrim) and
|
|
//! `src/views/history.zig` (fmtSignedMoneyBuf).
|
|
//!
|
|
//! ## Usage
|
|
//!
|
|
//! ```zig
|
|
//! const Money = @import("Money.zig");
|
|
//!
|
|
//! // Default {f}: "$1,234.56" with always-present cents.
|
|
//! try writer.print("{f}\n", .{Money.from(1234.56)});
|
|
//!
|
|
//! // Whole-dollar: "$1,235" rounded.
|
|
//! try writer.print("{f}\n", .{Money.from(1234.56).whole()});
|
|
//!
|
|
//! // Elide trailing .00: "$1,234" if whole, "$1,234.56" if fractional.
|
|
//! try writer.print("{f}\n", .{Money.from(1234.00).trim()});
|
|
//!
|
|
//! // Signed: "+$1,234.56" / "-$1,234.56" / "$0.00".
|
|
//! try writer.print("{f}\n", .{Money.from(-1234.56).signed()});
|
|
//! ```
|
|
//!
|
|
//! ## On `f64` precision
|
|
//!
|
|
//! For zfin's use case (personal-finance, totals up to ~$100M,
|
|
//! display rounded to cents), f64 has plenty of headroom. f64
|
|
//! starts losing cent precision around $10^15 ($1 quadrillion).
|
|
//! See AGENTS.md "Time and money helpers" for the full discussion.
|
|
//! When this stops being true, change the underlying storage in
|
|
//! ONE place (this file) and the format methods get redone — no
|
|
//! call-site churn.
|
|
|
|
const std = @import("std");
|
|
|
|
amount: f64,
|
|
|
|
const Money = @This();
|
|
|
|
/// Construct a Money from a raw f64 dollar amount.
|
|
pub fn from(amount: f64) Money {
|
|
return .{ .amount = amount };
|
|
}
|
|
|
|
/// Zero dollars. Convenience for explicit-init sites.
|
|
pub const zero: Money = .{ .amount = 0 };
|
|
|
|
// ── Standard format method (called via `{f}`) ──────────────────
|
|
|
|
/// Default format: `$1,234.56`. Always positive (sign is the
|
|
/// caller's job). 2 decimal places.
|
|
pub fn format(self: Money, w: *std.Io.Writer) std.Io.Writer.Error!void {
|
|
try writeAbsCents(w, self.amount);
|
|
}
|
|
|
|
// ── Variant wrappers ───────────────────────────────────────────
|
|
|
|
/// Wrapper whose `{f}` emits whole dollars rounded: `$1,234`.
|
|
/// Distinct from `trim()` — `whole()` rounds 1234.56 to `$1,235`.
|
|
pub fn whole(self: Money) Whole {
|
|
return .{ .amount = self.amount };
|
|
}
|
|
|
|
/// Wrapper whose `{f}` elides a trailing `.00` but preserves
|
|
/// non-zero cents: `$1,234` for 1234.00, `$1,234.56` for 1234.56.
|
|
/// Use for cosmetic displays where cents-when-present are still
|
|
/// informative.
|
|
pub fn trim(self: Money) Trim {
|
|
return .{ .amount = self.amount };
|
|
}
|
|
|
|
/// Wrapper whose `{f}` emits a leading sign: `+$1,234.56`,
|
|
/// `-$1,234.56`, or `$0.00` for zero.
|
|
pub fn signed(self: Money) Signed {
|
|
return .{ .amount = self.amount };
|
|
}
|
|
|
|
/// Pad the default-formatted Money to `width` columns, right-aligned.
|
|
/// For column-aligned tabular output: `try out.print("{f}", .{Money.from(x).padRight(10)})`.
|
|
///
|
|
/// Composes with the variant wrappers — call `.padRight(N)` on a
|
|
/// `Whole`, `Trim`, or `Signed` directly via the same generic
|
|
/// helper. See `Padded` below.
|
|
pub fn padRight(self: Money, width: usize) Padded(Money) {
|
|
return .{ .inner = self, .width = width, .alignment = .right };
|
|
}
|
|
|
|
/// Pad the default-formatted Money to `width` columns, left-aligned.
|
|
pub fn padLeft(self: Money, width: usize) Padded(Money) {
|
|
return .{ .inner = self, .width = width, .alignment = .left };
|
|
}
|
|
|
|
pub const Whole = struct {
|
|
amount: f64,
|
|
|
|
pub fn format(self: Whole, w: *std.Io.Writer) std.Io.Writer.Error!void {
|
|
try writeAbsWhole(w, self.amount);
|
|
}
|
|
|
|
pub fn padRight(self: Whole, width: usize) Padded(Whole) {
|
|
return .{ .inner = self, .width = width, .alignment = .right };
|
|
}
|
|
pub fn padLeft(self: Whole, width: usize) Padded(Whole) {
|
|
return .{ .inner = self, .width = width, .alignment = .left };
|
|
}
|
|
};
|
|
|
|
pub const Trim = struct {
|
|
amount: f64,
|
|
|
|
pub fn format(self: Trim, w: *std.Io.Writer) std.Io.Writer.Error!void {
|
|
// Render via a fixed buffer, then trim the .00 if present.
|
|
// The buffer is sized for the worst case (negative billions
|
|
// with cents, ~$1,234,567,890.12 = 14 chars); 24 bytes is
|
|
// generous.
|
|
var tmp: [24]u8 = undefined;
|
|
var fixed = std.Io.Writer.fixed(&tmp);
|
|
writeAbsCents(&fixed, self.amount) catch unreachable;
|
|
const written = fixed.buffered();
|
|
const out = if (std.mem.endsWith(u8, written, ".00"))
|
|
written[0 .. written.len - 3]
|
|
else
|
|
written;
|
|
try w.writeAll(out);
|
|
}
|
|
|
|
pub fn padRight(self: Trim, width: usize) Padded(Trim) {
|
|
return .{ .inner = self, .width = width, .alignment = .right };
|
|
}
|
|
pub fn padLeft(self: Trim, width: usize) Padded(Trim) {
|
|
return .{ .inner = self, .width = width, .alignment = .left };
|
|
}
|
|
};
|
|
|
|
pub const Signed = struct {
|
|
amount: f64,
|
|
|
|
pub fn format(self: Signed, w: *std.Io.Writer) std.Io.Writer.Error!void {
|
|
if (self.amount > 0) try w.writeByte('+');
|
|
if (self.amount < 0) try w.writeByte('-');
|
|
try writeAbsCents(w, self.amount);
|
|
}
|
|
|
|
pub fn padRight(self: Signed, width: usize) Padded(Signed) {
|
|
return .{ .inner = self, .width = width, .alignment = .right };
|
|
}
|
|
pub fn padLeft(self: Signed, width: usize) Padded(Signed) {
|
|
return .{ .inner = self, .width = width, .alignment = .left };
|
|
}
|
|
};
|
|
|
|
/// Generic alignment wrapper. Wraps any `T` whose `format` method
|
|
/// emits some text, then pads the result to `width` columns with
|
|
/// spaces on the requested side. Composes with `Money` and all of
|
|
/// its variant wrappers — and with anything else that exposes a
|
|
/// `format(self, *Writer) !void`.
|
|
///
|
|
/// Comptime instantiation per inner type is essentially free: the
|
|
/// compiler emits one body per `Padded(T)` actually used, with the
|
|
/// inner `format` call inlined.
|
|
pub fn Padded(comptime T: type) type {
|
|
return struct {
|
|
inner: T,
|
|
width: usize,
|
|
alignment: enum { left, right },
|
|
|
|
const Self = @This();
|
|
|
|
pub fn format(self: Self, w: *std.Io.Writer) std.Io.Writer.Error!void {
|
|
// Render the inner value into a stack buffer first so
|
|
// we can measure its length and pad. 64 bytes is more
|
|
// than enough for any realistic Money render
|
|
// (~14 chars worst case for $X,XXX,XXX,XXX.XX with sign).
|
|
var tmp: [64]u8 = undefined;
|
|
var fixed = std.Io.Writer.fixed(&tmp);
|
|
self.inner.format(&fixed) catch unreachable;
|
|
const text = fixed.buffered();
|
|
|
|
const pad = if (text.len >= self.width) 0 else self.width - text.len;
|
|
switch (self.alignment) {
|
|
.right => {
|
|
try w.splatByteAll(' ', pad);
|
|
try w.writeAll(text);
|
|
},
|
|
.left => {
|
|
try w.writeAll(text);
|
|
try w.splatByteAll(' ', pad);
|
|
},
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// ── Internal: byte-emission shared by all variants ─────────────
|
|
|
|
/// Write the absolute value of `amount` as `$X,XXX.XX` directly to
|
|
/// `w`. Same algorithm as the original `fmt.fmtMoneyAbs` — produces
|
|
/// byte-identical output, just streams to a writer instead of
|
|
/// returning a slice.
|
|
fn writeAbsCents(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void {
|
|
const cents = @as(i64, @intFromFloat(@round(amount * 100.0)));
|
|
const abs_cents = if (cents < 0) @as(u64, @intCast(-cents)) else @as(u64, @intCast(cents));
|
|
const dollars = abs_cents / 100;
|
|
const rem = abs_cents % 100;
|
|
|
|
// Build into a stack buffer, then write all at once. The
|
|
// buffer is sized for the worst case (24-char negative
|
|
// billions); using a fixed buffer keeps the per-digit
|
|
// append-and-shift logic identical to the original.
|
|
var tmp: [24]u8 = undefined;
|
|
var pos: usize = tmp.len;
|
|
|
|
// Cents
|
|
pos -= 1;
|
|
tmp[pos] = '0' + @as(u8, @intCast(rem % 10));
|
|
pos -= 1;
|
|
tmp[pos] = '0' + @as(u8, @intCast(rem / 10));
|
|
pos -= 1;
|
|
tmp[pos] = '.';
|
|
|
|
// Dollars with commas
|
|
var d = dollars;
|
|
var digit_count: usize = 0;
|
|
if (d == 0) {
|
|
pos -= 1;
|
|
tmp[pos] = '0';
|
|
} else {
|
|
while (d > 0) {
|
|
if (digit_count > 0 and digit_count % 3 == 0) {
|
|
pos -= 1;
|
|
tmp[pos] = ',';
|
|
}
|
|
pos -= 1;
|
|
tmp[pos] = '0' + @as(u8, @intCast(d % 10));
|
|
d /= 10;
|
|
digit_count += 1;
|
|
}
|
|
}
|
|
pos -= 1;
|
|
tmp[pos] = '$';
|
|
|
|
try w.writeAll(tmp[pos..]);
|
|
}
|
|
|
|
/// Write the absolute value of `amount` as `$X,XXX` (rounded to
|
|
/// whole dollars, no decimal portion) directly to `w`.
|
|
fn writeAbsWhole(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void {
|
|
const dollars_signed = @as(i64, @intFromFloat(@round(amount)));
|
|
const dollars: u64 = if (dollars_signed < 0) @intCast(-dollars_signed) else @intCast(dollars_signed);
|
|
|
|
var tmp: [24]u8 = undefined;
|
|
var pos: usize = tmp.len;
|
|
|
|
var d = dollars;
|
|
var digit_count: usize = 0;
|
|
if (d == 0) {
|
|
pos -= 1;
|
|
tmp[pos] = '0';
|
|
} else {
|
|
while (d > 0) {
|
|
if (digit_count > 0 and digit_count % 3 == 0) {
|
|
pos -= 1;
|
|
tmp[pos] = ',';
|
|
}
|
|
pos -= 1;
|
|
tmp[pos] = '0' + @as(u8, @intCast(d % 10));
|
|
d /= 10;
|
|
digit_count += 1;
|
|
}
|
|
}
|
|
pos -= 1;
|
|
tmp[pos] = '$';
|
|
|
|
try w.writeAll(tmp[pos..]);
|
|
}
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────
|
|
|
|
const testing = std.testing;
|
|
|
|
/// Render a value via the `{f}` format spec into an arena-allocated
|
|
/// string. Used by tests to verify format-method output matches the
|
|
/// expected string exactly.
|
|
fn renderToString(allocator: std.mem.Allocator, value: anytype) ![]u8 {
|
|
var aw: std.Io.Writer.Allocating = .init(allocator);
|
|
defer aw.deinit();
|
|
try aw.writer.print("{f}", .{value});
|
|
return aw.toOwnedSlice();
|
|
}
|
|
|
|
test "Money default {f}: $X,XXX.XX with cents" {
|
|
const allocator = testing.allocator;
|
|
|
|
const cases = [_]struct { amount: f64, expected: []const u8 }{
|
|
.{ .amount = 0, .expected = "$0.00" },
|
|
.{ .amount = 1, .expected = "$1.00" },
|
|
.{ .amount = 1.23, .expected = "$1.23" },
|
|
.{ .amount = 1234.56, .expected = "$1,234.56" },
|
|
.{ .amount = 1_234_567.89, .expected = "$1,234,567.89" },
|
|
// Negative amounts emit absolute value — sign is caller's job
|
|
// unless they use `.signed()`.
|
|
.{ .amount = -1234.56, .expected = "$1,234.56" },
|
|
};
|
|
|
|
for (cases) |c| {
|
|
const s = try renderToString(allocator, Money.from(c.amount));
|
|
defer allocator.free(s);
|
|
try testing.expectEqualStrings(c.expected, s);
|
|
}
|
|
}
|
|
|
|
test "Money.whole(): rounds to dollars, no decimals" {
|
|
const allocator = testing.allocator;
|
|
|
|
const cases = [_]struct { amount: f64, expected: []const u8 }{
|
|
.{ .amount = 0, .expected = "$0" },
|
|
.{ .amount = 1, .expected = "$1" },
|
|
.{ .amount = 1234, .expected = "$1,234" },
|
|
.{ .amount = 1_234_567, .expected = "$1,234,567" },
|
|
// @round is half-away-from-zero.
|
|
.{ .amount = 1.5, .expected = "$2" },
|
|
.{ .amount = 1.49, .expected = "$1" },
|
|
.{ .amount = 0.4, .expected = "$0" },
|
|
// Negative magnitude rounds: |-1234.56| = 1234.56 → 1235.
|
|
.{ .amount = -1234.56, .expected = "$1,235" },
|
|
};
|
|
|
|
for (cases) |c| {
|
|
const s = try renderToString(allocator, Money.from(c.amount).whole());
|
|
defer allocator.free(s);
|
|
try testing.expectEqualStrings(c.expected, s);
|
|
}
|
|
}
|
|
|
|
test "Money.trim(): elide .00 only" {
|
|
const allocator = testing.allocator;
|
|
|
|
const cases = [_]struct { amount: f64, expected: []const u8 }{
|
|
// Whole values elide .00.
|
|
.{ .amount = 0, .expected = "$0" },
|
|
.{ .amount = 1234, .expected = "$1,234" },
|
|
.{ .amount = 1_234_567, .expected = "$1,234,567" },
|
|
// Non-zero cents preserved.
|
|
.{ .amount = 1234.56, .expected = "$1,234.56" },
|
|
.{ .amount = 0.01, .expected = "$0.01" },
|
|
.{ .amount = 0.5, .expected = "$0.50" },
|
|
// Sub-cent rounds-to-zero-cents elides too.
|
|
.{ .amount = 10.001, .expected = "$10" },
|
|
// Negative magnitude.
|
|
.{ .amount = -1234, .expected = "$1,234" },
|
|
.{ .amount = -1234.56, .expected = "$1,234.56" },
|
|
};
|
|
|
|
for (cases) |c| {
|
|
const s = try renderToString(allocator, Money.from(c.amount).trim());
|
|
defer allocator.free(s);
|
|
try testing.expectEqualStrings(c.expected, s);
|
|
}
|
|
}
|
|
|
|
test "Money.signed(): leading sign for non-zero, none for zero" {
|
|
const allocator = testing.allocator;
|
|
|
|
const cases = [_]struct { amount: f64, expected: []const u8 }{
|
|
.{ .amount = 1234.56, .expected = "+$1,234.56" },
|
|
.{ .amount = -1234.56, .expected = "-$1,234.56" },
|
|
// Zero gets no sign — purely cosmetic, matches the prior
|
|
// `fmtSignedMoneyBuf` convention.
|
|
.{ .amount = 0, .expected = "$0.00" },
|
|
};
|
|
|
|
for (cases) |c| {
|
|
const s = try renderToString(allocator, Money.from(c.amount).signed());
|
|
defer allocator.free(s);
|
|
try testing.expectEqualStrings(c.expected, s);
|
|
}
|
|
}
|
|
|
|
test "Money.zero is zero" {
|
|
try testing.expectEqual(@as(f64, 0), Money.zero.amount);
|
|
}
|
|
|
|
test "Money.from preserves the underlying f64" {
|
|
try testing.expectEqual(@as(f64, 1234.56), Money.from(1234.56).amount);
|
|
try testing.expectEqual(@as(f64, -42), Money.from(-42).amount);
|
|
}
|
|
|
|
test "Money.padRight pads with leading spaces" {
|
|
const allocator = testing.allocator;
|
|
// "$1,234.56" is 9 chars; pad to 12 → 3 leading spaces.
|
|
const s = try renderToString(allocator, Money.from(1234.56).padRight(12));
|
|
defer allocator.free(s);
|
|
try testing.expectEqualStrings(" $1,234.56", s);
|
|
}
|
|
|
|
test "Money.padLeft pads with trailing spaces" {
|
|
const allocator = testing.allocator;
|
|
const s = try renderToString(allocator, Money.from(1234.56).padLeft(12));
|
|
defer allocator.free(s);
|
|
try testing.expectEqualStrings("$1,234.56 ", s);
|
|
}
|
|
|
|
test "padRight: text wider than width emits unchanged (no truncation)" {
|
|
const allocator = testing.allocator;
|
|
// "$1,234,567.89" is 13 chars; padding to 5 → unchanged.
|
|
const s = try renderToString(allocator, Money.from(1_234_567.89).padRight(5));
|
|
defer allocator.free(s);
|
|
try testing.expectEqualStrings("$1,234,567.89", s);
|
|
}
|
|
|
|
test "padRight composes with whole/trim/signed variants" {
|
|
const allocator = testing.allocator;
|
|
|
|
// Whole + padRight: "$1,234" is 6 chars; pad to 10 → 4 spaces.
|
|
const s_whole = try renderToString(allocator, Money.from(1234).whole().padRight(10));
|
|
defer allocator.free(s_whole);
|
|
try testing.expectEqualStrings(" $1,234", s_whole);
|
|
|
|
// Trim + padRight: "$1,234.56" is 9 chars; pad to 12 → 3 spaces.
|
|
const s_trim = try renderToString(allocator, Money.from(1234.56).trim().padRight(12));
|
|
defer allocator.free(s_trim);
|
|
try testing.expectEqualStrings(" $1,234.56", s_trim);
|
|
|
|
// Signed + padRight: "+$1,234.56" is 10 chars; pad to 14 → 4 spaces.
|
|
const s_signed = try renderToString(allocator, Money.from(1234.56).signed().padRight(14));
|
|
defer allocator.free(s_signed);
|
|
try testing.expectEqualStrings(" +$1,234.56", s_signed);
|
|
}
|