Merge Phase R (Reclaim): prune path — pure core, dry-run shell, guarded deletion
This commit is contained in:
+32
-1
@@ -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'
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user