From 63f35fa58dab6a606926f63bdbf883ba12d97129 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 14:50:18 -0400 Subject: [PATCH] =?UTF-8?q?feat(persist):=20owned-file=20manifest=20seam?= =?UTF-8?q?=20=E2=80=94=20capture=20records=20created=20files=20(B-cap)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CMakeLists.txt | 19 +- src/main.cpp | 20 ++- src/owned_manifest.cpp | 315 ++++++++++++++++++++++++++++++++++ src/owned_manifest.h | 91 ++++++++++ src/persist.cpp | 34 ++++ src/persist.h | 31 +++- tests/test_owned_manifest.cpp | 171 ++++++++++++++++++ 7 files changed, 674 insertions(+), 7 deletions(-) create mode 100644 src/owned_manifest.cpp create mode 100644 src/owned_manifest.h create mode 100644 tests/test_owned_manifest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c8158f2..c565887 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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") diff --git a/src/main.cpp b/src/main.cpp index 04a4974..49ec89e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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"; diff --git a/src/owned_manifest.cpp b/src/owned_manifest.cpp new file mode 100644 index 0000000..08c4798 --- /dev/null +++ b/src/owned_manifest.cpp @@ -0,0 +1,315 @@ +#include "owned_manifest.h" + +#include +#include + +// 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 : (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(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(c) < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", + static_cast(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& 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(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= static_cast(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(codePoint); + } else if (codePoint <= 0x7FF) { + out += static_cast(0xC0 | (codePoint >> 6)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } else if (codePoint <= 0xFFFF) { + out += static_cast(0xE0 | (codePoint >> 12)); + out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } else { + out += static_cast(0xF0 | (codePoint >> 18)); + out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); + out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } + break; + } + default: return false; + } + } else { + out += c; + } + } + return false; // unterminated string +} + +bool Parser::parseStringArray(std::vector& 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 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::deserialize(const std::string& json) { + OwnedFileManifest m; + Parser p(json); + if (!p.parseManifest(m)) return std::nullopt; + return m; +} + +} // namespace reasampler diff --git a/src/owned_manifest.h b/src/owned_manifest.h new file mode 100644 index 0000000..fe3a3d7 --- /dev/null +++ b/src/owned_manifest.h @@ -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 +#include +#include + +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& 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 deserialize(const std::string& json); + +private: + std::vector paths_; // insertion order; deduplicated +}; + +} // namespace reasampler diff --git a/src/persist.cpp b/src/persist.cpp index 2b43baa..fe303f1 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -217,6 +217,15 @@ bool ReaSamplerSession::saveToActiveProject() { SetProjExtState(static_cast(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(proj), kProjExtNamespace, + kProjExtOwnedKey, ownedJson.c_str()); + MarkProjectDirty(static_cast(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 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(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(proj)); + if (!proj) { book_ = BankBook{}; return; diff --git a/src/persist.h b/src/persist.h index c74c8df..75252fa 100644 --- a/src/persist.h +++ b/src/persist.h @@ -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); }; diff --git a/tests/test_owned_manifest.cpp b/tests/test_owned_manifest.cpp new file mode 100644 index 0000000..77ecf17 --- /dev/null +++ b/tests/test_owned_manifest.cpp @@ -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 +#include + +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; +}