feat(prune): R2 dry-run prune shell + persist wiring, report-only

Add ReaSamplerSession::pruneDryRun enumerating the resolved current bank
folder, feeding the R1 core, and returning a PruneReport (count/bytes/list).
New pure bankRelativeForName + buildPruneReport keep spelling and tally
testable. Register forever-stable BANK_PRUNE_FOLDER action, report-only. No deletion.
This commit is contained in:
2026-07-26 18:19:41 -04:00
parent 46176f3f04
commit c1473c42e1
10 changed files with 297 additions and 1 deletions
+1 -1
View File
@@ -402,7 +402,7 @@ add_library(reaper_reasampler MODULE
src/bank_book.cpp
src/owned_manifest.cpp
)
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim 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 app_version provenance)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# 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'
+38
View File
@@ -451,6 +451,10 @@ constexpr const char* kIdBankCopySel = "BANK_COPY_SELECTED";
constexpr const char* kIdBankRemoveSel = "BANK_REMOVE_SELECTED";
constexpr const char* kIdBankPoolFull = "BANK_POOL_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_cmdBankRename = 0;
@@ -463,6 +467,7 @@ int g_cmdBankCopySel = 0;
int g_cmdBankRemoveSel = 0;
int g_cmdBankPoolFull = 0;
int g_cmdBankBanksFull = 0;
int g_cmdBankPruneFolder = 0;
gaccel_register_t g_accelBankCreate{};
gaccel_register_t g_accelBankRename{};
@@ -475,6 +480,7 @@ gaccel_register_t g_accelBankCopySel{};
gaccel_register_t g_accelBankRemoveSel{};
gaccel_register_t g_accelBankPoolFull{};
gaccel_register_t g_accelBankBanksFull{};
gaccel_register_t g_accelBankPruneFolder{};
// 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
@@ -791,6 +797,31 @@ void doBankRemoveSelected() {
if (removed > 0) persistBankOp("ReaSampler: remove sample(s)");
}
// Prune bank folder — Phase R (Reclaim), R2: REPORT-ONLY dry run. Asks the session for
// the orphan set of the resolved CURRENT bank folder ((owned ∩ present) referenced,
// unioned across every bank) and prints the truthful reclaim report — count, reclaimable
// bytes, and the file list. DELETES NOTHING, writes no ext-state, opens no undo point
// (pruneDryRun is read-only across the persist seam). R3 extends this SAME action id to
// confirm-and-delete behind the dry-run guardrail; the report path here is what R3 wraps.
void doBankPruneFolder() {
const PruneReport report = g_session->pruneDryRun();
if (report.count == 0) {
ShowConsoleMsg("ReaSampler prune (dry run): no orphaned files to reclaim.\n");
return;
}
std::string msg = "ReaSampler prune (dry run): " + std::to_string(report.count) +
" orphaned file(s), " + std::to_string(report.totalBytes) +
" bytes reclaimable. (Dry run -- nothing deleted.)\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";
}
ShowConsoleMsg(msg.c_str());
}
} // namespace
// Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) —
@@ -851,6 +882,10 @@ void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session)
"toggle pool full-height");
g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull,
"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) {
@@ -867,6 +902,7 @@ bool bankHandleCommand(int command) {
if (command == g_cmdBankRemoveSel) { doBankRemoveSelected(); return true; }
if (command == g_cmdBankPoolFull) { bankPanelToggledPoolFullHeight(); 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
}
@@ -874,6 +910,8 @@ bool bankHandleCommand(int command) {
void bankUnregisterActions(reaper_plugin_info_t* rec) {
// Mirror-unregister with '-'-prefixed strings, reverse of registration order. Each
// '-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("-command_id", (void*)channelIdFor(kIdBankBanksFull));
rec->Register("-gaccel", (void*)&g_accelBankPoolFull);
+7
View File
@@ -191,6 +191,13 @@ BankPaths deriveBankPaths(const std::string& projectDir,
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,
const std::string& relativePath) {
// 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& 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) --------------------------------------
//
// The index stores relative paths only; on project load the persist shell must
+64
View File
@@ -77,10 +77,13 @@
#include <filesystem>
#include <string>
#include <system_error>
#include <unordered_map>
#include <vector>
#include "app_version.h"
#include "capture_paths.h"
#include "prune_reconcile.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
@@ -243,6 +246,67 @@ bool ReaSamplerSession::saveToActiveProject() {
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;
} // namespace
PruneReport ReaSamplerSession::pruneDryRun() const {
PruneReport report;
std::string rppPath;
void* proj = readActiveProject(rppPath);
if (!proj || rppPath.empty()) return report; // no active/saved project -> nothing
// 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 report; // unresolvable (no project dir) -> nothing
std::error_code ec;
if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) {
return report; // 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.
std::vector<std::string> present;
std::unordered_map<std::string, std::uint64_t> sizeByRel;
for (const auto& entry : fs::directory_iterator(bankDir, ec)) {
if (ec) break;
std::error_code fec;
if (!entry.is_regular_file(fec)) 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);
const std::uintmax_t sz = entry.file_size(fec);
sizeByRel[rel] = fec ? 0 : static_cast<std::uint64_t>(sz);
}
// The decision lives in the pure core — read-only inputs from the session's book and
// manifest (NO save, NO MarkProjectDirty, NO mutation). referencedPaths() unions
// across the whole book (pool included); owned().paths() is the manifest set. The
// count / byte-sum / display-truncation tally is the pure buildPruneReport, so this
// shell only enumerates, resolves, and stats — no report logic re-implemented here.
const std::vector<std::string> orphans =
pruneOrphans(present, book_.referencedPaths(), owned_.paths());
return buildPruneReport(orphans, sizeByRel, kPruneListDisplayCap);
}
namespace {
// 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-constructed model (Arrange + Design seeded, active = Arrange) — graceful,
+16
View File
@@ -23,6 +23,7 @@
#include "bank_book.h"
#include "bank_model.h"
#include "owned_manifest.h"
#include "prune_reconcile.h"
#include "tail_control.h"
#include "view_mode_model.h"
@@ -179,6 +180,21 @@ public:
// no dangling no-effect undo entry is opened on an unsaved project.
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;
// Poll the active project. Detects a project load (active project changed)
// 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.
+19
View File
@@ -33,4 +33,23 @@ std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
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;
}
} // namespace reasampler
+45
View File
@@ -41,11 +41,36 @@
// 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;
};
// Computes the prune orphan set: (owned ∩ present) referenced.
//
// Returns the subset of `present` that is BOTH owned AND unreferenced, in the ORDER
@@ -60,4 +85,24 @@ 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);
} // namespace reasampler
+25
View File
@@ -547,6 +547,29 @@ static void testHashWavContentDomainSeparationFromWholeFile() {
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() {
testNormalizeSlashes();
testNormalizeSlashesCaseFolding();
@@ -585,6 +608,8 @@ int main() {
testHashWavContentEmptyFallsBackToHashBytes();
testHashWavContentListMetaSkipped();
testHashWavContentDomainSeparationFromWholeFile();
testBankRelativeForNameMatchesDerivePathSpelling();
testBankRelativeForNameConventionAndEdge();
if (g_fail == 0) std::printf("capture_paths: all tests passed\n");
else std::printf("capture_paths: %d CHECK(s) FAILED\n", g_fail);
+70
View File
@@ -19,8 +19,10 @@
#include "../src/prune_reconcile.h"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <string>
#include <unordered_map>
#include <vector>
using namespace reasampler;
@@ -259,6 +261,69 @@ static void testReferencedPathsSkipsEmptyPath() {
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);
}
int main() {
testNullTestAllReferenced();
testFormulaMixedPopulations();
@@ -276,6 +341,11 @@ int main() {
testReferencedPathsUnionAcrossBanksAndPool();
testReferencedPathsEmptyBook();
testReferencedPathsSkipsEmptyPath();
testReportCountSizeAndFullList();
testReportMissingSizeCountsZeroNotDropped();
testReportTruncatesListButKeepsExactCountAndSize();
testReportUncappedWhenCapZero();
testReportEmptyOrphanSet();
if (g_fail == 0) std::printf("prune_reconcile_tests: ALL PASS\n");
else std::printf("prune_reconcile_tests: %d FAILURE(S)\n", g_fail);