Merge Phase R (Reclaim): prune path — pure core, dry-run shell, guarded deletion

This commit is contained in:
2026-07-26 19:21:15 -04:00
17 changed files with 1428 additions and 2 deletions
+32 -1
View File
@@ -214,6 +214,29 @@ target_link_libraries(bank_book PUBLIC bank_model)
add_library(owned_manifest STATIC src/owned_manifest.cpp) add_library(owned_manifest STATIC src/owned_manifest.cpp)
target_include_directories(owned_manifest PUBLIC src) target_include_directories(owned_manifest PUBLIC src)
# ---------------------------------------------------------------------------
# 2g''') Pure prune_reconcile library — NO REAPER, NO SWELL, NO filesystem. The
# Phase R (Reclaim) Wave-1 safety-critical decision: given the files present
# in the bank folder, the union of files referenced across every bank, and the
# owned-file manifest, compute the orphan set (owned ∩ present) referenced.
# Mirror of view_mode_model::reconcile one level down (files, not GUIDs). Small
# pure function; R2/R3 wrap the two ends (folder enumeration + deletion) in the
# shell. Standalone — depends only on the standard library.
# ---------------------------------------------------------------------------
add_library(prune_reconcile STATIC src/prune_reconcile.cpp)
target_include_directories(prune_reconcile PUBLIC src)
# ---------------------------------------------------------------------------
# 2g'''') Pure prune_button layout — NO REAPER, NO SWELL. The Phase R (Reclaim)
# Wave-3 (R3, fork R-E) prune-button geometry: footer rect -> right-anchored
# button rect (suppressed when the footer is too narrow), and point -> in/out
# hit-test. Split out so the button's placement + hit-test math is unit-tested
# outside the DAW; the bank_panel footer that draws it and dispatches the
# "Prune bank folder" command is DAW-verified. Mirror of mode_switch / tab_strip.
# ---------------------------------------------------------------------------
add_library(prune_button STATIC src/prune_button.cpp)
target_include_directories(prune_button PUBLIC src)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record # 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, # logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values,
@@ -338,6 +361,14 @@ add_executable(owned_manifest_tests tests/test_owned_manifest.cpp)
target_link_libraries(owned_manifest_tests PRIVATE owned_manifest) target_link_libraries(owned_manifest_tests PRIVATE owned_manifest)
add_test(NAME owned_manifest_tests COMMAND owned_manifest_tests) add_test(NAME owned_manifest_tests COMMAND owned_manifest_tests)
add_executable(prune_reconcile_tests tests/test_prune_reconcile.cpp)
target_link_libraries(prune_reconcile_tests PRIVATE prune_reconcile bank_book)
add_test(NAME prune_reconcile_tests COMMAND prune_reconcile_tests)
add_executable(prune_button_tests tests/test_prune_button.cpp)
target_link_libraries(prune_button_tests PRIVATE prune_button)
add_test(NAME prune_button_tests COMMAND prune_button_tests)
add_executable(app_version_tests tests/test_app_version.cpp) add_executable(app_version_tests tests/test_app_version.cpp)
target_link_libraries(app_version_tests PRIVATE app_version) target_link_libraries(app_version_tests PRIVATE app_version)
add_test(NAME app_version_tests COMMAND app_version_tests) add_test(NAME app_version_tests COMMAND app_version_tests)
@@ -386,7 +417,7 @@ add_library(reaper_reasampler MODULE
src/bank_book.cpp src/bank_book.cpp
src/owned_manifest.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 owned_manifest app_version provenance) 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 prune_reconcile prune_button app_version provenance)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or
# "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels'
+76
View File
@@ -451,6 +451,10 @@ constexpr const char* kIdBankCopySel = "BANK_COPY_SELECTED";
constexpr const char* kIdBankRemoveSel = "BANK_REMOVE_SELECTED"; constexpr const char* kIdBankRemoveSel = "BANK_REMOVE_SELECTED";
constexpr const char* kIdBankPoolFull = "BANK_POOL_FULLHEIGHT"; constexpr const char* kIdBankPoolFull = "BANK_POOL_FULLHEIGHT";
constexpr const char* kIdBankBanksFull = "BANK_BANKS_FULLHEIGHT"; constexpr const char* kIdBankBanksFull = "BANK_BANKS_FULLHEIGHT";
// Phase R (Reclaim), R2: the FOREVER-STABLE "Prune bank folder" id. Registered NOW so
// in-DAW dry-run verification is possible; R2 behaviour is REPORT-ONLY (no deletion),
// and R3 extends the confirm-and-delete step behind this SAME id — never a throwaway id.
constexpr const char* kIdBankPruneFolder = "BANK_PRUNE_FOLDER";
int g_cmdBankCreate = 0; int g_cmdBankCreate = 0;
int g_cmdBankRename = 0; int g_cmdBankRename = 0;
@@ -463,6 +467,7 @@ int g_cmdBankCopySel = 0;
int g_cmdBankRemoveSel = 0; int g_cmdBankRemoveSel = 0;
int g_cmdBankPoolFull = 0; int g_cmdBankPoolFull = 0;
int g_cmdBankBanksFull = 0; int g_cmdBankBanksFull = 0;
int g_cmdBankPruneFolder = 0;
gaccel_register_t g_accelBankCreate{}; gaccel_register_t g_accelBankCreate{};
gaccel_register_t g_accelBankRename{}; gaccel_register_t g_accelBankRename{};
@@ -475,6 +480,7 @@ gaccel_register_t g_accelBankCopySel{};
gaccel_register_t g_accelBankRemoveSel{}; gaccel_register_t g_accelBankRemoveSel{};
gaccel_register_t g_accelBankPoolFull{}; gaccel_register_t g_accelBankPoolFull{};
gaccel_register_t g_accelBankBanksFull{}; gaccel_register_t g_accelBankBanksFull{};
gaccel_register_t g_accelBankPruneFolder{};
// Persists the book after a bank mutation. Mirrors the CAPTURE path (main.cpp // Persists the book after a bank mutation. Mirrors the CAPTURE path (main.cpp
// RunCapture), NOT the Design-View path: a bank change is held in-session and written // RunCapture), NOT the Design-View path: a bank change is held in-session and written
@@ -791,6 +797,67 @@ void doBankRemoveSelected() {
if (removed > 0) persistBankOp("ReaSampler: remove sample(s)"); if (removed > 0) persistBankOp("ReaSampler: remove sample(s)");
} }
// Prune bank folder — Phase R (Reclaim), R3: the guarded DESTRUCTIVE step, and the SOLE
// file-deletion entry in ReaSampler. Dry-run FIRST (compute the orphan set, read-only),
// then — only when orphans exist — a blocking CONFIRM showing the SPECIFIC manifest
// (count + reclaimable bytes + the file list, truncated consistent with the 64-cap), then
// on explicit Yes delete EXACTLY that set (session->pruneReclaim, which recomputes the
// pure core fresh and deletes confirmed ∩ freshOrphans — trash-preferred, unlink fallback).
// Zero orphans => informational only, NO confirm ever shown. Cancel deletes nothing.
//
// The full (untruncated) orphan set is captured here for the delete; the dry-run's
// truncated list is only the confirm's readout. No ext-state is written and no undo point
// is opened (file deletion is not REAPER-undoable and pruneReclaim mutates no project
// state) — a Ctrl-Z after a prune correctly cannot claim to restore deleted files.
void doBankPruneFolder() {
const PruneReport report = g_session->pruneDryRun();
if (report.count == 0) {
ShowConsoleMsg("ReaSampler prune: no orphaned files to reclaim.\n");
return;
}
// The EXACT set the delete will target — full, untruncated, so what the confirm
// summarises (count + bytes) matches what pruneReclaim reclaims. Captured before the
// confirm so the confirm and the delete reason about the same enumeration.
const std::vector<std::string> orphanSet = g_session->pruneOrphanSet();
// Confirm-with-manifest: count + bytes exact; the file list is the dry-run's 64-capped
// list (the same clip the R2 readout used), with a "N more not shown" tail when clipped.
std::string msg =
"ReaSampler prune will PERMANENTLY reclaim " + std::to_string(report.count) +
" orphaned file(s), freeing " + std::to_string(report.totalBytes) + " bytes.\n\n"
"These files are no longer referenced by any bank and were created by ReaSampler.\n"
"They will be moved to the Recycle Bin on Windows (recoverable), or deleted on "
"other platforms.\n\n";
for (const std::string& rel : report.orphans) msg += " " + rel + "\n";
if (report.truncated) {
msg += " ... (" + std::to_string(report.count - report.orphans.size()) +
" more not shown)\n";
}
msg += "\nReclaim these files now?";
const int r = ShowMessageBox(msg.c_str(), "ReaSampler: prune bank folder", 4);
if (r != 6) { // 6 == YES; anything else cancels -> delete NOTHING (SDK ~6544)
ShowConsoleMsg("ReaSampler prune: cancelled -- nothing deleted.\n");
return;
}
// Confirmed -> delete exactly the confirmed set (recomputed fresh, stale entries skipped).
const PruneDeletionResult del = g_session->pruneReclaim(orphanSet);
std::string done = "ReaSampler prune: reclaimed " +
std::to_string(del.reclaimedCount) + " file(s), " +
std::to_string(del.reclaimedBytes) + " bytes" +
(del.usedTrash ? " (to Recycle Bin)" : " (deleted)") + ".";
if (del.skippedCount > 0) {
done += " " + std::to_string(del.skippedCount) +
" file(s) skipped (locked, or changed since the report).";
}
done += "\n";
ShowConsoleMsg(done.c_str());
}
} // namespace } // namespace
// Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) — // Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) —
@@ -851,6 +918,10 @@ void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session)
"toggle pool full-height"); "toggle pool full-height");
g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull, g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull,
"toggle banks full-height"); "toggle banks full-height");
// Phase R, R2: the "Prune bank folder" action (report-only in this wave; R3 extends
// the confirm-and-delete step behind this SAME forever-stable id).
g_cmdBankPruneFolder = registerAction(rec, kIdBankPruneFolder, g_accelBankPruneFolder,
"prune bank folder");
} }
bool bankHandleCommand(int command) { bool bankHandleCommand(int command) {
@@ -867,13 +938,18 @@ bool bankHandleCommand(int command) {
if (command == g_cmdBankRemoveSel) { doBankRemoveSelected(); return true; } if (command == g_cmdBankRemoveSel) { doBankRemoveSelected(); return true; }
if (command == g_cmdBankPoolFull) { bankPanelToggledPoolFullHeight(); return true; } if (command == g_cmdBankPoolFull) { bankPanelToggledPoolFullHeight(); return true; }
if (command == g_cmdBankBanksFull) { bankPanelToggledBanksFullHeight(); return true; } if (command == g_cmdBankBanksFull) { bankPanelToggledBanksFullHeight(); return true; }
if (command == g_cmdBankPruneFolder) { doBankPruneFolder(); return true; }
return false; // not ours — caller's hookcommand keeps looking return false; // not ours — caller's hookcommand keeps looking
} }
int bankPruneCommandId() { return g_cmdBankPruneFolder; }
void bankUnregisterActions(reaper_plugin_info_t* rec) { void bankUnregisterActions(reaper_plugin_info_t* rec) {
// Mirror-unregister with '-'-prefixed strings, reverse of registration order. Each // Mirror-unregister with '-'-prefixed strings, reverse of registration order. Each
// '-command_id' re-presents the same interned channel-qualified id (channelIdFor). // '-command_id' re-presents the same interned channel-qualified id (channelIdFor).
rec->Register("-gaccel", (void*)&g_accelBankPruneFolder);
rec->Register("-command_id", (void*)channelIdFor(kIdBankPruneFolder));
rec->Register("-gaccel", (void*)&g_accelBankBanksFull); rec->Register("-gaccel", (void*)&g_accelBankBanksFull);
rec->Register("-command_id", (void*)channelIdFor(kIdBankBanksFull)); rec->Register("-command_id", (void*)channelIdFor(kIdBankBanksFull));
rec->Register("-gaccel", (void*)&g_accelBankPoolFull); rec->Register("-gaccel", (void*)&g_accelBankPoolFull);
+6
View File
@@ -66,6 +66,12 @@ bool bankHandleCommand(int command);
// rec==nullptr (before g_session is torn down). // rec==nullptr (before g_session is torn down).
void bankUnregisterActions(reaper_plugin_info_t* rec); void bankUnregisterActions(reaper_plugin_info_t* rec);
// The registered command id for the "Prune bank folder" action (Phase R, R3), or 0 before
// registration. The bank_panel prune button fires the action THROUGH this id via
// Main_OnCommand (fork R-E: the button dispatches the command, it does not call the session
// directly) so the panel affordance and the bindable action share one guarded code path.
int bankPruneCommandId();
// Persists a completed bank-index verb as a single REAPER undo point (R-B). // Persists a completed bank-index verb as a single REAPER undo point (R-B).
// Wraps persistBook() (= SetProjExtState) in a Begin/End block with UNDO_STATE_MISCCFG // Wraps persistBook() (= SetProjExtState) in a Begin/End block with UNDO_STATE_MISCCFG
// so the bank op is one Ctrl-Z. On an unsaved / no-active project persistBook() no-ops // so the bank op is one Ctrl-Z. On an unsaved / no-active project persistBook() no-ops
+17
View File
@@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <climits> #include <climits>
#include <unordered_set>
// bank_book implementation. // bank_book implementation.
// //
@@ -321,6 +322,22 @@ bool BankBook::hashReferencedElsewhere(const std::string& hash,
return false; return false;
} }
std::vector<std::string> BankBook::referencedPaths() const {
// Union across the whole book (pool first, then named banks in ordinal order —
// banks_ is kept ordinal-sorted). De-duplicate by exact string so a file a copy
// put in two banks appears once. Skip empty paths (they reference no file).
std::vector<std::string> paths;
std::unordered_set<std::string> seen;
for (const auto& b : banks_) {
for (const auto& s : b.index.all()) {
if (s.relativePath.empty()) continue;
if (seen.insert(s.relativePath).second)
paths.push_back(s.relativePath);
}
}
return paths;
}
// =========================================================================== // ===========================================================================
// JSON — writer // JSON — writer
// =========================================================================== // ===========================================================================
+11
View File
@@ -220,6 +220,17 @@ public:
bool hashReferencedElsewhere(const std::string& hash, bool hashReferencedElsewhere(const std::string& hash,
const std::string& exceptBankId) const; const std::string& exceptBankId) const;
// Every project-relative file path referenced by ANY bank in the book, pool
// included — the union across the whole book (Phase R, prune). This is the
// safety-critical referenced-set the prune core subtracts: a file referenced by
// any bank (INCLUDING via a copy into a second bank) appears here, so prune never
// reclaims it. Paths are returned VERBATIM (Sample.relativePath, exact strings —
// no normalization), first-seen order across banks in ordinal order then sample
// insertion order, and DE-DUPLICATED (one file referenced by N banks appears
// once). An empty relativePath is skipped (it references no file). Additive
// read-only query; adds no mutation and no coupling to Phase R.
std::vector<std::string> referencedPaths() const;
// -- Query --------------------------------------------------------------- // -- Query ---------------------------------------------------------------
// The bank with `id`, or nullptr. Pointer invalidated by any mutating call. // The bank with `id`, or nullptr. Pointer invalidated by any mutating call.
+60 -1
View File
@@ -57,6 +57,7 @@
#include "mode_switch.h" #include "mode_switch.h"
#include "peaks.h" #include "peaks.h"
#include "persist.h" #include "persist.h"
#include "prune_button.h" // footer prune-button layout + hit-test (pure, R3)
#include "tab_strip.h" #include "tab_strip.h"
#include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure) #include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure)
#include "track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) #include "track_guid.h" // guidString — canonical track GUID key (D2 Wave 2)
@@ -102,6 +103,7 @@
#define REAPERAPI_WANT_StopPreview #define REAPERAPI_WANT_StopPreview
#define REAPERAPI_WANT_GetUserInputs #define REAPERAPI_WANT_GetUserInputs
#define REAPERAPI_WANT_ShowMessageBox #define REAPERAPI_WANT_ShowMessageBox
#define REAPERAPI_WANT_Main_OnCommand // fire the prune action by command id (R3 button)
#define REAPERAPI_WANT_genGuid #define REAPERAPI_WANT_genGuid
#define REAPERAPI_WANT_guidToString #define REAPERAPI_WANT_guidToString
#include "reaper_plugin_functions.h" #include "reaper_plugin_functions.h"
@@ -152,6 +154,13 @@ const COLORREF kRgbFooterText = RGB(190, 205, 198);
// not an interactive control, so it recedes visually (V3 unobtrusive placement). // not an interactive control, so it recedes visually (V3 unobtrusive placement).
const COLORREF kRgbFooterVersion = RGB(120, 128, 124); const COLORREF kRgbFooterVersion = RGB(120, 128, 124);
// Prune button (R3): a raised control in the footer that fires the "Prune bank folder"
// action. A muted warm tone so it reads as a distinct-but-not-alarming affordance (the
// destructive confirm lives behind it, not on the button itself).
const LICE_pixel kColPruneBtnBg = LICE_RGBA(62, 46, 42, 255);
const LICE_pixel kColPruneBtnBorder = LICE_RGBA(96, 72, 66, 255);
const COLORREF kRgbPruneBtnText = RGB(210, 188, 180);
// --- Vertical split + region headers + tab strip (Phase B4) ------------------- // --- Vertical split + region headers + tab strip (Phase B4) -------------------
// //
// The client area, top to bottom: mode-switch header (kHeaderHeight) | split body | // The client area, top to bottom: mode-switch header (kHeaderHeight) | split body |
@@ -547,13 +556,48 @@ void drawTailFooter(LICE_IBitmap* bmp, int w, int h) {
// matches the left inset; DT_RIGHT keeps it clear of the left-aligned tail label // matches the left inset; DT_RIGHT keeps it clear of the left-aligned tail label
// (the two never overlap at normal panel widths — the label is short, the readout is // (the two never overlap at normal panel widths — the label is short, the readout is
// ~10 chars, and DT_END_ELLIPSIS on both degrades gracefully if a panel is ever tiny). // ~10 chars, and DT_END_ELLIPSIS on both degrades gracefully if a panel is ever tiny).
// COUPLED TO PruneButtonSpec::rightInset (prune_button.h): the prune button is
// right-anchored at footer.right - 84, placing its right edge 76 px left of this
// readout's right margin. If this inset (currently 8) changes, update rightInset there.
RECT vrc = f; RECT vrc = f;
vrc.right -= 8; vrc.right -= 8; // COUPLED: PruneButtonSpec::rightInset in prune_button.h is 84
SetTextColor(dc, kRgbFooterVersion); SetTextColor(dc, kRgbFooterVersion);
DrawText(dc, reasampler::appVersion().c_str(), -1, &vrc, DrawText(dc, reasampler::appVersion().c_str(), -1, &vrc,
DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
} }
// The prune button's rect within the footer, derived from the client size. SINGLE source
// of truth for both draw and hit-test (they never drift). Empty (button.empty()) when the
// footer is degenerate or too narrow to place the button clear of the tail label — the
// action stays reachable via its bindable command, so a suppressed button is graceful.
// Clearance from the version readout: PruneButtonSpec::rightInset (84) places the button
// right edge 76 px left of the readout's 8 px right margin — see coupling comments in
// prune_button.h and drawTailFooter above.
ButtonRect pruneButtonRectFor(int w, int h) {
const RECT f = panelFooter(w, h);
if (f.top >= f.bottom) return ButtonRect{}; // degenerate footer -> no button
const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top};
return computePruneButton(footer, PruneButtonSpec{});
}
// Draws the prune button into the footer (called after drawTailFooter fills the strip).
// No-op when the button is suppressed (footer too narrow). READ-ONLY: draws only.
void drawPruneButton(LICE_IBitmap* bmp, int w, int h) {
const ButtonRect b = pruneButtonRectFor(w, h);
if (b.empty()) return;
LICE_FillRect(bmp, b.x, b.y, b.width, b.height, kColPruneBtnBg, 1.0f, 0);
LICE_DrawRect(bmp, b.x, b.y, b.width, b.height, kColPruneBtnBorder, 1.0f, 0);
HDC dc = bmp->getDC();
if (!dc) return;
RECT rc{b.x, b.y, b.x + b.width, b.y + b.height};
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, kRgbPruneBtnText);
DrawText(dc, "Prune", -1, &rc,
DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
}
// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. // True iff client-relative (x, y) falls inside the (non-degenerate) footer strip.
// Shared by the footer click (cycle mode) and the scroll-wheel (Manual fine-adjust) // Shared by the footer click (cycle mode) and the scroll-wheel (Manual fine-adjust)
// so both agree on the hit target. // so both agree on the hit target.
@@ -902,6 +946,7 @@ void paintPanel(HWND hwnd, HDC hdc) {
drawModeSwitch(&bmp, w); drawModeSwitch(&bmp, w);
drawTailFooter(&bmp, w, h); drawTailFooter(&bmp, w, h);
drawPruneButton(&bmp, w, h); // R3: raised over the footer strip
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
} }
@@ -1596,6 +1641,20 @@ void handleClick(int x, int y) {
} }
} }
// Prune button (R3): checked BEFORE the footer's tail-cycle so a click on the button
// fires prune, not a tail cycle. Fires the "Prune bank folder" action THROUGH its
// registered command id (fork R-E: dispatch the command, do not call the session
// directly), so the panel affordance and the bindable action share the one guarded
// dry-run/confirm/delete path in doBankPruneFolder. A 0 id (pre-registration) no-ops.
{
const ButtonRect pb = pruneButtonRectFor(w, h);
if (hitTestPruneButton(x, y, pb)) {
const int cmd = bankPruneCommandId();
if (cmd != 0) Main_OnCommand(cmd, 0);
return;
}
}
// Tail footer: a click anywhere in the bottom strip cycles the tail mode // Tail footer: a click anywhere in the bottom strip cycles the tail mode
// (None -> Auto -> Manual -> None) and repaints. It mutates the SESSION's tail // (None -> Auto -> Manual -> None) and repaints. It mutates the SESSION's tail
// setting (which the capture actions read and persist saves with the project) and // setting (which the capture actions read and persist saves with the project) and
+7
View File
@@ -191,6 +191,13 @@ BankPaths deriveBankPaths(const std::string& projectDir,
return p; return p;
} }
std::string bankRelativeForName(const std::string& fileName) {
if (fileName.empty()) return {};
// The SAME expression deriveBankPaths uses for relativePath, kept in one place so
// the two spellings can never drift (Phase R spelling-consistency invariant).
return std::string(kBankSubfolder) + "/" + fileName;
}
std::string resolveBankFile(const std::string& projectDir, std::string resolveBankFile(const std::string& projectDir,
const std::string& relativePath) { const std::string& relativePath) {
// No default-location fallback (CLAUDE.md invariant): an empty project dir or // No default-location fallback (CLAUDE.md invariant): an empty project dir or
+12
View File
@@ -92,6 +92,18 @@ BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName, const std::string& baseName,
const std::string& uniqueTag); const std::string& uniqueTag);
// The project-relative index spelling for a bank file KNOWN ONLY by its file name —
// the forward derivation the Phase R prune shell uses to spell an ENUMERATED folder
// entry the SAME way deriveBankPaths spelled it at capture time. By construction it
// is the identical expression deriveBankPaths().relativePath uses (kBankSubfolder +
// "/" + fileName), so a file the capture path created and a directory listing of that
// same file resolve to the byte-identical relative string — the safety-critical
// spelling-consistency the prune core's exact-string match depends on (a divergence
// here could make a referenced file look like an orphan). fileName is a bare entry
// name (no directory component); the caller supplies forward-slash-free names from the
// folder enumeration. Empty in -> empty out.
std::string bankRelativeForName(const std::string& fileName);
// --- Persist-side path arithmetic (M4) -------------------------------------- // --- Persist-side path arithmetic (M4) --------------------------------------
// //
// The index stores relative paths only; on project load the persist shell must // The index stores relative paths only; on project load the persist shell must
+227
View File
@@ -77,10 +77,25 @@
#include <filesystem> #include <filesystem>
#include <string> #include <string>
#include <system_error>
#include <unordered_map>
#include <unordered_set>
#include <vector> #include <vector>
// Move-to-trash surface (fork R-C, trash-preferred). On Windows the Recycle Bin is
// reached via SHFileOperationW + FOF_ALLOWUNDO (verified against the Windows SDK
// shellapi.h: SHFILEOPSTRUCTW { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... },
// FO_DELETE=0x3, FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on the SWELL
// (macOS/Linux) side of this codebase, so those platforms fall back to unlink behind the
// R3 dry-run/confirm guardrail — see deleteOrphanFile below for the per-platform routing.
#ifdef _WIN32
#include <windows.h>
#include <shellapi.h>
#endif
#include "app_version.h" #include "app_version.h"
#include "capture_paths.h" #include "capture_paths.h"
#include "prune_reconcile.h"
#define REAPERAPI_MINIMAL #define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_EnumProjects
@@ -243,6 +258,218 @@ bool ReaSamplerSession::saveToActiveProject() {
namespace { namespace {
// The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always
// exact (tallied over the full orphan set), but the enumerated file list handed to the
// console is clipped to this many entries so a project with thousands of orphans does
// not flood the report. PruneReport::truncated flags the clip. R3's confirm surface can
// choose its own presentation; this is purely the Wave-2 dry-run readout ceiling.
constexpr std::size_t kPruneListDisplayCap = 64;
// A fresh enumerate + pure-core prune compute for the active project. Shared by the
// dry-run report (pruneDryRun), the full-set query (pruneOrphanSet), and the deletion
// (pruneReclaim) so all three agree on ONE resolution + enumeration + set-algebra path
// (no divergence between what is shown and what is deleted). REAPER-facing (resolves the
// active project, enumerates the folder) but writes nothing.
//
// * bankDirAbs — the resolved CURRENT bank folder (absolute, forward-slashed). Empty
// when there is no active/saved project, no project dir, or no folder on
// disk yet -> the caller treats an empty dir as "nothing to reclaim".
// * orphans — the FULL orphan set (owned ∩ present) referenced, in enumeration
// order, untruncated. The pure core decides; this only supplies inputs.
// * sizeByRel — per-orphan-relative on-disk byte size (0 when it could not be stat'd).
struct PruneScan {
std::string bankDirAbs;
std::vector<std::string> orphans;
std::unordered_map<std::string, std::uint64_t> sizeByRel;
};
// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem
// call below uses an error_code form so no std::filesystem_error crosses REAPER's C ABI.
PruneScan scanPruneOrphans(const BankBook& book, const OwnedFileManifest& owned) {
PruneScan scan;
std::string rppPath;
void* proj = readActiveProject(rppPath);
if (!proj || rppPath.empty()) return scan; // no active/saved project -> empty scan
// Resolve the CURRENT bank folder the same way the index does (M4): project dir of
// the live .rpp + the fixed bank subfolder. Never a stored absolute path, so a
// Save-As relocation is followed automatically. resolveBankFile is the shared M4
// arithmetic; feeding it the bank subfolder as the "relative path" yields the folder.
const std::string projectDir = projectDirOf(rppPath);
const std::string bankDir = resolveBankFile(projectDir, kBankSubfolder);
if (bankDir.empty()) return scan; // unresolvable (no project dir) -> empty scan
std::error_code ec;
if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) {
return scan; // no bank folder captured yet -> nothing to reclaim
}
// Enumerate the folder into project-relative index-spelled paths, spelled the SAME
// way the capture path spelled them (bankRelativeForName == deriveBankPaths's
// convention) so the pure core's exact-string match lines up with referencedPaths()
// and the manifest. Non-recursive: the bank folder is flat (capture writes files
// directly here); skip any subdirectory. Size is stat'd here and cached by relative
// path so the report's byte tally reuses the same on-disk read.
// Manual iterator form (it.increment(ec)) keeps the loop non-throwing: a mid-iteration
// failure (file removed, permission flip) breaks out with a best-effort partial list
// rather than propagating std::filesystem_error across REAPER's C ABI.
std::vector<std::string> present;
fs::directory_iterator it(bankDir, ec);
for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) {
const auto& entry = *it;
std::error_code reg_ec;
if (!entry.is_regular_file(reg_ec)) continue; // skip subdirs / specials
const std::string name = entry.path().filename().string();
const std::string rel = bankRelativeForName(name);
if (rel.empty()) continue;
present.push_back(rel);
std::error_code sz_ec;
const std::uintmax_t sz = entry.file_size(sz_ec);
scan.sizeByRel[rel] = sz_ec ? 0 : static_cast<std::uint64_t>(sz);
}
// The decision lives in the pure core — read-only inputs from the book and manifest.
// referencedPaths() unions across the whole book (pool included); owned().paths() is
// the manifest set. This shell only enumerates, resolves, and stats.
scan.bankDirAbs = bankDir;
scan.orphans = pruneOrphans(present, book.referencedPaths(), owned.paths());
return scan;
}
} // namespace
PruneReport ReaSamplerSession::pruneDryRun() const {
const PruneScan scan = scanPruneOrphans(book_, owned_);
// buildPruneReport tallies count / byte-sum / display-truncation — no report logic
// re-implemented here. An empty scan (no project / no folder) yields a zero report.
return buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
}
std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
return scanPruneOrphans(book_, owned_).orphans; // FULL set, untruncated
}
namespace {
// Deletes ONE orphan file, trash-preferred (fork R-C, settled). Returns true iff the
// file was deleted BY THIS CALL (reclaimed here). Returns false for two distinct cases:
// * `outAlreadyAbsent` set true — the file was already gone before we touched it;
// the caller folds this into the stale/staleness tally, NOT reclaimedCount.
// * `outAlreadyAbsent` left false — a real delete failure (locked, conversion error);
// the caller folds this into skippedCount.
// `absPath` is the resolved absolute path (forward-slashed). NON-THROWING: no exception
// may cross the C ABI.
//
// Per-platform routing:
// * Windows — SHFileOperationW(FO_DELETE, pFrom=<double-NUL path>, FOF_ALLOWUNDO |
// FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI). FOF_ALLOWUNDO routes to the
// Recycle Bin (recoverable); the no-UI flags suppress REAPER-blocking dialogs (our
// own confirm already happened). Verified against shellapi.h. `outUsedTrash` set true.
// * Other (SWELL: macOS/Linux) — no portable move-to-trash surface is available in this
// codebase, so fall back to std::filesystem::remove (hard unlink) behind the R3
// confirm guardrail. `outUsedTrash` left as-is (false).
bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
bool& outAlreadyAbsent) {
#ifdef _WIN32
// Convert forward-slashed UTF-8 to a back-slashed, double-NUL-terminated wide string.
// SHFileOperation's pFrom is a list; a single path still needs the extra terminating
// NUL. Backslashes are required (shell APIs reject forward slashes in some cases).
std::string win = absPath;
for (char& c : win) if (c == '/') c = '\\';
const int wlen = MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, nullptr, 0);
if (wlen <= 0) return false; // conversion failed -> real skip (outAlreadyAbsent stays false)
std::vector<wchar_t> wbuf(static_cast<std::size_t>(wlen) + 1, L'\0'); // +1 for list NUL
MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, wbuf.data(), wlen);
// wbuf now holds the path + its NUL at [wlen-1]; the extra trailing L'\0' at [wlen]
// makes it the double-NUL-terminated single-element list SHFileOperation wants.
SHFILEOPSTRUCTW op{};
op.hwnd = nullptr;
op.wFunc = FO_DELETE;
op.pFrom = wbuf.data();
op.pTo = nullptr;
op.fFlags = static_cast<FILEOP_FLAGS>(FOF_ALLOWUNDO | FOF_NOCONFIRMATION |
FOF_SILENT | FOF_NOERRORUI);
const int rv = SHFileOperationW(&op);
if (rv == 0 && !op.fAnyOperationsAborted) {
outUsedTrash = true;
return true; // deleted this call -> reclaimed
}
// SHFileOperation failed (e.g. file already gone yields a nonzero code on some
// versions, or a lock). Distinguish "already absent" from a real failure so the
// caller can tally them separately (absent -> staleness skip; failure -> locked skip).
std::error_code ec;
if (!fs::exists(absPath, ec)) {
outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim
}
return false;
#else
// No portable trash surface on SWELL platforms -> hard unlink behind the confirm.
std::error_code ec;
const bool removed = fs::remove(absPath, ec);
if (removed) return true; // deleted this call -> reclaimed
if (ec) return false; // a real failure (locked / permission) -> skip
// remove returned false with no error == the file did not exist -> already gone.
outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim
return false;
#endif
}
} // namespace
PruneDeletionResult ReaSamplerSession::pruneReclaim(
const std::vector<std::string>& confirmed) const {
PruneDeletionResult result;
// Re-enumerate + run the pure core FRESH (never a stale set): the deletion targets
// exactly `confirmed ∩ freshOrphans` (pruneDeletePlan). A file that vanished or became
// referenced between confirm and delete drops out of freshOrphans and is skipped; a
// newly-appeared orphan not in `confirmed` is never swept without its own confirm.
// Because freshOrphans is itself a pure-core output, the plan can contain NO referenced
// and NO hand-dropped file — the R-C/R-D safety survives the recompute.
const PruneScan scan = scanPruneOrphans(book_, owned_);
if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing
const std::vector<std::string> plan = pruneDeletePlan(confirmed, scan.orphans);
// Staleness skip count: entries the user confirmed that are no longer fresh orphans
// (vanished or became referenced between confirm and delete). pruneDeletePlan already
// de-dups confirmed internally, so compute the unique-confirmed size to avoid counting
// de-duplicated entries as stale — that would be dishonest.
const std::size_t uniqueConfirmedCount =
std::unordered_set<std::string>(confirmed.begin(), confirmed.end()).size();
result.skippedCount += uniqueConfirmedCount - plan.size();
for (const std::string& rel : plan) {
// Reconstruct the absolute path from the resolved bank dir + the entry's file name.
// rel is index-spelled "<kBankSubfolder>/<name>"; the name is the tail after '/'.
const std::string::size_type slash = rel.find_last_of('/');
const std::string name = (slash == std::string::npos) ? rel : rel.substr(slash + 1);
if (name.empty()) { ++result.skippedCount; continue; }
const std::string absPath = scan.bankDirAbs + "/" + name;
const auto szIt = scan.sizeByRel.find(rel);
const std::uint64_t bytes = (szIt != scan.sizeByRel.end()) ? szIt->second : 0;
bool alreadyAbsent = false;
if (deleteOrphanFile(absPath, result.usedTrash, alreadyAbsent)) {
++result.reclaimedCount;
result.reclaimedBytes += bytes;
} else if (alreadyAbsent) {
// File vanished between plan and delete — treat as staleness, same as the
// confirm→plan gap above. Does NOT count as reclaimed (we didn't delete it).
++result.skippedCount;
} else {
++result.skippedCount; // locked / conversion failure -> recorded, not thrown
}
}
return result;
}
namespace {
// Load the Design-View model from a project's view_state key, or return a fresh // Load the Design-View model from a project's view_state key, or return a fresh
// default. An absent/empty key (older project with no view state) yields a // default. An absent/empty key (older project with no view state) yields a
// default-constructed model (Arrange + Design seeded, active = Arrange) — graceful, // default-constructed model (Arrange + Design seeded, active = Arrange) — graceful,
+48
View File
@@ -23,6 +23,7 @@
#include "bank_book.h" #include "bank_book.h"
#include "bank_model.h" #include "bank_model.h"
#include "owned_manifest.h" #include "owned_manifest.h"
#include "prune_reconcile.h"
#include "tail_control.h" #include "tail_control.h"
#include "view_mode_model.h" #include "view_mode_model.h"
@@ -179,6 +180,53 @@ public:
// no dangling no-effect undo entry is opened on an unsaved project. // no dangling no-effect undo entry is opened on an unsaved project.
bool saveToActiveProject(); bool saveToActiveProject();
// Compute the Phase R prune dry-run for the ACTIVE project (Wave 2 — REPORT ONLY,
// deletes nothing). Enumerates the resolved CURRENT bank folder (the SAME M4 project-
// relative machinery the index/persist use — never a stale absolute path, so it is
// correct across a Save-As relocation), spells every enumerated entry with the index's
// own convention (bankRelativeForName — byte-identical to the capture path's spelling),
// and feeds the R1 pure core with (present, book().referencedPaths(), owned().paths()).
// Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file
// list. The decision stays in the pure core — this method only enumerates, resolves,
// and stats. READ-ONLY across the whole persist seam: it writes NO ext-state, calls no
// save / MarkProjectDirty, and mutates neither the book, the manifest, nor any file.
//
// Yields an empty report (count 0) when there is no active/saved project or no bank
// folder on disk yet — an unsaved or never-captured project has nothing to reclaim.
PruneReport pruneDryRun() const;
// The FULL (untruncated) prune orphan set for the ACTIVE project — the same fresh
// enumerate + pure-core compute pruneDryRun() runs, but returning EVERY orphan (no
// 64-cap display clip) as project-relative index-spelled paths, in enumeration order.
// The R3 action calls this to obtain the exact set it will CONFIRM and then delete
// (pruneDryRun's truncated list is for the console readout; the delete set must be
// complete). READ-ONLY — no ext-state, no save, no file mutation. Empty when there is
// no active/saved project or no bank folder yet.
std::vector<std::string> pruneOrphanSet() const;
// Phase R (Reclaim), R3: DELETE the confirmed orphan set — the SOLE file-deletion path
// in ReaSampler, callable ONLY after an explicit user confirm of a specific manifest.
// Given the orphan set the user was shown and confirmed (`confirmed`, typically the
// full pruneOrphanSet() captured moments earlier), this re-enumerates the folder, runs
// the pure core FRESH, and deletes exactly `confirmed ∩ freshOrphans` (pruneDeletePlan)
// so a file that vanished or became referenced between confirm and delete is skipped,
// never wrongly deleted — and a newly-appeared orphan the user did NOT see is never
// swept. Deletion routes to the OS trash where a portable move-to-trash is verified
// (Windows Recycle Bin via SHFileOperation + FOF_ALLOWUNDO); elsewhere it falls back to
// std::filesystem unlink behind this confirm guardrail (see persist.cpp for per-platform
// routing). Non-throwing: every filesystem call uses error_code forms; a per-file
// failure (locked, already gone) is recorded and skipped, never thrown across the C ABI.
//
// Does NOT modify the BankIndex/book (orphans are unreferenced by definition) and does
// NOT modify the OwnedFileManifest (a reclaimed file drops out of the (owned ∩ present)
// algebra naturally once it is off disk — no persist write, so no undo-point question
// and no risk to the referenced/owned safety). Writes NO ext-state at all.
//
// No-ops (empty result) when there is no active/saved project, no bank folder, or the
// delete plan is empty (everything went stale). The caller is responsible for having
// shown the confirm; this method does NOT prompt.
PruneDeletionResult pruneReclaim(const std::vector<std::string>& confirmed) const;
// Poll the active project. Detects a project load (active project changed) // Poll the active project. Detects a project load (active project changed)
// and a Save-As (active project's .rpp path changed) and reacts accordingly. // and a Save-As (active project's .rpp path changed) and reacts accordingly.
// Intended to be driven by REAPER's "timer" register. Idempotent per tick. // Intended to be driven by REAPER's "timer" register. Idempotent per tick.
+39
View File
@@ -0,0 +1,39 @@
#include "prune_button.h"
// prune_button implementation — right-anchored button placement in the footer strip,
// with a left-collision suppression rule. Trivially auditable arithmetic; the safety
// property (a suppressed/empty button never claims a click) is a pure predicate tested
// outside the DAW.
namespace reasampler {
ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& spec) {
if (footer.width <= 0 || footer.height <= 0) return ButtonRect{}; // degenerate footer
if (spec.buttonWidth <= 0) return ButtonRect{}; // nothing to place
// Right-anchored: right edge inset from the footer's right; width fixed.
const int right = footer.x + footer.width - spec.rightInset;
const int left = right - spec.buttonWidth;
// Suppress if the button would encroach past the reserved left inset (tail label room)
// or spill off the left of the footer entirely.
if (left < footer.x + spec.minLeftInset) return ButtonRect{};
// Vertically centred by the inset; clamp so a thin footer never yields a negative height.
int top = footer.y + spec.verticalInset;
int height = footer.height - 2 * spec.verticalInset;
if (height <= 0) {
top = footer.y;
height = footer.height;
}
return ButtonRect{left, top, spec.buttonWidth, height};
}
bool hitTestPruneButton(int px, int py, const ButtonRect& button) {
if (button.empty()) return false; // suppressed button claims nothing
return px >= button.x && px < button.x + button.width &&
py >= button.y && py < button.y + button.height;
}
} // namespace reasampler
+96
View File
@@ -0,0 +1,96 @@
#pragma once
// prune_button — the REAPER-free layout math behind the bank_panel's Prune button
// (Phase R, Wave 3 — R3, fork R-E). A single labelled button drawn in the panel's
// tail-footer strip that fires the "Prune bank folder" command. The panel shell
// (bank_panel.cpp) owns the SWELL window, LICE drawing, and the Main_OnCommand
// dispatch of the registered command id — all REAPER-bound, DAW-verified. What is
// NOT DAW-bound — WHERE the button sits in the footer and whether a click lands on
// it — lives here so it is unit-tested outside the DAW (CLAUDE.md §load-bearing
// split). Mirror of mode_switch / tab_strip.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
// only. Builds and unit-tests without REAPER.
//
// -- Placement contract --------------------------------------------------------
//
// The footer already hosts a LEFT-aligned tail-mode label and a RIGHT-aligned
// version readout (bank_panel drawTailFooter). The prune button is a fixed-width
// button anchored to the RIGHT of the footer, inset from the right edge, sitting
// just LEFT of the version readout's inset region. It never overlaps the tail label
// at the left. When the footer is too narrow to fit the button without colliding
// with the left inset, the button is suppressed (empty rect) rather than drawn on
// top of the label — the action is always reachable via its bindable command, so a
// hidden button is a graceful degradation, not a lost affordance.
namespace reasampler {
// The footer strip the button is drawn into, top-left origin (SWELL/LICE
// convention). (x, y) is the top-left corner; width/height are the strip extents.
// bank_panel derives this from panelFooter() and passes it here.
struct FooterRect {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
bool operator==(const FooterRect& o) const {
return x == o.x && y == o.y && width == o.width && height == o.height;
}
};
// A button's pixel rectangle within the footer, top-left origin. A zero-area rect
// (width <= 0 or height <= 0) means "no button" — the footer is too narrow to place
// it, or the footer itself is degenerate; the caller must not draw or hit-test it.
struct ButtonRect {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
bool empty() const { return width <= 0 || height <= 0; }
bool operator==(const ButtonRect& o) const {
return x == o.x && y == o.y && width == o.width && height == o.height;
}
};
// Layout inputs for the prune button, in pixels. Defaults match the bank_panel footer
// metrics; the shell passes its own so draw and hit-test share one source of truth.
// * buttonWidth — the button's fixed width.
// * rightInset — gap from the footer's right edge to the button's right edge (the
// button sits left of this inset, clearing the right-aligned version
// readout). COUPLED TO drawTailFooter (bank_panel.cpp): the version
// readout uses `vrc.right -= 8` (8 px right margin). The button's
// right edge lands at footer.right - 84, i.e. 76 px left of the
// readout's right margin — enough clearance for the ~10-char label.
// If the version readout's inset changes in drawTailFooter, update
// this value to maintain clearance.
// * verticalInset — top/bottom gap inside the footer (the button is shorter than the
// strip so it reads as a raised control, not a full-height fill).
// * minLeftInset — the button's left edge must stay at least this far from the footer
// left edge (reserving room for the left-aligned tail label). If the
// button would encroach past this, computePruneButton yields an empty
// rect (button suppressed — see header placement contract).
struct PruneButtonSpec {
int buttonWidth = 72;
int rightInset = 84; // COUPLED: version readout in drawTailFooter uses vrc.right -= 8
int verticalInset = 4;
int minLeftInset = 120;
};
// Computes the prune button's rect within `footer` per `spec`. Right-anchored: the
// button's right edge is footer.x + footer.width - rightInset, its width is buttonWidth,
// and it is vertically centred by verticalInset. Returns an EMPTY rect (button
// suppressed) when: the footer is degenerate (width/height <= 0), OR the resulting left
// edge would fall closer to the footer left than minLeftInset (too narrow to place
// without colliding with the tail label). The action stays reachable via its command in
// that case — a suppressed button is graceful, not a lost feature.
ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& spec);
// True iff the point (px, py) (SWELL/LICE top-left client coords) falls inside `button`.
// Half-open bounds [x, x+width) x [y, y+height) — matches computePruneButton so draw and
// hit-test agree on the same pixels. An empty button never claims a point (always false),
// so a suppressed button cannot be accidentally clicked.
bool hitTestPruneButton(int px, int py, const ButtonRect& button);
} // namespace reasampler
+72
View File
@@ -0,0 +1,72 @@
#include "prune_reconcile.h"
#include <unordered_set>
// prune_reconcile implementation — the one set-algebra computation, kept trivially
// auditable: build the referenced and owned lookup sets, then walk `present` once,
// keeping a path iff it is owned AND not referenced. Walking `present` (not owned)
// gives the ∩-present clause for free and yields output in folder-enumeration order.
namespace reasampler {
std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
const std::vector<std::string>& referenced,
const std::vector<std::string>& owned) {
// Exact-string membership — the model's canonical relative-path comparison
// (Sample.relativePath / OwnedFileManifest::contains). std::string hashes/compares
// byte-for-byte, so no normalization creeps in.
const std::unordered_set<std::string> referencedSet(referenced.begin(),
referenced.end());
const std::unordered_set<std::string> ownedSet(owned.begin(), owned.end());
std::vector<std::string> orphans;
std::unordered_set<std::string> emitted; // de-dup repeated spellings in `present`
for (const std::string& path : present) {
// (owned ∩ present) referenced: present is the walk; owned and !referenced
// are the two membership tests; emitted guards against a duplicate `present`.
if (ownedSet.count(path) == 0) continue; // not our leaving — skip
if (referencedSet.count(path) != 0) continue; // some bank references it
if (!emitted.insert(path).second) continue; // already emitted
orphans.push_back(path);
}
return orphans;
}
PruneReport buildPruneReport(
const std::vector<std::string>& orphans,
const std::unordered_map<std::string, std::uint64_t>& sizeByPath,
std::size_t displayCap) {
PruneReport report;
report.count = orphans.size();
for (const std::string& o : orphans) {
// Sum EVERY orphan's bytes (the exact reclaimable total), not just the displayed
// ones. A path with no stat'd size contributes 0 — never dropped, never negative.
const auto it = sizeByPath.find(o);
report.totalBytes += (it != sizeByPath.end()) ? it->second : 0;
// Display list is capped (0 = uncapped). count stays exact above regardless.
if (displayCap == 0 || report.orphans.size() < displayCap)
report.orphans.push_back(o);
}
report.truncated = report.orphans.size() < report.count;
return report;
}
std::vector<std::string> pruneDeletePlan(const std::vector<std::string>& confirmed,
const std::vector<std::string>& freshOrphans) {
// fresh is a pure-core output (referenced/hand-dropped already excluded), so keeping a
// confirmed path iff it is still a fresh orphan can never re-admit an unsafe file. Walk
// `confirmed` to preserve the confirm's listing order; emitted de-dups repeats.
const std::unordered_set<std::string> freshSet(freshOrphans.begin(), freshOrphans.end());
std::vector<std::string> plan;
std::unordered_set<std::string> emitted;
for (const std::string& path : confirmed) {
if (freshSet.count(path) == 0) continue; // stale: vanished / now-referenced -> skip
if (!emitted.insert(path).second) continue; // already emitted
plan.push_back(path);
}
return plan;
}
} // namespace reasampler
+150
View File
@@ -0,0 +1,150 @@
#pragma once
// prune_reconcile — the pure core of Phase R (Reclaim), Wave 1. The safety-critical
// "which files are orphans" decision, computed with NO filesystem I/O and NO REAPER
// types. The mirror of view_mode_model's reconcile(liveGuids), one level DOWN: it
// reconciles FILES ON DISK against REFERENCED FILES (the union across every bank),
// where reconcile reconciled membership entries against live tracks.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes, NO filesystem calls. Standard library only. Unit-tested outside
// the DAW — this is the safety-critical part (it decides which bytes get deleted in
// R2/R3), so it is hard-tested here before any I/O exists.
//
// -- The one computation ------------------------------------------------------
//
// orphans = (owned ∩ present) referenced
//
// * present — files enumerated in the resolved current bank folder (R2 shell).
// * referenced — every project-relative path referenced by ANY bank in the book,
// pool included (union across the whole book — see BankBook::
// referencedPaths). A file referenced by any bank — including via a
// COPY into a second bank — is NEVER an orphan (the prune null test).
// * owned — the owned-file manifest: the files the bank system itself created
// (OwnedFileManifest). A present-but-unowned (hand-dropped) file is
// NEVER reclaimed — prune reclaims only the system's own leavings.
//
// The three settled guardrails fall straight out of the set algebra:
// * ∩ present — never proposes deleting a file that is not on disk (an owned-
// but-absent manifest entry yields no orphan, no error).
// * ∩ owned — never a hand-dropped file (ownership attribution, fork R-D).
// * referenced — never a file any bank references (union safety, prune null test).
//
// -- Path representation: EXACT-STRING match (safety-critical) -----------------
//
// Every path in the model is a project-relative string compared VERBATIM: Sample.
// relativePath, OwnedFileManifest::contains (p == relativePath), and BankIndex all
// use raw std::string equality — no separator normalization, no case-folding, no
// trailing-slash trimming. This core MATCHES that convention exactly: it compares
// the raw strings the shell supplies. Feeding a consistent spelling across the three
// inputs is the R2 shell's contract (it enumerates the folder, unions the book, and
// reads the manifest against the SAME resolved current folder). Diverging from exact
// match here (e.g. case-insensitive compare) would be the unsafe direction — it could
// let one spelling of a referenced file be treated as an orphan under another.
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
namespace reasampler {
// The dry-run prune result (Phase R, Wave 2 — report only, no deletion). The thin
// prune shell (persist) fills this from pruneOrphans() + a per-file size stat and hands
// it to the report surface; R3 will act on the SAME set behind the confirm guardrail.
// REAPER-free / filesystem-free by design (the shell does the I/O; this is just the
// tallied outcome), so the count/size aggregation is unit-testable outside the DAW.
//
// * count — number of orphan files (== orphans.size(); the AUTHORITATIVE tally,
// exact even when `orphans` below is a truncated display list).
// * totalBytes — sum of the on-disk sizes of the orphan files, in bytes (reclaimable
// space). A file the stat could not size contributes 0 (never negative).
// * orphans — the orphan file list as project-relative index-spelled paths, in
// folder-enumeration order (deterministic). MAY be truncated for a large
// set (the shell's display cap); `count` stays exact regardless, and
// `truncated` says whether the list was clipped.
// * truncated — true iff `orphans` holds fewer than `count` entries (a large set was
// clipped for display); false when the list is complete.
struct PruneReport {
std::size_t count = 0;
std::uint64_t totalBytes = 0;
std::vector<std::string> orphans;
bool truncated = false;
};
// The outcome of an actual prune DELETION (Phase R, Wave 3 — R3). The prune shell fills
// this as it deletes the confirmed orphan set, reporting what it ACTUALLY reclaimed (not
// what it intended to) so a locked/vanished file shows up as a skip, never a false claim.
// REAPER-free / filesystem-free by design (the shell does the deletion; this is the
// tallied outcome), so the count/byte aggregation is unit-testable outside the DAW.
//
// * reclaimedCount — number of files actually removed from disk BY THIS CALL (trash or
// unlink). Already-absent files are NOT counted here.
// * reclaimedBytes — sum of the on-disk sizes of the files actually removed, in bytes.
// * skippedCount — files that could not be or were not reclaimed: stale entries that
// dropped out of the fresh-orphan intersection, files that vanished
// between the plan and the delete call (already absent), and real
// delete failures (locked, conversion error). Never an error/crash.
// * usedTrash — true iff the deletions were routed to the OS trash/recycle bin
// (recoverable); false iff the platform fell back to hard unlink.
struct PruneDeletionResult {
std::size_t reclaimedCount = 0;
std::uint64_t reclaimedBytes = 0;
std::size_t skippedCount = 0;
bool usedTrash = false;
};
// Computes the prune orphan set: (owned ∩ present) referenced.
//
// Returns the subset of `present` that is BOTH owned AND unreferenced, in the ORDER
// they appear in `present` (deterministic output — mirror of the insertion-order
// determinism the index / manifest keep; the R2 dry-run reports a stable file list).
// Duplicate spellings within `present` are de-duplicated in the result (a folder
// enumeration yields distinct names, but the core does not rely on that).
//
// Pure: no I/O, no REAPER, no hidden state. All three inputs are project-relative
// path strings, compared by exact std::string equality (see header note).
std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
const std::vector<std::string>& referenced,
const std::vector<std::string>& owned);
// Tallies a dry-run PruneReport from a computed orphan set and a per-path size lookup.
// PURE (no I/O): the shell does the folder stat and passes the sizes in `sizeByPath`;
// this owns the count / byte-sum / display-truncation decision so it is unit-testable.
//
// * count == orphans.size() (the authoritative tally, exact regardless of the cap).
// * totalBytes == the sum of sizeByPath[o] over EVERY orphan o (not just the displayed
// ones); a path missing from sizeByPath contributes 0 (an orphan whose
// size could not be stat'd — never negative, never dropped from the sum).
// * orphans == the first `displayCap` orphans in input order (the deterministic
// folder-enumeration order pruneOrphans preserved); the whole set when
// count <= displayCap. displayCap == 0 means "no display cap" (whole set).
// * truncated == count > orphans.size() (a large set was clipped for display).
//
// Kept separate from pruneOrphans so the safety-critical set algebra stays a pure function
// of three sets, while the presentation tally (which the R2 dry-run and R3 confirm both
// need) is its own small, testable step.
PruneReport buildPruneReport(const std::vector<std::string>& orphans,
const std::unordered_map<std::string, std::uint64_t>& sizeByPath,
std::size_t displayCap);
// Computes the confirm-time delete plan: the intersection of the set the user was SHOWN
// and confirmed (`confirmed`) with a FRESH pure-core orphan output (`freshOrphans`) taken
// at delete time. Returns exactly `confirmed ∩ freshOrphans`, in the order of `confirmed`
// (deterministic — the same order the confirm listed).
//
// This is the R3 staleness guard, and it protects in BOTH directions so that "what was
// shown is what is deleted" holds no matter what changed between confirm and delete:
// * A confirmed path that is NO LONGER a fresh orphan — a file that vanished (gone from
// `present`), or that some bank now references (gone from ` referenced`), or whose
// ownership changed — is DROPPED (a skip, never an error, never a wrongful delete of a
// now-referenced file). Because `freshOrphans` is itself a pure-core output, the plan
// can never contain a referenced or hand-dropped file: the guard survives recompute.
// * A path that became an orphan AFTER the confirm (in `freshOrphans` but not `confirmed`)
// is NOT deleted — it was never shown, so it is never swept without its own confirm.
//
// PURE: no I/O, no REAPER. Duplicate spellings in `confirmed` are de-duplicated in the
// result (mirrors pruneOrphans; a confirmed set from a real scan holds distinct names).
std::vector<std::string> pruneDeletePlan(const std::vector<std::string>& confirmed,
const std::vector<std::string>& freshOrphans);
} // namespace reasampler
+25
View File
@@ -547,6 +547,29 @@ static void testHashWavContentDomainSeparationFromWholeFile() {
CHECK(contentHash != wholeHash); CHECK(contentHash != wholeHash);
} }
// --- bankRelativeForName spelling consistency (Phase R, R2) -----------------
//
// The safety-critical property: the relative spelling the prune shell derives for an
// ENUMERATED folder entry (bankRelativeForName) must be byte-identical to the spelling
// the capture path stored in the index (deriveBankPaths().relativePath) for the same
// file name. A divergence here could make a referenced file look like an orphan.
static void testBankRelativeForNameMatchesDerivePathSpelling() {
// For a file the capture path created, deriveBankPaths produced relativePath;
// a directory listing yields the bare file name. bankRelativeForName(name) must
// reproduce the SAME string, or the pure core's exact-string match misfires.
const BankPaths p = deriveBankPaths("/proj", "kick", "001");
// p.fileName is the on-disk entry name a folder enumeration would return.
CHECK(bankRelativeForName(p.fileName) == p.relativePath);
}
static void testBankRelativeForNameConventionAndEdge() {
// The convention verbatim: "reasampler_bank/<name>" (the one place the spelling lives).
CHECK(bankRelativeForName("a.wav") == "reasampler_bank/a.wav");
// Empty in -> empty out (a defensive guard; a real enumeration never yields "").
CHECK(bankRelativeForName("").empty());
}
int main() { int main() {
testNormalizeSlashes(); testNormalizeSlashes();
testNormalizeSlashesCaseFolding(); testNormalizeSlashesCaseFolding();
@@ -585,6 +608,8 @@ int main() {
testHashWavContentEmptyFallsBackToHashBytes(); testHashWavContentEmptyFallsBackToHashBytes();
testHashWavContentListMetaSkipped(); testHashWavContentListMetaSkipped();
testHashWavContentDomainSeparationFromWholeFile(); testHashWavContentDomainSeparationFromWholeFile();
testBankRelativeForNameMatchesDerivePathSpelling();
testBankRelativeForNameConventionAndEdge();
if (g_fail == 0) std::printf("capture_paths: all tests passed\n"); if (g_fail == 0) std::printf("capture_paths: all tests passed\n");
else std::printf("capture_paths: %d CHECK(s) FAILED\n", g_fail); else std::printf("capture_paths: %d CHECK(s) FAILED\n", g_fail);
+132
View File
@@ -0,0 +1,132 @@
// Standalone tests for reasampler::prune_button — no REAPER, no test framework.
// Same fast loop as the sibling pure tests (mode_switch / tab_strip): assert the
// footer prune-button placement math and hit-testing directly.
//
// Covers (R3 brief §button pure module): right-anchored layout in a wide footer;
// vertical inset; SUPPRESSION (empty rect) when the footer is too narrow to clear the
// tail-label inset or is degenerate; hit-test in/out/edge (half-open bounds); a
// suppressed/empty button claims no point; draw and hit-test agree over the whole rect.
#include "../src/prune_button.h"
#include <cstdio>
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)
// --- Layout: wide footer, right-anchored -------------------------------------
// Footer 400 wide at origin (0, 100), height 26. Default spec: buttonWidth 72,
// rightInset 84, verticalInset 4, minLeftInset 120. Right edge = 0+400-84 = 316,
// left = 316-72 = 244 (>= 0+120, so placed). Top = 100+4 = 104, height = 26-8 = 18.
static void testWideFooterRightAnchored() {
FooterRect f{0, 100, 400, 26};
const ButtonRect b = computePruneButton(f, PruneButtonSpec{});
CHECK(!b.empty());
CHECK((b == ButtonRect{244, 104, 72, 18}));
// Right edge sits at the rightInset from the footer's right.
CHECK(b.x + b.width == f.x + f.width - 84);
// Left edge clears the reserved tail-label inset.
CHECK(b.x >= f.x + 120);
}
// Origin offset is honoured (button anchors to THIS footer's right, not 0).
static void testOffsetFooterAnchors() {
FooterRect f{10, 200, 400, 26};
const ButtonRect b = computePruneButton(f, PruneButtonSpec{});
CHECK(!b.empty());
CHECK(b.x + b.width == f.x + f.width - 84); // = 10+400-84 = 326
CHECK(b.x == 254);
}
// --- Suppression: too narrow / degenerate ------------------------------------
// A footer just wide enough that the button's left edge would fall past the
// minLeftInset is suppressed (empty). left = x + width - rightInset - buttonWidth.
// Need left < x + minLeftInset -> width < rightInset + buttonWidth + minLeftInset
// = 84 + 72 + 120 = 276. Width 275 suppresses; 276 places (boundary).
static void testNarrowFooterSuppressed() {
CHECK(computePruneButton(FooterRect{0, 0, 275, 26}, PruneButtonSpec{}).empty());
CHECK(!computePruneButton(FooterRect{0, 0, 276, 26}, PruneButtonSpec{}).empty());
}
static void testDegenerateFooterSuppressed() {
CHECK(computePruneButton(FooterRect{0, 0, 0, 26}, PruneButtonSpec{}).empty()); // no width
CHECK(computePruneButton(FooterRect{0, 0, 400, 0}, PruneButtonSpec{}).empty()); // no height
PruneButtonSpec zero{}; zero.buttonWidth = 0;
CHECK(computePruneButton(FooterRect{0, 0, 400, 26}, zero).empty()); // zero button
}
// A very thin footer (height <= 2*verticalInset) still places a button but clamps its
// height to the footer's own, rather than yielding a negative height.
static void testThinFooterClampsHeight() {
FooterRect f{0, 0, 400, 6}; // 6 <= 2*4, so height would be negative -> clamp
const ButtonRect b = computePruneButton(f, PruneButtonSpec{});
CHECK(!b.empty());
CHECK(b.y == f.y);
CHECK(b.height == f.height);
}
// --- Hit-test ----------------------------------------------------------------
static void testHitTestInside() {
FooterRect f{0, 100, 400, 26};
const ButtonRect b = computePruneButton(f, PruneButtonSpec{}); // {244,104,72,18}
CHECK(hitTestPruneButton(b.x, b.y, b)); // top-left corner (inclusive)
CHECK(hitTestPruneButton(b.x + b.width - 1, b.y + b.height - 1, b)); // bottom-right inclusive
CHECK(hitTestPruneButton(b.x + b.width / 2, b.y + b.height / 2, b)); // centre
}
// Half-open bounds: the far edges (x+width, y+height) are EXCLUDED, matching the draw.
static void testHitTestEdgesExcluded() {
FooterRect f{0, 100, 400, 26};
const ButtonRect b = computePruneButton(f, PruneButtonSpec{});
CHECK(!hitTestPruneButton(b.x - 1, b.y, b)); // just left
CHECK(!hitTestPruneButton(b.x + b.width, b.y, b)); // right edge excluded
CHECK(!hitTestPruneButton(b.x, b.y - 1, b)); // just above
CHECK(!hitTestPruneButton(b.x, b.y + b.height, b)); // bottom edge excluded
}
// A suppressed (empty) button never claims a point — a click in the footer where the
// button would have been falls through to the tail cycle, never a phantom prune.
static void testEmptyButtonClaimsNothing() {
ButtonRect empty{};
CHECK(!hitTestPruneButton(0, 0, empty));
CHECK(!hitTestPruneButton(5, 5, empty));
// A "no button" from a narrow footer also claims nothing at any point.
const ButtonRect suppressed = computePruneButton(FooterRect{0, 0, 200, 26}, PruneButtonSpec{});
CHECK(suppressed.empty());
CHECK(!hitTestPruneButton(150, 13, suppressed));
}
// Draw/hit-test agreement: every point inside the computed rect hit-tests true, and the
// four immediate outside neighbours hit-test false (the load-bearing consistency).
static void testHitTestMatchesLayout() {
FooterRect f{3, 7, 377, 22}; // awkward origin/size
const ButtonRect b = computePruneButton(f, PruneButtonSpec{});
CHECK(!b.empty());
for (int py = b.y; py < b.y + b.height; ++py)
for (int px = b.x; px < b.x + b.width; ++px)
CHECK(hitTestPruneButton(px, py, b));
CHECK(!hitTestPruneButton(b.x - 1, b.y, b));
CHECK(!hitTestPruneButton(b.x + b.width, b.y, b));
}
int main() {
testWideFooterRightAnchored();
testOffsetFooterAnchors();
testNarrowFooterSuppressed();
testDegenerateFooterSuppressed();
testThinFooterClampsHeight();
testHitTestInside();
testHitTestEdgesExcluded();
testEmptyButtonClaimsNothing();
testHitTestMatchesLayout();
if (g_fail == 0) std::printf("prune_button: all tests passed\n");
else std::printf("prune_button: %d CHECK(s) FAILED\n", g_fail);
return g_fail == 0 ? 0 : 1;
}
+418
View File
@@ -0,0 +1,418 @@
// Standalone tests for reasampler::pruneOrphans (Phase R, Wave 1) and the additive
// BankBook::referencedPaths() union query it consumes — no REAPER, no framework.
//
// The safety-critical computation: orphans = (owned ∩ present) referenced. Every
// test below would FAIL if the set algebra were wrong (a missing ∩ present, a missing
// referenced, or a non-union referenced-set) — see the negative assertions.
//
// Covers (brief-named):
// 1. Prune null test: all present files referenced -> empty orphan set.
// 2. Formula: orphans = (owned ∩ present) referenced, mixed populations.
// 3. Copied file referenced by a SECOND bank survives (union semantics).
// 4. Present-but-unowned (hand-dropped) file is never reclaimed.
// 5. Edge cases: empty folder, empty book/referenced-set, empty manifest.
// 6. Owned-but-absent file (manifest entry, no file on disk) -> no orphan, no error.
// Plus: determinism (present-order output), duplicate-`present` de-dup, exact-string
// (non-normalizing) match, and the referencedPaths() union query directly.
#include "../src/bank_book.h"
#include "../src/prune_reconcile.h"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <string>
#include <unordered_map>
#include <vector>
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)
// --- helpers -----------------------------------------------------------------
static bool contains(const std::vector<std::string>& v, const std::string& s) {
return std::find(v.begin(), v.end(), s) != v.end();
}
// A minimal Sample carrying just the fields prune cares about (id + relativePath).
static Sample sampleAt(const std::string& id, const std::string& relPath,
const std::string& hash = "") {
Sample s;
s.id = id;
s.relativePath = relPath;
s.contentHash = hash;
return s;
}
// --- 1. prune null test: all present referenced -> empty --------------------
static void testNullTestAllReferenced() {
const std::vector<std::string> present = {"bank/a.wav", "bank/b.wav", "bank/c.wav"};
const std::vector<std::string> owned = {"bank/a.wav", "bank/b.wav", "bank/c.wav"};
const std::vector<std::string> referenced= {"bank/a.wav", "bank/b.wav", "bank/c.wav"};
const auto orphans = pruneOrphans(present, referenced, owned);
// The core invariant: a folder whose every file is referenced deletes NOTHING.
CHECK(orphans.empty());
}
// --- 2. formula: (owned ∩ present) referenced, mixed populations ----------
static void testFormulaMixedPopulations() {
// present : a b c d (on disk)
// owned : a b d e (system created; e is owned-but-absent)
// ref'd : a (still in an index)
// expected orphans: owned ∩ present = {a,b,d}; minus referenced {a} = {b,d}
// - c present but NOT owned -> excluded (hand-dropped)
// - e owned but NOT present -> excluded (no file on disk)
// - a owned+present but referenced -> excluded
const std::vector<std::string> present = {"bank/a.wav", "bank/b.wav",
"bank/c.wav", "bank/d.wav"};
const std::vector<std::string> owned = {"bank/a.wav", "bank/b.wav",
"bank/d.wav", "bank/e.wav"};
const std::vector<std::string> referenced = {"bank/a.wav"};
const auto orphans = pruneOrphans(present, referenced, owned);
CHECK(orphans.size() == 2);
CHECK(contains(orphans, "bank/b.wav"));
CHECK(contains(orphans, "bank/d.wav"));
// Negative assertions — each would fire if a clause of the formula were dropped:
CHECK(!contains(orphans, "bank/a.wav")); // referenced
CHECK(!contains(orphans, "bank/c.wav")); // ∩ owned (hand-dropped)
CHECK(!contains(orphans, "bank/e.wav")); // ∩ present (absent)
}
// --- 3. copied file referenced by a second bank survives (union) ------------
static void testCopiedFileSurvivesViaUnion() {
// A file present + owned, whose ONLY index reference lives in a second (non-active)
// bank via a copy. The union across the whole book must keep it out of the orphan
// set. Built through the real BankBook so referencedPaths()'s union is exercised.
BankBook book;
book.createBank("b1", "Drums");
// Add to the pool, then copy into b1 (same file referenced by two banks).
book.index(std::string(kPoolBankId))->add(sampleAt("s1", "bank/shared.wav", "h1"));
CHECK(book.copySample("s1", kPoolBankId, "b1") == TransferResult::Copied);
const auto referenced = book.referencedPaths();
// Union de-dups: one path though two banks reference it.
CHECK(referenced.size() == 1);
CHECK(referenced[0] == "bank/shared.wav");
const std::vector<std::string> present = {"bank/shared.wav"};
const std::vector<std::string> owned = {"bank/shared.wav"};
const auto orphans = pruneOrphans(present, referenced, owned);
// Referenced by b1 (the copy) -> never an orphan even if the pool later dropped it.
CHECK(orphans.empty());
}
// A sharper union test: remove the file from the pool but keep the copy in b1. The
// file must STILL survive (referenced by b1 alone) — the whole point of union safety.
static void testUnionSurvivesWhenOnlySecondBankReferences() {
BankBook book;
book.createBank("b1", "Drums");
book.index(std::string(kPoolBankId))->add(sampleAt("s1", "bank/shared.wav", "h1"));
CHECK(book.copySample("s1", kPoolBankId, "b1") == TransferResult::Copied);
// Drop the pool's reference; b1 still references the file.
CHECK(book.removeSample("s1", kPoolBankId) == RemoveResult::Removed);
const auto referenced = book.referencedPaths();
CHECK(referenced.size() == 1);
CHECK(contains(referenced, "bank/shared.wav"));
const auto orphans = pruneOrphans({"bank/shared.wav"}, referenced, {"bank/shared.wav"});
CHECK(orphans.empty()); // still referenced by b1 -> not reclaimable
}
// --- 4. present-but-unowned (hand-dropped) file never reclaimed -------------
static void testHandDroppedNeverReclaimed() {
// A file on disk that no index references AND the manifest does not own. It is a
// hand-dropped file — prune must never reclaim it (ownership attribution, fork R-D).
const std::vector<std::string> present = {"bank/user_drop.wav", "bank/ours.wav"};
const std::vector<std::string> owned = {"bank/ours.wav"}; // NOT user_drop
const std::vector<std::string> referenced = {}; // neither referenced
const auto orphans = pruneOrphans(present, referenced, owned);
CHECK(orphans.size() == 1);
CHECK(contains(orphans, "bank/ours.wav")); // ours, unreferenced -> orphan
CHECK(!contains(orphans, "bank/user_drop.wav")); // hand-dropped -> protected
}
// --- 5. edge cases: empty folder / book / manifest --------------------------
static void testEmptyFolder() {
// No files present -> nothing to reclaim regardless of owned/referenced.
const auto orphans = pruneOrphans({}, {"bank/a.wav"}, {"bank/a.wav"});
CHECK(orphans.empty());
}
static void testEmptyReferencedSet() {
// Empty book (nothing referenced): every present ∩ owned file is an orphan.
const std::vector<std::string> present = {"bank/a.wav", "bank/b.wav"};
const std::vector<std::string> owned = {"bank/a.wav", "bank/b.wav"};
const auto orphans = pruneOrphans(present, /*referenced*/ {}, owned);
CHECK(orphans.size() == 2);
CHECK(contains(orphans, "bank/a.wav"));
CHECK(contains(orphans, "bank/b.wav"));
}
static void testEmptyManifest() {
// Empty manifest (owns nothing): nothing is reclaimable even if present+unreferenced.
const std::vector<std::string> present = {"bank/a.wav", "bank/b.wav"};
const std::vector<std::string> referenced = {};
const auto orphans = pruneOrphans(present, referenced, /*owned*/ {});
CHECK(orphans.empty());
}
static void testAllEmpty() {
const auto orphans = pruneOrphans({}, {}, {});
CHECK(orphans.empty());
}
// --- 6. owned-but-absent file -> no orphan, no error ------------------------
static void testOwnedButAbsentNoOrphan() {
// A manifest entry whose file is NOT on disk (e.g. deleted out-of-band). It must
// yield no orphan entry (∩ present excludes it) and no error/crash.
const std::vector<std::string> present = {"bank/here.wav"};
const std::vector<std::string> owned = {"bank/here.wav", "bank/gone.wav"};
const std::vector<std::string> referenced = {};
const auto orphans = pruneOrphans(present, referenced, owned);
CHECK(orphans.size() == 1);
CHECK(contains(orphans, "bank/here.wav"));
CHECK(!contains(orphans, "bank/gone.wav")); // owned but absent -> not an orphan
}
// --- determinism + duplicate-present + exact-string match -------------------
static void testOutputPreservesPresentOrder() {
// Output order follows `present` order (deterministic dry-run file list), NOT the
// owned/referenced order.
const std::vector<std::string> present = {"bank/z.wav", "bank/y.wav", "bank/x.wav"};
const std::vector<std::string> owned = {"bank/x.wav", "bank/y.wav", "bank/z.wav"};
const std::vector<std::string> referenced = {};
const auto orphans = pruneOrphans(present, referenced, owned);
CHECK(orphans.size() == 3);
CHECK(orphans[0] == "bank/z.wav");
CHECK(orphans[1] == "bank/y.wav");
CHECK(orphans[2] == "bank/x.wav");
}
static void testDuplicatePresentDeduped() {
// A defensive property: a duplicated `present` spelling appears once in the output.
const std::vector<std::string> present = {"bank/a.wav", "bank/a.wav"};
const std::vector<std::string> owned = {"bank/a.wav"};
const auto orphans = pruneOrphans(present, /*referenced*/ {}, owned);
CHECK(orphans.size() == 1);
CHECK(orphans[0] == "bank/a.wav");
}
static void testExactStringMatchNotNormalized() {
// Safety-critical: the core matches VERBATIM (mirror of the model's exact-string
// convention). A referenced file spelled with backslashes is a DIFFERENT string
// from the forward-slash present spelling — the core does NOT normalize them to
// equal. This documents the invariant: consistent spelling is the R2 shell's
// contract. If the core silently normalized, this would (wrongly) treat the file
// as referenced and the assertion below would fail.
const std::vector<std::string> present = {"bank/a.wav"};
const std::vector<std::string> owned = {"bank/a.wav"};
const std::vector<std::string> referenced = {"bank\\a.wav"}; // different spelling
const auto orphans = pruneOrphans(present, referenced, owned);
CHECK(orphans.size() == 1); // not matched -> still an orphan
CHECK(orphans[0] == "bank/a.wav");
}
// --- BankBook::referencedPaths() union query directly -----------------------
static void testReferencedPathsUnionAcrossBanksAndPool() {
BankBook book;
book.createBank("b1", "Drums");
book.createBank("b2", "Bass");
book.index(std::string(kPoolBankId))->add(sampleAt("p1", "bank/pool.wav", "hp"));
book.index("b1")->add(sampleAt("d1", "bank/drum.wav", "hd"));
book.index("b2")->add(sampleAt("s1", "bank/bass.wav", "hb"));
const auto refs = book.referencedPaths();
// Union includes the pool AND every named bank.
CHECK(refs.size() == 3);
CHECK(contains(refs, "bank/pool.wav"));
CHECK(contains(refs, "bank/drum.wav"));
CHECK(contains(refs, "bank/bass.wav"));
// Pool-first ordinal order.
CHECK(refs[0] == "bank/pool.wav");
}
static void testReferencedPathsEmptyBook() {
BankBook book; // pool only, no samples
CHECK(book.referencedPaths().empty());
}
static void testReferencedPathsSkipsEmptyPath() {
// A sample with an empty relativePath references no file — it must not appear.
BankBook book;
book.index(std::string(kPoolBankId))->add(sampleAt("p1", "", "hp"));
book.index(std::string(kPoolBankId))->add(sampleAt("p2", "bank/real.wav", "hr"));
const auto refs = book.referencedPaths();
CHECK(refs.size() == 1);
CHECK(refs[0] == "bank/real.wav");
}
// --- buildPruneReport: count / byte-sum / truncation (Phase R, R2) ----------
static void testReportCountSizeAndFullList() {
// A known orphan set with known sizes -> exact count, exact byte sum, full list
// (under the cap). Order follows the orphan input order (deterministic).
const std::vector<std::string> orphans = {"bank/a.wav", "bank/b.wav", "bank/c.wav"};
const std::unordered_map<std::string, std::uint64_t> sizes = {
{"bank/a.wav", 100}, {"bank/b.wav", 250}, {"bank/c.wav", 50}};
const PruneReport r = buildPruneReport(orphans, sizes, /*displayCap=*/64);
CHECK(r.count == 3);
CHECK(r.totalBytes == 400); // 100 + 250 + 50 — would fail if sizes mis-summed
CHECK(r.orphans.size() == 3);
CHECK(!r.truncated);
CHECK(r.orphans[0] == "bank/a.wav"); // input order preserved
CHECK(r.orphans[1] == "bank/b.wav");
CHECK(r.orphans[2] == "bank/c.wav");
}
static void testReportMissingSizeCountsZeroNotDropped() {
// An orphan with no stat'd size contributes 0 to the sum but is STILL listed/counted.
const std::vector<std::string> orphans = {"bank/a.wav", "bank/nosize.wav"};
const std::unordered_map<std::string, std::uint64_t> sizes = {{"bank/a.wav", 10}};
const PruneReport r = buildPruneReport(orphans, sizes, 64);
CHECK(r.count == 2); // both counted (missing size must not drop the orphan)
CHECK(r.totalBytes == 10); // nosize contributes 0
CHECK(r.orphans.size() == 2);
}
static void testReportTruncatesListButKeepsExactCountAndSize() {
// More orphans than the display cap: list clips to the cap, but count and byte sum
// stay EXACT over the whole set, and truncated flags the clip.
std::vector<std::string> orphans;
std::unordered_map<std::string, std::uint64_t> sizes;
for (int i = 0; i < 10; ++i) {
const std::string p = "bank/f" + std::to_string(i) + ".wav";
orphans.push_back(p);
sizes[p] = 5; // 10 files * 5 bytes = 50 total
}
const PruneReport r = buildPruneReport(orphans, sizes, /*displayCap=*/3);
CHECK(r.count == 10); // exact, not clipped
CHECK(r.totalBytes == 50); // summed over ALL 10, not just the shown 3
CHECK(r.orphans.size() == 3); // list clipped to the cap
CHECK(r.truncated);
CHECK(r.orphans[0] == "bank/f0.wav"); // first-N in input order
}
static void testReportUncappedWhenCapZero() {
// displayCap == 0 means "no cap": the whole list is emitted, truncated stays false.
const std::vector<std::string> orphans = {"bank/a.wav", "bank/b.wav"};
const PruneReport r = buildPruneReport(orphans, /*sizes*/ {}, /*displayCap=*/0);
CHECK(r.count == 2);
CHECK(r.orphans.size() == 2);
CHECK(!r.truncated);
}
static void testReportEmptyOrphanSet() {
const PruneReport r = buildPruneReport({}, {}, 64);
CHECK(r.count == 0);
CHECK(r.totalBytes == 0);
CHECK(r.orphans.empty());
CHECK(!r.truncated);
}
// --- pruneDeletePlan (R3 staleness guard) ------------------------------------
//
// plan = confirmed ∩ freshOrphans, in confirmed order. Guards BOTH directions so
// "what was shown is what is deleted" holds after a recompute at delete time.
// No change between confirm and delete: the plan is the confirmed set exactly, in order.
static void testDeletePlanStableEqualsConfirmed() {
const std::vector<std::string> confirmed{"b/a.wav", "b/b.wav", "b/c.wav"};
const std::vector<std::string> fresh{"b/a.wav", "b/b.wav", "b/c.wav"};
const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
CHECK((plan == confirmed)); // exact set AND order
}
// A confirmed file that VANISHED (gone from fresh present -> not a fresh orphan) is a
// skip: it drops out of the plan. Would FAIL if the plan ignored freshOrphans.
static void testDeletePlanSkipsVanishedFile() {
const std::vector<std::string> confirmed{"b/a.wav", "b/gone.wav", "b/c.wav"};
const std::vector<std::string> fresh{"b/a.wav", "b/c.wav"}; // gone.wav disappeared
const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
CHECK((plan == std::vector<std::string>{"b/a.wav", "b/c.wav"}));
CHECK(!contains(plan, "b/gone.wav"));
}
// A confirmed file that became REFERENCED between confirm and delete drops OUT of the
// fresh orphan set (pruneOrphans excludes it), so the plan skips it — never deletes a
// now-referenced file. Modelled here as its absence from `fresh`. Load-bearing safety.
static void testDeletePlanSkipsNowReferencedFile() {
const std::vector<std::string> confirmed{"b/x.wav", "b/y.wav"};
const std::vector<std::string> fresh{"b/x.wav"}; // y.wav now referenced -> not a fresh orphan
const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
CHECK((plan == std::vector<std::string>{"b/x.wav"}));
}
// A NEWLY-APPEARED orphan (in fresh, NOT in confirmed) is NEVER swept: it was not shown,
// so it must not be deleted without its own confirm. Would FAIL if plan = fresh.
static void testDeletePlanNeverSweepsUnconfirmed() {
const std::vector<std::string> confirmed{"b/a.wav"};
const std::vector<std::string> fresh{"b/a.wav", "b/new_orphan.wav"};
const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
CHECK((plan == std::vector<std::string>{"b/a.wav"}));
CHECK(!contains(plan, "b/new_orphan.wav"));
}
// Empty inputs: an empty confirmed (nothing shown) -> empty plan regardless of fresh; an
// empty fresh (everything went stale) -> empty plan (all skipped).
static void testDeletePlanEmptyInputs() {
CHECK(pruneDeletePlan({}, {"b/a.wav"}).empty());
CHECK(pruneDeletePlan({"b/a.wav"}, {}).empty());
CHECK(pruneDeletePlan({}, {}).empty());
}
// Duplicate spellings in confirmed are de-duplicated in the plan (mirror of pruneOrphans).
static void testDeletePlanDeduplicatesConfirmed() {
const std::vector<std::string> confirmed{"b/a.wav", "b/a.wav", "b/b.wav"};
const std::vector<std::string> fresh{"b/a.wav", "b/b.wav"};
const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
CHECK((plan == std::vector<std::string>{"b/a.wav", "b/b.wav"}));
}
int main() {
testNullTestAllReferenced();
testFormulaMixedPopulations();
testCopiedFileSurvivesViaUnion();
testUnionSurvivesWhenOnlySecondBankReferences();
testHandDroppedNeverReclaimed();
testEmptyFolder();
testEmptyReferencedSet();
testEmptyManifest();
testAllEmpty();
testOwnedButAbsentNoOrphan();
testOutputPreservesPresentOrder();
testDuplicatePresentDeduped();
testExactStringMatchNotNormalized();
testReferencedPathsUnionAcrossBanksAndPool();
testReferencedPathsEmptyBook();
testReferencedPathsSkipsEmptyPath();
testReportCountSizeAndFullList();
testReportMissingSizeCountsZeroNotDropped();
testReportTruncatesListButKeepsExactCountAndSize();
testReportUncappedWhenCapZero();
testReportEmptyOrphanSet();
testDeletePlanStableEqualsConfirmed();
testDeletePlanSkipsVanishedFile();
testDeletePlanSkipsNowReferencedFile();
testDeletePlanNeverSweepsUnconfirmed();
testDeletePlanEmptyInputs();
testDeletePlanDeduplicatesConfirmed();
if (g_fail == 0) std::printf("prune_reconcile_tests: ALL PASS\n");
else std::printf("prune_reconcile_tests: %d FAILURE(S)\n", g_fail);
return g_fail == 0 ? 0 : 1;
}