feat(persist): owned-file manifest seam — capture records created files (B-cap)

Pure OwnedFileManifest (relative paths, dedup, JSON round-trip) persisted
under sibling owned_files ext-state key; both capture commit paths record;
joins the R-B undo-reload set. Phase R prune consumes it later.
This commit is contained in:
2026-07-26 14:50:18 -04:00
parent 0f27fc2e55
commit 63f35fa58d
7 changed files with 674 additions and 7 deletions
+18 -1
View File
@@ -157,6 +157,18 @@ add_library(bank_book STATIC src/bank_book.cpp)
target_include_directories(bank_book PUBLIC src)
target_link_libraries(bank_book PUBLIC bank_model)
# ---------------------------------------------------------------------------
# 2g'') Pure owned_manifest library — NO REAPER, NO SWELL. The owned-file manifest
# seam (Phase B B-cap): the set of project-relative files the capture path
# itself created, so Phase R prune can tell the bank system's own orphans from
# hand-dropped files. Deliberately DECOUPLED from bank_book — it tracks files
# CREATED, not index membership (sample-remove is not manifest-remove). Small
# pure type + JSON round-trip; mirror of wav_trim / tab_strip. B-cap writes +
# persists it; Phase R (R1/R2) consumes it — no prune logic here.
# ---------------------------------------------------------------------------
add_library(owned_manifest STATIC src/owned_manifest.cpp)
target_include_directories(owned_manifest PUBLIC src)
# ---------------------------------------------------------------------------
# 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record
# logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values,
@@ -251,6 +263,10 @@ add_executable(wav_trim_tests tests/test_wav_trim.cpp)
target_link_libraries(wav_trim_tests PRIVATE wav_trim)
add_test(NAME wav_trim_tests COMMAND wav_trim_tests)
add_executable(owned_manifest_tests tests/test_owned_manifest.cpp)
target_link_libraries(owned_manifest_tests PRIVATE owned_manifest)
add_test(NAME owned_manifest_tests COMMAND owned_manifest_tests)
# ---------------------------------------------------------------------------
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
# ---------------------------------------------------------------------------
@@ -286,8 +302,9 @@ add_library(reaper_reasampler MODULE
src/item_read.cpp
src/actions.cpp
src/bank_book.cpp
src/owned_manifest.cpp
)
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim)
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim owned_manifest)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
+15 -5
View File
@@ -145,7 +145,12 @@ static void CommitRealtimeResult(const reasampler::CaptureResult& res)
return;
}
reasampler::AddResult added = g_session.bank().add(res.sample);
g_session.saveToActiveProject(); // persist + MarkProjectDirty (travels with .rpp)
// B-cap: record the file the capture created in the owned-file manifest, at the same
// point the Sample is added and before the same persist. Recorded regardless of the
// index AddResult — even a hash-collapse still WROTE a file the tool owns, and the
// manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index).
g_session.owned().add(res.sample.relativePath);
g_session.saveToActiveProject(); // persist book + manifest + MarkProjectDirty (travels with .rpp)
std::string log = "ReaSampler: " + res.message + "\n";
log += " bank size now " + std::to_string(g_session.bank().size()) +
@@ -615,10 +620,15 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
// Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2).
reasampler::AddResult added = g_session.bank().add(res.sample);
// Persist the updated book into the active project's ext state (the `banks` key)
// so the capture survives Save / close+reopen (M4) and travels with the .rpp.
// saveToActiveProject also clears the retired legacy key and calls MarkProjectDirty.
// Non-destructive: writes only our own ext-state keys.
// B-cap: record the created file in the owned-file manifest, at the same point the
// Sample is added and before the same persist. Recorded regardless of the index
// AddResult — even a hash-collapse still WROTE a file the tool owns, and the manifest
// dedups a repeat path itself (Phase R prune reconciles manifest vs index later).
g_session.owned().add(res.sample.relativePath);
// Persist the updated book AND manifest into the active project's ext state (the
// `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and
// travels with the .rpp. saveToActiveProject also clears the retired legacy key and
// calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys.
g_session.saveToActiveProject();
std::string log = "ReaSampler: " + res.message + "\n";
+315
View File
@@ -0,0 +1,315 @@
#include "owned_manifest.h"
#include <cctype>
#include <cstdio>
// owned_manifest implementation.
//
// JSON is hand-rolled and self-contained (project convention: the pure core is
// dependency-free — no third-party JSON lib, mirror of bank_model / bank_book /
// tail_control). The shape is a single object with one string array:
//
// {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]}
//
// so a compact writer + a focused string-array parser is all it needs — far smaller
// than bank_model's full recursive-descent parser, because there is exactly one key
// and one value kind.
namespace reasampler {
// ---------------------------------------------------------------------------
// path invariant (mirror of bank_model's isAbsolutePath)
// ---------------------------------------------------------------------------
namespace {
// Any leading '/' or '\' (POSIX root / UNC), or a leading <alpha>: (Windows drive,
// incl. drive-relative "C:foo") is absolute. Same rejection bank_model applies to
// Sample.relativePath — the manifest holds the SAME kind of path, so the invariant
// must match exactly (a path the index accepts must be recordable, and vice versa).
bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true;
if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
return true;
return false;
}
} // namespace
// ---------------------------------------------------------------------------
// mutation / query
// ---------------------------------------------------------------------------
ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) {
if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath;
if (isAbsolutePath(relativePath)) return ManifestAddResult::RejectedAbsolutePath;
if (contains(relativePath)) return ManifestAddResult::AlreadyPresent;
paths_.push_back(relativePath);
return ManifestAddResult::Added;
}
bool OwnedFileManifest::contains(const std::string& relativePath) const {
for (const auto& p : paths_)
if (p == relativePath) return true;
return false;
}
// ---------------------------------------------------------------------------
// JSON writer
// ---------------------------------------------------------------------------
namespace {
void writeEscaped(std::string& out, const std::string& s) {
out += '"';
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x",
static_cast<unsigned char>(c));
out += buf;
} else {
out += c;
}
}
}
out += '"';
}
} // namespace
std::string OwnedFileManifest::serialize() const {
std::string out = "{\"owned\":[";
for (std::size_t i = 0; i < paths_.size(); ++i) {
if (i) out += ',';
writeEscaped(out, paths_[i]);
}
out += "]}";
return out;
}
// ---------------------------------------------------------------------------
// JSON parser (string-array only)
// ---------------------------------------------------------------------------
namespace {
class Parser {
public:
explicit Parser(const std::string& s) : s_(s) {}
// Parse the manifest object into `out`. Tolerates unknown keys (forward-compat)
// and requires the "owned" value to be an array of strings.
bool parseManifest(OwnedFileManifest& out);
private:
const std::string& s_;
std::size_t pos_ = 0;
bool eof() const { return pos_ >= s_.size(); }
void skipWs() {
while (!eof()) {
char c = s_[pos_];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_;
else break;
}
}
bool consume(char c) {
skipWs();
if (eof() || s_[pos_] != c) return false;
++pos_;
return true;
}
bool parseString(std::string& out);
bool parseStringArray(std::vector<std::string>& out);
bool skipValue(); // for forward-compat unknown keys
};
// Parses a JSON string literal (with the escapes our writer emits, plus \uXXXX for
// control chars). Positioned before the opening quote (skips leading whitespace).
bool Parser::parseString(std::string& out) {
skipWs();
if (eof() || s_[pos_] != '"') return false;
++pos_;
out.clear();
while (!eof()) {
char c = s_[pos_++];
if (c == '"') return true;
if (c == '\\') {
if (eof()) return false;
char e = s_[pos_++];
switch (e) {
case '"': out += '"'; break;
case '\\': out += '\\'; break;
case '/': out += '/'; break;
case 'b': out += '\b'; break;
case 'f': out += '\f'; break;
case 'n': out += '\n'; break;
case 'r': out += '\r'; break;
case 't': out += '\t'; break;
case 'u': {
auto readHex4 = [&](unsigned int& cp) -> bool {
if (pos_ + 4 > s_.size()) return false;
cp = 0;
for (int i = 0; i < 4; ++i) {
char h = s_[pos_++];
cp <<= 4;
if (h >= '0' && h <= '9') cp |= static_cast<unsigned>(h - '0');
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(h - 'A' + 10);
else return false;
}
return true;
};
unsigned int hi = 0;
if (!readHex4(hi)) return false;
unsigned int codePoint = hi;
if (hi >= 0xD800 && hi <= 0xDBFF) {
if (pos_ + 6 > s_.size()) return false;
if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false;
pos_ += 2;
unsigned int lo = 0;
if (!readHex4(lo)) return false;
if (lo < 0xDC00 || lo > 0xDFFF) return false;
codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
} else if (hi >= 0xDC00 && hi <= 0xDFFF) {
return false; // unpaired low surrogate
}
if (codePoint <= 0x7F) {
out += static_cast<char>(codePoint);
} else if (codePoint <= 0x7FF) {
out += static_cast<char>(0xC0 | (codePoint >> 6));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else if (codePoint <= 0xFFFF) {
out += static_cast<char>(0xE0 | (codePoint >> 12));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else {
out += static_cast<char>(0xF0 | (codePoint >> 18));
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
}
break;
}
default: return false;
}
} else {
out += c;
}
}
return false; // unterminated string
}
bool Parser::parseStringArray(std::vector<std::string>& out) {
if (!consume('[')) return false;
skipWs();
if (consume(']')) return true; // empty array
for (;;) {
std::string s;
if (!parseString(s)) return false;
out.push_back(std::move(s));
skipWs();
if (consume(',')) continue;
if (consume(']')) return true;
return false; // neither separator nor terminator — malformed
}
}
// Skip a single JSON value (string / array / object / bare scalar) so an unknown key
// does not abort the parse. Minimal: enough for forward-compat siblings we don't know.
bool Parser::skipValue() {
skipWs();
if (eof()) return false;
char c = s_[pos_];
if (c == '"') {
std::string tmp;
return parseString(tmp);
}
if (c == '[' || c == '{') {
// Balance nested brackets of either kind, ignoring bracket chars inside
// strings. Enough to step over an unknown nested value; not a full validator.
int depth = 0;
bool inStr = false;
while (!eof()) {
char d = s_[pos_];
if (inStr) {
if (d == '\\') { pos_ += 2; continue; }
if (d == '"') inStr = false;
++pos_;
continue;
}
if (d == '"') { inStr = true; ++pos_; continue; }
if (d == '[' || d == '{') ++depth;
else if (d == ']' || d == '}') {
--depth;
if (depth == 0) { ++pos_; return true; }
}
++pos_;
}
return false;
}
// bare scalar (number / true / false / null) — read to the next structural char
while (!eof()) {
char d = s_[pos_];
if (d == ',' || d == '}' || d == ']' || d == ' ' || d == '\t' ||
d == '\n' || d == '\r')
break;
++pos_;
}
return true;
}
bool Parser::parseManifest(OwnedFileManifest& out) {
if (!consume('{')) return false;
skipWs();
if (consume('}')) return true; // empty object -> empty manifest
for (;;) {
std::string key;
if (!parseString(key)) return false;
if (!consume(':')) return false;
if (key == "owned") {
std::vector<std::string> paths;
if (!parseStringArray(paths)) return false;
for (auto& p : paths) {
// Feed through add() so the persisted invariants (dedup, reject
// empty/absolute) are re-asserted on load — a hand-edited or corrupt
// blob cannot smuggle an absolute or duplicate path into the manifest.
out.add(p);
}
} else {
if (!skipValue()) return false; // forward-compat: tolerate unknown keys
}
skipWs();
if (consume(',')) continue;
if (consume('}')) return true;
return false;
}
}
} // namespace
std::optional<OwnedFileManifest> OwnedFileManifest::deserialize(const std::string& json) {
OwnedFileManifest m;
Parser p(json);
if (!p.parseManifest(m)) return std::nullopt;
return m;
}
} // namespace reasampler
+91
View File
@@ -0,0 +1,91 @@
#pragma once
// owned_manifest — the pure core of the owned-file manifest seam (Phase B, B-cap).
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the same
// "small pure type + JSON round-trip" pattern as wav_trim / tab_strip.
//
// -- What it is --------------------------------------------------------------
//
// The set of files the bank system ITSELF created — every file the capture path
// writes gets recorded here. Phase R prune consumes it to tell the system's own
// orphans (owned ∩ present referenced) apart from hand-dropped files. B-cap only
// WRITES and PERSISTS the manifest; no prune logic lives here (fork R-D, settled
// 2026-07-24: "defer the feature, design the seam").
//
// -- What it is NOT ----------------------------------------------------------
//
// It is NOT a mirror of the bank index. Removing or moving an index entry does NOT
// remove the file's manifest record: the manifest tracks files *created*, and prune
// (Phase R) reconciles manifest-vs-index later. The ONLY thing that adds to it is
// the capture add-path. There is deliberately no remove verb here.
//
// -- The relative-paths-only invariant ---------------------------------------
//
// A manifest path is ALWAYS project-relative (same invariant as Sample.relativePath
// and the persisted BankIndex). add() rejects an absolute path rather than guess a
// relativization — the pure model has no project root, so a "normalization" would be
// a guess that could point at the wrong file (mirror of BankIndex::add's rejection).
#include <optional>
#include <string>
#include <vector>
namespace reasampler {
// Outcome of an add(). Mirrors BankIndex::AddResult's honesty — the op reports what
// happened rather than silently mutating on a bad request.
// - Added: the path was new and recorded.
// - AlreadyPresent: the path was already in the manifest (dedup no-op).
// - RejectedEmptyPath: the path was empty.
// - RejectedAbsolutePath: the path was absolute (relative-paths-only invariant).
enum class ManifestAddResult {
Added,
AlreadyPresent,
RejectedEmptyPath,
RejectedAbsolutePath,
};
// The owned-file manifest: an insertion-ordered, deduplicated set of project-relative
// paths the capture path has created. Insertion order is preserved so serialize()
// round-trips byte-identically (deterministic ext-state, mirror of the index).
class OwnedFileManifest {
public:
OwnedFileManifest() = default;
// Record a project-relative path as owned. Rejects an empty or absolute path (no
// mutation). A path already present is a dedup no-op (AlreadyPresent), so a repeat
// capture of an identical request does not double-record.
ManifestAddResult add(const std::string& relativePath);
// True iff the exact path string is recorded. Phase R uses this to attribute a
// present file to the bank system. Exact string match — path normalization (if any)
// is the caller's concern, consistent across add and query.
bool contains(const std::string& relativePath) const;
// The owned paths in insertion order. Phase R unions this with the on-disk file
// set; here it is the round-trip + query surface.
const std::vector<std::string>& paths() const { return paths_; }
std::size_t size() const { return paths_.size(); }
bool empty() const { return paths_.empty(); }
bool operator==(const OwnedFileManifest& o) const { return paths_ == o.paths_; }
// -- Persistence ---------------------------------------------------------
// Serialize to a JSON string (lossless round-trip): deserialize(serialize(x)) == x.
// An empty manifest serializes to a well-formed empty shape (round-trips to empty).
std::string serialize() const;
// Parse a manifest JSON produced by serialize(). std::nullopt on malformed input
// (the persist shell warns + falls back to an empty manifest, mirroring the bank /
// view malformed handling). An empty/absent stored value is the caller's concern
// (an empty string is not valid JSON) — the shell maps absence to a fresh manifest.
static std::optional<OwnedFileManifest> deserialize(const std::string& json);
private:
std::vector<std::string> paths_; // insertion order; deduplicated
};
} // namespace reasampler
+34
View File
@@ -217,6 +217,15 @@ bool ReaSamplerSession::saveToActiveProject() {
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
kProjExtTailKey, tailJson.c_str());
// Additive: the owned-file manifest (Phase B B-cap) rides alongside in its own
// `owned_files` key. Independent write — does not disturb the blobs above. Written
// on EVERY save so a capture's manifest record survives Save / Save-As / reopen,
// and so the manifest and the bank stay in lockstep on disk (both persisted by the
// same saveToActiveProject the capture add-path calls).
const std::string ownedJson = owned_.serialize();
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
kProjExtOwnedKey, ownedJson.c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj));
return true;
}
@@ -259,6 +268,25 @@ TailSetting loadTailSetting(ReaProject* proj) {
return *loaded;
}
// Load the owned-file manifest from a project's owned_files key, or return an empty
// manifest. An absent/empty key (older / never-captured project) yields an empty
// manifest — graceful, never a crash. Malformed JSON is warned and also falls back to
// empty, mirroring the bank's / view's / tail's malformed handling. Phase R prune then
// sees an empty ownership record and (safely) attributes nothing until the next capture
// rebuilds it — losing the record degrades safety, never correctness.
OwnedFileManifest loadOwnedManifest(ReaProject* proj) {
if (!proj) return OwnedFileManifest{};
const std::string ownedJson =
getProjExtStateString(proj, kProjExtNamespace, kProjExtOwnedKey);
if (ownedJson.empty()) return OwnedFileManifest{}; // no stored manifest -> empty
std::optional<OwnedFileManifest> loaded = OwnedFileManifest::deserialize(ownedJson);
if (!loaded) {
ShowConsoleMsg("ReaSampler: stored owned-file manifest is malformed — ignoring.\n");
return OwnedFileManifest{};
}
return std::move(*loaded);
}
} // namespace
void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) {
@@ -281,6 +309,12 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi
// the previous project's choice (this REPLACES the old session-carry behavior).
tail_ = loadTailSetting(static_cast<ReaProject*>(proj));
// The owned-file manifest is restored on EVERY load path too (peer-symmetry with the
// bank/view/tail resets): switching to a project with no stored manifest must reset
// to empty, not inherit the previous project's ownership record; an undo/redo reload
// (R-B) must re-read the restored manifest so it matches the rolled-back bank state.
owned_ = loadOwnedManifest(static_cast<ReaProject*>(proj));
if (!proj) {
book_ = BankBook{};
return;
+30 -1
View File
@@ -21,6 +21,7 @@
#include "bank_book.h"
#include "bank_model.h"
#include "owned_manifest.h"
#include "tail_control.h"
#include "view_mode_model.h"
@@ -58,6 +59,17 @@ inline constexpr const char* kProjExtViewKey = "view_state";
// default — graceful, but the user's saved choice would be lost).
inline constexpr const char* kProjExtTailKey = "tail_setting";
// The ext-state key holding the owned-file manifest JSON (the set of project-relative
// files the capture path itself created — Phase B B-cap seam, consumed by Phase R
// prune to distinguish the bank system's own orphans from hand-dropped files). A
// SIBLING key alongside banks/view_state/tail_setting — NOT folded into the `banks`
// blob, so it stays decoupled from bank membership (removing an index entry is not a
// manifest removal). One namespace, four content keys. FOREVER-STABLE: changing it
// strands every already-saved project's ownership record, so Phase R prune could no
// longer tell the tool's own files apart (it would fall back to an empty manifest —
// graceful, but the attribution safety net is lost until the next capture rebuilds it).
inline constexpr const char* kProjExtOwnedKey = "owned_files";
// The ext-state key holding a GUID we mint per project to establish CONTENT-BASED
// project identity (REAPER exposes no stable per-project GUID). poll() uses it to
// tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch
@@ -122,6 +134,15 @@ public:
TailSetting& tail() { return tail_; }
const TailSetting& tail() const { return tail_; }
// The owned-file manifest (Phase B B-cap): the set of project-relative files the
// capture path itself created. The capture add-path records each created file here
// (main.cpp, alongside the bank add), exactly as it adds the Sample to the active
// bank; persist serializes it under the `owned_files` key on save and replaces it on
// project load / undo-reload — peer to book_/view_/tail_. Phase R prune CONSUMES it;
// B-cap only writes and persists it (no prune logic here).
OwnedFileManifest& owned() { return owned_; }
const OwnedFileManifest& owned() const { return owned_; }
// Serialize the current book (under the `banks` key), view model, and tail setting
// to the active project's ext state (namespace "reasampler"), and clear the retired
// legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys.
@@ -180,6 +201,13 @@ private:
// adjusted project), so an absent key is graceful. Peer to bank_/view_.
TailSetting tail_;
// The owned-file manifest. Default empty; loadFromProject resets it to empty (or the
// stored set) on EVERY load path (peer-symmetry with book_/view_/tail_): switching to
// a project with no stored manifest must not inherit the previous project's ownership
// record, and an undo that rolled back a capture must re-read the restored manifest so
// the in-memory set matches disk. Absent key -> empty is graceful (older project).
OwnedFileManifest owned_;
// The project identity last observed by poll(), used to detect load/Save-As.
// The GUID is the PRIMARY signal (a different stored GUID = a different project
// of record = Load, immune to pointer recycling). The pointer disambiguates the
@@ -197,7 +225,8 @@ private:
// Load the book from the given project's ext state (the `banks` key, else the
// legacy `bank_index` key migrated into the pool) and resolve bank paths against
// projectDir at read time. Replaces the in-memory book. projectDir empty -> the
// projectDir at read time. Replaces the in-memory book. Also restores view_, tail_,
// and owned_ from their sibling keys on every load path. projectDir empty -> the
// book is reset to empty (unsaved project has no resolvable banks).
void loadFromProject(void* proj, const std::string& projectDir);
};
+171
View File
@@ -0,0 +1,171 @@
// Standalone tests for reasampler::OwnedFileManifest — no REAPER, no framework.
// The owned-file manifest seam (Phase B B-cap): a deduplicated, insertion-ordered
// set of project-relative files the capture path created, with JSON round-trip.
//
// Covers (brief-named): JSON round-trip, dedup of repeated adds, the empty manifest.
// Plus: the relative-paths-only invariant (reject empty / absolute), contains()
// semantics, insertion-order preservation, malformed-parse -> nullopt (the persist
// shell's warn+fallback hinges on it), and round-trip of paths with JSON metacharacters.
#include "../src/owned_manifest.h"
#include <cstdio>
#include <string>
using namespace reasampler;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- empty manifest ----------------------------------------------------------
static void testEmptyManifest() {
OwnedFileManifest m;
CHECK(m.empty());
CHECK(m.size() == 0);
CHECK(m.paths().empty());
CHECK(!m.contains("anything.wav"));
// An empty manifest serializes to a well-formed shape and round-trips to empty.
const std::string json = m.serialize();
auto back = OwnedFileManifest::deserialize(json);
CHECK(back.has_value());
CHECK(*back == m);
CHECK(back->empty());
}
// --- add / contains / order --------------------------------------------------
static void testAddAndContains() {
OwnedFileManifest m;
CHECK(m.add("reasampler_bank/a.wav") == ManifestAddResult::Added);
CHECK(m.add("reasampler_bank/b.wav") == ManifestAddResult::Added);
CHECK(m.size() == 2);
CHECK(m.contains("reasampler_bank/a.wav"));
CHECK(m.contains("reasampler_bank/b.wav"));
CHECK(!m.contains("reasampler_bank/c.wav"));
// Exact-string match — not a prefix / substring match.
CHECK(!m.contains("reasampler_bank/a"));
CHECK(!m.contains("a.wav"));
// Insertion order is preserved (deterministic ext-state).
CHECK(m.paths().size() == 2);
CHECK(m.paths()[0] == "reasampler_bank/a.wav");
CHECK(m.paths()[1] == "reasampler_bank/b.wav");
}
// --- dedup of repeated adds --------------------------------------------------
static void testDedupRepeatedAdds() {
OwnedFileManifest m;
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::Added);
// A repeat capture of an identical request must not double-record the file.
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::AlreadyPresent);
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::AlreadyPresent);
CHECK(m.size() == 1);
CHECK(m.paths().size() == 1);
}
// --- relative-paths-only invariant -------------------------------------------
static void testRejectsEmptyAndAbsolute() {
OwnedFileManifest m;
CHECK(m.add("") == ManifestAddResult::RejectedEmptyPath);
// Every absolute form bank_model rejects, the manifest rejects too.
CHECK(m.add("/abs/take.wav") == ManifestAddResult::RejectedAbsolutePath); // POSIX root
CHECK(m.add("\\\\host\\share\\x.wav") == ManifestAddResult::RejectedAbsolutePath); /* UNC */
CHECK(m.add("C:/bank/x.wav") == ManifestAddResult::RejectedAbsolutePath); // Win drive /
CHECK(m.add("C:\\bank\\x.wav") == ManifestAddResult::RejectedAbsolutePath); /* Win drive backslash */
CHECK(m.add("C:x.wav") == ManifestAddResult::RejectedAbsolutePath); // drive-relative
// A rejected add never mutates.
CHECK(m.empty());
CHECK(!m.contains("/abs/take.wav"));
}
// --- JSON round-trip ---------------------------------------------------------
static void testRoundTrip() {
OwnedFileManifest m;
m.add("reasampler_bank/one.wav");
m.add("reasampler_bank/two.wav");
m.add("reasampler_bank/three.wav");
const std::string json = m.serialize();
auto back = OwnedFileManifest::deserialize(json);
CHECK(back.has_value());
CHECK(*back == m);
// Order + membership survive.
CHECK(back->paths().size() == 3);
CHECK(back->paths()[0] == "reasampler_bank/one.wav");
CHECK(back->paths()[2] == "reasampler_bank/three.wav");
// serialize(deserialize(serialize(x))) is stable.
CHECK(back->serialize() == json);
}
// A path carrying JSON metacharacters must survive the escape/unescape round-trip.
static void testRoundTripEscaping() {
OwnedFileManifest m;
m.add("reasampler_bank/od\"d name.wav"); // embedded quote
m.add("reasampler_bank/back\\slash.wav"); // embedded backslash
m.add("reasampler_bank/tab\tafter.wav"); // control char
auto back = OwnedFileManifest::deserialize(m.serialize());
CHECK(back.has_value());
CHECK(*back == m);
CHECK(back->contains("reasampler_bank/od\"d name.wav"));
CHECK(back->contains("reasampler_bank/back\\slash.wav"));
CHECK(back->contains("reasampler_bank/tab\tafter.wav"));
}
// --- malformed / tolerant parse ----------------------------------------------
static void testMalformedParse() {
// The persist shell's warn+fallback hinges on nullopt for a corrupt blob.
CHECK(!OwnedFileManifest::deserialize("").has_value()); // empty string
CHECK(!OwnedFileManifest::deserialize("not json").has_value());
CHECK(!OwnedFileManifest::deserialize("{\"owned\":[").has_value()); // unterminated array
CHECK(!OwnedFileManifest::deserialize("{\"owned\":[1,2]}").has_value()); // non-string element
CHECK(!OwnedFileManifest::deserialize("{\"owned\":\"x\"}").has_value()); // wrong value type
// An explicit empty array parses to an empty manifest.
auto empty = OwnedFileManifest::deserialize("{\"owned\":[]}");
CHECK(empty.has_value());
CHECK(empty->empty());
// An unknown sibling key is tolerated (forward-compat) — the owned array still loads.
auto fwd = OwnedFileManifest::deserialize(
"{\"future\":{\"nested\":[1,2]},\"owned\":[\"reasampler_bank/x.wav\"]}");
CHECK(fwd.has_value());
CHECK(fwd->size() == 1);
CHECK(fwd->contains("reasampler_bank/x.wav"));
// A stored blob cannot smuggle a duplicate or absolute path past the load-time
// invariant re-assertion (deserialize routes each element through add()).
auto dupe = OwnedFileManifest::deserialize(
"{\"owned\":[\"reasampler_bank/x.wav\",\"reasampler_bank/x.wav\"]}");
CHECK(dupe.has_value());
CHECK(dupe->size() == 1);
auto absolute = OwnedFileManifest::deserialize(
"{\"owned\":[\"reasampler_bank/ok.wav\",\"/etc/evil.wav\"]}");
CHECK(absolute.has_value());
CHECK(absolute->size() == 1);
CHECK(absolute->contains("reasampler_bank/ok.wav"));
CHECK(!absolute->contains("/etc/evil.wav"));
}
int main() {
testEmptyManifest();
testAddAndContains();
testDedupRepeatedAdds();
testRejectsEmptyAndAbsolute();
testRoundTrip();
testRoundTripEscaping();
testMalformedParse();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}