731 lines
28 KiB
Zig
731 lines
28 KiB
Zig
//! Journal of acknowledged observations: `acknowledgments.srf`.
|
|
//!
|
|
//! The observation engine produces findings ("Position concentration: NVDA
|
|
//! at 18.2%") on every run. The user can acknowledge a finding via the
|
|
//! review tab to record reasoning ("Holding through earnings cycle") and
|
|
//! suppress it from the active findings list. The journal is the durable
|
|
//! record of those acknowledgments.
|
|
//!
|
|
//! ## File format
|
|
//!
|
|
//! Compact-form SRF, discriminated union by `type::`. Records have a
|
|
//! positional relationship: each `type::note` record attaches to the
|
|
//! most-recently-seen `type::acknowledgment`.
|
|
//!
|
|
//! ### `type::acknowledgment`
|
|
//!
|
|
//! - `observation::` - check name, e.g. `position_concentration`.
|
|
//! - `target::` - per-check string convention. `"NVDA"` for single-symbol
|
|
//! observations; `"sector:Technology"` for sector-scoped; `"VTI,SCHD"`
|
|
//! for pair-based observations like sector dominance.
|
|
//! - `acknowledged_at::` - date the user first acked. Immutable after
|
|
//! creation.
|
|
//! - `state::` - `active` | `acknowledged` | `resolved`.
|
|
//! - `unacknowledged_at::` - info-only breadcrumb, set when the user
|
|
//! most recently un-acked. Persists across re-acks.
|
|
//! - `resolved_at::` - info-only, set when the engine auto-resolves.
|
|
//!
|
|
//! Each ack is uniquely identified by `(observation, target)`. There is
|
|
//! never more than one entry per pair - `setState` mutates in place; we
|
|
//! don't preserve transition history (git tracks that on the file).
|
|
//!
|
|
//! ### `type::note`
|
|
//!
|
|
//! Zero or more per ack. One field:
|
|
//!
|
|
//! - `line::` - single-line content. Multi-line notes are written as N
|
|
//! consecutive note records following the ack.
|
|
//!
|
|
//! Notes are positional: a note record attaches to the most-recent
|
|
//! preceding ack record. A note record before any ack is a hard parse
|
|
//! error (`error.OrphanedNote`). The visual layout in the file
|
|
//! (ack followed by indented-feeling note records) makes the
|
|
//! relationship obvious to a human reader.
|
|
//!
|
|
//! Example:
|
|
//!
|
|
//! ```
|
|
//! #!srfv1
|
|
//! type::acknowledgment,observation::position_concentration,target::NVDA,acknowledged_at::2026-06-08,state::acknowledged
|
|
//! type::note,line::Holding through earnings cycle.
|
|
//! type::note,line::Will trim by Q3 2026.
|
|
//! type::acknowledgment,observation::sector_dominance,target::VTI,SCHD,acknowledged_at::2026-06-08,state::acknowledged
|
|
//! ```
|
|
//!
|
|
//! ## Lifecycle
|
|
//!
|
|
//! - **Read:** single-pass iterator over the file. Acks push a new
|
|
//! `Entry`; notes append to the last entry. Orphan note =>
|
|
//! `error.OrphanedNote`.
|
|
//! - **Write:** `append` / `setState` mutate the in-memory `entries` and
|
|
//! atomic-rewrite the file via `atomic.writeFileAtomic`.
|
|
//! - **Concurrency:** the file is per-portfolio (sibling of
|
|
//! `portfolio.srf`); concurrent zfin invocations on the same portfolio
|
|
//! would race, but that's the existing convention for every sibling
|
|
//! file in this codebase.
|
|
|
|
const std = @import("std");
|
|
const srf = @import("srf");
|
|
const Date = @import("../Date.zig");
|
|
const atomic = @import("../atomic.zig");
|
|
|
|
const Journal = @This();
|
|
|
|
allocator: std.mem.Allocator,
|
|
entries: []Entry,
|
|
|
|
pub const State = enum {
|
|
active,
|
|
acknowledged,
|
|
resolved,
|
|
};
|
|
|
|
/// One acknowledgment record. Uniquely identified by
|
|
/// `(observation, target)`.
|
|
pub const Acknowledgment = struct {
|
|
observation: []const u8,
|
|
target: []const u8,
|
|
acknowledged_at: Date,
|
|
state: State,
|
|
unacknowledged_at: ?Date = null,
|
|
resolved_at: ?Date = null,
|
|
};
|
|
|
|
/// Wire-format note record: just a single line of text. Used only by
|
|
/// the SRF parser/formatter; the rest of the codebase sees notes as
|
|
/// `[]const u8` slices on `Entry.notes`.
|
|
const NoteRecord = struct {
|
|
line: []const u8,
|
|
};
|
|
|
|
/// Discriminated-union over the two SRF record types in the journal.
|
|
/// SRF dispatches on the `type` tag field by convention. Internal to
|
|
/// the parser/formatter.
|
|
const JournalRecord = union(enum) {
|
|
acknowledgment: Acknowledgment,
|
|
note: NoteRecord,
|
|
};
|
|
|
|
/// In-memory ack with its notes already grouped. Built by `load`;
|
|
/// consumed by callers that want "the ack and its reasoning together."
|
|
pub const Entry = struct {
|
|
ack: Acknowledgment,
|
|
/// Note fragments in the order the user entered them. Each is an
|
|
/// allocator-owned slice; freed by `Journal.deinit`.
|
|
notes: []const []const u8,
|
|
|
|
/// Concatenate the notes with newlines into a single string.
|
|
/// Allocator-owned; caller frees.
|
|
pub fn fullNote(self: Entry, allocator: std.mem.Allocator) ![]u8 {
|
|
return try std.mem.join(allocator, "\n", self.notes);
|
|
}
|
|
};
|
|
|
|
pub fn deinit(self: *Journal) void {
|
|
const a = self.allocator;
|
|
for (self.entries) |entry| {
|
|
a.free(entry.ack.observation);
|
|
a.free(entry.ack.target);
|
|
for (entry.notes) |line| a.free(line);
|
|
a.free(entry.notes);
|
|
}
|
|
a.free(self.entries);
|
|
self.* = undefined;
|
|
}
|
|
|
|
/// Find the entry matching `(observation, target)`. Returns null if
|
|
/// not found. There's only ever one entry per pair (we don't
|
|
/// preserve transition history), so no tiebreaker is needed.
|
|
pub fn findByTarget(
|
|
self: *const Journal,
|
|
observation: []const u8,
|
|
target: []const u8,
|
|
) ?*const Entry {
|
|
for (self.entries) |*e| {
|
|
if (!std.mem.eql(u8, e.ack.observation, observation)) continue;
|
|
if (!std.mem.eql(u8, e.ack.target, target)) continue;
|
|
return e;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Load and parse a journal file. Returns an empty journal when the
|
|
/// file doesn't exist (first-time use case). `path` should be an
|
|
/// absolute or cwd-relative path to `acknowledgments.srf`.
|
|
pub fn load(
|
|
allocator: std.mem.Allocator,
|
|
io: std.Io,
|
|
path: []const u8,
|
|
) !Journal {
|
|
const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch |err| switch (err) {
|
|
error.FileNotFound => {
|
|
return .{ .allocator = allocator, .entries = try allocator.alloc(Entry, 0) };
|
|
},
|
|
else => return err,
|
|
};
|
|
defer allocator.free(file_data);
|
|
return try parse(allocator, file_data);
|
|
}
|
|
|
|
/// Parse pre-read file bytes into a `Journal`. Used by `load` and by
|
|
/// tests that supply synthetic data.
|
|
///
|
|
/// Walks records in a single pass. Each `type::acknowledgment`
|
|
/// pushes a new entry with empty notes. Each `type::note` appends
|
|
/// to the most-recent entry's notes. A note before any ack
|
|
/// returns `error.OrphanedNote`.
|
|
///
|
|
/// **Strict**: any record that fails to deserialize (missing
|
|
/// required field, unknown enum variant, garbage bytes) propagates
|
|
/// the error out of `parse`. We don't silently skip - a malformed
|
|
/// record means user-visible data loss (acks suppress findings;
|
|
/// dropping an ack pops a finding back into the active list with
|
|
/// no explanation). Better to fail loud at load time so the user
|
|
/// can fix the file.
|
|
pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Journal {
|
|
// Empty input => empty journal. `srf.iterator` requires a version
|
|
// banner on the first line and errors out otherwise; short-circuit.
|
|
if (data.len == 0) {
|
|
return .{
|
|
.allocator = allocator,
|
|
.entries = try allocator.alloc(Entry, 0),
|
|
};
|
|
}
|
|
|
|
var entries = std.ArrayList(Entry).empty;
|
|
errdefer freeEntries(allocator, &entries);
|
|
|
|
// Per-entry notes lists. Lives parallel to `entries` and is
|
|
// converted to owned slices at the end. We use a separate list
|
|
// (instead of mutating each entry's `notes` field as we go)
|
|
// because `Entry.notes` is `[]const []const u8` - a const
|
|
// slice - so we can't append to it after the entry is created.
|
|
var notes_per_entry = std.ArrayList(std.ArrayList([]const u8)).empty;
|
|
errdefer {
|
|
for (notes_per_entry.items) |*notes| {
|
|
for (notes.items) |line| allocator.free(line);
|
|
notes.deinit(allocator);
|
|
}
|
|
notes_per_entry.deinit(allocator);
|
|
}
|
|
|
|
var reader = std.Io.Reader.fixed(data);
|
|
var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return error.InvalidData;
|
|
defer it.deinit();
|
|
|
|
while (try it.next()) |fields| {
|
|
const rec = try fields.to(JournalRecord, .{});
|
|
switch (rec) {
|
|
.acknowledgment => |a| {
|
|
try entries.append(allocator, .{
|
|
.ack = .{
|
|
.observation = try allocator.dupe(u8, a.observation),
|
|
.target = try allocator.dupe(u8, a.target),
|
|
.acknowledged_at = a.acknowledged_at,
|
|
.state = a.state,
|
|
.unacknowledged_at = a.unacknowledged_at,
|
|
.resolved_at = a.resolved_at,
|
|
},
|
|
.notes = &.{}, // placeholder; replaced below
|
|
});
|
|
try notes_per_entry.append(allocator, std.ArrayList([]const u8).empty);
|
|
},
|
|
.note => |n| {
|
|
if (entries.items.len == 0) return error.OrphanedNote;
|
|
const last_notes = ¬es_per_entry.items[notes_per_entry.items.len - 1];
|
|
try last_notes.append(allocator, try allocator.dupe(u8, n.line));
|
|
},
|
|
}
|
|
}
|
|
|
|
// Move notes lists into their entries' `notes` fields as owned
|
|
// slices. After this each entry owns its own notes; the
|
|
// outer `notes_per_entry` list is empty.
|
|
for (entries.items, 0..) |*entry, i| {
|
|
entry.notes = try notes_per_entry.items[i].toOwnedSlice(allocator);
|
|
}
|
|
notes_per_entry.deinit(allocator);
|
|
|
|
return .{
|
|
.allocator = allocator,
|
|
.entries = try entries.toOwnedSlice(allocator),
|
|
};
|
|
}
|
|
|
|
/// Free a partially-built entries list (for parse errdefer).
|
|
fn freeEntries(allocator: std.mem.Allocator, entries: *std.ArrayList(Entry)) void {
|
|
for (entries.items) |entry| {
|
|
allocator.free(entry.ack.observation);
|
|
allocator.free(entry.ack.target);
|
|
for (entry.notes) |line| allocator.free(line);
|
|
allocator.free(entry.notes);
|
|
}
|
|
entries.deinit(allocator);
|
|
}
|
|
|
|
/// Append a new acknowledgment with notes, then atomic-rewrite the
|
|
/// file. The journal's in-memory state is updated to reflect the new
|
|
/// entry; caller owns the journal as before.
|
|
///
|
|
/// `note_fragments` is the list of single-line strings the user
|
|
/// entered (one Enter press per fragment in the TUI's note input).
|
|
/// Pass an empty slice to record an ack with no reasoning.
|
|
pub fn append(
|
|
self: *Journal,
|
|
io: std.Io,
|
|
path: []const u8,
|
|
new_ack: Acknowledgment,
|
|
note_fragments: []const []const u8,
|
|
) !void {
|
|
const a = self.allocator;
|
|
|
|
// Build the new entry's owned strings first. errdefer cleanup
|
|
// is fiddly because we need to roll back partial allocations
|
|
// on any failure; doing it in stages keeps the pattern clear.
|
|
const owned_obs = try a.dupe(u8, new_ack.observation);
|
|
errdefer a.free(owned_obs);
|
|
const owned_target = try a.dupe(u8, new_ack.target);
|
|
errdefer a.free(owned_target);
|
|
|
|
var owned_notes = try a.alloc([]const u8, note_fragments.len);
|
|
errdefer a.free(owned_notes);
|
|
var note_idx: usize = 0;
|
|
errdefer for (owned_notes[0..note_idx]) |line| a.free(line);
|
|
for (note_fragments, 0..) |frag, i| {
|
|
owned_notes[i] = try a.dupe(u8, frag);
|
|
note_idx = i + 1;
|
|
}
|
|
|
|
// Grow the entries slice by one and append.
|
|
var new_entries = try a.alloc(Entry, self.entries.len + 1);
|
|
errdefer a.free(new_entries);
|
|
@memcpy(new_entries[0..self.entries.len], self.entries);
|
|
new_entries[self.entries.len] = .{
|
|
.ack = .{
|
|
.observation = owned_obs,
|
|
.target = owned_target,
|
|
.acknowledged_at = new_ack.acknowledged_at,
|
|
.state = new_ack.state,
|
|
.unacknowledged_at = new_ack.unacknowledged_at,
|
|
.resolved_at = new_ack.resolved_at,
|
|
},
|
|
.notes = owned_notes,
|
|
};
|
|
|
|
// Replace the slice WITHOUT freeing the old strings - they're
|
|
// shallow-copied into new_entries above. Just free the old slice.
|
|
a.free(self.entries);
|
|
self.entries = new_entries;
|
|
|
|
try writeFile(self, io, path);
|
|
}
|
|
|
|
/// Update the state of an existing acknowledgment, set the relevant
|
|
/// breadcrumb timestamp, and atomic-rewrite the file. The state
|
|
/// transition machine:
|
|
///
|
|
/// - `active -> acknowledged` - clears `unacknowledged_at`.
|
|
/// - `acknowledged -> active` - sets `unacknowledged_at = today`.
|
|
/// - `* -> resolved` - sets `resolved_at = today`.
|
|
/// - `resolved -> active` - clears `resolved_at`.
|
|
///
|
|
/// Returns `error.AckNotFound` if no entry matches `(observation,
|
|
/// target)`.
|
|
pub fn setState(
|
|
self: *Journal,
|
|
io: std.Io,
|
|
path: []const u8,
|
|
observation: []const u8,
|
|
target: []const u8,
|
|
new_state: State,
|
|
today: Date,
|
|
) !void {
|
|
var found: ?*Entry = null;
|
|
for (self.entries) |*e| {
|
|
if (!std.mem.eql(u8, e.ack.observation, observation)) continue;
|
|
if (!std.mem.eql(u8, e.ack.target, target)) continue;
|
|
found = e;
|
|
break;
|
|
}
|
|
const entry = found orelse return error.AckNotFound;
|
|
|
|
switch (new_state) {
|
|
.acknowledged => {
|
|
entry.ack.unacknowledged_at = null;
|
|
},
|
|
.active => {
|
|
if (entry.ack.state == .acknowledged) entry.ack.unacknowledged_at = today;
|
|
if (entry.ack.state == .resolved) entry.ack.resolved_at = null;
|
|
},
|
|
.resolved => {
|
|
entry.ack.resolved_at = today;
|
|
},
|
|
}
|
|
entry.ack.state = new_state;
|
|
|
|
try writeFile(self, io, path);
|
|
}
|
|
|
|
/// Atomic file write: serialize all records, then hand to
|
|
/// `atomic.writeFileAtomic` which writes to `<path>.tmp`, fsyncs,
|
|
/// and renames. Crash-safe.
|
|
fn writeFile(self: *const Journal, io: std.Io, path: []const u8) !void {
|
|
const a = self.allocator;
|
|
|
|
// Build the file contents in memory. For a journal of typical
|
|
// size (dozens to low hundreds of records) this is trivially
|
|
// small; if it grows large we revisit streaming.
|
|
var buf: std.Io.Writer.Allocating = .init(a);
|
|
defer buf.deinit();
|
|
|
|
// Flatten into a single records slice so `srf.fmt` writes the
|
|
// `#!srfv1` directive header once at the top and emits every
|
|
// record through its native formatter. Ack records are followed
|
|
// immediately by their note records, in entry-list order.
|
|
var total_records: usize = self.entries.len;
|
|
for (self.entries) |e| total_records += e.notes.len;
|
|
var records = try a.alloc(JournalRecord, total_records);
|
|
defer a.free(records);
|
|
var ri: usize = 0;
|
|
for (self.entries) |e| {
|
|
records[ri] = .{ .acknowledgment = e.ack };
|
|
ri += 1;
|
|
for (e.notes) |line| {
|
|
records[ri] = .{ .note = .{ .line = line } };
|
|
ri += 1;
|
|
}
|
|
}
|
|
|
|
try buf.writer.print("{f}", .{srf.fmt(JournalRecord, records, .{})});
|
|
|
|
try atomic.writeFileAtomic(io, a, path, buf.writer.buffered());
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
|
|
const testing = std.testing;
|
|
|
|
test "parse: empty input produces empty journal" {
|
|
var journal = try parse(testing.allocator, "");
|
|
defer journal.deinit();
|
|
try testing.expectEqual(@as(usize, 0), journal.entries.len);
|
|
}
|
|
|
|
test "parse: single ack with two notes round-trips" {
|
|
const data =
|
|
\\#!srfv1
|
|
\\type::acknowledgment,observation::position_concentration,target::NVDA,acknowledged_at::2026-06-12,state::acknowledged
|
|
\\type::note,line::Holding through earnings cycle.
|
|
\\type::note,line::Will trim by Q3 2026.
|
|
;
|
|
var journal = try parse(testing.allocator, data);
|
|
defer journal.deinit();
|
|
try testing.expectEqual(@as(usize, 1), journal.entries.len);
|
|
const entry = journal.entries[0];
|
|
try testing.expectEqualStrings("position_concentration", entry.ack.observation);
|
|
try testing.expectEqualStrings("NVDA", entry.ack.target);
|
|
try testing.expectEqual(State.acknowledged, entry.ack.state);
|
|
try testing.expectEqual(@as(usize, 2), entry.notes.len);
|
|
try testing.expectEqualStrings("Holding through earnings cycle.", entry.notes[0]);
|
|
try testing.expectEqualStrings("Will trim by Q3 2026.", entry.notes[1]);
|
|
}
|
|
|
|
test "round-trip: a note with emoji and accented text survives format + parse" {
|
|
// UTF-8 note content (emoji, accented letters) must survive the
|
|
// SRF format -> parse cycle byte-for-byte. Multi-byte sequences
|
|
// can't collide with SRF's ASCII delimiters (',' '::' newline), so
|
|
// this is safe; the test pins that guarantee for the ack-note flow.
|
|
const a = testing.allocator;
|
|
const records = [_]JournalRecord{
|
|
.{ .acknowledgment = .{
|
|
.observation = "position_concentration",
|
|
.target = "NVDA",
|
|
.acknowledged_at = Date.fromYmd(2026, 6, 12),
|
|
.state = .acknowledged,
|
|
} },
|
|
.{ .note = .{ .line = "Trim by Q3 \u{1F389} - café conviction" } },
|
|
};
|
|
var buf: std.Io.Writer.Allocating = .init(a);
|
|
defer buf.deinit();
|
|
try buf.writer.print("{f}", .{srf.fmt(JournalRecord, &records, .{})});
|
|
|
|
var journal = try parse(a, buf.writer.buffered());
|
|
defer journal.deinit();
|
|
try testing.expectEqual(@as(usize, 1), journal.entries.len);
|
|
try testing.expectEqual(@as(usize, 1), journal.entries[0].notes.len);
|
|
try testing.expectEqualStrings("Trim by Q3 \u{1F389} - café conviction", journal.entries[0].notes[0]);
|
|
}
|
|
|
|
test "parse: notes attach to the most-recent preceding ack" {
|
|
const data =
|
|
\\#!srfv1
|
|
\\type::acknowledgment,observation::p,target::A,acknowledged_at::2026-06-12,state::active
|
|
\\type::note,line::for A
|
|
\\type::acknowledgment,observation::p,target::B,acknowledged_at::2026-06-13,state::active
|
|
\\type::note,line::for B
|
|
\\type::note,line::also for B
|
|
;
|
|
var journal = try parse(testing.allocator, data);
|
|
defer journal.deinit();
|
|
try testing.expectEqual(@as(usize, 2), journal.entries.len);
|
|
try testing.expectEqual(@as(usize, 1), journal.entries[0].notes.len);
|
|
try testing.expectEqualStrings("for A", journal.entries[0].notes[0]);
|
|
try testing.expectEqual(@as(usize, 2), journal.entries[1].notes.len);
|
|
try testing.expectEqualStrings("for B", journal.entries[1].notes[0]);
|
|
try testing.expectEqualStrings("also for B", journal.entries[1].notes[1]);
|
|
}
|
|
|
|
test "parse: orphan note before any ack returns error.OrphanedNote" {
|
|
const data =
|
|
\\#!srfv1
|
|
\\type::note,line::orphaned
|
|
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active
|
|
;
|
|
try testing.expectError(error.OrphanedNote, parse(testing.allocator, data));
|
|
}
|
|
|
|
test "findByTarget: finds matching, returns null for non-match" {
|
|
const data =
|
|
\\#!srfv1
|
|
\\type::acknowledgment,observation::position_concentration,target::NVDA,acknowledged_at::2026-06-12,state::acknowledged
|
|
\\type::acknowledgment,observation::sector_concentration,target::sector:Technology,acknowledged_at::2026-06-13,state::acknowledged
|
|
;
|
|
var journal = try parse(testing.allocator, data);
|
|
defer journal.deinit();
|
|
|
|
const found = journal.findByTarget("position_concentration", "NVDA").?;
|
|
try testing.expectEqualStrings("NVDA", found.ack.target);
|
|
|
|
const not_found = journal.findByTarget("position_concentration", "AAPL");
|
|
try testing.expect(not_found == null);
|
|
}
|
|
|
|
test "Entry.fullNote: joins fragments with newlines" {
|
|
const data =
|
|
\\#!srfv1
|
|
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active
|
|
\\type::note,line::first
|
|
\\type::note,line::second
|
|
\\type::note,line::third
|
|
;
|
|
var journal = try parse(testing.allocator, data);
|
|
defer journal.deinit();
|
|
const full = try journal.entries[0].fullNote(testing.allocator);
|
|
defer testing.allocator.free(full);
|
|
try testing.expectEqualStrings("first\nsecond\nthird", full);
|
|
}
|
|
|
|
test "Entry.fullNote: empty notes returns empty string" {
|
|
const data =
|
|
\\#!srfv1
|
|
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active
|
|
;
|
|
var journal = try parse(testing.allocator, data);
|
|
defer journal.deinit();
|
|
const full = try journal.entries[0].fullNote(testing.allocator);
|
|
defer testing.allocator.free(full);
|
|
try testing.expectEqualStrings("", full);
|
|
}
|
|
|
|
test "parse: optional unacknowledged_at and resolved_at fields work when omitted" {
|
|
const data =
|
|
\\#!srfv1
|
|
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::acknowledged
|
|
;
|
|
var journal = try parse(testing.allocator, data);
|
|
defer journal.deinit();
|
|
try testing.expect(journal.entries[0].ack.unacknowledged_at == null);
|
|
try testing.expect(journal.entries[0].ack.resolved_at == null);
|
|
}
|
|
|
|
test "parse: optional unacknowledged_at and resolved_at fields work when set" {
|
|
const data =
|
|
\\#!srfv1
|
|
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active,unacknowledged_at::2026-08-01,resolved_at::2026-12-01
|
|
;
|
|
var journal = try parse(testing.allocator, data);
|
|
defer journal.deinit();
|
|
try testing.expectEqual(Date.fromYmd(2026, 8, 1).days, journal.entries[0].ack.unacknowledged_at.?.days);
|
|
try testing.expectEqual(Date.fromYmd(2026, 12, 1).days, journal.entries[0].ack.resolved_at.?.days);
|
|
}
|
|
|
|
test "parse: malformed record returns parse error" {
|
|
// A record with no `type::` discriminator should fail SRF
|
|
// deserialization (ActiveTagNotFirstField), and we propagate
|
|
// the error rather than silently skipping. The exact error
|
|
// variant is SRF's choice; we just assert that an error is
|
|
// returned.
|
|
const data =
|
|
\\#!srfv1
|
|
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::acknowledged
|
|
\\garbage_field::nope
|
|
\\type::acknowledgment,observation::q,target::U,acknowledged_at::2026-06-13,state::active
|
|
;
|
|
try testing.expectError(error.ActiveTagNotFirstField, parse(testing.allocator, data));
|
|
}
|
|
|
|
// ── I/O tests (load + append + setState round-trips) ──────────
|
|
|
|
test "load: missing file returns empty journal" {
|
|
const allocator = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
|
defer allocator.free(dir_path);
|
|
|
|
const path = try std.fmt.allocPrint(allocator, "{s}/does_not_exist.srf", .{dir_path});
|
|
defer allocator.free(path);
|
|
|
|
var journal = try load(allocator, io, path);
|
|
defer journal.deinit();
|
|
try testing.expectEqual(@as(usize, 0), journal.entries.len);
|
|
}
|
|
|
|
test "append + load round-trip: ack with two notes" {
|
|
const allocator = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
|
defer allocator.free(dir_path);
|
|
|
|
const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
|
|
defer allocator.free(path);
|
|
|
|
var journal = try load(allocator, io, path);
|
|
defer journal.deinit();
|
|
try testing.expectEqual(@as(usize, 0), journal.entries.len);
|
|
|
|
const fragments = [_][]const u8{ "first thought", "follow-up rationale" };
|
|
try journal.append(io, path, .{
|
|
.observation = "position_concentration",
|
|
.target = "NVDA",
|
|
.acknowledged_at = Date.fromYmd(2026, 6, 8),
|
|
.state = .acknowledged,
|
|
}, &fragments);
|
|
|
|
var reloaded = try load(allocator, io, path);
|
|
defer reloaded.deinit();
|
|
try testing.expectEqual(@as(usize, 1), reloaded.entries.len);
|
|
try testing.expectEqualStrings("position_concentration", reloaded.entries[0].ack.observation);
|
|
try testing.expectEqualStrings("NVDA", reloaded.entries[0].ack.target);
|
|
try testing.expectEqual(State.acknowledged, reloaded.entries[0].ack.state);
|
|
try testing.expectEqual(@as(usize, 2), reloaded.entries[0].notes.len);
|
|
try testing.expectEqualStrings("first thought", reloaded.entries[0].notes[0]);
|
|
try testing.expectEqualStrings("follow-up rationale", reloaded.entries[0].notes[1]);
|
|
}
|
|
|
|
test "append: two acks land in append-order on reload" {
|
|
const allocator = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
|
defer allocator.free(dir_path);
|
|
|
|
const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
|
|
defer allocator.free(path);
|
|
|
|
var journal = try load(allocator, io, path);
|
|
defer journal.deinit();
|
|
|
|
try journal.append(io, path, .{
|
|
.observation = "k",
|
|
.target = "B",
|
|
.acknowledged_at = Date.fromYmd(2026, 6, 8),
|
|
.state = .acknowledged,
|
|
}, &.{});
|
|
try journal.append(io, path, .{
|
|
.observation = "k",
|
|
.target = "A",
|
|
.acknowledged_at = Date.fromYmd(2026, 6, 8),
|
|
.state = .acknowledged,
|
|
}, &.{});
|
|
|
|
var reloaded = try load(allocator, io, path);
|
|
defer reloaded.deinit();
|
|
try testing.expectEqual(@as(usize, 2), reloaded.entries.len);
|
|
try testing.expectEqualStrings("B", reloaded.entries[0].ack.target);
|
|
try testing.expectEqualStrings("A", reloaded.entries[1].ack.target);
|
|
}
|
|
|
|
test "setState: acknowledged -> active sets unacknowledged_at" {
|
|
const allocator = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
|
defer allocator.free(dir_path);
|
|
|
|
const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
|
|
defer allocator.free(path);
|
|
|
|
var journal = try load(allocator, io, path);
|
|
defer journal.deinit();
|
|
|
|
try journal.append(io, path, .{
|
|
.observation = "k",
|
|
.target = "X",
|
|
.acknowledged_at = Date.fromYmd(2026, 6, 8),
|
|
.state = .acknowledged,
|
|
}, &.{});
|
|
|
|
try journal.setState(io, path, "k", "X", .active, Date.fromYmd(2026, 6, 9));
|
|
|
|
var reloaded = try load(allocator, io, path);
|
|
defer reloaded.deinit();
|
|
try testing.expectEqual(State.active, reloaded.entries[0].ack.state);
|
|
try testing.expect(reloaded.entries[0].ack.unacknowledged_at != null);
|
|
try testing.expect(reloaded.entries[0].ack.unacknowledged_at.?.eql(Date.fromYmd(2026, 6, 9)));
|
|
}
|
|
|
|
test "setState: missing target returns AckNotFound" {
|
|
const allocator = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
|
defer allocator.free(dir_path);
|
|
|
|
const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
|
|
defer allocator.free(path);
|
|
|
|
var journal = try load(allocator, io, path);
|
|
defer journal.deinit();
|
|
|
|
try testing.expectError(
|
|
error.AckNotFound,
|
|
journal.setState(io, path, "k", "missing", .resolved, Date.fromYmd(2026, 6, 8)),
|
|
);
|
|
}
|
|
|
|
test "setState: -> resolved sets resolved_at" {
|
|
const allocator = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
|
defer allocator.free(dir_path);
|
|
|
|
const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
|
|
defer allocator.free(path);
|
|
|
|
var journal = try load(allocator, io, path);
|
|
defer journal.deinit();
|
|
|
|
try journal.append(io, path, .{
|
|
.observation = "k",
|
|
.target = "X",
|
|
.acknowledged_at = Date.fromYmd(2026, 6, 8),
|
|
.state = .acknowledged,
|
|
}, &.{});
|
|
|
|
try journal.setState(io, path, "k", "X", .resolved, Date.fromYmd(2026, 6, 10));
|
|
|
|
var reloaded = try load(allocator, io, path);
|
|
defer reloaded.deinit();
|
|
try testing.expectEqual(State.resolved, reloaded.entries[0].ack.state);
|
|
try testing.expect(reloaded.entries[0].ack.resolved_at != null);
|
|
try testing.expect(reloaded.entries[0].ack.resolved_at.?.eql(Date.fromYmd(2026, 6, 10)));
|
|
}
|