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
+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