import: remediate review findings — ledger gate, docs, message split

Delegates the refuse-gate to ledgerDegraded(), lifts its console message into a
pure testable fold, fixes stale doc line citations and an inaccurate outcome-enum
comment, and splits the rename counter into collision-vs-sanitize.
This commit is contained in:
2026-08-02 14:02:05 -04:00
parent a927dad2f4
commit f8dde16a7e
20 changed files with 299 additions and 92 deletions
+9 -6
View File
@@ -2366,7 +2366,7 @@ These bind every track in this phase, in addition to the plan-wide set above.
touching it must **cite, not restate**. touching it must **cite, not restate**.
- **Export is read-only against the project.** No ext-state write, no `bumpBankGeneration()`, - **Export is read-only against the project.** No ext-state write, no `bumpBankGeneration()`,
no undo point. Import does the opposite: it bumps the generation no undo point. Import does the opposite: it bumps the generation
(`src/shell/persist/session.h:108`) so live ReaSampler 9000 instances reload, and batches (`src/shell/persist/session.h:114`) so live ReaSampler 9000 instances reload, and batches
its index mutation into one Ctrl-Z through `persistBankOp`. its index mutation into one Ctrl-Z through `persistBankOp`.
- **All-or-nothing on both sides.** No partial export, no partial import. A truncated - **All-or-nothing on both sides.** No partial export, no partial import. A truncated
`.rsbank` must never exist on disk (temp file + atomic rename, the Ψ-W2-T2 precedent); a `.rsbank` must never exist on disk (temp file + atomic rename, the Ψ-W2-T2 precedent); a
@@ -2396,10 +2396,13 @@ new directories** (`src/core/package/`, `src/shell/package/`) that no other phas
not read here. The only pre-existing files any Ε track edits are named per track below — not read here. The only pre-existing files any Ε track edits are named per track below —
`core/tracking/origin_ledger` (W1-T3, exclusively), the root `CMakeLists.txt` `core/tracking/origin_ledger` (W1-T3, exclusively), the root `CMakeLists.txt`
`add_subdirectory` list (W1-T1 and W1-T2, one line each), `src/app/main.cpp` and the panel's `add_subdirectory` list (W1-T1 and W1-T2, one line each), `src/app/main.cpp` and the panel's
bank menu (W2-T1 and W2-T2, one registration line and one menu row each), and bank menu (W2-T1 and W2-T2, one registration line and one menu row each),
`core/model/bank_book.{h,cpp}` (W2-T2 only — **one additive public `const` member**, required `core/model/bank_book.{h,cpp}` (W2-T2 only — **one additive public `const` member**, required
by the Ε-F2 auto-suffix rule so the name fold keeps its single home). **No Ε track by the Ε-F2 auto-suffix rule so the name fold keeps its single home), and
touches `core/instrument/`, `shell/instrument/`, or any capture backend.** `shell/persist/session.h` (W2-T2 only — one additive public accessor, `ledgerStatus()`, so the
import gate can key on `LedgerStatus` alone without going through `pruneDryRun()`'s
enumeration+scan). **No Ε track touches `core/instrument/`, `shell/instrument/`, or any
capture backend.**
--- ---
@@ -2764,7 +2767,7 @@ fold, it does not add a second one), `origin_ledger` (Ε-W1-T3's).
`"Drums 3"` — a bare trailing integer is indistinguishable from `"Kit 808"`); the probe `"Drums 3"` — a bare trailing integer is indistinguishable from `"Kit 808"`); the probe
**fills gaps** (first-free, not highest-plus-one, so it is a pure function of the current **fills gaps** (first-free, not highest-plus-one, so it is a pure function of the current
name set); the probe **terminates** by pigeonhole within `B + 1` candidates for `B` banks, name set); the probe **terminates** by pigeonhole within `B + 1` candidates for `B` banks,
so **no arbitrary cap**; and the fold is `BankBook`'s own (`bank_book.h:252-258`), reached so **no arbitrary cap**; and the fold is `BankBook`'s own (`bank_book.h:263-269`), reached
through the new public member, never re-implemented in `import_plan`. Sample display names through the new public member, never re-implemented in `import_plan`. Sample display names
are **not** suffixed, and `slot_map` positions are untouched. are **not** suffixed, and `slot_map` positions are untouched.
- **Always a new bank; never a merge (Ε-F2, ruled).** The import creates a bank — it never - **Always a new bank; never a merge (Ε-F2, ruled).** The import creates a bank — it never
@@ -2787,7 +2790,7 @@ fold, it does not add a second one), `origin_ledger` (Ε-W1-T3's).
remain as orphans until a prune reclaims them — the same designed window a non-empty bank remain as orphans until a prune reclaims them — the same designed window a non-empty bank
delete already produces (`core/model/CLAUDE.md`'s sample-removal section). The user-facing delete already produces (`core/model/CLAUDE.md`'s sample-removal section). The user-facing
summary says so. summary says so.
- **`bumpBankGeneration()` on success** (`session.h:108`), so live instances reload. - **`bumpBankGeneration()` on success** (`session.h:114`), so live instances reload.
- **No timeline item is placed. Ever.** - **No timeline item is placed. Ever.**
- **A new FOREVER-STABLE command id**, minted the same way T1's is. - **A new FOREVER-STABLE command id**, minted the same way T1's is.
+3 -3
View File
@@ -381,7 +381,7 @@ trim, the seed is the literal `Imported bank`.
**The probe.** Let `seed` be that string and `fold(x)` be `BankBook`'s own uniqueness **The probe.** Let `seed` be that string and `fold(x)` be `BankBook`'s own uniqueness
key — strip leading/trailing ASCII whitespace, lower-case ASCII letters key — strip leading/trailing ASCII whitespace, lower-case ASCII letters
(`bank_book.h:252-258`). Take the **first** name in this sequence whose fold is not (`bank_book.h:263-269`). Take the **first** name in this sequence whose fold is not
already carried by a bank in the destination book: already carried by a bank in the destination book:
seed, seed + " 2", seed + " 3", seed + " 4", … seed, seed + " 2", seed + " 3", seed + " 4", …
@@ -410,7 +410,7 @@ implementations diverge:**
`B + 1` candidates is free by pigeonhole, so no cap is needed and none should be `B + 1` candidates is free by pigeonhole, so no cap is needed and none should be
added. added.
4. **The fold has exactly one home.** `import_plan` must **not** re-implement 4. **The fold has exactly one home.** `import_plan` must **not** re-implement
`nameKey``bank_book.h:252-258` says in as many words that a drifted second copy `nameKey``bank_book.h:263-269` says in as many words that a drifted second copy
would let the uniqueness invariant be violated. The probe therefore runs behind would let the uniqueness invariant be violated. The probe therefore runs behind
`BankBook`'s own folding, which means Ε-W2-T2 adds **one additive public `const` `BankBook`'s own folding, which means Ε-W2-T2 adds **one additive public `const`
member** to `BankBook` (recommended: `std::string uniqueDisplayName(const member** to `BankBook` (recommended: `std::string uniqueDisplayName(const
@@ -690,7 +690,7 @@ constructors.
freshly-generated pair. freshly-generated pair.
- **Bank generation.** Import mutates bank content that live ReaSampler 9000 - **Bank generation.** Import mutates bank content that live ReaSampler 9000
instances may play, so it must `bumpBankGeneration()` instances may play, so it must `bumpBankGeneration()`
(`src/shell/persist/session.h:108`, whose own comment says call sites "err toward (`src/shell/persist/session.h:114`, whose own comment says call sites "err toward
bumping"). Export mutates nothing and must bump nothing, write no ext state, and bumping"). Export mutates nothing and must bump nothing, write no ext state, and
open no undo point. open no undo point.
- **Beta/stable channel isolation.** Packages are channel-**agnostic** and this is - **Beta/stable channel isolation.** Packages are channel-**agnostic** and this is
+4 -3
View File
@@ -3,6 +3,8 @@
#include <algorithm> #include <algorithm>
#include <unordered_set> #include <unordered_set>
#include "core/util/ascii_ws.h"
// bank_book implementation — the registry RULES half: construction, pool // bank_book implementation — the registry RULES half: construction, pool
// privileges, bank lifecycle, active bank, sample movement/removal, slot order, // privileges, bank lifecycle, active bank, sample movement/removal, slot order,
// and the reference queries. The JSON round-trip half lives in bank_book_json.cpp, // and the reference queries. The JSON round-trip half lives in bank_book_json.cpp,
@@ -76,9 +78,8 @@ void BankBook::normalizeOrdinals() {
// one folding rule shared with bank_book_json.cpp's parse-time coalesce. // one folding rule shared with bank_book_json.cpp's parse-time coalesce.
std::string BankBook::nameKey(const std::string& s) { std::string BankBook::nameKey(const std::string& s) {
std::size_t b = 0, e = s.size(); std::size_t b = 0, e = s.size();
auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }; while (b < e && util::isAsciiWs(s[b])) ++b;
while (b < e && isWs(s[b])) ++b; while (e > b && util::isAsciiWs(s[e - 1])) --e;
while (e > b && isWs(s[e - 1])) --e;
std::string out; std::string out;
out.reserve(e - b); out.reserve(e - b);
for (std::size_t i = b; i < e; ++i) { for (std::size_t i = b; i < e; ++i) {
+16 -2
View File
@@ -76,8 +76,11 @@ landing after the format.
- `import_plan` — the pure import decision, and the reason the whole feature is - `import_plan` — the pure import decision, and the reason the whole feature is
testable without a DAW: the destination bank's display name after testable without a DAW: the destination bank's display name after
`BankBook`'s own fold, the reminted sample ids and remapped parents, and the `BankBook`'s own fold, the reminted sample ids and remapped parents, and the
per-entry land / collapse / rename disposition. Also `importLedgerRefusal`, per-entry land / collapse / rename disposition. Also `importLedgerRefusal` (the
the import's ledger gate. import's ledger gate, delegating entirely to `tracking::ledgerDegraded`) and
`ledgerRefusalMessage` (the gate's console-block body, a pure
`(LedgerRefusal, namespace) -> string` fold the shell only supplies the
channel-correct namespace to).
- `bank_package` — framing and arithmetic composing the two above: - `bank_package` — framing and arithmetic composing the two above:
`encodePackage` (prefix bytes + layout + total size, stamping this build's `encodePackage` (prefix bytes + layout + total size, stamping this build's
ladder pair and `version::stampVersion()`), `decodePackage` (prefix + observed ladder pair and `version::stampVersion()`), `decodePackage` (prefix + observed
@@ -156,3 +159,14 @@ landing after the format.
not a real entry). `serializeManifest` refuses a zero-length `PackageEntry` not a real entry). `serializeManifest` refuses a zero-length `PackageEntry`
at encode so this layer never produces one; decode does not enforce it (a at encode so this layer never produces one; decode does not enforce it (a
hostile/older package declaring one is not this track's concern). hostile/older package declaring one is not this track's concern).
- **`import_plan`'s `spelledLikeABankFile` mints a fresh name even with NO
collision, and that third condition is a deliberate decision, not spec-derived.**
`docs/product/bank-package.md:447` ties the auto-rename mint to a *collision*
only; `spelledLikeABankFile` additionally mints whenever the package's own name
isn't spelled the way `deriveBankPaths` spells one (extension, sanitized stem).
Kept for two reasons: uniform folder spelling for every landed file regardless of
origin, and — the sharper one — a hostile entry name that isn't a legal Windows
filename or carries an unexpected extension (e.g. `evil.exe`) lands sanitized
(`evil_<tag>.wav`) rather than verbatim. `ImportPlan` counts this separately from a
genuine folder-name collision (`sanitizeRenameCount` vs `collisionRenameCount`) so
the summary line means what `bank-package.md:447` says it means.
+59 -8
View File
@@ -6,6 +6,7 @@
#include "core/capture/capture_paths.h" #include "core/capture/capture_paths.h"
#include "core/package/package_format.h" #include "core/package/package_format.h"
#include "core/util/ascii_ws.h"
namespace reasampler::package { namespace reasampler::package {
@@ -14,10 +15,13 @@ namespace {
using capture::bankRelativeForName; using capture::bankRelativeForName;
using capture::deriveBankPaths; using capture::deriveBankPaths;
using capture::sanitizeStem; using capture::sanitizeStem;
using util::isAsciiWs;
// Shares BankBook::nameKey's whitespace set (core/util/ascii_ws.h) so a name nameKey
// would fold to empty is never treated as recorded here.
bool blankName(const std::string& s) { bool blankName(const std::string& s) {
for (char c : s) for (char c : s)
if (c != ' ' && c != '\t' && c != '\n' && c != '\r') return false; if (!isAsciiWs(c)) return false;
return true; return true;
} }
@@ -40,6 +44,14 @@ bool spelledLikeABankFile(const std::string& fileName) {
// The bank-folder names an import must not land on: what is there already, plus what // The bank-folder names an import must not land on: what is there already, plus what
// this import has minted so far. Case-folded, because the two filesystems this tool // this import has minted so far. Case-folded, because the two filesystems this tool
// ships on would treat "Kick.wav" and "kick.wav" as one file. // ships on would treat "Kick.wav" and "kick.wav" as one file.
//
// `bankFolderFileNames` comes from `listFolderFileNames` (shell/package/package_io),
// which skips non-regular files — so a DIRECTORY sharing a bank file's name is
// invisible here. The plan then never mints around it, and the later exclusive-create
// land fails on that one entry (WriteFailed, rolled back). Safe direction ("never
// overwrite" still holds) but worth knowing before chasing a WriteFailed report that
// traces back to a same-named folder in the bank directory; test_import_landing's
// rollback suite deliberately exploits this to exercise the rollback path.
class NameSet { class NameSet {
public: public:
explicit NameSet(const std::vector<std::string>& present) { explicit NameSet(const std::vector<std::string>& present) {
@@ -71,13 +83,46 @@ std::string mintFileName(const std::string& projectDir, const std::string& packa
} // namespace } // namespace
LedgerRefusal importLedgerRefusal(tracking::LedgerStatus status) { LedgerRefusal importLedgerRefusal(tracking::LedgerStatus status) {
switch (status) { // Delegates the refuse/proceed decision entirely to ledgerDegraded() rather than
case tracking::LedgerStatus::Unreadable: return LedgerRefusal::Malformed; // re-deriving it from the two named statuses, so a future degraded status added
case tracking::LedgerStatus::FutureVersion: return LedgerRefusal::FutureVersion; // there is refused here too rather than silently falling through to None.
case tracking::LedgerStatus::Fresh: if (!tracking::ledgerDegraded(status)) return LedgerRefusal::None;
case tracking::LedgerStatus::Loaded: break; // Below this point status is known degraded; only the message variant is picked.
// Unreadable gets its own "corrupt, may be cleared" wording; every other degraded
// status (today only FutureVersion) gets the "written by a newer build" wording.
return status == tracking::LedgerStatus::Unreadable ? LedgerRefusal::Malformed
: LedgerRefusal::FutureVersion;
} }
return LedgerRefusal::None;
// Mirrors prune's abort block in structure and tone (shell/actions/prune_action.cpp),
// because a user who has hit that one should recognise this one. Every recovery line
// names THIS build's namespace: a beta user handed the stable spelling clears the wrong
// key and is still blocked.
std::string ledgerRefusalMessage(LedgerRefusal refusal, const std::string& extStateNamespace) {
if (refusal == LedgerRefusal::None) return {};
std::string msg =
"ReaSampler import: ABORTED -- the file-tracking ledger could not be read. "
"Nothing was imported.\n";
if (refusal == LedgerRefusal::Malformed) {
msg += "The stored file-tracking ledger is malformed. It has been left intact "
"rather than overwritten, so it can be repaired or cleared:\n"
" reaper.SetProjExtState(0, \"" + extStateNamespace + "\", \"owned_files\", \"\")\n"
"Clearing it makes every existing bank file un-reclaimable (they stop "
"being attributable to ReaSampler); no file is lost. Reopen the project "
"afterwards -- the block is held for the rest of this session.\n";
} else {
msg += "The stored file-tracking ledger was written by a NEWER version of "
"ReaSampler than this one, so its records cannot be read safely. It has "
"been left intact and will NOT be overwritten. Reopen the project with "
"that newer version -- do NOT clear this key from here, that would "
"discard tracking records this build cannot see. The block is held for "
"the rest of this session.\n";
}
msg += "An import can land hundreds of files in one gesture. With no readable "
"ledger, none of them could be given a birth record, and every one would be "
"permanently unreclaimable.\n";
return msg;
} }
std::string bankFolderDir(const std::string& projectDir) { std::string bankFolderDir(const std::string& projectDir) {
@@ -139,7 +184,13 @@ ImportPlan planImport(const PackageManifest& manifest,
idRemap[src.sample.id] = e.sample.id; idRemap[src.sample.id] = e.sample.id;
++plan.landCount; ++plan.landCount;
if (e.renamed) ++plan.renameCount; // A rename happens for one of two reasons: the package's own name was already
// taken (spelledLikeABankFile true but the mint's fast path lost the race to
// `taken`), or the name never qualified for that fast path at all (sanitize).
if (e.renamed) {
if (spelledLikeABankFile(src.fileName)) ++plan.collisionRenameCount;
else ++plan.sanitizeRenameCount;
}
plan.entries.push_back(std::move(e)); plan.entries.push_back(std::move(e));
} }
+13 -1
View File
@@ -32,6 +32,13 @@ enum class LedgerRefusal { None, Malformed, FutureVersion };
LedgerRefusal importLedgerRefusal(tracking::LedgerStatus status); LedgerRefusal importLedgerRefusal(tracking::LedgerStatus status);
// The console-block body for a refusal — a pure (LedgerRefusal, namespace) -> string
// fold, so the wording is assertable without a DAW. `extStateNamespace` is the
// channel-correct namespace (`version::extStateNamespace()`) every recovery line must
// name, so a beta user is never handed the stable spelling. Empty string for None —
// callers only reach this once `importLedgerRefusal` has already returned a refusal.
std::string ledgerRefusalMessage(LedgerRefusal refusal, const std::string& extStateNamespace);
// What one manifest entry does when the import runs. // What one manifest entry does when the import runs.
// - Land: write the payload under destFileName and add `sample`. // - Land: write the payload under destFileName and add `sample`.
// - Collapse: an equal contentHash already lands in this same import, so the payload // - Collapse: an equal contentHash already lands in this same import, so the payload
@@ -56,7 +63,12 @@ struct ImportPlan {
model::SlotMap slots; // the package's slots over the reminted ids model::SlotMap slots; // the package's slots over the reminted ids
int landCount = 0; int landCount = 0;
int collapseCount = 0; int collapseCount = 0;
int renameCount = 0; // Two distinct triggers, counted separately (bank-package.md:447 defines the first
// as THE collision counter; conflating the second into it would misreport a mint
// that never collided as a collision).
int collisionRenameCount = 0; // the package's own name was already taken in the bank folder
int sanitizeRenameCount = 0; // the package's name was not spelled the way this tool spells
// a bank file (see spelledLikeABankFile, core/package/CLAUDE.md)
}; };
// The bank folder an import lands into — the same expression capture uses, so an // The bank folder an import lands into — the same expression capture uses, so an
+3 -2
View File
@@ -3,8 +3,8 @@
## Scope ## Scope
Tiny, dependency-free pure helpers linked by both artifacts: whole-file byte Tiny, dependency-free pure helpers linked by both artifacts: whole-file byte
loading, unit-interval clamping, the absolute-path rejection test, and the loading, unit-interval clamping, the absolute-path rejection test, the
per-segment envelope curve law. per-segment envelope curve law, and the ASCII-whitespace fold test.
## Modules ## Modules
@@ -19,6 +19,7 @@ per-segment envelope curve law.
before curves existed play unchanged, and what the knob law's centre detent exists to keep before curves existed play unchanged, and what the knob law's centre detent exists to keep
reachable from the dial. reachable from the dial.
- `relative_path` (`core/util`, header-only) — the ONE absolute-path rejection test behind the relative-paths-only invariant, shared by `bank_model` (`Sample.relativePath`) and `core/tracking/origin_ledger` (`OriginRecord.relativePath`). The two must reject identically or a path one accepts could be smuggled past the other; that is why it is one function and not two. - `relative_path` (`core/util`, header-only) — the ONE absolute-path rejection test behind the relative-paths-only invariant, shared by `bank_model` (`Sample.relativePath`) and `core/tracking/origin_ledger` (`OriginRecord.relativePath`). The two must reject identically or a path one accepts could be smuggled past the other; that is why it is one function and not two.
- `ascii_ws` (`core/util`, header-only) — the ONE ASCII-whitespace test (space/tab/CR/LF) behind `BankBook::nameKey`'s trim, shared by `core/package/import_plan`'s blank-bank-name fallback. Same rationale as `relative_path`: two independently-maintained copies could drift on what counts as blank.
## Gotchas ## Gotchas
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// ascii_ws — the ONE ASCII-whitespace test shared by every fold that must agree with
// BankBook::nameKey's trim (space/tab/CR/LF): a drifted second copy could accept a
// package bank name nameKey would treat as blank, or vice versa.
namespace reasampler::util {
inline bool isAsciiWs(char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }
} // namespace reasampler::util
+1 -1
View File
@@ -43,7 +43,7 @@ is owned by other directories and only skinned here.
- `drag_out_win` — OS drag-out shell: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`. - `drag_out_win` — OS drag-out shell: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`.
- `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns), and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.** - `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns), and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
- `arrange_drop_win` — the drag-out gesture's arrange outcome: `arrangeTimeAtScreenX` (pointer column → time via `GetSet_ArrangeView2`'s one-pixel-span reading — inferred, not SDK-documented) and `performArrangeDrop` (snap the drop time, then one `InsertMedia` per capture on the pointer's track — assumed, not confirmed, to land end-to-end via REAPER's own cursor advance — in ONE undo block, counting only InsertMedia's reported successes, with the caller's track selection and edit cursor restored). The one timeline-placing shell here, per the invariant above; it never captures and never writes the bank. - `arrange_drop_win` — the drag-out gesture's arrange outcome: `arrangeTimeAtScreenX` (pointer column → time via `GetSet_ArrangeView2`'s one-pixel-span reading — inferred, not SDK-documented) and `performArrangeDrop` (snap the drop time, then one `InsertMedia` per capture on the pointer's track — assumed, not confirmed, to land end-to-end via REAPER's own cursor advance — in ONE undo block, counting only InsertMedia's reported successes, with the caller's track selection and edit cursor restored). The one timeline-placing shell here, per the invariant above; it never captures and never writes the bank.
- `package_import_action` — the bindable / bank-menu / file-drop skin over `shell/package`'s `importBankPackage`. Owns the **ledger gate**, which runs BEFORE the file picker (a refusal must not cost the user a file choice) and is keyed on the session's `LedgerStatus` alone — never on `PruneReport::blockedByTracking`, whose undecodable-`rsusage_*` arm governs deletion-time protection and would refuse an import that only writes birth records. Owns every message the import produces; the verb itself is promptless. - `package_import_action` — the bindable / bank-menu / file-drop skin over `shell/package`'s `importBankPackage`. Owns the **ledger gate**, which runs BEFORE the file picker (a refusal must not cost the user a file choice) and is keyed on the session's `LedgerStatus` alone — never on `PruneReport::blockedByTracking`, whose undecodable-`rsusage_*` arm governs deletion-time protection and would refuse an import that only writes birth records. Builds and shows every message the import produces, but the ledger-refusal body itself is `core/package::ledgerRefusalMessage` — a pure fold this TU only supplies the channel-correct namespace to — so the wording is assertable without a DAW. `doImportBankPackage`/`doImportBankPackageFile` return the minted bank id on a landed import (empty otherwise) so a caller can focus it; the verb itself is promptless.
- `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.** Surface (2)'s action is the one in this directory published into a NON-main action section (Media Explorer) as well as Main — two ids, one handler, two dispatch hooks; see root `CLAUDE.md` §"REAPER extension contract" for the mechanism. - `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.** Surface (2)'s action is the one in this directory published into a NON-main action section (Media Explorer) as well as Main — two ids, one handler, two dispatch hooks; see root `CLAUDE.md` §"REAPER extension contract" for the mechanism.
## Gotchas ## Gotchas
+50 -45
View File
@@ -25,48 +25,31 @@ constexpr const char* kTitle = "ReaSampler: import bank package";
std::string quoted(const std::string& s) { return "\"" + s + "\""; } std::string quoted(const std::string& s) { return "\"" + s + "\""; }
// Mirrors prune's abort block in structure and tone, because a user who has hit that // The message body itself is core/package::ledgerRefusalMessage — a pure
// one should recognise this one. Every recovery line names THIS build's namespace: a // (LedgerRefusal, namespace) -> string fold, testable without a DAW. This TU only
// beta user handed the stable spelling clears the wrong key and is still blocked. // supplies the channel-correct namespace and the console call.
void reportLedgerRefusal(package::LedgerRefusal refusal) { void reportLedgerRefusal(package::LedgerRefusal refusal) {
const std::string& ns = version::extStateNamespace(); ShowConsoleMsg(
std::string msg = package::ledgerRefusalMessage(refusal, version::extStateNamespace()).c_str());
"ReaSampler import: ABORTED -- the file-tracking ledger could not be read. "
"Nothing was imported.\n";
if (refusal == package::LedgerRefusal::Malformed) {
msg += "The stored file-tracking ledger is malformed. It has been left intact "
"rather than overwritten, so it can be repaired or cleared:\n"
" reaper.SetProjExtState(0, \"" + ns + "\", \"owned_files\", \"\")\n"
"Clearing it makes every existing bank file un-reclaimable (they stop "
"being attributable to ReaSampler); no file is lost. Reopen the project "
"afterwards -- the block is held for the rest of this session.\n";
} else {
msg += "The stored file-tracking ledger was written by a NEWER version of "
"ReaSampler than this one, so its records cannot be read safely. It has "
"been left intact and will NOT be overwritten. Reopen the project with "
"that newer version -- do NOT clear this key from here, that would "
"discard tracking records this build cannot see. The block is held for "
"the rest of this session.\n";
}
msg += "An import can land hundreds of files in one gesture. With no readable "
"ledger, none of them could be given a birth record, and every one would be "
"permanently unreclaimable.\n";
ShowConsoleMsg(msg.c_str());
} }
// The refusal a user can act on names all three: what the package needs, what this // The refusal a user can act on names all three: what the package needs, what this
// build reads, and which build wrote it. Any two of them leave them stuck. // build reads, and which build wrote it. Any two of them leave them stuck.
void reportTooNew(const ImportBankResult& r) { void reportTooNew(const ImportBankResult& r) {
const bool knownWriter = !r.header.writerVersion.empty();
const std::string writer = const std::string writer =
r.header.writerVersion.empty() ? std::string("an unidentified build") knownWriter ? "ReaSampler " + r.header.writerVersion : std::string("an unidentified build");
: "ReaSampler " + r.header.writerVersion; std::string msg =
const std::string msg =
"Cannot import this bank package.\n" "Cannot import this bank package.\n"
"It was written by " + writer + " and needs package format " + "It was written by " + writer + " and needs package format " +
std::to_string(r.header.minReaderVersion) + " or newer.\n" std::to_string(r.header.minReaderVersion) + " or newer.\n"
"This build (" + version::appVersion() + ") reads package format " + "This build (" + version::appVersion() + ") reads package format " +
std::to_string(package::kPackageFormatVersion) + ".\n" std::to_string(package::kPackageFormatVersion) + ".\n"
"Nothing was imported. Install " + writer + " or newer and try again."; "Nothing was imported. ";
// "Install <writer> or newer" reads fine when writer is a real semver; it does not
// when writer is the "unidentified build" filler, so that case gets its own sentence.
msg += knownWriter ? "Install " + writer + " or newer and try again."
: "Install a newer version of ReaSampler and try again.";
ShowMessageBox(msg.c_str(), kTitle, 0); ShowMessageBox(msg.c_str(), kTitle, 0);
} }
@@ -77,17 +60,27 @@ void reportSuccess(const ImportBankResult& r) {
detail += " (a bank named " + quoted(r.seedBankName) + detail += " (a bank named " + quoted(r.seedBankName) +
" already exists in this project)"; " already exists in this project)";
detail += ".\n"; detail += ".\n";
if (r.renamedCount > 0) { // Two distinct triggers (core/package::ImportPlan), reported as two counts rather
detail += " " + std::to_string(r.renamedCount) + // than folded into one ambiguous "already taken, or not spelled right" line.
if (r.collisionRenameCount > 0) {
detail += " " + std::to_string(r.collisionRenameCount) +
" file(s) landed under a freshly minted name (the package's own name " " file(s) landed under a freshly minted name (the package's own name "
"was already taken in the bank folder, or was not spelled the way " "was already taken in the bank folder). An existing bank file is "
"this bank spells a file). An existing bank file is never " "never overwritten.\n";
"overwritten.\n"; }
if (r.sanitizeRenameCount > 0) {
detail += " " + std::to_string(r.sanitizeRenameCount) +
" file(s) landed under a freshly minted name (not spelled the way "
"this bank spells a file).\n";
} }
if (r.collapsedCount > 0) { if (r.collapsedCount > 0) {
// "Already present" here can only mean a duplicate BY CONTENT inside this same
// package (Ε-F2: import never consults another bank's hashes) — deliberately
// reworded from bank-package.md:448's "already present" phrasing, which reads
// as "already in your project" and is misleading in this direction.
detail += " " + std::to_string(r.collapsedCount) + detail += " " + std::to_string(r.collapsedCount) +
" sample(s) were already present by content and were not written " " sample(s) duplicated another entry in this same package by content "
"again.\n"; "and were written once.\n";
} }
detail += "One undo removes the imported bank and its entries. It does NOT delete " detail += "One undo removes the imported bank and its entries. It does NOT delete "
"the imported files -- they stay in the bank folder, referenced by " "the imported files -- they stay in the bank folder, referenced by "
@@ -129,6 +122,14 @@ void report(const ImportBankResult& r) {
case ImportOutcome::Malformed: case ImportOutcome::Malformed:
// Distinct from TooNew on purpose: the recoveries are opposite -- one is // Distinct from TooNew on purpose: the recoveries are opposite -- one is
// "install a newer build", this one is "get an intact copy". // "install a newer build", this one is "get an intact copy".
//
// bank-package.md:443 asks for a separate "This package is not well-formed"
// message when an entry name carries a separator / ".." / an absolute form.
// Not implemented: deserializeManifest returns one indistinguishable nullopt
// for that and for ordinary corruption, so it folds into this generic box.
// The binding spec (PLAN.md:2678) only requires Malformed != TooNew, which
// this still satisfies -- that product-doc row is knowingly left open, not
// silently missed.
ShowMessageBox("This file is not a readable bank package (corrupt or " ShowMessageBox("This file is not a readable bank package (corrupt or "
"truncated). Nothing was imported.", "truncated). Nothing was imported.",
kTitle, 0); kTitle, 0);
@@ -167,17 +168,21 @@ bool ledgerPermits(ReaSamplerSession& session) {
} // namespace } // namespace
void doImportBankPackage(ReaSamplerSession& session) { std::string doImportBankPackage(ReaSamplerSession& session) {
if (!ledgerPermits(session)) return; if (!ledgerPermits(session)) return {};
std::string path; std::string path;
if (!pickPackageForImport(path) || path.empty()) return; if (!pickPackageForImport(path) || path.empty()) return {};
report(importBankPackage(session, path)); const ImportBankResult r = importBankPackage(session, path);
report(r);
return r.outcome == ImportOutcome::Landed ? r.bankId : std::string{};
} }
void doImportBankPackageFile(ReaSamplerSession& session, const std::string& packageAbsPath) { std::string doImportBankPackageFile(ReaSamplerSession& session, const std::string& packageAbsPath) {
if (packageAbsPath.empty()) return; if (packageAbsPath.empty()) return {};
if (!ledgerPermits(session)) return; if (!ledgerPermits(session)) return {};
report(importBankPackage(session, packageAbsPath)); const ImportBankResult r = importBankPackage(session, packageAbsPath);
report(r);
return r.outcome == ImportOutcome::Landed ? r.bankId : std::string{};
} }
} // namespace reasampler } // namespace reasampler
+7 -4
View File
@@ -9,11 +9,14 @@ namespace reasampler {
class ReaSamplerSession; class ReaSamplerSession;
// Gate, pick, import, report. The bound action and the panel's bank menu both call this. // Gate, pick, import, report. The bound action and the panel's bank menu both call
void doImportBankPackage(ReaSamplerSession& session); // this. Returns the minted bank id on a landed import, "" otherwise (cancelled,
// refused, or failed) — a caller that wants to focus the new bank (mirroring
// doCreateBank) checks the return rather than reaching back into ImportBankResult.
std::string doImportBankPackage(ReaSamplerSession& session);
// Same, for a .rsbank already named by the user — the panel's file-drop route. The gate // Same, for a .rsbank already named by the user — the panel's file-drop route. The gate
// still runs first; only the picker is skipped. // still runs first; only the picker is skipped. Same return contract as doImportBankPackage.
void doImportBankPackageFile(ReaSamplerSession& session, const std::string& packageAbsPath); std::string doImportBankPackageFile(ReaSamplerSession& session, const std::string& packageAbsPath);
} // namespace reasampler } // namespace reasampler
+5 -2
View File
@@ -46,7 +46,8 @@ void fillPlanCounts(ImportBankResult& out, const package::ImportPlan& plan) {
out.seedBankName = plan.seedBankName; out.seedBankName = plan.seedBankName;
out.bankNameAdjusted = plan.bankNameAdjusted; out.bankNameAdjusted = plan.bankNameAdjusted;
out.landedCount = plan.landCount; out.landedCount = plan.landCount;
out.renamedCount = plan.renameCount; out.collisionRenameCount = plan.collisionRenameCount;
out.sanitizeRenameCount = plan.sanitizeRenameCount;
out.collapsedCount = plan.collapseCount; out.collapsedCount = plan.collapseCount;
} }
@@ -70,8 +71,9 @@ ImportBankResult importBankPackage(ReaSamplerSession& session,
fillPlanCounts(out, landing.plan); fillPlanCounts(out, landing.plan);
if (landing.outcome != ImportOutcome::Landed) return out; if (landing.outcome != ImportOutcome::Landed) return out;
const std::string bankId = mintBankId();
const bool applied = applyImportedBank( const bool applied = applyImportedBank(
session.book(), mintBankId(), landing.plan, session.book(), bankId, landing.plan,
[&session](const model::Sample& s) { [&session](const model::Sample& s) {
session.recordCreated(s, tracking::OriginKind::PackageImport); session.recordCreated(s, tracking::OriginKind::PackageImport);
}); });
@@ -80,6 +82,7 @@ ImportBankResult importBankPackage(ReaSamplerSession& session,
out.rollback = journal.rollback(); out.rollback = journal.rollback();
return out; return out;
} }
out.bankId = bankId;
// Generation bump + persist ride inside one undo block, so a Ctrl-Z takes the whole // Generation bump + persist ride inside one undo block, so a Ctrl-Z takes the whole
// import back out of the index. It does NOT un-write the files — the summary says so. // import back out of the index. It does NOT un-write the files — the summary says so.
+3 -1
View File
@@ -17,12 +17,14 @@ struct ImportBankResult {
ImportOutcome outcome = ImportOutcome::Unreadable; ImportOutcome outcome = ImportOutcome::Unreadable;
package::PackageHeader header; // TooNew names the writer's build from here package::PackageHeader header; // TooNew names the writer's build from here
std::string bankId; // the minted id — meaningful only when outcome == Landed
std::string bankDisplayName; // the bank actually created std::string bankDisplayName; // the bank actually created
std::string seedBankName; // what the package asked to be called std::string seedBankName; // what the package asked to be called
bool bankNameAdjusted = false; bool bankNameAdjusted = false;
int landedCount = 0; int landedCount = 0;
int renamedCount = 0; int collisionRenameCount = 0; // renamed: the package's own name was already taken
int sanitizeRenameCount = 0; // renamed: not spelled the way this tool spells a bank file
int collapsedCount = 0; int collapsedCount = 0;
std::string failedEntryName; std::string failedEntryName;
+13 -1
View File
@@ -3,6 +3,7 @@
#include "shell/package/import_landing.h" #include "shell/package/import_landing.h"
#include <cassert>
#include <cstdint> #include <cstdint>
#include <filesystem> #include <filesystem>
#include <system_error> #include <system_error>
@@ -118,12 +119,23 @@ ImportLanding landPackage(const std::string& packageAbsPath,
bool applyImportedBank(BankBook& book, const std::string& bankId, bool applyImportedBank(BankBook& book, const std::string& bankId,
const package::ImportPlan& plan, const RecordBirth& recordBirth) { const package::ImportPlan& plan, const RecordBirth& recordBirth) {
// An empty std::function throws std::bad_function_call on invoke; every real caller
// supplies one, so an empty one here is a caller bug, not a runtime condition to
// recover from — enforce the contract rather than let it surface as an uncaught
// exception out of an extension action.
assert(recordBirth && "applyImportedBank: RecordBirth must not be empty");
if (!book.createBank(bankId, plan.bankDisplayName)) return false; if (!book.createBank(bankId, plan.bankDisplayName)) return false;
BankModel* index = book.index(bankId); BankModel* index = book.index(bankId);
for (const package::PlannedEntry& e : plan.entries) { for (const package::PlannedEntry& e : plan.entries) {
if (e.action != EntryAction::Land) continue; if (e.action != EntryAction::Land) continue;
index->add(e.sample); const AddResult added = index->add(e.sample);
// planImport already deduped Land entries by hash against an empty destination
// bank (this same freshly-created one), so a Collapsed add here would mean the
// plan and the book disagree — that would silently undercount reportSuccess's
// landedCount rather than fail loudly.
assert(added == AddResult::Added && "planImport's Land entries must not collapse");
(void)added;
// Unconditional on the add's outcome: the file exists either way, and an // Unconditional on the add's outcome: the file exists either way, and an
// unrecorded file is permanently unreclaimable. // unrecorded file is permanently unreclaimable.
recordBirth(e.sample); recordBirth(e.sample);
+10 -6
View File
@@ -15,18 +15,22 @@
namespace reasampler { namespace reasampler {
// How a landing ended. Every value but Landed means NOTHING is on disk and NO index // How a landing ended. Every value but Landed means NOTHING is on disk and NO index was
// was touched — the two refuse-whole failures (TooNew, Malformed) before a byte is // touched. NoProject/Unreadable/Malformed/TooNew refuse before a byte is written.
// written, the other two after a rollback. // IntegrityFailed also refuses before any write — the full-package digest verification
// runs to completion first (landPackage) — so it needs no rollback either. WriteFailed
// is the only outcome that actually wrote and then rolled back. IndexRejected is never
// returned by landPackage/this struct — it is import_bank's own outcome, minted after a
// successful landing when the book itself refuses the create.
enum class ImportOutcome { enum class ImportOutcome {
Landed, Landed,
NoProject, // unsaved project: there is no bank folder to land into NoProject, // unsaved project: there is no bank folder to land into
Unreadable, // the package file could not be opened Unreadable, // the package file could not be opened
Malformed, // not a well-formed RSBK: corrupt, truncated, or trailing garbage Malformed, // not a well-formed RSBK: corrupt, truncated, or trailing garbage
TooNew, // minReaderVersion above this build's ladder TooNew, // minReaderVersion above this build's ladder
IntegrityFailed, // an entry's payload did not match its recorded digest IntegrityFailed, // an entry's payload did not match its recorded digest; pre-write refusal
WriteFailed, // a write failed partway; the landed files were rolled back WriteFailed, // a write failed partway; the landed files were rolled back
IndexRejected, // the book refused the bank the plan minted a free name for IndexRejected, // never set here — see the comment above; import_bank's outcome only
}; };
struct ImportLanding { struct ImportLanding {
@@ -36,7 +40,7 @@ struct ImportLanding {
package::PackageHeader header; package::PackageHeader header;
package::ImportPlan plan; package::ImportPlan plan;
std::string failedEntryName; // IntegrityFailed / WriteFailed std::string failedEntryName; // IntegrityFailed / WriteFailed
RollbackResult rollback; // IntegrityFailed / WriteFailed RollbackResult rollback; // WriteFailed only — IntegrityFailed leaves it default
}; };
// Streams `packageAbsPath` into the project's bank folder: decode, plan, verify EVERY // Streams `packageAbsPath` into the project's bank folder: decode, plan, verify EVERY
+8 -1
View File
@@ -284,7 +284,14 @@ void showTabMenu(int screenX, int screenY, const std::string& bankId) {
// Always a NEW bank, never a merge into the right-clicked one — the row sits // Always a NEW bank, never a merge into the right-clicked one — the row sits
// here because this is the panel's bank menu, not because it targets this bank. // here because this is the panel's bank menu, not because it targets this bank.
case kMenuImportPackage: case kMenuImportPackage:
if (g_panel.session) doImportBankPackage(*g_panel.session); if (g_panel.session) {
const std::string id = doImportBankPackage(*g_panel.session);
if (!id.empty()) { // landed — show the freshly-imported bank
g_panel.shownBankId = id;
g_panel.focusedRegion = Region::Banks;
invalidatePanel();
}
}
break; break;
default: break; default: break;
} }
+17 -1
View File
@@ -16,6 +16,9 @@
#include "shell/panel/draw_kit.h" #include "shell/panel/draw_kit.h"
#include "shell/actions/ingest.h" #include "shell/actions/ingest.h"
#include "shell/actions/package_import_action.h" #include "shell/actions/package_import_action.h"
#include "core/package/import_plan.h"
#include "core/version/app_version.h"
#include "shell/persist/session.h" // ReaSamplerSession::ledgerStatus() — panel_state.h only forward-declares it
#ifdef _WIN32 #ifdef _WIN32
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux) #include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)
@@ -29,6 +32,7 @@
#define REAPERAPI_WANT_DockWindowActivate #define REAPERAPI_WANT_DockWindowActivate
#define REAPERAPI_WANT_DockWindowRemove #define REAPERAPI_WANT_DockWindowRemove
#define REAPERAPI_WANT_GetMainHwnd #define REAPERAPI_WANT_GetMainHwnd
#define REAPERAPI_WANT_ShowConsoleMsg
#include "reaper_plugin_functions.h" #include "reaper_plugin_functions.h"
// main.cpp owns the module instance handle. // main.cpp owns the module instance handle.
@@ -72,9 +76,21 @@ void handleDropFiles(HDROP hDrop) {
else paths.push_back(std::move(p)); else paths.push_back(std::move(p));
} }
DragFinish(hDrop); DragFinish(hDrop);
if (g_panel.session) if (g_panel.session && !packages.empty()) {
// One refusal block for the whole drop, not one per dropped .rsbank: the gate
// decision is the same for all N (session state does not change mid-drop), so
// checking it here first avoids doImportBankPackageFile's own per-file gate
// check printing the identical console block N times.
const package::LedgerRefusal refusal =
package::importLedgerRefusal(g_panel.session->ledgerStatus());
if (refusal != package::LedgerRefusal::None) {
ShowConsoleMsg(
package::ledgerRefusalMessage(refusal, version::extStateNamespace()).c_str());
} else {
for (const std::string& pkg : packages) for (const std::string& pkg : packages)
doImportBankPackageFile(*g_panel.session, pkg); doImportBankPackageFile(*g_panel.session, pkg);
}
}
if (!paths.empty()) ingestDroppedFiles(paths); if (!paths.empty()) ingestDroppedFiles(paths);
} }
+7
View File
@@ -98,6 +98,13 @@ public:
// the records. That is not a hole in the pairing rule above: the rule exists so an // the records. That is not a hole in the pairing rule above: the rule exists so an
// absent record is never read as a definite answer, and this exposes strictly less // absent record is never read as a definite answer, and this exposes strictly less
// than the pair. The package import gates on it before it opens a file picker. // than the pair. The package import gates on it before it opens a file picker.
//
// A tradeoff, not the only route: `pruneDryRun()` already exposes the same degraded
// pair via `PruneReport::ledgerUnreadable`/`ledgerFutureVersion`, with no new
// accessor needed. Rejected because that route is genuinely worse for a gate: it
// drags a full bank-folder enumeration and every live instance's FX scan onto a
// check that only needs to know "can I write a record", and it shapes an import
// decision as an answer borrowed from prune's report rather than the session's own.
tracking::LedgerStatus ledgerStatus() const { return trackingStatus_; } tracking::LedgerStatus ledgerStatus() const { return trackingStatus_; }
// The version that last wrote the active project: PreVersioning (no // The version that last wrote the active project: PreVersioning (no
+3 -1
View File
@@ -372,7 +372,9 @@ static void testReimportingIntoTheSourceProjectLandsBesideAnUntouchedOriginal()
CHECK(third.landing.outcome == ImportOutcome::Landed); CHECK(third.landing.outcome == ImportOutcome::Landed);
CHECK(book.bank("bank-b3")->displayName == "B 3"); CHECK(book.bank("bank-b3")->displayName == "B 3");
CHECK(*book.index("bank-b") == originalB); CHECK(*book.index("bank-b") == originalB);
// Six distinct files: the original two plus two per re-import, never overwritten. // Four distinct files: two per re-import, never overwritten. Bank "B"'s own two
// entries were seeded index-only above (book.index("bank-b")->add), never written
// to disk, so they don't add to this count.
CHECK(scratch.bankFiles().size() == 4); CHECK(scratch.bankFiles().size() == 4);
} }
+56 -2
View File
@@ -202,7 +202,8 @@ static void testAFreeBankLegalNameIsKept() {
{"unrelated.wav"}, kTag); {"unrelated.wav"}, kTag);
CHECK(landed(plan, 0).destFileName == "kick.wav"); CHECK(landed(plan, 0).destFileName == "kick.wav");
CHECK(!landed(plan, 0).renamed); CHECK(!landed(plan, 0).renamed);
CHECK(plan.renameCount == 0); CHECK(plan.collisionRenameCount == 0);
CHECK(plan.sanitizeRenameCount == 0);
CHECK(landed(plan, 0).sample.relativePath == "reasampler_bank/kick.wav"); CHECK(landed(plan, 0).sample.relativePath == "reasampler_bank/kick.wav");
} }
@@ -212,7 +213,9 @@ static void testATakenNameIsMintedFreshAndNeverOverwritten() {
{"kick.wav"}, kTag); {"kick.wav"}, kTag);
CHECK(landed(plan, 0).destFileName != "kick.wav"); CHECK(landed(plan, 0).destFileName != "kick.wav");
CHECK(landed(plan, 0).renamed); CHECK(landed(plan, 0).renamed);
CHECK(plan.renameCount == 1); // A genuine folder-name collision, not a spelling mint.
CHECK(plan.collisionRenameCount == 1);
CHECK(plan.sanitizeRenameCount == 0);
CHECK(landed(plan, 0).sample.relativePath == CHECK(landed(plan, 0).sample.relativePath ==
"reasampler_bank/" + landed(plan, 0).destFileName); "reasampler_bank/" + landed(plan, 0).destFileName);
} }
@@ -224,6 +227,9 @@ static void testTheFolderNameCheckFoldsAsciiCase() {
{"KICK.WAV"}, kTag); {"KICK.WAV"}, kTag);
CHECK(landed(plan, 0).destFileName != "kick.wav"); CHECK(landed(plan, 0).destFileName != "kick.wav");
CHECK(landed(plan, 0).renamed); CHECK(landed(plan, 0).renamed);
// The case-fold hit is still a collision, not a spelling mint.
CHECK(plan.collisionRenameCount == 1);
CHECK(plan.sanitizeRenameCount == 0);
} }
static void testTwoEntriesNeverLandOnOneName() { static void testTwoEntriesNeverLandOnOneName() {
@@ -242,6 +248,9 @@ static void testAnUnsanitaryNameIsMintedRatherThanLandedVerbatim() {
bookWithBanks({}), kProjectDir, {}, kTag); bookWithBanks({}), kProjectDir, {}, kTag);
CHECK(landed(plan, 0).destFileName.find(' ') == std::string::npos); CHECK(landed(plan, 0).destFileName.find(' ') == std::string::npos);
CHECK(landed(plan, 0).renamed); CHECK(landed(plan, 0).renamed);
// No collision here (the bank folder is empty) — this is a sanitize mint.
CHECK(plan.sanitizeRenameCount == 1);
CHECK(plan.collisionRenameCount == 0);
} }
// --- content hash (collision class 3) ---------------------------------------- // --- content hash (collision class 3) ----------------------------------------
@@ -334,6 +343,46 @@ static void testAnUndecodableUsageKeyBlocksPruneButNotImport() {
CHECK(importLedgerRefusal(tracking::LedgerStatus::Loaded) == LedgerRefusal::None); CHECK(importLedgerRefusal(tracking::LedgerStatus::Loaded) == LedgerRefusal::None);
} }
// Pins the delegation itself: importLedgerRefusal must refuse EXACTLY the statuses
// ledgerDegraded() calls degraded, over every value the enum has today. A gate that
// re-derived its own notion of "degraded" could silently diverge from this the moment
// either side changes without the other.
static void testImportLedgerRefusalDelegatesToLedgerDegraded() {
const tracking::LedgerStatus all[] = {
tracking::LedgerStatus::Fresh,
tracking::LedgerStatus::Loaded,
tracking::LedgerStatus::Unreadable,
tracking::LedgerStatus::FutureVersion,
};
for (tracking::LedgerStatus s : all)
CHECK((importLedgerRefusal(s) != LedgerRefusal::None) == tracking::ledgerDegraded(s));
}
// --- the ledger-refusal message (pure, so both channels are assertable without a DAW) --
static void testLedgerRefusalMessageIsEmptyForNone() {
CHECK(ledgerRefusalMessage(LedgerRefusal::None, "reasampler").empty());
}
static void testLedgerRefusalMessageNamesTheChannelCorrectNamespace() {
// The two real namespaces (app_version.h): stable "reasampler", beta "reasampler_beta".
const std::string stable = ledgerRefusalMessage(LedgerRefusal::Malformed, "reasampler");
CHECK(stable.find("\"reasampler\"") != std::string::npos);
CHECK(stable.find("reasampler_beta") == std::string::npos);
const std::string beta = ledgerRefusalMessage(LedgerRefusal::Malformed, "reasampler_beta");
CHECK(beta.find("\"reasampler_beta\"") != std::string::npos);
}
static void testLedgerRefusalMessageDistinguishesMalformedFromFutureVersion() {
const std::string malformed = ledgerRefusalMessage(LedgerRefusal::Malformed, "reasampler");
const std::string futureVersion =
ledgerRefusalMessage(LedgerRefusal::FutureVersion, "reasampler");
CHECK(malformed != futureVersion);
CHECK(malformed.find("malformed") != std::string::npos);
CHECK(futureVersion.find("NEWER version") != std::string::npos);
}
int main() { int main() {
testFreeSeedIsKeptVerbatim(); testFreeSeedIsKeptVerbatim();
testFoldedCollisionTakesTheFirstSuffix(); testFoldedCollisionTakesTheFirstSuffix();
@@ -364,6 +413,11 @@ int main() {
testDegradedLedgerStatusesRefuseAndTheUsableOnesDoNot(); testDegradedLedgerStatusesRefuseAndTheUsableOnesDoNot();
testAnUndecodableUsageKeyBlocksPruneButNotImport(); testAnUndecodableUsageKeyBlocksPruneButNotImport();
testImportLedgerRefusalDelegatesToLedgerDegraded();
testLedgerRefusalMessageIsEmptyForNone();
testLedgerRefusalMessageNamesTheChannelCorrectNamespace();
testLedgerRefusalMessageDistinguishesMalformedFromFutureVersion();
if (g_fail == 0) std::printf("import_plan: all tests passed\n"); if (g_fail == 0) std::printf("import_plan: all tests passed\n");
return g_fail == 0 ? 0 : 1; return g_fail == 0 ? 0 : 1;