diff --git a/CMakeLists.txt b/CMakeLists.txt index 410ed35..889dbe1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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' diff --git a/src/actions.cpp b/src/actions.cpp index ec998ab..0cbaec2 100644 --- a/src/actions.cpp +++ b/src/actions.cpp @@ -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); diff --git a/src/capture_paths.cpp b/src/capture_paths.cpp index c512ded..47336e4 100644 --- a/src/capture_paths.cpp +++ b/src/capture_paths.cpp @@ -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 diff --git a/src/capture_paths.h b/src/capture_paths.h index dee685d..a83387a 100644 --- a/src/capture_paths.h +++ b/src/capture_paths.h @@ -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 diff --git a/src/persist.cpp b/src/persist.cpp index 3865afc..0d24c2c 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -77,10 +77,13 @@ #include #include +#include +#include #include #include "app_version.h" #include "capture_paths.h" +#include "prune_reconcile.h" #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects @@ -243,6 +246,72 @@ 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. + // 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 present; + std::unordered_map sizeByRel; + 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); + sizeByRel[rel] = sz_ec ? 0 : static_cast(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 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, diff --git a/src/persist.h b/src/persist.h index 92851ee..72ccac2 100644 --- a/src/persist.h +++ b/src/persist.h @@ -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. diff --git a/src/prune_reconcile.cpp b/src/prune_reconcile.cpp index 5c89ff5..9a21d36 100644 --- a/src/prune_reconcile.cpp +++ b/src/prune_reconcile.cpp @@ -33,4 +33,23 @@ std::vector pruneOrphans(const std::vector& present, return orphans; } +PruneReport buildPruneReport( + const std::vector& orphans, + const std::unordered_map& 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 diff --git a/src/prune_reconcile.h b/src/prune_reconcile.h index c432fcd..58d83b7 100644 --- a/src/prune_reconcile.h +++ b/src/prune_reconcile.h @@ -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 #include +#include #include 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 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 pruneOrphans(const std::vector& present, const std::vector& referenced, const std::vector& 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& orphans, + const std::unordered_map& sizeByPath, + std::size_t displayCap); + } // namespace reasampler diff --git a/tests/test_capture_paths.cpp b/tests/test_capture_paths.cpp index 4796893..aff2b5e 100644 --- a/tests/test_capture_paths.cpp +++ b/tests/test_capture_paths.cpp @@ -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/" (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); diff --git a/tests/test_prune_reconcile.cpp b/tests/test_prune_reconcile.cpp index e739fba..cb547cd 100644 --- a/tests/test_prune_reconcile.cpp +++ b/tests/test_prune_reconcile.cpp @@ -19,8 +19,10 @@ #include "../src/prune_reconcile.h" #include +#include #include #include +#include #include 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 orphans = {"bank/a.wav", "bank/b.wav", "bank/c.wav"}; + const std::unordered_map 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 orphans = {"bank/a.wav", "bank/nosize.wav"}; + const std::unordered_map 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 orphans; + std::unordered_map 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 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);