IO-as-an-interface refactor across the codebase. The big shifts: - std.io → std.Io, std.fs → std.Io.Dir/File, std.process.Child → spawn/run. - Juicy Main: pub fn main(init: std.process.Init) gives gpa, io, arena, environ_map up front. main.zig + the build/ scripts use it directly. - Threading io through everywhere that touches the outside world (HTTP, files, stderr, sleep, terminal detection). Functions taking `io` now announce side effects at the call site — the smell is the feature. - date math takes `as_of: Date`, not `today: Date`. Caller resolves `--as-of` flag vs wall-clock at the boundary; the function operates on whatever date it's given. Every "today" parameter renamed and the as_of: ?Date + today: Date pattern collapsed. - now_s: i64 (or before_s/after_s pairs) for sub-second metadata fields like snapshot captured_at, audit cadence, formatAge/fmtTimeAgo. Also pure and testable. - legitimate Timestamp.now callers (cache TTL math, FetchResult timestamps, rate limiter, per-frame TUI "now" captures) gain `// wall-clock required: ...` comments justifying the read. Test discovery: replaced the local refAllDeclsRecursive with bare std.testing.refAllDecls(@This()). Sema-pulling main.zig's top-level decls reaches every test file transitively through the import graph; no explicit _ = @import(...) lines needed. Cleanup along the way: - Dropped DataService.allocator()/io() accessor methods; renamed the fields to drop the base_ prefix. Callers use self.allocator and self.io directly. - Dropped now-vestigial io parameters from buildSnapshot, analyzePortfolio, compareSchwabSummary, compareAccounts, buildPortfolioData, divs.display, quote.display, parsePortfolioOpts, aggregateLiveStocks, renderEarningsLines, capitalGainsIndicator, aggregateDripLots, printLotRow, portfolio.display, printSnapNote. - Dropped the unused contributions.computeAttribution date-form wrapper (only computeAttributionSpec is called). - formatAge/fmtTimeAgo take (before_s, after_s) instead of io and reading the clock internally. - parseProjectionsConfig uses an internal stack-buffer FixedBufferAllocator instead of an allocator parameter. - ThreadSafeAllocator wrappers in cache concurrency tests dropped (0.16's DebugAllocator is thread-safe by default). - analyzePortfolio bug surfaced by the rename: snapshot.zig was passing wall-clock today instead of as_of, mis-valuing cash/CDs for historical backfills. 83 new unit tests added due to removal of IO, bringing coverage from 58% -> 64%
143 lines
5.6 KiB
Zig
143 lines
5.6 KiB
Zig
//! Atomic filesystem writes.
|
|
//!
|
|
//! `writeFileAtomic` writes to `<path>.tmp`, fsyncs, and renames to
|
|
//! `<path>`. Crash-safe replacement for `createFile + writeAll + close`:
|
|
//! if the process dies mid-write, the destination file is left at its
|
|
//! prior contents (or absent) rather than truncated or half-written.
|
|
//!
|
|
//! Used by the snapshot writer so a ctrl-C or kernel panic mid-run
|
|
//! can't produce a corrupt `history/<date>-portfolio.srf`.
|
|
|
|
const std = @import("std");
|
|
|
|
/// Suffix appended to the temp file during atomic writes. Exposed so
|
|
/// callers that want to sweep orphan temp files (e.g. from a previous
|
|
/// crash) know what to look for.
|
|
pub const tmp_suffix = ".tmp";
|
|
|
|
/// Write `bytes` to `path` atomically.
|
|
///
|
|
/// Strategy:
|
|
/// 1. Write to `<path>.tmp` (truncating any previous tmp file).
|
|
/// 2. `fsync` the tmp file so the data is durable before we rename.
|
|
/// 3. Rename tmp -> path (atomic on POSIX when src/dst are on the same
|
|
/// filesystem, which is guaranteed here because both are the literal
|
|
/// path plus `.tmp`).
|
|
///
|
|
/// On any error the tmp file is best-effort removed so we don't leave
|
|
/// clutter behind. The caller's `path` is unchanged unless the final
|
|
/// rename succeeds.
|
|
///
|
|
/// The allocator is used for a short-lived temp-path buffer
|
|
/// (`path.len + tmp_suffix.len` bytes) and freed before return.
|
|
pub fn writeFileAtomic(
|
|
io: std.Io,
|
|
allocator: std.mem.Allocator,
|
|
path: []const u8,
|
|
bytes: []const u8,
|
|
) !void {
|
|
const tmp_path = try std.fmt.allocPrint(allocator, "{s}{s}", .{ path, tmp_suffix });
|
|
defer allocator.free(tmp_path);
|
|
|
|
{
|
|
var tmp_file = try std.Io.Dir.cwd().createFile(io, tmp_path, .{
|
|
.truncate = true,
|
|
.exclusive = false,
|
|
});
|
|
errdefer {
|
|
tmp_file.close(io);
|
|
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch {};
|
|
}
|
|
|
|
try tmp_file.writeStreamingAll(io, bytes);
|
|
// fsync so the kernel flushes data to disk before the rename
|
|
// appears. Without this, a crash between rename() and the data
|
|
// hitting disk could leave an empty-but-present file at `path`.
|
|
try tmp_file.sync(io);
|
|
tmp_file.close(io);
|
|
}
|
|
|
|
std.Io.Dir.cwd().rename(tmp_path, std.Io.Dir.cwd(), path, io) catch |err| {
|
|
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch {};
|
|
return err;
|
|
};
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
|
|
test "writeFileAtomic creates new file" {
|
|
const io = std.testing.io;
|
|
var tmp_dir = std.testing.tmpDir(.{});
|
|
defer tmp_dir.cleanup();
|
|
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf);
|
|
const dir_path = path_buf[0..dir_len];
|
|
const file_path = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "atomic_new.txt" });
|
|
defer std.testing.allocator.free(file_path);
|
|
|
|
try writeFileAtomic(io, std.testing.allocator, file_path, "hello world\n");
|
|
|
|
const contents = try std.Io.Dir.cwd().readFileAlloc(io, file_path, std.testing.allocator, .limited(4096));
|
|
defer std.testing.allocator.free(contents);
|
|
try std.testing.expectEqualStrings("hello world\n", contents);
|
|
|
|
// Tmp file should have been consumed by rename.
|
|
const tmp_path = try std.fmt.allocPrint(std.testing.allocator, "{s}{s}", .{ file_path, tmp_suffix });
|
|
defer std.testing.allocator.free(tmp_path);
|
|
try std.testing.expectError(error.FileNotFound, std.Io.Dir.cwd().access(io, tmp_path, .{}));
|
|
|
|
// Clean up for the next test run.
|
|
std.Io.Dir.cwd().deleteFile(io, file_path) catch {};
|
|
}
|
|
|
|
test "writeFileAtomic overwrites existing file" {
|
|
const io = std.testing.io;
|
|
var tmp_dir = std.testing.tmpDir(.{});
|
|
defer tmp_dir.cleanup();
|
|
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf);
|
|
const dir_path = path_buf[0..dir_len];
|
|
const file_path = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "atomic_over.txt" });
|
|
defer std.testing.allocator.free(file_path);
|
|
|
|
// Seed with old content.
|
|
{
|
|
var f = try std.Io.Dir.cwd().createFile(io, file_path, .{});
|
|
try f.writeStreamingAll(io, "old contents");
|
|
f.close(io);
|
|
}
|
|
|
|
try writeFileAtomic(io, std.testing.allocator, file_path, "new contents");
|
|
|
|
const contents = try std.Io.Dir.cwd().readFileAlloc(io, file_path, std.testing.allocator, .limited(4096));
|
|
defer std.testing.allocator.free(contents);
|
|
try std.testing.expectEqualStrings("new contents", contents);
|
|
|
|
std.Io.Dir.cwd().deleteFile(io, file_path) catch {};
|
|
}
|
|
|
|
test "writeFileAtomic: missing parent directory surfaces FileNotFound" {
|
|
// Point at a path whose parent directory doesn't exist. The tmp dir
|
|
// itself exists (so the filesystem is fine), but the "missing"
|
|
// subdirectory does not — createFile on the .tmp file must fail
|
|
// with FileNotFound regardless of platform.
|
|
const io = std.testing.io;
|
|
var tmp_dir = std.testing.tmpDir(.{});
|
|
defer tmp_dir.cleanup();
|
|
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf);
|
|
const dir_path = path_buf[0..dir_len];
|
|
const bad_path = try std.fs.path.join(
|
|
std.testing.allocator,
|
|
&.{ dir_path, "missing", "file.txt" },
|
|
);
|
|
defer std.testing.allocator.free(bad_path);
|
|
|
|
try std.testing.expectError(
|
|
error.FileNotFound,
|
|
writeFileAtomic(io, std.testing.allocator, bad_path, "x"),
|
|
);
|
|
}
|